"""GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间),全本地 message 聚合。 镜像 staff_chat_stats 聚合模式(GROUP BY + case 计数 + Python 侧合并昵称/头像)。 today_start 钉死 Asia/Shanghai(UTC+8)当日 0 点,不依赖服务器时区。 """ from __future__ import annotations from datetime import datetime from typing import Annotated from zoneinfo import ZoneInfo 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 SessionListOut, SessionOut from app.db.models import ChatGroup, Customer, Message router = APIRouter(prefix="/api", tags=["sessions"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] _TZ = ZoneInfo("Asia/Shanghai") def _today_start_ms() -> int: """Asia/Shanghai 当日 0 点 ms-epoch(部署无关、测试确定性)。""" midnight = datetime.now(_TZ).replace(hour=0, minute=0, second=0, microsecond=0) return int(midnight.timestamp() * 1000) def _base(msg, owner: str, channel: str): return [ msg.channel == channel, msg.owner_account_id == owner, msg.timestamp.is_not(None), ] async def _friend_rows(session: AsyncSession, owner: str, channel: str, today_start: int): stmt = ( sa.select( Message.talker_id, sa.func.max(Message.timestamp).label("last_chat_time"), sa.func.sum(sa.case((Message.timestamp >= today_start, 1), else_=0)).label("today_count"), ) .where(*_base(Message, owner, channel), sa.or_(Message.room_id.is_(None), Message.room_id == ""), Message.talker_id.is_not(None)) .group_by(Message.talker_id) ) return (await session.execute(stmt)).all() async def _group_rows(session: AsyncSession, owner: str, channel: str, today_start: int): stmt = ( sa.select( Message.room_id, sa.func.max(Message.timestamp).label("last_chat_time"), sa.func.sum(sa.case((Message.timestamp >= today_start, 1), else_=0)).label("today_count"), ) .where(*_base(Message, owner, channel), Message.room_id.is_not(None), Message.room_id != "") .group_by(Message.room_id) ) return (await session.execute(stmt)).all() async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]): friends: dict[str, tuple[str, str | None]] = {} if friend_ids: for fid, remark, nick, real, avatar in (await session.execute( sa.select(Customer.friend_id, Customer.friend_remark, Customer.friend_nickname, Customer.real_name, Customer.head_image_url) .where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(friend_ids)) )).all(): friends[fid] = (remark or nick or real or fid, avatar) groups: dict[str, tuple[str, str | None]] = {} if room_ids: for rid, name, avatar in (await session.execute( sa.select(ChatGroup.room_id, ChatGroup.room_name, ChatGroup.head_image_url) .where(ChatGroup.channel == channel, ChatGroup.owner_account_id == owner, ChatGroup.room_id.in_(room_ids)) )).all(): groups[rid] = (name or rid, avatar) return friends, groups @router.get("/sessions") async def list_sessions( session: SessionDep, owner_account_id: Annotated[str, Query(min_length=1)], channel: Annotated[str, Query()] = "wechat", ) -> SessionListOut: """账号会话列表:好友(talker_id,room_id 空)+ 群(room_id),按 last_chat_time desc。""" today_start = _today_start_ms() f_rows = await _friend_rows(session, owner_account_id, channel, today_start) g_rows = await _group_rows(session, owner_account_id, channel, today_start) fnames, gnames = await _meta( session, owner_account_id, channel, [r.talker_id for r in f_rows], [r.room_id for r in g_rows] ) sessions: list[SessionOut] = [] for r in f_rows: name, avatar = fnames.get(r.talker_id, (r.talker_id, None)) sessions.append(SessionOut(type="friend", talker_id=r.talker_id, name=name, avatar=avatar, today_count=int(r.today_count or 0), last_chat_time=r.last_chat_time)) for r in g_rows: name, avatar = gnames.get(r.room_id, (r.room_id, None)) sessions.append(SessionOut(type="group", room_id=r.room_id, name=name, avatar=avatar, today_count=int(r.today_count or 0), last_chat_time=r.last_chat_time)) sessions.sort(key=lambda s: (s.last_chat_time is None, -(s.last_chat_time or 0))) return SessionListOut(owner_account_id=owner_account_id, channel=channel, today_start=today_start, sessions=sessions)