"""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]): """查 customer/chat_group 表合并昵称/头像;好友行额外带 delete_flag(is_del 过滤用)。 friends 值为 ``(name, avatar, delete_flag)``;群无删除标记,groups 值仍为 ``(name, avatar)``。 """ friends: dict[str, tuple[str, str | None, int | None]] = {} if friend_ids: for fid, remark, nick, real, avatar, delete_flag in (await session.execute( sa.select( Customer.friend_id, Customer.friend_remark, Customer.friend_nickname, Customer.real_name, Customer.head_image_url, Customer.delete_flag, ) .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, delete_flag) 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", is_del: Annotated[int | None, Query(ge=0, le=1)] = None, ) -> SessionListOut: """账号会话列表:好友(talker_id,room_id 空)+ 群(room_id),按 last_chat_time desc。 ``is_del`` 可选过滤(对齐云客员工聊天窗口「删除好友」开关):按 customer.delete_flag 在 Python 侧过滤——0=未删(NULL 视为 0),1=已删;越界值(非 0/1)422 拒绝而非 静默空列表;无 customer 行的 talker(如 filehelper 等系统账号)视作未删保留; 群会话不受该过滤影响。不传时行为不变。 ``pinned``:filehelper 会话置顶标记(对齐云客置顶区),其余好友/群恒 False。 """ 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, delete_flag = fnames.get(r.talker_id, (r.talker_id, None, None)) if is_del is not None and (delete_flag or 0) != is_del: continue sessions.append(SessionOut(type="friend", talker_id=r.talker_id, name=name, avatar=avatar, pinned=(r.talker_id == "filehelper"), 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)