From a418830a905252c577d3a7d38afb30e17b0b6e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sat, 8 Aug 2026 14:58:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20/api/sessions=20=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8(=E5=A5=BD=E5=8F=8B+?= =?UTF-8?q?=E7=BE=A4,=E4=BB=8A=E6=97=A5=E6=B6=88=E6=81=AF=E6=95=B0+?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E8=81=94=E7=B3=BB=E6=97=B6=E9=97=B4,?= =?UTF-8?q?=E5=85=A8=E6=9C=AC=E5=9C=B0=E8=81=9A=E5=90=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- yunke-server/app/api/main.py | 2 + yunke-server/app/api/routers/sessions.py | 107 +++++++++++++++++++++++ yunke-server/app/api/schemas.py | 21 ++++- yunke-server/tests/test_api_sessions.py | 61 +++++++++++++ 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 yunke-server/app/api/routers/sessions.py create mode 100644 yunke-server/tests/test_api_sessions.py diff --git a/yunke-server/app/api/main.py b/yunke-server/app/api/main.py index 10055c3..2d028d5 100644 --- a/yunke-server/app/api/main.py +++ b/yunke-server/app/api/main.py @@ -45,6 +45,7 @@ from app.api.routers import ( messages, organization, search, + sessions, sms, staff_chat_stats, sync, @@ -69,6 +70,7 @@ app.include_router(sms.router) app.include_router(staff_chat_stats.router) app.include_router(sync.router) app.include_router(search.router) +app.include_router(sessions.router) app.include_router(export.router) diff --git a/yunke-server/app/api/routers/sessions.py b/yunke-server/app/api/routers/sessions.py new file mode 100644 index 0000000..5ee24c5 --- /dev/null +++ b/yunke-server/app/api/routers/sessions.py @@ -0,0 +1,107 @@ +"""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) diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index bd84f53..961cc76 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations import datetime as dt -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Literal, TypeVar from pydantic import BaseModel, ConfigDict @@ -325,3 +325,22 @@ class StaffChatStatsOut(BaseModel): end: int # ms-epoch summary: StaffChatStatsSummary customers: list[StaffChatCustomerOut] + + +class SessionOut(BaseModel): + """账号会话列表的一行(好友或群)。""" + + type: Literal["friend", "group"] + talker_id: str | None = None + room_id: str | None = None + name: str | None = None + avatar: str | None = None + today_count: int + last_chat_time: int | None = None + + +class SessionListOut(BaseModel): + owner_account_id: str + channel: str + today_start: int + sessions: list[SessionOut] diff --git a/yunke-server/tests/test_api_sessions.py b/yunke-server/tests/test_api_sessions.py new file mode 100644 index 0000000..52c53d8 --- /dev/null +++ b/yunke-server/tests/test_api_sessions.py @@ -0,0 +1,61 @@ +"""Tests for GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间)。""" +from __future__ import annotations + +import time + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ChatGroup, Customer, Message + +OWNER = "wx_owner_s" +CH = "wechat" +_NOW = int(time.time() * 1000) +_MS_DAY = 86_400_000 + + +async def _seed(session: AsyncSession) -> None: + session.add_all([ + Message(msg_id="t1a", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="x"), + Message(msg_id="t1b", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=False, timestamp=_NOW - 2 * _MS_DAY, type="text", type_raw=1, text="y"), + Message(msg_id="t2a", channel=CH, owner_account_id=OWNER, talker_id="t2", room_id=None, is_send=True, timestamp=_NOW - 5 * _MS_DAY, type="text", type_raw=1, text="old"), + Message(msg_id="g1a", channel=CH, owner_account_id=OWNER, talker_id="member_x", room_id="room1@chatroom", is_send=False, timestamp=_NOW, type="text", type_raw=1, text="g"), + Message(msg_id="x1", channel=CH, owner_account_id="wx_other", talker_id="t1", room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="other"), + Customer(friend_id="t1", channel=CH, owner_account_id=OWNER, friend_nickname="Alice", friend_remark=None), + ChatGroup(room_id="room1@chatroom", channel=CH, owner_account_id=OWNER, room_name="群一号"), + ]) + await session.commit() + + +async def test_sessions_friend_and_group(make_client): + client = await make_client(_seed) + r = await client.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH}) + assert r.status_code == 200 + body = r.json() + assert body["owner_account_id"] == OWNER + by = {(s["type"], s.get("talker_id") or s.get("room_id")): s for s in body["sessions"]} + assert ("friend", "t1") in by and ("friend", "t2") in by and ("group", "room1@chatroom") in by + assert len(body["sessions"]) == 3 + t1 = by[("friend", "t1")] + assert t1["today_count"] == 1 + assert t1["last_chat_time"] == _NOW + assert t1["name"] == "Alice" + assert by[("friend", "t2")]["today_count"] == 0 + assert by[("friend", "t2")]["name"] == "t2" + g = by[("group", "room1@chatroom")] + assert g["name"] == "群一号" and g["today_count"] == 1 + + +async def test_sessions_sorted_by_last_chat_time_desc(make_client): + client = await make_client(_seed) + body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json() + last_times = [s["last_chat_time"] for s in body["sessions"]] + assert last_times == sorted(last_times, reverse=True) + + +async def test_sessions_empty(make_client): + async def _empty(s: AsyncSession): + await s.commit() + client = await make_client(_empty) + r = await client.get("/api/sessions", params={"owner_account_id": OWNER}) + assert r.status_code == 200 + assert r.json()["sessions"] == []