mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-07 15:51:32 +08:00
refactor: 项目结构优化与FastAPI生命周期更新
重构项目目录结构,提高代码组织性和可维护性 将schemas目录重命名为domain,更好地表达领域模型概念 将services目录细分为service/chat、service/image等子目录 将api目录重命名为router,更符合FastAPI惯例 创建utils目录存放通用工具函数 更新FastAPI应用程序生命周期管理 替换已弃用的on_event方法为推荐的lifespan事件处理器 添加应用程序关闭时的日志记录 代码质量改进 抽取常量到constants.py,减少硬编码值 添加helpers.py提供通用工具函数 优化配置管理,使用环境变量和默认值 完善文档字符串,提高代码可读性
This commit is contained in:
3
app/utils/__init__.py
Normal file
3
app/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
工具包初始化模块
|
||||
"""
|
||||
146
app/utils/helpers.py
Normal file
146
app/utils/helpers.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
通用工具函数模块
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import base64
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
||||
|
||||
|
||||
def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
从 base64 字符串中提取 MIME 类型和数据
|
||||
|
||||
Args:
|
||||
base64_string: 可能包含 MIME 类型信息的 base64 字符串
|
||||
|
||||
Returns:
|
||||
tuple: (mime_type, encoded_data)
|
||||
"""
|
||||
# 检查字符串是否以 "data:" 格式开始
|
||||
if base64_string.startswith('data:'):
|
||||
# 提取 MIME 类型和数据
|
||||
pattern = DATA_URL_PATTERN
|
||||
match = re.match(pattern, base64_string)
|
||||
if match:
|
||||
mime_type = "image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
|
||||
encoded_data = match.group(2)
|
||||
return mime_type, encoded_data
|
||||
|
||||
# 如果不是预期格式,假定它只是数据部分
|
||||
return None, base64_string
|
||||
|
||||
|
||||
def convert_image_to_base64(url: str) -> str:
|
||||
"""
|
||||
将图片URL转换为base64编码
|
||||
|
||||
Args:
|
||||
url: 图片URL
|
||||
|
||||
Returns:
|
||||
str: base64编码的图片数据
|
||||
|
||||
Raises:
|
||||
Exception: 如果获取图片失败
|
||||
"""
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
# 将图片内容转换为base64
|
||||
img_data = base64.b64encode(response.content).decode('utf-8')
|
||||
return img_data
|
||||
else:
|
||||
raise Exception(f"Failed to fetch image: {response.status_code}")
|
||||
|
||||
|
||||
def format_json_response(data: Dict[str, Any], indent: int = 2) -> str:
|
||||
"""
|
||||
格式化JSON响应
|
||||
|
||||
Args:
|
||||
data: 要格式化的数据
|
||||
indent: 缩进空格数
|
||||
|
||||
Returns:
|
||||
str: 格式化后的JSON字符串
|
||||
"""
|
||||
return json.dumps(data, indent=indent, ensure_ascii=False)
|
||||
|
||||
|
||||
def parse_prompt_parameters(prompt: str, default_ratio: str = "1:1") -> Tuple[str, int, str]:
|
||||
"""
|
||||
从prompt中解析参数
|
||||
|
||||
支持的格式:
|
||||
- {n:数量} 例如: {n:2} 生成2张图片
|
||||
- {ratio:比例} 例如: {ratio:16:9} 使用16:9比例
|
||||
|
||||
Args:
|
||||
prompt: 提示文本
|
||||
default_ratio: 默认比例
|
||||
|
||||
Returns:
|
||||
tuple: (清理后的提示文本, 图片数量, 比例)
|
||||
"""
|
||||
# 默认值
|
||||
n = 1
|
||||
aspect_ratio = default_ratio
|
||||
|
||||
# 解析n参数
|
||||
n_match = re.search(r'{n:(\d+)}', prompt)
|
||||
if n_match:
|
||||
n = int(n_match.group(1))
|
||||
if n < 1 or n > 4:
|
||||
raise ValueError(f"Invalid n value: {n}. Must be between 1 and 4.")
|
||||
prompt = prompt.replace(n_match.group(0), '').strip()
|
||||
|
||||
# 解析ratio参数
|
||||
ratio_match = re.search(r'{ratio:(\d+:\d+)}', prompt)
|
||||
if ratio_match:
|
||||
aspect_ratio = ratio_match.group(1)
|
||||
if aspect_ratio not in VALID_IMAGE_RATIOS:
|
||||
raise ValueError(
|
||||
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(VALID_IMAGE_RATIOS)}"
|
||||
)
|
||||
prompt = prompt.replace(ratio_match.group(0), '').strip()
|
||||
|
||||
return prompt, n, aspect_ratio
|
||||
|
||||
|
||||
def extract_image_urls_from_markdown(text: str) -> List[str]:
|
||||
"""
|
||||
从Markdown文本中提取图片URL
|
||||
|
||||
Args:
|
||||
text: Markdown文本
|
||||
|
||||
Returns:
|
||||
List[str]: 图片URL列表
|
||||
"""
|
||||
pattern = IMAGE_URL_PATTERN
|
||||
matches = re.findall(pattern, text)
|
||||
return [match[1] for match in matches]
|
||||
|
||||
|
||||
def is_valid_api_key(key: str) -> bool:
|
||||
"""
|
||||
检查API密钥格式是否有效
|
||||
|
||||
Args:
|
||||
key: API密钥
|
||||
|
||||
Returns:
|
||||
bool: 如果密钥格式有效则返回True
|
||||
"""
|
||||
# 检查Gemini API密钥格式
|
||||
if key.startswith('AIza'):
|
||||
return len(key) >= 30
|
||||
|
||||
# 检查OpenAI API密钥格式
|
||||
if key.startswith('sk-'):
|
||||
return len(key) >= 30
|
||||
|
||||
return False
|
||||
393
app/utils/uploader.py
Normal file
393
app/utils/uploader.py
Normal file
@@ -0,0 +1,393 @@
|
||||
import requests
|
||||
from app.domain.image_models import ImageMetadata, ImageUploader, UploadResponse
|
||||
from enum import Enum
|
||||
from typing import Optional, Any
|
||||
|
||||
class UploadErrorType(Enum):
|
||||
"""上传错误类型枚举"""
|
||||
NETWORK_ERROR = "network_error" # 网络请求错误
|
||||
AUTH_ERROR = "auth_error" # 认证错误
|
||||
INVALID_FILE = "invalid_file" # 无效文件
|
||||
SERVER_ERROR = "server_error" # 服务器错误
|
||||
PARSE_ERROR = "parse_error" # 响应解析错误
|
||||
UNKNOWN = "unknown" # 未知错误
|
||||
|
||||
|
||||
class UploadError(Exception):
|
||||
"""图片上传错误异常类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
error_type: UploadErrorType = UploadErrorType.UNKNOWN,
|
||||
status_code: Optional[int] = None,
|
||||
details: Optional[dict] = None,
|
||||
original_error: Optional[Exception] = None
|
||||
):
|
||||
"""
|
||||
初始化上传错误异常
|
||||
|
||||
Args:
|
||||
message: 错误消息
|
||||
error_type: 错误类型
|
||||
status_code: HTTP状态码
|
||||
details: 详细错误信息
|
||||
original_error: 原始异常
|
||||
"""
|
||||
self.message = message
|
||||
self.error_type = error_type
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
self.original_error = original_error
|
||||
|
||||
# 构建完整错误信息
|
||||
full_message = f"[{error_type.value}] {message}"
|
||||
if status_code:
|
||||
full_message = f"{full_message} (Status: {status_code})"
|
||||
if details:
|
||||
full_message = f"{full_message} - Details: {details}"
|
||||
|
||||
super().__init__(full_message)
|
||||
|
||||
@classmethod
|
||||
def from_response(cls, response: Any, message: Optional[str] = None) -> "UploadError":
|
||||
"""
|
||||
从HTTP响应创建错误实例
|
||||
|
||||
Args:
|
||||
response: HTTP响应对象
|
||||
message: 自定义错误消息
|
||||
"""
|
||||
try:
|
||||
error_data = response.json()
|
||||
details = error_data.get("data", {})
|
||||
return cls(
|
||||
message=message or error_data.get("message", "Unknown error"),
|
||||
error_type=UploadErrorType.SERVER_ERROR,
|
||||
status_code=response.status_code,
|
||||
details=details
|
||||
)
|
||||
except Exception:
|
||||
return cls(
|
||||
message=message or "Failed to parse error response",
|
||||
error_type=UploadErrorType.PARSE_ERROR,
|
||||
status_code=response.status_code
|
||||
)
|
||||
|
||||
|
||||
class SmMsUploader(ImageUploader):
|
||||
API_URL = "https://sm.ms/api/v2/upload"
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
self.api_key = api_key
|
||||
|
||||
def upload(self, file: bytes, filename: str) -> UploadResponse:
|
||||
try:
|
||||
# 准备请求头
|
||||
headers = {
|
||||
"Authorization": f"Basic {self.api_key}"
|
||||
}
|
||||
|
||||
# 准备文件数据
|
||||
files = {
|
||||
"smfile": (filename, file, "image/png")
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
response = requests.post(
|
||||
self.API_URL,
|
||||
headers=headers,
|
||||
files=files
|
||||
)
|
||||
|
||||
# 检查响应状态
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
|
||||
# 验证上传是否成功
|
||||
if not result.get("success"):
|
||||
raise UploadError(result.get("message", "Upload failed"))
|
||||
|
||||
# 转换为统一格式
|
||||
data = result["data"]
|
||||
image_metadata = ImageMetadata(
|
||||
width=data["width"],
|
||||
height=data["height"],
|
||||
filename=data["filename"],
|
||||
size=data["size"],
|
||||
url=data["url"],
|
||||
delete_url=data["delete"]
|
||||
)
|
||||
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
code="success",
|
||||
message="Upload success",
|
||||
data=image_metadata
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
# 处理网络请求相关错误
|
||||
raise UploadError(f"Upload request failed: {str(e)}")
|
||||
except (KeyError, ValueError) as e:
|
||||
# 处理响应解析错误
|
||||
raise UploadError(f"Invalid response format: {str(e)}")
|
||||
except Exception as e:
|
||||
# 处理其他未预期的错误
|
||||
raise UploadError(f"Upload failed: {str(e)}")
|
||||
|
||||
|
||||
class QiniuUploader(ImageUploader):
|
||||
def __init__(self, access_key: str, secret_key: str):
|
||||
self.access_key = access_key
|
||||
self.secret_key = secret_key
|
||||
|
||||
def upload(self, file: bytes, filename: str) -> UploadResponse:
|
||||
# 实现七牛云的具体上传逻辑
|
||||
pass
|
||||
|
||||
|
||||
class PicGoUploader(ImageUploader):
|
||||
"""Chevereto API 图片上传器"""
|
||||
|
||||
def __init__(self, api_key: str, api_url: str = "https://www.picgo.net/api/1/upload"):
|
||||
"""
|
||||
初始化 Chevereto 上传器
|
||||
|
||||
Args:
|
||||
api_key: Chevereto API 密钥
|
||||
api_url: Chevereto API 上传地址
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.api_url = api_url
|
||||
|
||||
def upload(self, file: bytes, filename: str) -> UploadResponse:
|
||||
"""
|
||||
上传图片到 Chevereto 服务
|
||||
|
||||
Args:
|
||||
file: 图片文件二进制数据
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
UploadResponse: 上传响应对象
|
||||
|
||||
Raises:
|
||||
UploadError: 上传失败时抛出异常
|
||||
"""
|
||||
try:
|
||||
# 准备请求头
|
||||
headers = {
|
||||
"X-API-Key": self.api_key
|
||||
}
|
||||
|
||||
# 准备文件数据
|
||||
files = {
|
||||
"source": (filename, file)
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
response = requests.post(
|
||||
self.api_url,
|
||||
headers=headers,
|
||||
files=files
|
||||
)
|
||||
|
||||
# 检查响应状态
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
|
||||
# 验证上传是否成功
|
||||
if result.get("status_code") != 200:
|
||||
error_message = "Upload failed"
|
||||
if "error" in result:
|
||||
error_message = result["error"].get("message", error_message)
|
||||
raise UploadError(
|
||||
message=error_message,
|
||||
error_type=UploadErrorType.SERVER_ERROR,
|
||||
status_code=result.get("status_code"),
|
||||
details=result.get("error")
|
||||
)
|
||||
|
||||
# 从响应中提取图片信息
|
||||
image_data = result.get("image", {})
|
||||
|
||||
# 构建图片元数据
|
||||
image_metadata = ImageMetadata(
|
||||
width=image_data.get("width", 0),
|
||||
height=image_data.get("height", 0),
|
||||
filename=image_data.get("filename", filename),
|
||||
size=image_data.get("size", 0),
|
||||
url=image_data.get("url", ""),
|
||||
delete_url=image_data.get("delete_url", None)
|
||||
)
|
||||
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
code="success",
|
||||
message=result.get("success", {}).get("message", "Upload success"),
|
||||
data=image_metadata
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
# 处理网络请求相关错误
|
||||
raise UploadError(
|
||||
message=f"Upload request failed: {str(e)}",
|
||||
error_type=UploadErrorType.NETWORK_ERROR,
|
||||
original_error=e
|
||||
)
|
||||
except (KeyError, ValueError, TypeError) as e:
|
||||
# 处理响应解析错误
|
||||
raise UploadError(
|
||||
message=f"Invalid response format: {str(e)}",
|
||||
error_type=UploadErrorType.PARSE_ERROR,
|
||||
original_error=e
|
||||
)
|
||||
except UploadError:
|
||||
# 重新抛出已经是 UploadError 类型的异常
|
||||
raise
|
||||
except Exception as e:
|
||||
# 处理其他未预期的错误
|
||||
raise UploadError(
|
||||
message=f"Upload failed: {str(e)}",
|
||||
error_type=UploadErrorType.UNKNOWN,
|
||||
original_error=e
|
||||
)
|
||||
|
||||
|
||||
class CloudFlareImgBedUploader(ImageUploader):
|
||||
"""CloudFlare图床上传器"""
|
||||
|
||||
def __init__(self, auth_code: str, api_url: str):
|
||||
"""
|
||||
初始化CloudFlare图床上传器
|
||||
|
||||
Args:
|
||||
auth_code: 认证码
|
||||
api_url: 上传API地址
|
||||
"""
|
||||
self.auth_code = auth_code
|
||||
self.api_url = api_url
|
||||
|
||||
def upload(self, file: bytes, filename: str) -> UploadResponse:
|
||||
"""
|
||||
上传图片到CloudFlare图床
|
||||
|
||||
Args:
|
||||
file: 图片文件二进制数据
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
UploadResponse: 上传响应对象
|
||||
|
||||
Raises:
|
||||
UploadError: 上传失败时抛出异常
|
||||
"""
|
||||
try:
|
||||
# 准备请求URL(添加认证码参数,如果存在)
|
||||
if self.auth_code:
|
||||
request_url = f"{self.api_url}?authCode={self.auth_code}&uploadNameType=origin"
|
||||
else:
|
||||
request_url = f"{self.api_url}?uploadNameType=origin"
|
||||
|
||||
# 准备文件数据
|
||||
files = {
|
||||
"file": (filename, file)
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
response = requests.post(
|
||||
request_url,
|
||||
files=files
|
||||
)
|
||||
|
||||
# 检查响应状态
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
|
||||
# 验证响应格式
|
||||
if not result or not isinstance(result, list) or len(result) == 0:
|
||||
raise UploadError(
|
||||
message="Invalid response format",
|
||||
error_type=UploadErrorType.PARSE_ERROR
|
||||
)
|
||||
|
||||
# 获取文件URL
|
||||
file_path = result[0].get("src")
|
||||
if not file_path:
|
||||
raise UploadError(
|
||||
message="Missing file URL in response",
|
||||
error_type=UploadErrorType.PARSE_ERROR
|
||||
)
|
||||
|
||||
# 构建完整URL(如果返回的是相对路径)
|
||||
base_url = self.api_url.split("/upload")[0]
|
||||
full_url = file_path if file_path.startswith(("http://", "https://")) else f"{base_url}{file_path}"
|
||||
|
||||
# 构建图片元数据(注意:CloudFlare-ImgBed不返回所有元数据,所以部分字段为默认值)
|
||||
image_metadata = ImageMetadata(
|
||||
width=0, # CloudFlare-ImgBed不返回宽度
|
||||
height=0, # CloudFlare-ImgBed不返回高度
|
||||
filename=filename,
|
||||
size=0, # CloudFlare-ImgBed不返回大小
|
||||
url=full_url,
|
||||
delete_url=None # CloudFlare-ImgBed不返回删除URL
|
||||
)
|
||||
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
code="success",
|
||||
message="Upload success",
|
||||
data=image_metadata
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
# 处理网络请求相关错误
|
||||
raise UploadError(
|
||||
message=f"Upload request failed: {str(e)}",
|
||||
error_type=UploadErrorType.NETWORK_ERROR,
|
||||
original_error=e
|
||||
)
|
||||
except (KeyError, ValueError, TypeError, IndexError) as e:
|
||||
# 处理响应解析错误
|
||||
raise UploadError(
|
||||
message=f"Invalid response format: {str(e)}",
|
||||
error_type=UploadErrorType.PARSE_ERROR,
|
||||
original_error=e
|
||||
)
|
||||
except UploadError:
|
||||
# 重新抛出已经是 UploadError 类型的异常
|
||||
raise
|
||||
except Exception as e:
|
||||
# 处理其他未预期的错误
|
||||
raise UploadError(
|
||||
message=f"Upload failed: {str(e)}",
|
||||
error_type=UploadErrorType.UNKNOWN,
|
||||
original_error=e
|
||||
)
|
||||
|
||||
class ImageUploaderFactory:
|
||||
@staticmethod
|
||||
def create(provider: str, **credentials) -> ImageUploader:
|
||||
if provider == "smms":
|
||||
return SmMsUploader(credentials["api_key"])
|
||||
elif provider == "qiniu":
|
||||
return QiniuUploader(
|
||||
credentials["access_key"],
|
||||
credentials["secret_key"]
|
||||
)
|
||||
elif provider == "picgo":
|
||||
api_url = credentials.get("api_url", "https://www.picgo.net/api/1/upload")
|
||||
return PicGoUploader(credentials["api_key"], api_url)
|
||||
elif provider == "cloudflare_imgbed":
|
||||
return CloudFlareImgBedUploader(
|
||||
credentials["auth_code"],
|
||||
credentials["base_url"]
|
||||
)
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
Reference in New Issue
Block a user