52ff2f80ad
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
244 lines
8.2 KiB
Python
244 lines
8.2 KiB
Python
"""阿里云 OSS 存储服务"""
|
|
|
|
import base64
|
|
import datetime as dt
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
from urllib.parse import urlparse
|
|
|
|
try:
|
|
import oss2
|
|
except ImportError: # pragma: no cover - exercised in minimal local/test environments
|
|
oss2 = None
|
|
from app.config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OSSStorageService:
|
|
"""阿里云 OSS 存储服务"""
|
|
|
|
def __init__(self):
|
|
settings = get_settings()
|
|
self.bucket_name = settings.OSS_BUCKET_NAME
|
|
self.public_url = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
|
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
|
|
self.bucket = None
|
|
|
|
has_key_id = bool(settings.OSS_ACCESS_KEY_ID)
|
|
has_key_secret = bool(settings.OSS_ACCESS_KEY_SECRET)
|
|
|
|
if has_key_id and has_key_secret:
|
|
if oss2 is not None:
|
|
try:
|
|
auth = oss2.Auth(
|
|
settings.OSS_ACCESS_KEY_ID,
|
|
settings.OSS_ACCESS_KEY_SECRET,
|
|
)
|
|
self.bucket = oss2.Bucket(
|
|
auth,
|
|
settings.OSS_ENDPOINT,
|
|
settings.OSS_BUCKET_NAME,
|
|
)
|
|
logger.info(
|
|
"OSS initialized: endpoint=%s bucket=%s",
|
|
settings.OSS_ENDPOINT,
|
|
settings.OSS_BUCKET_NAME,
|
|
)
|
|
except Exception as error:
|
|
logger.error("Failed to initialize OSS bucket client: %s", error)
|
|
else:
|
|
logger.error("oss2 SDK is not installed — OSS operations will fail")
|
|
else:
|
|
missing = []
|
|
if not has_key_id:
|
|
missing.append("OSS_ACCESS_KEY_ID")
|
|
if not has_key_secret:
|
|
missing.append("OSS_ACCESS_KEY_SECRET")
|
|
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
|
|
|
|
self.access_key_id = settings.OSS_ACCESS_KEY_ID
|
|
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
|
self.endpoint = settings.OSS_ENDPOINT
|
|
|
|
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
|
parsed = urlparse(storage_key_or_url)
|
|
path = parsed.path if parsed.scheme else storage_key_or_url
|
|
return path.startswith(f"{self.local_url_prefix}/")
|
|
|
|
def create_direct_upload_post(
|
|
self,
|
|
storage_key: str,
|
|
content_type: str,
|
|
max_size_bytes: int,
|
|
expires_seconds: int,
|
|
) -> dict[str, object]:
|
|
"""创建浏览器直传 OSS 的 PostObject 表单。"""
|
|
if not self.access_key_id or not self.access_key_secret:
|
|
raise RuntimeError("OSS storage is not configured")
|
|
normalized_key = self._normalize_storage_key(storage_key)
|
|
if not normalized_key.startswith("uploads/"):
|
|
raise ValueError("direct upload key must be under uploads/")
|
|
|
|
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
|
|
"%Y-%m-%dT%H:%M:%S.000Z"
|
|
)
|
|
policy = {
|
|
"expiration": expiration,
|
|
"conditions": [
|
|
{"bucket": self.bucket_name},
|
|
{"key": normalized_key},
|
|
["content-length-range", 1, max_size_bytes],
|
|
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
|
|
],
|
|
}
|
|
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
|
|
signature = base64.b64encode(
|
|
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
|
|
).decode("ascii")
|
|
|
|
return {
|
|
"url": self.public_url,
|
|
"method": "POST",
|
|
"storage_key": normalized_key,
|
|
"expires_at": expiration,
|
|
"fields": {
|
|
"key": normalized_key,
|
|
"OSSAccessKeyId": self.access_key_id,
|
|
"policy": encoded_policy,
|
|
"Signature": signature,
|
|
"success_action_status": "201",
|
|
"Content-Type": content_type,
|
|
},
|
|
}
|
|
|
|
def upload_file(
|
|
self,
|
|
file_or_path,
|
|
storage_key: str,
|
|
content_type: str = "application/octet-stream",
|
|
) -> str:
|
|
"""
|
|
上传文件到 OSS
|
|
|
|
Args:
|
|
file_or_path: 文件对象或本地文件路径
|
|
storage_key: 存储键(文件路径)
|
|
content_type: 内容类型
|
|
|
|
Returns:
|
|
文件公网 URL
|
|
"""
|
|
if self.bucket is None:
|
|
raise RuntimeError("OSS storage is not configured")
|
|
|
|
try:
|
|
# 如果是字符串路径,从本地文件上传
|
|
if isinstance(file_or_path, str):
|
|
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
|
|
else:
|
|
# 文件对象
|
|
file_or_path.seek(0)
|
|
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
|
|
|
|
return f"{self.public_url}/{storage_key}"
|
|
except Exception as e:
|
|
raise Exception(f"Failed to upload file to OSS: {e}")
|
|
|
|
def get_url(self, storage_key: str) -> str:
|
|
"""获取文件公网 URL"""
|
|
return f"{self.public_url}/{storage_key}"
|
|
|
|
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
|
"""
|
|
获取文件下载签名 URL(用于私有文件)
|
|
|
|
Args:
|
|
storage_key_or_url: 存储键或完整 URL
|
|
expires_seconds: 过期时间(秒)
|
|
|
|
Returns:
|
|
签名 URL
|
|
"""
|
|
if self.bucket is None:
|
|
if self._is_local_generated_url(storage_key_or_url):
|
|
return storage_key_or_url
|
|
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
|
|
|
storage_key = self._normalize_storage_key(storage_key_or_url)
|
|
try:
|
|
return self.bucket.sign_url("GET", storage_key, expires_seconds)
|
|
except Exception:
|
|
return self.get_url(storage_key)
|
|
|
|
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
|
"""从 URL 中提取存储键"""
|
|
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
|
parsed = urlparse(storage_key_or_url)
|
|
# 移除开头的 /
|
|
return parsed.path.lstrip("/")
|
|
return storage_key_or_url.lstrip("/")
|
|
|
|
def download_file(self, storage_key: str, local_path: str):
|
|
"""
|
|
从 OSS 下载文件到本地
|
|
|
|
Args:
|
|
storage_key: 存储键
|
|
local_path: 本地文件路径
|
|
"""
|
|
if self.bucket is None:
|
|
raise RuntimeError("OSS storage is not configured")
|
|
|
|
try:
|
|
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
self.bucket.get_object_to_file(storage_key, local_path)
|
|
except Exception as e:
|
|
raise Exception(f"Failed to download file from OSS: {e}")
|
|
|
|
def delete_file(self, storage_key: str):
|
|
"""
|
|
删除 OSS 文件
|
|
|
|
Args:
|
|
storage_key: 存储键
|
|
"""
|
|
if self.bucket is None:
|
|
return
|
|
|
|
try:
|
|
self.bucket.delete_object(storage_key)
|
|
except Exception as error:
|
|
logger.warning(
|
|
"Failed to delete file from OSS",
|
|
extra={"storage_key": storage_key, "error": str(error)},
|
|
)
|
|
|
|
def file_exists(self, storage_key: str) -> bool:
|
|
"""
|
|
检查文件是否存在
|
|
|
|
Args:
|
|
storage_key: 存储键
|
|
|
|
Returns:
|
|
是否存在
|
|
"""
|
|
if self.bucket is None:
|
|
return False
|
|
return self.bucket.object_exists(storage_key)
|
|
|
|
|
|
_storage_service = None
|
|
|
|
|
|
def get_storage_service() -> OSSStorageService:
|
|
"""获取存储服务实例(全局单例)"""
|
|
global _storage_service
|
|
if _storage_service is None:
|
|
_storage_service = OSSStorageService()
|
|
return _storage_service
|