feat(api): /api/sessions 账号会话列表(好友+群,今日消息数+最近联系时间,全本地聚合)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-08 14:58:23 +08:00
parent 4cb6cf50c4
commit a418830a90
4 changed files with 190 additions and 1 deletions
+2
View File
@@ -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)
+107
View File
@@ -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)
+20 -1
View File
@@ -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]
+61
View File
@@ -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"] == []