docs(plan): 评审修订——修5处seed/签名矛盾+起步词库补种+四页角标

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 14:42:56 +08:00
parent aecb556efd
commit 0bc1250476
@@ -740,6 +740,8 @@ async def recompute_days(
) -> int:
"""重算给定 day×channel 的员工行:删旧插新,幂等。
返回**有数据行的 day×channel 组合数**(空渠道不计——与测试断言的
「天数」语义一致,seed 只有 wechat 日行时返回 1)。
单日日行仅数百~两千,拉回 Python 侧按 user_id 聚合——COUNT(DISTINCT
peer) 必须在「账号合并到员工」之后算(同人多账号 distinct peer 跨账号
去重),写 SQL 反而绕且踩 only_full_group_by。
@@ -761,6 +763,13 @@ async def recompute_days(
)
)
).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 原值(读侧标注)
@@ -770,11 +779,6 @@ async def recompute_days(
a["sent"] += int(s or 0)
a["recv"] += int(r or 0)
(a["friends"] if kind == "friend" else a["rooms"]).add(peer)
await session.execute(
sa.delete(StatDailyStaff).where(
StatDailyStaff.day == day, StatDailyStats.channel == channel
)
)
for uid, a in sorted(agg.items()):
session.add(
StatDailyStaff(
@@ -799,7 +803,7 @@ async def recompute_all(session: AsyncSession) -> int:
return await recompute_days(session, list(days))
```
⚠️ 上面 `recompute_days` 里 delete 语句有一个笔误要修正:`StatDailyStats.channel == channel` 应为 `StatDailyStaff.channel == channel`。写代码时注意。
⚠️ 上面 `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
@@ -837,28 +841,34 @@ _TODAY = 20260904 # 测试统一用固定 day 键,monkeypatch today_key
async def _seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
# 昨日(20260903):u1 100 条;前 7 日均值(20260827-0902):每天 200 → 跌 50% 触发
# 昨日(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, 100)]:
(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=1756950000000,
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=1756240000000,
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=1756950000000,
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=1756950000000, call_duration=60))
start_call_time=1788500000000, call_duration=60))
await session.commit()
@@ -883,9 +893,9 @@ async def test_anomaly_yesterday_vs_prev7(make_client, monkeypatch):
await _seed(session)
a = metrics.anomaly(session, channel="wechat")
assert a is not None
assert a["total_delta_pct"] <= -0.5 # 100 vs 均值 200 = -50%
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"] == 100
assert staff["u1"]["yesterday"] == 90
assert staff["u1"]["prev7_mean"] == 200.0
await make_client(seed)
@@ -1308,7 +1318,7 @@ import datetime as dt
from app.analytics import metrics
from app.db.models import Customer, MsgDailyStats, Staff
_NOW = 1756950000000 # 2026-09-04 ~ 上海
_NOW = 1756950000000 # 相对时间基准(绝对日期无关紧要,断言全部相对此值自洽)
def _cust(friend_id, *, last_chat=None, user_id="u1", delete=0, block=None):
@@ -1885,7 +1895,11 @@ async def delete_word(session: AsyncSession, word_id: int) -> None:
async def preview_count(
session: AsyncSession, word: str, exclude_words: list[str] | None = None
) -> int:
"""加词前 COUNT——MySQL 走 ngram MATCH,其他方言 LIKE(测试)。"""
"""加词前 COUNT——MySQL 走 ngram MATCH,其他方言 LIKE(测试)。
口径注:MySQL MATCH 路径不扣排除词(预览=上限,排除词在扫描时精确
生效);LIKE 路径(测试库)精确扣。前端文案「预计命中」体现上限语义。
"""
word = (word or "").strip()
if not word:
return 0
@@ -1914,6 +1928,36 @@ def _hit_exclude(text: str | None, word: str, exclude_words: list[str] | None) -
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` 已引入)
@@ -2211,7 +2255,7 @@ async def run_incremental(session: AsyncSession) -> dict:
# 子串判定即可——MATCH 只在全量回填用,增量统一 LIKE 语义
# 精确)。此处直接子串:
hit_ids = {r[0] for r in rows if w.word in (texts[r[0]] or "")}
sel_rows = [r for r in rows if r[0] in hit_ids]
sel_rows = [tuple(r[:7]) for r in rows if r[0] in hit_ids] # 切掉第8列 fetched_at
existing = set(
(
await session.execute(
@@ -2526,15 +2570,16 @@ 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
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] {out}")
print(f"[sensitive-scan] seeded={seeded} {out}")
return 0
finally:
await engine.dispose()
@@ -2973,7 +3018,7 @@ onMounted(load)
**Files:**
- Create(填实): `yunkes/src/views/analytics/ChurnView.vue`
- [ ] **Step 1: 实现页面**(三分法 tab + tier 筛选 + 表格 + CSV 导出 + 行点击跳聊天窗;CSV 用项目现成 `csvExport.ts` 全量翻页惯例)
- [ ] **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 的「按员工分组」由表格列承担,注释说明))
```vue
<template>
@@ -3094,7 +3139,7 @@ onMounted(load)
**Files:**
- Create(填实): `yunkes/src/views/analytics/UnrepliedView.vue`
- [ ] **Step 1: 实现页面**(时长筛选 1/2/4/24h + 表格 + 等待时长红橙着色 + 行点击跳聊天)
- [ ] **Step 1: 实现页面**(时长筛选 1/2/4/24h + 表格 + 等待时长红橙着色 + 行点击跳聊天;toolbar 右侧「数据截至 HH:mm」角标同 ChurnView)
```vue
<template>
@@ -3187,7 +3232,7 @@ onMounted(load)
**Files:**
- Create(填实): `yunkes/src/views/analytics/SensitiveView.vue`
- [ ] **Step 1: 实现页面**(双 tab:词库管理(CRUD+预览)/ 命中列表(默认 pending+confirmed,多选批量处置,跳上下文))
- [ ] **Step 1: 实现页面**(双 tab:词库管理(CRUD+预览)/ 命中列表(默认 pending+confirmed,多选批量处置,跳上下文);页面顶部「数据截至 HH:mm」角标同 ChurnView)
```vue
<template>