Files
hunjing/backend/app/services/avatar.py
T
2026-08-21 16:00:13 +08:00

132 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""角色头像文件服务。
职责:
- 上传头像文件到本地文件系统
- 提供头像文件流
- 清理头像文件
"""
from __future__ import annotations
from pathlib import Path
from fastapi.responses import FileResponse
from app.core.config import settings
from app.core.errors import AppError
from app.services.characters import get_owned_character
_ALLOWED_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".webp"})
_MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def _avatar_path(character_id: str) -> Path:
"""获取角色头像文件路径(无扩展名,需遍历查找实际文件)。"""
return settings.resolved_avatar_dir / character_id
def _find_avatar_file(character_id: str) -> Path | None:
"""查找角色已有的头像文件(任一格式)。"""
base = _avatar_path(character_id)
for ext in _ALLOWED_EXTENSIONS:
f = base.with_suffix(ext)
if f.exists():
return f
return None
def _allowed(content_type: str) -> bool:
return content_type in {
"image/jpeg",
"image/png",
"image/webp",
}
async def upload_avatar(
character_id: str,
content: bytes,
content_type: str,
owner_scope: str = settings.default_owner_scope,
) -> str:
"""上传角色头像。
Args:
character_id: 角色 ID
content: 图片文件二进制内容
content_type: MIME 类型
owner_scope: 归属范围
Returns:
头像访问的相对 URL 路径
Raises:
AppError: 文件类型不允许、大小超限、角色不存在
"""
from app.db.session import AsyncSessionLocal
# 校验角色存在性
async with AsyncSessionLocal() as session:
await get_owned_character(session, character_id, owner_scope=owner_scope)
if not _allowed(content_type):
raise AppError(
"invalid_input",
f"不支持的文件类型 '{content_type}',仅支持 JPG/PNG/WebP。",
)
if len(content) > _MAX_FILE_SIZE:
raise AppError(
"invalid_input",
"头像文件过大,最大允许 5MB。",
)
# 扩展名映射
ext_map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
ext = ext_map[content_type]
# 删除旧头像
old = _find_avatar_file(character_id)
if old:
old.unlink(missing_ok=True)
# 写入新文件
dest = _avatar_path(character_id).with_suffix(ext)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(content)
return f"/api/v1/characters/{character_id}/avatar"
async def get_avatar_response(character_id: str) -> FileResponse:
"""获取角色头像文件响应。
Args:
character_id: 角色 ID
Returns:
FileResponse 或 None(无头像时抛出 not_found)
"""
avatar_file = _find_avatar_file(character_id)
if avatar_file is None:
raise AppError("not_found", "该角色未设置头像。")
# 推测 media_type
ext = avatar_file.suffix.lower()
media_type = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}.get(ext, "application/octet-stream")
return FileResponse(str(avatar_file), media_type=media_type)
async def delete_avatar(character_id: str) -> None:
"""删除角色头像文件。"""
avatar_file = _find_avatar_file(character_id)
if avatar_file:
avatar_file.unlink(missing_ok=True)