⚒️ 重大重构 LoveACE V2

引入了 mongodb
对数据库进行了一定程度的数据加密
性能改善
代码简化
统一错误模型和响应
使用 apifox 作为文档
This commit is contained in:
2025-11-20 20:44:25 +08:00
parent 6b90c6d7bb
commit bbc86b8330
168 changed files with 14264 additions and 19152 deletions

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
from loveace.router.endpoint.profile.flutter import profile_flutter_router
from loveace.router.endpoint.profile.model.error import ProfileErrorToCode
from loveace.router.endpoint.profile.user import profile_user_router
profile_router = APIRouter(
prefix="/profile",
responses=ProfileErrorToCode.gen_code_table(),
)
profile_router.include_router(profile_user_router)
profile_router.include_router(profile_flutter_router)

View File

@@ -0,0 +1,427 @@
from hashlib import md5
from typing import Literal
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.database.auth.user import ACEUser
from loveace.database.creator import get_db_session
from loveace.database.profile.flutter_profile import FlutterThemeProfile
from loveace.router.dependencies.auth import get_user_by_token
from loveace.router.dependencies.logger import logger_mixin_with_user
from loveace.router.endpoint.profile.model.error import ProfileErrorToCode
from loveace.router.endpoint.profile.model.flutter import (
FlutterImageMD5Response,
FlutterImageMode,
FlutterImageUploadResponse,
FlutterProfileResponse,
FlutterProfileUpdateRequest,
)
from loveace.router.endpoint.profile.model.uuid2s3key import Uuid2S3KeyCache
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.s3 import S3Service
from loveace.service.remote.s3.depends import get_s3_service
from loveace.utils.redis_client import RedisClient, get_redis_client
profile_flutter_router = APIRouter(
prefix="/flutter",
tags=["Flutter 资料"],
)
@profile_flutter_router.get(
"/get",
response_model=UniResponseModel[FlutterProfileResponse],
summary="获取 Flutter 用户资料",
)
async def profile_flutter_get(
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
) -> UniResponseModel[FlutterProfileResponse] | JSONResponse:
"""
获取用户的 Flutter 应用主题配置
✅ 功能特性:
- 获取深色和浅色模式配置
- 获取背景图片、透明度、亮度设置
- 获取模糊效果参数
💡 使用场景:
- Flutter 客户端启动时加载主题
- 显示用户自定义主题设置
Returns:
FlutterProfileResponse: 包含深色/浅色模式的完整主题配置
"""
logger = logger_mixin_with_user(user.userid)
try:
result = await session.execute(
select(FlutterThemeProfile).where(
FlutterThemeProfile.user_id == user.userid
)
)
flutter_profile: FlutterThemeProfile | None = result.scalars().first()
if not flutter_profile:
return ProfileErrorToCode().profile_not_found.to_json_response(
logger.trace_id, "您还未设置用户资料,请先设置用户资料。"
)
if flutter_profile.light_mode_background_url:
if light_bg_url := await s3_service.generate_presigned_url_from_direct_url(
flutter_profile.light_mode_background_url
):
flutter_profile.light_mode_background_url = light_bg_url
else:
logger.warning("生成用户浅色模式背景预签名 URL 失败,可能图片已被删除")
flutter_profile.light_mode_background_url = ""
else:
flutter_profile.light_mode_background_url = ""
if flutter_profile.dark_mode_background_url:
if dark_bg_url := await s3_service.generate_presigned_url_from_direct_url(
flutter_profile.dark_mode_background_url
):
flutter_profile.dark_mode_background_url = dark_bg_url
else:
logger.warning("生成用户深色模式背景预签名 URL 失败,可能图片已被删除")
flutter_profile.dark_mode_background_url = ""
else:
flutter_profile.dark_mode_background_url = ""
flutter_response = FlutterProfileResponse(
dark_mode=flutter_profile.dark_mode,
light_mode_opacity=flutter_profile.light_mode_opacity,
light_mode_brightness=flutter_profile.light_mode_brightness,
light_mode_background_url=flutter_profile.light_mode_background_url,
light_mode_blur=flutter_profile.light_mode_blur,
dark_mode_opacity=flutter_profile.dark_mode_opacity,
dark_mode_brightness=flutter_profile.dark_mode_brightness,
dark_mode_background_url=flutter_profile.dark_mode_background_url,
dark_mode_background_blur=flutter_profile.dark_mode_background_blur,
)
return UniResponseModel[FlutterProfileResponse](
success=True,
data=flutter_response,
message="获取 Flutter 用户资料成功",
error=None,
)
except Exception as e:
logger.error("获取 Flutter 用户资料时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)
@profile_flutter_router.put(
"/image/upload",
response_model=UniResponseModel[FlutterImageUploadResponse],
summary="上传 Flutter 背景图片",
)
async def profile_flutter_image_upload(
background_image_upload: UploadFile = File(
..., description="背景图片文件,限制大小小于 5MB"
),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
redis_client: RedisClient = Depends(get_redis_client),
) -> UniResponseModel[FlutterImageUploadResponse] | JSONResponse:
"""
上传 Flutter 主题的背景图片
✅ 功能特性:
- 支持 JPEG 和 PNG 格式
- 限制文件大小为 5MB 以内
- 上传后返回临时 UUID需要通过 /update 接口确认才会保存
⚠️ 限制条件:
- 仅支持 JPEG 和 PNG 格式
- 文件大小不能超过 5MB
- 上传的临时文件有效期为 1 小时
💡 使用场景:
- 用户上传深色模式背景图片
- 用户上传浅色模式背景图片
Args:
background_image_upload: 背景图片文件
Returns:
FlutterImageUploadResponse: 包含临时图片 UUID
"""
logger = logger_mixin_with_user(user.userid)
print(background_image_upload.content_type)
if not background_image_upload.content_type and not background_image_upload.size:
logger.warning("上传的背景图片文件缺少必要的内容类型或大小信息")
return ProfileErrorToCode().mimetype_not_allowed.to_json_response(
logger.trace_id, "上传的背景图片文件缺少必要的内容类型或大小信息"
)
if background_image_upload.size and background_image_upload.size > 5 * 1024 * 1024:
logger.warning("上传的背景图片文件过大")
return ProfileErrorToCode().too_large_image.to_json_response(
logger.trace_id, "上传的背景图片文件过大最大允许5MB"
)
if background_image_upload.content_type not in ["image/jpeg", "image/png"]:
logger.warning("上传的背景图片文件格式不支持")
return ProfileErrorToCode().mimetype_not_allowed.to_json_response(
logger.trace_id, "上传的背景图片文件格式不支持,仅支持 JPEG、PNG"
)
md5_hash = md5(background_image_upload.file.read()).hexdigest()
background_image_upload.file.seek(0)
s3_key = f"backgrounds/{user.userid}/never_use/{user.userid}-{md5_hash}.jpg"
back_upload = await s3_service.upload_obj(
file_obj=background_image_upload.file,
s3_key=s3_key,
extra_args={"ContentType": background_image_upload.content_type},
)
if not back_upload.success or not back_upload.url:
logger.error("上传用户背景图片到 S3 失败")
return ProfileErrorToCode().remote_service_error.to_json_response(
logger.trace_id, "上传用户背景图片失败,请稍后重试"
)
cache_data = Uuid2S3KeyCache(s3_key=s3_key, md5=md5_hash)
await redis_client.set_object(
key=f"flutter:background:{user.userid}-{md5_hash}",
value=cache_data,
model_class=Uuid2S3KeyCache,
expire=3600,
)
upload_response = FlutterImageUploadResponse(uuid=f"{user.userid}-{md5_hash}", md5=md5_hash)
return UniResponseModel[FlutterImageUploadResponse](
success=True,
data=upload_response,
message="上传背景图片成功",
error=None,
)
@profile_flutter_router.put(
"/update",
response_model=UniResponseModel[FlutterProfileResponse],
summary="更新 Flutter 用户资料",
)
async def profile_flutter_update(
profile_update_request: FlutterProfileUpdateRequest,
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
redis_client: RedisClient = Depends(get_redis_client),
) -> UniResponseModel[FlutterProfileResponse] | JSONResponse:
"""
更新用户的 Flutter 主题配置
✅ 功能特性:
- 支持更新深色和浅色模式配置
- 支持更新背景图片、透明度、亮度、模糊效果
- 可同时更新或选择性更新
💡 使用场景:
- 用户自定义 Flutter 客户端主题
- 修改深色模式或浅色模式设置
- 更新背景图片
Args:
profile_update_request: 包含要更新的主题配置字段
session: 数据库会话
user: 当前用户
s3_service: S3 服务
redis_client: Redis 客户端
Returns:
FlutterProfileResponse: 更新后的主题配置
"""
logger = logger_mixin_with_user(user.userid)
try:
if not any(
[
profile_update_request.dark_mode,
profile_update_request.light_mode_opacity,
profile_update_request.light_mode_brightness,
profile_update_request.light_mode_background_uuid,
profile_update_request.light_mode_blur,
profile_update_request.dark_mode_opacity,
profile_update_request.dark_mode_brightness,
profile_update_request.dark_mode_background_uuid,
profile_update_request.dark_mode_background_blur,
]
):
logger.warning("未提供任何更新的资料字段")
return ProfileErrorToCode().need_one_more_field.to_json_response(
logger.trace_id, "未提供任何更新的资料字段"
)
result = await session.execute(
select(FlutterThemeProfile).where(
FlutterThemeProfile.user_id == user.userid
)
)
flutter_profile: FlutterThemeProfile | None = result.scalars().first()
if not flutter_profile:
flutter_profile = FlutterThemeProfile(user_id=user.userid)
if profile_update_request.dark_mode is not None:
flutter_profile.dark_mode = profile_update_request.dark_mode
if profile_update_request.light_mode_opacity is not None:
flutter_profile.light_mode_opacity = (
profile_update_request.light_mode_opacity
)
if profile_update_request.light_mode_brightness is not None:
flutter_profile.light_mode_brightness = (
profile_update_request.light_mode_brightness
)
if profile_update_request.light_mode_background_uuid is not None:
light_bg_cache = await redis_client.get_object(
key=f"flutter:background:{profile_update_request.light_mode_background_uuid}",
model_class=Uuid2S3KeyCache,
)
if light_bg_cache:
copy = await s3_service.copy_object(
source_key=light_bg_cache.s3_key,
dest_key=f"backgrounds/{user.userid}/{user.userid}-light.jpg",
)
if copy.success and copy.dest_url:
flutter_profile.light_mode_background_url = copy.dest_url
flutter_profile.light_mode_background_md5 = (
light_bg_cache.md5 if light_bg_cache else ""
)
await redis_client.delete(
key=f"flutter:background:{profile_update_request.light_mode_background_uuid}"
)
else:
logger.warning("提供的浅色模式背景图片 UUID 无效或已过期")
return ProfileErrorToCode().resource_expired.to_json_response(
logger.trace_id, "提供的浅色模式背景图片 UUID 无效或已过期"
)
if profile_update_request.light_mode_blur is not None:
flutter_profile.light_mode_blur = profile_update_request.light_mode_blur
if profile_update_request.dark_mode_opacity is not None:
flutter_profile.dark_mode_opacity = profile_update_request.dark_mode_opacity
if profile_update_request.dark_mode_brightness is not None:
flutter_profile.dark_mode_brightness = (
profile_update_request.dark_mode_brightness
)
if profile_update_request.dark_mode_background_uuid is not None:
dark_bg_cache = await redis_client.get_object(
key=f"flutter:background:{profile_update_request.dark_mode_background_uuid}",
model_class=Uuid2S3KeyCache,
)
if dark_bg_cache:
copy = await s3_service.copy_object(
source_key=dark_bg_cache.s3_key,
dest_key=f"backgrounds/{user.userid}/{user.userid}-dark.jpg",
)
if copy.success and copy.dest_url:
flutter_profile.dark_mode_background_url = copy.dest_url
flutter_profile.dark_mode_background_md5 = (
dark_bg_cache.md5 if dark_bg_cache else ""
)
await redis_client.delete(
key=f"flutter:background:{profile_update_request.dark_mode_background_uuid}"
)
else:
logger.warning("提供的深色模式背景图片 UUID 无效或已过期")
return ProfileErrorToCode().resource_expired.to_json_response(
logger.trace_id, "提供的深色模式背景图片 UUID 无效或已过期"
)
if profile_update_request.dark_mode_background_blur is not None:
flutter_profile.dark_mode_background_blur = (
profile_update_request.dark_mode_background_blur
)
session.add(flutter_profile)
await session.commit()
flutter_response = FlutterProfileResponse(
dark_mode=flutter_profile.dark_mode,
light_mode_opacity=flutter_profile.light_mode_opacity,
light_mode_brightness=flutter_profile.light_mode_brightness,
light_mode_background_url=(
await s3_service.generate_presigned_url_from_direct_url(
flutter_profile.light_mode_background_url
)
if flutter_profile.light_mode_background_url
else ""
),
light_mode_blur=flutter_profile.light_mode_blur,
dark_mode_opacity=flutter_profile.dark_mode_opacity,
dark_mode_brightness=flutter_profile.dark_mode_brightness,
dark_mode_background_url=(
await s3_service.generate_presigned_url_from_direct_url(
flutter_profile.dark_mode_background_url
)
if flutter_profile.dark_mode_background_url
else ""
),
dark_mode_background_blur=flutter_profile.dark_mode_background_blur,
)
return UniResponseModel[FlutterProfileResponse](
success=True,
data=flutter_response,
message="更新 Flutter 用户资料成功",
error=None,
)
except Exception as e:
logger.error("更新 Flutter 用户资料时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)
@profile_flutter_router.get(
"/image/{mode}/md5",
summary="获取 Flutter 背景图片的 MD5 值",
response_model=UniResponseModel[FlutterImageMD5Response],
)
async def profile_flutter_image_md5(
mode: FlutterImageMode,
user: ACEUser = Depends(get_user_by_token),
session: AsyncSession = Depends(get_db_session),
) -> UniResponseModel[FlutterImageMD5Response] | JSONResponse:
"""
获取 Flutter 主题背景图片的 MD5 值
✅ 功能特性:
- 支持获取深色模式或浅色模式背景图片的 MD5 值
- 通过用户 ID 定位对应的背景图片
💡 使用场景:
- 验证当前背景图片是否被篡改
- 用于缓存或同步背景图片时的完整性校验
Args:
black_or_white: 指定获取深色模式black或浅色模式white的背景图片 MD5 值
user: 当前用户
redis_client: Redis 客户端
Returns:
FlutterImageMD5Response: 包含背景图片的 MD5 值
"""
logger = logger_mixin_with_user(user.userid)
try:
if mode == FlutterImageMode.DARK:
md5_value = await session.execute(
select(FlutterThemeProfile.dark_mode_background_md5).where(
FlutterThemeProfile.user_id == user.userid
)
)
result_md5 = md5_value.scalars().first()
else:
md5_value = await session.execute(
select(FlutterThemeProfile.light_mode_background_md5).where(
FlutterThemeProfile.user_id == user.userid
)
)
result_md5 = md5_value.scalars().first()
if result_md5:
result = FlutterImageMD5Response(md5=result_md5)
return UniResponseModel[FlutterImageMD5Response](
success=True,
data=result,
message="获取 Flutter 背景图片 MD5 值成功",
error=None,
)
else:
logger.warning("用户背景图片的 MD5 值未找到")
return ProfileErrorToCode().profile_not_found.to_json_response(
logger.trace_id, "用户背景图片的 MD5 值未找到"
)
except Exception as e:
logger.error("获取 Flutter 背景图片 MD5 值时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)

View File

@@ -0,0 +1,46 @@
from fastapi import status
from loveace.router.schemas import ErrorToCode, ErrorToCodeNode
class ProfileErrorToCode(ErrorToCode):
profile_not_found: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_404_NOT_FOUND,
code="PROFILE_NOT_FOUND",
message="用户资料未找到",
)
unauthorized_access: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_401_UNAUTHORIZED,
code="UNAUTHORIZED_ACCESS",
message="未授权的访问",
)
need_one_more_field: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="NEED_ONE_MORE_FIELD",
message="需要至少提供一个字段进行更新",
)
too_large_image: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
code="TOO_LARGE_IMAGE",
message="上传的图片过大",
)
mimetype_not_allowed: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
code="MIMETYPE_NOT_ALLOWED",
message="不支持的图片格式",
)
resource_expired: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_410_GONE,
code="RESOURCE_EXPIRED",
message="资源已过期",
)
remote_service_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_502_BAD_GATEWAY,
code="REMOTE_SERVICE_ERROR",
message="远程服务错误",
)
server_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="SERVER_ERROR",
message="服务器错误",
)

View File

@@ -0,0 +1,56 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class FlutterImageUploadResponse(BaseModel):
uuid: str = Field(..., description="图片的UUID")
md5: str = Field(..., description="图片的MD5值")
class FlutterProfileResponse(BaseModel):
dark_mode: bool = Field(..., description="是否启用暗黑模式")
light_mode_opacity: float = Field(..., description="浅色模式下的透明度")
light_mode_brightness: float = Field(..., description="浅色模式下的亮度")
light_mode_background_url: Optional[str] = Field(
None, description="浅色模式下的背景图片 URL"
)
light_mode_blur: float = Field(..., description="浅色模式下的背景模糊程度")
dark_mode_opacity: float = Field(..., description="深色模式下的透明度")
dark_mode_brightness: float = Field(..., description="深色模式下的亮度")
dark_mode_background_url: Optional[str] = Field(
None, description="深色模式下的背景图片 URL"
)
dark_mode_background_blur: float = Field(
..., description="深色模式下的背景模糊程度"
)
class FlutterProfileUpdateRequest(BaseModel):
dark_mode: Optional[bool] = Field(None, description="是否启用暗黑模式")
light_mode_opacity: Optional[float] = Field(None, description="浅色模式下的透明度")
light_mode_brightness: Optional[float] = Field(None, description="浅色模式下的亮度")
light_mode_background_uuid: Optional[str] = Field(
None, description="浅色模式下的背景图片 UUID"
)
light_mode_blur: Optional[float] = Field(
None, description="浅色模式下的背景模糊程度"
)
dark_mode_opacity: Optional[float] = Field(None, description="深色模式下的透明度")
dark_mode_brightness: Optional[float] = Field(None, description="深色模式下的亮度")
dark_mode_background_uuid: Optional[str] = Field(
None, description="深色模式下的背景图片 UUID"
)
dark_mode_background_blur: Optional[float] = Field(
None, description="深色模式下的背景模糊程度"
)
class FlutterImageMD5Response(BaseModel):
md5: str = Field(..., description="图片的MD5值")
class FlutterImageMode(Enum):
LIGHT = "light"
DARK = "dark"

View File

@@ -0,0 +1,24 @@
from typing import Optional
from pydantic import BaseModel, Field
class UserProfileUpdateRequest(BaseModel):
nickname: Optional[str] = Field(..., description="用户昵称")
slogan: Optional[str] = Field(..., description="用户个性签名")
avatar_uuid: Optional[str] = Field(..., description="用户头像UUID")
class UserProfileResponse(BaseModel):
nickname: str = Field(..., description="用户昵称")
slogan: str = Field(..., description="用户个性签名")
avatar_url: str = Field(..., description="用户头像URL")
class AvatarUpdateResponse(BaseModel):
uuid: str = Field(..., description="新的头像UUID")
md5: str = Field(..., description="头像文件的MD5值")
class AvatarMD5Response(BaseModel):
md5: str = Field(..., description="用户头像的MD5值")

View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel, Field
class Uuid2S3KeyCache(BaseModel):
"""UUID 到 S3 Key 的缓存模型"""
s3_key: str = Field(..., description="S3对象的key")
md5: str = Field(..., description="文件的MD5值")

View File

@@ -0,0 +1,339 @@
from hashlib import md5
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.database.auth.user import ACEUser
from loveace.database.creator import get_db_session
from loveace.database.profile.user_profile import UserProfile
from loveace.router.dependencies.auth import get_user_by_token
from loveace.router.dependencies.logger import logger_mixin_with_user
from loveace.router.endpoint.profile.model.error import ProfileErrorToCode
from loveace.router.endpoint.profile.model.user import (
AvatarMD5Response,
AvatarUpdateResponse,
UserProfileResponse,
UserProfileUpdateRequest,
)
from loveace.router.endpoint.profile.model.uuid2s3key import Uuid2S3KeyCache
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.s3 import S3Service
from loveace.service.remote.s3.depends import get_s3_service
from loveace.utils.redis_client import RedisClient, get_redis_client
profile_user_router = APIRouter(
prefix="/user",
tags=["用户资料"],
)
@profile_user_router.get(
"/get", response_model=UniResponseModel[UserProfileResponse], summary="获取用户资料"
)
async def profile_user_get(
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
) -> UniResponseModel[UserProfileResponse] | JSONResponse:
"""
获取当前用户的资料信息
✅ 功能特性:
- 获取昵称、个签、头像等用户资料
- 实时从数据库查询最新信息
💡 使用场景:
- 个人中心展示用户资料
- 编辑资料前获取当前信息
- 其他用户查看个人资料
Returns:
UserProfileResponse: 包含昵称、个签、头像 URL
"""
logger = logger_mixin_with_user(user.userid)
try:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user.userid)
)
user_profile: UserProfile | None = result.scalars().first()
if not user_profile:
return ProfileErrorToCode().profile_not_found.to_json_response(
logger.trace_id, "您还未设置用户资料,请先设置用户资料。"
)
if user_profile.avatar_url:
if avatar_url := await s3_service.generate_presigned_url_from_direct_url(
user_profile.avatar_url
):
avatar_url = avatar_url
else:
logger.warning("生成用户头像预签名 URL 失败,可能头像已被删除")
avatar_url = ""
else:
avatar_url = ""
user_response = UserProfileResponse(
nickname=user_profile.nickname,
slogan=user_profile.slogan,
avatar_url=avatar_url,
)
return UniResponseModel[UserProfileResponse](
success=True,
data=user_response,
message="获取用户资料成功",
error=None,
)
except Exception as e:
logger.error("获取用户资料时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)
@profile_user_router.put(
"/avatar/upload",
response_model=UniResponseModel[AvatarUpdateResponse],
summary="上传用户头像",
)
async def profile_user_avatar_upload(
avatar_update_request: UploadFile = File(
..., description="用户头像文件,限制大小小于 5MB"
),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
redis_client: RedisClient = Depends(get_redis_client),
) -> UniResponseModel[AvatarUpdateResponse] | JSONResponse:
"""
上传用户头像到 S3 存储
✅ 功能特性:
- 支持 JPEG 和 PNG 格式
- 限制文件大小为 5MB 以内
- 上传后返回临时 UUID需要通过 /update 接口确认才会保存
⚠️ 限制条件:
- 仅支持 JPEG 和 PNG 格式
- 文件大小不能超过 5MB
- 上传的临时文件有效期为 1 小时
💡 使用场景:
- 用户上传新头像
- 裁剪或预览后再确认保存
Args:
avatar_update_request: 头像文件
Returns:
AvatarUpdateResponse: 包含临时头像 UUID后续需在 /update 接口中使用
"""
logger = logger_mixin_with_user(user.userid)
if not avatar_update_request.content_type and not avatar_update_request.size:
logger.warning("上传的头像文件缺少必要的内容类型或大小信息")
return ProfileErrorToCode().mimetype_not_allowed.to_json_response(
logger.trace_id, "上传的头像文件缺少必要的内容类型或大小信息"
)
if avatar_update_request.size and avatar_update_request.size > 5 * 1024 * 1024:
logger.warning("上传的头像文件过大")
return ProfileErrorToCode().too_large_image.to_json_response(
logger.trace_id, "上传的头像文件过大最大允许5MB"
)
if avatar_update_request.content_type not in ["image/jpeg", "image/png"]:
logger.warning("上传的头像文件格式不支持")
return ProfileErrorToCode().mimetype_not_allowed.to_json_response(
logger.trace_id, "上传的头像文件格式不支持,仅支持 JPEG、PNG"
)
s3_key = f"avatars/{user.userid}/never_use/{user.userid}.jpg"
avatar_upload = await s3_service.upload_obj(
file_obj=avatar_update_request.file,
s3_key=s3_key,
extra_args={"ContentType": avatar_update_request.content_type},
)
if not avatar_upload.success or not avatar_upload.url:
logger.error("上传用户头像到 S3 失败")
return ProfileErrorToCode().remote_service_error.to_json_response(
logger.trace_id, "上传用户头像失败,请稍后重试"
)
avatar_update_request.file.seek(0)
md5_hash = md5(avatar_update_request.file.read()).hexdigest()
logger.info(f"计算上传头像的 MD5 值: {md5_hash}")
cache_data = Uuid2S3KeyCache(s3_key=s3_key, md5=md5_hash)
await redis_client.set_object(
key=f"user:avatar:{user.userid}",
value=cache_data,
model_class=Uuid2S3KeyCache,
expire=3600,
)
avatar_response = AvatarUpdateResponse(uuid=user.userid, md5=md5_hash)
return UniResponseModel[AvatarUpdateResponse](
success=True,
data=avatar_response,
message="上传头像成功",
error=None,
)
@profile_user_router.put(
"/update",
response_model=UniResponseModel[UserProfileResponse],
summary="更新用户资料",
)
async def profile_user_update(
profile_update_request: UserProfileUpdateRequest,
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
redis_client: RedisClient = Depends(get_redis_client),
) -> UniResponseModel[UserProfileResponse] | JSONResponse:
"""
更新用户资料(昵称、个签、头像)
✅ 功能特性:
- 支持更新昵称、个签、头像
- 可同时更新或选择性更新
- 头像通过 /avatar/upload 上传后,需传入 avatar_uuid 进行确认
💡 使用场景:
- 用户编辑个人资料
- 修改昵称或个签
- 确认并保存头像
Args:
profile_update_request: 包含要更新的资料字段(至少一个)
session: 数据库会话
user: 当前用户
s3_service: S3 服务
redis_client: Redis 客户端
Returns:
UserProfileResponse: 更新后的用户资料
"""
logger = logger_mixin_with_user(user.userid)
try:
if not any(
[
profile_update_request.nickname,
profile_update_request.slogan,
profile_update_request.avatar_uuid,
]
):
logger.warning("用户资料更新请求中未包含任何可更新的字段")
return ProfileErrorToCode().need_one_more_field.to_json_response(
logger.trace_id, "请至少提供一个需要更新的字段"
)
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user.userid)
)
user_profile: UserProfile | None = result.scalars().first()
avatar_url = ""
preset_avatar_cache = None
if profile_update_request.avatar_uuid:
preset_avatar_cache = await redis_client.get_object(
key=f"user:avatar:{profile_update_request.avatar_uuid}",
model_class=Uuid2S3KeyCache,
)
if preset_avatar_cache:
copy = await s3_service.copy_object(
source_key=preset_avatar_cache.s3_key,
dest_key=f"avatars/{user.userid}/{user.userid}.jpg",
)
if copy.success:
avatar_url = copy.dest_url
else:
logger.error("复制用户头像到正式存储位置失败")
return ProfileErrorToCode().remote_service_error.to_json_response(
logger.trace_id, "设置用户头像失败,请稍后重试"
)
if not user_profile:
user_profile = UserProfile(
user_id=user.userid,
nickname=profile_update_request.nickname,
slogan=profile_update_request.slogan,
avatar_url=avatar_url if preset_avatar_cache else "",
avatar_md5=preset_avatar_cache.md5 if preset_avatar_cache else "",
)
session.add(user_profile)
else:
if profile_update_request.nickname:
user_profile.nickname = profile_update_request.nickname
if profile_update_request.slogan:
user_profile.slogan = profile_update_request.slogan
if profile_update_request.avatar_uuid:
if avatar_url:
user_profile.avatar_url = avatar_url
user_profile.avatar_md5 = (
preset_avatar_cache.md5 if preset_avatar_cache else ""
)
await redis_client.delete(
key=f"user:avatar:{profile_update_request.avatar_uuid}"
)
else:
logger.warning("提供的头像 UUID 无效或已过期")
return ProfileErrorToCode().resource_expired.to_json_response(
logger.trace_id, "提供的头像 UUID 无效或已过期"
)
await session.commit()
if user_profile.avatar_url:
avatar_url = await s3_service.generate_presigned_url_from_direct_url(
user_profile.avatar_url
)
if not avatar_url:
logger.warning("生成用户头像预签名 URL 失败,可能头像已被删除")
avatar_url = ""
else:
avatar_url = ""
user_response = UserProfileResponse(
nickname=user_profile.nickname,
slogan=user_profile.slogan if user_profile.slogan else "",
avatar_url=avatar_url,
)
return UniResponseModel[UserProfileResponse](
success=True,
data=user_response,
message="更新用户资料成功",
error=None,
)
except Exception as e:
logger.error("更新用户资料时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)
@profile_user_router.get(
"/avatar/md5",
summary="获取用户头像的MD5值",
response_model=UniResponseModel[AvatarMD5Response],
)
async def profile_user_avatar_md5(
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
) -> UniResponseModel[AvatarMD5Response] | JSONResponse:
"""
获取当前用户头像的 MD5 值
✅ 功能特性:
- 从数据库中获取用户头像的 MD5 值
- 用于验证头像文件完整性或进行缓存控制
💡 使用场景:
- 在头像上传后,验证文件的完整性
- 缓存头像文件的 MD5 值,以便后续快速验证
"""
logger = logger_mixin_with_user(user.userid)
result = await session.execute(
select(UserProfile.avatar_md5).where(UserProfile.user_id == user.userid)
)
avatar_md5: str | None = result.scalar()
if not avatar_md5:
logger.warning("用户头像的 MD5 值未找到")
return ProfileErrorToCode().profile_not_found.to_json_response(
logger.trace_id, "用户头像的 MD5 值未找到"
)
return UniResponseModel[AvatarMD5Response](
success=True,
data=AvatarMD5Response(md5=avatar_md5),
message="获取用户头像的 MD5 值成功",
error=None,
)