新增头像接口:POST /profiles/me/avatar 上传、DELETE /profiles/me/avatar 删除
This commit is contained in:
+34
-1
@@ -1,7 +1,8 @@
|
|||||||
import math
|
import math
|
||||||
import uuid
|
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 import func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import joinedload
|
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.models import Cloud, Profile, User, UserCollection
|
||||||
from app.schemas import CloudOut, PageOut, ProfileOut, ProfileStatsOut, ProfileUpdateIn, PublicProfileOut
|
from app.schemas import CloudOut, PageOut, ProfileOut, ProfileStatsOut, ProfileUpdateIn, PublicProfileOut
|
||||||
from app.serializers import cloud_out, profile_out, public_profile_out
|
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=["用户资料"])
|
router = APIRouter(prefix="/profiles", tags=["用户资料"])
|
||||||
@@ -38,6 +40,37 @@ async def update_my_profile(
|
|||||||
return profile_out(user.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)
|
@router.get("/{user_id}/stats", response_model=ProfileStatsOut)
|
||||||
async def get_user_stats(
|
async def get_user_stats(
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
ALLOWED_FORMATS = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
|
ALLOWED_FORMATS = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
|
||||||
|
AVATAR_MAX_EDGE = 512
|
||||||
|
|
||||||
|
|
||||||
def _process_image(raw: bytes) -> tuple[bytes, str, bytes]:
|
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)
|
logger.exception("删除图片失败:%s", relative_path)
|
||||||
|
|
||||||
await asyncio.to_thread(remove)
|
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
|
||||||
|
|||||||
@@ -200,3 +200,87 @@ def test_get_user_stats_nonexistent(client: TestClient) -> None:
|
|||||||
headers=bearer(admin_token(client)),
|
headers=bearer(admin_token(client)),
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user