"""GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细。 **双路径(2026-09-03 全时段窗 6.9s 超时修复)**:``msg_daily_stats`` 有行时 读日粒度汇总(窗口对齐到自然日,毫秒级);空表回退旧实时聚合,行为不变。 窗口语义随之统一为「上海自然日」(旧 SQL 日期函数跟随 DB 会话时区,UTC 容器下与今日数口径割裂——顺带修复)。 数据全本地:按 员工微信 wxid × ms-epoch 日期窗 聚合 1:1 私聊(room_id 过滤), 不访问云客。5 汇总数从 customers 在 Python 侧派生(spec §5)。 """ from __future__ import annotations import time from collections import namedtuple from typing import Annotated 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 StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary from app.db.models import Customer, CustomerAccountLink, Message, MsgDailyStats from app.storage.summary import day_key, has_rows as _summary_has_rows router = APIRouter(prefix="/api", tags=["staff-chat-stats"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] _MS_PER_DAY = 86_400_000 def _private_window(msg, owner: str, start: int, end: int): """1:1 私聊 + 窗口过滤条件(channel 固定 wechat),排除 timestamp IS NULL。""" return [ msg.channel == "wechat", msg.owner_account_id == owner, sa.or_(msg.room_id.is_(None), msg.room_id == ""), msg.timestamp.is_not(None), msg.timestamp >= start, msg.timestamp <= end, ] async def _aggregate(session: AsyncSession, owner: str, start: int, end: int) -> tuple[list, dict[str, bool | None]]: """返回 [(talker_id, send, recv, interaction_days, last_time)] + last_is_send map。""" # 跨库日期提取:从 ms-epoch 取日期。镜像 repos.py 方言检测。 dialect = session.bind.name # 镜像 app/storage/repos.py(.name 等价 .dialect.name) if dialect == "sqlite": day_expr = sa.func.date(Message.timestamp / 1000, "unixepoch") else: # mysql(生产) day_expr = sa.func.date(sa.func.from_unixtime(Message.timestamp / 1000)) base = _private_window(Message, owner, start, end) agg_stmt = ( sa.select( Message.talker_id, sa.func.sum(sa.case((Message.is_send.is_(True), 1), else_=0)).label("send_count"), sa.func.sum(sa.case((Message.is_send.is_(False), 1), else_=0)).label("receive_count"), sa.func.count(sa.func.distinct(day_expr)).label("interaction_days"), sa.func.max(Message.timestamp).label("last_time"), ) .where(*base) .group_by(Message.talker_id) .order_by(Message.talker_id) ) agg_rows = (await session.execute(agg_stmt)).all() # 每客户最后一条消息方向:ROW_NUMBER 窗口取 rn=1(tie-break msg_id 保证确定性) inner = ( sa.select( Message.talker_id, Message.is_send, sa.func.row_number() .over( partition_by=Message.talker_id, order_by=(Message.timestamp.desc(), Message.msg_id.desc()), ) .label("rn"), ) .where(*base) ) sub = inner.subquery("last_msg") last_stmt = sa.select(sub.c.talker_id, sub.c.is_send).where(sub.c.rn == 1) last_map = {tid: is_send for tid, is_send in (await session.execute(last_stmt)).all()} return agg_rows, last_map # ── 汇总路径:msg_daily_stats 日行(namedtuple 与旧 Row 同形)────────────── # _AggRow = namedtuple( "_AggRow", ["talker_id", "send_count", "receive_count", "interaction_days", "last_time"] ) async def _aggregate_summary( session: AsyncSession, owner: str, start: int, end: int ) -> tuple[list, dict[str, bool | None]]: """窗口 → [day_key(start), day_key(end)] 自然日区间,按日行 Python 聚合。 interaction_days = 窗内有消息的自然日数;last_time/last_is_send 取窗内 最后一条(last_ts 最大的日行)。窗口对齐自然日是文档化的语义变化 (前端日期选择器本就按天取值)。 """ rows = ( await session.execute( sa.select( MsgDailyStats.peer_id, MsgDailyStats.day, MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt, MsgDailyStats.last_ts, MsgDailyStats.last_is_send, ).where( MsgDailyStats.channel == "wechat", MsgDailyStats.owner_account_id == owner, MsgDailyStats.peer_kind == "friend", MsgDailyStats.day >= day_key(start), MsgDailyStats.day <= day_key(end), ) ) ).all() per: dict[str, dict] = {} for peer, _day, send, recv, last_ts, last_is in rows: acc = per.setdefault( peer, {"send": 0, "recv": 0, "days": 0, "last_ts": None, "last_is": None} ) acc["send"] += int(send or 0) acc["recv"] += int(recv or 0) acc["days"] += 1 if acc["last_ts"] is None or (last_ts is not None and last_ts >= acc["last_ts"]): acc["last_ts"] = last_ts acc["last_is"] = last_is agg_rows = [ _AggRow(talker_id=p, send_count=a["send"], receive_count=a["recv"], interaction_days=a["days"], last_time=a["last_ts"]) for p, a in per.items() ] last_map = {p: a["last_is"] for p, a in per.items()} return agg_rows, last_map async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, tuple[str, str, str]]: """客户名/头像解析:优先 customer_account_link per-owner 快照,档案兜底。 共享客户的档案行(customer 全局单行,last-wins)可能挂在别的员工名下, 按 owner 查档案会查空、明细列表回退显示 wxid(2026-09-09,同 sessions ._meta 修复)。 """ if not talker_ids: return {} info: dict[str, tuple[str, str, str]] = {} link_stmt = sa.select( CustomerAccountLink.friend_id, CustomerAccountLink.friend_remark, CustomerAccountLink.friend_nickname, CustomerAccountLink.real_name, CustomerAccountLink.head_image_url, ).where( CustomerAccountLink.channel == "wechat", CustomerAccountLink.owner_account_id == owner, CustomerAccountLink.friend_id.in_(talker_ids), ) for fid, remark, nick, real, head in (await session.execute(link_stmt)).all(): info[fid] = (remark or nick or real or fid, real or "", head or "") missing = [t for t in talker_ids if t not in info] if missing: stmt = sa.select( Customer.friend_id, Customer.friend_remark, Customer.friend_nickname, Customer.real_name, Customer.head_image_url, ).where( Customer.channel == "wechat", Customer.owner_account_id == owner, Customer.friend_id.in_(missing), ) for fid, remark, nick, real, head in (await session.execute(stmt)).all(): info[fid] = (remark or nick or real or fid, real or "", head or "") return info @router.get("/staff-chat-stats") async def get_staff_chat_stats( session: SessionDep, owner_account_id: Annotated[str, Query(min_length=1)], start: Annotated[int | None, Query(ge=0)] = None, end: Annotated[int | None, Query(ge=0)] = None, ) -> StaffChatStatsOut: """微信员工沟通统计:5 数字 + 客户明细(全本地 message 聚合,1:1 私聊)。""" if end is None: end = int(time.time() * 1000) if start is None: start = end - 3 * _MS_PER_DAY # 默认近 3 天 if await _summary_has_rows(session): agg_rows, last_map = await _aggregate_summary(session, owner_account_id, start, end) else: agg_rows, last_map = await _aggregate(session, owner_account_id, start, end) talker_ids = [r.talker_id for r in agg_rows] names = await _customer_names(session, owner_account_id, talker_ids) customers: list[StaffChatCustomerOut] = [] for r in agg_rows: tid = r.talker_id last_is_send = last_map.get(tid) # bool | None(None=最后一条方向未知) customers.append( StaffChatCustomerOut( talker_id=tid, friend_name=(names.get(tid) or (tid, "", ""))[0], real_name=(names.get(tid) or ("", "", ""))[1] or None, head_image_url=(names.get(tid) or ("", "", ""))[2] or None, send_count=int(r.send_count or 0), receive_count=int(r.receive_count or 0), interaction_days=int(r.interaction_days or 0), # True→1, False→0, None→None(方向未知不计入两率;summary 用 ==1/==0 自动排除 None) last_is_send=int(last_is_send) if last_is_send is not None else None, last_time=r.last_time, ) ) summary = StaffChatStatsSummary( chat_count=len(customers), effective_count=sum(1 for c in customers if c.send_count >= 1 and c.receive_count >= 1), follow_count=sum(1 for c in customers if c.interaction_days >= 2), last_reply_count=sum(1 for c in customers if c.last_is_send == 1), no_reply_count=sum(1 for c in customers if c.last_is_send == 0), ) return StaffChatStatsOut( owner_account_id=owner_account_id, start=start, end=end, summary=summary, customers=customers, )