feat(chat): 完善聊天

This commit is contained in:
xingc
2025-04-16 14:44:52 +08:00
parent 02a1589833
commit e3f28ad67c
5 changed files with 110 additions and 79 deletions

View File

@@ -10,6 +10,7 @@ from typing import List, Dict
import redis.asyncio as aioredis import redis.asyncio as aioredis
from fastapi import WebSocket from fastapi import WebSocket
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.db.crud import CRUD from core.db.crud import CRUD
@@ -26,19 +27,20 @@ socket_manager = SocketManager()
async def send_new_message( async def send_new_message(
websocket: WebSocket, websocket: WebSocket,
db_session: AsyncSession, db_session: AsyncSession,
cache: aioredis.Redis,
chats: dict, chats: dict,
current_user: User, current_user: User,
incoming_message: schema.Message, incoming_message: schema.WSMessage,
): ):
chat_guid: str = str(incoming_message.chat_guid) chat_guid: str = str(incoming_message.chat_guid)
chat: Chat | None = await CRUD(db_session, Chat).get(filters={'chat_guid': chat_guid}) chat: Chat | None = await CRUD(db_session, Chat).get(filters={'guid': chat_guid})
# 判断是否为新聊天 # 判断是否为新聊天
if not chats or chat_guid not in chats: if not chats or chat_guid not in chats:
if chat: if chat:
chats[chat_guid] = chat.id chats[chat_guid] = chat.id
await socket_manager.add_user_to_chat(chat_guid, websocket) await socket_manager.add_user_to_chat(chat_guid, websocket)
else: else:
logger.warning(f'chat {chat_guid} not found')
await socket_manager.send_error('聊天尚未添加', websocket) await socket_manager.send_error('聊天尚未添加', websocket)
return return
@@ -49,64 +51,59 @@ async def send_new_message(
user_id=current_user.user_id, user_id=current_user.user_id,
chat_id=chat_id, chat_id=chat_id,
message_type=incoming_message.message_type, message_type=incoming_message.message_type,
content=incoming_message.body, content=incoming_message.content,
) )
db_session.add(message) db_session.add(message)
await db_session.flush()
chat.updated_at = datetime.now() chat.updated_at = datetime.now()
db_session.add(chat) db_session.add(chat)
await db_session.flush()
await db_session.commit() await db_session.commit()
await db_session.refresh(message, attribute_names=["user", "chat"]) # await db_session.refresh(message, attribute_names=["user", "chat"])
await db_session.refresh(chat, attribute_names=["users"]) # await db_session.refresh(chat, attribute_names=["users"])
except Exception as exc_info: except Exception as exc_info:
await db_session.rollback() await db_session.rollback()
logger.exception(f"[new_message] Exception, rolling back session, detail: {exc_info}") logger.exception(f"[new_message] Exception, rolling back session, detail: {exc_info}")
raise exc_info raise exc_info
send_message_schema = schema.MessageOut( send_message_schema = schema.WSMessageOut(
type='chat.new_message', type='chat.new_message',
message_id=message.id, message_id=message.id,
message_guid=message.guid, message_guid=message.guid,
message_type=message.message_type, message_type=message.message_type,
chat_guid=chat.guid, chat_guid=chat.guid,
user_id=current_user.user_id,
content=message.content, content=message.content,
created_at=message.created_at, friend_user=current_user.dict(),
friend_user=current_user, )
send_status_replay = schema.WSMessageOut(
type='chat.send_status_reply',
message_id=message.id,
message_guid=message.guid,
message_type=message.message_type,
chat_guid=chat.guid,
) )
await socket_manager.broadcast_to_chat(chat_guid, send_message_schema.dict())
await socket_manager.broadcast_to_chat(chat_guid, send_message_schema.model_dump_json())
@socket_manager.handler('chat.refresh_unread_messages') await websocket.send_json(send_status_replay.model_dump_json())
async def refresh_unread_messages(
websocket: WebSocket,
db_session: AsyncSession,
cache: aioredis.Redis,
current_user: User,
message: schema.Message,
):
pass
@socket_manager.handler('chat.message_read') @socket_manager.handler('chat.message_read')
async def read_message( async def read_message(
websocket: WebSocket, websocket: WebSocket,
db_session: AsyncSession, db_session: AsyncSession,
cache: aioredis.Redis,
current_user: User, current_user: User,
message: schema.Message, incoming_message: schema.WSMessage,
): ):
last_read_message_id: str | None = message.body last_read_message_id: str | None = incoming_message.content
if not last_read_message_id: if not last_read_message_id:
return return
db_crud = CRUD(db_session, ReadStatus) db_crud = CRUD(db_session, ReadStatus)
condition = { condition = {
'user_id': current_user.user_id, 'user_id': current_user.user_id,
'chat_guid': message.chat_guid, 'chat_guid': incoming_message.chat_guid,
} }
result = await db_crud.update(filters=condition, data={'last_read_message_id': last_read_message_id}) result = await db_crud.update(filters=condition, data={'last_read_message_id': last_read_message_id})
if result is None: if result is None:
@@ -117,3 +114,13 @@ async def read_message(
}, },
filters=condition filters=condition
) )
@socket_manager.handler('chat.ping')
async def ping(
websocket: WebSocket,
db_session: AsyncSession,
current_user: User,
incoming_message: schema.WSMessage,
):
await websocket.send_json({'type': 'chat.send_status_reply', 'status': 'ok'})

View File

@@ -10,6 +10,7 @@ import logging
import redis.asyncio as aioredis import redis.asyncio as aioredis
import ujson import ujson
from fastapi import WebSocket from fastapi import WebSocket
from fastapi.websockets import WebSocketState
from core.db.engine import get_cache from core.db.engine import get_cache
@@ -72,7 +73,7 @@ class SocketManager:
self.chats[chat_guid] = {websocket} self.chats[chat_guid] = {websocket}
await self.pubsub_client.connect() await self.pubsub_client.connect()
pubsub_subscriber = await self.pubsub_client.subscribe(chat_guid) pubsub_subscriber = await self.pubsub_client.subscribe(chat_guid)
await asyncio.create_task(self._pubsub_data_reader(pubsub_subscriber)) asyncio.create_task(self._pubsub_data_reader(pubsub_subscriber))
async def broadcast_to_chat(self, chat_guid: str, message: str | dict) -> None: async def broadcast_to_chat(self, chat_guid: str, message: str | dict) -> None:
if isinstance(message, dict): if isinstance(message, dict):
@@ -100,6 +101,8 @@ class SocketManager:
sockets = self.chats.get(chat_guid) sockets = self.chats.get(chat_guid)
if sockets: if sockets:
for socket in sockets: for socket in sockets:
if socket.client_state != WebSocketState.CONNECTED:
continue
data = message['data'] data = message['data']
await socket.send_text(data) await socket.send_text(data)
else: else:
@@ -109,7 +112,8 @@ class SocketManager:
logger.exception(f'Exception occurred: {exc}') logger.exception(f'Exception occurred: {exc}')
async def send_error(self, message: str, websocket: WebSocket): async def send_error(self, message: str, websocket: WebSocket):
await websocket.send_json({'status': 'error', 'message': message}) if websocket.client_state == WebSocketState.CONNECTED:
await websocket.send_json({'status': 'error', 'content': message})
async def quit(self): async def quit(self):
self._task_cancel = False self._task_cancel = False

View File

@@ -20,7 +20,7 @@ from core.db.crud import CRUD
from core.db.engine import get_async_session, get_cache from core.db.engine import get_async_session, get_cache
from core.db.models import Message, Chat, User, ReadStatus, chat_participant from core.db.models import Message, Chat, User, ReadStatus, chat_participant
from core.routers.auth.schema import LoginUser from core.routers.auth.schema import LoginUser
from core.routers.auth.services import get_current_user from core.routers.auth.services import get_current_user_form_http, get_current_user_form_ws
from core.base.exceptions import WebsocketTooManyRequests from core.base.exceptions import WebsocketTooManyRequests
from . import schema from . import schema
@@ -41,7 +41,7 @@ chat_router = APIRouter(prefix='/chat', tags=['聊天模块'])
] ]
) )
async def get_all_chats( async def get_all_chats(
login_user: LoginUser = Depends(get_current_user), login_user: LoginUser = Depends(get_current_user_form_http),
db_session: AsyncSession = Depends(get_async_session), db_session: AsyncSession = Depends(get_async_session),
page: int = Query(default=1, gt=0), page: int = Query(default=1, gt=0),
page_size: int = Query(default=15, gt=0, le=200), page_size: int = Query(default=15, gt=0, le=200),
@@ -82,13 +82,13 @@ async def get_all_chats(
'/{chat_guid}/messages/', summary='获取单个聊天的历史消息', '/{chat_guid}/messages/', summary='获取单个聊天的历史消息',
response_model=base_schema.BaseResponse[ response_model=base_schema.BaseResponse[
base_schema.Paginated[ base_schema.Paginated[
List[Dict] List[schema.MessageOut]
] ]
] ]
) )
async def get_single_chat_messages( async def get_single_chat_messages(
chat_guid: str, chat_guid: str,
login_user: LoginUser = Depends(get_current_user), login_user: LoginUser = Depends(get_current_user_form_http),
db_session: AsyncSession = Depends(get_async_session), db_session: AsyncSession = Depends(get_async_session),
page: int = Query(default=1, gt=0), page: int = Query(default=1, gt=0),
page_size: int = Query(default=15, gt=0, le=200), page_size: int = Query(default=15, gt=0, le=200),
@@ -125,15 +125,18 @@ async def get_single_chat_messages(
.options(selectinload(Message.user), selectinload(Message.chat)) .options(selectinload(Message.user), selectinload(Message.chat))
) )
records = await db_session.execute(stmt) records = await db_session.execute(stmt)
results = [
{
'user': record[0].user.dict(),
'chat_guid': record[0].chat.guid,
'message_guid': record[0].guid,
**record[0].dict(exclude={'guid'})
} for record in records
]
return { return {
'data': { 'data': {
'list': [ 'list': results,
{
**card.dict(),
**user_collection.dict()
} for card, user_collection in records
],
'total': total.scalar(), 'total': total.scalar(),
'page': page, 'page': page,
'page_size': page_size, 'page_size': page_size,
@@ -144,7 +147,7 @@ async def get_single_chat_messages(
@chat_router.post('/create', summary='创建新聊天', response_model=base_schema.BaseResponse[schema.Chat]) @chat_router.post('/create', summary='创建新聊天', response_model=base_schema.BaseResponse[schema.Chat])
async def create_new_chat( async def create_new_chat(
payload: schema.ChatCreateIn, payload: schema.ChatCreateIn,
login_user: LoginUser = Depends(get_current_user), login_user: LoginUser = Depends(get_current_user_form_http),
db_session: AsyncSession = Depends(get_async_session), db_session: AsyncSession = Depends(get_async_session),
): ):
if payload.recipient_user_id == login_user.user_id: if payload.recipient_user_id == login_user.user_id:
@@ -201,7 +204,7 @@ async def create_new_chat(
@chat_router.delete('/{chat_guid}/', summary='删除聊天', response_model=base_schema.BaseResponse) @chat_router.delete('/{chat_guid}/', summary='删除聊天', response_model=base_schema.BaseResponse)
async def delete_single_chat( async def delete_single_chat(
chat_guid: str, chat_guid: str,
login_user: LoginUser = Depends(get_current_user), login_user: LoginUser = Depends(get_current_user_form_http),
db_session: AsyncSession = Depends(get_async_session), db_session: AsyncSession = Depends(get_async_session),
): ):
chat = await CRUD(db_session, Chat).update( chat = await CRUD(db_session, Chat).update(
@@ -217,17 +220,16 @@ async def delete_single_chat(
@chat_router.websocket('/ws') @chat_router.websocket('/ws')
async def get_chat_websocket( async def get_chat_websocket(
websocket: WebSocket, websocket: WebSocket,
login_user: LoginUser = Depends(get_current_user), login_user: LoginUser = Depends(get_current_user_form_ws),
db_session: AsyncSession = Depends(get_async_session), db_session: AsyncSession = Depends(get_async_session),
redis_conn: aioredis.Redis = Depends(get_cache)
): ):
current_user = await CRUD(db_session, User).get(filters={'user_id': login_user.user_id})
await socket_manager.connect_socket(websocket=websocket) await socket_manager.connect_socket(websocket=websocket)
logger.info('Websocket connection is established') logger.info(f'Websocket connection is established: {login_user.user_id}')
ratelimit = WebSocketRateLimiter(times=50, seconds=10, callback=websocket_callback)
user_id = str(login_user.user_id) user_id = str(login_user.user_id)
await socket_manager.add_user_socket_connection(user_id, websocket) await socket_manager.add_user_socket_connection(user_id, websocket)
ratelimit = WebSocketRateLimiter(times=50, seconds=10, callback=websocket_callback)
current_user = await CRUD(db_session, User).get(filters={'user_id': login_user.user_id})
if chats := await get_user_active_direct_chats(db_session, current_user=current_user): if chats := await get_user_active_direct_chats(db_session, current_user=current_user):
for chat_guid in chats: for chat_guid in chats:
await socket_manager.add_user_to_chat(chat_guid, websocket) await socket_manager.add_user_to_chat(chat_guid, websocket)
@@ -238,10 +240,10 @@ async def get_chat_websocket(
while True: while True:
try: try:
incoming_message = await websocket.receive_json() incoming_message = await websocket.receive_json()
logger.info(f'新消息:{incoming_message}')
await ratelimit(websocket) await ratelimit(websocket)
try: try:
message = schema.Message(**incoming_message) message = schema.WSMessage(**incoming_message)
except ValueError: except ValueError:
await socket_manager.send_error(websocket, 'The message type or format you sent is invalid') await socket_manager.send_error(websocket, 'The message type or format you sent is invalid')
continue continue
@@ -255,10 +257,9 @@ async def get_chat_websocket(
await handler( await handler(
websocket=websocket, websocket=websocket,
db_session=db_session, db_session=db_session,
cache=redis_conn,
current_user=current_user, current_user=current_user,
chats=chats, chats=chats,
message=message, incoming_message=message,
) )
except (JSONDecodeError, AttributeError) as exc: except (JSONDecodeError, AttributeError) as exc:
@@ -273,4 +274,4 @@ async def get_chat_websocket(
logger.exception(f'User: {login_user} sent too many ws requests') logger.exception(f'User: {login_user} sent too many ws requests')
await websocket.send_json({'status': 'error', 'message': 'You have sent too many requests'}) await websocket.send_json({'status': 'error', 'message': 'You have sent too many requests'})
except WebSocketDisconnect: except WebSocketDisconnect:
logging.info('Websocket is disconnected') logging.info(f'Websocket is disconnected: {login_user.user_id}')

View File

@@ -17,31 +17,6 @@ class ChatUser(LiteUser):
pass pass
class Message(BaseModel):
type: Literal[
'chat.new_message', 'chat.message_read'
]
message_guid: UUID4
message_type: MessageType
chat_guid: UUID4
receive_user: Optional[int] = None
body: str
class MessageOut(BaseModel):
type: Literal[
'chat.new_message', 'chat.send_status_reply'
]
code: Optional[int] = 200
status: Optional[Literal['ok', 'error']] = 'ok'
message_id: int
message_guid: UUID4
message_type: MessageType
chat_guid: UUID4
body: Optional[str] = None
friend_user: LiteUser
class Chat(BaseModel): class Chat(BaseModel):
chat_guid: UUID4 chat_guid: UUID4
friend_user: LiteUser friend_user: LiteUser
@@ -51,10 +26,51 @@ class ChatCreateIn(BaseModel):
recipient_user_id: int recipient_user_id: int
class PreviewMessage(BaseModel):
message_type: MessageType
content: str
class ChatUnreadOut(BaseModel): class ChatUnreadOut(BaseModel):
chat_guid: UUID4 chat_guid: UUID4
created_at: str created_at: str
updated_at: str updated_at: str
recipient_user: LiteUser recipient_user: LiteUser
last_message: str | None last_message: PreviewMessage | None
unread_messages_count: int unread_messages_count: int
class MessageOut(BaseModel):
chat_id: int
chat_guid: UUID4
id: int
message_guid: UUID4
message_type: MessageType
content: str
user: LiteUser
created_at: str
updated_at: str
class WSMessage(BaseModel):
type: Literal[
'chat.new_message', 'chat.message_read', 'chat.ping'
]
message_guid: UUID4
message_type: MessageType
chat_guid: UUID4
receive_user: Optional[int] = None
content: str
class WSMessageOut(BaseModel):
type: Literal[
'chat.new_message', 'chat.send_status_reply',
]
status: Optional[Literal['ok', 'error']] = 'ok'
message_id: int
message_guid: UUID4
message_type: MessageType
chat_guid: UUID4
content: Optional[str] = None
friend_user: Optional[LiteUser] = None

View File

@@ -50,7 +50,7 @@ async def get_unread_messages_per_chat(
# 查询消息情况 # 查询消息情况
read_status_alias = aliased(ReadStatus) read_status_alias = aliased(ReadStatus)
query = ( query = (
select(Message.chat_id, Message.content, func.count().label("message_count")) select(Message.chat_id, Message.content, Message.message_type, func.count().label("message_count"))
.join( .join(
read_status_alias, read_status_alias,
and_(read_status_alias.user_id == login_user.user_id, read_status_alias.chat_id == Message.chat_id), and_(read_status_alias.user_id == login_user.user_id, read_status_alias.chat_id == Message.chat_id),
@@ -70,8 +70,11 @@ async def get_unread_messages_per_chat(
for messages_info in unread_messages_info: for messages_info in unread_messages_info:
unread_messages_info_per_chat[messages_info[0]] = { unread_messages_info_per_chat[messages_info[0]] = {
'message': messages_info[1], 'message': {
'message_count': messages_info[2], 'message_type': messages_info[2],
'content': messages_info[1],
},
'message_count': messages_info[3],
} }
results = [] results = []