Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.base_schema import ResponseModel
|
||||
from core.chat.schema import (
|
||||
CreatePrivateConversationIn,
|
||||
CreateGroupConversationIn,
|
||||
UpdateConversationIn,
|
||||
ConversationOut,
|
||||
ConversationListOut,
|
||||
ConversationMemberOut,
|
||||
AddMembersIn,
|
||||
ConversationSettingIn,
|
||||
SendMessageIn,
|
||||
ChatMessageOut,
|
||||
ChatMessageListOut,
|
||||
MarkReadIn,
|
||||
)
|
||||
from core.chat.service import ConversationService, ChatMessageService
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["聊天"])
|
||||
|
||||
|
||||
# ============ 会话管理 ============
|
||||
|
||||
@router.get("/conversations", response_model=ConversationListOut, summary="我的会话列表")
|
||||
async def get_conversations(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户的会话列表"""
|
||||
user_id = request.state.user_id
|
||||
items = await ConversationService.get_user_conversations(db, user_id)
|
||||
return ConversationListOut(
|
||||
items=[ConversationOut(**item) for item in items],
|
||||
total=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/conversations/private", response_model=ConversationOut, summary="创建/获取单聊")
|
||||
async def create_private_conversation(
|
||||
request: Request,
|
||||
data: CreatePrivateConversationIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建或获取与某用户的单聊会话"""
|
||||
user_id = request.state.user_id
|
||||
if data.user_id == user_id:
|
||||
raise HTTPException(status_code=400, detail="不能与自己创建单聊")
|
||||
|
||||
conv = await ConversationService.get_or_create_private(db, user_id, data.user_id)
|
||||
|
||||
# 获取对方用户信息
|
||||
from core.user.model import User
|
||||
from sqlalchemy import select
|
||||
peer_result = await db.execute(select(User).where(User.id == data.user_id))
|
||||
peer = peer_result.scalar_one_or_none()
|
||||
|
||||
member = await ConversationService.get_member(db, conv.id, user_id)
|
||||
|
||||
return ConversationOut(
|
||||
id=conv.id,
|
||||
type=conv.type,
|
||||
name=conv.name,
|
||||
avatar=conv.avatar,
|
||||
owner_id=conv.owner_id,
|
||||
last_message_time=conv.last_message_time,
|
||||
last_message_preview=conv.last_message_preview,
|
||||
member_count=conv.member_count,
|
||||
sys_create_datetime=conv.sys_create_datetime,
|
||||
unread_count=member.unread_count if member else 0,
|
||||
is_muted=member.is_muted if member else False,
|
||||
is_pinned=member.is_pinned if member else False,
|
||||
peer_user_id=peer.id if peer else None,
|
||||
peer_user_name=(peer.name or peer.username) if peer else None,
|
||||
peer_user_avatar=peer.avatar if peer else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/conversations/group", response_model=ConversationOut, summary="创建群聊")
|
||||
async def create_group_conversation(
|
||||
request: Request,
|
||||
data: CreateGroupConversationIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建群聊"""
|
||||
user_id = request.state.user_id
|
||||
if len(data.member_ids) < 1:
|
||||
raise HTTPException(status_code=400, detail="群聊至少需要1个其他成员")
|
||||
|
||||
conv = await ConversationService.create_group(
|
||||
db, name=data.name, owner_id=user_id,
|
||||
member_ids=data.member_ids, avatar=data.avatar,
|
||||
)
|
||||
return ConversationOut(
|
||||
id=conv.id,
|
||||
type=conv.type,
|
||||
name=conv.name,
|
||||
avatar=conv.avatar,
|
||||
owner_id=conv.owner_id,
|
||||
last_message_time=conv.last_message_time,
|
||||
last_message_preview=conv.last_message_preview,
|
||||
member_count=conv.member_count,
|
||||
sys_create_datetime=conv.sys_create_datetime,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}", response_model=ConversationOut, summary="会话详情")
|
||||
async def get_conversation(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话详情"""
|
||||
user_id = request.state.user_id
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
if not await ConversationService.is_member(db, conversation_id, user_id):
|
||||
raise HTTPException(status_code=403, detail="非会话成员")
|
||||
|
||||
member = await ConversationService.get_member(db, conversation_id, user_id)
|
||||
return ConversationOut(
|
||||
id=conv.id,
|
||||
type=conv.type,
|
||||
name=conv.name,
|
||||
avatar=conv.avatar,
|
||||
owner_id=conv.owner_id,
|
||||
last_message_time=conv.last_message_time,
|
||||
last_message_preview=conv.last_message_preview,
|
||||
member_count=conv.member_count,
|
||||
sys_create_datetime=conv.sys_create_datetime,
|
||||
unread_count=member.unread_count if member else 0,
|
||||
is_muted=member.is_muted if member else False,
|
||||
is_pinned=member.is_pinned if member else False,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/conversations/{conversation_id}", response_model=ConversationOut, summary="更新群聊信息")
|
||||
async def update_conversation(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
data: UpdateConversationIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新群聊名称/头像"""
|
||||
user_id = request.state.user_id
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
if conv.type != "group":
|
||||
raise HTTPException(status_code=400, detail="只能更新群聊信息")
|
||||
if conv.owner_id != user_id:
|
||||
# 检查是否是管理员
|
||||
member = await ConversationService.get_member(db, conversation_id, user_id)
|
||||
if not member or member.role not in ("owner", "admin"):
|
||||
raise HTTPException(status_code=403, detail="无权限修改群聊信息")
|
||||
|
||||
updated = await ConversationService.update_conversation(
|
||||
db, conversation_id, name=data.name, avatar=data.avatar,
|
||||
)
|
||||
return ConversationOut(
|
||||
id=updated.id,
|
||||
type=updated.type,
|
||||
name=updated.name,
|
||||
avatar=updated.avatar,
|
||||
owner_id=updated.owner_id,
|
||||
last_message_time=updated.last_message_time,
|
||||
last_message_preview=updated.last_message_preview,
|
||||
member_count=updated.member_count,
|
||||
sys_create_datetime=updated.sys_create_datetime,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}", response_model=ResponseModel, summary="解散群聊")
|
||||
async def delete_conversation(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""解散群聊(仅群主)"""
|
||||
user_id = request.state.user_id
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
if conv.type != "group":
|
||||
raise HTTPException(status_code=400, detail="单聊不能解散")
|
||||
if conv.owner_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="只有群主可以解散群聊")
|
||||
|
||||
await ConversationService.delete_conversation(db, conversation_id)
|
||||
return ResponseModel(message="群聊已解散")
|
||||
|
||||
|
||||
# ============ 成员管理 ============
|
||||
|
||||
@router.get("/conversations/{conversation_id}/members", summary="获取成员列表")
|
||||
async def get_members(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话成员列表"""
|
||||
user_id = request.state.user_id
|
||||
if not await ConversationService.is_member(db, conversation_id, user_id):
|
||||
raise HTTPException(status_code=403, detail="非会话成员")
|
||||
|
||||
members = await ConversationService.get_members(db, conversation_id)
|
||||
return [ConversationMemberOut(**m) for m in members]
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/members", response_model=ResponseModel, summary="添加成员")
|
||||
async def add_members(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
data: AddMembersIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""添加群成员"""
|
||||
user_id = request.state.user_id
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
if conv.type != "group":
|
||||
raise HTTPException(status_code=400, detail="单聊不能添加成员")
|
||||
if not await ConversationService.is_member(db, conversation_id, user_id):
|
||||
raise HTTPException(status_code=403, detail="非会话成员")
|
||||
|
||||
added = await ConversationService.add_members(db, conversation_id, data.user_ids)
|
||||
return ResponseModel(message=f"已添加 {added} 个成员")
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}/members/{member_user_id}", response_model=ResponseModel, summary="移除成员")
|
||||
async def remove_member(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
member_user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""移除群成员(群主/管理员操作)"""
|
||||
user_id = request.state.user_id
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
if conv.type != "group":
|
||||
raise HTTPException(status_code=400, detail="单聊不能移除成员")
|
||||
|
||||
member = await ConversationService.get_member(db, conversation_id, user_id)
|
||||
if not member or member.role not in ("owner", "admin"):
|
||||
raise HTTPException(status_code=403, detail="无权限移除成员")
|
||||
if member_user_id == conv.owner_id:
|
||||
raise HTTPException(status_code=400, detail="不能移除群主")
|
||||
|
||||
success = await ConversationService.remove_member(db, conversation_id, member_user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="成员不存在")
|
||||
return ResponseModel(message="已移除成员")
|
||||
|
||||
|
||||
# ============ 会话设置 ============
|
||||
|
||||
@router.put("/conversations/{conversation_id}/pin", response_model=ResponseModel, summary="置顶/取消置顶")
|
||||
async def toggle_pin(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
data: ConversationSettingIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""置顶或取消置顶会话"""
|
||||
user_id = request.state.user_id
|
||||
success = await ConversationService.update_setting(
|
||||
db, conversation_id, user_id, "is_pinned", data.value,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或非成员")
|
||||
return ResponseModel(message="设置成功")
|
||||
|
||||
|
||||
@router.put("/conversations/{conversation_id}/mute", response_model=ResponseModel, summary="免打扰设置")
|
||||
async def toggle_mute(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
data: ConversationSettingIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设置或取消免打扰"""
|
||||
user_id = request.state.user_id
|
||||
success = await ConversationService.update_setting(
|
||||
db, conversation_id, user_id, "is_muted", data.value,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或非成员")
|
||||
return ResponseModel(message="设置成功")
|
||||
|
||||
|
||||
# ============ 在线状态 ============
|
||||
|
||||
@router.get("/users/online", summary="获取在线用户列表")
|
||||
async def get_online_users(
|
||||
request: Request,
|
||||
):
|
||||
"""获取当前所有在线用户ID列表"""
|
||||
from core.websocket.consumers.base import manager
|
||||
online_ids = list(manager.get_online_user_ids())
|
||||
return {"user_ids": online_ids}
|
||||
|
||||
|
||||
# ============ 消息管理 ============
|
||||
|
||||
@router.get("/messages/unread", summary="获取所有未读聊天消息")
|
||||
async def get_unread_messages(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=100, description="最大数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户所有未读聊天消息(跨会话,按时间倒序)"""
|
||||
user_id = request.state.user_id
|
||||
items = await ChatMessageService.get_unread_messages(db, user_id, limit=limit)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages", summary="获取消息列表")
|
||||
async def get_messages(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
before_id: Optional[str] = Query(default=None, alias="beforeId", description="游标消息ID"),
|
||||
limit: int = Query(default=30, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取消息列表(游标分页,向上加载历史)"""
|
||||
user_id = request.state.user_id
|
||||
if not await ConversationService.is_member(db, conversation_id, user_id):
|
||||
raise HTTPException(status_code=403, detail="非会话成员")
|
||||
|
||||
items, has_more = await ChatMessageService.get_messages(
|
||||
db, conversation_id, before_id=before_id, limit=limit,
|
||||
)
|
||||
return ChatMessageListOut(
|
||||
items=[ChatMessageOut(**item) for item in items],
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages", response_model=ChatMessageOut, summary="发送消息")
|
||||
async def send_message(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
data: SendMessageIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""发送消息(REST备用,主要走WebSocket)"""
|
||||
user_id = request.state.user_id
|
||||
if not await ConversationService.is_member(db, conversation_id, user_id):
|
||||
raise HTTPException(status_code=403, detail="非会话成员")
|
||||
|
||||
if data.msg_type == "text" and not data.content:
|
||||
raise HTTPException(status_code=400, detail="文本消息内容不能为空")
|
||||
if data.msg_type in ("image", "file", "voice") and not data.file_id:
|
||||
raise HTTPException(status_code=400, detail="文件消息需要提供文件ID")
|
||||
|
||||
msg = await ChatMessageService.send_message(
|
||||
db,
|
||||
conversation_id=conversation_id,
|
||||
sender_id=user_id,
|
||||
msg_type=data.msg_type,
|
||||
content=data.content,
|
||||
file_id=data.file_id,
|
||||
reply_to_id=data.reply_to_id,
|
||||
extra=data.extra,
|
||||
)
|
||||
|
||||
# 获取发送者信息
|
||||
from core.user.model import User
|
||||
from sqlalchemy import select
|
||||
sender_result = await db.execute(select(User).where(User.id == user_id))
|
||||
sender = sender_result.scalar_one_or_none()
|
||||
|
||||
# 通过WebSocket推送给会话其他成员
|
||||
await _push_message_to_members(db, conversation_id, user_id, msg, sender)
|
||||
|
||||
return ChatMessageOut(
|
||||
id=msg.id,
|
||||
conversation_id=msg.conversation_id,
|
||||
sender_id=msg.sender_id,
|
||||
msg_type=msg.msg_type,
|
||||
content=msg.content,
|
||||
file_id=msg.file_id,
|
||||
reply_to_id=msg.reply_to_id,
|
||||
is_recalled=msg.is_recalled,
|
||||
extra=msg.extra,
|
||||
sys_create_datetime=msg.sys_create_datetime,
|
||||
sender_name=(sender.name or sender.username) if sender else None,
|
||||
sender_avatar=sender.avatar if sender else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/recall", response_model=ResponseModel, summary="撤回消息")
|
||||
async def recall_message(
|
||||
request: Request,
|
||||
message_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""撤回消息(2分钟内)"""
|
||||
user_id = request.state.user_id
|
||||
msg = await ChatMessageService.recall_message(db, message_id, user_id)
|
||||
if not msg:
|
||||
raise HTTPException(status_code=400, detail="无法撤回消息(超时或非本人消息)")
|
||||
|
||||
# 通过WebSocket通知撤回
|
||||
member_ids = await ConversationService.get_member_user_ids(db, msg.conversation_id)
|
||||
from core.websocket.consumers.base import manager
|
||||
for uid in member_ids:
|
||||
await manager.send_to_user(uid, {
|
||||
"type": "chat.recalled",
|
||||
"data": {
|
||||
"conversation_id": msg.conversation_id,
|
||||
"message_id": message_id,
|
||||
},
|
||||
})
|
||||
|
||||
return ResponseModel(message="消息已撤回")
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/read", response_model=ResponseModel, summary="标记已读")
|
||||
async def mark_read(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
data: MarkReadIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""标记已读到某条消息"""
|
||||
user_id = request.state.user_id
|
||||
success = await ChatMessageService.mark_read(
|
||||
db, conversation_id, user_id, data.message_id,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或非成员")
|
||||
|
||||
# 通过WebSocket通知已读回执
|
||||
member_ids = await ConversationService.get_member_user_ids(db, conversation_id)
|
||||
from core.websocket.consumers.base import manager
|
||||
for uid in member_ids:
|
||||
if uid != user_id:
|
||||
await manager.send_to_user(uid, {
|
||||
"type": "chat.read_receipt",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"message_id": data.message_id,
|
||||
},
|
||||
})
|
||||
|
||||
return ResponseModel(message="已标记已读")
|
||||
|
||||
|
||||
# ============ 辅助函数 ============
|
||||
|
||||
async def _push_message_to_members(db, conversation_id, sender_id, msg, sender):
|
||||
"""通过WebSocket推送消息给会话成员"""
|
||||
try:
|
||||
member_ids = await ConversationService.get_member_user_ids(db, conversation_id)
|
||||
from core.websocket.consumers.base import manager
|
||||
message_data = {
|
||||
"type": "chat.message",
|
||||
"data": {
|
||||
"id": msg.id,
|
||||
"conversation_id": msg.conversation_id,
|
||||
"sender_id": msg.sender_id,
|
||||
"msg_type": msg.msg_type,
|
||||
"content": msg.content,
|
||||
"file_id": msg.file_id,
|
||||
"reply_to_id": msg.reply_to_id,
|
||||
"is_recalled": msg.is_recalled,
|
||||
"extra": msg.extra,
|
||||
"sys_create_datetime": msg.sys_create_datetime.isoformat() if msg.sys_create_datetime else None,
|
||||
"sender_name": (sender.name or sender.username) if sender else None,
|
||||
"sender_avatar": sender.avatar if sender else None,
|
||||
},
|
||||
}
|
||||
for uid in member_ids:
|
||||
await manager.send_to_user(uid, message_data)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"推送聊天消息失败: {e}")
|
||||
|
||||
|
||||
# 需要导入 Optional
|
||||
from typing import Optional
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, Text, Boolean, Integer, DateTime, Index, JSON
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class Conversation(BaseModel):
|
||||
"""会话表"""
|
||||
__tablename__ = "core_conversation"
|
||||
|
||||
type = Column(String(20), nullable=False, default="private", comment="会话类型: private/group")
|
||||
name = Column(String(100), nullable=True, comment="群聊名称(单聊为空)")
|
||||
avatar = Column(String(500), nullable=True, comment="群头像文件ID")
|
||||
owner_id = Column(String(21), nullable=True, comment="群主用户ID(逻辑外键关联core_user)")
|
||||
last_message_id = Column(String(21), nullable=True, comment="最后一条消息ID(逻辑外键)")
|
||||
last_message_time = Column(DateTime, nullable=True, comment="最后消息时间")
|
||||
last_message_preview = Column(String(200), nullable=True, comment="最后消息预览文本")
|
||||
member_count = Column(Integer, default=0, comment="成员数")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_conversation_type", "type"),
|
||||
Index("idx_conversation_last_msg_time", "last_message_time"),
|
||||
)
|
||||
|
||||
|
||||
class ConversationMember(BaseModel):
|
||||
"""会话成员表"""
|
||||
__tablename__ = "core_conversation_member"
|
||||
|
||||
conversation_id = Column(String(21), nullable=False, comment="会话ID(逻辑外键关联core_conversation)")
|
||||
user_id = Column(String(21), nullable=False, comment="用户ID(逻辑外键关联core_user)")
|
||||
role = Column(String(20), default="member", comment="角色: owner/admin/member")
|
||||
nickname = Column(String(50), nullable=True, comment="群内昵称")
|
||||
is_muted = Column(Boolean, default=False, comment="是否免打扰")
|
||||
is_pinned = Column(Boolean, default=False, comment="是否置顶")
|
||||
unread_count = Column(Integer, default=0, comment="未读消息数")
|
||||
last_read_message_id = Column(String(21), nullable=True, comment="最后已读消息ID")
|
||||
joined_at = Column(DateTime, server_default=func.now(), comment="加入时间")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_conv_member_conv_id", "conversation_id"),
|
||||
Index("idx_conv_member_user_id", "user_id"),
|
||||
Index("idx_conv_member_conv_user", "conversation_id", "user_id", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""聊天消息表"""
|
||||
__tablename__ = "core_chat_message"
|
||||
|
||||
conversation_id = Column(String(21), nullable=False, comment="会话ID(逻辑外键关联core_conversation)")
|
||||
sender_id = Column(String(21), nullable=False, comment="发送者用户ID(逻辑外键关联core_user)")
|
||||
msg_type = Column(String(20), default="text", comment="消息类型: text/image/file/voice/system/recall")
|
||||
content = Column(Text, nullable=True, comment="文本内容")
|
||||
file_id = Column(String(21), nullable=True, comment="文件ID(逻辑外键关联core_file_manager)")
|
||||
reply_to_id = Column(String(21), nullable=True, comment="回复的消息ID(逻辑外键)")
|
||||
is_recalled = Column(Boolean, default=False, comment="是否已撤回")
|
||||
recalled_at = Column(DateTime, nullable=True, comment="撤回时间")
|
||||
extra = Column(JSON, nullable=True, comment="扩展数据(@提醒列表、链接预览等)")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_chat_msg_conv_id_created", "conversation_id", "sys_create_datetime"),
|
||||
Index("idx_chat_msg_sender_id", "sender_id"),
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from core.chat.api import router as chat_api_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(chat_api_router)
|
||||
@@ -0,0 +1,144 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ============ 会话 Schema ============
|
||||
|
||||
class ConversationBase(BaseModel):
|
||||
"""会话基础Schema"""
|
||||
type: str = Field(default="private", description="会话类型: private/group")
|
||||
name: Optional[str] = Field(default=None, max_length=100, description="群聊名称")
|
||||
avatar: Optional[str] = Field(default=None, description="群头像文件ID")
|
||||
|
||||
|
||||
class CreatePrivateConversationIn(BaseModel):
|
||||
"""创建单聊会话"""
|
||||
user_id: str = Field(..., description="对方用户ID")
|
||||
|
||||
|
||||
class CreateGroupConversationIn(BaseModel):
|
||||
"""创建群聊会话"""
|
||||
name: str = Field(..., max_length=100, description="群聊名称")
|
||||
member_ids: List[str] = Field(..., description="成员用户ID列表")
|
||||
avatar: Optional[str] = Field(default=None, description="群头像文件ID")
|
||||
|
||||
|
||||
class UpdateConversationIn(BaseModel):
|
||||
"""更新群聊信息"""
|
||||
name: Optional[str] = Field(default=None, max_length=100, description="群聊名称")
|
||||
avatar: Optional[str] = Field(default=None, description="群头像文件ID")
|
||||
|
||||
|
||||
class ConversationMemberOut(BaseModel):
|
||||
"""会话成员输出"""
|
||||
id: str
|
||||
user_id: str
|
||||
role: str
|
||||
nickname: Optional[str] = None
|
||||
is_muted: bool = False
|
||||
is_pinned: bool = False
|
||||
unread_count: int = 0
|
||||
joined_at: Optional[datetime] = None
|
||||
# 关联用户信息(由API填充)
|
||||
user_name: Optional[str] = None
|
||||
user_avatar: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ConversationOut(BaseModel):
|
||||
"""会话输出"""
|
||||
id: str
|
||||
type: str
|
||||
name: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
owner_id: Optional[str] = None
|
||||
last_message_time: Optional[datetime] = None
|
||||
last_message_preview: Optional[str] = None
|
||||
member_count: int = 0
|
||||
sys_create_datetime: Optional[datetime] = None
|
||||
# 当前用户相关(由API填充)
|
||||
unread_count: int = 0
|
||||
is_muted: bool = False
|
||||
is_pinned: bool = False
|
||||
# 单聊对方信息(由API填充)
|
||||
peer_user_id: Optional[str] = None
|
||||
peer_user_name: Optional[str] = None
|
||||
peer_user_avatar: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ConversationListOut(BaseModel):
|
||||
"""会话列表输出"""
|
||||
items: List[ConversationOut]
|
||||
total: int
|
||||
|
||||
|
||||
# ============ 成员操作 Schema ============
|
||||
|
||||
class AddMembersIn(BaseModel):
|
||||
"""添加成员"""
|
||||
user_ids: List[str] = Field(..., description="用户ID列表")
|
||||
|
||||
|
||||
class ConversationSettingIn(BaseModel):
|
||||
"""会话设置(置顶/免打扰)"""
|
||||
value: bool = Field(..., description="设置值")
|
||||
|
||||
|
||||
# ============ 消息 Schema ============
|
||||
|
||||
class SendMessageIn(BaseModel):
|
||||
"""发送消息"""
|
||||
msg_type: str = Field(default="text", description="消息类型: text/image/file/voice")
|
||||
content: Optional[str] = Field(default=None, description="文本内容")
|
||||
file_id: Optional[str] = Field(default=None, description="文件ID")
|
||||
reply_to_id: Optional[str] = Field(default=None, description="回复的消息ID")
|
||||
extra: Optional[Dict[str, Any]] = Field(default=None, description="扩展数据")
|
||||
|
||||
|
||||
class ChatMessageOut(BaseModel):
|
||||
"""消息输出"""
|
||||
id: str
|
||||
conversation_id: str
|
||||
sender_id: str
|
||||
msg_type: str
|
||||
content: Optional[str] = None
|
||||
file_id: Optional[str] = None
|
||||
reply_to_id: Optional[str] = None
|
||||
is_recalled: bool = False
|
||||
recalled_at: Optional[datetime] = None
|
||||
extra: Optional[Dict[str, Any]] = None
|
||||
sys_create_datetime: Optional[datetime] = None
|
||||
# 关联信息(由API填充)
|
||||
sender_name: Optional[str] = None
|
||||
sender_avatar: Optional[str] = None
|
||||
# 回复消息预览(由API填充)
|
||||
reply_to_preview: Optional[str] = None
|
||||
reply_to_sender_name: Optional[str] = None
|
||||
# 文件信息(由API填充)
|
||||
file_name: Optional[str] = None
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
file_ext: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ChatMessageListOut(BaseModel):
|
||||
"""消息列表输出(游标分页)"""
|
||||
items: List[ChatMessageOut]
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class RecallMessageIn(BaseModel):
|
||||
"""撤回消息"""
|
||||
pass
|
||||
|
||||
|
||||
class MarkReadIn(BaseModel):
|
||||
"""标记已读"""
|
||||
message_id: str = Field(..., description="已读到的消息ID")
|
||||
@@ -0,0 +1,866 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple, Dict, Any
|
||||
|
||||
from sqlalchemy import select, func, desc, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.base_model import generate_nanoid
|
||||
from core.chat.model import Conversation, ConversationMember, ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationService:
|
||||
"""会话服务"""
|
||||
|
||||
@staticmethod
|
||||
async def get_or_create_private(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
peer_user_id: str,
|
||||
) -> Conversation:
|
||||
"""获取或创建单聊会话"""
|
||||
# 查找已有的单聊会话
|
||||
subq1 = select(ConversationMember.conversation_id).where(
|
||||
ConversationMember.user_id == user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
subq2 = select(ConversationMember.conversation_id).where(
|
||||
ConversationMember.user_id == peer_user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
result = await db.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.type == "private",
|
||||
Conversation.is_deleted == False, # noqa: E712
|
||||
Conversation.id.in_(subq1),
|
||||
Conversation.id.in_(subq2),
|
||||
)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if conv:
|
||||
return conv
|
||||
|
||||
# 创建新会话
|
||||
conv = Conversation(
|
||||
id=generate_nanoid(),
|
||||
type="private",
|
||||
member_count=2,
|
||||
)
|
||||
db.add(conv)
|
||||
await db.flush()
|
||||
|
||||
# 添加两个成员
|
||||
for uid in [user_id, peer_user_id]:
|
||||
member = ConversationMember(
|
||||
id=generate_nanoid(),
|
||||
conversation_id=conv.id,
|
||||
user_id=uid,
|
||||
role="member",
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(conv)
|
||||
return conv
|
||||
|
||||
@staticmethod
|
||||
async def create_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
owner_id: str,
|
||||
member_ids: List[str],
|
||||
avatar: Optional[str] = None,
|
||||
) -> Conversation:
|
||||
"""创建群聊"""
|
||||
# 确保群主在成员列表中
|
||||
all_member_ids = list(set([owner_id] + member_ids))
|
||||
|
||||
conv = Conversation(
|
||||
id=generate_nanoid(),
|
||||
type="group",
|
||||
name=name,
|
||||
avatar=avatar,
|
||||
owner_id=owner_id,
|
||||
member_count=len(all_member_ids),
|
||||
)
|
||||
db.add(conv)
|
||||
await db.flush()
|
||||
|
||||
for uid in all_member_ids:
|
||||
member = ConversationMember(
|
||||
id=generate_nanoid(),
|
||||
conversation_id=conv.id,
|
||||
user_id=uid,
|
||||
role="owner" if uid == owner_id else "member",
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(conv)
|
||||
return conv
|
||||
|
||||
@staticmethod
|
||||
async def get_user_conversations(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取用户的会话列表(按最后消息时间降序)"""
|
||||
# 查询用户参与的会话
|
||||
member_subq = select(ConversationMember.conversation_id).where(
|
||||
ConversationMember.user_id == user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
result = await db.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.id.in_(member_subq),
|
||||
Conversation.is_deleted == False, # noqa: E712
|
||||
).order_by(
|
||||
desc(Conversation.last_message_time),
|
||||
desc(Conversation.sys_create_datetime),
|
||||
)
|
||||
)
|
||||
conversations = list(result.scalars().all())
|
||||
|
||||
# 获取当前用户的成员信息
|
||||
conv_ids = [c.id for c in conversations]
|
||||
if not conv_ids:
|
||||
return []
|
||||
|
||||
member_result = await db.execute(
|
||||
select(ConversationMember).where(
|
||||
ConversationMember.conversation_id.in_(conv_ids),
|
||||
ConversationMember.user_id == user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
member_map = {m.conversation_id: m for m in member_result.scalars().all()}
|
||||
|
||||
# 对于单聊,获取对方用户信息
|
||||
private_conv_ids = [c.id for c in conversations if c.type == "private"]
|
||||
peer_map: Dict[str, Dict] = {}
|
||||
if private_conv_ids:
|
||||
peer_result = await db.execute(
|
||||
select(ConversationMember).where(
|
||||
ConversationMember.conversation_id.in_(private_conv_ids),
|
||||
ConversationMember.user_id != user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
peer_members = list(peer_result.scalars().all())
|
||||
# 批量获取用户信息
|
||||
peer_user_ids = [pm.user_id for pm in peer_members]
|
||||
if peer_user_ids:
|
||||
from core.user.model import User
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id.in_(peer_user_ids))
|
||||
)
|
||||
user_map = {u.id: u for u in user_result.scalars().all()}
|
||||
for pm in peer_members:
|
||||
u = user_map.get(pm.user_id)
|
||||
if u:
|
||||
peer_map[pm.conversation_id] = {
|
||||
"peer_user_id": u.id,
|
||||
"peer_user_name": u.name or u.username,
|
||||
"peer_user_avatar": u.avatar,
|
||||
}
|
||||
|
||||
# 组装结果
|
||||
items = []
|
||||
for conv in conversations:
|
||||
member = member_map.get(conv.id)
|
||||
item = {
|
||||
"id": conv.id,
|
||||
"type": conv.type,
|
||||
"name": conv.name,
|
||||
"avatar": conv.avatar,
|
||||
"owner_id": conv.owner_id,
|
||||
"last_message_time": conv.last_message_time,
|
||||
"last_message_preview": conv.last_message_preview,
|
||||
"member_count": conv.member_count,
|
||||
"sys_create_datetime": conv.sys_create_datetime,
|
||||
"unread_count": member.unread_count if member else 0,
|
||||
"is_muted": member.is_muted if member else False,
|
||||
"is_pinned": member.is_pinned if member else False,
|
||||
"peer_user_id": None,
|
||||
"peer_user_name": None,
|
||||
"peer_user_avatar": None,
|
||||
}
|
||||
if conv.type == "private" and conv.id in peer_map:
|
||||
item.update(peer_map[conv.id])
|
||||
items.append(item)
|
||||
|
||||
# 置顶排序:置顶的在前
|
||||
items.sort(key=lambda x: (not x["is_pinned"], 0))
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(db: AsyncSession, conversation_id: str) -> Optional[Conversation]:
|
||||
"""获取会话"""
|
||||
result = await db.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def is_member(db: AsyncSession, conversation_id: str, user_id: str) -> bool:
|
||||
"""检查用户是否是会话成员"""
|
||||
result = await db.execute(
|
||||
select(ConversationMember).where(
|
||||
ConversationMember.conversation_id == conversation_id,
|
||||
ConversationMember.user_id == user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@staticmethod
|
||||
async def get_member(
|
||||
db: AsyncSession, conversation_id: str, user_id: str
|
||||
) -> Optional[ConversationMember]:
|
||||
"""获取成员记录"""
|
||||
result = await db.execute(
|
||||
select(ConversationMember).where(
|
||||
ConversationMember.conversation_id == conversation_id,
|
||||
ConversationMember.user_id == user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_members(
|
||||
db: AsyncSession, conversation_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取会话成员列表(含用户信息)"""
|
||||
result = await db.execute(
|
||||
select(ConversationMember).where(
|
||||
ConversationMember.conversation_id == conversation_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
).order_by(ConversationMember.joined_at)
|
||||
)
|
||||
members = list(result.scalars().all())
|
||||
|
||||
# 批量获取用户信息
|
||||
user_ids = [m.user_id for m in members]
|
||||
if not user_ids:
|
||||
return []
|
||||
|
||||
from core.user.model import User
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id.in_(user_ids))
|
||||
)
|
||||
user_map = {u.id: u for u in user_result.scalars().all()}
|
||||
|
||||
items = []
|
||||
for m in members:
|
||||
u = user_map.get(m.user_id)
|
||||
items.append({
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"role": m.role,
|
||||
"nickname": m.nickname,
|
||||
"is_muted": m.is_muted,
|
||||
"is_pinned": m.is_pinned,
|
||||
"unread_count": m.unread_count,
|
||||
"joined_at": m.joined_at,
|
||||
"user_name": (u.name or u.username) if u else None,
|
||||
"user_avatar": u.avatar if u else None,
|
||||
})
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
async def add_members(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
user_ids: List[str],
|
||||
) -> int:
|
||||
"""添加群成员,返回实际添加数量"""
|
||||
added = 0
|
||||
for uid in user_ids:
|
||||
# 检查是否已是成员
|
||||
existing = await db.execute(
|
||||
select(ConversationMember).where(
|
||||
ConversationMember.conversation_id == conversation_id,
|
||||
ConversationMember.user_id == uid,
|
||||
)
|
||||
)
|
||||
member = existing.scalar_one_or_none()
|
||||
if member:
|
||||
if member.is_deleted:
|
||||
member.is_deleted = False
|
||||
added += 1
|
||||
continue
|
||||
|
||||
new_member = ConversationMember(
|
||||
id=generate_nanoid(),
|
||||
conversation_id=conversation_id,
|
||||
user_id=uid,
|
||||
role="member",
|
||||
)
|
||||
db.add(new_member)
|
||||
added += 1
|
||||
|
||||
if added > 0:
|
||||
# 更新成员数
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if conv:
|
||||
conv.member_count = (conv.member_count or 0) + added
|
||||
await db.commit()
|
||||
return added
|
||||
|
||||
@staticmethod
|
||||
async def remove_member(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
"""移除群成员"""
|
||||
member = await ConversationService.get_member(db, conversation_id, user_id)
|
||||
if not member:
|
||||
return False
|
||||
|
||||
member.is_deleted = True
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if conv:
|
||||
conv.member_count = max(0, (conv.member_count or 0) - 1)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def update_conversation(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
name: Optional[str] = None,
|
||||
avatar: Optional[str] = None,
|
||||
) -> Optional[Conversation]:
|
||||
"""更新群聊信息"""
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if not conv:
|
||||
return None
|
||||
if name is not None:
|
||||
conv.name = name
|
||||
if avatar is not None:
|
||||
conv.avatar = avatar
|
||||
await db.commit()
|
||||
await db.refresh(conv)
|
||||
return conv
|
||||
|
||||
@staticmethod
|
||||
async def update_setting(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
field: str,
|
||||
value: bool,
|
||||
) -> bool:
|
||||
"""更新会话设置(置顶/免打扰)"""
|
||||
member = await ConversationService.get_member(db, conversation_id, user_id)
|
||||
if not member:
|
||||
return False
|
||||
setattr(member, field, value)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def delete_conversation(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
) -> bool:
|
||||
"""解散群聊(软删除)"""
|
||||
conv = await ConversationService.get_by_id(db, conversation_id)
|
||||
if not conv:
|
||||
return False
|
||||
conv.is_deleted = True
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_member_user_ids(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
) -> List[str]:
|
||||
"""获取会话所有成员的user_id"""
|
||||
result = await db.execute(
|
||||
select(ConversationMember.user_id).where(
|
||||
ConversationMember.conversation_id == conversation_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
@staticmethod
|
||||
async def get_related_user_ids(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
) -> List[str]:
|
||||
"""获取与指定用户有共同会话的所有用户ID"""
|
||||
# 先获取该用户参与的所有会话ID
|
||||
conv_ids_result = await db.execute(
|
||||
select(ConversationMember.conversation_id).where(
|
||||
ConversationMember.user_id == user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
conv_ids = [row[0] for row in conv_ids_result.all()]
|
||||
if not conv_ids:
|
||||
return []
|
||||
|
||||
# 获取这些会话中的所有成员user_id
|
||||
result = await db.execute(
|
||||
select(ConversationMember.user_id).where(
|
||||
ConversationMember.conversation_id.in_(conv_ids),
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
).distinct()
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
class ChatMessageService:
|
||||
"""聊天消息服务"""
|
||||
|
||||
@staticmethod
|
||||
async def send_message(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
sender_id: str,
|
||||
msg_type: str = "text",
|
||||
content: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
reply_to_id: Optional[str] = None,
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> ChatMessage:
|
||||
"""发送消息"""
|
||||
msg = ChatMessage(
|
||||
id=generate_nanoid(),
|
||||
conversation_id=conversation_id,
|
||||
sender_id=sender_id,
|
||||
msg_type=msg_type,
|
||||
content=content,
|
||||
file_id=file_id,
|
||||
reply_to_id=reply_to_id,
|
||||
extra=extra,
|
||||
)
|
||||
db.add(msg)
|
||||
await db.flush()
|
||||
|
||||
# 更新会话最后消息
|
||||
preview = content[:200] if content else f"[{msg_type}]"
|
||||
await db.execute(
|
||||
update(Conversation).where(Conversation.id == conversation_id).values(
|
||||
last_message_id=msg.id,
|
||||
last_message_time=msg.sys_create_datetime,
|
||||
last_message_preview=preview,
|
||||
)
|
||||
)
|
||||
|
||||
# 更新其他成员的未读数
|
||||
await db.execute(
|
||||
update(ConversationMember).where(
|
||||
ConversationMember.conversation_id == conversation_id,
|
||||
ConversationMember.user_id != sender_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
).values(
|
||||
unread_count=ConversationMember.unread_count + 1,
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
return msg
|
||||
|
||||
@staticmethod
|
||||
async def get_messages(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
before_id: Optional[str] = None,
|
||||
limit: int = 30,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""获取消息列表(游标分页,向上加载历史)"""
|
||||
query = select(ChatMessage).where(
|
||||
ChatMessage.conversation_id == conversation_id,
|
||||
ChatMessage.is_deleted == False, # noqa: E712
|
||||
)
|
||||
|
||||
if before_id:
|
||||
# 获取游标消息的创建时间
|
||||
cursor_result = await db.execute(
|
||||
select(ChatMessage.sys_create_datetime).where(ChatMessage.id == before_id)
|
||||
)
|
||||
cursor_time = cursor_result.scalar_one_or_none()
|
||||
if cursor_time:
|
||||
query = query.where(ChatMessage.sys_create_datetime < cursor_time)
|
||||
|
||||
# 多取一条判断是否有更多
|
||||
result = await db.execute(
|
||||
query.order_by(desc(ChatMessage.sys_create_datetime)).limit(limit + 1)
|
||||
)
|
||||
messages = list(result.scalars().all())
|
||||
has_more = len(messages) > limit
|
||||
messages = messages[:limit]
|
||||
|
||||
# 批量获取发送者信息
|
||||
sender_ids = list(set(m.sender_id for m in messages))
|
||||
sender_map: Dict[str, Any] = {}
|
||||
if sender_ids:
|
||||
from core.user.model import User
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id.in_(sender_ids))
|
||||
)
|
||||
sender_map = {u.id: u for u in user_result.scalars().all()}
|
||||
|
||||
# 获取回复消息预览
|
||||
reply_ids = [m.reply_to_id for m in messages if m.reply_to_id]
|
||||
reply_map: Dict[str, Dict] = {}
|
||||
if reply_ids:
|
||||
reply_result = await db.execute(
|
||||
select(ChatMessage).where(ChatMessage.id.in_(reply_ids))
|
||||
)
|
||||
for rm in reply_result.scalars().all():
|
||||
sender = sender_map.get(rm.sender_id)
|
||||
reply_map[rm.id] = {
|
||||
"reply_to_preview": rm.content[:100] if rm.content else f"[{rm.msg_type}]",
|
||||
"reply_to_sender_name": (sender.name or sender.username) if sender else None,
|
||||
}
|
||||
|
||||
# 批量获取文件信息
|
||||
file_ids = [m.file_id for m in messages if m.file_id]
|
||||
file_map: Dict[str, Any] = {}
|
||||
if file_ids:
|
||||
from core.file_manager.model import FileManager
|
||||
file_result = await db.execute(
|
||||
select(FileManager).where(FileManager.id.in_(file_ids))
|
||||
)
|
||||
for f in file_result.scalars().all():
|
||||
file_map[f.id] = {
|
||||
"file_name": f.name,
|
||||
"file_url": f"/api/core/file_manager/stream/{f.id}",
|
||||
"file_size": f.size,
|
||||
"file_ext": f.file_ext,
|
||||
}
|
||||
|
||||
# 组装结果
|
||||
items = []
|
||||
for m in messages:
|
||||
sender = sender_map.get(m.sender_id)
|
||||
file_info = file_map.get(m.file_id) if m.file_id else None
|
||||
item = {
|
||||
"id": m.id,
|
||||
"conversation_id": m.conversation_id,
|
||||
"sender_id": m.sender_id,
|
||||
"msg_type": m.msg_type,
|
||||
"content": m.content,
|
||||
"file_id": m.file_id,
|
||||
"reply_to_id": m.reply_to_id,
|
||||
"is_recalled": m.is_recalled,
|
||||
"recalled_at": m.recalled_at,
|
||||
"extra": m.extra,
|
||||
"sys_create_datetime": m.sys_create_datetime,
|
||||
"sender_name": (sender.name or sender.username) if sender else None,
|
||||
"sender_avatar": sender.avatar if sender else None,
|
||||
"reply_to_preview": None,
|
||||
"reply_to_sender_name": None,
|
||||
"file_name": file_info["file_name"] if file_info else None,
|
||||
"file_url": file_info["file_url"] if file_info else None,
|
||||
"file_size": file_info["file_size"] if file_info else None,
|
||||
"file_ext": file_info["file_ext"] if file_info else None,
|
||||
}
|
||||
if m.reply_to_id and m.reply_to_id in reply_map:
|
||||
item.update(reply_map[m.reply_to_id])
|
||||
items.append(item)
|
||||
|
||||
# 按时间正序返回(前端从上到下显示)
|
||||
items.reverse()
|
||||
return items, has_more
|
||||
|
||||
@staticmethod
|
||||
async def recall_message(
|
||||
db: AsyncSession,
|
||||
message_id: str,
|
||||
user_id: str,
|
||||
recall_timeout_minutes: int = 2,
|
||||
) -> Optional[ChatMessage]:
|
||||
"""撤回消息(限制时间内)"""
|
||||
result = await db.execute(
|
||||
select(ChatMessage).where(
|
||||
ChatMessage.id == message_id,
|
||||
ChatMessage.sender_id == user_id,
|
||||
ChatMessage.is_deleted == False, # noqa: E712
|
||||
ChatMessage.is_recalled == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
return None
|
||||
|
||||
# 检查时间限制
|
||||
if msg.sys_create_datetime:
|
||||
elapsed = datetime.utcnow() - msg.sys_create_datetime
|
||||
if elapsed > timedelta(minutes=recall_timeout_minutes):
|
||||
return None
|
||||
|
||||
msg.is_recalled = True
|
||||
msg.recalled_at = datetime.utcnow()
|
||||
msg.content = None
|
||||
|
||||
# 更新会话最后消息预览
|
||||
conv = await db.execute(
|
||||
select(Conversation).where(Conversation.last_message_id == message_id)
|
||||
)
|
||||
conversation = conv.scalar_one_or_none()
|
||||
if conversation:
|
||||
conversation.last_message_preview = "[消息已撤回]"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
return msg
|
||||
|
||||
@staticmethod
|
||||
async def mark_read(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
message_id: str,
|
||||
) -> bool:
|
||||
"""标记已读到某条消息"""
|
||||
member = await ConversationService.get_member(db, conversation_id, user_id)
|
||||
if not member:
|
||||
return False
|
||||
|
||||
member.last_read_message_id = message_id
|
||||
member.unread_count = 0
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_unread_messages(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取用户所有未读聊天消息(跨会话)"""
|
||||
from core.user.model import User
|
||||
|
||||
# 查询用户所在的有未读消息的会话
|
||||
member_result = await db.execute(
|
||||
select(ConversationMember).where(
|
||||
ConversationMember.user_id == user_id,
|
||||
ConversationMember.is_deleted == False, # noqa: E712
|
||||
ConversationMember.unread_count > 0,
|
||||
)
|
||||
)
|
||||
members = list(member_result.scalars().all())
|
||||
if not members:
|
||||
return []
|
||||
|
||||
# 收集所有未读消息
|
||||
all_messages: List[Dict[str, Any]] = []
|
||||
for member in members:
|
||||
conv_id = member.conversation_id
|
||||
last_read_id = member.last_read_message_id
|
||||
|
||||
# 构建查询:获取 last_read_message_id 之后的消息(非自己发的)
|
||||
query = select(ChatMessage).where(
|
||||
ChatMessage.conversation_id == conv_id,
|
||||
ChatMessage.is_deleted == False, # noqa: E712
|
||||
ChatMessage.is_recalled == False, # noqa: E712
|
||||
ChatMessage.sender_id != user_id,
|
||||
)
|
||||
|
||||
if last_read_id:
|
||||
# 获取最后已读消息的时间
|
||||
cursor_result = await db.execute(
|
||||
select(ChatMessage.sys_create_datetime).where(ChatMessage.id == last_read_id)
|
||||
)
|
||||
cursor_time = cursor_result.scalar_one_or_none()
|
||||
if cursor_time:
|
||||
query = query.where(ChatMessage.sys_create_datetime > cursor_time)
|
||||
|
||||
result = await db.execute(
|
||||
query.order_by(desc(ChatMessage.sys_create_datetime)).limit(limit)
|
||||
)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
for m in messages:
|
||||
all_messages.append({
|
||||
"id": m.id,
|
||||
"conversation_id": m.conversation_id,
|
||||
"sender_id": m.sender_id,
|
||||
"msg_type": m.msg_type,
|
||||
"content": m.content,
|
||||
"file_id": m.file_id,
|
||||
"is_recalled": m.is_recalled,
|
||||
"sys_create_datetime": m.sys_create_datetime,
|
||||
"sender_name": None,
|
||||
"sender_avatar": None,
|
||||
"conversation_name": None,
|
||||
})
|
||||
|
||||
# 批量获取发送者信息
|
||||
sender_ids = list(set(m["sender_id"] for m in all_messages))
|
||||
sender_map: Dict[str, Any] = {}
|
||||
if sender_ids:
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id.in_(sender_ids))
|
||||
)
|
||||
sender_map = {u.id: u for u in user_result.scalars().all()}
|
||||
|
||||
# 批量获取会话信息
|
||||
conv_ids = list(set(m["conversation_id"] for m in all_messages))
|
||||
conv_map: Dict[str, Any] = {}
|
||||
if conv_ids:
|
||||
conv_result = await db.execute(
|
||||
select(Conversation).where(Conversation.id.in_(conv_ids))
|
||||
)
|
||||
conv_map = {c.id: c for c in conv_result.scalars().all()}
|
||||
|
||||
# 填充发送者和会话信息
|
||||
for m in all_messages:
|
||||
sender = sender_map.get(m["sender_id"])
|
||||
if sender:
|
||||
m["sender_name"] = sender.name or sender.username
|
||||
m["sender_avatar"] = sender.avatar
|
||||
conv = conv_map.get(m["conversation_id"])
|
||||
if conv:
|
||||
m["conversation_name"] = conv.name
|
||||
|
||||
# 按时间倒序排列,取最新的 limit 条
|
||||
all_messages.sort(key=lambda x: x.get("sys_create_datetime") or "", reverse=True)
|
||||
return all_messages[:limit]
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(db: AsyncSession, message_id: str) -> Optional[ChatMessage]:
|
||||
"""获取消息"""
|
||||
result = await db.execute(
|
||||
select(ChatMessage).where(
|
||||
ChatMessage.id == message_id,
|
||||
ChatMessage.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def send_system_notification(
|
||||
db: AsyncSession,
|
||||
recipient_id: str,
|
||||
content: str,
|
||||
title: str = "",
|
||||
link_type: str = "",
|
||||
link_id: str = "",
|
||||
) -> Optional[ChatMessage]:
|
||||
"""
|
||||
以系统通知用户身份发送聊天消息
|
||||
|
||||
自动获取/创建系统通知用户,创建单聊会话,发送消息并通过 WebSocket 推送。
|
||||
extra 中携带 notify=True, title, link_type, link_id 供前端渲染通知卡片。
|
||||
"""
|
||||
from core.user.model import User
|
||||
|
||||
# 获取系统通知用户
|
||||
system_user_id = await ChatMessageService._get_system_notify_user_id(db)
|
||||
if not system_user_id:
|
||||
logger.warning("系统通知用户未配置且自动创建失败,跳过聊天通知")
|
||||
return None
|
||||
|
||||
# 获取或创建单聊会话
|
||||
conv = await ConversationService.get_or_create_private(db, system_user_id, recipient_id)
|
||||
|
||||
# 构建 extra 数据(前端根据 notify=True 渲染为通知卡片)
|
||||
extra = {"notify": True}
|
||||
if title:
|
||||
extra["title"] = title
|
||||
if link_type:
|
||||
extra["link_type"] = link_type
|
||||
if link_id:
|
||||
extra["link_id"] = link_id
|
||||
|
||||
# 发送消息
|
||||
msg = await ChatMessageService.send_message(
|
||||
db,
|
||||
conversation_id=conv.id,
|
||||
sender_id=system_user_id,
|
||||
msg_type="text",
|
||||
content=content,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# WebSocket 推送
|
||||
try:
|
||||
from core.websocket.consumers.base import manager
|
||||
|
||||
sender_result = await db.execute(select(User).where(User.id == system_user_id))
|
||||
sender = sender_result.scalar_one_or_none()
|
||||
|
||||
message_data = {
|
||||
"type": "chat.message",
|
||||
"data": {
|
||||
"id": msg.id,
|
||||
"conversation_id": msg.conversation_id,
|
||||
"sender_id": msg.sender_id,
|
||||
"msg_type": msg.msg_type,
|
||||
"content": msg.content,
|
||||
"file_id": None,
|
||||
"reply_to_id": None,
|
||||
"is_recalled": False,
|
||||
"extra": msg.extra,
|
||||
"sys_create_datetime": msg.sys_create_datetime.isoformat() if msg.sys_create_datetime else None,
|
||||
"sender_name": (sender.name or sender.username) if sender else "系统通知",
|
||||
"sender_avatar": sender.avatar if sender else None,
|
||||
},
|
||||
}
|
||||
await manager.send_to_user(recipient_id, message_data)
|
||||
except Exception as e:
|
||||
logger.error(f"聊天通知 WebSocket 推送失败: {e}")
|
||||
|
||||
return msg
|
||||
|
||||
@staticmethod
|
||||
async def _get_system_notify_user_id(db: AsyncSession) -> Optional[str]:
|
||||
"""获取系统通知用户 ID,如未配置则自动创建"""
|
||||
from app.config import settings
|
||||
from core.user.model import User
|
||||
|
||||
# 优先使用配置的 ID
|
||||
if settings.SYSTEM_NOTIFY_USER_ID:
|
||||
result = await db.execute(
|
||||
select(User.id).where(
|
||||
User.id == settings.SYSTEM_NOTIFY_USER_ID,
|
||||
User.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
return settings.SYSTEM_NOTIFY_USER_ID
|
||||
|
||||
# 查找已有的系统通知用户(username='system_notify')
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.username == "system_notify",
|
||||
User.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user.id
|
||||
|
||||
# 自动创建
|
||||
try:
|
||||
user = User(
|
||||
id=generate_nanoid(),
|
||||
username="system_notify",
|
||||
name="系统通知",
|
||||
user_type=0,
|
||||
user_status=1,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
logger.info(f"自动创建系统通知用户: {user.id}")
|
||||
return user.id
|
||||
except Exception as e:
|
||||
logger.error(f"创建系统通知用户失败: {e}")
|
||||
await db.rollback()
|
||||
return None
|
||||
@@ -0,0 +1,246 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
聊天 WebSocket 消费者
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from core.websocket.consumers.base import TokenAuthWebSocketConsumer, manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatConsumer(TokenAuthWebSocketConsumer):
|
||||
"""聊天WebSocket消费者"""
|
||||
|
||||
def __init__(self, websocket: WebSocket):
|
||||
super().__init__(websocket)
|
||||
self._conversation_ids: set = set()
|
||||
|
||||
async def connect(self):
|
||||
"""连接并加入用户聊天组"""
|
||||
await super().connect()
|
||||
if self.is_authenticated and self.user_id:
|
||||
# 加入用户聊天组(用于接收所有聊天消息)
|
||||
await manager.group_add(
|
||||
f"chat_user_{self.user_id}",
|
||||
self.websocket,
|
||||
)
|
||||
# 广播上线事件给相关会话成员
|
||||
await self._broadcast_presence("online")
|
||||
|
||||
async def disconnect(self, close_code: int = 1000):
|
||||
"""断开连接"""
|
||||
if self.user_id:
|
||||
# 先广播下线事件(在移除连接之前检查是否还有其他连接)
|
||||
await manager.group_discard(
|
||||
f"chat_user_{self.user_id}",
|
||||
self.websocket,
|
||||
)
|
||||
# 离开所有会话组
|
||||
for conv_id in self._conversation_ids:
|
||||
await manager.group_discard(
|
||||
f"chat_conv_{conv_id}",
|
||||
self.websocket,
|
||||
)
|
||||
await super().disconnect(close_code)
|
||||
# 断开后检查用户是否还有其他连接,没有则广播离线
|
||||
if self.user_id and not manager.is_online(self.user_id):
|
||||
await self._broadcast_presence("offline")
|
||||
|
||||
async def _broadcast_presence(self, status: str):
|
||||
"""广播用户在线状态变更给所有相关会话成员"""
|
||||
try:
|
||||
from app.database import AsyncSessionLocal
|
||||
from core.chat.service import ConversationService
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 获取该用户参与的所有会话的成员
|
||||
related_user_ids = await ConversationService.get_related_user_ids(db, self.user_id)
|
||||
|
||||
presence_data = {
|
||||
"type": "chat.presence",
|
||||
"data": {
|
||||
"user_id": self.user_id,
|
||||
"status": status,
|
||||
},
|
||||
}
|
||||
for uid in related_user_ids:
|
||||
if uid != self.user_id:
|
||||
await manager.send_to_user(uid, presence_data)
|
||||
except Exception as e:
|
||||
logger.error(f"广播在线状态失败: {e}")
|
||||
|
||||
async def handle_message(self, data: Dict[str, Any]):
|
||||
"""处理聊天相关消息"""
|
||||
message_type = data.get("type", "unknown")
|
||||
payload = data.get("data", {})
|
||||
|
||||
if message_type == "chat.send":
|
||||
await self._handle_send(payload)
|
||||
elif message_type == "chat.typing":
|
||||
await self._handle_typing(payload)
|
||||
elif message_type == "chat.read":
|
||||
await self._handle_read(payload)
|
||||
elif message_type == "chat.join":
|
||||
await self._handle_join(payload)
|
||||
else:
|
||||
await self.send_error(f"未知消息类型: {message_type}")
|
||||
|
||||
async def _handle_send(self, payload: Dict[str, Any]):
|
||||
"""处理发送消息"""
|
||||
conversation_id = payload.get("conversation_id")
|
||||
msg_type = payload.get("msg_type", "text")
|
||||
content = payload.get("content")
|
||||
file_id = payload.get("file_id")
|
||||
reply_to_id = payload.get("reply_to_id")
|
||||
extra = payload.get("extra")
|
||||
|
||||
if not conversation_id:
|
||||
await self.send_error("缺少 conversation_id")
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database import AsyncSessionLocal
|
||||
from core.chat.service import ConversationService, ChatMessageService
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 验证成员身份
|
||||
if not await ConversationService.is_member(db, conversation_id, self.user_id):
|
||||
await self.send_error("非会话成员")
|
||||
return
|
||||
|
||||
# 发送消息
|
||||
msg = await ChatMessageService.send_message(
|
||||
db,
|
||||
conversation_id=conversation_id,
|
||||
sender_id=self.user_id,
|
||||
msg_type=msg_type,
|
||||
content=content,
|
||||
file_id=file_id,
|
||||
reply_to_id=reply_to_id,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# 获取发送者信息
|
||||
from core.user.model import User
|
||||
from sqlalchemy import select
|
||||
sender_result = await db.execute(select(User).where(User.id == self.user_id))
|
||||
sender = sender_result.scalar_one_or_none()
|
||||
|
||||
# 获取文件信息
|
||||
file_info = {}
|
||||
if msg.file_id:
|
||||
from core.file_manager.model import FileManager
|
||||
file_result = await db.execute(
|
||||
select(FileManager).where(FileManager.id == msg.file_id)
|
||||
)
|
||||
f = file_result.scalar_one_or_none()
|
||||
if f:
|
||||
file_info = {
|
||||
"file_name": f.name,
|
||||
"file_url": f"/api/core/file_manager/stream/{f.id}",
|
||||
"file_size": f.size,
|
||||
"file_ext": f.file_ext,
|
||||
}
|
||||
|
||||
# 推送给会话所有成员
|
||||
member_ids = await ConversationService.get_member_user_ids(db, conversation_id)
|
||||
message_data = {
|
||||
"type": "chat.message",
|
||||
"data": {
|
||||
"id": msg.id,
|
||||
"conversation_id": msg.conversation_id,
|
||||
"sender_id": msg.sender_id,
|
||||
"msg_type": msg.msg_type,
|
||||
"content": msg.content,
|
||||
"file_id": msg.file_id,
|
||||
"reply_to_id": msg.reply_to_id,
|
||||
"is_recalled": msg.is_recalled,
|
||||
"extra": msg.extra,
|
||||
"sys_create_datetime": msg.sys_create_datetime.isoformat() if msg.sys_create_datetime else None,
|
||||
"sender_name": (sender.name or sender.username) if sender else None,
|
||||
"sender_avatar": sender.avatar if sender else None,
|
||||
**file_info,
|
||||
},
|
||||
}
|
||||
for uid in member_ids:
|
||||
await manager.send_to_user(uid, message_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e}")
|
||||
await self.send_error(f"发送消息失败: {str(e)}")
|
||||
|
||||
async def _handle_typing(self, payload: Dict[str, Any]):
|
||||
"""处理正在输入状态"""
|
||||
conversation_id = payload.get("conversation_id")
|
||||
if not conversation_id:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database import AsyncSessionLocal
|
||||
from core.chat.service import ConversationService
|
||||
from core.user.model import User
|
||||
from sqlalchemy import select
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
member_ids = await ConversationService.get_member_user_ids(db, conversation_id)
|
||||
sender_result = await db.execute(select(User).where(User.id == self.user_id))
|
||||
sender = sender_result.scalar_one_or_none()
|
||||
|
||||
typing_data = {
|
||||
"type": "chat.typing",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"user_id": self.user_id,
|
||||
"user_name": (sender.name or sender.username) if sender else None,
|
||||
},
|
||||
}
|
||||
for uid in member_ids:
|
||||
if uid != self.user_id:
|
||||
await manager.send_to_user(uid, typing_data)
|
||||
except Exception as e:
|
||||
logger.error(f"发送typing状态失败: {e}")
|
||||
|
||||
async def _handle_read(self, payload: Dict[str, Any]):
|
||||
"""处理已读标记"""
|
||||
conversation_id = payload.get("conversation_id")
|
||||
message_id = payload.get("message_id")
|
||||
if not conversation_id or not message_id:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database import AsyncSessionLocal
|
||||
from core.chat.service import ConversationService, ChatMessageService
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await ChatMessageService.mark_read(db, conversation_id, self.user_id, message_id)
|
||||
|
||||
# 通知其他成员已读回执
|
||||
member_ids = await ConversationService.get_member_user_ids(db, conversation_id)
|
||||
read_data = {
|
||||
"type": "chat.read_receipt",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"user_id": self.user_id,
|
||||
"message_id": message_id,
|
||||
},
|
||||
}
|
||||
for uid in member_ids:
|
||||
if uid != self.user_id:
|
||||
await manager.send_to_user(uid, read_data)
|
||||
except Exception as e:
|
||||
logger.error(f"标记已读失败: {e}")
|
||||
|
||||
async def _handle_join(self, payload: Dict[str, Any]):
|
||||
"""加入会话组(用于接收该会话的实时消息)"""
|
||||
conversation_id = payload.get("conversation_id")
|
||||
if not conversation_id:
|
||||
return
|
||||
|
||||
self._conversation_ids.add(conversation_id)
|
||||
await manager.group_add(f"chat_conv_{conversation_id}", self.websocket)
|
||||
await self.send_message("chat.joined", f"已加入会话 {conversation_id}")
|
||||
Reference in New Issue
Block a user