From 8149505f1b13619dd5dd0a3e991f4120352b6ce3 Mon Sep 17 00:00:00 2001 From: Mplan Date: Sun, 26 Jul 2026 02:14:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=B4=E5=83=8F=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=9APOST=20/profiles/me/avatar=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E3=80=81DELETE=20/profiles/me/avatar=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/profiles.py | 35 ++++++++++++++++- app/services/storage.py | 36 ++++++++++++++++++ tests/test_profiles.py | 84 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/app/routers/profiles.py b/app/routers/profiles.py index 3345fd0..4db790b 100644 --- a/app/routers/profiles.py +++ b/app/routers/profiles.py @@ -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, diff --git a/app/services/storage.py b/app/services/storage.py index b270a7f..47301e9 100644 --- a/app/services/storage.py +++ b/app/services/storage.py @@ -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 diff --git a/tests/test_profiles.py b/tests/test_profiles.py index e2818df..20e50b7 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -200,3 +200,87 @@ def test_get_user_stats_nonexistent(client: TestClient) -> None: headers=bearer(admin_token(client)), ) assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# avatar +# --------------------------------------------------------------------------- + + +def test_upload_avatar(client: TestClient, monkeypatch) -> None: + info = register_and_confirm( + client, monkeypatch, + f"avatar-{uuid.uuid4().hex[:8]}@example.com", + "avatarp12", "头像用户", + ) + r = client.post( + "/api/v1/profiles/me/avatar", + headers=bearer(info["token"]), + files={"image": ("avatar.png", png_bytes(), "image/png")}, + ) + assert r.status_code == 200 + body = r.json() + assert body["avatar_url"] is not None + assert "avatars/" in body["avatar_url"] + + +def test_replace_avatar(client: TestClient, monkeypatch) -> None: + info = register_and_confirm( + client, monkeypatch, + f"avat2-{uuid.uuid4().hex[:8]}@example.com", + "avat2p12", "换头像", + ) + r1 = client.post( + "/api/v1/profiles/me/avatar", + headers=bearer(info["token"]), + files={"image": ("avatar.png", png_bytes(), "image/png")}, + ) + assert r1.status_code == 200 + assert r1.json()["avatar_url"] is not None + + r2 = client.post( + "/api/v1/profiles/me/avatar", + headers=bearer(info["token"]), + files={"image": ("avatar2.png", png_bytes(), "image/png")}, + ) + assert r2.status_code == 200 + assert r2.json()["avatar_url"] is not None + + +def test_delete_avatar(client: TestClient, monkeypatch) -> None: + info = register_and_confirm( + client, monkeypatch, + f"avat3-{uuid.uuid4().hex[:8]}@example.com", + "avat3p12", "删头像", + ) + client.post( + "/api/v1/profiles/me/avatar", + headers=bearer(info["token"]), + files={"image": ("avatar.png", png_bytes(), "image/png")}, + ) + r = client.delete("/api/v1/profiles/me/avatar", headers=bearer(info["token"])) + assert r.status_code == 200 + assert r.json()["avatar_url"] is None + + +def test_delete_avatar_not_found(client: TestClient, monkeypatch) -> None: + info = register_and_confirm( + client, monkeypatch, + f"avat4-{uuid.uuid4().hex[:8]}@example.com", + "avat4p12", "没头像删", + ) + r = client.delete("/api/v1/profiles/me/avatar", headers=bearer(info["token"])) + assert r.status_code == 404 + + +def test_upload_avatar_unauthenticated(client: TestClient) -> None: + r = client.post( + "/api/v1/profiles/me/avatar", + files={"image": ("avatar.png", png_bytes(), "image/png")}, + ) + assert r.status_code == 401 + + +def test_delete_avatar_unauthenticated(client: TestClient) -> None: + r = client.delete("/api/v1/profiles/me/avatar") + assert r.status_code == 401