Merge pull request #360 from minguncle:feat-support-aliyunoss

Feat support aliyunoss
This commit is contained in:
snaily
2025-09-17 20:43:27 +08:00
committed by GitHub
8 changed files with 349 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
# 流式输出相关常量

View File

@@ -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"]

View File

@@ -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}"

View File

@@ -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>
<!-- 流式输出优化配置 -->

View File

@@ -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(
[

View File

@@ -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}")