⚒️ 重大重构 LoveACE V2
引入了 mongodb 对数据库进行了一定程度的数据加密 性能改善 代码简化 统一错误模型和响应 使用 apifox 作为文档
This commit is contained in:
13
loveace/router/endpoint/profile/__init__.py
Normal file
13
loveace/router/endpoint/profile/__init__.py
Normal 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)
|
||||
427
loveace/router/endpoint/profile/flutter.py
Normal file
427
loveace/router/endpoint/profile/flutter.py
Normal 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)
|
||||
46
loveace/router/endpoint/profile/model/error.py
Normal file
46
loveace/router/endpoint/profile/model/error.py
Normal 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="服务器错误",
|
||||
)
|
||||
56
loveace/router/endpoint/profile/model/flutter.py
Normal file
56
loveace/router/endpoint/profile/model/flutter.py
Normal 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"
|
||||
24
loveace/router/endpoint/profile/model/user.py
Normal file
24
loveace/router/endpoint/profile/model/user.py
Normal 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值")
|
||||
8
loveace/router/endpoint/profile/model/uuid2s3key.py
Normal file
8
loveace/router/endpoint/profile/model/uuid2s3key.py
Normal 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值")
|
||||
339
loveace/router/endpoint/profile/user.py
Normal file
339
loveace/router/endpoint/profile/user.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user