This commit is contained in:
2026-06-14 11:16:42 +08:00
parent ca39190ad7
commit c9fc87cd89
35 changed files with 1480 additions and 18 deletions
+113 -1
View File
@@ -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="验证码无效或已过期")
+90
View File
@@ -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))
+73
View File
@@ -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}
+57
View File
@@ -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
+83
View File
@@ -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