🎉初次提交
This commit is contained in:
6
utils/__init__.py
Normal file
6
utils/__init__.py
Normal 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
322
utils/file_manager.py
Normal 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
370
utils/s3_client.py
Normal 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()
|
||||
Reference in New Issue
Block a user