Files
yunke/yunke-server/app/api/routers/sessions.py
T
浅念 b289aa2650 fix(api): sessions is_del 越界422校验+省略不过滤显式契约测试
is_del 加 Query(ge=0, le=1)——越界值(如2)422 拒绝而非静默空列表;
test_sessions_is_del_filter 追加第三个请求:不带 is_del 时全部 4 个
talker 都返回(f1/f2/filehelper/gh0st),补上省略=完全不过滤的显式契约。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 15:57:59 +08:00

127 lines
5.7 KiB
Python

"""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)