mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-05-30 12:49:33 +08:00
Merge pull request #360 from minguncle:feat-support-aliyunoss
Feat support aliyunoss
This commit is contained in:
@@ -48,6 +48,13 @@ PICGO_API_URL=https://www.picgo.net/api/1/upload
|
||||
CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload
|
||||
CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx
|
||||
CLOUDFLARE_IMGBED_UPLOAD_FOLDER=
|
||||
# 阿里云OSS配置
|
||||
OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
OSS_ENDPOINT_INNER=oss-cn-shanghai-internal.aliyuncs.com
|
||||
OSS_ACCESS_KEY=LTAI5txxxxxxxxxxxxxxxx
|
||||
OSS_ACCESS_KEY_SECRET=yXxxxxxxxxxxxxxxxxxxxxx
|
||||
OSS_BUCKET_NAME=your-bucket-name
|
||||
OSS_REGION=cn-shanghai
|
||||
##########################################################################
|
||||
#########################stream_optimizer 相关配置########################
|
||||
STREAM_OPTIMIZER_ENABLED=false
|
||||
|
||||
@@ -98,6 +98,13 @@ class Settings(BaseSettings):
|
||||
CLOUDFLARE_IMGBED_URL: str = ""
|
||||
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
|
||||
CLOUDFLARE_IMGBED_UPLOAD_FOLDER: str = ""
|
||||
# 阿里云OSS配置
|
||||
OSS_ENDPOINT: str = ""
|
||||
OSS_ENDPOINT_INNER: str = ""
|
||||
OSS_ACCESS_KEY: str = ""
|
||||
OSS_ACCESS_KEY_SECRET: str = ""
|
||||
OSS_BUCKET_NAME: str = ""
|
||||
OSS_REGION: str = ""
|
||||
|
||||
# 流式输出优化器配置
|
||||
STREAM_OPTIMIZER_ENABLED: bool = False
|
||||
|
||||
@@ -27,7 +27,7 @@ DEFAULT_CREATE_IMAGE_MODEL = "imagen-3.0-generate-002"
|
||||
VALID_IMAGE_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"]
|
||||
|
||||
# 上传提供商
|
||||
UPLOAD_PROVIDERS = ["smms", "picgo", "cloudflare_imgbed"]
|
||||
UPLOAD_PROVIDERS = ["smms", "picgo", "cloudflare_imgbed", "aliyun_oss"]
|
||||
DEFAULT_UPLOAD_PROVIDER = "smms"
|
||||
|
||||
# 流式输出相关常量
|
||||
|
||||
@@ -304,6 +304,16 @@ def _extract_image_data(part: dict) -> str:
|
||||
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
|
||||
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "aliyun_oss":
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER,
|
||||
access_key=settings.OSS_ACCESS_KEY,
|
||||
access_key_secret=settings.OSS_ACCESS_KEY_SECRET,
|
||||
bucket_name=settings.OSS_BUCKET_NAME,
|
||||
endpoint=settings.OSS_ENDPOINT,
|
||||
region=settings.OSS_REGION,
|
||||
use_internal=False
|
||||
)
|
||||
current_date = time.strftime("%Y/%m/%d")
|
||||
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
||||
base64_data = part["inlineData"]["data"]
|
||||
|
||||
@@ -131,6 +131,16 @@ class ImageCreateService:
|
||||
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
|
||||
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "aliyun_oss":
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER,
|
||||
access_key=settings.OSS_ACCESS_KEY,
|
||||
access_key_secret=settings.OSS_ACCESS_KEY_SECRET,
|
||||
bucket_name=settings.OSS_BUCKET_NAME,
|
||||
endpoint=settings.OSS_ENDPOINT,
|
||||
region=settings.OSS_REGION,
|
||||
use_internal=False
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported upload provider: {settings.UPLOAD_PROVIDER}"
|
||||
|
||||
@@ -1814,6 +1814,7 @@ endblock %} {% block head_extra_styles %}
|
||||
<option value="smms" selected>SM.MS</option>
|
||||
<option value="picgo">PicGo</option>
|
||||
<option value="cloudflare_imgbed">Cloudflare</option>
|
||||
<option value="aliyun_oss">阿里云OSS</option>
|
||||
</select>
|
||||
<small class="text-gray-500 mt-1 block">图片上传服务提供商</small>
|
||||
</div>
|
||||
@@ -1921,6 +1922,109 @@ endblock %} {% block head_extra_styles %}
|
||||
>Cloudflare图床的上传文件夹路径(可选)</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 阿里云OSS配置 -->
|
||||
<!-- OSS Endpoint -->
|
||||
<div class="mb-6 provider-config" data-provider="aliyun_oss">
|
||||
<label
|
||||
for="OSS_ENDPOINT"
|
||||
class="block font-semibold mb-2 text-gray-700"
|
||||
>OSS Endpoint</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="OSS_ENDPOINT"
|
||||
name="OSS_ENDPOINT"
|
||||
placeholder="oss-cn-shanghai.aliyuncs.com"
|
||||
class="w-full px-4 py-3 rounded-lg form-input-themed"
|
||||
/>
|
||||
<small class="text-gray-500 mt-1 block">阿里云OSS的Endpoint地址</small>
|
||||
</div>
|
||||
|
||||
<!-- OSS Access Key -->
|
||||
<div class="mb-6 provider-config" data-provider="aliyun_oss">
|
||||
<label
|
||||
for="OSS_ACCESS_KEY"
|
||||
class="block font-semibold mb-2 text-gray-700"
|
||||
>OSS Access Key</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="OSS_ACCESS_KEY"
|
||||
name="OSS_ACCESS_KEY"
|
||||
placeholder="LTAI5txxxxxxxxxx"
|
||||
class="w-full px-4 py-3 rounded-lg sensitive-input form-input-themed"
|
||||
/>
|
||||
<small class="text-gray-500 mt-1 block">阿里云OSS的Access Key ID</small>
|
||||
</div>
|
||||
|
||||
<!-- OSS Access Key Secret -->
|
||||
<div class="mb-6 provider-config" data-provider="aliyun_oss">
|
||||
<label
|
||||
for="OSS_ACCESS_KEY_SECRET"
|
||||
class="block font-semibold mb-2 text-gray-700"
|
||||
>OSS Access Key Secret</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="OSS_ACCESS_KEY_SECRET"
|
||||
name="OSS_ACCESS_KEY_SECRET"
|
||||
placeholder="yXxxxxxxxxxxxx"
|
||||
class="w-full px-4 py-3 rounded-lg sensitive-input form-input-themed"
|
||||
/>
|
||||
<small class="text-gray-500 mt-1 block">阿里云OSS的Access Key Secret</small>
|
||||
</div>
|
||||
|
||||
<!-- OSS Bucket Name -->
|
||||
<div class="mb-6 provider-config" data-provider="aliyun_oss">
|
||||
<label
|
||||
for="OSS_BUCKET_NAME"
|
||||
class="block font-semibold mb-2 text-gray-700"
|
||||
>OSS Bucket名称</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="OSS_BUCKET_NAME"
|
||||
name="OSS_BUCKET_NAME"
|
||||
placeholder="your-bucket-name"
|
||||
class="w-full px-4 py-3 rounded-lg form-input-themed"
|
||||
/>
|
||||
<small class="text-gray-500 mt-1 block">阿里云OSS的Bucket名称</small>
|
||||
</div>
|
||||
|
||||
<!-- OSS Region -->
|
||||
<div class="mb-6 provider-config" data-provider="aliyun_oss">
|
||||
<label
|
||||
for="OSS_REGION"
|
||||
class="block font-semibold mb-2 text-gray-700"
|
||||
>OSS Region</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="OSS_REGION"
|
||||
name="OSS_REGION"
|
||||
placeholder="cn-shanghai"
|
||||
class="w-full px-4 py-3 rounded-lg form-input-themed"
|
||||
/>
|
||||
<small class="text-gray-500 mt-1 block">阿里云OSS的Region区域</small>
|
||||
</div>
|
||||
|
||||
<!-- OSS Internal Endpoint (可选) -->
|
||||
<div class="mb-6 provider-config" data-provider="aliyun_oss">
|
||||
<label
|
||||
for="OSS_ENDPOINT_INNER"
|
||||
class="block font-semibold mb-2 text-gray-700"
|
||||
>OSS内网Endpoint(可选)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="OSS_ENDPOINT_INNER"
|
||||
name="OSS_ENDPOINT_INNER"
|
||||
placeholder="oss-cn-shanghai-internal.aliyuncs.com"
|
||||
class="w-full px-4 py-3 rounded-lg form-input-themed"
|
||||
/>
|
||||
<small class="text-gray-500 mt-1 block">阿里云OSS的内网Endpoint地址(可选)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流式输出优化配置 -->
|
||||
|
||||
@@ -211,6 +211,16 @@ def is_image_upload_configured(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "SMMS_SECRET_TOKEN", ""))
|
||||
if provider == "picgo":
|
||||
return bool(getattr(settings, "PICGO_API_KEY", ""))
|
||||
if provider == "aliyun_oss":
|
||||
return all(
|
||||
[
|
||||
getattr(settings, "OSS_ACCESS_KEY", ""),
|
||||
getattr(settings, "OSS_ACCESS_KEY_SECRET", ""),
|
||||
getattr(settings, "OSS_BUCKET_NAME", ""),
|
||||
getattr(settings, "OSS_ENDPOINT", ""),
|
||||
getattr(settings, "OSS_REGION", "")
|
||||
]
|
||||
)
|
||||
if provider == "cloudflare_imgbed":
|
||||
return all(
|
||||
[
|
||||
|
||||
@@ -2,6 +2,12 @@ import requests
|
||||
from app.domain.image_models import ImageMetadata, ImageUploader, UploadResponse
|
||||
from enum import Enum
|
||||
from typing import Optional, Any
|
||||
import hashlib
|
||||
import base64
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
from app.log.logger import get_image_create_logger
|
||||
|
||||
class UploadErrorType(Enum):
|
||||
"""上传错误类型枚举"""
|
||||
@@ -300,6 +306,191 @@ class PicGoUploader(ImageUploader):
|
||||
)
|
||||
|
||||
|
||||
class AliyunOSSUploader(ImageUploader):
|
||||
"""阿里云OSS图片上传器"""
|
||||
|
||||
def __init__(self, access_key: str, access_key_secret: str, bucket_name: str,
|
||||
endpoint: str, region: str, use_internal: bool = False):
|
||||
"""
|
||||
初始化阿里云OSS上传器
|
||||
|
||||
Args:
|
||||
access_key: OSS访问密钥ID
|
||||
access_key_secret: OSS访问密钥
|
||||
bucket_name: OSS存储桶名称
|
||||
endpoint: OSS端点地址
|
||||
region: OSS区域
|
||||
use_internal: 是否使用内网端点
|
||||
"""
|
||||
self.access_key = access_key
|
||||
self.access_key_secret = access_key_secret
|
||||
self.bucket_name = bucket_name
|
||||
self.endpoint = endpoint
|
||||
self.region = region
|
||||
self.use_internal = use_internal
|
||||
self.logger = get_image_create_logger()
|
||||
|
||||
# 构建请求URL
|
||||
if not endpoint.startswith(('http://', 'https://')):
|
||||
self.base_url = f"https://{bucket_name}.{endpoint}"
|
||||
else:
|
||||
self.base_url = f"{endpoint}/{bucket_name}"
|
||||
|
||||
self.logger.info(f"Initialized AliyunOSSUploader for bucket: {bucket_name}, region: {region}")
|
||||
|
||||
def _sign_request(self, method: str, path: str, headers: dict, content: bytes = b'') -> dict:
|
||||
"""
|
||||
为OSS请求生成签名
|
||||
|
||||
Args:
|
||||
method: HTTP方法
|
||||
path: 请求路径
|
||||
headers: 请求头
|
||||
content: 请求内容
|
||||
|
||||
Returns:
|
||||
包含签名的请求头
|
||||
"""
|
||||
# 计算Content-MD5
|
||||
content_md5 = base64.b64encode(hashlib.md5(content).digest()).decode('utf-8') if content else ''
|
||||
|
||||
# 设置日期
|
||||
date = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
|
||||
# 更新headers
|
||||
headers['Date'] = date
|
||||
if content_md5:
|
||||
headers['Content-MD5'] = content_md5
|
||||
headers['Content-Type'] = headers.get('Content-Type', 'image/png')
|
||||
|
||||
# 构建CanonicalizedOSSHeaders
|
||||
oss_headers = []
|
||||
for key, value in sorted(headers.items()):
|
||||
if key.lower().startswith('x-oss-'):
|
||||
oss_headers.append(f"{key.lower()}:{value}")
|
||||
canonicalized_oss_headers = '\n'.join(oss_headers)
|
||||
if canonicalized_oss_headers:
|
||||
canonicalized_oss_headers += '\n'
|
||||
|
||||
# 构建CanonicalizedResource
|
||||
canonicalized_resource = f"/{self.bucket_name}{path}"
|
||||
|
||||
# 构建StringToSign
|
||||
string_to_sign = f"{method}\n{content_md5}\n{headers.get('Content-Type', '')}\n{date}\n{canonicalized_oss_headers}{canonicalized_resource}"
|
||||
|
||||
# 计算签名
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
self.access_key_secret.encode('utf-8'),
|
||||
string_to_sign.encode('utf-8'),
|
||||
hashlib.sha1
|
||||
).digest()
|
||||
).decode('utf-8')
|
||||
|
||||
# 添加Authorization头
|
||||
headers['Authorization'] = f"OSS {self.access_key}:{signature}"
|
||||
|
||||
return headers
|
||||
|
||||
def upload(self, file: bytes, filename: str) -> UploadResponse:
|
||||
"""
|
||||
上传图片到阿里云OSS
|
||||
|
||||
Args:
|
||||
file: 图片文件二进制数据
|
||||
filename: 文件名(将作为OSS对象的key)
|
||||
|
||||
Returns:
|
||||
UploadResponse: 上传响应对象
|
||||
|
||||
Raises:
|
||||
UploadError: 上传失败时抛出异常
|
||||
"""
|
||||
# 记录开始上传的日志
|
||||
self.logger.info(f"Starting OSS upload for file: {filename}, size: {len(file)} bytes")
|
||||
|
||||
try:
|
||||
# 构建对象路径
|
||||
object_key = f"/{filename}"
|
||||
|
||||
# 准备请求头
|
||||
headers = {
|
||||
'Content-Type': 'image/png',
|
||||
'x-oss-object-acl': 'public-read' # 设置为公共读
|
||||
}
|
||||
|
||||
# 签名请求
|
||||
signed_headers = self._sign_request('PUT', object_key, headers, file)
|
||||
|
||||
# 构建完整URL
|
||||
upload_url = f"{self.base_url}{object_key}"
|
||||
self.logger.debug(f"OSS upload URL: {upload_url}")
|
||||
|
||||
# 发送请求
|
||||
response = requests.put(
|
||||
upload_url,
|
||||
data=file,
|
||||
headers=signed_headers
|
||||
)
|
||||
|
||||
# 检查响应状态
|
||||
if response.status_code != 200:
|
||||
error_msg = f"OSS upload failed with status {response.status_code}, response: {response.text}"
|
||||
self.logger.error(f"OSS upload failed for {filename}: {error_msg}")
|
||||
raise UploadError(
|
||||
message=f"OSS upload failed with status {response.status_code}",
|
||||
error_type=UploadErrorType.SERVER_ERROR,
|
||||
status_code=response.status_code,
|
||||
details={'response': response.text}
|
||||
)
|
||||
|
||||
# 构建访问URL
|
||||
if self.endpoint.startswith(('http://', 'https://')):
|
||||
access_url = f"{self.endpoint}/{self.bucket_name}{object_key}"
|
||||
else:
|
||||
access_url = f"https://{self.bucket_name}.{self.endpoint}{object_key}"
|
||||
|
||||
# 构建图片元数据
|
||||
image_metadata = ImageMetadata(
|
||||
width=0, # OSS PUT不返回图片尺寸
|
||||
height=0,
|
||||
filename=filename,
|
||||
size=len(file),
|
||||
url=access_url,
|
||||
delete_url=None # OSS需要单独的删除操作
|
||||
)
|
||||
|
||||
# 记录上传成功的日志
|
||||
self.logger.info(f"OSS upload successful for {filename}, URL: {access_url}")
|
||||
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
code="success",
|
||||
message="Upload to Aliyun OSS success",
|
||||
data=image_metadata
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_msg = f"OSS upload request failed: {str(e)}"
|
||||
self.logger.error(f"OSS upload request failed for {filename}: {error_msg}")
|
||||
raise UploadError(
|
||||
message=error_msg,
|
||||
error_type=UploadErrorType.NETWORK_ERROR,
|
||||
original_error=e
|
||||
)
|
||||
except UploadError:
|
||||
# UploadError 已经被记录了,直接重新抛出
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"OSS upload failed: {str(e)}"
|
||||
self.logger.error(f"OSS upload unexpected error for {filename}: {error_msg}")
|
||||
raise UploadError(
|
||||
message=error_msg,
|
||||
error_type=UploadErrorType.UNKNOWN,
|
||||
original_error=e
|
||||
)
|
||||
|
||||
|
||||
class CloudFlareImgBedUploader(ImageUploader):
|
||||
"""CloudFlare图床上传器"""
|
||||
|
||||
@@ -438,4 +629,13 @@ class ImageUploaderFactory:
|
||||
credentials["base_url"],
|
||||
credentials.get("upload_folder", ""),
|
||||
)
|
||||
elif provider == "aliyun_oss":
|
||||
return AliyunOSSUploader(
|
||||
credentials["access_key"],
|
||||
credentials["access_key_secret"],
|
||||
credentials["bucket_name"],
|
||||
credentials["endpoint"],
|
||||
credentials["region"],
|
||||
credentials.get("use_internal", False)
|
||||
)
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
|
||||
Reference in New Issue
Block a user