From 1314e0ee0922b231729120445c32182db7320b94 Mon Sep 17 00:00:00 2001
From: wanglinjie <399659786@qq.com>
Date: Wed, 3 Sep 2025 09:38:01 +0800
Subject: [PATCH] feat(upload): add support for Aliyhun OSS
---
.env.example | 7 +
app/config/config.py | 7 +
app/core/constants.py | 2 +-
app/handler/response_handler.py | 10 ++
app/service/image/image_create_service.py | 10 ++
app/templates/config_editor.html | 104 +++++++++++
app/utils/helpers.py | 10 ++
app/utils/uploader.py | 200 ++++++++++++++++++++++
8 files changed, 349 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index 734e036..4b13593 100644
--- a/.env.example
+++ b/.env.example
@@ -47,6 +47,13 @@ PICGO_API_KEY=xxxx
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
diff --git a/app/config/config.py b/app/config/config.py
index 18a8fef..c684a33 100644
--- a/app/config/config.py
+++ b/app/config/config.py
@@ -97,6 +97,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
diff --git a/app/core/constants.py b/app/core/constants.py
index 5b44a54..f7aef94 100644
--- a/app/core/constants.py
+++ b/app/core/constants.py
@@ -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"
# 流式输出相关常量
diff --git a/app/handler/response_handler.py b/app/handler/response_handler.py
index 57a2932..973de29 100644
--- a/app/handler/response_handler.py
+++ b/app/handler/response_handler.py
@@ -302,6 +302,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"]
diff --git a/app/service/image/image_create_service.py b/app/service/image/image_create_service.py
index 7b676d7..8eb2158 100644
--- a/app/service/image/image_create_service.py
+++ b/app/service/image/image_create_service.py
@@ -130,6 +130,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}"
diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html
index 95cb86b..a4ceef4 100644
--- a/app/templates/config_editor.html
+++ b/app/templates/config_editor.html
@@ -1814,6 +1814,7 @@ endblock %} {% block head_extra_styles %}
+
图片上传服务提供商
@@ -1904,6 +1905,109 @@ endblock %} {% block head_extra_styles %}
>Cloudflare图床的上传文件夹路径(可选)
+
+
+
+
+
+
+ 阿里云OSS的Endpoint地址
+
+
+
+
+
+
+ 阿里云OSS的Access Key ID
+
+
+
+
+
+
+ 阿里云OSS的Access Key Secret
+
+
+
+
+
+
+ 阿里云OSS的Bucket名称
+
+
+
+
+
+
+ 阿里云OSS的Region区域
+
+
+
+
+
+
+ 阿里云OSS的内网Endpoint地址(可选)
+
diff --git a/app/utils/helpers.py b/app/utils/helpers.py
index 6afa69b..1720a76 100644
--- a/app/utils/helpers.py
+++ b/app/utils/helpers.py
@@ -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(
[
diff --git a/app/utils/uploader.py b/app/utils/uploader.py
index 7496cf4..0a78d70 100644
--- a/app/utils/uploader.py
+++ b/app/utils/uploader.py
@@ -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):
"""上传错误类型枚举"""
@@ -259,6 +265,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图床上传器"""
@@ -397,4 +588,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}")