1.9
This commit is contained in:
@@ -16,6 +16,11 @@ from app.models.sync_seed import SyncQuestion, SyncSeed
|
||||
from app.models.chat_climate import ChatClimate
|
||||
from app.models.flash_event import FlashEvent, FlashParticipation
|
||||
from app.models.user_streak import UserStreak
|
||||
from app.models.group_announcement import GroupAnnouncement
|
||||
from app.models.message_reaction import MessageReaction
|
||||
from app.models.user_block import UserBlock
|
||||
from app.models.password_reset import PasswordResetToken
|
||||
from app.models.friend_tag import FriendTag, FriendTagAssignment
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -38,4 +43,10 @@ __all__ = [
|
||||
"FlashEvent",
|
||||
"FlashParticipation",
|
||||
"UserStreak",
|
||||
"GroupAnnouncement",
|
||||
"MessageReaction",
|
||||
"UserBlock",
|
||||
"PasswordResetToken",
|
||||
"FriendTag",
|
||||
"FriendTagAssignment",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -17,6 +17,7 @@ class Conversation(Base):
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # 群头像
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True) # 群描述
|
||||
creator_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
mute_all: Mapped[bool] = mapped_column(Boolean, default=False) # 全员禁言(仅成员不能发,管理员可以)
|
||||
last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_message_preview: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -24,6 +24,9 @@ class ConversationMember(Base):
|
||||
role: Mapped[str] = mapped_column(String(20), default="member") # owner / admin / member
|
||||
nickname: Mapped[str | None] = mapped_column(String(50), nullable=True) # 群内昵称
|
||||
last_read_message_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
is_pinned: Mapped[bool] = mapped_column(Boolean, default=False) # 置顶
|
||||
pinned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
is_muted: Mapped[bool] = mapped_column(Boolean, default=False) # 免打扰
|
||||
joined_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
|
||||
left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""好友分组/标签模型"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FriendTag(Base):
|
||||
"""好友标签(一个好友可有多个标签)"""
|
||||
__tablename__ = "friend_tags"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) # 标签所有者
|
||||
name: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
|
||||
class FriendTagAssignment(Base):
|
||||
"""好友-标签 关联"""
|
||||
__tablename__ = "friend_tag_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("friend_id", "tag_id", name="uq_friend_tag"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
friend_id: Mapped[str] = mapped_column(String(36), ForeignKey("friends.id", ondelete="CASCADE"))
|
||||
tag_id: Mapped[str] = mapped_column(String(36), ForeignKey("friend_tags.id", ondelete="CASCADE"))
|
||||
@@ -0,0 +1,25 @@
|
||||
"""群公告模型"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class GroupAnnouncement(Base):
|
||||
__tablename__ = "group_announcements"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
author_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
content: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=lambda: datetime.utcnow(), onupdate=lambda: datetime.utcnow()
|
||||
)
|
||||
|
||||
author = relationship("User", foreign_keys=[author_id])
|
||||
@@ -21,12 +21,15 @@ class Message(Base):
|
||||
sender_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(20), default="text") # text / image / file / system
|
||||
type: Mapped[str] = mapped_column(String(20), default="text") # text / image / file / voice / system
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
reply_to_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("messages.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
mentions: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON: 被@的用户ID列表
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_recalled: Mapped[bool] = mapped_column(Boolean, default=False) # 撤回
|
||||
recalled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=lambda: datetime.utcnow(), index=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""消息表情回应模型"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MessageReaction(Base):
|
||||
__tablename__ = "message_reactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("message_id", "user_id", "emoji", name="uq_msg_reaction"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
message_id: Mapped[str] = mapped_column(String(36), ForeignKey("messages.id", ondelete="CASCADE"))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
|
||||
emoji: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
message = relationship("Message")
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
@@ -0,0 +1,21 @@
|
||||
"""密码重置令牌模型"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False) # 验证码的 hash
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
used: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
@@ -18,7 +18,11 @@ class User(Base):
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
bio: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="offline") # online/offline/away
|
||||
status: Mapped[str] = mapped_column(String(20), default="offline") # online/offline/away (presence)
|
||||
custom_status: Mapped[str | None] = mapped_column(String(50), nullable=True) # 个人心情状态文字
|
||||
status_emoji: Mapped[str | None] = mapped_column(String(10), nullable=True) # 心情 emoji
|
||||
status_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # 状态到期自动清空
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False) # 邮箱是否已验证
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
banned_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""用户拉黑模型"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserBlock(Base):
|
||||
__tablename__ = "user_blocks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("blocker_id", "blocked_id", name="uq_user_block"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
blocker_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
|
||||
blocked_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
blocker = relationship("User", foreign_keys=[blocker_id])
|
||||
blocked = relationship("User", foreign_keys=[blocked_id])
|
||||
+113
-1
@@ -1,16 +1,38 @@
|
||||
"""认证路由"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.models.user import User
|
||||
from app.models.password_reset import PasswordResetToken
|
||||
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, RefreshRequest
|
||||
from app.services.auth_service import AuthService
|
||||
from app.utils.security import decode_refresh_token
|
||||
from app.services.email_service import generate_code, hash_code, send_verification_email
|
||||
from app.utils.security import decode_refresh_token, hash_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ForgotRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class ResetRequest(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""用户注册"""
|
||||
@@ -54,3 +76,93 @@ async def refresh_token(req: RefreshRequest, db: AsyncSession = Depends(get_db))
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/forgot")
|
||||
async def forgot_password(req: ForgotRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""找回密码:生成验证码(开发期打印到日志)"""
|
||||
result = await db.execute(select(User).where(User.email == req.email))
|
||||
user = result.scalars().first()
|
||||
# 出于安全,无论用户是否存在都返回成功
|
||||
if user:
|
||||
import uuid
|
||||
code = generate_code()
|
||||
token = PasswordResetToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
token_hash=hash_code(code),
|
||||
expires_at=datetime.utcnow() + timedelta(minutes=15),
|
||||
)
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await send_verification_email(req.email, code, "找回密码")
|
||||
return {"success": True, "message": "若该邮箱已注册,验证码已发送(开发期见后端日志)"}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_password(req: ResetRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""用验证码重置密码"""
|
||||
result = await db.execute(select(User).where(User.email == req.email))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="用户不存在")
|
||||
# 找匹配且未过期的 token
|
||||
tokens = await db.execute(
|
||||
select(PasswordResetToken).where(
|
||||
PasswordResetToken.user_id == user.id,
|
||||
PasswordResetToken.used == False,
|
||||
).order_by(PasswordResetToken.created_at.desc())
|
||||
)
|
||||
matched = None
|
||||
for t in tokens.scalars().all():
|
||||
if t.token_hash == hash_code(req.code) and t.expires_at > datetime.utcnow():
|
||||
matched = t
|
||||
break
|
||||
if not matched:
|
||||
raise HTTPException(status_code=400, detail="验证码无效或已过期")
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
matched.used = True
|
||||
await db.flush()
|
||||
return {"success": True, "message": "密码已重置"}
|
||||
|
||||
|
||||
@router.post("/send-verify-email")
|
||||
async def send_verify_email(req: ForgotRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""发送邮箱验证码"""
|
||||
result = await db.execute(select(User).where(User.email == req.email))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
code = generate_code()
|
||||
import uuid
|
||||
token = PasswordResetToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
token_hash=hash_code(code),
|
||||
expires_at=datetime.utcnow() + timedelta(minutes=15),
|
||||
)
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await send_verification_email(req.email, code, "邮箱验证")
|
||||
return {"success": True, "message": "验证码已发送(开发期见后端日志)"}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email(req: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""验证邮箱"""
|
||||
result = await db.execute(select(User).where(User.email == req.email))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="用户不存在")
|
||||
tokens = await db.execute(
|
||||
select(PasswordResetToken).where(
|
||||
PasswordResetToken.user_id == user.id,
|
||||
PasswordResetToken.used == False,
|
||||
).order_by(PasswordResetToken.created_at.desc())
|
||||
)
|
||||
for t in tokens.scalars().all():
|
||||
if t.token_hash == hash_code(req.code) and t.expires_at > datetime.utcnow():
|
||||
t.used = True
|
||||
user.email_verified = True
|
||||
await db.flush()
|
||||
return {"success": True, "email_verified": True}
|
||||
raise HTTPException(status_code=400, detail="验证码无效或已过期")
|
||||
|
||||
@@ -10,12 +10,19 @@ from app.schemas.conversation import (
|
||||
GroupCreate, GroupUpdate, MemberAdd, RoleUpdate,
|
||||
)
|
||||
from app.services.conversation_service import ConversationService
|
||||
from app.services.draft_service import DraftService
|
||||
from app.websocket.events import EventType
|
||||
from app.websocket.manager import manager
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConvPrefs(BaseModel):
|
||||
is_pinned: bool | None = None
|
||||
is_muted: bool | None = None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[dict])
|
||||
async def list_conversations(
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -181,3 +188,86 @@ async def update_member_role(
|
||||
return {"success": True, "message": "角色已更新"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{conversation_id}/prefs")
|
||||
async def update_prefs(
|
||||
conversation_id: str,
|
||||
req: ConvPrefs,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新会话个人偏好(置顶/免打扰)"""
|
||||
service = ConversationService(db)
|
||||
try:
|
||||
return await service.update_prefs(conversation_id, user.id, req.is_pinned, req.is_muted)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{conversation_id}/draft")
|
||||
async def get_draft(
|
||||
conversation_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取草稿"""
|
||||
service = DraftService()
|
||||
return {"draft": await service.get(user.id, conversation_id)}
|
||||
|
||||
|
||||
@router.put("/{conversation_id}/draft")
|
||||
async def save_draft(
|
||||
conversation_id: str,
|
||||
body: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""保存草稿"""
|
||||
service = DraftService()
|
||||
await service.set(user.id, conversation_id, body.get("draft", ""))
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/{conversation_id}/announcement")
|
||||
async def get_announcement(
|
||||
conversation_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取群公告"""
|
||||
from app.services.announcement_service import AnnouncementService
|
||||
service = AnnouncementService(db)
|
||||
return await service.get(conversation_id)
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/announcement")
|
||||
async def set_announcement(
|
||||
conversation_id: str,
|
||||
body: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设置群公告(管理员)"""
|
||||
from app.services.announcement_service import AnnouncementService
|
||||
service = AnnouncementService(db)
|
||||
try:
|
||||
return await service.upsert(conversation_id, user.id, body.get("content", ""))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{conversation_id}/mute-all")
|
||||
async def toggle_mute_all(
|
||||
conversation_id: str,
|
||||
body: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""全员禁言开关(群主/管理员)"""
|
||||
service = ConversationService(db)
|
||||
try:
|
||||
await service.update_group(conversation_id, user.id, mute_all=body.get("mute_all"))
|
||||
return {"success": True, "mute_all": body.get("mute_all")}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -144,3 +144,76 @@ async def remove_friend(
|
||||
service = FriendService(db)
|
||||
await service.remove_friend(user.id, friend_id)
|
||||
return {"success": True, "message": "已删除好友"}
|
||||
|
||||
|
||||
# ============ 好友分组/标签 ============
|
||||
|
||||
@router.get("/tags/list")
|
||||
async def list_tags(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取我的好友标签"""
|
||||
from app.models.friend_tag import FriendTag
|
||||
result = await db.execute(
|
||||
select(FriendTag).where(FriendTag.user_id == user.id).order_by(FriendTag.created_at)
|
||||
)
|
||||
return [{"id": t.id, "name": t.name} for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/tags/create")
|
||||
async def create_tag(
|
||||
body: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建好友标签"""
|
||||
from app.models.friend_tag import FriendTag
|
||||
import uuid
|
||||
tag = FriendTag(id=str(uuid.uuid4()), user_id=user.id, name=body.get("name", "")[:30])
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
return {"id": tag.id, "name": tag.name}
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_id}")
|
||||
async def delete_tag(
|
||||
tag_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除好友标签"""
|
||||
from app.models.friend_tag import FriendTag
|
||||
from sqlalchemy import delete as sql_delete
|
||||
await db.execute(sql_delete(FriendTag).where(FriendTag.id == tag_id, FriendTag.user_id == user.id))
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.put("/{friend_id}/tags")
|
||||
async def assign_tags(
|
||||
friend_id: str,
|
||||
body: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""给好友分配标签(覆盖式)"""
|
||||
from app.models.friend_tag import FriendTag, FriendTagAssignment
|
||||
from app.models.friend import Friend
|
||||
from sqlalchemy import delete as sql_delete
|
||||
# 找到该好友关系记录
|
||||
fr = await db.execute(
|
||||
select(Friend).where(Friend.user_id == user.id, Friend.friend_user_id == friend_id)
|
||||
)
|
||||
friendship = fr.scalars().first()
|
||||
if not friendship:
|
||||
raise HTTPException(status_code=404, detail="好友不存在")
|
||||
# 清空旧分配
|
||||
await db.execute(
|
||||
sql_delete(FriendTagAssignment).where(FriendTagAssignment.friend_id == friendship.id)
|
||||
)
|
||||
# 新分配
|
||||
tag_ids = body.get("tag_ids", [])
|
||||
import uuid
|
||||
for tid in tag_ids:
|
||||
db.add(FriendTagAssignment(id=str(uuid.uuid4()), friend_id=friendship.id, tag_id=tid))
|
||||
return {"success": True}
|
||||
|
||||
@@ -79,3 +79,60 @@ async def delete_message(
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/messages/{message_id}/recall")
|
||||
async def recall_message(
|
||||
conversation_id: str,
|
||||
message_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""撤回消息(2 分钟内)"""
|
||||
service = MessageService(db)
|
||||
try:
|
||||
await service.recall_message(message_id, user.id)
|
||||
# 广播撤回事件
|
||||
from app.services.conversation_service import ConversationService
|
||||
conv_service = ConversationService(db)
|
||||
detail = await conv_service.get_conversation_detail(conversation_id, user.id)
|
||||
if detail and "members" in detail:
|
||||
from app.websocket.events import EventType
|
||||
from app.websocket.manager import manager
|
||||
member_ids = [m["user_id"] for m in detail["members"]]
|
||||
for uid in member_ids:
|
||||
await manager.send_to_user(uid, EventType.CHAT_MESSAGE_RECALLED, {
|
||||
"conversation_id": conversation_id, "message_id": message_id,
|
||||
"recalled_by": user.id,
|
||||
})
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/messages/{message_id}/reactions")
|
||||
async def add_reaction(
|
||||
conversation_id: str,
|
||||
message_id: str,
|
||||
body: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""添加/取消表情回应(toggle)"""
|
||||
service = MessageService(db)
|
||||
result = await service.react(message_id, user.id, body.get("emoji", ""))
|
||||
# 广播回应变化
|
||||
from app.services.conversation_service import ConversationService
|
||||
from app.websocket.events import EventType
|
||||
from app.websocket.manager import manager
|
||||
conv_service = ConversationService(db)
|
||||
detail = await conv_service.get_conversation_detail(conversation_id, user.id)
|
||||
if detail and "members" in detail:
|
||||
member_ids = [m["user_id"] for m in detail["members"]]
|
||||
etype = EventType.CHAT_REACTION_ADDED if result["action"] == "added" else EventType.CHAT_REACTION_REMOVED
|
||||
for uid in member_ids:
|
||||
await manager.send_to_user(uid, etype, {
|
||||
"conversation_id": conversation_id, "message_id": message_id,
|
||||
"emoji": result["emoji"], "user_id": user.id,
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""用户路由"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.user_block import UserBlock
|
||||
from app.schemas.user import (
|
||||
UserRead, UserProfile, UserUpdate, UserSearchResult,
|
||||
PasswordChange, EmailChange,
|
||||
@@ -14,6 +19,12 @@ from app.services.user_service import UserService
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
custom_status: str | None = None
|
||||
status_emoji: str | None = None
|
||||
expires_hours: int | None = None
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
"""获取当前用户信息"""
|
||||
@@ -85,3 +96,75 @@ async def get_user(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/me/status")
|
||||
async def update_status(
|
||||
req: StatusUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设置个人心情状态(对外可见,可设过期)"""
|
||||
user.custom_status = req.custom_status or None
|
||||
user.status_emoji = req.status_emoji or None
|
||||
if req.expires_hours:
|
||||
from datetime import timedelta
|
||||
user.status_expires_at = datetime.utcnow() + timedelta(hours=req.expires_hours)
|
||||
else:
|
||||
user.status_expires_at = None
|
||||
await db.flush()
|
||||
return {
|
||||
"custom_status": user.custom_status,
|
||||
"status_emoji": user.status_emoji,
|
||||
"status_expires_at": user.status_expires_at.isoformat() if user.status_expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{user_id}/block")
|
||||
async def block_user(
|
||||
user_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""拉黑用户"""
|
||||
if user_id == user.id:
|
||||
raise HTTPException(status_code=400, detail="不能拉黑自己")
|
||||
existing = await db.execute(
|
||||
select(UserBlock).where(UserBlock.blocker_id == user.id, UserBlock.blocked_id == user_id)
|
||||
)
|
||||
if not existing.scalars().first():
|
||||
import uuid
|
||||
db.add(UserBlock(id=str(uuid.uuid4()), blocker_id=user.id, blocked_id=user_id))
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/{user_id}/block")
|
||||
async def unblock_user(
|
||||
user_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""取消拉黑"""
|
||||
from sqlalchemy import delete as sql_delete
|
||||
await db.execute(
|
||||
sql_delete(UserBlock).where(UserBlock.blocker_id == user.id, UserBlock.blocked_id == user_id)
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/me/blocks")
|
||||
async def list_blocks(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""我的黑名单"""
|
||||
result = await db.execute(
|
||||
select(UserBlock).where(UserBlock.blocker_id == user.id)
|
||||
)
|
||||
blocks = []
|
||||
for b in result.scalars().all():
|
||||
u = await db.execute(select(User).where(User.id == b.blocked_id))
|
||||
bu = u.scalars().first()
|
||||
if bu:
|
||||
blocks.append({"user_id": bu.id, "username": bu.username, "nickname": bu.nickname})
|
||||
return blocks
|
||||
|
||||
@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
|
||||
class MessageSend(BaseModel):
|
||||
conversation_id: str
|
||||
content: str = Field(..., min_length=1, max_length=5000)
|
||||
type: str = Field(default="text", pattern="^(text|image|file)$")
|
||||
type: str = Field(default="text", pattern="^(text|image|file|voice)$")
|
||||
reply_to_id: str | None = None
|
||||
mentioned_user_ids: list[str] | None = None
|
||||
|
||||
|
||||
class MessageRead(BaseModel):
|
||||
@@ -23,7 +24,10 @@ class MessageRead(BaseModel):
|
||||
reply_to_id: str | None = None
|
||||
reply_to_content: str | None = None
|
||||
reply_to_sender_name: str | None = None
|
||||
mentions: list[str] | None = None
|
||||
is_deleted: bool = False
|
||||
is_recalled: bool = False
|
||||
reactions: list[dict] = []
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""群公告服务"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.group_announcement import GroupAnnouncement
|
||||
from app.services.conversation_service import ConversationService
|
||||
|
||||
|
||||
class AnnouncementService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(self, conversation_id: str) -> dict | None:
|
||||
result = await self.db.execute(
|
||||
select(GroupAnnouncement).where(
|
||||
GroupAnnouncement.conversation_id == conversation_id
|
||||
).order_by(GroupAnnouncement.updated_at.desc())
|
||||
)
|
||||
ann = result.scalars().first()
|
||||
if not ann:
|
||||
return None
|
||||
return self._to_dict(ann)
|
||||
|
||||
async def upsert(self, conversation_id: str, author_id: str, content: str) -> dict:
|
||||
# 校验管理员
|
||||
conv_service = ConversationService(self.db)
|
||||
await conv_service._get_conv_if_admin(conversation_id, author_id)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(GroupAnnouncement).where(GroupAnnouncement.conversation_id == conversation_id)
|
||||
)
|
||||
ann = result.scalars().first()
|
||||
if ann:
|
||||
ann.content = content
|
||||
ann.author_id = author_id
|
||||
ann.updated_at = datetime.utcnow()
|
||||
else:
|
||||
ann = GroupAnnouncement(
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conversation_id,
|
||||
author_id=author_id, content=content,
|
||||
)
|
||||
self.db.add(ann)
|
||||
await self.db.flush()
|
||||
return self._to_dict(ann)
|
||||
|
||||
def _to_dict(self, ann: GroupAnnouncement) -> dict:
|
||||
return {
|
||||
"id": ann.id,
|
||||
"conversation_id": ann.conversation_id,
|
||||
"author_id": ann.author_id,
|
||||
"content": ann.content,
|
||||
"updated_at": ann.updated_at.isoformat() if ann.updated_at else None,
|
||||
}
|
||||
@@ -178,6 +178,21 @@ class ConversationService:
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def update_prefs(self, conv_id: str, user_id: str,
|
||||
is_pinned: bool | None = None,
|
||||
is_muted: bool | None = None) -> dict:
|
||||
"""更新会话个人偏好(置顶/免打扰)"""
|
||||
member = await self._get_member(conv_id, user_id)
|
||||
if not member:
|
||||
raise ValueError("无权访问该会话")
|
||||
if is_pinned is not None:
|
||||
member.is_pinned = is_pinned
|
||||
member.pinned_at = datetime.utcnow() if is_pinned else None
|
||||
if is_muted is not None:
|
||||
member.is_muted = is_muted
|
||||
await self.db.flush()
|
||||
return {"is_pinned": member.is_pinned, "is_muted": member.is_muted}
|
||||
|
||||
async def get_user_conversations(self, user_id: str) -> list[dict]:
|
||||
"""获取用户的会话列表"""
|
||||
result = await self.db.execute(
|
||||
@@ -217,9 +232,15 @@ class ConversationService:
|
||||
"last_message_at": conv.last_message_at,
|
||||
"unread_count": unread,
|
||||
"created_at": conv.created_at,
|
||||
"is_pinned": member.is_pinned,
|
||||
"is_muted": member.is_muted,
|
||||
})
|
||||
|
||||
conversations.sort(key=lambda x: x["last_message_at"] or x["created_at"], reverse=True)
|
||||
# 排序:置顶优先,组内按最后消息时间倒序
|
||||
conversations.sort(key=lambda x: (
|
||||
x["is_pinned"],
|
||||
x["last_message_at"] or x["created_at"],
|
||||
), reverse=True)
|
||||
return conversations
|
||||
|
||||
async def get_conversation_detail(self, conv_id: str, user_id: str) -> dict | None:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""草稿服务(Redis 存储,零迁移)"""
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class DraftService:
|
||||
def __init__(self):
|
||||
self._redis = None
|
||||
|
||||
async def _get_redis(self):
|
||||
if self._redis is None:
|
||||
self._redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return self._redis
|
||||
|
||||
async def get(self, user_id: str, conv_id: str) -> str:
|
||||
r = await self._get_redis()
|
||||
return await r.get(f"draft:{user_id}:{conv_id}") or ""
|
||||
|
||||
async def set(self, user_id: str, conv_id: str, text: str):
|
||||
r = await self._get_redis()
|
||||
key = f"draft:{user_id}:{conv_id}"
|
||||
if text:
|
||||
await r.set(key, text, ex=30 * 86400) # 30 天 TTL
|
||||
else:
|
||||
await r.delete(key)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""邮件服务(假实现:验证码打印到日志;以后替换为 SMTP)"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
|
||||
def generate_code() -> str:
|
||||
"""生成 6 位数字验证码"""
|
||||
return f"{secrets.randbelow(1000000):06d}"
|
||||
|
||||
|
||||
def hash_code(code: str) -> str:
|
||||
return hashlib.sha256(code.encode()).hexdigest()
|
||||
|
||||
|
||||
async def send_verification_email(to_email: str, code: str, purpose: str = "验证"):
|
||||
"""发送验证邮件(开发期假实现:打印到日志)
|
||||
|
||||
生产环境替换此函数为真实 SMTP 发送即可,调用方无需改动。
|
||||
"""
|
||||
print(f"\n{'=' * 50}")
|
||||
print(f"📧 [邮件服务-开发模式] 收件人: {to_email}")
|
||||
print(f"📋 用途: {purpose}")
|
||||
print(f"🔢 验证码: {code}")
|
||||
print(f"{'=' * 50}\n")
|
||||
return True
|
||||
@@ -1,5 +1,6 @@
|
||||
"""消息服务"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.message import Message
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.conversation_member import ConversationMember
|
||||
from app.models.message_reaction import MessageReaction
|
||||
|
||||
|
||||
class MessageService:
|
||||
@@ -17,8 +19,24 @@ class MessageService:
|
||||
|
||||
async def send_message(self, conversation_id: str, sender_id: str,
|
||||
content: str, msg_type: str = "text",
|
||||
reply_to_id: str | None = None) -> Message:
|
||||
reply_to_id: str | None = None,
|
||||
mentioned_user_ids: list[str] | None = None) -> Message:
|
||||
"""发送消息"""
|
||||
# 全员禁言检查(群聊,非管理员不能发)
|
||||
conv_result = await self.db.execute(
|
||||
select(Conversation).where(Conversation.id == conversation_id)
|
||||
)
|
||||
conv = conv_result.scalars().first()
|
||||
if conv and conv.type == "group" and conv.mute_all:
|
||||
from app.models.conversation_member import ConversationMember as CM
|
||||
m_result = await self.db.execute(
|
||||
select(CM).where(CM.conversation_id == conversation_id, CM.user_id == sender_id)
|
||||
)
|
||||
m = m_result.scalars().first()
|
||||
if m and m.role == "member":
|
||||
raise ValueError("群已开启全员禁言")
|
||||
|
||||
import json
|
||||
message = Message(
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conversation_id,
|
||||
@@ -26,6 +44,7 @@ class MessageService:
|
||||
type=msg_type,
|
||||
content=content,
|
||||
reply_to_id=reply_to_id,
|
||||
mentions=json.dumps(mentioned_user_ids) if mentioned_user_ids else None,
|
||||
)
|
||||
self.db.add(message)
|
||||
|
||||
@@ -101,6 +120,27 @@ class MessageService:
|
||||
)
|
||||
reply_senders_map = {u.id: u for u in reply_senders_result.scalars().all()}
|
||||
|
||||
# 批量预加载这些消息的表情回应
|
||||
msg_ids = [m.id for m in messages]
|
||||
reaction_result = await self.db.execute(
|
||||
select(MessageReaction).where(MessageReaction.message_id.in_(msg_ids))
|
||||
)
|
||||
from app.models.user import User as U
|
||||
reactions_raw = reaction_result.scalars().all()
|
||||
reaction_user_ids = list(set(r.user_id for r in reactions_raw))
|
||||
reaction_users_map: dict[str, U] = {}
|
||||
if reaction_user_ids:
|
||||
ru_result = await self.db.execute(select(U).where(U.id.in_(reaction_user_ids)))
|
||||
reaction_users_map = {u.id: u for u in ru_result.scalars().all()}
|
||||
reaction_map: dict[str, list[dict]] = {}
|
||||
for r in reactions_raw:
|
||||
u = reaction_users_map.get(r.user_id)
|
||||
reaction_map.setdefault(r.message_id, []).append({
|
||||
"emoji": r.emoji,
|
||||
"user_id": r.user_id,
|
||||
"username": u.username if u else None,
|
||||
})
|
||||
|
||||
message_list = []
|
||||
for msg in reversed(messages):
|
||||
sender = senders_map.get(msg.sender_id)
|
||||
@@ -126,7 +166,10 @@ class MessageService:
|
||||
"reply_to_id": msg.reply_to_id,
|
||||
"reply_to_content": reply_to_content,
|
||||
"reply_to_sender_name": reply_to_sender_name,
|
||||
"mentions": json.loads(msg.mentions) if msg.mentions else None,
|
||||
"is_deleted": msg.is_deleted,
|
||||
"is_recalled": msg.is_recalled,
|
||||
"reactions": reaction_map.get(msg.id, []),
|
||||
"created_at": msg.created_at,
|
||||
})
|
||||
|
||||
@@ -158,6 +201,43 @@ class MessageService:
|
||||
raise ValueError("消息不存在或无权删除")
|
||||
message.is_deleted = True
|
||||
|
||||
async def recall_message(self, message_id: str, user_id: str):
|
||||
"""撤回消息(仅本人,2 分钟内)"""
|
||||
result = await self.db.execute(
|
||||
select(Message).where(Message.id == message_id, Message.sender_id == user_id)
|
||||
)
|
||||
message = result.scalars().first()
|
||||
if not message:
|
||||
raise ValueError("消息不存在或无权撤回")
|
||||
if message.is_recalled:
|
||||
raise ValueError("消息已撤回")
|
||||
elapsed = (datetime.utcnow() - message.created_at).total_seconds()
|
||||
if elapsed > 120:
|
||||
raise ValueError("超过 2 分钟,无法撤回")
|
||||
message.is_recalled = True
|
||||
message.recalled_at = datetime.utcnow()
|
||||
|
||||
async def react(self, message_id: str, user_id: str, emoji: str):
|
||||
"""添加表情回应(toggle:已存在则取消)"""
|
||||
result = await self.db.execute(
|
||||
select(MessageReaction).where(
|
||||
MessageReaction.message_id == message_id,
|
||||
MessageReaction.user_id == user_id,
|
||||
MessageReaction.emoji == emoji,
|
||||
)
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
await self.db.delete(existing)
|
||||
return {"action": "removed", "emoji": emoji}
|
||||
reaction = MessageReaction(
|
||||
id=str(uuid.uuid4()),
|
||||
message_id=message_id, user_id=user_id, emoji=emoji,
|
||||
)
|
||||
self.db.add(reaction)
|
||||
await self.db.flush()
|
||||
return {"action": "added", "emoji": emoji}
|
||||
|
||||
async def get_total_count(self) -> int:
|
||||
"""获取消息总数"""
|
||||
result = await self.db.execute(select(func.count(Message.id)))
|
||||
|
||||
@@ -17,6 +17,9 @@ class EventType(str, Enum):
|
||||
CHAT_TYPING_INDICATOR = "chat.typing"
|
||||
CHAT_READ_RECEIPT = "chat.read"
|
||||
CHAT_MESSAGE_DELETED = "chat.message_deleted"
|
||||
CHAT_MESSAGE_RECALLED = "chat.message_recalled"
|
||||
CHAT_REACTION_ADDED = "chat.reaction_added"
|
||||
CHAT_REACTION_REMOVED = "chat.reaction_removed"
|
||||
CONVERSATION_UPDATED = "conversation.updated"
|
||||
CONVERSATION_MEMBER_ADDED = "conversation.member_added"
|
||||
CONVERSATION_MEMBER_REMOVED = "conversation.member_removed"
|
||||
@@ -24,6 +27,7 @@ class EventType(str, Enum):
|
||||
FRIEND_ACCEPTED = "friend.accepted"
|
||||
PRESENCE_ONLINE = "presence.online"
|
||||
PRESENCE_OFFLINE = "presence.offline"
|
||||
PRESENCE_MENTIONED = "presence.mentioned"
|
||||
ECHO_SEND = "echo.send"
|
||||
HEARTBEAT_SYNC = "heartbeat.sync"
|
||||
FLASH_SPAWN = "flash.spawn"
|
||||
|
||||
@@ -22,6 +22,7 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes
|
||||
content=data["content"],
|
||||
msg_type=data.get("type", "text"),
|
||||
reply_to_id=data.get("reply_to_id"),
|
||||
mentioned_user_ids=data.get("mentioned_user_ids"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -60,6 +61,8 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes
|
||||
"reply_to_id": message.reply_to_id,
|
||||
"reply_to_content": reply_to_content,
|
||||
"reply_to_sender_name": reply_to_sender_name,
|
||||
"mentions": data.get("mentioned_user_ids"),
|
||||
"is_recalled": False,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -69,6 +72,15 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes
|
||||
await manager.broadcast_to_conversation(
|
||||
member_ids, EventType.CHAT_MESSAGE, msg_data
|
||||
)
|
||||
# 被@的人:单独推送提及通知(即使免打扰也提醒)
|
||||
mentioned = data.get("mentioned_user_ids") or []
|
||||
for mid in mentioned:
|
||||
if mid in member_ids and mid != user_id:
|
||||
await manager.send_to_user(mid, EventType.PRESENCE_MENTIONED, {
|
||||
"conversation_id": message.conversation_id,
|
||||
"from_user_id": user_id,
|
||||
"from_username": sender.username if sender else "未知",
|
||||
})
|
||||
except Exception as e:
|
||||
await manager.send_to_user(user_id, EventType.ERROR, {"message": str(e)})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user