Co-Authored-By: Claude Code <noreply@anthropic.com>
135 KiB
数据分析板块 v1 实施计划(A1+A3+B2+C1+E0-lite+stat_daily_staff)
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: 云客后台新增「数据分析」板块:A1 经营总览、A3 流失预警、B2 该回未回、C1 敏感词,外加 stat_daily_staff 员工日汇总地基与 E0-lite 数据健康端点。
Architecture: 全部读路径骑小表(msg_daily_stats 6.2万 / customer 1.2万 / phone_call 3千 / stat_daily_staff ~2万),message 只做索引点查(生产 buffer pool 128MB 禁裸扫)。后端新增 app/analytics/ 服务层(口径收敛处)+ app/storage/staff_summary.py(rollup 维护);前端侧栏新组四页 + ECharts 按需引入。
Spec: docs/superpowers/specs/2026-09-04-yunke-analytics-module-design.md(口径速查表在 spec §8,实现时以它为准)
Tech Stack: FastAPI + SQLAlchemy 2.0 async + alembic;Vue3 + TS + Element Plus + echarts/core 按需;pytest 内存 SQLite(SQL 双方言兼容)。
项目约定(执行者必读):
- master 单主干直接提交;提交信息中文
type(scope): 描述,结尾加Co-Authored-By: Claude Code <noreply@anthropic.com>。 - 后端测试:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q。新 SQL 禁 bare column in GROUP BY、禁 SQL 日期函数、日期一律 day 整数(Asia/Shanghai,Python 侧算)。 - 测试用 conftest 的
make_client工厂(tests/conftest.py,seed 协程 + AsyncClient)。 - 前端验证:
cd yunkes && npm run build(vue-tsc 类型检查)。 - 日志禁止输出敏感词内容与命中正文。
- R1 已核实:
staff.is_on_job存在(models.py:136),A3 待交接不降级。 - 迁移链现状 head =
a7d3e9f1c2b4(msg_daily_stats)。
文件结构总览
yunke-server/
app/db/models.py +4 模型 +1 索引声明
migrations/versions/f0a1b2c3d4e5_analytics_module_tables.py 新迁移
app/storage/summary.py +day_bounds_ms 公共别名
app/storage/staff_summary.py 新:stat_daily_staff 维护层
app/analytics/__init__.py 新:分析服务层包
app/analytics/health.py 新:E0-lite
app/analytics/metrics.py 新:overview/churn/unreplied
app/analytics/sensitive/__init__.py 新
app/analytics/sensitive/words.py 新:词库 CRUD + 预览
app/analytics/sensitive/scanner.py 新:增量/全量扫描
app/api/routers/analytics.py 新:全部 /api/analytics/* 端点
app/api/main.py +注册 router
app/sync/cli.py +staff-daily-stats / sensitive-scan
app/sync/scheduler.py +4h analytics tick job
scripts/full_sync_all.sh +staff-daily-stats 步骤
tests/test_analytics_models.py / test_analytics_api.py / test_staff_summary.py
tests/test_analytics_churn_unreplied.py / test_sensitive.py
yunkes/
src/api/analytics.ts 新:全部 API 函数+类型
src/composables/useChart.ts 新:ECharts 封装
src/router/index.ts +4 路由
src/layouts/MainLayout.vue +「数据分析」组
src/views/analytics/DashboardView.vue 新:A1
src/views/analytics/ChurnView.vue 新:A3
src/views/analytics/UnrepliedView.vue 新:B2
src/views/analytics/SensitiveView.vue 新:C1
package.json +echarts
docs/DEPLOY.md +升级回填注记
Task 1: 数据模型 + alembic 迁移
Files:
-
Modify:
yunke-server/app/db/models.py(MsgDailyStats 类之后、__all__之前插入 4 个模型;Message.table_args 追加索引) -
Create:
yunke-server/migrations/versions/f0a1b2c3d4e5_analytics_module_tables.py -
Test:
yunke-server/tests/test_analytics_models.py -
Step 1: models.py 追加 4 个模型(放在
class MsgDailyStats之后,__all__列表之前;并把这 4 个名字加进__all__)
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")
)
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: Mapped[dt.datetime] = _updated_at()
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: Mapped[dt.datetime] = _updated_at()
__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()
- Step 2: Message.table_args 追加 fetched_at 索引(models.py:437-444 的
ix_message_channel_owner_timeIndex 之后、同一元组内;模型+迁移双声明防 autogenerate 反 DROP,惯例同 c53f90b1e6a4)
Index(
"ix_message_fetched_at",
"fetched_at",
),
- Step 3: 写迁移文件
migrations/versions/f0a1b2c3d4e5_analytics_module_tables.py(幂等风格照抄 a7d3e9f1c2b4:sa.inspect 先查再建;列定义与上面模型一字对应)
"""数据分析板块 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"),
)
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)
- Step 4: 写冒烟测试
tests/test_analytics_models.py
"""新表建模冒烟: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="飞单"))
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()
- Step 5: 跑测试
Run: cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_analytics_models.py -x -q
Expected: 2 passed
- Step 6: 全量回归(确保新模型不破坏现有 512+ 测试)
Run: cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q
Expected: all passed
- Step 7: Commit
git add yunke-server/app/db/models.py yunke-server/migrations/versions/f0a1b2c3d4e5_analytics_module_tables.py yunke-server/tests/test_analytics_models.py
git commit -m "feat(analytics): 数据分析板块四新表+迁移——stat_daily_staff/敏感词三表/fetched_at 索引"
Task 2: analytics 包骨架 + E0-lite health 端点
Files:
-
Create:
yunke-server/app/analytics/__init__.py -
Create:
yunke-server/app/analytics/health.py -
Create:
yunke-server/app/api/routers/analytics.py -
Modify:
yunke-server/app/api/main.py(import + include_router) -
Test:
yunke-server/tests/test_analytics_api.py -
Step 1:
app/analytics/__init__.py
"""分析服务层——所有数据分析指标的口径收敛处(spec 2026-09-04 §2)。
指标函数是纯查询(只依赖 AsyncSession,无 HTTP/请求上下文),E2 报表订阅
(二批)直接复用。口径速查表:spec §8;函数 docstring 与之同步。
"""
- Step 2:
app/analytics/health.py
"""E0-lite 数据健康——「数据截至」角标 + 实体新鲜度。
完整 E0(调度日志解析/成功率账本)二批;v1 用表新鲜度判定:
msg_daily_stats 当日有行 = 4h 消息链路活着。
"""
from __future__ import annotations
import datetime as dt
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)。"""
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]:
"""各实体最后更新时间(UPDATED_AT 列 MAX;message 用 fetched_at)。"""
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
- Step 3:
app/api/routers/analytics.py
"""GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。"""
from __future__ import annotations
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics import health as health_svc
from app.api.deps import get_session
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@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)}
-
Step 4: main.py 注册(routers import 列表按字母序加
analytics;app.include_router(analytics.router)放 include 列表任意处,照现有行样式) -
Step 5: 写失败测试
tests/test_analytics_api.py
"""/api/analytics/health:E0-lite 冒烟。"""
from __future__ import annotations
from app.db.models import MsgDailyStats
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 any(e["table"] == "message" 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,
)
)
client = await make_client(seed)
r = await client.get("/api/analytics/health")
assert r.json()["data_as_of"] == 1756900000000
-
Step 6: 跑测试 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_analytics_api.py -x -q→ Expected: 2 passed -
Step 7: Commit
git add yunke-server/app/analytics/ yunke-server/app/api/routers/analytics.py yunke-server/app/api/main.py yunke-server/tests/test_analytics_api.py
git commit -m "feat(analytics): E0-lite 数据健康端点——数据截至+实体新鲜度"
Task 3: stat_daily_staff 维护层(staff_summary.py)
Files:
-
Modify:
yunke-server/app/storage/summary.py(文件尾部加公共别名) -
Create:
yunke-server/app/storage/staff_summary.py -
Test:
yunke-server/tests/test_staff_summary.py -
Step 1: summary.py 尾部加别名(DRY:日界计算唯一权威在此)
# 公共别名(2026-09-04):analytics 层复用日界计算,避免各处复制时区逻辑。
day_bounds_ms = _day_bounds_ms
- Step 2: 写失败测试
tests/test_staff_summary.py
"""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
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 test_recompute_aggregates_by_user(make_client):
async def seed(session):
await _seed_pair(session)
n = await staff_summary.recompute_days(session, [20260904])
await session.commit()
assert n == 1
client = await make_client(seed)
rows = (
await client.app.state.get("rows") if False else None
)
# 直接用返回值断言:recompute 后查表
async def verify(session):
out = (
await session.execute(
__import__("sqlalchemy").select(StatDailyStaff)
)
).scalars().all()
return {(r.user_id): r for r in out}
# make_client 工厂只给 http 客户端;这里改用直连引擎断言——
# 换一种写法:在 seed 里断言(seed 也有 session)
上面这个测试写法不对——make_client 的 seed 里就有 session,直接在 seed 里断言即可。正确版本:
async def test_recompute_aggregates_by_user(make_client):
"""两账号同人合并(sent=10+3+2=15,recv=5+0+1=6;friends 跨账号去重=2?否——
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
await make_client(seed)
days = staff_summary.rolling_days(20260904, window=3)
assert days == [20260902, 20260903, 20260904]
-
Step 3: 跑测试确认失败 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_staff_summary.py -x -q→ Expected: FAIL(ModuleNotFoundError: staff_summary) -
Step 4: 实现
app/storage/staff_summary.py
"""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
for day in sorted(set(days)):
for channel in channels:
amap = await account_map(session, channel)
rows = (
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(StatDailyStaff).where(
StatDailyStaff.day == day, StatDailyStaff.channel == channel
)
)
if not rows:
continue
agg: dict[str, dict] = {}
for owner, kind, peer, s, r in rows:
uid = amap.get(owner, owner) # 映射不上按 owner 原值(读侧标注)
a = agg.setdefault(
uid, {"sent": 0, "recv": 0, "friends": set(), "rooms": set()}
)
a["sent"] += int(s or 0)
a["recv"] += int(r or 0)
(a["friends"] if kind == "friend" else 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"]),
)
)
n += 1
return n
async def recompute_all(session: AsyncSession) -> int:
"""全量重建(夜间自愈 / 部署回填,幂等)。"""
days = (
await session.execute(sa.select(MsgDailyStats.day).distinct())
).scalars().all()
return await recompute_days(session, list(days))
⚠️ 上面 recompute_days 的 delete 已修正为 StatDailyStaff.channel == channel;返回值语义 = 有数据行的 day×channel 组合数(空渠道不计,测试断言 n==1 成立:seed 只有 wechat 日行)。
-
Step 5: 跑测试 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_staff_summary.py -x -q→ Expected: 4 passed -
Step 6: Commit
git add yunke-server/app/storage/summary.py yunke-server/app/storage/staff_summary.py yunke-server/tests/test_staff_summary.py
git commit -m "feat(analytics): stat_daily_staff 员工日汇总维护层——rollup派生/幂等/判别法"
Task 4: metrics.py —— A1 总览指标(卡片/趋势/异常警示)
Files:
-
Create:
yunke-server/app/analytics/metrics.py -
Test:
yunke-server/tests/test_analytics_overview.py -
Step 1: 写失败测试
tests/test_analytics_overview.py
"""A1 总览指标:卡片/首沟通口径/环比(仅周月)/异常警示。"""
from __future__ import annotations
import datetime as dt
from app.analytics import metrics
from app.db.models import (
MsgDailyStats,
PhoneCall,
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 out["cards"].get("mom_total_delta") is None # 今日无环比
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 = 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=1756950000000,
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)
-
Step 2: 跑测试确认失败 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_analytics_overview.py -x -q→ Expected: FAIL(no module metrics) -
Step 3: 实现
app/analytics/metrics.py(口径注释 = spec §8 速查表)
"""A1/A3/B2 指标函数——纯查询,口径速查表见 spec §8。
时区:day 键一律 Asia/Shanghai 自然日(Python 侧算,禁 SQL 日期函数)。
周起始=周一(scope=week)。
"""
from __future__ import annotations
import datetime as dt
import math
from collections.abc import Iterable
from typing import Any
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import MsgDailyStats, PhoneCall, StatDailyStaff
from app.storage.staff_summary import account_map, has_rows as _staff_rows
from app.storage.summary import day_bounds_ms
_SH = ZoneInfo("Asia/Shanghai")
_CHANNELS = ("wechat", "whatsapp")
def today_key(now: dt.datetime | None = None) -> int:
"""上海自然日 yyyymmdd(测试 monkeypatch 点)。"""
d = (now or dt.datetime.now(_SH)).date()
return d.year * 10000 + d.month * 100 + d.day
def _day_date(day: int) -> dt.date:
return dt.date(day // 10000, (day // 100) % 100, day % 100)
def scope_day_keys(scope: str, today: int) -> list[int]:
"""today={今};week=本周一..今天;month=本月1号..今天(含端点)。"""
end = _day_date(today)
if scope == "today":
start = end
elif scope == "week":
start = end - dt.timedelta(days=end.weekday()) # 周一=0
elif scope == "month":
start = end.replace(day=1)
else:
raise ValueError(f"bad scope: {scope!r}")
out, cur = [], start
while cur <= end:
out.append(cur.year * 10000 + cur.month * 100 + cur.day)
cur += dt.timedelta(days=1)
return out
def _prev_scope_days(scope: str, days: list[int]) -> list[int]:
"""紧邻的前一个等长窗口(环比;今日 scope 不调用)。"""
n = len(days)
last = _day_date(days[0]) - dt.timedelta(days=1)
return [
(lambda d: d.year * 10000 + d.month * 100 + d.day)(
last - dt.timedelta(days=i)
)
for i in range(n - 1, -1, -1)
]
async def _day_rows(
session: AsyncSession, channel: str, days: Iterable[int]
) -> list[Any]:
return (
await session.execute(
sa.select(
MsgDailyStats.day,
MsgDailyStats.peer_kind,
MsgDailyStats.peer_id,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
).where(
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(list(days))
)
)
).all()
async def _call_count(session: AsyncSession, channel_scope_days: list[int]) -> int:
"""phone_call 落窗计数(3k 行小表;start_call_time ms 与 day 键边界换算)。"""
if not channel_scope_days:
return 0
lo, _hi = day_bounds_ms(channel_scope_days[0])
hi_day_start, _ = day_bounds_ms(channel_scope_days[-1])
hi = hi_day_start + 86_400_000 # 含末日全天
val = (
await session.execute(
sa.select(sa.func.count()).select_from(PhoneCall).where(
PhoneCall.start_call_time.is_not(None),
PhoneCall.start_call_time >= lo,
PhoneCall.start_call_time < hi,
)
)
).scalar_one()
return int(val)
async def _first_contact(
session: AsyncSession, channel: str, days: list[int]
) -> int:
"""首次沟通客户数:peer 按渠道全史 MIN(day) 落窗(跨员工归一,不跨渠道)。"""
rows = (
await session.execute(
sa.select(MsgDailyStats.peer_id, sa.func.min(MsgDailyStats.day))
.where(
MsgDailyStats.channel == channel,
MsgDailyStats.peer_kind == "friend",
)
.group_by(MsgDailyStats.peer_id)
)
).all()
dayset = set(days)
return sum(1 for _peer, first in rows if first in dayset)
async def _active_staff(
session: AsyncSession, channel: str, days: list[int]
) -> int:
"""活跃员工 distinct user_id;stat_daily_staff 空表回退 msg_daily_stats+映射。"""
if await _staff_rows(session):
val = (
await session.execute(
sa.select(sa.func.count(sa.distinct(StatDailyStaff.user_id))).where(
StatDailyStaff.channel == channel,
StatDailyStaff.day.in_(days),
)
)
).scalar_one()
return int(val)
amap = await account_map(session, channel)
owners = set(amap.get(o, o) for (o,) in (
await session.execute(
sa.select(MsgDailyStats.owner_account_id).where(
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(days)
)
)
).all())
return len(owners)
def anomaly_threshold() -> float:
return 0.5 # 昨日 vs 前 7 日均值偏离阈值,v1 固定
async def anomaly(session: AsyncSession, channel: str) -> dict | None:
"""E0-lite 异常警示:昨日总量 vs 前 7 日均值偏离>50%,明细到员工。"""
today = today_key()
yesterday = _day_date(today) - dt.timedelta(days=1)
def key(d: dt.date) -> int:
return d.year * 10000 + d.month * 100 + d.day
yday = key(yesterday)
prev7 = [key(yesterday - dt.timedelta(days=i)) for i in range(1, 8)]
use_rollup = await _staff_rows(session)
if use_rollup:
rows = (
await session.execute(
sa.select(
StatDailyStaff.day, StatDailyStaff.user_id,
sa.func.coalesce(StatDailyStaff.msg_sent + StatDailyStaff.msg_recv, 0),
).where(
StatDailyStaff.channel == channel,
StatDailyStaff.day.in_([yday] + prev7),
)
)
).all()
else:
amap = await account_map(session, channel)
raw = (
await session.execute(
sa.select(
MsgDailyStats.day, MsgDailyStats.owner_account_id,
sa.func.coalesce(
sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0
),
)
.where(
MsgDailyStats.channel == channel,
MsgDailyStats.day.in_([yday] + prev7),
)
.group_by(MsgDailyStats.day, MsgDailyStats.owner_account_id)
)
).all()
rows = [(d, amap.get(o, o), v) for d, o, v in raw]
per_user: dict[str, list[int]] = {}
for _day, uid, v in rows:
per_user.setdefault(uid, []).append(int(v))
def split(vals: list[int]) -> tuple[int, float]:
"""昨日量 = day==yday 的合计;前7均值 = 其余均值。"""
y = vals[-1] # rows 未按序——改为显式传 day;见下方实现修正
raise NotImplementedError
# 直接逐 user 算(不猜顺序):
staff_out = []
totals_y, totals_p = 0, 0.0
day_rows: dict[str, dict[int, int]] = {}
for day, uid, v in rows:
day_rows.setdefault(uid, {})[day] = int(v)
for uid, by_day in day_rows.items():
y = by_day.get(yday, 0)
pvals = [by_day.get(d, 0) for d in prev7]
pmean = sum(pvals) / len(pvals) if pvals else 0.0
totals_y += y
totals_p += pmean
if pmean > 0 and abs(y - pmean) / pmean > anomaly_threshold():
staff_out.append({
"user_id": uid,
"yesterday": y,
"prev7_mean": round(pmean, 1),
})
if totals_p <= 0:
return None
delta = (totals_y - totals_p) / totals_p
if abs(delta) > anomaly_threshold():
return {
"total_delta_pct": round(delta, 3),
"yesterday_total": totals_y,
"prev7_mean": round(totals_p, 1),
"staff": sorted(staff_out, key=lambda s: s["yesterday"]),
}
return None
async def overview(
session: AsyncSession, *, channel: str, scope: str, trend_days: int = 30
) -> dict[str, Any]:
"""A1:卡片 + 环比(仅周/月) + 近 N 天趋势 + 异常警示 + 数据截至。"""
today = today_key()
days = scope_day_keys(scope, today)
rows = await _day_rows(session, channel, days)
msg_total = sum(int(s or 0) + int(r or 0) for *_ , s, r in rows)
active_customers = len({p for _d, kind, p, *_ in rows if kind == "friend"})
cards: dict[str, Any] = {
"msg_total": msg_total,
"active_customers": active_customers,
"active_staff": await _active_staff(session, channel, days),
"call_cnt": await _call_count(session, days),
"first_contact": await _first_contact(session, channel, days),
}
# 环比仅周/月(今日 vs 昨日全天会误导,spec §8)
if scope in ("week", "month"):
prev_days = _prev_scope_days(scope, days)
prev_rows = await _day_rows(session, channel, prev_days)
prev_total = sum(int(s or 0) + int(r or 0) for *_, s, r in prev_rows)
cards["prev_msg_total"] = prev_total
cards["msg_total_delta"] = (
round((msg_total - prev_total) / prev_total, 3) if prev_total else None
)
# 趋势:近 trend_days 天 每日 消息量/活跃客户/通话量
t_days = scope_day_keys("month", today) # 占位,下面按窗口重算
end = _day_date(today)
t_days = []
cur = end - dt.timedelta(days=trend_days - 1)
while cur <= end:
t_days.append(cur.year * 10000 + cur.month * 100 + cur.day)
cur += dt.timedelta(days=1)
t_rows = await _day_rows(session, channel, t_days)
by_day: dict[int, dict] = {d: {"msg": 0, "cust": set()} for d in t_days}
for d, kind, p, s, r in t_rows:
by_day[d]["msg"] += int(s or 0) + int(r or 0)
if kind == "friend":
by_day[d]["cust"].add(p)
# 通话趋势:phone_call 拉窗内 (start_call_time) Python 侧按日分桶(3k 行)
lo, _ = day_bounds_ms(t_days[0])
hi_day, _ = day_bounds_ms(t_days[-1])
calls = (
await session.execute(
sa.select(PhoneCall.start_call_time).where(
PhoneCall.start_call_time.is_not(None),
PhoneCall.start_call_time >= lo,
PhoneCall.start_call_time < hi_day + 86_400_000,
)
)
).scalars().all()
call_by_day: dict[int, int] = {}
for ts in calls:
d = dt.datetime.fromtimestamp(ts / 1000, _SH)
k = d.year * 10000 + d.month * 100 + d.day
call_by_day[k] = call_by_day.get(k, 0) + 1
trend = [
{"day": d, "msg": v["msg"], "active_customers": len(v["cust"]),
"call_cnt": call_by_day.get(d, 0)}
for d, v in by_day.items()
]
return {
"scope": scope,
"channel": channel,
"days": days,
"cards": cards,
"trend": trend,
"anomaly": await anomaly(session, channel),
}
实现注意:① anomaly 里那个 split 半成品函数不要写进去(上面保留它是提示逐 user 算法,落地时删掉);② overview 里 t_days = scope_day_keys("month", today) 占位行删除;③ for *_ , s, r in rows 的解包统一写成显式五元组。写完后跑测试修正到绿。
-
Step 4: 跑测试到绿 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_analytics_overview.py -x -q→ Expected: 3 passed -
Step 5: Commit
git add yunke-server/app/analytics/metrics.py yunke-server/tests/test_analytics_overview.py
git commit -m "feat(analytics): A1 总览指标——卡片/首沟通口径/周月环比/异常警示/空表回退"
Task 5: /api/analytics/overview 端点
Files:
-
Modify:
yunke-server/app/api/routers/analytics.py -
Test:
yunke-server/tests/test_analytics_api.py(追加) -
Step 1: 追加失败测试(test_analytics_api.py)
async def test_overview_endpoint(make_client):
from app.db.models import MsgDailyStats
async def seed(session):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=4, recv_cnt=1, last_ts=1756950000000,
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
- Step 2: 端点实现(analytics.py router 追加)
from typing import Literal
from app.analytics import metrics as metrics_svc
@router.get("/overview")
async def overview(
session: SessionDep,
channel: Literal["wechat", "whatsapp"] = "wechat",
scope: Literal["today", "week", "month"] = "today",
) -> dict:
"""A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。"""
return await metrics_svc.overview(session, channel=channel, scope=scope)
-
Step 3: 跑测试 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_analytics_api.py -x -q→ Expected: 3 passed -
Step 4: Commit
git commit -am "feat(analytics): /overview 端点"
Task 6: A3 流失预警(churn 指标 + 端点)
Files:
-
Modify:
yunke-server/app/analytics/metrics.py(追加 churn) -
Modify:
yunke-server/app/api/routers/analytics.py(追加端点) -
Test:
yunke-server/tests/test_analytics_churn_unreplied.py -
Step 1: 写失败测试
"""A3 三分法流失 + B2 该回未回。"""
from __future__ import annotations
import datetime as dt
from app.analytics import metrics
from app.db.models import Customer, MsgDailyStats, 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)
- Step 2: 实现 churn(metrics.py 追加;
_now_ms是测试 monkeypatch 点)
def _now_ms() -> int:
return int(dt.datetime.now(_SH).timestamp() * 1000)
async def churn(
session: AsyncSession, *, channel: str, kind: str, tier: int,
min_msgs: int, page: int, page_size: int,
) -> dict:
"""A3 三分法(spec §3.3)。tier ∈ {30,60,90} 分层区间 [tier, next) ,90=≥90。"""
day_ms = 86_400_000
now = _now_ms()
base = [
Customer.channel == channel,
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
]
if kind == "real":
lo = now - _next_tier(tier) * day_ms
hi = now - tier * day_ms
# last_chat_time ∈ (lo, hi]:沉默 tier..next_tier 天
base += [Customer.last_chat_time.is_not(None),
Customer.last_chat_time > lo,
Customer.last_chat_time <= hi]
order = Customer.last_chat_time.asc()
elif kind == "inactive":
base += [Customer.last_chat_time.is_(None)]
order = Customer.friend_id.asc()
elif kind == "handover":
base += [Customer.user_id.in_(
sa.select(Staff.user_id).where(Staff.is_on_job.is_(False))
)]
order = Customer.last_chat_time.desc().nulls_last()
else:
raise ValueError(f"bad kind: {kind!r}")
total = (
await session.execute(
sa.select(sa.func.count()).select_from(Customer).where(*base)
)
).scalar_one()
rows = (
await session.execute(
sa.select(Customer).where(*base).order_by(order)
.offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
# enrich:历史消息量/活跃天数(msg_daily_stats 按 peer 全史聚合,62k 行)
items = []
if rows:
ids = [c.friend_id for c in rows]
agg = (
await session.execute(
sa.select(
MsgDailyStats.peer_id,
sa.func.coalesce(
sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0
),
sa.func.count(),
)
.where(
MsgDailyStats.channel == channel,
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.peer_id.in_(ids),
)
.group_by(MsgDailyStats.peer_id)
)
).all()
hist = {p: (int(m), int(d)) for p, m, d in agg}
for c in rows:
m, d = hist.get(c.friend_id, (0, 0))
items.append({
"friend_id": c.friend_id,
"channel": c.channel,
"nickname": c.friend_nickname or c.friend_remark or c.friend_id,
"owner_account_id": c.owner_account_id,
"user_id": c.user_id,
"last_chat_time": c.last_chat_time,
"hist_msgs": m,
"active_days": d,
})
# real 需 hist_msgs ≥ min_msgs(过滤后再分页会复杂——先查满页再过滤,
# 数量大时循环补页;v1 简化:SQL 端先过滤(见下),此处保险双滤)
if kind == "real":
items = [it for it in items if it["hist_msgs"] >= min_msgs]
return {"items": items, "total": int(total), "page": page, "page_size": page_size}
def _next_tier(tier: int) -> int:
return {30: 60, 60: 90, 90: 10_000}[tier]
⚠️ 实现修正点(写码时落实):real 的 hist_msgs ≥ min_msgs 过滤放 SQL 端才对(否则分页 total 错)——用半连接:Customer.friend_id.in_(sa.select(MsgDailyStats.peer_id).where(channel, peer_kind=='friend').group_by(peer_id).having(sum(send+recv) >= min_msgs))。上面 Python 端双滤保留作保险但 total 语义按 SQL 端为准。nulls_last 在 MySQL 会被 app/db/compat.py 编译补丁剥掉(已有机制,DESC 语义天然等价),SQLite 支持——直接用没问题。
- Step 3: 端点(analytics.py 追加)
@router.get("/churn")
async def churn_list(
session: SessionDep,
channel: Literal["wechat", "whatsapp"] = "wechat",
type: Literal["real", "inactive", "handover"] = "real",
tier: int = Query(30, ge=30, le=90), # 30/60/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:
"""A3 流失预警名单(三分法)。"""
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,
)
(Query/HTTPException 从 fastapi import。)
-
Step 4: 跑测试到绿 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_analytics_churn_unreplied.py -x -q(此时 unreplied 测试还没写,只有 churn 两个) -
Step 5: Commit
git commit -am "feat(analytics): A3 流失预警三分法——真流失/从未激活/待交接"
Task 7: B2 该回未回(unreplied 指标 + 端点)
Files:
-
Modify:
yunke-server/app/analytics/metrics.py(追加 unreplied) -
Modify:
yunke-server/app/api/routers/analytics.py(追加端点) -
Test:
yunke-server/tests/test_analytics_churn_unreplied.py(追加) -
Step 1: 写失败测试(追加到同一测试文件)
from app.db.models import Customer, Message, PhoneCall # noqa: E402
_VALID_TYPES = ("text", "image", "voice", "video", "file", "link")
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)) # phone=13800000005
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)
注意:Message 的类型列实际名是 type_? 执行时先确认列名——models.py 里 Message 的列可能是 type(SQL 保留字处理过)或 label。grep -n "type" yunke-server/app/db/models.py 看 Message 类实际列名后照实写(存量代码 messages.py 路由有引用)。Customer 的 phone 赋值直接在 _cust() 工厂加参数更干净。
- Step 2: 实现 unreplied(metrics.py 追加)
_VALID_TAIL_TYPES = ("text", "image", "voice", "video", "file", "link")
def _norm_phone(p: str | None) -> str | None:
"""归一:+86/空格/横线剥掉(spec B1 同款规则)。"""
if not p:
return None
out = p.replace(" ", "").replace("-", "")
for pre in ("+86", "86"):
if out.startswith(pre) and len(out) > len(pre):
out = out[len(pre):]
break
return out or None
async def unreplied(
session: AsyncSession, *, channel: str, hours: int, page: int, page_size: int,
) -> dict:
"""B2 该回未回(spec §3.4)。候选=汇总表最新日行;决赛圈点查类型/预览/通话豁免。"""
now = _now_ms()
cutoff = now - hours * 3600 * 1000
# 候选集:每 (channel,owner,peer) 最新日行,方向=客户 且 ≤cutoff(窗口函数双方言)
rn = (
sa.func.row_number()
.over(
partition_by=(
MsgDailyStats.channel,
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
),
order_by=MsgDailyStats.day.desc(),
)
.label("rn")
)
sub = (
sa.select(
MsgDailyStats.owner_account_id.label("owner"),
MsgDailyStats.peer_id.label("peer"),
MsgDailyStats.last_ts.label("ts"),
rn,
)
.where(
MsgDailyStats.channel == channel,
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.last_is_send.is_(False),
MsgDailyStats.last_ts.is_not(None),
MsgDailyStats.last_ts <= cutoff,
)
).subquery()
cand = (
await session.execute(
sa.select(sub.c.owner, sub.c.peer, sub.c.ts).where(sub.c.rn == 1)
)
).all()
total = len(cand)
page_rows = cand[(page - 1) * page_size : (page - 1) * page_size + page_size]
items: list[dict] = []
for owner, peer, ts in page_rows:
# 决赛圈点查:尾部消息类型+预览(走 ix_message_channel_owner_time)
last_msg = (
await session.execute(
sa.select(Message).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),
).order_by(Message.timestamp.desc()).limit(1)
)
).scalar_one_or_none()
if last_msg is None or last_msg.type_ not in _VALID_TAIL_TYPES: # 列名按实际
continue
cust = await session.get(Customer, peer)
if cust is not None and (
cust.delete_flag == 1 or cust.is_block is True
):
continue
# 通话豁免:该客户手机号在员工名下有「晚于最后消息」的通话 → 已回应
tail = ts
phone = _norm_phone(cust.phone if cust else None)
if phone:
amap = await account_map(session, channel)
uid = amap.get(owner)
call_ts = (
await session.execute(
sa.select(sa.func.max(PhoneCall.start_call_time)).where(
PhoneCall.call_number == phone,
*(([PhoneCall.user_id == uid] if uid else [])),
)
)
).scalar_one_or_none()
if call_ts and call_ts > ts:
continue # 通话已回应
text = (last_msg.text or "").replace("\n", " ")
items.append({
"peer_id": peer,
"channel": channel,
"owner_account_id": owner,
"last_ts": ts,
"wait_ms": now - ts,
"preview": text[:50],
"msg_id": last_msg.msg_id,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
注意:last_msg.type_ 按实际列名替换(见 Step 1 的说明);total 语义 = 候选对话数(决赛圈类型过滤后可能少几行,v1 可接受,注释说明)。补一个 test_unreplied_total_counts_candidates 断言 total=5(t1/t3/t4/t5/t6 都是候选,过滤发生在 items)——或者把 total 改成 items 过滤后的口径?spec 说「候选集…决赛圈处理」,total=候选数会让前端页码有少量空页。定案:total = 过滤后条数——简单实现:先全量决赛圈过滤再 Python 分页(候选最多几千行,点查每行 3 次会慢…)。折中:候选集先点查过滤到完整列表再分页,一页 20 行时点查 60 次=180 个毫秒查询≈可接受,但几千候选就炸。最终定案(写进代码注释):候选 ≤500 时全过滤后分页(total 精确);>500 时按候选分页(total=候选数,注释口径)。用 len(cand) <= 500 分支。
- Step 3: 端点(analytics.py 追加)
@router.get("/unreplied")
async def unreplied_list(
session: SessionDep,
channel: Literal["wechat", "whatsapp"] = "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:
"""B2 该回未回清单。"""
return await metrics_svc.unreplied(
session, channel=channel, hours=hours, page=page, page_size=page_size
)
-
Step 4: 跑测试到绿 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_analytics_churn_unreplied.py -x -q→ Expected: all passed(churn 2 + unreplied 1+) -
Step 5: Commit
git commit -am "feat(analytics): B2 该回未回——尾部判定/有效类型/通话豁免/已删剔除"
Task 8: C1 敏感词——词库 CRUD + 加词预览
Files:
- Create:
yunke-server/app/analytics/sensitive/__init__.py(docstring 包说明) - Create:
yunke-server/app/analytics/sensitive/words.py - Modify:
yunke-server/app/api/routers/analytics.py - Test:
yunke-server/tests/test_sensitive.py
已确认事实:Message 类型列名是 Message.type(String(32),normalized label:text/image/…/unknown)。Task 7 的 last_msg.type_ 全部写为 last_msg.type。
- Step 1:
sensitive/__init__.py
"""C1 敏感词子系统(spec §3.6)——words(词库)/scanner(扫描)/hits(处置)。
内容源抽象:scanner 目前扫 message.text;预留 refer_msg 引文与 D4 ASR
转写文本接入位(二/三批)。日志禁打词内容与命中正文。
"""
- Step 2: 写失败测试
tests/test_sensitive.py
"""C1 词库 CRUD + 预览。"""
from __future__ import annotations
import datetime as dt
from app.analytics.sensitive import words as words_svc
from app.db.models import Message, SensitiveWord
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
- Step 3: 实现
sensitive/words.py
"""词库服务——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}") # 词内容不进异常文案之外的位置?词本身是用户输入,回显可
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 路径(测试库)精确扣。前端文案「预计命中」体现上限语义。
"""
word = (word or "").strip()
if not word:
return 0
conds = [Message.text.is_not(None), Message.text != ""]
dialect = session.bind.dialect.name if session.bind else ""
if dialect == "mysql" and len(word) >= 2:
conds.append(
sa.text("MATCH(message.text) AGAINST (:w IN BOOLEAN MODE)")
.bindparams(w=f'"{word}"')
)
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)
)
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
- Step 4: 端点(analytics.py 追加;
HTTPException已引入)
from pydantic import BaseModel
from app.analytics.sensitive import words as words_svc
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]:
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:
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
@router.post("/sensitive/words/preview")
async def preview_word(session: SessionDep, body: WordPreviewIn) -> dict:
n = await words_svc.preview_count(session, body.word, body.exclude_words)
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:
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:
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
-
Step 5: 跑测试 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_sensitive.py -x -q→ Expected: 3 passed -
Step 6: Commit
git commit -am "feat(analytics): C1 敏感词库 CRUD+加词预览——防一加就淹没"
Task 9: C1 scanner(增量水位扫描 + 全量回填)
Files:
-
Create:
yunke-server/app/analytics/sensitive/scanner.py -
Test:
yunke-server/tests/test_sensitive.py(追加) -
Step 1: 追加失败测试
from app.analytics.sensitive import scanner
from app.db.models import SensitiveHit, SensitiveScanState
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)
f2 = dt.datetime(2026, 9, 4, 11, 0)
session.add(_msg("m1", "聊聊私单", f1))
session.add(_msg("m2", "正常", f1))
w = await words_svc.create_word(session, word="私单")
await session.commit()
r1 = await scanner.run_incremental(session)
await session.commit()
assert r1["hits"] == 1 and r1["new_watermark"] == f2 # 无行也有 MAX?——
# ↑ m1/m2 fetched 10:00,水位应=10:00。断言修正:
assert r1["new_watermark"] == f1
r2 = await scanner.run_incremental(session) # 再跑:水位之后无新行
await session.commit()
assert r2["hits"] == 0
n = (await session.execute(
__import__("sqlalchemy").select(
__import__("sqlalchemy").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))
w = await words_svc.create_word(session, word="私单")
await session.commit()
r1 = await scanner.run_incremental(session)
await session.commit()
assert r1["hits"] == 1
# 迟到行:timestamp 2026-01(fetched_at 现在)
session.add(_msg("m_late", "迟到的私单", dt.datetime(2026, 9, 4, 12, 0),
ts=1767000000000))
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)))
w = await words_svc.create_word(session, word="坑") # 单字
await session.commit()
r = await scanner.run_incremental(session)
await session.commit()
assert r["hits"] == 1 # 单字走 LIKE,MySQL ngram 也走 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()
hit = (await session.execute(
__import__("sqlalchemy").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()
r = await scanner.run_incremental(session)
await session.commit()
hit2 = (await session.execute(
__import__("sqlalchemy").select(SensitiveHit)
)).scalars().one()
assert hit2.disposition == "false_positive"
await make_client(seed)
- Step 2: 实现
sensitive/scanner.py
"""敏感词扫描器——增量水位(fetched_at)+ 首次全量回填。
水位语义(spec §3.6 P2):last_fetched_at = MAX(message.fetched_at)。增量
补扫拉回的历史消息 timestamp 早于水位但 fetched_at 是新的,按 timestamp
做水位会永久漏扫。等值边界行可能重扫——UNIQUE(word_id,msg_id)+已有命中
跳过兜底,幂等。
方言:MySQL ngram MATCH(≥2字,快);单字词与其他方言走 LIKE。语义差
异(MATCH 分词 vs LIKE 子串)可接受——命中可处置,误报治理闭环在。
分批:增量每次全量拉水位后行数(4h 增量仅数千);全量回填按词扫描、
结果 partitions(500) 流式插入,防内存峰值。
"""
from __future__ import annotations
import datetime as dt
import logging
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Message, SensitiveHit, SensitiveScanState, SensitiveWord
from app.analytics.sensitive.words import _hit_exclude
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 不可用)。"""
if dialect == "mysql" and len(word) >= 2:
return [
sa.text("MATCH(message.text) AGAINST (:w IN BOOLEAN MODE)")
.bindparams(w=f'"{word}"')
]
escaped = word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return [Message.text.like(f"%{escaped}%")]
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 run_incremental(session: AsyncSession) -> dict:
"""增量扫描:fetched_at > 水位 的行 × 全部 enabled 词。"""
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)
conds = [Message.text.is_not(None), Message.text != ""]
if st.last_fetched_at is not None:
conds.append(Message.fetched_at > st.last_fetched_at)
base = 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)
rows = (await session.execute(base)).all()
scanned = len(rows)
hits = 0
if rows and words:
texts = {r[0]: r[6] for r in rows}
for w in words:
hit_ids = {
r[0] for r in rows
if w.word in (texts[r[0]] or "")
} if len(w.word) < 2 or dialect != "mysql" else None
if hit_ids is None:
# MySQL MATCH 路径:对候选行集合再过滤(增量行少,Python 侧
# 子串判定即可——MATCH 只在全量回填用,增量统一 LIKE 语义
# 精确)。此处直接子串:
hit_ids = {r[0] for r in rows if w.word in (texts[r[0]] or "")}
sel_rows = [tuple(r[:7]) for r in rows if r[0] in hit_ids] # 切掉第8列 fetched_at
existing = set(
(
await session.execute(
sa.select(SensitiveHit.msg_id).where(
SensitiveHit.word_id == w.id,
SensitiveHit.msg_id.in_([r[0] for r in sel_rows] or ["-"]),
)
)
).scalars().all()
)
hits += await _insert_hits(session, w, sel_rows, existing)
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 一次性):每词单独扫全库;水位推进同增量。"""
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)
total_hits = 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)
existing: set[str] = set()
batch: list = []
result = await session.stream(stmt)
async for partition in result.partitions(500):
for r in partition:
batch.append(tuple(r))
existing.update(
(
await session.execute(
sa.select(SensitiveHit.msg_id).where(
SensitiveHit.word_id == w.id,
SensitiveHit.msg_id.in_(
[b[0] for b in batch] or ["-"]
),
)
)
).scalars().all()
)
total_hits += await _insert_hits(session, w, batch, existing)
batch = []
await session.flush()
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
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}
实现说明:① 增量路径统一 Python 子串判定(4h 增量仅数千行,语义精确=LIKE);MATCH 只在全量回填的 MySQL 路径用(65 万行必须走索引)。上面 run_incremental 里那个 if len(w.word) < 2 or dialect != "mysql" 的分支两臂相同——落地时合并成一行 {r[0] for r in rows if w.word in (texts[r[0]] or "")}。② session.stream SQLite(aiosqlite) 支持需验证——测试若报错改 session.execute + .partitions()(Result 对象同样有 partitions)。③ 测试里 new_watermark == f1 修正版为准。
-
Step 3: 跑测试 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_sensitive.py -x -q→ Expected: all passed(词库 3 + 扫描 4) -
Step 4: Commit
git commit -am "feat(analytics): C1 扫描器——fetched_at 水位增量/迟到免疫/幂等/全量回填"
Task 10: C1 命中列表 + 批量处置端点
Files:
-
Create:
yunke-server/app/analytics/sensitive/hits.py -
Modify:
yunke-server/app/api/routers/analytics.py -
Test:
yunke-server/tests/test_sensitive.py(追加) -
Step 1: 追加失败测试
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"))
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
- Step 2: 实现
sensitive/hits.py
"""命中查询与处置——默认只显示 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)
.order_by(SensitiveHit.ts.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:
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)
- Step 3: 端点(analytics.py 追加)
from app.analytics.sensitive import hits as hits_svc
class DisposeIn(BaseModel):
ids: list[int]
disposition: str
@router.get("/sensitive/hits")
async def list_hits(
session: SessionDep,
disposition: str | None = None,
word_id: int | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict:
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:
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
- Step 4: 跑测试 + Commit Run:
pytest tests/test_sensitive.py -x -q→ all passed;git commit -am "feat(analytics): C1 命中列表默认过滤+批量处置"
Task 11: 调度 4h job + CLI 回填 + full_sync 步骤
Files:
-
Modify:
yunke-server/app/sync/scheduler.py(start() 里加 job + 新方法_analytics_tick) -
Modify:
yunke-server/app/sync/cli.py(staff-daily-stats / sensitive-scan 两个子命令) -
Modify:
yunke-server/scripts/full_sync_all.sh(daily-stats 步骤后追加一步) -
Test:
yunke-server/tests/test_scheduler_analytics_tick.py -
Step 1: scheduler.py 加 job(
start()内 weekly job 之后、sched.start()之前)
# 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,
)
新方法(_safe_page_run 附近):
async def _analytics_tick(self) -> None:
"""4h 分析链(stat_daily_staff + 敏感词增量)——异常只记日志不断链。"""
try:
async with self._session_maker() as session:
from app.storage.staff_summary import recompute_days, rolling_days
from app.storage.summary import day_key
from app.analytics.sensitive import scanner
today = day_key(int(dt.datetime.now().timestamp() * 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
logger.exception("analytics tick failed")
(dt/logging 该文件已有 import;确认 import datetime as dt 存在,没有则补。)
- Step 2: cli.py 加两个子命令(照
daily-stats的三处模式:_build_parser加 parser、main()加分支、新增_cmd_*)
# ``staff-daily-stats`` —— p_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. Idempotent.",
)
# ``sensitive-scan`` ——
p_scan = sub.add_parser(
"sensitive-scan",
help="Sensitive-word scan (incremental; --full for backfill).",
)
p_scan.add_argument("--full", action="store_true",
help="Full backfill scan (first deploy).")
命令实现:
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:
settings = get_settings()
engine, session_maker = await _init_db(settings.db_url)
try:
from app.analytics.sensitive import scanner, 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()
(main() 加两个 if args.command == ... 分支。)
- Step 3: full_sync_all.sh 追加步骤(先
grep -n "daily-stats" scripts/full_sync_all.sh找到该步骤的写法,紧随其后镜像加一行;形如)
step_n "staff daily stats" "$PY -m app.sync.cli staff-daily-stats"
(变量名/函数名以脚本实际为准;这一步是夜间全量自愈的第 12 步。)
- Step 4: 写调度测试
tests/test_scheduler_analytics_tick.py
"""_analytics_tick:滚动刷新+增量扫描+异常吞掉。"""
from __future__ import annotations
import pytest
from app.db.models import MsgDailyStats, SensitiveWord
from app.sync.scheduler import SyncScheduler
@pytest.fixture
async def sched_engine():
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.db.base import Base
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):
# _analytics_tick 用真实「今天」算滚动窗——seed 的 day 必须相对当日,
# 固定 20260904 会在三天后过期(评审残留#2 修正)
import datetime as dt
from app.storage.summary import day_key
engine, maker = sched_engine
today = day_key(int(dt.datetime.now().timestamp() * 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() # 不抛即过
from sqlalchemy import func, select
from app.db.models import StatDailyStaff
async with maker() as s:
n = (await s.execute(
select(func.count()).select_from(StatDailyStaff)
)).scalar_one()
assert n >= 1
- Step 5: 跑测试 + 全量回归
Run: cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scheduler_analytics_tick.py -x -q && .venv/Scripts/python.exe -m pytest tests/ -q
Expected: all passed
- Step 6: Commit
git commit -am "feat(analytics): 4h 调度分析链+CLI 回填+full_sync 夜间自愈步"
Task 12: 前端骨架——echarts + useChart + 路由 + 侧栏组 + API 模块
Files:
-
Modify:
yunkes/package.json(+echarts) -
Create:
yunkes/src/composables/useChart.ts -
Create:
yunkes/src/api/analytics.ts -
Modify:
yunkes/src/router/index.ts -
Modify:
yunkes/src/layouts/MainLayout.vue -
Create:
yunkes/src/views/analytics/四个占位页(后续任务填实) -
Step 1: 安装 echarts Run:
cd yunkes && npm install echarts -
Step 2:
src/composables/useChart.ts
// ECharts 按需封装(2026-09-04):注册/主题/自适应/销毁一处管——后续所有
// 图表页复用(二批 A2/A4/A5 直接 import 本 composable)。
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
import * as echarts from 'echarts/core'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { EChartsCoreOption } from 'echarts/core'
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
export function useChart(el: Ref<HTMLElement | null>) {
let chart: echarts.ECharts | null = null
let ro: ResizeObserver | null = null
const render = (option: EChartsCoreOption) => {
if (!el.value) return
if (!chart) {
chart = echarts.init(el.value)
ro = new ResizeObserver(() => chart?.resize())
ro.observe(el.value)
}
chart.setOption(option, true) // notMerge:指标切换时丢弃旧系列
}
onBeforeUnmount(() => {
ro?.disconnect()
chart?.dispose()
chart = null
})
return { render }
}
export { echarts }
export const CHART_REF = ref // 防止 tree-shake 误删(无实际用途可去掉)
(CHART_REF 行删掉——多余。落地时不要。)
- Step 3:
src/api/analytics.ts(类型与后端返回一一对应)
// 数据分析板块 API(client.ts 的 http 已带 Bearer + baseURL=/api)
import { getJson, http } from './client'
export type Channel = 'wechat' | 'whatsapp'
export type Scope = 'today' | 'week' | 'month'
export interface OverviewCards {
msg_total: number
active_customers: number
active_staff: number
call_cnt: number
first_contact: number
prev_msg_total?: number
msg_total_delta?: number | null
}
export interface TrendPoint { day: number; msg: number; active_customers: number; call_cnt: number }
export interface AnomalyStaff { user_id: string; yesterday: number; prev7_mean: number }
export interface Anomaly { total_delta_pct: number; yesterday_total: number; prev7_mean: number; staff: AnomalyStaff[] }
export interface OverviewResp {
cards: OverviewCards
trend: TrendPoint[]
anomaly: Anomaly | null
}
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 Page<T> { items: T[]; total: number; page: number; page_size: number }
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
}
export const getOverview = (channel: Channel, scope: Scope) =>
getJson<OverviewResp>('/analytics/overview', { channel, scope })
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[] }) =>
http.post('/analytics/sensitive/words/preview', data) as unknown as Promise<{ count: number; warn: boolean }>
export const createWord = (data: { word: string; category?: string; exclude_words?: string[] }) =>
http.post('/analytics/sensitive/words', data) as unknown as Promise<{ id: number; word: string }>
export const patchWord = (id: number, data: Record<string, unknown>) =>
http.patch(`/analytics/sensitive/words/${id}`, data) as unknown as Promise<Record<string, unknown>>
export const deleteWord = (id: number) =>
http.delete(`/analytics/sensitive/words/${id}`) as unknown as Promise<{ ok: boolean }>
export const getHits = (params: Record<string, unknown>) =>
getJson<Page<SensitiveHitRow>>('/analytics/sensitive/hits', params)
export const disposeHits = (ids: number[], disposition: string) =>
http.patch('/analytics/sensitive/hits', { ids, disposition }) as unknown as Promise<{ updated: number }>
(注意:client.ts 是否已导出 http.patch/http.delete?——http 是 axios 实例直接可用;若 client.ts 只导出了 getJson/http 两项则无需改。检查 export 再动手。)
- Step 4: 路由(router/index.ts,
/search之后加)
{
// 数据分析板块(spec 2026-09-04):四页
path: '/analytics/dashboard',
name: 'analytics-dashboard',
component: () => import('@/views/analytics/DashboardView.vue'),
meta: { title: '数据看板' },
},
{
path: '/analytics/churn',
name: 'analytics-churn',
component: () => import('@/views/analytics/ChurnView.vue'),
meta: { title: '流失预警' },
},
{
path: '/analytics/unreplied',
name: 'analytics-unreplied',
component: () => import('@/views/analytics/UnrepliedView.vue'),
meta: { title: '该回未回' },
},
{
path: '/analytics/sensitive',
name: 'analytics-sensitive',
component: () => import('@/views/analytics/SensitiveView.vue'),
meta: { title: '敏感词' },
},
- Step 5: 侧栏组(MainLayout.vue「通用功能」三行之后、
</el-menu>之前;照 wx/wa 组的样式惯例用平铺菜单项——分析页内部自带 channel 切换,无需挂 channel 参数)
<!-- 数据分析 -->
<el-sub-menu index="analytics">
<template #title>
<span class="yk-sidenav__group">数据分析</span>
</template>
<el-menu-item index="/analytics/dashboard" @click="goPlain('/analytics/dashboard')">
数据看板
</el-menu-item>
<el-menu-item index="/analytics/churn" @click="goPlain('/analytics/churn')">
流失预警
</el-menu-item>
<el-menu-item index="/analytics/unreplied" @click="goPlain('/analytics/unreplied')">
该回未回
</el-menu-item>
<el-menu-item index="/analytics/sensitive" @click="goPlain('/analytics/sensitive')">
敏感词
</el-menu-item>
</el-sub-menu>
- Step 6: 四个占位页(每页先放最小可编译内容,后续任务填实)
<template>
<div class="page">
<h2>数据看板</h2>
<p>实现于后续任务</p>
</div>
</template>
<script setup lang="ts"></script>
(四页同款,标题分别 流失预警/该回未回/敏感词。)
-
Step 7: 构建验证 Run:
cd yunkes && npm run build→ Expected: 无类型错误 -
Step 8: Commit
git add yunkes/package.json yunkes/package-lock.json yunkes/src/composables/useChart.ts yunkes/src/api/analytics.ts yunkes/src/router/index.ts yunkes/src/layouts/MainLayout.vue yunkes/src/views/analytics/
git commit -m "feat(analytics): 前端板块骨架——echarts按需/useChart/路由/侧栏组/API模块"
Task 13: DashboardView(A1 总览页)
Files:
-
Create(填实):
yunkes/src/views/analytics/DashboardView.vue -
Step 1: 实现页面(结构=筛选栏 → 异常警示条 → 卡片行 → 趋势图;seq 守卫防 channel 切换竞态——项目惯例)
<template>
<div class="pa4-dashboard">
<div class="toolbar">
<el-radio-group v-model="channel" @change="load">
<el-radio-button value="wechat">微信</el-radio-button>
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
</el-radio-group>
<el-radio-group v-model="scope" @change="load">
<el-radio-button value="today">今日</el-radio-button>
<el-radio-button value="week">本周</el-radio-button>
<el-radio-button value="month">本月</el-radio-button>
</el-radio-group>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div>
<el-alert v-if="data?.anomaly" type="warning" :closable="false" class="anomaly">
<template #title>
昨日消息量较前 7 日均值{{ data.anomaly.total_delta_pct > 0 ? '高' : '低' }}
{{ Math.abs(Math.round(data.anomaly.total_delta_pct * 100)) }}%
({{ data.anomaly.yesterday_total }} vs 均值 {{ data.anomaly.prev7_mean }})
<span v-if="data.anomaly.staff.length">
— 涉及 {{ data.anomaly.staff.length }} 名员工
</span>
</template>
<div v-if="data.anomaly.staff.length" class="anomaly-staff">
<div v-for="s in data.anomaly.staff.slice(0, 5)" :key="s.user_id">
{{ s.user_id }}:{{ s.yesterday }}(均值 {{ s.prev7_mean }})
</div>
</div>
</el-alert>
<div class="cards">
<el-card v-for="c in cardList" :key="c.label" shadow="never">
<div class="card-label">{{ c.label }}</div>
<div class="card-value">{{ c.value }}</div>
<div v-if="c.delta !== undefined && c.delta !== null" class="card-delta" :class="{ down: c.delta < 0 }">
环比 {{ (c.delta * 100).toFixed(1) }}%
</div>
</el-card>
</div>
<el-card shadow="never" class="trend-card">
<template #header>
<el-radio-group v-model="trendMetric" size="small">
<el-radio-button value="msg">消息量</el-radio-button>
<el-radio-button value="active_customers">活跃客户</el-radio-button>
<el-radio-button value="call_cnt">通话量</el-radio-button>
</el-radio-group>
</template>
<div ref="chartEl" class="trend-chart"></div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
getAnalyticsHealth, getOverview, type Channel, type Scope, type OverviewResp,
} from '@/api/analytics'
import { useChart } from '@/composables/useChart'
const channel = ref<Channel>('wechat')
const scope = ref<Scope>('today')
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
const data = ref<OverviewResp | null>(null)
const asOf = ref<number | null>(null)
const chartEl = ref<HTMLElement | null>(null)
const { render } = useChart(chartEl)
let seq = 0 // 竞态守卫:channel/scope 快速切换时旧响应作废(项目惯例)
async function load() {
const my = ++seq
const [ov, h] = await Promise.all([getOverview(channel.value, scope.value), getAnalyticsHealth()])
if (my !== seq) return
data.value = ov
asOf.value = h.data_as_of
}
const asOfText = computed(() => {
if (!asOf.value) return ''
const d = new Date(asOf.value)
return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
})
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
const cardList = computed(() => {
const c = data.value?.cards
if (!c) return []
const out = [
{ label: '消息量', value: fmt(c.msg_total), delta: scope.value === 'today' ? undefined : (c.msg_total_delta ?? undefined) },
{ label: '活跃客户', value: fmt(c.active_customers) },
{ label: '活跃员工', value: fmt(c.active_staff) },
{ label: '通话量', value: fmt(c.call_cnt) },
{ label: '首次沟通客户', value: fmt(c.first_contact) },
]
return out
})
watch([data, trendMetric], draw)
function draw() {
if (!data.value) return
const t = data.value.trend
render({
grid: { left: 48, right: 16, top: 24, bottom: 28 },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: t.map((p) => `${String(p.day).slice(4, 6)}-${String(p.day).slice(6)}`),
},
yAxis: { type: 'value' },
series: [{
type: 'line',
smooth: true,
areaStyle: { opacity: 0.12 },
data: t.map((p) => p[trendMetric.value]),
}],
})
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; }
.as-of { color: var(--el-text-color-secondary); font-size: 12px; margin-left: auto; }
.anomaly { margin-bottom: 16px; }
.anomaly-staff { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; }
.card-label { color: var(--el-text-color-secondary); font-size: 13px; }
.card-value { font-size: 26px; font-weight: 600; margin: 6px 0 2px; }
.card-delta { font-size: 12px; color: var(--el-color-success); }
.card-delta.down { color: var(--el-color-danger); }
.trend-chart { height: 320px; }
</style>
(若 el-radio-button 的 value prop 在当前 Element Plus 版本用 label,照项目其他页写法统一。)
-
Step 2: 构建验证 Run:
cd yunkes && npm run build→ Expected: 通过 -
Step 3: 目测(本地起后端+前端,或直接看构建产物)确认卡片/警示条/折线渲染、channel 切换无竞态。
-
Step 4: Commit
git commit -am "feat(analytics): A1 数据看板页——卡片/异常警示/趋势折线/数据截至角标"
Task 14: ChurnView(A3 流失预警页)
Files:
-
Create(填实):
yunkes/src/views/analytics/ChurnView.vue -
Step 1: 实现页面(三分法 tab + tier 筛选 + 表格 + CSV 导出 + 行点击跳聊天窗;CSV 用项目现成
csvExport.ts的exportListToCsv({ fetchPage, columns, filename })(yunkes/src/utils/csvExport.ts:45,传 fetchPage 即可,不用手写翻页循环);toolbar 右侧加「数据截至 HH:mm」角标——getAnalyticsHealth()一次,三个列表页同款(spec §5 全板块带角标)。CSV 平铺带归属员工列= v1 简化(spec 的「按员工分组」由表格列承担,注释说明))
<template>
<div>
<div class="toolbar">
<el-radio-group v-model="channel" @change="load">
<el-radio-button value="wechat">微信</el-radio-button>
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
</el-radio-group>
<el-tabs v-model="kind" @change="load">
<el-tab-pane label="真流失" name="real" />
<el-tab-pane label="从未激活" name="inactive" />
<el-tab-pane label="待交接" name="handover" />
</el-tabs>
<el-select v-if="kind === 'real'" v-model="tier" @change="load" style="width: 130px">
<el-option :value="30" label="沉默 30-60 天" />
<el-option :value="60" label="沉默 60-90 天" />
<el-option :value="90" label="沉默 90 天+" />
</el-select>
<el-button :loading="exporting" @click="exportCsv">导出 CSV</el-button>
</div>
<el-table :data="rows" v-loading="loading" @row-click="goChat" row-class-name="clickable">
<el-table-column prop="nickname" label="客户" min-width="160" />
<el-table-column label="归属员工" width="140">
<template #default="{ row }">{{ row.owner_account_id || row.user_id || '-' }}</template>
</el-table-column>
<el-table-column label="最后沟通" width="170">
<template #default="{ row }">{{ fmtTime(row.last_chat_time) }}</template>
</el-table-column>
<el-table-column prop="hist_msgs" label="历史消息量" width="110" sortable />
<el-table-column prop="active_days" label="活跃天数" width="100" sortable />
</el-table>
<el-pagination layout="total, prev, pager, next" :total="total"
v-model:current-page="page" :page-size="20" @current-change="load" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getChurn, type Channel, type ChurnRow } from '@/api/analytics'
import { exportListToCsv } from '@/utils/csvExport'
const router = useRouter()
const channel = ref<Channel>('wechat')
const kind = ref<'real' | 'inactive' | 'handover'>('real')
const tier = ref(30)
const page = ref(1)
const rows = ref<ChurnRow[]>([])
const total = ref(0)
const loading = ref(false)
const exporting = ref(false)
let seq = 0
async function load() {
const my = ++seq
loading.value = true
try {
const out = await getChurn({
channel: channel.value, type: kind.value, tier: tier.value,
min_msgs: 5, page: page.value, page_size: 20,
})
if (my !== seq) return
rows.value = out.items
total.value = out.total
} finally {
loading.value = false
}
}
const fmtTime = (ts: number | null) =>
ts ? new Date(ts).toLocaleString('zh-CN', { hour12: false }) : '从未沟通'
function goChat(row: ChurnRow) {
// 微信 → 员工聊天窗;WA → 旧聊天页(项目跳转惯例,见 CallList)
if (row.channel === 'wechat') {
router.push(`/staff-chat/${row.owner_account_id}/single/${row.friend_id}`)
} else {
router.push(`/chat/${row.channel}/${row.owner_account_id}/${row.friend_id}`)
}
}
async function exportCsv() {
exporting.value = true
try {
await exportListToCsv<ChurnRow>({
fetchPage: (page, pageSize) => getChurn({
channel: channel.value, type: kind.value, tier: tier.value,
min_msgs: 5, page, page_size: pageSize,
}),
columns: [
{ key: 'nickname', label: '客户' },
{ key: 'channel', label: '渠道' },
{ key: 'owner_account_id', label: '归属员工' },
{
key: 'last_chat_time', label: '最后沟通',
format: (r) => (r.last_chat_time
? new Date(r.last_chat_time).toLocaleString('zh-CN', { hour12: false })
: '从未沟通'),
},
{ key: 'hist_msgs', label: '历史消息量' },
{ key: 'active_days', label: '活跃天数' },
],
filename: `流失预警_${kind.value}_${channel.value}`,
}) // 工具自带翻页+BOM+10000 行 cap
} finally {
exporting.value = false
}
}
onMounted(load)
</script>
(exportRowsCsv 的实际函数名/签名以 yunkes/src/utils/csvExport.ts 为准——先 grep 再用;导出行数上限遵守项目 cap 惯例。)
- Step 2: 构建验证 + Commit Run:
npm run build→ 通过;git commit -am "feat(analytics): A3 流失预警页——三分法/分层/CSV导出/跳聊天"
Task 15: UnrepliedView(B2 该回未回页)
Files:
-
Create(填实):
yunkes/src/views/analytics/UnrepliedView.vue -
Step 1: 实现页面(时长筛选 1/2/4/24h + 表格 + 等待时长红橙着色 + 行点击跳聊天;toolbar 右侧「数据截至 HH:mm」角标同 ChurnView)
<template>
<div>
<div class="toolbar">
<el-radio-group v-model="channel" @change="load">
<el-radio-button value="wechat">微信</el-radio-button>
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
</el-radio-group>
<el-radio-group v-model="hours" @change="load">
<el-radio-button :value="1">1h</el-radio-button>
<el-radio-button :value="2">2h</el-radio-button>
<el-radio-button :value="4">4h</el-radio-button>
<el-radio-button :value="24">24h</el-radio-button>
</el-radio-group>
<span class="hint">尾部为客户消息且超过 {{ hours }}h 未回应的对话(通话也算已回应)</span>
</div>
<el-table :data="rows" v-loading="loading" @row-click="goChat">
<el-table-column prop="peer_id" label="客户" min-width="150" />
<el-table-column prop="owner_account_id" label="员工账号" width="160" />
<el-table-column label="最后消息" width="170">
<template #default="{ row }">{{ fmtTime(row.last_ts) }}</template>
</el-table-column>
<el-table-column label="等待时长" width="120">
<template #default="{ row }">
<span :class="{ hot: row.wait_ms > 24 * 3600 * 1000 }">{{ fmtWait(row.wait_ms) }}</span>
</template>
</el-table-column>
<el-table-column prop="preview" label="预览" min-width="220" show-overflow-tooltip />
</el-table>
<el-pagination layout="total, prev, pager, next" :total="total"
v-model:current-page="page" :page-size="20" @current-change="load" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getUnreplied, type Channel, type UnrepliedRow } from '@/api/analytics'
const router = useRouter()
const channel = ref<Channel>('wechat')
const hours = ref(2)
const page = ref(1)
const rows = ref<UnrepliedRow[]>([])
const total = ref(0)
const loading = ref(false)
let seq = 0
async function load() {
const my = ++seq
loading.value = true
try {
const out = await getUnreplied({
channel: channel.value, hours: hours.value, page: page.value, page_size: 20,
})
if (my !== seq) return
rows.value = out.items
total.value = out.total
} finally {
loading.value = false
}
}
const fmtTime = (ts: number) => new Date(ts).toLocaleString('zh-CN', { hour12: false })
const fmtWait = (ms: number) => {
const h = Math.floor(ms / 3600000)
return h >= 24 ? `${Math.floor(h / 24)}天${h % 24}时` : `${h}时${Math.floor((ms % 3600000) / 60000)}分`
}
function goChat(row: UnrepliedRow) {
if (row.channel === 'wechat') {
router.push(`/staff-chat/${row.owner_account_id}/single/${row.peer_id}?msgid=${row.msg_id}`)
} else {
router.push(`/chat/${row.channel}/${row.owner_account_id}/${row.peer_id}`)
}
}
onMounted(load)
</script>
- Step 2: 构建验证 + Commit Run:
npm run build→ 通过;git commit -am "feat(analytics): B2 该回未回页——时长筛选/等待着色/msgid定位跳转"
Task 16: SensitiveView(C1 词库 + 命中处置页)
Files:
-
Create(填实):
yunkes/src/views/analytics/SensitiveView.vue -
Step 1: 实现页面(双 tab:词库管理(CRUD+预览)/ 命中列表(默认 pending+confirmed,多选批量处置,跳上下文);页面顶部「数据截至 HH:mm」角标同 ChurnView)
<template>
<div>
<el-tabs v-model="tab">
<el-tab-pane label="命中列表" name="hits">
<div class="toolbar">
<el-select v-model="dispFilter" @change="loadHits" style="width: 150px">
<el-option value="" label="待处置+已确认" />
<el-option value="pending" label="待处置" />
<el-option value="confirmed" label="已确认" />
<el-option value="false_positive" label="误报" />
<el-option value="ignored" label="已忽略" />
</el-select>
<el-button :disabled="!sel.length" @click="dispose('confirmed')">批量确认</el-button>
<el-button :disabled="!sel.length" @click="dispose('false_positive')">批量误报</el-button>
<el-button :disabled="!sel.length" @click="dispose('ignored')">批量忽略</el-button>
</div>
<el-table :data="hits" v-loading="loadingHits" @selection-change="(s: SensitiveHitRow[]) => (sel = s)">
<el-table-column type="selection" width="42" />
<el-table-column prop="word" label="词" width="100" />
<el-table-column prop="category" label="分类" width="90" />
<el-table-column prop="snippet" label="上下文" min-width="260" show-overflow-tooltip />
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ row.ts ? new Date(row.ts).toLocaleString('zh-CN', { hour12: false }) : '-' }}</template>
</el-table-column>
<el-table-column prop="disposition" label="处置" width="90" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="primary" @click.stop="goContext(row)">上下文</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination layout="total, prev, pager, next" :total="hitsTotal"
v-model:current-page="hitsPage" :page-size="20" @current-change="loadHits" />
</el-tab-pane>
<el-tab-pane label="词库管理" name="words">
<div class="toolbar">
<el-input v-model="newWord" placeholder="新词" style="width: 160px" />
<el-input v-model="newCategory" placeholder="分类(可选)" style="width: 140px" />
<el-input v-model="newExcludes" placeholder="排除词,逗号分隔(可选)" style="width: 220px" />
<el-button type="primary" @click="addWord">预览并添加</el-button>
</div>
<el-table :data="words">
<el-table-column prop="word" label="词" width="140" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column label="排除词" min-width="180">
<template #default="{ row }">{{ (row.exclude_words || []).join('、') || '-' }}</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-switch :model-value="row.enabled" @change="(v: boolean) => toggleWord(row, v)" />
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" @click="removeWord(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
createWord, deleteWord, disposeHits, getHits, getWords, patchWord, previewWord,
type SensitiveHitRow, type SensitiveWordRow,
} from '@/api/analytics'
const router = useRouter()
const tab = ref('hits')
const hits = ref<SensitiveHitRow[]>([])
const hitsTotal = ref(0)
const hitsPage = ref(1)
const dispFilter = ref('')
const sel = ref<SensitiveHitRow[]>([])
const loadingHits = ref(false)
const words = ref<SensitiveWordRow[]>([])
const newWord = ref('')
const newCategory = ref('')
const newExcludes = ref('')
let seq = 0
async function loadHits() {
const my = ++seq
loadingHits.value = true
try {
const out = await getHits({
disposition: dispFilter.value || undefined,
page: hitsPage.value, page_size: 20,
})
if (my !== seq) return
hits.value = out.items
hitsTotal.value = out.total
} finally {
loadingHits.value = false
}
}
async function dispose(disposition: string) {
await disposeHits(sel.value.map((h) => h.id), disposition)
ElMessage.success('已批量处置')
await loadHits()
}
function goContext(row: SensitiveHitRow) {
if (row.room_id) {
router.push(`/group/${row.channel}/${row.owner_account_id}/${row.room_id}`)
} else if (row.channel === 'wechat' && row.owner_account_id) {
router.push(`/staff-chat/${row.owner_account_id}/single/${row.talker_id}?msgid=${row.msg_id}`)
} else {
router.push(`/chat/${row.channel}/${row.owner_account_id}/${row.talker_id}`)
}
}
async function loadWords() {
words.value = await getWords()
}
async function addWord() {
const word = newWord.value.trim()
if (!word) return
const excludes = newExcludes.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
const p = await previewWord({ word, exclude_words: excludes.length ? excludes : undefined })
if (p.warn) {
await ElMessageBox.confirm(
`该词预计命中 ${p.count} 条(≥500),建议换长词或加排除词。仍要添加吗?`,
'命中量较大', { type: 'warning' },
)
}
try {
await createWord({
word, category: newCategory.value.trim() || undefined,
exclude_words: excludes.length ? excludes : undefined,
})
ElMessage.success('已添加(下次 4h 扫描生效;立即生效可让管理员跑 CLI)')
newWord.value = ''
newCategory.value = ''
newExcludes.value = ''
await loadWords()
} catch (e) {
ElMessage.error((e as Error).message)
}
}
async function toggleWord(row: SensitiveWordRow, v: boolean) {
await patchWord(row.id, { enabled: v })
await loadWords()
}
async function removeWord(row: SensitiveWordRow) {
await ElMessageBox.confirm(
`删除该词将连带删除其全部命中记录,不可恢复。确认删除?`, '危险操作', { type: 'warning' },
)
await deleteWord(row.id)
await loadWords()
}
onMounted(() => { loadHits(); loadWords() })
</script>
(词内容不在 console 打印;删除确认文案不回显词本身以外的命中内容。)
- Step 2: 构建验证 + Commit Run:
npm run build→ 通过;git commit -am "feat(analytics): C1 敏感词页——词库CRUD/预览警示/批量处置/上下文跳转"
Task 17: 全量回归 + 部署收尾
Files:
-
Modify:
docs/DEPLOY.md(升级一节追加回填命令) -
Step 1: 后端全量测试 Run:
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q→ Expected: all passed(原 512+ 新增 ~20) -
Step 2: 前端构建 Run:
cd yunkes && npm run build→ Expected: 通过 -
Step 3: DEPLOY.md 追加升级注记(升级流程一节,照现有条目格式加)
### 数据分析板块首部署附加步骤(一次性)
迁移自动跑(4 新表+message.fetched_at 索引,86 万行建索引约十几秒)后,
夜间窗口执行两条回填:
```bash
docker compose exec backend python -m app.sync.cli staff-daily-stats
docker compose exec backend python -m app.sync.cli sensitive-scan --full
fetched_at 索引建立会锁表写入,避开抓取窗口(与 ngram 索引同注意事项)。 之后 4h 调度自动增量,无需再手动跑。
- [ ] **Step 4: Commit** `git commit -am "docs(deploy): 数据分析板块首部署回填注记"`
- [ ] **Step 5: 服务器部署**(用户执行/确认后操作,照 DEPLOY.md 流程)
1. `git push`(或 bundle:`git bundle create x.bundle <服务器HEAD>..master` + scp + 服务器 `git pull /tmp/x.bundle master`)
2. 服务器 `sudo docker compose up -d --build`(迁移自动)
3. 夜间窗口跑 Step 3 两条回填
4. `sudo docker compose restart frontend`(防 nginx DNS 缓存 502——9-03 踩过)
5. 浏览器验收四页:`http://219.157.255.213:32002/analytics/dashboard`
---
## 任务依赖图
T1 模型迁移 ─→ T2 health ─→ T3 staff_summary ─→ T4 metrics.overview ─→ T5 /overview │ │ │ └─→ T6 churn / T7 unreplied(并行) │ └─→ T8 词库 ─→ T9 scanner ─→ T10 hits ─→ T11 调度/CLI(汇合 T3+T9) T12 前端骨架 ─→ T13 Dashboard(依赖 T5) / T14 Churn(依赖 T6) / T15 Unreplied(依赖 T7) / T16 Sensitive(依赖 T8+T10) T17 收尾(依赖全部)
## 验收清单(对照 spec §8 口径速查表)
- [ ] A1 五卡片 + 周月环比 + 异常警示条 + 30 天折线 + 数据截至角标
- [ ] A3 三分法名单 + tier 分层 + CSV + 跳聊天
- [ ] B2 默认 2h 可调 + 有效类型过滤 + 通话豁免 + msgid 定位跳转
- [ ] C1 加词预览警示 + 批量处置 + 默认过滤 + 词删除级联
- [ ] stat_daily_staff 判别法测试(删 message 不丢数) + 空表回退
- [ ] 生产无裸扫(全部查询骑汇总表/小表/索引点查)
- [ ] 敏感词内容不出现在任何日志