99773f69b9
全项目同型审计结论的收尾项:统计分析 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>
482 lines
17 KiB
Python
482 lines
17 KiB
Python
"""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)
|