feat: implement avatar upload, Gravatar support, and avatar settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 15:56:24 +08:00
parent eddf38d316
commit bc2182720d
5 changed files with 189 additions and 27 deletions

View File

@@ -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"),

View File

@@ -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/webpfile 模式)
- 302: 重定向到外部 URLdefault/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)
#####################
# 需要登录的接口

View File

@@ -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-datafile 字段
流程:
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)],
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(

View File

@@ -76,6 +76,9 @@ class SiteConfigResponse(SQLModelBase):
email_binding_required: bool = True
"""是否强制绑定邮箱"""
avatar_max_size: int = 2097152
"""头像文件最大字节数(默认 2MB"""
footer_code: str | None = None
"""自定义页脚代码"""

View File

@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../.
from main import app
from sqlmodels import Group, GroupClaims, GroupOptions, Object, ObjectType, Policy, PolicyType, Setting, SettingsType, User
from sqlmodels.policy import GroupPolicyLink
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
from sqlmodels.user import UserStatus
from utils import Password
@@ -108,6 +109,12 @@ async def initialized_db(test_session: AsyncSession) -> AsyncSession:
Setting(type=SettingsType.AUTH, name="auth_email_binding_required", value="1"),
Setting(type=SettingsType.OAUTH, name="github_enabled", value="0"),
Setting(type=SettingsType.OAUTH, name="qq_enabled", value="0"),
Setting(type=SettingsType.AVATAR, name="gravatar_server", value="https://www.gravatar.com/"),
Setting(type=SettingsType.AVATAR, name="avatar_size", value="2097152"),
Setting(type=SettingsType.AVATAR, name="avatar_size_l", value="200"),
Setting(type=SettingsType.AVATAR, name="avatar_size_m", value="130"),
Setting(type=SettingsType.AVATAR, name="avatar_size_s", value="50"),
Setting(type=SettingsType.PATH, name="avatar_path", value="avatar"),
]
for setting in settings:
test_session.add(setting)
@@ -156,7 +163,11 @@ async def initialized_db(test_session: AsyncSession) -> AsyncSession:
await test_session.refresh(admin_group)
await test_session.refresh(default_policy)
# 4. 创建用户组选项
# 4. 关联用户组与存储策略
test_session.add(GroupPolicyLink(group_id=default_group.id, policy_id=default_policy.id))
test_session.add(GroupPolicyLink(group_id=admin_group.id, policy_id=default_policy.id))
# 5. 创建用户组选项
default_group_options = GroupOptions(
group_id=default_group.id,
share_download=True,