feat: implement avatar upload, Gravatar support, and avatar settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -82,7 +82,8 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
|
||||
(Setting.type == SettingsType.REGISTER) |
|
||||
(Setting.type == SettingsType.CAPTCHA) |
|
||||
(Setting.type == SettingsType.AUTH) |
|
||||
(Setting.type == SettingsType.OAUTH),
|
||||
(Setting.type == SettingsType.OAUTH) |
|
||||
(Setting.type == SettingsType.AVATAR),
|
||||
fetch_mode="all",
|
||||
)
|
||||
|
||||
@@ -122,6 +123,7 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
|
||||
password_required=s.get("auth_password_required") == "1",
|
||||
phone_binding_required=s.get("auth_phone_binding_required") == "1",
|
||||
email_binding_required=s.get("auth_email_binding_required") == "1",
|
||||
avatar_max_size=int(s["avatar_size"]),
|
||||
footer_code=s.get("footer_code"),
|
||||
tos_url=s.get("tos_url"),
|
||||
privacy_url=s.get("privacy_url"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from loguru import logger
|
||||
from webauthn import (
|
||||
@@ -358,20 +359,78 @@ def router_user_profile(id: str) -> sqlmodels.ResponseBase:
|
||||
@user_router.get(
|
||||
path='/avatar/{id}/{size}',
|
||||
summary='获取用户头像',
|
||||
description='Get user avatar by ID and size.',
|
||||
response_model=None,
|
||||
)
|
||||
def router_user_avatar(id: str, size: int = 128) -> sqlmodels.ResponseBase:
|
||||
async def router_user_avatar(
|
||||
session: SessionDep,
|
||||
id: UUID,
|
||||
size: int = 128,
|
||||
) -> FileResponse | RedirectResponse:
|
||||
"""
|
||||
Get user avatar by ID and size.
|
||||
获取指定用户指定尺寸的头像(公开端点,无需认证)
|
||||
|
||||
Args:
|
||||
id (str): The user ID.
|
||||
size (int): The size of the avatar image.
|
||||
路径参数:
|
||||
- id: 用户 UUID
|
||||
- size: 请求的头像尺寸(px),默认 128
|
||||
|
||||
Returns:
|
||||
str: A Base64 encoded string of the user avatar image.
|
||||
行为:
|
||||
- default: 302 重定向到 Gravatar identicon
|
||||
- gravatar: 302 重定向到 Gravatar(使用用户邮箱 MD5)
|
||||
- file: 返回本地 WebP 文件
|
||||
|
||||
响应:
|
||||
- 200: image/webp(file 模式)
|
||||
- 302: 重定向到外部 URL(default/gravatar 模式)
|
||||
- 404: 用户不存在
|
||||
|
||||
缓存:Cache-Control: public, max-age=3600
|
||||
"""
|
||||
http_exceptions.raise_not_implemented()
|
||||
import aiofiles.os
|
||||
|
||||
from service.avatar import (
|
||||
get_avatar_file_path,
|
||||
get_avatar_settings,
|
||||
gravatar_url,
|
||||
resolve_avatar_size,
|
||||
)
|
||||
|
||||
user = await sqlmodels.User.get(session, sqlmodels.User.id == id)
|
||||
if not user:
|
||||
http_exceptions.raise_not_found("用户不存在")
|
||||
|
||||
avatar_path, _, size_l, size_m, size_s = await get_avatar_settings(session)
|
||||
|
||||
if user.avatar == "file":
|
||||
size_label = resolve_avatar_size(size, size_l, size_m, size_s)
|
||||
file_path = get_avatar_file_path(avatar_path, user.id, size_label)
|
||||
|
||||
if not await aiofiles.os.path.exists(file_path):
|
||||
# 文件丢失,降级为 identicon
|
||||
fallback_url = gravatar_url(str(user.id), size, "https://www.gravatar.com/")
|
||||
return RedirectResponse(url=fallback_url, status_code=302)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
elif user.avatar == "gravatar":
|
||||
gravatar_setting = await sqlmodels.Setting.get(
|
||||
session,
|
||||
(sqlmodels.Setting.type == sqlmodels.SettingsType.AVATAR)
|
||||
& (sqlmodels.Setting.name == "gravatar_server"),
|
||||
)
|
||||
server = gravatar_setting.value if gravatar_setting else "https://www.gravatar.com/"
|
||||
email = user.email or str(user.id)
|
||||
url = gravatar_url(email, size, server)
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
|
||||
else:
|
||||
# default: identicon
|
||||
email_or_id = user.email or str(user.id)
|
||||
url = gravatar_url(email_or_id, size, "https://www.gravatar.com/")
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
|
||||
#####################
|
||||
# 需要登录的接口
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
|
||||
import sqlmodels
|
||||
@@ -165,34 +165,121 @@ async def router_user_settings(
|
||||
@user_settings_router.post(
|
||||
path='/avatar',
|
||||
summary='从文件上传头像',
|
||||
description='Upload user avatar from file.',
|
||||
dependencies=[Depends(auth_required)],
|
||||
status_code=204,
|
||||
)
|
||||
def router_user_settings_avatar() -> sqlmodels.ResponseBase:
|
||||
async def router_user_settings_avatar(
|
||||
session: SessionDep,
|
||||
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
|
||||
file: UploadFile = File(...),
|
||||
) -> None:
|
||||
"""
|
||||
Upload user avatar from file.
|
||||
上传头像文件
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the result of the avatar upload.
|
||||
认证:JWT token
|
||||
请求体:multipart/form-data,file 字段
|
||||
|
||||
流程:
|
||||
1. 验证文件 MIME 类型(JPEG/PNG/GIF/WebP)
|
||||
2. 验证文件大小 <= avatar_size 设置(默认 2MB)
|
||||
3. 调用 Pillow 验证图片有效性并处理(居中裁剪、缩放 L/M/S)
|
||||
4. 保存三种尺寸的 WebP 文件
|
||||
5. 更新 User.avatar = "file"
|
||||
|
||||
错误处理:
|
||||
- 400: 文件类型不支持 / 图片无法解析
|
||||
- 413: 文件过大
|
||||
"""
|
||||
http_exceptions.raise_not_implemented()
|
||||
from service.avatar import (
|
||||
ALLOWED_CONTENT_TYPES,
|
||||
get_avatar_settings,
|
||||
process_and_save_avatar,
|
||||
)
|
||||
|
||||
# 验证 MIME 类型
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
http_exceptions.raise_bad_request(
|
||||
f"不支持的图片格式,允许: {', '.join(ALLOWED_CONTENT_TYPES)}"
|
||||
)
|
||||
|
||||
# 读取并验证大小
|
||||
_, max_upload_size, _, _, _ = await get_avatar_settings(session)
|
||||
raw_bytes = await file.read()
|
||||
if len(raw_bytes) > max_upload_size:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件过大,最大允许 {max_upload_size} 字节",
|
||||
)
|
||||
|
||||
# 处理并保存(内部会验证图片有效性,无效抛出 ValueError)
|
||||
try:
|
||||
await process_and_save_avatar(session, user.id, raw_bytes)
|
||||
except ValueError as e:
|
||||
http_exceptions.raise_bad_request(str(e))
|
||||
|
||||
# 更新用户头像字段
|
||||
user.avatar = "file"
|
||||
await user.save(session)
|
||||
|
||||
|
||||
@user_settings_router.put(
|
||||
path='/avatar',
|
||||
summary='设定为Gravatar头像',
|
||||
description='Set user avatar to Gravatar.',
|
||||
dependencies=[Depends(auth_required)],
|
||||
summary='设定为 Gravatar 头像',
|
||||
status_code=204,
|
||||
)
|
||||
def router_user_settings_avatar_gravatar() -> None:
|
||||
async def router_user_settings_avatar_gravatar(
|
||||
session: SessionDep,
|
||||
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
|
||||
) -> None:
|
||||
"""
|
||||
Set user avatar to Gravatar.
|
||||
将头像切换为 Gravatar
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the result of setting the Gravatar avatar.
|
||||
认证:JWT token
|
||||
|
||||
流程:
|
||||
1. 验证用户有邮箱(Gravatar 基于邮箱 MD5)
|
||||
2. 如果当前是 FILE 头像,删除本地文件
|
||||
3. 更新 User.avatar = "gravatar"
|
||||
|
||||
错误处理:
|
||||
- 400: 用户没有邮箱
|
||||
"""
|
||||
http_exceptions.raise_not_implemented()
|
||||
from service.avatar import delete_avatar_files
|
||||
|
||||
if not user.email:
|
||||
http_exceptions.raise_bad_request("Gravatar 需要邮箱,请先绑定邮箱")
|
||||
|
||||
if user.avatar == "file":
|
||||
await delete_avatar_files(session, user.id)
|
||||
|
||||
user.avatar = "gravatar"
|
||||
await user.save(session)
|
||||
|
||||
|
||||
@user_settings_router.delete(
|
||||
path='/avatar',
|
||||
summary='重置头像为默认',
|
||||
status_code=204,
|
||||
)
|
||||
async def router_user_settings_avatar_delete(
|
||||
session: SessionDep,
|
||||
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
|
||||
) -> None:
|
||||
"""
|
||||
重置头像为默认
|
||||
|
||||
认证:JWT token
|
||||
|
||||
流程:
|
||||
1. 如果当前是 FILE 头像,删除本地文件
|
||||
2. 更新 User.avatar = "default"
|
||||
"""
|
||||
from service.avatar import delete_avatar_files
|
||||
|
||||
if user.avatar == "file":
|
||||
await delete_avatar_files(session, user.id)
|
||||
|
||||
user.avatar = "default"
|
||||
await user.save(session)
|
||||
|
||||
|
||||
@user_settings_router.patch(
|
||||
|
||||
Reference in New Issue
Block a user