新增头像接口:POST /profiles/me/avatar 上传、DELETE /profiles/me/avatar 删除

This commit is contained in:
2026-07-26 02:14:22 +08:00
parent 49df3f067a
commit 8149505f1b
3 changed files with 154 additions and 1 deletions
+34 -1
View File
@@ -1,7 +1,8 @@
import math
import uuid
from typing import Annotated
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
@@ -10,6 +11,7 @@ from app.deps import AdminUser, CurrentUser, DbSession, OptionalUser
from app.models import Cloud, Profile, User, UserCollection
from app.schemas import CloudOut, PageOut, ProfileOut, ProfileStatsOut, ProfileUpdateIn, PublicProfileOut
from app.serializers import cloud_out, profile_out, public_profile_out
from app.services.storage import delete_files, save_avatar
router = APIRouter(prefix="/profiles", tags=["用户资料"])
@@ -38,6 +40,37 @@ async def update_my_profile(
return profile_out(user.profile)
@router.post("/me/avatar", response_model=ProfileOut)
async def upload_avatar(
image: Annotated[UploadFile, File()],
user: CurrentUser,
db: DbSession,
) -> ProfileOut:
if user.profile.avatar_path:
old_path = user.profile.avatar_path
user.profile.avatar_path = None
await db.commit()
await delete_files([old_path])
avatar_path = await save_avatar(image, user.id)
user.profile.avatar_path = avatar_path
await db.commit()
return profile_out(user.profile)
@router.delete("/me/avatar", response_model=ProfileOut)
async def delete_avatar(
user: CurrentUser,
db: DbSession,
) -> ProfileOut:
if not user.profile.avatar_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有头像")
old_path = user.profile.avatar_path
user.profile.avatar_path = None
await db.commit()
await delete_files([old_path])
return profile_out(user.profile)
@router.get("/{user_id}/stats", response_model=ProfileStatsOut)
async def get_user_stats(
user_id: uuid.UUID,
+36
View File
@@ -12,6 +12,7 @@ from app.config import settings
logger = logging.getLogger(__name__)
ALLOWED_FORMATS = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
AVATAR_MAX_EDGE = 512
def _process_image(raw: bytes) -> tuple[bytes, str, bytes]:
@@ -105,3 +106,38 @@ async def delete_files(paths: list[str]) -> None:
logger.exception("删除图片失败:%s", relative_path)
await asyncio.to_thread(remove)
async def save_avatar(file: UploadFile, user_id: uuid.UUID) -> str:
raw = await file.read(settings.max_upload_bytes + 1)
if not raw:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="图片不能为空")
if len(raw) > settings.max_upload_bytes:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="图片文件过大")
def process() -> str:
Image.MAX_IMAGE_PIXELS = settings.max_image_pixels
try:
with Image.open(io.BytesIO(raw)) as source:
if source.format not in ALLOWED_FORMATS:
raise ValueError("仅支持 JPEG、PNG 和 WebP 图片")
source = source.convert("RGB")
source.thumbnail(
(AVATAR_MAX_EDGE, AVATAR_MAX_EDGE),
Image.Resampling.LANCZOS,
)
relative_path = Path("avatars") / f"{user_id}.jpg"
target = settings.upload_dir / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
source.save(target, format="JPEG", quality=85, optimize=True)
return relative_path.as_posix()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError, ValueError) as exc:
raise ValueError(str(exc) or "图片内容无效") from exc
try:
return await asyncio.to_thread(process)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"图片无法处理:{exc}",
) from exc