🎉初次提交

This commit is contained in:
2025-08-03 16:50:56 +08:00
commit 56bdf5388d
67 changed files with 18379 additions and 0 deletions

6
utils/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
try:
from .s3_client import s3_client
__all__ = ["s3_client"]
except ImportError:
# 如果S3客户端依赖不可用则不导出
__all__ = []

322
utils/file_manager.py Normal file
View File

@@ -0,0 +1,322 @@
import os
import uuid
import json
import base64
import aiofiles
import glob
from typing import Optional, Dict, Any
from pathlib import Path
class FileManager:
def __init__(self, base_path: str = "data"):
self.base_path = Path(base_path)
self.avatar_path = self.base_path / "avatars"
self.background_path = self.base_path / "backgrounds"
self.settings_path = self.base_path / "settings"
# 确保目录存在
self.avatar_path.mkdir(parents=True, exist_ok=True)
self.background_path.mkdir(parents=True, exist_ok=True)
self.settings_path.mkdir(parents=True, exist_ok=True)
def generate_file_id(self) -> str:
"""生成文件ID"""
return str(uuid.uuid4())
async def cleanup_user_files(self, userid: str, file_type: str) -> None:
"""
清理用户的所有旧文件
:param userid: 用户ID
:param file_type: 文件类型 ('avatar', 'background', 'settings')
"""
if file_type == 'avatar':
pattern = self.avatar_path / f"{userid}_*"
elif file_type == 'background':
pattern = self.background_path / f"{userid}_*"
elif file_type == 'settings':
pattern = self.settings_path / f"{userid}_*"
else:
return
# 删除所有匹配的文件
for file_path in glob.glob(str(pattern)):
try:
Path(file_path).unlink()
except Exception:
pass # 忽略删除失败
async def save_avatar(self, userid: str, avatar_base64: str) -> str:
"""
保存用户头像,删除旧头像
:param userid: 用户ID
:param avatar_base64: base64编码的头像数据
:return: 文件名
"""
if not avatar_base64:
return ""
try:
# 先清理旧的头像文件
await self.cleanup_user_files(userid, 'avatar')
# 解析base64数据
if avatar_base64.startswith('data:'):
# 处理data URI格式
header, data = avatar_base64.split(',', 1)
# 提取文件格式
if 'image/png' in header:
ext = 'png'
elif 'image/jpeg' in header or 'image/jpg' in header:
ext = 'jpg'
elif 'image/gif' in header:
ext = 'gif'
else:
ext = 'png' # 默认格式
else:
# 纯base64数据默认为png
data = avatar_base64
ext = 'png'
# 生成文件名
file_id = self.generate_file_id()
filename = f"{userid}_{file_id}.{ext}"
file_path = self.avatar_path / filename
# 解码并保存文件
image_data = base64.b64decode(data)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(image_data)
return filename
except Exception as e:
raise ValueError(f"保存头像失败: {str(e)}")
async def get_avatar(self, filename: str) -> Optional[bytes]:
"""
获取用户头像
:param filename: 文件名
:return: 图片数据
"""
if not filename:
return None
file_path = self.avatar_path / filename
if not file_path.exists():
return None
try:
async with aiofiles.open(file_path, 'rb') as f:
return await f.read()
except Exception:
return None
async def delete_avatar(self, filename: str) -> bool:
"""
删除用户头像
:param filename: 文件名
:return: 是否删除成功
"""
if not filename:
return True
file_path = self.avatar_path / filename
if file_path.exists():
try:
file_path.unlink()
return True
except Exception:
return False
return True
async def save_background(self, userid: str, background_base64: str) -> str:
"""
保存用户背景,删除旧背景
:param userid: 用户ID
:param background_base64: base64编码的背景数据
:return: 文件名
"""
if not background_base64:
return ""
try:
# 先清理旧的背景文件
await self.cleanup_user_files(userid, 'background')
# 解析base64数据
if background_base64.startswith('data:'):
# 处理data URI格式
header, data = background_base64.split(',', 1)
# 提取文件格式
if 'image/png' in header:
ext = 'png'
elif 'image/jpeg' in header or 'image/jpg' in header:
ext = 'jpg'
elif 'image/gif' in header:
ext = 'gif'
elif 'image/webp' in header:
ext = 'webp'
else:
ext = 'png' # 默认格式
else:
# 纯base64数据默认为png
data = background_base64
ext = 'png'
# 生成文件名
file_id = self.generate_file_id()
filename = f"{userid}_{file_id}.{ext}"
file_path = self.background_path / filename
# 解码并保存文件
image_data = base64.b64decode(data)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(image_data)
return filename
except Exception as e:
raise ValueError(f"保存背景失败: {str(e)}")
async def get_background(self, filename: str) -> Optional[bytes]:
"""
获取用户背景
:param filename: 文件名
:return: 图片数据
"""
if not filename:
return None
file_path = self.background_path / filename
if not file_path.exists():
return None
try:
async with aiofiles.open(file_path, 'rb') as f:
return await f.read()
except Exception:
return None
async def delete_background(self, filename: str) -> bool:
"""
删除用户背景
:param filename: 文件名
:return: 是否删除成功
"""
if not filename:
return True
file_path = self.background_path / filename
if file_path.exists():
try:
file_path.unlink()
return True
except Exception:
return False
return True
async def save_settings(self, userid: str, settings: Dict[str, Any]) -> str:
"""
保存用户设置,删除旧设置
:param userid: 用户ID
:param settings: 设置字典
:return: 文件名
"""
try:
# 先清理旧的设置文件
await self.cleanup_user_files(userid, 'settings')
# 生成文件名
file_id = self.generate_file_id()
filename = f"{userid}_{file_id}.json"
file_path = self.settings_path / filename
# 保存JSON文件
async with aiofiles.open(file_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(settings, ensure_ascii=False, indent=2))
return filename
except Exception as e:
raise ValueError(f"保存设置失败: {str(e)}")
async def get_settings(self, filename: str) -> Optional[Dict[str, Any]]:
"""
获取用户设置
:param filename: 文件名
:return: 设置字典
"""
if not filename:
return None
file_path = self.settings_path / filename
if not file_path.exists():
return None
try:
async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
content = await f.read()
return json.loads(content)
except Exception:
return None
async def delete_settings(self, filename: str) -> bool:
"""
删除用户设置文件
:param filename: 文件名
:return: 是否删除成功
"""
if not filename:
return True
file_path = self.settings_path / filename
if file_path.exists():
try:
file_path.unlink()
return True
except Exception:
return False
return True
# 全局文件管理器实例
file_manager = FileManager()
def validate_settings(settings: Dict[str, Any]) -> bool:
"""
验证设置字典是否符合要求的格式
:param settings: 设置字典
:return: 是否有效
"""
required_fields = {
'theme': str,
'lightModeOpacity': (int, float),
'lightModeBrightness': (int, float),
'darkModeOpacity': (int, float),
'darkModeBrightness': (int, float),
'backgroundBlur': (int, float),
}
try:
# 检查所有必需字段是否存在且类型正确
for field, expected_type in required_fields.items():
if field not in settings:
return False
if not isinstance(settings[field], expected_type):
return False
# 验证数值范围0-1
numeric_fields = ['lightModeOpacity', 'lightModeBrightness',
'darkModeOpacity', 'darkModeBrightness', 'backgroundBlur']
for field in numeric_fields:
value = settings[field]
if not (0 <= value <= 1):
return False
# 验证主题值
valid_themes = ['light', 'dark', 'system', 'ThemeMode.light', 'ThemeMode.dark', 'ThemeMode.system']
if settings['theme'] not in valid_themes:
return False
return True
except Exception:
return False

370
utils/s3_client.py Normal file
View File

@@ -0,0 +1,370 @@
import asyncio
from typing import Optional, Dict, Any, BinaryIO, Union
from pathlib import Path
from loguru import logger
from config import config_manager
# 可选导入aioboto3
try:
import aioboto3
from botocore.exceptions import ClientError, NoCredentialsError
HAS_BOTO3 = True
except ImportError:
aioboto3 = None
ClientError = Exception
NoCredentialsError = Exception
HAS_BOTO3 = False
class S3Client:
"""异步S3客户端"""
def __init__(self):
self._session = None
self._client = None
self._config = None
if not HAS_BOTO3:
logger.warning("aioboto3未安装S3客户端功能不可用")
def _get_s3_config(self):
"""获取S3配置"""
if self._config is None:
self._config = config_manager.get_settings().s3
return self._config
async def _get_client(self):
"""获取S3客户端"""
if not HAS_BOTO3:
raise RuntimeError("aioboto3未安装无法使用S3客户端功能。请运行: pip install aioboto3")
if self._client is None:
config = self._get_s3_config()
# 验证必要的配置
if not config.access_key_id or not config.secret_access_key:
raise ValueError("S3 access_key_id 和 secret_access_key 不能为空")
if not config.bucket_name:
raise ValueError("S3 bucket_name 不能为空")
if self._session is None:
self._session = aioboto3.Session()
self._client = self._session.client(
's3',
aws_access_key_id=config.access_key_id,
aws_secret_access_key=config.secret_access_key,
endpoint_url=config.endpoint_url,
region_name=config.region_name,
use_ssl=config.use_ssl,
config=aioboto3.Config(signature_version=config.signature_version)
)
return self._client
async def upload_file(
self,
file_path: Union[str, Path],
key: str,
bucket: Optional[str] = None,
extra_args: Optional[Dict[str, Any]] = None
) -> bool:
"""
上传文件到S3
Args:
file_path: 本地文件路径
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
extra_args: 额外参数如metadata, ACL等
Returns:
bool: 是否上传成功
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
await s3.upload_file(
Filename=str(file_path),
Bucket=bucket,
Key=key,
ExtraArgs=extra_args or {}
)
logger.info(f"文件上传成功: {file_path} -> s3://{bucket}/{key}")
return True
except FileNotFoundError:
logger.error(f"文件不存在: {file_path}")
return False
except NoCredentialsError:
logger.error("S3凭据未配置或无效")
return False
except ClientError as e:
logger.error(f"S3客户端错误: {e}")
return False
except Exception as e:
logger.error(f"上传文件失败: {e}")
return False
async def download_file(
self,
key: str,
file_path: Union[str, Path],
bucket: Optional[str] = None
) -> bool:
"""
从S3下载文件
Args:
key: S3对象键名
file_path: 本地保存路径
bucket: 存储桶名称如果为None则使用配置中的默认bucket
Returns:
bool: 是否下载成功
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
# 确保目标目录存在
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
async with await self._get_client() as s3:
await s3.download_file(
Bucket=bucket,
Key=key,
Filename=str(file_path)
)
logger.info(f"文件下载成功: s3://{bucket}/{key} -> {file_path}")
return True
except ClientError as e:
if e.response['Error']['Code'] == 'NoSuchKey':
logger.error(f"S3对象不存在: s3://{bucket}/{key}")
else:
logger.error(f"S3客户端错误: {e}")
return False
except Exception as e:
logger.error(f"下载文件失败: {e}")
return False
async def upload_bytes(
self,
data: bytes,
key: str,
bucket: Optional[str] = None,
content_type: Optional[str] = None,
extra_args: Optional[Dict[str, Any]] = None
) -> bool:
"""
上传字节数据到S3
Args:
data: 要上传的字节数据
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
content_type: 内容类型
extra_args: 额外参数
Returns:
bool: 是否上传成功
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
extra_args = extra_args or {}
if content_type:
extra_args['ContentType'] = content_type
async with await self._get_client() as s3:
await s3.put_object(
Bucket=bucket,
Key=key,
Body=data,
**extra_args
)
logger.info(f"数据上传成功: s3://{bucket}/{key}")
return True
except Exception as e:
logger.error(f"上传数据失败: {e}")
return False
async def download_bytes(
self,
key: str,
bucket: Optional[str] = None
) -> Optional[bytes]:
"""
从S3下载字节数据
Args:
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
Returns:
Optional[bytes]: 下载的字节数据失败时返回None
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
response = await s3.get_object(Bucket=bucket, Key=key)
async with response['Body'] as stream:
data = await stream.read()
logger.info(f"数据下载成功: s3://{bucket}/{key}")
return data
except ClientError as e:
if e.response['Error']['Code'] == 'NoSuchKey':
logger.error(f"S3对象不存在: s3://{bucket}/{key}")
else:
logger.error(f"S3客户端错误: {e}")
return None
except Exception as e:
logger.error(f"下载数据失败: {e}")
return None
async def delete_object(
self,
key: str,
bucket: Optional[str] = None
) -> bool:
"""
删除S3对象
Args:
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
Returns:
bool: 是否删除成功
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
await s3.delete_object(Bucket=bucket, Key=key)
logger.info(f"对象删除成功: s3://{bucket}/{key}")
return True
except Exception as e:
logger.error(f"删除对象失败: {e}")
return False
async def list_objects(
self,
prefix: str = "",
bucket: Optional[str] = None,
max_keys: int = 1000
) -> list:
"""
列出S3对象
Args:
prefix: 对象键前缀
bucket: 存储桶名称如果为None则使用配置中的默认bucket
max_keys: 最大返回对象数量
Returns:
list: 对象列表
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
response = await s3.list_objects_v2(
Bucket=bucket,
Prefix=prefix,
MaxKeys=max_keys
)
objects = response.get('Contents', [])
logger.info(f"列出对象成功: s3://{bucket}/{prefix}* ({len(objects)}个对象)")
return objects
except Exception as e:
logger.error(f"列出对象失败: {e}")
return []
async def object_exists(
self,
key: str,
bucket: Optional[str] = None
) -> bool:
"""
检查S3对象是否存在
Args:
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
Returns:
bool: 对象是否存在
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
await s3.head_object(Bucket=bucket, Key=key)
return True
except ClientError as e:
if e.response['Error']['Code'] == '404':
return False
else:
logger.error(f"检查对象存在性失败: {e}")
return False
except Exception as e:
logger.error(f"检查对象存在性失败: {e}")
return False
async def close(self):
"""关闭S3客户端"""
if self._client:
await self._client.close()
self._client = None
if self._session:
await self._session.close()
self._session = None
# 全局S3客户端实例
s3_client = S3Client()