refactor(backend): 调整模块结构、调度器逻辑与健康检查路由

This commit is contained in:
zhangtao
2026-09-06 00:54:24 +08:00
parent 92081815f4
commit d470c7eb1f
324 changed files with 3435 additions and 7404 deletions
+667
View File
@@ -0,0 +1,667 @@
"""存储协议适配器抽象基类
子类只需实现同步的 _sync_* 方法(协议相关逻辑),异步公开接口统一在
基类中经 asyncio.to_thread 包装提供,避免阻塞事件循环。
可选钩子:_sync_get_url(预签名 URL,不支持则省略,get_url 返回 None)、
_sync_close(释放连接资源,无持久连接则省略,close 为空操作)。
适配器实例按请求创建(无连接池复用),用完由调用方关闭。
"""
import asyncio
import os
import types
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from enum import Enum
from typing import Any, Literal, get_args, get_origin
from pydantic import BaseModel, Field
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.crypto_util import CryptoUtil
class StorageProtocolDefSchema(BaseModel):
"""存储协议定义(/protocols 接口返回)"""
protocol: str = Field(..., description="协议标识")
name: str = Field(..., description="协议名称")
default_port: int = Field(..., description="默认端口")
class AdvancedFieldDefSchema(BaseModel):
"""高级配置字段定义元数据(/advanced-fields 接口返回,前端按类型动态渲染)"""
key: str = Field(..., description="字段名")
label: str = Field(..., description="字段显示名")
default: Any | None = Field(default=None, description="默认值")
type: Literal["boolean", "number", "text", "select"] = Field("text", description="组件类型")
options: list[str] | None = Field(default=None, description="下拉选项")
class StorageProtocol(str, Enum):
"""存储协议枚举"""
FTP = "ftp"
FTPS = "ftps"
SFTP = "sftp"
S3 = "s3"
OBS = "obs"
OSS = "oss"
COS = "cos"
LOCAL = "local"
# 各协议默认端口
DEFAULT_PORTS: dict[StorageProtocol, int] = {
StorageProtocol.FTP: 21,
StorageProtocol.FTPS: 990,
StorageProtocol.SFTP: 22,
StorageProtocol.S3: 443,
StorageProtocol.OBS: 443,
StorageProtocol.OSS: 443,
StorageProtocol.COS: 443,
StorageProtocol.LOCAL: 0,
}
def encrypt_password(plain: str | None) -> str:
"""明文密码 → 密文。空值原样返回空串。
统一走 CryptoUtil(独立数据加密密钥 + 支持轮换),不再自行从
SECRET_KEY 派生,避免 JWT 签名密钥与落库敏感数据的加密密钥同源。
"""
return CryptoUtil.encrypt(plain)
def decrypt_password(cipher: str | None) -> str:
"""密文 → 明文密码。空值原样返回空串,解不开时抛业务异常。"""
try:
return CryptoUtil.decrypt(cipher)
except CustomException as e:
raise CustomException(msg=f"存储源密码解密失败:{e!s}")
# 文件系统类协议与对象存储类协议的划分:仅用于文档说明与配置元数据,
# 浏览/操作统一以"存储真根"为基准,path_prefix 不参与任何 key 计算。
_FILE_SYSTEM_PROTOCOLS: frozenset[StorageProtocol] = frozenset(
{StorageProtocol.FTP, StorageProtocol.FTPS, StorageProtocol.SFTP, StorageProtocol.LOCAL}
)
_OBJECT_STORE_PROTOCOLS: frozenset[StorageProtocol] = frozenset(
{StorageProtocol.S3, StorageProtocol.OSS, StorageProtocol.COS, StorageProtocol.OBS}
)
# 流式传输阈值:SDK 高层上传超过该大小才触发分片(100GB,等效单次上传)
_STREAM_THRESHOLD = 100 * 1024 * 1024 * 1024
class StorageAdapterConfig(BaseModel):
"""存储适配器配置(从 StorageSourceModel 剥离加密字段后注入,解耦 ORM 与协议层)"""
protocol: StorageProtocol = Field(description="存储协议")
host: str | None = Field(default=None, description="主机地址(对象存储可不填)")
port: int = Field(description="端口")
username: str | None = Field(default=None, description="用户名/AccessKey")
password: str | None = Field(default=None, description="密码/SecretKey(已解密)")
bucket: str | None = Field(default=None, description="桶名(对象存储专用;FTP/SFTP/LOCAL 不使用)")
endpoint: str | None = Field(default=None, description="接入点(对象存储)")
scheme: str = Field(default="https", description="对象存储访问协议(http/https)")
region: str | None = Field(default=None, description="区域(对象存储)")
path_prefix: str | None = Field(default=None, description="路径前缀(仅作节点元数据/传输默认路径,不参与浏览与 key 计算)")
is_secure: bool = Field(default=False, description="是否启用 TLS(FTPS 显示加密 / COS 访问协议)")
implicit_tls: bool = Field(default=False, description="FTPS 是否隐式 TLS(默认显式)")
encrypt_type: int = Field(default=1, description="FTPS加密类型(0=明文 1=显式TLS可用时 2=要求显式TLS 3=隐式TLS)")
connection_mode: int = Field(default=0, description="FTP/FTPS传输模式(0=默认 1=主动 2=被动)")
encoding: str = Field(default="UTF-8", description="FTP/FTPS/SFTP编码(utf-8/gbk 等)")
# 分片传输参数(对象存储分片上传,MB 单位;每个端点独立配置;单片上限 5GB 对齐各对象存储 SDK 上限)
multipart_part_size: int = Field(default=50, ge=5, le=5000, description="分片大小(MB)")
multipart_concurrency: int = Field(default=6, ge=1, le=64, description="分片上传并发路数")
multipart_memory_budget: int = Field(default=512, ge=8, le=10240, description="分片上传内存预算(MB)")
# 传输方式(连线/任务级覆盖):stream 不触发分片(等效单次上传),multipart 使用分片参数;空=按分片参数走 SDK 高层上传
transfer_mode: Literal["stream", "multipart"] | None = Field(default=None, description="传输方式")
# SDK 高级配置(原始 JSON,由各适配器按协议解析合并默认值)
advanced_config: dict = Field(default_factory=dict, description="SDK高级配置")
def normalize_endpoint(endpoint: str | None, scheme: str = "https") -> str | None:
"""规范化对象存储接入点:SDK 要求完整 URL(带协议),缺失时按 scheme 自动补充。
例如 ``s3.example.com`` -> ``https://s3.example.com``。
"""
if not endpoint:
return endpoint
return endpoint if "://" in endpoint else f"{scheme or 'https'}://{endpoint}"
class StorageObject(BaseModel):
"""远端文件对象信息"""
name: str = Field(description="文件/目录名")
key: str = Field(description="相对存储真根的路径(path_prefix 仅作为普通目录层,不做剥离)")
is_dir: bool = Field(default=False, description="是否目录")
size: int | None = Field(default=None, description="大小(字节)")
modified_time: datetime | None = Field(default=None, description="修改时间")
class StoragePage(BaseModel):
"""游标分页结果(对象存储 SDK 原生游标分页,无总条数,只能顺序翻页)"""
items: list[StorageObject] = Field(description="当前页条目")
has_next: bool = Field(default=False, description="是否还有下一页")
next_cursor: str | None = Field(default=None, description="下一页游标(无下一页为 None)")
class BaseStorageAdapter(ABC):
"""存储协议适配器抽象基类
子类实现同步 _sync_* 方法即可,基类统一经 asyncio.to_thread 包装为异步接口。
可选钩子:_sync_get_url(生成预签名 URL,不支持则省略,get_url 返回 None)、
_sync_close(释放连接资源,无持久连接则省略,close 为空操作)。
适配器实例按请求创建(无连接池复用),用完由调用方关闭。
"""
# 并发安全:持共享连接的协议(如 SFTP)不支持多线程并发读写同一连接
concurrency_safe: bool = True
def __init__(self, config: StorageAdapterConfig) -> None:
self.config = config
# 当前操作桶(对象存储):默认取节点配置,多桶浏览时经 set_bucket 覆盖
self.bucket_name = config.bucket or ""
def set_bucket(self, name: str) -> None:
"""切换当前操作桶(对象存储多桶浏览用)。"""
self.bucket_name = name
def _require_bucket(self) -> str:
"""获取当前操作桶,未配置时抛出异常。"""
if not self.bucket_name:
raise CustomException(msg="存储源未配置 bucket")
return self.bucket_name
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶(对象存储专用)。FTP/SFTP/LOCAL 等协议无需实现。"""
raise NotImplementedError(f"{type(self).__name__} 不支持桶列表")
def _multipart_settings(self) -> tuple[int, int, bool]:
"""返回 (分片大小, 并发数, 是否分片),分片大小单位字节;并发按内存预算护栏收敛。
源项目运行设置语义:分片大小默认 50MB、并发默认 6、内存预算默认 512MB。
仅对象存储分片上传使用,FTP/SFTP/LOCAL 等协议忽略。
传输方式为 stream 时返回超大阈值并关闭分片(等效单次上传)。
"""
if self.config.transfer_mode == "stream":
return _STREAM_THRESHOLD, 1, False
part_size = max(self.config.multipart_part_size, 5) * 1024 * 1024
concurrency = max(self.config.multipart_concurrency, 1)
budget = self.config.multipart_memory_budget * 1024 * 1024
if budget > 0:
max_by_budget = max(1, budget // part_size)
concurrency = min(concurrency, max_by_budget)
return part_size, concurrency, True
# ── 同步协议操作(子类实现,经 asyncio.to_thread 包装对外)─────────
@abstractmethod
def _sync_test_connection(self) -> bool:
"""测试连接是否可用。"""
@abstractmethod
def _sync_upload(self, local_path: str, remote_path: str) -> str:
"""上传本地文件到远端,返回远端完整 key。"""
@abstractmethod
def _sync_download(self, remote_path: str, local_path: str) -> str:
"""下载远端文件到本地,返回本地路径。"""
@abstractmethod
def _sync_delete(self, remote_path: str) -> None:
"""删除远端文件(目录递归删除由 delete_dir 处理)。"""
@abstractmethod
def _sync_exists(self, remote_path: str) -> bool:
"""判断远端文件是否存在。"""
@abstractmethod
def _sync_list(self, prefix: str) -> list[StorageObject]:
"""列出远端目录下的文件与目录(不含前缀)。"""
# ── 目录操作(子类实现,经 asyncio.to_thread 包装对外)─────────────
@abstractmethod
def _sync_mkdir(self, remote_dir: str) -> None:
"""创建目录(remote_dir 为相对存储真根的目录路径)。"""
@abstractmethod
def _sync_rmdir(self, remote_dir: str) -> None:
"""删除空目录(对象存储删除占位标记)。"""
@abstractmethod
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动(src/dst 为相对存储真根的路径,支持文件与目录)。"""
@abstractmethod
def _sync_copy(self, src: str, dst: str) -> None:
"""复制文件或目录(src/dst 为相对存储真根的路径)。"""
# ── 默认实现(子类可按协议优化覆盖)───────────────────────────────
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
"""递归列出(prefix 为相对存储真根的目录路径)。默认深度优先遍历;对象存储可覆盖为单次 API。"""
result: list[StorageObject] = []
stack = [prefix]
while stack:
current = stack.pop()
for entry in self._sync_list(current):
if entry.is_dir:
stack.append(f"{current}/{entry.name}" if current else entry.name)
result.append(entry)
return result
def _sync_delete_dir(self, remote_dir: str) -> None:
"""递归删除目录(含目录本身)。默认:先删文件,再自底向上删空目录。"""
for entry in reversed(self._sync_list_recursive(remote_dir)):
if entry.is_dir:
try:
self._sync_rmdir(entry.key)
except Exception as e:
logger.warning("删除远端目录失败(将残留空目录): {}: {}", entry.key, e)
else:
self._sync_delete(entry.key)
if remote_dir:
try:
self._sync_rmdir(remote_dir)
except Exception as e:
logger.warning("删除远端目录失败(将残留空目录): {}: {}", remote_dir, e)
def _sync_copy_dir(self, src: str, dst: str) -> None:
"""递归复制目录(src/dst 为相对存储真根的路径):遍历后先建目录再逐文件复制。"""
src = src.rstrip("/")
for entry in self._sync_list_recursive(src):
full = entry.key
rel = full[len(src) + 1 :] if full.startswith(src + "/") else entry.key
target = f"{dst}/{rel}".strip("/")
if entry.is_dir:
self._sync_mkdir(target)
else:
self._sync_copy(full, target)
def _sync_move_dir(self, src: str, dst: str) -> None:
"""递归移动目录(对象存储无原生目录重命名):先复制后删除源。"""
self._sync_copy_dir(src, dst)
self._sync_delete_dir(src)
def _sync_upload_dir(self, local_dir: str, remote_dir: str, concurrency: int) -> int:
"""批量上传目录:os.walk 收集文件后按 concurrency 并发上传,返回上传文件数。"""
if not os.path.isdir(local_dir):
raise CustomException(msg=f"本地目录不存在: {local_dir}")
base = os.path.normpath(local_dir)
tasks: list[tuple[str, str]] = []
for root, _dirs, names in os.walk(base):
for name in names:
local_path = os.path.join(root, name)
rel = os.path.relpath(local_path, base).replace(os.sep, "/")
remote_path = f"{remote_dir}/{rel}".strip("/") if remote_dir else rel
tasks.append((local_path, remote_path))
if not tasks:
return 0
errors: list[Exception] = []
def _run(task: tuple[str, str]) -> None:
try:
self._sync_upload(task[0], task[1])
except Exception as e:
errors.append(e)
workers = max(1, concurrency) if self.concurrency_safe else 1
if workers == 1:
for task in tasks:
_run(task)
else:
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(_run, tasks))
if errors:
raise CustomException(msg=f"批量上传失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}")
return len(tasks)
def _sync_download_dir(self, remote_dir: str, local_dir: str, concurrency: int) -> int:
"""批量下载目录:递归列出后按 concurrency 并发下载,返回下载文件数。"""
entries = [e for e in self._sync_list_recursive(remote_dir) if not e.is_dir]
base = remote_dir.strip("/")
tasks: list[tuple[str, str]] = []
for e in entries:
rel = e.key[len(base) + 1 :] if base and e.key.startswith(base + "/") else e.key
tasks.append((e.key, os.path.join(local_dir, rel.replace("/", os.sep))))
if not tasks:
return 0
errors: list[Exception] = []
def _run(task: tuple[str, str]) -> None:
try:
os.makedirs(os.path.dirname(task[1]), exist_ok=True)
self._sync_download(task[0], task[1])
except Exception as e:
errors.append(e)
workers = max(1, concurrency) if self.concurrency_safe else 1
if workers == 1:
for task in tasks:
_run(task)
else:
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(_run, tasks))
if errors:
raise CustomException(msg=f"批量下载失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}")
return len(tasks)
@staticmethod
def _entries_from_keys(keys: list[str]) -> list[StorageObject]:
"""由扁平 key 列表构造含隐含目录的条目列表(对象存储递归列举用,key 已剥离前缀)。"""
dirs: set[str] = set()
files: set[str] = set()
for raw in keys:
key = raw.rstrip("/")
if not key:
continue
(dirs if raw.endswith("/") else files).add(key)
parts = key.split("/")
for i in range(1, len(parts)):
dirs.add("/".join(parts[:i]))
result: list[StorageObject] = []
for k in sorted(dirs):
result.append(StorageObject(name=k.rsplit("/", 1)[-1], key=k, is_dir=True))
for k in sorted(files):
result.append(StorageObject(name=k.rsplit("/", 1)[-1], key=k, is_dir=False))
return result
# ── 异步公开接口(经 asyncio.to_thread 包装)─────────────────────
async def test_connection(self) -> bool:
return await asyncio.to_thread(self._sync_test_connection)
async def upload(self, local_path: str, remote_path: str) -> str:
return await asyncio.to_thread(self._sync_upload, local_path, remote_path)
async def download(self, remote_path: str, local_path: str) -> str:
return await asyncio.to_thread(self._sync_download, remote_path, local_path)
async def delete(self, remote_path: str) -> None:
await asyncio.to_thread(self._sync_delete, remote_path)
async def exists(self, remote_path: str) -> bool:
return await asyncio.to_thread(self._sync_exists, remote_path)
async def list_files(
self, prefix: str = "", page_size: int | None = None, cursor: str | None = None
) -> list[StorageObject] | StoragePage:
"""列出目录条目。
- 不传 page_size:全量拉取(目录选择器/搜索等场景)。
- 传 page_size:游标分页。对象存储子类重写 _sync_list_page 走 SDK 原生游标
(每页只拉一页数据);FTP/SFTP/LOCAL 等无服务器游标的协议用基类默认实现
(全量列举 + 内存切片,游标即偏移量)。
"""
if page_size is not None:
return await asyncio.to_thread(self._sync_list_page, prefix, page_size, cursor)
return await asyncio.to_thread(self._sync_list, prefix)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页默认实现:全量列举后内存切片(游标为字符串偏移量,FTP/SFTP/LOCAL 使用)。"""
items = self._sync_list(prefix)
start = 0
if cursor:
try:
start = int(cursor)
except ValueError:
start = 0
page = items[start : start + page_size]
has_next = start + page_size < len(items)
return StoragePage(
items=page,
has_next=has_next,
next_cursor=str(start + page_size) if has_next else None,
)
async def list_buckets(self) -> list[str]:
"""列出账号下全部存储桶(对象存储专用)。"""
return await asyncio.to_thread(self._sync_list_buckets)
async def list_recursive(self, prefix: str = "") -> list[StorageObject]:
"""递归列出目录树(含隐含目录条目)。"""
return await asyncio.to_thread(self._sync_list_recursive, prefix)
async def mkdir(self, remote_dir: str) -> None:
"""创建目录。"""
await asyncio.to_thread(self._sync_mkdir, remote_dir)
async def rmdir(self, remote_dir: str) -> None:
"""删除空目录。"""
await asyncio.to_thread(self._sync_rmdir, remote_dir)
async def delete_dir(self, remote_dir: str) -> None:
"""递归删除目录(含目录本身)。"""
await asyncio.to_thread(self._sync_delete_dir, remote_dir)
async def rename(self, src: str, dst: str) -> None:
"""重命名/移动(文件或目录)。"""
await asyncio.to_thread(self._sync_rename, src, dst)
async def copy(self, src: str, dst: str) -> None:
"""复制(文件或目录)。"""
await asyncio.to_thread(self._sync_copy, src, dst)
async def upload_dir(self, local_dir: str, remote_dir: str = "", concurrency: int = 1) -> int:
"""批量上传本地目录到远端,返回上传文件数。"""
return await asyncio.to_thread(self._sync_upload_dir, local_dir, remote_dir, concurrency)
async def download_dir(self, remote_dir: str, local_dir: str, concurrency: int = 1) -> int:
"""批量下载远端目录到本地,返回下载文件数。"""
return await asyncio.to_thread(self._sync_download_dir, remote_dir, local_dir, concurrency)
async def get_url(self, remote_path: str, expire: int = 3600) -> str | None:
"""获取访问 URL(对象存储返回预签名 URL;FTP/SFTP/LOCAL 不支持返回 None)。"""
sync = getattr(self, "_sync_get_url", None)
if sync is None:
return None
return await asyncio.to_thread(sync, remote_path, expire)
async def close(self) -> None:
"""释放连接资源(无持久连接的协议实现为空操作)。"""
sync = getattr(self, "_sync_close", None)
if sync is None:
return
await asyncio.to_thread(sync)
class S3AdvancedConfig(BaseModel):
"""S3 兼容对象存储(boto3/botocore Config)"""
connect_timeout: int = Field(default=60, ge=1, le=300, description="连接超时(秒)")
read_timeout: int = Field(default=60, ge=1, le=300, description="读取超时(秒)")
max_attempts: int = Field(default=3, ge=1, le=10, description="最大重试次数")
retries_mode: str = Field(default="standard", description="重试模式")
max_pool_connections: int = Field(default=10, ge=1, le=100, description="连接池大小")
tcp_keepalive: bool = Field(default=False, description="TCP 长连接保活")
use_dualstack_endpoint: bool = Field(default=False, description="使用双栈端点(IPv4/IPv6)")
signature_version: str = Field(default="s3v4", description="签名版本")
addressing_style: str = Field(default="auto", description="寻址风格")
proxies: str | None = Field(default=None, max_length=255, description="代理地址(http://host:port,HTTP/HTTPS 通用)")
use_accelerate_endpoint: bool = Field(default=False, description="使用传输加速端点")
parameter_validation: bool = Field(default=True, description="启用请求参数校验(关闭可提升性能)")
us_east_1_regional_endpoint: str | None = Field(default=None, description="us-east-1 区域端点策略(regional/legacy)")
request_checksum_calculation: str = Field(default="when_supported", description="请求校验和计算时机(when_supported/when_required)")
response_checksum_validation: str = Field(default="when_supported", description="响应校验和验证时机(when_supported/when_required)")
class OssAdvancedConfig(BaseModel):
"""阿里云 OSS(alibabacloud_oss_v2 Config)"""
connect_timeout: int = Field(default=10, ge=1, le=300, description="连接超时(秒)")
readwrite_timeout: int = Field(default=20, ge=1, le=300, description="读写超时(秒)")
retry_max_attempts: int = Field(default=3, ge=1, le=10, description="最大重试次数")
use_cname: bool = Field(default=False, description="使用自定义域名(CNAME)访问")
use_path_style: bool = Field(default=False, description="路径风格寻址(兼容自建服务)")
use_internal_endpoint: bool = Field(default=False, description="使用内网访问")
use_accelerate_endpoint: bool = Field(default=False, description="使用传输加速端点")
use_dualstack_endpoint: bool = Field(default=False, description="使用双栈端点(IPv4/IPv6)")
insecure_skip_verify: bool = Field(default=False, description="跳过服务端证书校验")
proxy_host: str | None = Field(default=None, max_length=255, description="代理服务器地址")
signature_version: str = Field(default="v4", description="签名版本")
disable_upload_crc64_check: bool = Field(default=False, description="关闭上传 CRC64 校验(提升性能)")
disable_download_crc64_check: bool = Field(default=False, description="关闭下载 CRC64 校验(提升性能)")
enabled_redirect: bool = Field(default=False, description="启用 HTTP 重定向")
class CosAdvancedConfig(BaseModel):
"""腾讯云 COS(cos-python-sdk-v5:CosConfig + CosS3Client retry)"""
appid: str | None = Field(default=None, max_length=64, description="账号 Appid(部分场景必需)")
token: str | None = Field(default=None, max_length=1024, description="临时密钥 Token(临时密钥认证时填写)")
endpoint: str | None = Field(default=None, max_length=255, description="自定义接入域名(默认按 region 解析)")
timeout: int = Field(default=60, ge=1, le=300, description="请求超时(秒)")
retry: int = Field(default=3, ge=0, le=10, description="最大重试次数")
enable_md5: bool = Field(default=False, description="分片上传启用 MD5 校验")
verify_ssl: bool = Field(default=True, description="验证服务端证书")
auto_switch_domain_on_retry: bool = Field(default=False, description="重试时自动切换域名")
pool_connections: int = Field(default=10, ge=1, le=100, description="连接池大小")
pool_max_size: int = Field(default=100, ge=10, le=1000, description="连接池最大容量")
keep_alive: bool = Field(default=True, description="HTTP 连接保活")
allow_redirects: bool = Field(default=True, description="允许 HTTP 重定向")
http_proxy: str | None = Field(default=None, max_length=255, description="HTTP 代理")
https_proxy: str | None = Field(default=None, max_length=255, description="HTTPS 代理")
class ObsAdvancedConfig(BaseModel):
"""华为云 OBS(esdk-obs-python ObsClient,3.26.x 无独立连接超时参数)"""
timeout: int = Field(default=60, ge=10, le=600, description="请求超时(秒)")
max_retry_count: int = Field(default=3, ge=1, le=5, description="最大重试次数")
ssl_verify: bool = Field(default=False, description="验证服务端证书")
is_cname: bool = Field(default=False, description="使用自定义域名访问")
path_style: bool = Field(default=False, description="路径风格寻址(兼容自建服务)")
pool_size: int = Field(default=10, ge=1, le=200, description="连接池大小")
signature: str = Field(default="v4", description="签名类型(v2/v4/obs)")
region: str | None = Field(default=None, max_length=64, description="区域(部分场景必需)")
security_token: str | None = Field(default=None, max_length=1024, description="临时密钥 Token(临时密钥认证时填写)")
class SftpAdvancedConfig(BaseModel):
"""SFTP(paramiko SSHClient.connect)"""
connect_timeout: int = Field(default=30, ge=1, le=300, description="连接超时(秒)")
banner_timeout: int = Field(default=30, ge=1, le=300, description="横幅超时(秒)")
auth_timeout: int = Field(default=30, ge=1, le=300, description="认证超时(秒)")
channel_timeout: int = Field(default=30, ge=1, le=300, description="通道操作超时(秒)")
compress: bool = Field(default=False, description="启用传输压缩")
allow_agent: bool = Field(default=True, description="允许使用 ssh-agent 认证")
look_for_keys: bool = Field(default=True, description="查找本地密钥文件认证")
keepalive_interval: int = Field(default=0, ge=0, le=3600, description="心跳间隔(秒,0 关闭)")
class FtpAdvancedConfig(BaseModel):
"""FTP / FTPS(ftplib)"""
timeout: int = Field(default=30, ge=1, le=300, description="连接超时(秒)")
# ════════════════════════════════════════════════════════════════════════════
# 各协议数据完整性校验能力对照(通常用于传输后的数据完整性保障,平时保持默认即可)
#
# S3 : 请求/响应 Checksum(CRC32/CRC32C 等算法)
# 由 botocore 控制计算与验证时机:
# - request_checksum_calculation: when_supported / when_required
# - response_checksum_validation: when_supported / when_required
# (注意:当前 botocore 版本运行时不接受文档中的 "never" 值)
# OSS : CRC64(默认开启):
# - disable_upload_crc64_check : 关闭上传校验(性能敏感时可开)
# - disable_download_crc64_check : 关闭下载校验(性能敏感时可开)
# COS : MD5(分片上传,默认关闭):
# - enable_md5: 开启后分片上传附带 MD5 校验,可靠性提升、略有性能开销
# OBS : SDK 自动校验(esdk-obs-python 无用户可控开关,内部计算并在失败时报错)
#
# 提示:以上校验字段均为"隐藏级"配置(不在前端高级配置面板展示),
# 确有需要时通过接口在 advanced_config 中传入。
# ════════════════════════════════════════════════════════════════════════════
# 各协议传输加速能力对照(用于跨地域大文件传输加速,需先在云控制台开通)
#
# S3 : use_accelerate_endpoint(botocore 原生开关,advanced_config 隐藏字段)
# OSS : use_accelerate_endpoint(oss_v2 原生开关,advanced_config 隐藏字段)
# COS : 无 SDK 开关,改用加速域名接入:
# endpoint 填 {bucket}.cos.accelerate.myqcloud.com
# OBS : 无 SDK 开关,改用 CDN/自定义域名接入:
# server 填 CDN 加速域名 + is_cname=true(advanced_config 隐藏字段)
# ════════════════════════════════════════════════════════════════════════════
# 协议 → 高级配置模型(local 无高级参数)
ADVANCED_CONFIG_MODELS: dict[StorageProtocol, type[BaseModel]] = {
StorageProtocol.S3: S3AdvancedConfig,
StorageProtocol.OSS: OssAdvancedConfig,
StorageProtocol.COS: CosAdvancedConfig,
StorageProtocol.OBS: ObsAdvancedConfig,
StorageProtocol.SFTP: SftpAdvancedConfig,
StorageProtocol.FTP: FtpAdvancedConfig,
StorageProtocol.FTPS: FtpAdvancedConfig,
}
def parse_advanced_config(protocol: StorageProtocol, raw: dict[str, Any] | None) -> BaseModel | None:
"""解析端点高级配置 JSON 为对应协议模型;local 或无配置返回 None。"""
model_cls = ADVANCED_CONFIG_MODELS.get(protocol)
if model_cls is None:
return None
return model_cls.model_validate(raw or {})
# 前端渲染元数据:协议 → 字段 key → 可选值(按协议区分同名枚举,如 signature_version)
_SELECT_OPTIONS: dict[str, dict[str, list[str]]] = {
"s3": {
"signature_version": ["s3v4", "s3", "v2"],
"addressing_style": ["auto", "path", "virtual"],
"retries_mode": ["standard", "adaptive", "legacy"],
"us_east_1_regional_endpoint": ["regional", "legacy"],
"request_checksum_calculation": ["when_supported", "when_required"],
"response_checksum_validation": ["when_supported", "when_required"],
},
"oss": {"signature_version": ["v4", "v1"]},
"obs": {"signature": ["v2", "v4", "obs"]},
}
def _field_defs(model_cls: type[BaseModel], protocol_key: str) -> list[AdvancedFieldDefSchema]:
defs: list[AdvancedFieldDefSchema] = []
protocol_options = _SELECT_OPTIONS.get(protocol_key, {})
for name, field_info in model_cls.model_fields.items():
default = field_info.default
if isinstance(default, BaseModel):
default = None
annotation = field_info.annotation
if annotation is bool:
field_type = "boolean"
elif annotation is int:
field_type = "number"
elif annotation is str:
field_type = "text"
elif get_origin(annotation) is types.UnionType:
# Optional[X]:取非 None 的分支推断组件类型
inner = next((a for a in get_args(annotation) if a is not type(None)), str)
field_type = "number" if inner is int else "text"
else:
field_type = "text"
options = protocol_options.get(name)
if options is not None:
field_type = "select"
defs.append(
AdvancedFieldDefSchema(
key=name,
label=field_info.description or name,
default=default,
type=field_type,
options=options,
)
)
return defs
ADVANCED_FIELD_DEFS: dict[str, list[AdvancedFieldDefSchema]] = {
protocol.value: _field_defs(model_cls, protocol.value) for protocol, model_cls in ADVANCED_CONFIG_MODELS.items()
}
@@ -0,0 +1,364 @@
from typing import Any
from qcloud_cos import CosConfig, CosS3Client
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.modules.workflow.core.base import (
BaseStorageAdapter,
CosAdvancedConfig,
StorageObject,
StoragePage,
StorageProtocol,
)
class CosStorageAdapter(BaseStorageAdapter):
"""腾讯云 COS 存储适配器(cos-python-sdk-v5,同步调用经 asyncio.to_thread 包装)。"""
protocol = StorageProtocol.COS
def __init__(self, config) -> None:
super().__init__(config)
if not self.config.region:
raise CustomException(msg="COS 存储源必须配置 region")
if not self.config.username or not self.config.password:
raise CustomException(msg="COS 存储源必须配置 SecretId/SecretKey")
adv = CosAdvancedConfig(**self.config.advanced_config)
proxies: dict[str, str] = {}
if adv.http_proxy:
proxies["http"] = adv.http_proxy
if adv.https_proxy:
proxies["https"] = adv.https_proxy
cos_config = CosConfig(
Region=self.config.region,
SecretId=self.config.username or "",
SecretKey=self.config.password or "",
Scheme="https" if self.config.is_secure else "http",
Token=adv.token,
Appid=adv.appid,
Endpoint=adv.endpoint,
Timeout=adv.timeout,
Proxies=proxies or None,
VerifySSL=adv.verify_ssl,
AutoSwitchDomainOnRetry=adv.auto_switch_domain_on_retry,
PoolConnections=adv.pool_connections,
PoolMaxSize=adv.pool_max_size,
KeepAlive=adv.keep_alive,
AllowRedirects=adv.allow_redirects,
)
self.client = CosS3Client(cos_config, retry=adv.retry)
self._adv = adv
def _sync_test_connection(self) -> bool:
"""
列出符合条件的bucket
:param Bucket(string): 存储桶名称
:param TagKey(string): 标签键
:param TagValue(string): 标签值
:param Region(string): 地域名称
:param CreateTime(Timestamp): GMT时间戳, 和 Range 参数一起使用, 支持根据创建时间过滤存储桶
:param Range(string): 和 CreateTime 参数一起使用, 支持根据创建时间过滤存储桶,支持枚举值 lt(创建时间早于 create-time)、gt(创建时间晚于 create-time)、lte(创建时间早于或等于 create-time)、gte(创建时间晚于或等于create-time)
:param Marker(string): 起始标记, 从该标记之后(不含)按照 UTF-8 字典序返回存储桶条目
:param MaxKeys(int): 单次返回最大的条目数量,默认值为2000,最大为2000
:return(dict): 账号下bucket相关信息.
"""
try:
self.client.list_buckets()
if self.client.head_bucket(Bucket=self._require_bucket()):
return True
return False
except Exception as e:
logger.warning(f"COS 连接测试失败: {e}")
return False
def _sync_upload(self, local_path: str, remote_path: str) -> str:
"""
:param Bucket(string): 存储桶名称.
:param key(string): 分块上传路径名.
:param LocalFilePath(string): 本地文件路径名.
:param PartSize(int): 分块的大小设置,单位为MB.
:param MAXThread(int): 并发上传的最大线程数.
:param EnableMD5(bool): 是否打开MD5校验.
:param kwargs(dict): 设置请求headers.
:return(dict): 成功上传文件的元信息.
"""
try:
# 分片大小/并发取端点配置;文件小于分片大小自动单次上传(流式传输时该值为超大值,等效单次)
part_size, concurrency, _ = self._multipart_settings()
self.client.upload_file(
Bucket=self._require_bucket(),
Key=remote_path,
LocalFilePath=local_path,
PartSize=part_size // (1024 * 1024),
MAXThread=concurrency,
EnableMD5=self._adv.enable_md5,
)
except Exception as e:
raise CustomException(msg=f"COS 上传失败: {e!s}")
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
try:
self.client.download_file(
Bucket=self._require_bucket(),
Key=remote_path,
DestFilePath=local_path,
)
except Exception as e:
raise CustomException(msg=f"COS 下载失败: {e!s}")
return local_path
def _sync_delete(self, remote_path: str) -> None:
"""
单文件删除接口
:param Bucket(string): 存储桶名称.
:param Key(string): COS路径.
:param kwargs(dict): 设置请求headers.
:return: dict.
"""
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_path)
except Exception as e:
raise CustomException(msg=f"COS 删除失败: {e!s}")
def _sync_exists(self, remote_path: str) -> bool:
try:
return self.client.object_exists(Bucket=self._require_bucket(), Key=remote_path)
except Exception:
return False
def _sync_list(self, prefix: str) -> list[StorageObject]:
"""列举目录层条目。COS 单次最多返回 1000 条,经 Marker 翻页拉全量。"""
try:
result: list[StorageObject] = []
seen_dirs: set[str] = set()
seen_files: set[str] = set()
marker: str | None = None
while True:
kwargs: dict[str, Any] = {
"Bucket": self._require_bucket(),
"Prefix": prefix,
"Delimiter": "/",
}
if marker:
kwargs["Marker"] = marker
resp = self.client.list_objects(**kwargs)
for common in resp.get("CommonPrefixes", []):
raw_key = common.get("Prefix", "").rstrip("/")
if raw_key in seen_dirs:
continue
seen_dirs.add(raw_key)
result.append(StorageObject(name=raw_key.rsplit("/", 1)[-1], key=raw_key, is_dir=True))
for obj in resp.get("Contents", []):
raw_key = obj.get("Key", "")
if raw_key == prefix:
continue
name = raw_key.rsplit("/", 1)[-1]
if not name: # key 以 "/" 结尾的目录占位对象,不按文件展示
continue
if raw_key in seen_files:
continue
seen_files.add(raw_key)
result.append(
StorageObject(
name=name,
key=raw_key,
is_dir=False,
size=obj.get("Size"),
modified_time=obj.get("LastModified"),
)
)
if resp.get("IsTruncated"):
marker = resp.get("NextMarker") or ""
if not marker and resp.get("Contents"):
marker = resp["Contents"][-1].get("Key", "") # 部分实现不返回 NextMarker 时回退最后 key
if not marker:
break
else:
break
return result
except Exception as e:
raise CustomException(msg=f"COS 列表失败: {e!s}")
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(COS Marker),翻页经前端回传游标。"""
try:
kwargs: dict[str, Any] = {
"Bucket": self._require_bucket(),
"Prefix": prefix,
"Delimiter": "/",
"MaxKeys": page_size,
}
if cursor:
kwargs["Marker"] = cursor
resp = self.client.list_objects(**kwargs)
contents = resp.get("Contents", [])
prefixes = resp.get("CommonPrefixes", [])
truncated = bool(resp.get("IsTruncated"))
# 条目数(含子目录前缀)少于请求页大小 → 必然已枚举完,忽略服务端边界误报
if len(contents) + len(prefixes) < page_size:
truncated = False
next_cursor = resp.get("NextMarker") or ""
if not next_cursor and truncated:
# COS 带 Delimiter 时 NextMarker 可能缺失(如目录下只有子目录前缀、Contents 为空):
# 取全部条目(子目录前缀 + 文件)字典序最后的 key 作为游标,避免翻页失效
keys = [p.get("Prefix", "").rstrip("/") for p in prefixes if p.get("Prefix")] + [
o.get("Key", "") for o in contents if o.get("Key")
]
if keys:
next_cursor = max(keys)
# COS 服务端在条目数恰好等于 MaxKeys 时可能保守返回 IsTruncated=true(实际已枚举完),
# 用 MaxKeys=1 探测确认是否存在真正的下一页,避免出现空的下一页
if truncated and next_cursor:
try:
probe = self.client.list_objects(
Bucket=self._require_bucket(),
Prefix=prefix,
Delimiter="/",
Marker=next_cursor,
MaxKeys=1,
)
truncated = bool(probe.get("Contents")) or bool(probe.get("CommonPrefixes"))
except Exception:
pass # 探测失败时保持原判定
items: list[StorageObject] = []
for common in resp.get("CommonPrefixes", []):
raw_key = common.get("Prefix", "").rstrip("/")
items.append(StorageObject(name=raw_key.rsplit("/", 1)[-1], key=raw_key, is_dir=True))
for obj in resp.get("Contents", []):
raw_key = obj.get("Key", "")
if raw_key == prefix:
continue
name = raw_key.rsplit("/", 1)[-1]
if not name: # key 以 "/" 结尾的目录占位对象,不按文件展示
continue
items.append(
StorageObject(
name=name,
key=raw_key,
is_dir=False,
size=obj.get("Size"),
modified_time=obj.get("LastModified"),
)
)
return StoragePage(
items=items,
has_next=truncated,
next_cursor=next_cursor or None,
)
except Exception as e:
raise CustomException(msg=f"COS 列表失败: {e!s}")
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
try:
resp = self.client.list_buckets()
buckets = (resp.get("Buckets") or {}).get("Bucket") or []
return [b.get("Name", "") for b in buckets if b.get("Name")]
except Exception as e:
raise CustomException(msg=f"COS 桶列表失败: {e!s}")
def _sync_get_url(self, remote_path: str, expire: int) -> str:
"""生成预签名 URL
:param Bucket(string): 存储桶名称.
:param Key(string): COS路径.
:param Method(string): HTTP请求的方法, 'PUT'|'POST'|'GET'|'DELETE'|'HEAD'
:param Expired(int): 签名过期时间.
:param Params(dict): 签入签名的参数
:param Headers(dict): 签入签名的头部
:param SignHost(bool): 是否将host算入签名.
:return(string): 预先签名的URL.
"""
try:
return self.client.get_presigned_url(
Method="GET",
Bucket=self._require_bucket(),
Key=remote_path,
Expired=expire,
)
except Exception as e:
raise CustomException(msg=f"COS 生成预签名 URL 失败: {e!s}")
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
def _list_all_keys(self, prefix: str) -> list[str]:
"""分页列举前缀下的全部对象 key。"""
keys: list[str] = []
marker = ""
while True:
resp = self.client.list_objects(Bucket=self._require_bucket(), Prefix=prefix, Marker=marker)
keys.extend(obj["Key"] for obj in resp.get("Contents", []))
if not resp.get("IsTruncated"):
break
# 未指定 delimiter 时服务端可能不返回 NextMarker,退化为当前页最后一个 key,避免死循环
marker = resp.get("NextMarker") or (resp["Contents"][-1]["Key"] if resp.get("Contents") else "")
if not marker:
break
return keys
def _is_dir(self, key: str) -> bool:
"""判断 key 是否代表目录(存在占位对象或前缀下有任何对象)。"""
try:
resp = self.client.list_objects(Bucket=self._require_bucket(), Prefix=key.rstrip("/") + "/", MaxKeys=1)
return bool(resp.get("Contents") or resp.get("CommonPrefixes"))
except Exception:
return False
def _sync_mkdir(self, remote_dir: str) -> None:
try:
self.client.put_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/", Body=b"")
except Exception as e:
raise CustomException(msg=f"COS 创建目录失败: {e!s}")
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/")
except Exception as e:
raise CustomException(msg=f"COS 删除目录失败: {e!s}")
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
try:
if self._is_dir(src):
self._sync_copy_dir(src, dst)
return
self.client.copy_object(
Bucket=self._require_bucket(),
Key=dst,
CopySource={"Bucket": self._require_bucket(), "Key": src},
)
except Exception as e:
raise CustomException(msg=f"COS 复制失败: {e!s}")
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
if self._is_dir(src):
self._sync_move_dir(src, dst)
else:
self._sync_copy(src, dst)
self._sync_delete(src)
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"COS 递归列表失败: {e!s}")
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
"""递归删除:一次列举全部对象并分批批量删除(含占位目录对象)。"""
try:
keys = self._list_all_keys(remote_dir)
marker = remote_dir.rstrip("/") + "/"
if marker not in keys:
keys.append(marker)
for i in range(0, len(keys), 1000):
self.client.delete_objects(
Bucket=self._require_bucket(),
Delete={"Objects": [{"Key": k} for k in keys[i : i + 1000]], "Quiet": True},
)
except Exception as e:
raise CustomException(msg=f"COS 递归删除失败: {e!s}")
@@ -0,0 +1,32 @@
from app.core.exceptions import CustomException
from app.modules.workflow.core.base import BaseStorageAdapter, StorageAdapterConfig, StorageProtocol
from app.modules.workflow.core.cos_adapter import CosStorageAdapter
from app.modules.workflow.core.ftp_ftps_adapter import FtpStorageAdapter
from app.modules.workflow.core.local_adapter import LocalStorageAdapter
from app.modules.workflow.core.obs_adapter import ObsStorageAdapter
from app.modules.workflow.core.oss_adapter import OssStorageAdapter
from app.modules.workflow.core.s3_adapter import S3StorageAdapter
from app.modules.workflow.core.sftp_adapter import SftpStorageAdapter
# 协议 → 适配器类映射(FTPS 复用 FTP 适配器,由配置区分显式/隐式 TLS)
_STORAGE_ADAPTERS: dict[str, type[BaseStorageAdapter]] = {
StorageProtocol.FTP.value: FtpStorageAdapter,
StorageProtocol.FTPS.value: FtpStorageAdapter,
StorageProtocol.SFTP.value: SftpStorageAdapter,
StorageProtocol.S3.value: S3StorageAdapter,
StorageProtocol.OBS.value: ObsStorageAdapter,
StorageProtocol.OSS.value: OssStorageAdapter,
StorageProtocol.COS.value: CosStorageAdapter,
StorageProtocol.LOCAL.value: LocalStorageAdapter,
}
class StorageAdapterFactory:
"""存储适配器工厂:根据协议创建对应适配器实例。"""
@staticmethod
def create(config: StorageAdapterConfig) -> BaseStorageAdapter:
adapter_cls = _STORAGE_ADAPTERS.get(config.protocol.value)
if adapter_cls is None:
raise CustomException(msg=f"不支持的存储协议: {config.protocol}")
return adapter_cls(config)
@@ -0,0 +1,253 @@
import ftplib
import os
import socket
import ssl
import tempfile
from datetime import datetime
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.modules.workflow.core.base import BaseStorageAdapter, FtpAdvancedConfig, StorageObject, StorageProtocol
class _ImplicitFTP_TLS(ftplib.FTP_TLS):
"""隐式 FTPS(默认端口 990)连接子类:socket 直连即套 TLS。"""
def connect(self, host: str = "", port: int = 0, timeout: int = -999, source_address=None) -> str:
if host != "":
self.host = host
if port > 0:
self.port = port
if timeout != -999:
self.timeout = timeout
if source_address is not None:
self.source_address = source_address
context = self.context
if context is None:
# FTP_TLS 默认 context 未设置时,构造宽松校验的客户端上下文(兼容自签名证书)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
self.sock = context.wrap_socket(
socket.create_connection((self.host, self.port), self.timeout, self.source_address),
server_hostname=self.host,
)
self.file = self.sock.makefile("r", encoding=self.encoding)
self.welcome = self.getresp()
return self.welcome
class FtpStorageAdapter(BaseStorageAdapter):
"""FTP / FTPS 存储适配器(ftplib 标准库,同步调用经 asyncio.to_thread 包装)。"""
protocol = StorageProtocol.FTP
def __init__(self, config) -> None:
super().__init__(config)
self._adv = FtpAdvancedConfig(**self.config.advanced_config)
def _new_client(self) -> ftplib.FTP_TLS | ftplib.FTP:
"""建立连接并登录。FTP 明文 / FTPS(显式或隐式 TLS)按配置选择。"""
implicit = False
if self.config.protocol == StorageProtocol.FTPS:
# 隐式 TLS:implicit_tls 开关或 encrypt_type>=3(兼容两种配置写法)
implicit = self.config.implicit_tls or (self.config.encrypt_type or 0) >= 3
client = _ImplicitFTP_TLS() if implicit else ftplib.FTP_TLS()
else:
client = ftplib.FTP()
client.encoding = self.config.encoding or "utf-8"
client.connect(host=self.config.host, port=self.config.port, timeout=self._adv.timeout)
# 传输模式:0=默认(被动) 1=主动 2=被动
if self.config.connection_mode == 1:
client.set_pasv(False)
elif self.config.connection_mode == 2:
client.set_pasv(True)
if isinstance(client, ftplib.FTP_TLS):
if not implicit:
client.auth() # 显式 FTPS:升级 TLS 通道(隐式 TLS 连接时已是加密通道,重复升级会报错)
client.login(user=self.config.username or "", passwd=self.config.password or "")
if isinstance(client, ftplib.FTP_TLS):
client.prot_p() # 数据通道加密
return client
@staticmethod
def _close_client(client: ftplib.FTP) -> None:
try:
client.quit()
except Exception:
try:
client.close()
except Exception:
pass
def _sync_test_connection(self) -> bool:
client = self._new_client()
try:
client.pwd()
return True
except Exception as e:
logger.warning(f"FTP 连接测试失败: {e}")
return False
finally:
self._close_client(client)
def _sync_upload(self, local_path: str, remote_path: str) -> str:
client = self._new_client()
try:
# 目标目录不存在时逐级创建(与 SFTP 上传行为对齐,否则 STOR 直接失败)
dir_part = remote_path.rsplit("/", 1)[0] if "/" in remote_path else ""
if dir_part:
self._ensure_remote_dir(client, dir_part)
with open(local_path, "rb") as f:
client.storbinary(f"STOR {remote_path}", f)
except Exception as e:
raise CustomException(msg=f"FTP 上传失败: {e!s}")
finally:
self._close_client(client)
return remote_path
@staticmethod
def _ensure_remote_dir(client: ftplib.FTP, remote_dir: str) -> None:
"""逐级创建远端目录(mkd -p,目录已存在的 550 错误静默跳过)。"""
current = ""
for part in [p for p in remote_dir.split("/") if p]:
current = f"{current}/{part}"
try:
client.mkd(current)
except ftplib.error_perm:
pass
def _sync_download(self, remote_path: str, local_path: str) -> str:
client = self._new_client()
try:
with open(local_path, "wb") as f:
client.retrbinary(f"RETR {remote_path}", f.write)
except Exception as e:
raise CustomException(msg=f"FTP 下载失败: {e!s}")
finally:
self._close_client(client)
return local_path
def _sync_delete(self, remote_path: str) -> None:
client = self._new_client()
try:
client.delete(remote_path)
except Exception as e:
raise CustomException(msg=f"FTP 删除失败: {e!s}")
finally:
self._close_client(client)
def _sync_exists(self, remote_path: str) -> bool:
client = self._new_client()
try:
try:
client.size(remote_path)
return True
except ftplib.error_perm:
# 部分服务器不支持 SIZE,退化为 NLST 判断
try:
client.nlst(remote_path)
return True
except ftplib.error_perm:
return False
except Exception:
return False
finally:
self._close_client(client)
def _sync_list(self, prefix: str) -> list[StorageObject]:
client = self._new_client()
try:
# 空 prefix 表示浏览根目录:FTP 用 "." 表示登录后的当前目录
entries = list(client.mlsd(prefix or "."))
result: list[StorageObject] = []
for name, facts in entries:
if name in (".", ".."):
continue
modified_time = None
raw_mtime = facts.get("modify")
if raw_mtime:
try:
modified_time = datetime.strptime(raw_mtime, "%Y%m%d%H%M%S")
except ValueError:
modified_time = None
result.append(
StorageObject(
name=name,
key=f"{prefix}/{name}" if prefix else name,
is_dir=facts.get("type") == "dir",
size=int(facts["size"]) if facts.get("size") else None,
modified_time=modified_time,
)
)
return result
except Exception as e:
raise CustomException(msg=f"FTP 列表失败: {e!s}")
finally:
self._close_client(client)
# ── 目录操作(FTP mkd 不自动创建父目录,需逐级创建)────────────────
def _sync_mkdir(self, remote_dir: str) -> None:
client = self._new_client()
try:
parts = [p for p in remote_dir.split("/") if p]
current = ""
for part in parts:
current = f"{current}/{part}" if current else part
try:
client.mkd(current)
except ftplib.error_perm:
pass # 目录已存在时跳过(部分服务器返回 550)
except Exception as e:
raise CustomException(msg=f"FTP 创建目录失败: {e!s}")
finally:
self._close_client(client)
def _sync_rmdir(self, remote_dir: str) -> None:
client = self._new_client()
try:
client.rmd(remote_dir)
except Exception as e:
raise CustomException(msg=f"FTP 删除目录失败: {e!s}")
finally:
self._close_client(client)
def _sync_rename(self, src: str, dst: str) -> None:
client = self._new_client()
try:
client.rename(src, dst)
except Exception as e:
raise CustomException(msg=f"FTP 重命名失败: {e!s}")
finally:
self._close_client(client)
@staticmethod
def _is_dir(client: ftplib.FTP, path: str) -> bool:
"""通过 MLSD 类型判断路径是否为目录。"""
try:
return any(facts.get("type") == "dir" for _name, facts in client.mlsd(path))
except Exception:
return False
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件经本地临时文件中转(FTP 无服务端复制)。"""
client = self._new_client()
try:
if self._is_dir(client, src):
self._close_client(client)
self._sync_copy_dir(src, dst)
return
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
with open(tmp_path, "wb") as f:
client.retrbinary(f"RETR {src}", f.write)
with open(tmp_path, "rb") as f:
client.storbinary(f"STOR {dst}", f)
finally:
os.remove(tmp_path)
except Exception as e:
raise CustomException(msg=f"FTP 复制失败: {e!s}")
finally:
self._close_client(client)
@@ -0,0 +1,120 @@
import os
import shutil
from datetime import datetime
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.modules.workflow.core.base import BaseStorageAdapter, StorageObject, StorageProtocol
class LocalStorageAdapter(BaseStorageAdapter):
"""本地磁盘/挂载目录存储适配器(零 SDK,同步调用经 asyncio.to_thread 包装)。"""
protocol = StorageProtocol.LOCAL
def __init__(self, config) -> None:
super().__init__(config)
self.root = config.host or ""
def _abs_path(self, remote_path: str) -> str:
"""将远端相对路径映射为本地绝对路径,并防御路径穿越。
remote_path 为相对存储真根(host 根目录)的路径,直接 join 到 root 下。
"""
if ".." in remote_path:
raise CustomException(msg="本地存储不允许路径穿越(..)")
return os.path.join(self.root, remote_path.lstrip("/"))
def _sync_test_connection(self) -> bool:
if not self.root or not os.path.isdir(self.root):
logger.warning(f"本地存储根目录不可用: {self.root}")
return False
return os.access(self.root, os.R_OK | os.W_OK)
def _sync_upload(self, local_path: str, remote_path: str) -> str:
dest = self._abs_path(remote_path)
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(local_path, dest)
except Exception as e:
raise CustomException(msg=f"本地存储上传失败: {e!s}")
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
src = self._abs_path(remote_path)
try:
shutil.copy2(src, local_path)
except Exception as e:
raise CustomException(msg=f"本地存储下载失败: {e!s}")
return local_path
def _sync_delete(self, remote_path: str) -> None:
target = self._abs_path(remote_path)
try:
if os.path.isdir(target):
shutil.rmtree(target)
elif os.path.exists(target):
os.remove(target)
except Exception as e:
raise CustomException(msg=f"本地存储删除失败: {e!s}")
def _sync_exists(self, remote_path: str) -> bool:
return os.path.exists(self._abs_path(remote_path))
def _sync_list(self, prefix: str) -> list[StorageObject]:
base_dir = self._abs_path(prefix) if prefix else self.root
try:
entries = os.scandir(base_dir)
except OSError as e:
raise CustomException(msg=f"本地存储列表失败: {e!s}")
result: list[StorageObject] = []
for entry in entries:
try:
is_dir = entry.is_dir()
stat = entry.stat()
except OSError:
continue
key = f"{prefix}/{entry.name}" if prefix else entry.name
result.append(
StorageObject(
name=entry.name,
key=key,
is_dir=is_dir,
size=None if is_dir else stat.st_size,
modified_time=datetime.fromtimestamp(stat.st_mtime),
)
)
return result
# ── 目录操作(本地文件系统原生支持,零额外开销)──────────────────
def _sync_mkdir(self, remote_dir: str) -> None:
try:
os.makedirs(self._abs_path(remote_dir), exist_ok=True)
except Exception as e:
raise CustomException(msg=f"本地存储创建目录失败: {e!s}")
def _sync_rmdir(self, remote_dir: str) -> None:
try:
os.rmdir(self._abs_path(remote_dir))
except Exception as e:
raise CustomException(msg=f"本地存储删除目录失败: {e!s}")
def _sync_rename(self, src: str, dst: str) -> None:
try:
os.makedirs(os.path.dirname(self._abs_path(dst)), exist_ok=True)
shutil.move(self._abs_path(src), self._abs_path(dst))
except Exception as e:
raise CustomException(msg=f"本地存储重命名失败: {e!s}")
def _sync_copy(self, src: str, dst: str) -> None:
src_abs, dst_abs = self._abs_path(src), self._abs_path(dst)
try:
if os.path.isdir(src_abs):
shutil.copytree(src_abs, dst_abs, dirs_exist_ok=True)
else:
os.makedirs(os.path.dirname(dst_abs), exist_ok=True)
shutil.copy2(src_abs, dst_abs)
except Exception as e:
raise CustomException(msg=f"本地存储复制失败: {e!s}")
@@ -0,0 +1,487 @@
from datetime import UTC, datetime
from typing import Any
from obs import ObsClient
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.modules.workflow.core.base import (
BaseStorageAdapter,
ObsAdvancedConfig,
StorageObject,
StoragePage,
StorageProtocol,
normalize_endpoint,
)
class ObsStorageAdapter(BaseStorageAdapter):
"""华为云 OBS 存储适配器(esdk-obs-python,同步调用经 asyncio.to_thread 包装)。
SDK API 概览
------------
一、桶管理(Bucket)
| 接口名 | 方法 | 功能描述 |
| --- | --- | --- |
| 创建桶 | ObsClient.createBucket | 创建桶。 |
| 获取桶列表 | ObsClient.listBuckets | 查询桶列表,返回结果按照桶名字典序排列。 |
| 判断桶是否存在 | ObsClient.headBucket | 判断桶是否存在。 |
| 删除桶 | ObsClient.deleteBucket | 删除桶,待删除的桶必须为空。 |
| 列举桶内对象 | ObsClient.listObjects | 列举桶内对象,默认返回最大1000个对象。 |
| 列举桶内多版本对象 | ObsClient.listVersions | 列举桶内多版本对象,默认返回最大1000个多版本对象。 |
| 列举分段上传任务 | ObsClient.listMultipartUploads | 列举指定桶中所有的初始化后还未合并或还未取消的分段上传任务。 |
| 获取桶元数据 | ObsClient.getBucketMetadata | 对桶发送HEAD请求,获取桶的存储类型、CORS规则(如果已设置)等信息。 |
| 获取桶区域位置 | ObsClient.getBucketLocation | 获取桶所在的区域位置。 |
| 获取桶存量信息 | ObsClient.getBucketStorageInfo | 获取桶的存量信息,包含桶的空间大小以及对象个数。 |
| 设置桶配额 | ObsClient.setBucketQuota | 设置桶的配额值,单位为字节,支持的最大值为2^63-1,配额值设为0表示桶的配额没有上限。 |
| 获取桶配额 | ObsClient.getBucketQuota | 获取桶的配额值,0代表配额没有上限。 |
| 设置桶存储类型 | ObsClient.setBucketStoragePolicy | 设置桶的存储类型,桶中对象的存储类型默认将与桶的存储类型保持一致。 |
| 获取桶存储类型 | ObsClient.getBucketStoragePolicy | 获取桶的存储类型。 |
| 设置桶ACL | ObsClient.setBucketAcl | 设置桶ACL。 |
| 获取桶ACL | ObsClient.getBucketAcl | 获取桶ACL。 |
| 设置桶日志管理配置 | ObsClient.setBucketLogging | 设置桶的访问日志配置。 |
| 获取桶日志管理配置 | ObsClient.getBucketLogging | 获取桶的访问日志配置。 |
| 设置桶策略 | ObsClient.setBucketPolicy | 配置桶的策略,如果桶已经存在一个策略,当前请求中的策略将完全覆盖桶中现存的策略。 |
| 获取桶策略 | ObsClient.getBucketPolicy | 获取桶的策略配置。 |
| 删除桶策略 | ObsClient.deleteBucketPolicy | 删除桶的策略配置。 |
| 设置桶生命周期配置 | ObsClient.setBucketLifecycle | 配置桶的生命周期规则,实现定时转换桶中对象的存储类型,以及定时删除桶中对象的功能。 |
| 获取桶生命周期配置 | ObsClient.getBucketLifecycle | 获取桶的生命周期规则。 |
| 删除桶生命周期配置 | ObsClient.deleteBucketLifecycle | 删除桶所有的生命周期规则。 |
| 设置桶Website配置 | ObsClient.setBucketWebsite | 设置桶的Website配置。 |
| 获取桶Website配置 | ObsClient.getBucketWebsite | 获取桶的Website配置。 |
| 删除桶Website配置 | ObsClient.deleteBucketWebsite | 删除指定桶的Website配置。 |
| 设置桶多版本状态 | ObsClient.setBucketVersioning | 设置桶的多版本状态。 |
| 获取桶多版本状态 | ObsClient.getBucketVersioning | 获取桶的多版本状态。 |
| 设置桶CORS配置 | ObsClient.setBucketCors | 设置桶的跨域资源共享规则,以允许客户端浏览器进行跨域请求。 |
| 获取桶CORS配置 | ObsClient.getBucketCors | 获取指定桶的跨域资源共享规则。 |
| 删除桶CORS配置 | ObsClient.deleteBucketCors | 删除指定桶的跨域资源共享规则。 |
| 设置桶标签 | ObsClient.setBucketTagging | 设置桶的标签。 |
| 获取桶标签 | ObsClient.getBucketTagging | 获取指定桶的标签。 |
| 删除桶标签 | ObsClient.deleteBucketTagging | 删除指定桶的标签。 |
二、对象管理(Object)
| 接口名 | 方法 | 功能描述 |
| --- | --- | --- |
| 上传对象 | ObsClient.putContent | 上传对象到指定桶中。 |
| 上传文件 | ObsClient.putFile | 上传文件/文件夹到指定桶中。 |
| 追加上传 | ObsClient.appendObject | 对同一个对象追加数据内容。 |
| 下载对象 | ObsClient.getObject | 下载指定桶中的对象。 |
| 复制对象 | ObsClient.copyObject | 为指定桶中的对象创建一个副本。 |
| 删除对象 | ObsClient.deleteObject | 删除指定桶中的对象。 |
| 批量删除对象 | ObsClient.deleteObjects | 批量删除指定桶中的多个对象。 |
| 获取对象元数据 | ObsClient.getObjectMetadata | 对指定桶中的对象发送HEAD请求,获取对象的元数据信息。 |
| 修改对象元数据 | ObsClient.setObjectMetadata | 修改指定桶中对象的元数据信息。 |
| 设置对象ACL | ObsClient.setObjectAcl | 设置指定桶中对象ACL。 |
| 获取对象ACL | ObsClient.getObjectAcl | 获取指定桶中对象ACL。 |
三、分段上传(Multipart Upload)
| 接口名 | 方法 | 功能描述 |
| --- | --- | --- |
| 初始化分段上传任务 | ObsClient.initiateMultipartUpload | 在指定桶中初始化分段上传任务。 |
| 上传段 | ObsClient.uploadPart | 初始化分段上传任务后,通过分段上传任务的ID,上传段到指定桶中。 |
| 复制段 | ObsClient.copyPart | 初始化分段上传任务后,通过分段上传任务的ID,复制段到指定桶中。 |
| 列举已上传的段 | ObsClient.listParts | 通过分段上传任务的ID,列举指定桶中已上传的段。 |
| 合并段 | ObsClient.completeMultipartUpload | 通过分段上传任务的ID,合并指定桶中已上传的段。 |
| 取消分段上传任务 | ObsClient.abortMultipartUpload | 通过分段上传任务的ID,取消指定桶中的分段上传任务。 |
四、高级功能
| 接口名 | 方法 | 功能描述 |
| --- | --- | --- |
| 恢复归档存储对象 | ObsClient.restoreObject | 恢复指定桶中的归档存储对象。 |
| 生成带授权信息的URL | ObsClient.createSignedUrl | 通过访问密钥、请求方法类型、请求参数等信息生成一个在Query参数中携带鉴权信息的URL,以对OBS服务进行特定操作。 |
| 生成带授权信息的表单上传参数 | ObsClient.createPostSignature | 生成用于鉴权的请求参数,以进行基于浏览器的POST表单上传。 |
| 断点续传上传 | ObsClient.uploadFile | 对分段上传的封装和加强,解决上传大文件时由于网络不稳定或程序崩溃导致上传失败的问题。 |
| 断点续传下载 | ObsClient.downloadFile | 对范围下载的封装和加强,解决下载大对象到本地时由于网络不稳定或程序崩溃导致下载失败的问题。 |
五、工作流(WorkflowClient)
| 接口名 | 方法 | 功能描述 |
| --- | --- | --- |
| 创建工作流 | WorkflowClient.createWorkflow | 根据模板创建工作流。 |
| 查询工作流 | WorkflowClient.getWorkflow | 按名称查询工作流。 |
| 删除工作流 | WorkflowClient.deleteWorkflow | 删除存在的工作流。 |
| 更新工作流 | WorkflowClient.updateWorkflow | 更新工作流。 |
| 查询工作流列表 | WorkflowClient.listWorkflow | 查询工作流列表。 |
| API触发启动工作流 | WorkflowClient.asyncAPIStartWorkflow | API触发启动工作流。 |
| 查询工作流实例列表 | WorkflowClient.listWorkflowExecution | 查询工作流实例列表。 |
| 查询工作流实例 | WorkflowClient.getWorkflowExecution | 查询工作流实例详细。 |
| 恢复失败状态的工作流实例 | WorkflowClient.restoreFailedWorkflowExecution | 当且仅当工作流实例处于执行失败状态才能执行恢复操作。恢复后,工作流实例将从上次失败的状态处继续执行,已执行过的状态不会再执行。 |
| 配置桶触发器 | WorkflowClient.putTriggerPolicy | 在桶上绑定工作流触发器。 |
| 查询桶触发器 | WorkflowClient.getTriggerPolicy | 查询桶上绑定工作流触发器。 |
| 删除桶触发器 | WorkflowClient.deleteTriggerPolicy | 删除在桶上绑定工作流触发器。 |
"""
protocol = StorageProtocol.OBS
def __init__(self, config) -> None:
super().__init__(config)
if not self.config.endpoint:
raise CustomException(msg="OBS 存储源必须配置 endpoint")
adv = ObsAdvancedConfig(**self.config.advanced_config)
self.client = ObsClient(
access_key_id=self.config.username or "",
secret_access_key=self.config.password or "",
server=normalize_endpoint(self.config.endpoint, self.config.scheme),
is_secure=self.config.is_secure,
max_retry_count=adv.max_retry_count,
timeout=adv.timeout,
ssl_verify=adv.ssl_verify,
is_cname=adv.is_cname,
path_style=adv.path_style,
pool_size=adv.pool_size,
signature=adv.signature,
region=adv.region,
security_token=adv.security_token,
)
@staticmethod
def _is_ok(resp: Any) -> bool:
"""判断 OBS 响应是否成功(status < 300)。SDK 未提供类型存根,故用 getattr 访问动态属性。"""
status = getattr(resp, "status", None)
return status is not None and status < 300
@staticmethod
def _error_desc(resp: Any) -> str:
"""提取 OBS 响应中的错误描述。"""
code = getattr(resp, "errorCode", "") or ""
message = getattr(resp, "errorMessage", "") or ""
return f"{code} {message}".strip()
def _sync_test_connection(self) -> bool:
try:
resp = self.client.listBuckets()
if self._is_ok(resp):
resp = self.client.headBucket(bucketName=self._require_bucket())
if self._is_ok(resp):
return True
logger.warning(f"OBS 连接测试失败: {self._error_desc(resp)}")
return False
except Exception as e:
logger.warning(f"OBS 连接测试失败: {e}")
return False
def _sync_upload(self, local_path: str, remote_path: str) -> str:
"""上传:默认 uploadFile(断点续传 + 分片并发),流式传输(stream)走 putContent 单次上传。"""
try:
if self.config.transfer_mode == "stream":
with open(local_path, "rb") as f:
resp = self.client.putContent(
bucketName=self._require_bucket(),
objectKey=remote_path,
content=f,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}")
return remote_path
part_size, concurrency, _ = self._multipart_settings()
resp = self.client.uploadFile(
bucketName=self._require_bucket(),
objectKey=remote_path,
uploadFile=local_path,
partSize=part_size,
taskNum=concurrency,
enableCheckpoint=True,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 上传失败: {e!s}")
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
"""下载(downloadFile 断点续传 + 分片并发)。"""
try:
resp = self.client.downloadFile(
bucketName=self._require_bucket(),
objectKey=remote_path,
downloadFile=local_path,
partSize=5 * 1024 * 1024,
taskNum=3,
enableCheckpoint=True,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 下载失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 下载失败: {e!s}")
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
resp = self.client.deleteObject(bucketName=self._require_bucket(), objectKey=remote_path)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 删除失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 删除失败: {e!s}")
def _sync_exists(self, remote_path: str) -> bool:
try:
resp = self.client.getObjectMetadata(bucketName=self._require_bucket(), objectKey=remote_path)
return self._is_ok(resp)
except Exception:
return False
@staticmethod
def _to_utc_dt(value: str | None) -> datetime | None:
"""OBS SDK 的 lastModified 为 UTC 字符串(如 '2026/08/25 09:25:27'),转为 aware datetime。"""
if not value:
return None
try:
return datetime.strptime(value, "%Y/%m/%d %H:%M:%S").replace(tzinfo=UTC)
except ValueError:
return None
def _sync_list(self, prefix: str) -> list[StorageObject]:
"""列举桶内“目录层”条目(Delimiter=/ 模拟文件夹,返回名称/是否目录/大小/修改时间)。
OBS 单次最多返回 1000 条,经 marker 翻页拉全量。"""
try:
result: list[StorageObject] = []
seen_dirs: set[str] = set()
seen_files: set[str] = set()
marker: str | None = None
while True:
kwargs: dict[str, Any] = {"bucketName": self._require_bucket(), "prefix": prefix, "delimiter": "/"}
if marker:
kwargs["marker"] = marker
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}")
body = getattr(resp, "body", None)
for common in getattr(body, "commonPrefixs", None) or []:
raw_key = (getattr(common, "prefix", "") or "").rstrip("/")
if raw_key in seen_dirs:
continue
seen_dirs.add(raw_key)
result.append(StorageObject(name=raw_key.rsplit("/", 1)[-1], key=raw_key, is_dir=True))
for obj in getattr(body, "contents", None) or []:
raw_key = getattr(obj, "key", "") or ""
if raw_key == prefix:
continue
name = raw_key.rsplit("/", 1)[-1]
if not name: # key 以 "/" 结尾的目录占位对象,不按文件展示
continue
if raw_key in seen_files:
continue
seen_files.add(raw_key)
result.append(
StorageObject(
name=name,
key=raw_key,
is_dir=False,
size=getattr(obj, "size", None),
modified_time=self._to_utc_dt(getattr(obj, "lastModified", None)),
)
)
if getattr(body, "is_truncated", None):
marker = getattr(body, "next_marker", None) or ""
if not marker:
break
else:
break
return result
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 列表失败: {e!s}")
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(OBS marker),翻页经前端回传游标。"""
try:
kwargs: dict[str, Any] = {
"bucketName": self._require_bucket(),
"prefix": prefix,
"delimiter": "/",
"max_keys": page_size,
}
if cursor:
kwargs["marker"] = cursor
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}")
body = getattr(resp, "body", None)
truncated = bool(getattr(body, "is_truncated", None))
next_cursor = getattr(body, "next_marker", None) or None
items: list[StorageObject] = []
for common in getattr(body, "commonPrefixs", None) or []:
raw_key = (getattr(common, "prefix", "") or "").rstrip("/")
items.append(StorageObject(name=raw_key.rsplit("/", 1)[-1], key=raw_key, is_dir=True))
for obj in getattr(body, "contents", None) or []:
raw_key = getattr(obj, "key", "") or ""
if raw_key == prefix:
continue
name = raw_key.rsplit("/", 1)[-1]
if not name: # key 以 "/" 结尾的目录占位对象,不按文件展示
continue
items.append(
StorageObject(
name=name,
key=raw_key,
is_dir=False,
size=getattr(obj, "size", None),
modified_time=self._to_utc_dt(getattr(obj, "lastModified", None)),
)
)
return StoragePage(
items=items,
has_next=truncated,
next_cursor=next_cursor,
)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 列表失败: {e!s}")
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
try:
resp = self.client.listBuckets()
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 桶列表失败: {self._error_desc(resp)}")
body = getattr(resp, "body", None)
buckets = getattr(body, "buckets", None) or []
return [getattr(b, "name", "") or "" for b in buckets if getattr(b, "name", "")]
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 桶列表失败: {e!s}")
def _sync_get_url(self, remote_path: str, expire: int) -> str:
try:
resp = self.client.createSignedUrl("GET", bucketName=self._require_bucket(), objectKey=remote_path, expires=expire)
return getattr(resp, "signedUrl", "")
except Exception as e:
raise CustomException(msg=f"OBS 生成预签名 URL 失败: {e!s}")
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
def _list_all_keys(self, prefix: str) -> list[str]:
"""分页列举前缀下的全部对象 key。"""
keys: list[str] = []
marker: str | None = None
while True:
kwargs: dict[str, Any] = {"bucketName": self._require_bucket(), "prefix": prefix}
if marker:
kwargs["marker"] = marker
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列举对象失败: {self._error_desc(resp)}")
body = getattr(resp, "body", None)
for obj in getattr(body, "contents", None) or []:
key = getattr(obj, "key", "")
if key:
keys.append(key)
if not getattr(body, "is_truncated", False):
break
# 未指定 delimiter 时服务端可能不返回 next_marker,退化为当前页最后一个 key,避免死循环
marker = getattr(body, "next_marker", None) or (keys[-1] if keys else "")
if not marker:
break
return keys
def _is_dir(self, key: str) -> bool:
"""判断 key 是否代表目录(存在占位对象或前缀下有任何对象)。"""
try:
resp = self.client.listObjects(bucketName=self._require_bucket(), prefix=key.rstrip("/") + "/", max_keys=1)
if not self._is_ok(resp):
return False
body = getattr(resp, "body", None)
return bool(getattr(body, "contents", None) or getattr(body, "commonPrefixs", None))
except Exception:
return False
def _sync_mkdir(self, remote_dir: str) -> None:
try:
resp = self.client.putContent(bucketName=self._require_bucket(), objectKey=remote_dir.rstrip("/") + "/", content=b"")
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 创建目录失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 创建目录失败: {e!s}")
def _sync_rmdir(self, remote_dir: str) -> None:
try:
resp = self.client.deleteObject(bucketName=self._require_bucket(), objectKey=remote_dir.rstrip("/") + "/")
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 删除目录失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 删除目录失败: {e!s}")
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copyObject。"""
try:
if self._is_dir(src):
self._sync_copy_dir(src, dst)
return
resp = self.client.copyObject(
sourceBucketName=self._require_bucket(),
sourceObjectKey=src,
destBucketName=self._require_bucket(),
destObjectKey=dst,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 复制失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 复制失败: {e!s}")
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:优先 OBS 原生 renameFile(支持目录级),失败回退先复制后删除。"""
try:
resp = self.client.renameFile(bucketName=self._require_bucket(), objectKey=src, newObjectKey=dst)
if self._is_ok(resp):
return
except Exception:
pass
if self._is_dir(src):
self._sync_move_dir(src, dst)
else:
self._sync_copy(src, dst)
self._sync_delete(src)
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"OBS 递归列表失败: {e!s}")
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
"""递归删除:一次列举全部对象并分批批量删除(含占位目录对象)。"""
try:
keys = self._list_all_keys(remote_dir)
marker = remote_dir.rstrip("/") + "/"
if marker not in keys:
keys.append(marker)
for i in range(0, len(keys), 1000):
batch = keys[i : i + 1000]
resp = self.client.deleteObjects(
bucketName=self._require_bucket(),
deleteObjectsRequest={"quiet": True, "objects": [{"key": k} for k in batch]},
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 递归删除失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 递归删除失败: {e!s}")
def _sync_close(self) -> None:
"""关闭 OBS 客户端连接。"""
try:
self.client.close()
except Exception as e:
logger.warning(f"OBS 关闭连接失败: {e}")
@@ -0,0 +1,285 @@
from datetime import UTC, datetime, timedelta
import alibabacloud_oss_v2 as oss
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.modules.workflow.core.base import (
BaseStorageAdapter,
OssAdvancedConfig,
StorageObject,
StoragePage,
StorageProtocol,
normalize_endpoint,
)
class OssStorageAdapter(BaseStorageAdapter):
"""阿里云 OSS 存储适配器(alibabacloud_oss_v2 SDK,同步调用经 asyncio.to_thread 包装)。"""
protocol = StorageProtocol.OSS
def __init__(self, config) -> None:
super().__init__(config)
if not self.config.endpoint:
raise CustomException(msg="OSS 存储源必须配置 endpoint")
if not self.config.region:
raise CustomException(msg="OSS 存储源必须配置 region(V4 签名要求,如 cn-hangzhou)")
if not self.config.username or not self.config.password:
raise CustomException(msg="OSS 存储源必须配置 AccessKeyId 与 AccessKeySecret")
cfg = oss.config.load_default()
cfg.credentials_provider = oss.credentials.StaticCredentialsProvider(
self.config.username,
self.config.password
)
cfg.region = self.config.region
cfg.endpoint = normalize_endpoint(self.config.endpoint, self.config.scheme)
adv = OssAdvancedConfig(**self.config.advanced_config)
cfg.connect_timeout = adv.connect_timeout
cfg.readwrite_timeout = adv.readwrite_timeout
cfg.retry_max_attempts = adv.retry_max_attempts
cfg.use_cname = adv.use_cname
cfg.use_path_style = adv.use_path_style
cfg.insecure_skip_verify = adv.insecure_skip_verify
cfg.use_internal_endpoint = adv.use_internal_endpoint
cfg.use_accelerate_endpoint = adv.use_accelerate_endpoint
cfg.proxy_host = adv.proxy_host
cfg.signature_version = adv.signature_version
cfg.disable_upload_crc64_check = adv.disable_upload_crc64_check
cfg.disable_download_crc64_check = adv.disable_download_crc64_check
cfg.enabled_redirect = adv.enabled_redirect
cfg.use_dualstack_endpoint = adv.use_dualstack_endpoint
self.client = oss.Client(cfg)
def _sync_test_connection(self) -> bool:
try:
self.client.get_bucket_info(oss.GetBucketInfoRequest(bucket=self.bucket_name))
return True
except oss.exceptions.ServiceError as e:
logger.warning(f"OSS 连接测试失败: {e.code} {e.message}")
return False
except Exception as e:
logger.warning(f"OSS 连接测试失败: {e}")
return False
def _sync_upload(self, local_path: str, remote_path: str) -> str:
try:
# 走 SDK 高层分片上传:普通小文件自动单次上传,大文件按端点分片参数并发分片;
# 流式传输(stream)时 part_size 为超大值,等效单次上传
part_size, concurrency, _ = self._multipart_settings()
self.client.uploader.upload_file(
oss.PutObjectRequest(bucket=self.bucket_name, key=remote_path),
local_path,
part_size=part_size,
parallel_num=concurrency,
)
except Exception as e:
raise CustomException(msg=f"OSS 上传失败: {e!s}")
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
try:
self.client.get_object_to_file(
oss.GetObjectRequest(bucket=self.bucket_name, key=remote_path),
local_path,
)
except Exception as e:
raise CustomException(msg=f"OSS 下载失败: {e!s}")
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self.client.delete_object(oss.DeleteObjectRequest(bucket=self.bucket_name, key=remote_path))
except Exception as e:
raise CustomException(msg=f"OSS 删除失败: {e!s}")
def _sync_exists(self, remote_path: str) -> bool:
try:
self.client.head_object(oss.HeadObjectRequest(bucket=self.bucket_name, key=remote_path))
return True
except oss.exceptions.ServiceError as e:
if e.status_code == 404:
return False
logger.warning(f"OSS 判断文件存在失败: {e.code} {e.message}")
return False
except Exception:
return False
@staticmethod
def _to_utc_dt(value: int | float | datetime | None) -> datetime | None:
"""兼容 SDK 返回的时间戳(int/float)与 datetime 两种类型。"""
if value is None:
return None
if isinstance(value, datetime):
return value.astimezone(UTC) if value.tzinfo else value.replace(tzinfo=UTC)
return datetime.fromtimestamp(value, tz=UTC)
def _sync_list(self, prefix: str) -> list[StorageObject]:
try:
paginator = self.client.list_objects_v2_paginator()
result: list[StorageObject] = []
for page in paginator.iter_page(oss.ListObjectsV2Request(bucket=self.bucket_name, prefix=prefix, delimiter="/")):
for common in page.common_prefixes or []:
raw_key = (common.prefix or "").rstrip("/")
result.append(StorageObject(name=raw_key.rsplit("/", 1)[-1], key=raw_key, is_dir=True))
for obj in page.contents or []:
raw_key = obj.key or ""
if raw_key == prefix:
continue
name = raw_key.rsplit("/", 1)[-1]
if not name: # key 以 "/" 结尾的目录占位对象,不按文件展示
continue
result.append(
StorageObject(
name=name,
key=raw_key,
is_dir=False,
size=obj.size,
modified_time=self._to_utc_dt(obj.last_modified),
)
)
return result
except Exception as e:
raise CustomException(msg=f"OSS 列表失败: {e!s}")
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(OSS continuation_token),翻页经前端回传游标。"""
try:
result = self.client.list_objects_v2(
oss.ListObjectsV2Request(
bucket=self.bucket_name,
prefix=prefix,
delimiter="/",
max_keys=page_size,
continuation_token=cursor or None,
)
)
truncated = bool(getattr(result, "is_truncated", False))
next_cursor = getattr(result, "next_continuation_token", None) or None
items: list[StorageObject] = []
for common in result.common_prefixes or []:
raw_key = (common.prefix or "").rstrip("/")
items.append(StorageObject(name=raw_key.rsplit("/", 1)[-1], key=raw_key, is_dir=True))
for obj in result.contents or []:
raw_key = obj.key or ""
if raw_key == prefix:
continue
name = raw_key.rsplit("/", 1)[-1]
if not name: # key 以 "/" 结尾的目录占位对象,不按文件展示
continue
items.append(
StorageObject(
name=name,
key=raw_key,
is_dir=False,
size=obj.size,
modified_time=self._to_utc_dt(obj.last_modified),
)
)
return StoragePage(items=items, has_next=truncated, next_cursor=next_cursor)
except Exception as e:
raise CustomException(msg=f"OSS 列表失败: {e!s}")
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
try:
paginator = self.client.list_buckets_paginator()
result: list[str] = []
for page in paginator.iter_page(oss.ListBucketsRequest()):
for b in page.buckets or []:
if b.name:
result.append(b.name)
return result
except Exception as e:
raise CustomException(msg=f"OSS 桶列表失败: {e!s}")
def _sync_get_url(self, remote_path: str, expire: int) -> str | None:
try:
result = self.client.presign(
oss.GetObjectRequest(bucket=self.bucket_name, key=remote_path),
expires=timedelta(seconds=expire),
)
return result.url
except Exception as e:
raise CustomException(msg=f"OSS 生成预签名 URL 失败: {e!s}")
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
def _list_all_keys(self, prefix: str) -> list[str]:
"""分页列举前缀下的全部对象 key。"""
keys: list[str] = []
paginator = self.client.list_objects_v2_paginator()
for page in paginator.iter_page(oss.ListObjectsV2Request(bucket=self.bucket_name, prefix=prefix)):
for obj in page.contents or []:
if obj.key:
keys.append(obj.key)
return keys
def _is_dir(self, key: str) -> bool:
"""判断 key 是否代表目录(存在占位对象或前缀下有任何对象)。"""
try:
page = next(
self.client.list_objects_v2_paginator().iter_page(
oss.ListObjectsV2Request(bucket=self.bucket_name, prefix=key.rstrip("/") + "/", max_keys=1)
)
)
return bool(page.contents or page.common_prefixes)
except Exception:
return False
def _sync_mkdir(self, remote_dir: str) -> None:
try:
self.client.put_object(oss.PutObjectRequest(bucket=self.bucket_name, key=remote_dir.rstrip("/") + "/", body=b""))
except Exception as e:
raise CustomException(msg=f"OSS 创建目录失败: {e!s}")
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(oss.DeleteObjectRequest(bucket=self.bucket_name, key=remote_dir.rstrip("/") + "/"))
except Exception as e:
raise CustomException(msg=f"OSS 删除目录失败: {e!s}")
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
try:
if self._is_dir(src):
self._sync_copy_dir(src, dst)
return
self.client.copy_object(
oss.CopyObjectRequest(bucket=self.bucket_name, key=dst, source_bucket=self.bucket_name, source_key=src)
)
except Exception as e:
raise CustomException(msg=f"OSS 复制失败: {e!s}")
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
if self._is_dir(src):
self._sync_move_dir(src, dst)
else:
self._sync_copy(src, dst)
self._sync_delete(src)
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"OSS 递归列表失败: {e!s}")
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
"""递归删除:一次列举全部对象并分批批量删除(含占位目录对象)。"""
try:
keys = self._list_all_keys(remote_dir)
marker = remote_dir.rstrip("/") + "/"
if marker not in keys:
keys.append(marker)
for i in range(0, len(keys), 1000):
self.client.delete_multiple_objects(
oss.DeleteMultipleObjectsRequest(
bucket=self.bucket_name,
objects=[oss.DeleteObject(key=k) for k in keys[i : i + 1000]],
quiet=True,
)
)
except Exception as e:
raise CustomException(msg=f"OSS 递归删除失败: {e!s}")
@@ -0,0 +1,306 @@
from typing import Any
import boto3
from boto3.s3.transfer import TransferConfig
from botocore.config import Config
from botocore.exceptions import ClientError
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.modules.workflow.core.base import (
BaseStorageAdapter,
S3AdvancedConfig,
StorageObject,
StoragePage,
StorageProtocol,
normalize_endpoint,
)
class S3StorageAdapter(BaseStorageAdapter):
"""S3 兼容对象存储适配器(boto3,同步调用经 asyncio.to_thread 包装)。"""
protocol = StorageProtocol.S3
def __init__(self, config) -> None:
super().__init__(config)
# 凭据为空时传空串而非 None,避免 boto3 走 EC2 实例元数据(IMDS)探测导致超时
if not self.config.username or not self.config.password:
raise CustomException(msg="S3 存储源必须配置 AccessKeyId 与 AccessKeySecret")
if not self.config.bucket:
raise CustomException(msg="S3 存储源必须配置 bucket")
adv = S3AdvancedConfig(**self.config.advanced_config)
proxies: dict[str, str] | None = None
if adv.proxies:
proxies = {"http": adv.proxies, "https": adv.proxies}
s3_options: dict[str, Any] = {"addressing_style": adv.addressing_style}
if adv.use_accelerate_endpoint:
s3_options["use_accelerate_endpoint"] = True
if adv.us_east_1_regional_endpoint:
s3_options["us_east_1_regional_endpoint"] = adv.us_east_1_regional_endpoint
config = Config(
signature_version=adv.signature_version,
retries={"max_attempts": adv.max_attempts, "mode": adv.retries_mode},
connect_timeout=adv.connect_timeout,
read_timeout=adv.read_timeout,
max_pool_connections=adv.max_pool_connections,
tcp_keepalive=adv.tcp_keepalive,
use_dualstack_endpoint=adv.use_dualstack_endpoint,
proxies=proxies,
parameter_validation=adv.parameter_validation,
request_checksum_calculation=adv.request_checksum_calculation,
response_checksum_validation=adv.response_checksum_validation,
s3=s3_options,
)
self.client = boto3.client(
"s3",
endpoint_url=normalize_endpoint(self.config.endpoint, self.config.scheme),
region_name=self.config.region,
aws_access_key_id=self.config.username or "",
aws_secret_access_key=self.config.password or "",
config=config,
)
def _sync_test_connection(self) -> bool:
try:
self.client.head_bucket(Bucket=self._require_bucket())
return True
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
# 403 表示凭据有效但无权限查看桶,连接本身是通的
if code == "403":
return True
logger.warning(f"S3 连接测试失败: {code} {e}")
return False
except Exception as e:
logger.warning(f"S3 连接测试失败: {e}")
return False
def _sync_upload(self, local_path: str, remote_path: str) -> str:
try:
# 按端点分片参数配置:超过分片大小阈值即分片并发上传(并发受内存预算护栏约束);
# 流式传输(stream)时阈值为超大值,等效单次上传
part_size, concurrency, _ = self._multipart_settings()
transfer_config = TransferConfig(
multipart_threshold=part_size,
multipart_chunksize=part_size,
max_concurrency=concurrency,
)
self.client.upload_file(local_path, self._require_bucket(), remote_path, Config=transfer_config)
except Exception as e:
raise CustomException(msg=f"S3 上传失败: {e!s}")
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
try:
self.client.download_file(self._require_bucket(), remote_path, local_path)
except Exception as e:
raise CustomException(msg=f"S3 下载失败: {e!s}")
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_path)
except Exception as e:
raise CustomException(msg=f"S3 删除失败: {e!s}")
def _sync_exists(self, remote_path: str) -> bool:
try:
self.client.head_object(Bucket=self._require_bucket(), Key=remote_path)
return True
except ClientError as e:
if e.response.get("ResponseMetadata", {}).get("HTTPStatusCode") == 404:
return False
logger.warning(f"S3 head_object 失败: {e}")
return False
except Exception:
return False
def _sync_list(self, prefix: str) -> list[StorageObject]:
"""列举目录层条目。S3 单次最多返回 1000 条,经 ContinuationToken 翻页拉全量。"""
try:
result: list[StorageObject] = []
seen_dirs: set[str] = set()
seen_files: set[str] = set()
token: str | None = None
while True:
kwargs: dict[str, Any] = {
"Bucket": self._require_bucket(),
"Prefix": prefix,
"Delimiter": "/",
}
if token:
kwargs["ContinuationToken"] = token
resp = self.client.list_objects_v2(**kwargs)
for cp in resp.get("CommonPrefixes", []):
raw_key = cp.get("Prefix", "").rstrip("/")
if raw_key in seen_dirs:
continue
seen_dirs.add(raw_key)
result.append(StorageObject(name=raw_key.rsplit("/", 1)[-1], key=raw_key, is_dir=True))
for obj in resp.get("Contents", []):
raw_key = obj.get("Key", "")
if raw_key == prefix:
continue
name = raw_key.rsplit("/", 1)[-1]
if not name: # key 以 "/" 结尾的目录占位对象,不按文件展示
continue
if raw_key in seen_files:
continue
seen_files.add(raw_key)
result.append(
StorageObject(
name=name,
key=raw_key,
is_dir=False,
size=obj.get("Size"),
modified_time=obj.get("LastModified"),
)
)
if resp.get("IsTruncated") and resp.get("NextContinuationToken"):
token = resp["NextContinuationToken"]
else:
break
return result
except Exception as e:
raise CustomException(msg=f"S3 列表失败: {e!s}")
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(S3 ContinuationToken),翻页经前端回传游标。"""
try:
kwargs: dict[str, Any] = {
"Bucket": self._require_bucket(),
"Prefix": prefix,
"Delimiter": "/",
"MaxKeys": page_size,
}
if cursor:
kwargs["ContinuationToken"] = cursor
resp = self.client.list_objects_v2(**kwargs)
items: list[StorageObject] = []
for cp in resp.get("CommonPrefixes", []):
raw_key = cp.get("Prefix", "").rstrip("/")
items.append(StorageObject(name=raw_key.rsplit("/", 1)[-1], key=raw_key, is_dir=True))
for obj in resp.get("Contents", []):
raw_key = obj.get("Key", "")
if raw_key == prefix:
continue
name = raw_key.rsplit("/", 1)[-1]
if not name: # key 以 "/" 结尾的目录占位对象,不按文件展示
continue
items.append(
StorageObject(
name=name,
key=raw_key,
is_dir=False,
size=obj.get("Size"),
modified_time=obj.get("LastModified"),
)
)
return StoragePage(
items=items,
has_next=bool(resp.get("IsTruncated")),
next_cursor=resp.get("NextContinuationToken"),
)
except Exception as e:
raise CustomException(msg=f"S3 列表失败: {e!s}")
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
try:
resp = self.client.list_buckets()
return [b.get("Name", "") for b in resp.get("Buckets", []) if b.get("Name")]
except Exception as e:
raise CustomException(msg=f"S3 桶列表失败: {e!s}")
def _sync_get_url(self, remote_path: str, expire: int) -> str:
try:
return self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self._require_bucket(), "Key": remote_path},
ExpiresIn=expire,
)
except Exception as e:
raise CustomException(msg=f"S3 生成预签名 URL 失败: {e!s}")
def _sync_close(self) -> None:
"""关闭 boto3 客户端连接。"""
close = getattr(self.client, "close", None)
if callable(close):
close()
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
def _list_all_keys(self, prefix: str) -> list[str]:
"""分页列举前缀下的全部对象 key。"""
keys: list[str] = []
paginator = self.client.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=self._require_bucket(), Prefix=prefix):
keys.extend(obj["Key"] for obj in page.get("Contents", []))
return keys
def _is_dir(self, key: str) -> bool:
"""判断 key 是否代表目录(存在占位对象或前缀下有任何对象)。"""
try:
resp = self.client.list_objects_v2(
Bucket=self._require_bucket(), Prefix=key.rstrip("/") + "/", MaxKeys=1
)
return bool(resp.get("Contents") or resp.get("CommonPrefixes"))
except Exception:
return False
def _sync_mkdir(self, remote_dir: str) -> None:
try:
self.client.put_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/", Body=b"")
except Exception as e:
raise CustomException(msg=f"S3 创建目录失败: {e!s}")
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/")
except Exception as e:
raise CustomException(msg=f"S3 删除目录失败: {e!s}")
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
try:
if self._is_dir(src):
self._sync_copy_dir(src, dst)
return
self.client.copy_object(
Bucket=self._require_bucket(),
CopySource={"Bucket": self._require_bucket(), "Key": src},
Key=dst,
)
except Exception as e:
raise CustomException(msg=f"S3 复制失败: {e!s}")
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
if self._is_dir(src):
self._sync_move_dir(src, dst)
else:
self._sync_copy(src, dst)
self._sync_delete(src)
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"S3 递归列表失败: {e!s}")
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
"""递归删除:一次列举全部对象并分批批量删除(含占位目录对象)。"""
try:
keys = self._list_all_keys(remote_dir)
marker = remote_dir.rstrip("/") + "/"
if marker not in keys:
keys.append(marker)
for i in range(0, len(keys), 1000):
self.client.delete_objects(
Bucket=self._require_bucket(),
Delete={"Objects": [{"Key": k} for k in keys[i : i + 1000]], "Quiet": True},
)
except Exception as e:
raise CustomException(msg=f"S3 递归删除失败: {e!s}")
@@ -0,0 +1,217 @@
"""SFTP 存储适配器
基于 paramiko。连接在首次使用时建立,并在适配器实例生命周期内复用
(避免每次操作都重新进行 SSH 握手,与 FTP/FTPS 客户端保持一致);
同步操作经基类 asyncio.to_thread 包装,不阻塞事件循环。
用完由调用方调用 close() 释放连接。
"""
import os
import stat
from datetime import UTC, datetime
import paramiko
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.modules.workflow.core.base import BaseStorageAdapter, SftpAdvancedConfig, StorageObject, StorageProtocol
class SftpStorageAdapter(BaseStorageAdapter):
"""SFTP 存储适配器(paramiko,同步调用经 asyncio.to_thread 包装)。"""
protocol = StorageProtocol.SFTP
# SFTP 通道复用同一连接,不支持多线程并发读写
concurrency_safe: bool = False
def __init__(self, config) -> None:
super().__init__(config)
self._ssh: paramiko.SSHClient | None = None
self._sftp: paramiko.SFTPClient | None = None
self._adv = SftpAdvancedConfig(**self.config.advanced_config)
# ── 连接管理 ──────────────────────────────────────────────────
def _connect(self) -> paramiko.SFTPClient:
"""建立并复用 SFTP 连接:首次使用时连接,之后直接复用。"""
if self._sftp is not None:
return self._sftp
if not self.config.username or not self.config.password:
raise CustomException(msg="SFTP 存储源必须配置用户名与密码")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(
hostname=self.config.host,
port=self.config.port,
username=self.config.username or "",
password=self.config.password or "",
timeout=self._adv.connect_timeout,
banner_timeout=self._adv.banner_timeout,
auth_timeout=self._adv.auth_timeout,
channel_timeout=self._adv.channel_timeout,
allow_agent=self._adv.allow_agent,
look_for_keys=self._adv.look_for_keys,
compress=self._adv.compress,
)
transport = ssh.get_transport()
if transport is None or not transport.is_active():
raise RuntimeError("SSH 连接已断开")
if self._adv.keepalive_interval > 0:
transport.set_keepalive(self._adv.keepalive_interval)
sftp = ssh.open_sftp()
# 给 SFTP 通道设置超时,防止服务器不响应时 listdir/stat 等操作无限阻塞
channel = sftp.get_channel()
if channel:
channel.settimeout(self._adv.connect_timeout)
except Exception:
ssh.close()
raise
self._ssh = ssh
self._sftp = sftp
return sftp
@staticmethod
def _ensure_remote_dir(client: paramiko.SFTPClient, remote_dir: str) -> None:
"""递归创建远端目录(mkdir -p)。"""
parts = [p for p in remote_dir.split("/") if p]
current = ""
for part in parts:
current = f"{current}/{part}" if current else part
try:
client.stat(current)
except FileNotFoundError:
client.mkdir(current)
except OSError:
pass
def _sync_close(self) -> None:
"""关闭 SFTP 通道与 SSH 连接。"""
if self._sftp:
try:
self._sftp.close()
except Exception:
pass
self._sftp = None
if self._ssh:
try:
self._ssh.close()
except Exception:
pass
self._ssh = None
# ── 同步协议操作 ──────────────────────────────────────────────
def _sync_test_connection(self) -> bool:
try:
self._connect().listdir(".")
return True
except Exception as e:
logger.warning(f"SFTP 连接测试失败: {e}")
return False
def _sync_upload(self, local_path: str, remote_path: str) -> str:
client = self._connect()
try:
dir_part, _ = remote_path.rsplit("/", 1) if "/" in remote_path else ("", remote_path)
if dir_part:
self._ensure_remote_dir(client, dir_part)
client.put(local_path, remote_path)
except Exception as e:
raise CustomException(msg=f"SFTP 上传失败: {e!s}")
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
"""下载,支持断点续传:本地已有部分文件时从偏移处继续。"""
client = self._connect()
try:
remote_size = client.stat(remote_path).st_size or 0
local_size = os.path.getsize(local_path) if os.path.exists(local_path) else 0
if local_size > remote_size:
# 本地文件比远端还大(可能内容不一致),整文件重新下载
os.remove(local_path)
local_size = 0
if local_size == remote_size:
return local_path # 已完整下载,跳过
if local_size:
with client.open(remote_path, "rb") as rf, open(local_path, "ab") as lf:
rf.seek(local_size)
while chunk := rf.read(1024 * 1024):
lf.write(chunk)
else:
client.get(remote_path, local_path)
except Exception as e:
raise CustomException(msg=f"SFTP 下载失败: {e!s}")
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self._connect().remove(remote_path)
except Exception as e:
raise CustomException(msg=f"SFTP 删除失败: {e!s}")
def _sync_exists(self, remote_path: str) -> bool:
try:
self._connect().stat(remote_path)
return True
except (FileNotFoundError, OSError):
return False
def _sync_list(self, prefix: str) -> list[StorageObject]:
try:
# 空 prefix 表示浏览根目录:SFTP 用 "." 表示登录后的当前目录
attrs = self._connect().listdir_attr(prefix or ".")
except Exception as e:
raise CustomException(msg=f"SFTP 列表失败: {e!s}")
result: list[StorageObject] = []
for attr in attrs:
result.append(
StorageObject(
name=attr.filename,
key=f"{prefix}/{attr.filename}" if prefix else attr.filename,
is_dir=bool(attr.st_mode and stat.S_ISDIR(attr.st_mode)),
size=attr.st_size,
modified_time=datetime.fromtimestamp(attr.st_mtime, tz=UTC) if attr.st_mtime else None,
)
)
return result
# ── 目录操作(SFTP 原生支持目录重命名,复制则流式传输)────────────
def _is_dir(self, path: str) -> bool:
try:
attr = self._connect().stat(path)
return bool(attr.st_mode and stat.S_ISDIR(attr.st_mode))
except (FileNotFoundError, OSError):
return False
def _sync_mkdir(self, remote_dir: str) -> None:
try:
self._ensure_remote_dir(self._connect(), remote_dir)
except Exception as e:
raise CustomException(msg=f"SFTP 创建目录失败: {e!s}")
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self._connect().rmdir(remote_dir)
except Exception as e:
raise CustomException(msg=f"SFTP 删除目录失败: {e!s}")
def _sync_rename(self, src: str, dst: str) -> None:
try:
self._connect().posix_rename(src, dst)
except Exception as e:
raise CustomException(msg=f"SFTP 重命名失败: {e!s}")
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用通道流式复制(SFTP 无服务端复制)。"""
client = self._connect()
try:
if self._is_dir(src):
self._sync_copy_dir(src, dst)
return
with client.open(src, "rb") as rf, client.open(dst, "wb") as wf:
while chunk := rf.read(1024 * 1024):
wf.write(chunk)
except Exception as e:
raise CustomException(msg=f"SFTP 复制失败: {e!s}")