diff --git a/.env.example b/.env.example index b2f66c1..7a63227 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/config/config.py b/app/config/config.py index 1f41da1..00fb7e5 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -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 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 80de990..bdb0d90 100644 --- a/app/handler/response_handler.py +++ b/app/handler/response_handler.py @@ -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"] diff --git a/app/service/image/image_create_service.py b/app/service/image/image_create_service.py index 8c6bd28..59f347e 100644 --- a/app/service/image/image_create_service.py +++ b/app/service/image/image_create_service.py @@ -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}" diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html index f1db0b3..1082400 100644 --- a/app/templates/config_editor.html +++ b/app/templates/config_editor.html @@ -1814,6 +1814,7 @@ endblock %} {% block head_extra_styles %} + 图片上传服务提供商 @@ -1921,6 +1922,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 931b5e1..01a1683 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): """上传错误类型枚举""" @@ -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}")