Files
yunke/yunke-server/app/api/routers/staff_stats.py
T
浅念 99773f69b9 fix(staff-stats): 统计tab新客户/删除客户/红包昵称切 link——共享好友不再漏计
全项目同型审计结论的收尾项:统计分析 tab 按 owner 查全局单行档案表,
共享好友(档案挂别的员工名下)不计入——

- _new_customers(新客户):主体 Customer → CustomerAccountLink(add_time
  窗+首条消息兜底 JOIN 平移到 link.owner);数字变大=对齐云客按员工口径
- _del_customers(删除客户):_flag_customers 参数化 model,del 走 link
  (delete_flag per-relation);blocked 保留档案口径(is_block 未镜像进
  link,已知偏差=他人员工名下拉黑不计,注释标明)
- _red_transfer 昵称:link 优先档案兜底(同 sessions._meta 模式)

审计同时确认无需修:群侧(chat_group 复合主键天然多对多)、搜索/长语音
(按 friend 全局直查)、analytics(全局口径)、WA(与微信共用同批修复,
12,578 行 link 已回填,1,967 共享客户在册;无昵称行=WA 联系人本身没存
名字,与云客一致显示号码)。

644 测试绿(新增共享好友新客户判别测试;存量测试种子补 link 行镜像
生产写路径)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 13:45:42 +08:00

482 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""GET /api/staff-stats — 员工聊天窗口 7 类计数 + 分型明细(本地聚合,spec §4.5)。
对齐云客员工聊天窗口顶部 7 个统计数字与其点击弹出的分型明细列表:
* 计数模式(无 ``kind``)→ ``{new_customers, new_groups, red_transfer,
del_customers, blocked_customers, voice_calls, sensitive_words}``
(``sensitive_words`` 恒 0——敏感词占位,spec 决策)。
* 明细模式(``?kind=``)→ ``{kind, rows}``,每 kind 字段不同(统一
``list[dict]``,前端按 kind 配置列)。
统计口径(spec §4.5,全部本地聚合,逐条实测验证):
* new_customers ``customer.add_time`` 窗内;``add_time IS NULL`` 的行用
「该对话首条消息时间」兜底(message 同 owner+talker 的
``min(timestamp)`` 在窗内即算,~38% 行靠兜底)。
* new_groups ``chat_group.create_time`` 窗内(Task 3 新列,
raw_json createTime 回填);无值行不计。
* red_transfer wechat 消息 type IN (red_packet, transfer) 且
timestamp 窗内(Task 4 已回填类型);金额从 text
红包 XML ``<feedesc>`` / 转账纯文本「发送转账:¥N」提取。
* del_customers ``delete_flag=1`` 现存量(无时间字段→忽略窗口;
明细行 time 用 updated_at 毫秒)。
* blocked_customers ``is_block=1`` 现存量(同上)。
* voice_calls ``phone_call.user_id`` 存的是员工 userId(32位HEX)而非
wxId(实测坑!)——必须先经 ``wechat_account.user_id``
换算再查,直查 wxId 恒 0。
* sensitive_words 恒 0(占位)。
start/end 缺省 = 近 7 天(对齐云客统计默认窗)。结构镜像
:mod:`app.api.routers.staff_chat_stats`(SessionDep + 每类一个私有
聚合函数 + 主端点分派)。
"""
from __future__ import annotations
import re
import time
from typing import Annotated, Any, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.api.schemas import StaffStatsCountsOut, StaffStatsDetailOut
from app.db.models import (
ChatGroup,
Customer,
CustomerAccountLink,
Message,
PhoneCall,
WechatAccount,
)
router = APIRouter(prefix="/api", tags=["staff-stats"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
_MS_PER_DAY = 86_400_000
KindParam = Literal[
"new_customers",
"new_groups",
"red_transfer",
"del_customers",
"blocked_customers",
"voice_calls",
]
# 红包/转账金额提取(spec §4.5 red_transfer):
# 1) 红包 XML:``<wcpayinfo><feedesc>¥88.00</feedesc>``(feedesc 内货币符号非数字)
# 2) 云客转账纯文本:``发送转账:¥100.00``
# 3) 转账 XML ``<totalamount>100.00</totalamount>`` 兜底
# 金额可带千分位逗号(``¥1,000.00``)→ 捕获组为 [\d.,]+,float() 前剔逗号。
_AMOUNT_FEEDESC_RE = re.compile(r"<feedesc>[^0-9]*([\d.,]+)")
_AMOUNT_PLAIN_RE = re.compile(r"转账[::]?\s*[¥¥]\s*([\d.,]+)")
_AMOUNT_TOTAL_RE = re.compile(r"<totalamount>\s*([\d.,]+)")
def _extract_amount(text: str | None) -> float | None:
"""从消息 text 提取金额(元);双格式,失败 None。"""
if not text:
return None
m = (
_AMOUNT_FEEDESC_RE.search(text)
or _AMOUNT_PLAIN_RE.search(text)
or _AMOUNT_TOTAL_RE.search(text)
)
if not m:
return None
try:
return float(m.group(1).replace(",", ""))
except ValueError: # pragma: no cover — 正则已限定 [\d.,]+,防御畸形串
return None
def _dt_ms(value: Any) -> int | None:
"""DateTime 列 → ms epoch(naive,按本地时区解释;仅作展示兜底)。"""
if value is None:
return None
return int(value.timestamp() * 1000)
async def _new_customers(
session: AsyncSession, wxid: str, start: int, end: int
) -> tuple[int, list[dict[str, Any]]]:
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。
读 ``customer_account_link``(2026-09-09):customer 档案全局单行、
归属 last-wins,共享客户在其他员工名下会漏计;link 每账号一行,与
云客按员工统计的口径一致(数字会比档案口径大)。
无 ``detail`` 分支——计数模式有意复用明细物化(窗内新增客户通常
几十行,``len()`` 即可;COUNT 需 direct+fallback 双查询,不值)。
"""
base = [
CustomerAccountLink.channel == "wechat",
CustomerAccountLink.owner_account_id == wxid,
]
# 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp)
# (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。
fallback = (
sa.select(
CustomerAccountLink.friend_id,
CustomerAccountLink.friend_nickname,
CustomerAccountLink.friend_remark,
CustomerAccountLink.head_image_url,
sa.func.min(Message.timestamp).label("first_ts"),
)
.outerjoin(
Message,
sa.and_(
Message.channel == "wechat",
Message.owner_account_id == CustomerAccountLink.owner_account_id,
Message.talker_id == CustomerAccountLink.friend_id,
Message.timestamp.is_not(None),
),
)
.where(*base, CustomerAccountLink.add_time.is_(None))
# GROUP BY 显式列出全部裸选列:friend_id 是 link 复合 PK 的尾列,
# (owner,friend) 已由 WHERE 钉死,其余列函数依赖于它→分组结果不变;
# MySQL ONLY_FULL_GROUP_BY 部署安全(SQLite 同样兼容)。
.group_by(
CustomerAccountLink.friend_id,
CustomerAccountLink.friend_nickname,
CustomerAccountLink.friend_remark,
CustomerAccountLink.head_image_url,
)
.having(sa.func.min(Message.timestamp) >= start)
.having(sa.func.min(Message.timestamp) <= end)
)
# 直接口径:add_time 本身在窗内。
direct = sa.select(
CustomerAccountLink.friend_id,
CustomerAccountLink.friend_nickname,
CustomerAccountLink.friend_remark,
CustomerAccountLink.head_image_url,
CustomerAccountLink.add_time,
).where(*base, CustomerAccountLink.add_time >= start, CustomerAccountLink.add_time <= end)
rows: list[dict[str, Any]] = [
{
"talker_id": fid,
"nickname": nick,
"avatar": head or None,
"remark": remark,
"time": add_time,
}
for fid, nick, remark, head, add_time in (
await session.execute(direct)
).all()
]
rows += [
{
"talker_id": fid,
"nickname": nick,
"avatar": head or None,
"remark": remark,
"time": first_ts,
}
for fid, nick, remark, head, first_ts in (
await session.execute(fallback)
).all()
]
rows.sort(key=lambda r: r["time"] or 0, reverse=True)
return len(rows), rows
async def _new_groups(
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
) -> tuple[int, list[dict[str, Any]]]:
"""新群:chat_group.create_time 窗内(每员工一行,owner 过滤)。"""
conds = [
ChatGroup.channel == "wechat",
ChatGroup.owner_account_id == wxid,
ChatGroup.create_time >= start,
ChatGroup.create_time <= end,
]
count = (
await session.execute(
sa.select(sa.func.count()).select_from(ChatGroup).where(*conds)
)
).scalar_one()
if not detail:
return count, []
rows = (
await session.execute(
sa.select(ChatGroup.room_id, ChatGroup.room_name, ChatGroup.create_time)
.where(*conds)
.order_by(ChatGroup.create_time.desc(), ChatGroup.room_id.asc())
)
).all()
return count, [
{"room_id": rid, "room_name": name, "time": ts} for rid, name, ts in rows
]
async def _red_transfer(
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
) -> tuple[int, list[dict[str, Any]]]:
"""红包+转账:wechat 消息 type IN (red_packet, transfer) 且窗内。"""
conds = [
Message.channel == "wechat",
Message.owner_account_id == wxid,
Message.type.in_(["red_packet", "transfer"]),
Message.timestamp >= start,
Message.timestamp <= end,
]
count = (
await session.execute(
sa.select(sa.func.count()).select_from(Message).where(*conds)
)
).scalar_one()
if not detail:
return count, []
msgs = (
await session.execute(
sa.select(
Message.msg_id,
Message.talker_id,
Message.room_id,
Message.type,
Message.text,
Message.timestamp,
Message.sender_name,
Message.raw_json,
)
.where(*conds)
.order_by(Message.timestamp.desc(), Message.msg_id.asc())
)
).all()
# nickname 优先 customer 昵称,回退 message raw_json.name(sender_name 即其
# 结构化列);群红包 talker 是发红包的成员,同口径查 customer。
talker_ids = [m.talker_id for m in msgs if m.talker_id]
nick_map: dict[str, str] = {}
if talker_ids:
# 昵称优先 link per-owner 快照(共享好友档案行可能挂别的员工名下,
# 2026-09-09 同 sessions._meta),档案兜底
nick_map = {
fid: nick
for fid, nick in (
await session.execute(
sa.select(
CustomerAccountLink.friend_id,
CustomerAccountLink.friend_nickname,
).where(
CustomerAccountLink.channel == "wechat",
CustomerAccountLink.owner_account_id == wxid,
CustomerAccountLink.friend_id.in_(talker_ids),
)
)
).all()
if nick
}
missing = [t for t in talker_ids if t not in nick_map]
if missing:
nick_map.update(
{
fid: nick
for fid, nick in (
await session.execute(
sa.select(
Customer.friend_id, Customer.friend_nickname
).where(
Customer.channel == "wechat",
Customer.owner_account_id == wxid,
Customer.friend_id.in_(missing),
)
)
).all()
if nick
}
)
rows: list[dict[str, Any]] = []
for m in msgs:
raw_name = (m.raw_json or {}).get("name") if m.raw_json else None
rows.append(
{
"op_type": "红包" if m.type == "red_packet" else "转账",
"talker_id": m.talker_id,
"room_id": m.room_id or None,
"nickname": nick_map.get(m.talker_id or "") or m.sender_name or raw_name,
"amount": _extract_amount(m.text),
"time": m.timestamp,
"msg_id": m.msg_id,
}
)
return count, rows
async def _flag_customers(
session: AsyncSession,
wxid: str,
flag_cond: sa.ColumnElement,
*,
detail: bool,
model: type,
) -> tuple[int, list[dict[str, Any]]]:
"""del_customers / blocked_customers 共用:现存量口径,忽略时间窗。
``model`` 由调用方决定(两者列名同形):del 走 ``CustomerAccountLink``
(delete_flag per-relation,共享好友不漏),blocked 走 ``Customer``
档案(is_block 未镜像进 link,2026-09-09)。
无时间字段→明细行 time 用 updated_at 毫秒(仅展示;update 触发即变)。
"""
conds = [model.channel == "wechat", model.owner_account_id == wxid, flag_cond]
count = (
await session.execute(
sa.select(sa.func.count()).select_from(model).where(*conds)
)
).scalar_one()
if not detail:
return count, []
rows = (
await session.execute(
sa.select(
model.friend_id,
model.friend_nickname,
model.friend_remark,
model.updated_at,
)
.where(*conds)
.order_by(model.updated_at.desc(), model.friend_id.asc())
)
).all()
return count, [
{
"talker_id": fid,
"nickname": nick or remark,
"time": _dt_ms(updated),
}
for fid, nick, remark, updated in rows
]
async def _del_customers(
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
) -> tuple[int, list[dict[str, Any]]]:
"""删除客户:delete_flag=1 现存量(start/end 按口径忽略)。
读 link(per-relation delete_flag,共享好友不漏计,2026-09-09)。
"""
return await _flag_customers(
session, wxid, CustomerAccountLink.delete_flag == 1,
detail=detail, model=CustomerAccountLink,
)
async def _blocked_customers(
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
) -> tuple[int, list[dict[str, Any]]]:
"""流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。
保持档案口径:is_block 未镜像进 link 表,切了会漏;已知偏差=共享好友
在其他员工名下的拉黑不计入(待 link 补列后切换)。
"""
return await _flag_customers(
session, wxid, Customer.is_block.is_(True), detail=detail, model=Customer
)
async def _voice_calls(
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
) -> tuple[int, list[dict[str, Any]]]:
"""语音通话:先 wechat_account.user_id 换算(防直查 wxId 恒 0)再查。
明细字段语义对齐 CallList.vue / PhoneCallOut:
``connected`` 按 voice_call 极性 call_status==1(与前端 callStatusLabel
同判据:1=已接通,0/2=未接通);``media_url`` 本地 /api/media/{hash}
优先、OSS record_file 兜底(与 playRecord 同顺序)。账号无 user_id
(未同步/未知员工)→ 无法归属,返回空而非全量(防跨员工串数)。
"""
uid = (
await session.execute(
sa.select(WechatAccount.user_id).where(WechatAccount.wx_id == wxid)
)
).scalar_one_or_none()
if not uid:
return 0, []
conds = [
PhoneCall.channel == "voice_call",
PhoneCall.user_id == uid,
PhoneCall.start_call_time >= start,
PhoneCall.start_call_time <= end,
]
count = (
await session.execute(
sa.select(sa.func.count()).select_from(PhoneCall).where(*conds)
)
).scalar_one()
if not detail:
return count, []
calls = (
await session.execute(
sa.select(PhoneCall)
.where(*conds)
.order_by(PhoneCall.start_call_time.desc(), PhoneCall.call_id.asc())
)
).scalars().all()
rows = [
{
"id": c.call_id,
"peer": c.call_number,
"incoming": c.incoming_call,
"connected": c.call_status == 1,
"duration": c.call_duration,
"start_time": c.start_call_time,
"media_url": (
f"/api/media/{c.record_media_hash}" if c.record_media_hash else c.record_file
),
}
for c in calls
]
return count, rows
_AGGREGATORS = {
"new_customers": _new_customers,
"new_groups": _new_groups,
"red_transfer": _red_transfer,
"del_customers": _del_customers,
"blocked_customers": _blocked_customers,
"voice_calls": _voice_calls,
}
@router.get("/staff-stats")
async def get_staff_stats(
session: SessionDep,
wechat_id: Annotated[str, Query(min_length=1)],
start: Annotated[int | None, Query(ge=0)] = None,
end: Annotated[int | None, Query(ge=0)] = None,
kind: Annotated[KindParam | None, Query()] = None,
) -> StaffStatsCountsOut | StaffStatsDetailOut:
"""员工 7 类计数(无 kind)或某一类的明细列表(?kind=)。
窗口 [start, end] 均为 ms epoch 闭区间;缺省 = 近 7 天(云客统计默认窗)。
del_customers / blocked_customers 是现存量口径,窗口仅约束其余 4 类。
"""
if end is None:
end = int(time.time() * 1000)
if start is None:
start = end - 7 * _MS_PER_DAY
if kind is None:
counts: dict[str, int] = {}
for kind_key, aggregate in _AGGREGATORS.items():
if kind_key == "new_customers": # 无 detail 参数(见其 docstring)
counts[kind_key], _ = await aggregate(session, wechat_id, start, end)
else:
counts[kind_key], _ = await aggregate(
session, wechat_id, start, end, detail=False
)
counts["sensitive_words"] = 0 # 占位,spec 决策
return StaffStatsCountsOut(**counts)
if kind == "new_customers": # 同上:无 detail 参数
_, rows = await _AGGREGATORS[kind](session, wechat_id, start, end)
else:
_, rows = await _AGGREGATORS[kind](
session, wechat_id, start, end, detail=True
)
return StaffStatsDetailOut(kind=kind, rows=rows)