fix(analytics): unreplied is_send守卫防漂移假行+真预算注释+501候选分支测试

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 19:22:22 +08:00
parent 52af531598
commit 1e0db3913c
2 changed files with 78 additions and 11 deletions
+18 -8
View File
@@ -460,6 +460,9 @@ async def churn(
_VALID_TAIL_TYPES = ("text", "image", "voice", "video", "file", "link")
# 候选量阈值:≤此值决赛圈全量点查(total 精确);超过则按页点查(total=候选数)
_FINALIZE_ALL_MAX = 500
def _norm_phone(p: str | None) -> str | None:
"""归一:+86/空格/横线剥掉(spec B1 同款规则)。"""
@@ -479,12 +482,16 @@ async def unreplied(
"""B2 该回未回(spec §3.4)。候选=汇总表最新日行;决赛圈点查类型/预览/通话豁免。
候选资格看「最新日行」(row_number 先于方向/超时过滤——员工次日已回的
旧未回日不复活);决赛圈再点查尾部消息有效类型(排除 unknown/divider 等)、
剔除已删/拉黑客户、通话豁免(该客户 phone 任一晚于最后消息的通话=已回应,
旧未回日不复活);决赛圈再点查尾部消息:须员工未回(is_send 非 True——
stats/message 漂移时以 message 实尾为准,写钩子吞重算失败是已记录状态;
is_send=None 保留=与统计同向保守)且有效类型(排除 unknown/divider 等),
剔除已删/拉黑客户,通话豁免(该客户 phone 任一晚于最后消息的通话=已回应,
不限定通话员工——客户被回应过即可,spec:尾部=max(最后消息,最后通话))。
无 customer 行的 peer(voice-only)保留在清单——豁免不可判定,保守方向
是有意为之。
total 口径:候选 ≤500 时决赛圈全过滤后分页(total 精确);>500 时按候选
分页(total=候选数,注释口径——决赛圈过滤可能让个别页少几行)。
total 口径:候选 ≤ _FINALIZE_ALL_MAX 时决赛圈全过滤后分页(total 精确);
超过则按候选分页(total=候选数,注释口径——决赛圈过滤可能让个别页少几行)。
"""
now = _now_ms()
cutoff = now - hours * 3600 * 1000
@@ -542,7 +549,7 @@ async def unreplied(
).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:
if last_msg is None or last_msg.is_send or last_msg.type not in _VALID_TAIL_TYPES:
return None
cust = await session.get(Customer, peer)
if cust is not None and (
@@ -575,8 +582,11 @@ async def unreplied(
}
async def _finalize(rows: list[Any]) -> list[dict]:
# 顺序逐个 await——AsyncSession 不能并发执行查询(禁 asyncio.gather);
# 每页 ≤100 个点查、每个毫秒级,顺序总耗时百毫秒级可接受
# 顺序逐个 await——AsyncSession 不能并发执行查询(禁 asyncio.gather)。
# 真预算:≤_FINALIZE_ALL_MAX 分支=全部候选点查(≤500 候选 ×~3 查询
# ≈1500 顺序往返,毫秒级/个);>500 分支=仅本页 ≤100 候选。豁免查询
# phone_call.call_number 无索引(3k 行小表扫;量大时批量化 IN 或补
# 索引——后续优化不在本次)
out = []
for o, p, t in rows:
r = await _finalist(o, p, t)
@@ -584,7 +594,7 @@ async def unreplied(
out.append(r)
return out
if len(cand) <= 500:
if len(cand) <= _FINALIZE_ALL_MAX:
items = await _finalize(cand)
# keep 候选顺序(ts asc, peer asc)——顺序循环天然保序
total = len(items)