"""stat_daily_staff(员工×日)维护层——rollup-of-rollup,不挂写钩子。 刷新策略(spec §3.1):4h 调度刷当日+滚动 3 天(staff_summary.rolling_days), 夜间 full_sync 全量自愈(recompute_all),部署回填 CLI 同款。空表时读端点 回退直接聚合 msg_daily_stats(毫秒级)——部署即用,沿用 sessions 的 has_rows 回退模式。 """ from __future__ import annotations import datetime as dt import logging from collections.abc import Iterable from zoneinfo import ZoneInfo import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import MsgDailyStats, StatDailyStaff, WechatAccount, WhatsappAccount logger = logging.getLogger(__name__) _SH = ZoneInfo("Asia/Shanghai") async def has_rows(session: AsyncSession) -> bool: """汇总表是否已有数据(读端点空表守卫;表不存在与空表同视)。""" try: return bool( ( await session.execute( sa.select(sa.func.count()).select_from(StatDailyStaff) ) ).scalar_one() ) except (sa.exc.OperationalError, sa.exc.ProgrammingError): return False async def account_map(session: AsyncSession, channel: str) -> dict[str, str]: """owner_account_id → user_id(按渠道选账号表;映射不上的不在 dict)。""" if channel == "whatsapp": stmt = sa.select(WhatsappAccount.uid, WhatsappAccount.user_id).where( WhatsappAccount.user_id.is_not(None) ) else: stmt = sa.select(WechatAccount.wx_id, WechatAccount.user_id).where( WechatAccount.user_id.is_not(None) ) return {owner: uid for owner, uid in (await session.execute(stmt)).all() if uid} def rolling_days(today: int, window: int = 3) -> list[int]: """当日 + 前 window-1 天的 day 键(4h job 的滚动刷新窗)。""" d = _day_from_key(today) return [_key_from_day(d - dt.timedelta(days=i)) for i in range(window - 1, -1, -1)] def _day_from_key(day: int) -> dt.date: return dt.date(day // 10000, (day // 100) % 100, day % 100) def _key_from_day(d: dt.date) -> int: return d.year * 10000 + d.month * 100 + d.day async def recompute_days( session: AsyncSession, days: Iterable[int], channels: tuple[str, ...] = ("wechat", "whatsapp") ) -> int: """重算给定 day×channel 的员工行:删旧插新,幂等。 返回**有数据行的 day×channel 组合数**(空渠道不计——与测试断言的 「天数」语义一致,seed 只有 wechat 日行时返回 1)。 单日日行仅数百~两千,拉回 Python 侧按 user_id 聚合——COUNT(DISTINCT peer) 必须在「账号合并到员工」之后算(同人多账号 distinct peer 跨账号 去重),写 SQL 反而绕且踩 only_full_group_by。 """ n = 0 for day in sorted(set(days)): for channel in channels: amap = await account_map(session, channel) rows = ( await session.execute( sa.select( MsgDailyStats.owner_account_id, MsgDailyStats.peer_kind, MsgDailyStats.peer_id, MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt, ).where( MsgDailyStats.day == day, MsgDailyStats.channel == channel ) ) ).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 原值(读侧标注) a = agg.setdefault( uid, {"sent": 0, "recv": 0, "friends": set(), "rooms": set()} ) a["sent"] += int(s or 0) a["recv"] += int(r or 0) (a["friends"] if kind == "friend" else a["rooms"]).add(peer) for uid, a in sorted(agg.items()): session.add( StatDailyStaff( day=day, channel=channel, user_id=uid, msg_sent=a["sent"], msg_recv=a["recv"], active_friends=len(a["friends"]), active_rooms=len(a["rooms"]), ) ) n += 1 return n async def recompute_all(session: AsyncSession) -> int: """全量重建(夜间自愈 / 部署回填,幂等)。""" days = ( await session.execute(sa.select(MsgDailyStats.day).distinct()) ).scalars().all() return await recompute_days(session, list(days))