From 97cbd816fefb180c8614cb27ec87e53350d0bb15 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 3 Jul 2025 12:31:59 +0800 Subject: [PATCH] add SMB Storage --- app/modules/filemanager/storages/alist.py | 4 +- app/modules/filemanager/storages/smb.py | 566 ++++++++++++++++++++++ app/schemas/types.py | 1 + requirements.in | 1 + 4 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 app/modules/filemanager/storages/smb.py diff --git a/app/modules/filemanager/storages/alist.py b/app/modules/filemanager/storages/alist.py index a356c490..53972338 100644 --- a/app/modules/filemanager/storages/alist.py +++ b/app/modules/filemanager/storages/alist.py @@ -38,7 +38,7 @@ class Alist(StorageBase, metaclass=Singleton): """ 初始化 """ - pass + self.__generate_token.clear_cache() @property def __get_base_url(self) -> str: @@ -127,7 +127,7 @@ class Alist(StorageBase, metaclass=Singleton): """ 检查存储是否可用 """ - pass + return True if self.__generate_token else False def list( self, diff --git a/app/modules/filemanager/storages/smb.py b/app/modules/filemanager/storages/smb.py new file mode 100644 index 00000000..1aba76a0 --- /dev/null +++ b/app/modules/filemanager/storages/smb.py @@ -0,0 +1,566 @@ +import threading +import time +from pathlib import Path +from typing import List, Optional, Union + +import smbclient +from smbclient import ClientConfig, register_session, reset_connection_cache +from smbprotocol.exceptions import SMBException, SMBResponseException, SMBAuthenticationError + +from app import schemas +from app.core.config import settings +from app.log import logger +from app.modules.filemanager import StorageBase +from app.schemas.types import StorageSchema +from app.utils.singleton import Singleton + +lock = threading.Lock() + + +class SMBConnectionError(Exception): + """SMB 连接错误""" + pass + + +class SMB(StorageBase, metaclass=Singleton): + """ + SMB网络挂载存储相关操作 - 使用 smbclient 高级接口 + """ + + # 存储类型 + schema = StorageSchema.SMB + + # 支持的整理方式 + transtype = { + "move": "移动", + "copy": "复制", + } + + def __init__(self): + super().__init__() + self._connected = False + self._server_path = None + self._host = None + self._username = None + self._password = None + self._init_connection() + + def _init_connection(self): + """ + 初始化SMB连接配置 + """ + try: + conf = self.get_conf() + if not conf: + return + + self._host = conf.get("host") + self._username = conf.get("username") + self._password = conf.get("password") + domain = conf.get("domain", "") + share = conf.get("share", "") + port = conf.get("port", 445) + + if not all([self._host, self._username, self._password, share]): + logger.error("【SMB】缺少必要的连接参数") + return + + # 构建服务器路径 + self._server_path = f"\\\\{self._host}\\{share}" + + # 配置全局客户端设置 + ClientConfig( + username=self._username, + password=self._password, + domain=domain if domain else None, + connection_timeout=60, + port=port, + auth_protocol="negotiate", # 使用协商认证 + require_secure_negotiate=True + ) + + # 注册会话以启用连接池 + register_session( + self._host, + username=self._username, + password=self._password, + port=port, + encrypt=False, # 根据需要启用加密 + connection_timeout=60 + ) + + # 测试连接 + self._test_connection() + + self._connected = True + logger.info(f"【SMB】连接成功:{self._server_path}") + + except Exception as e: + logger.error(f"【SMB】连接初始化失败:{e}") + self._connected = False + + def _test_connection(self): + """ + 测试SMB连接 + """ + try: + # 尝试列出根目录来测试连接 + smbclient.listdir(self._server_path) + except SMBAuthenticationError as e: + raise SMBConnectionError(f"SMB认证失败:{e}") + except SMBResponseException as e: + raise SMBConnectionError(f"SMB响应错误:{e}") + except SMBException as e: + raise SMBConnectionError(f"SMB连接错误:{e}") + except Exception as e: + raise SMBConnectionError(f"连接测试失败:{e}") + + def _check_connection(self): + """ + 检查SMB连接状态 + """ + if not self._connected or not self._server_path: + raise SMBConnectionError("【SMB】连接未建立或已断开,请检查配置!") + + def _normalize_path(self, path: Union[str, Path]) -> str: + """ + 标准化路径格式为SMB路径 + """ + path_str = str(path) + + # 处理根路径 + if path_str in ["/", "\\"]: + return self._server_path + + # 去除前导斜杠 + if path_str.startswith("/"): + path_str = path_str[1:] + + # 构建完整的SMB路径 + if path_str: + return f"{self._server_path}\\{path_str.replace('/', '\\')}" + else: + return self._server_path + + def _create_fileitem(self, stat_result, file_path: str, name: str) -> schemas.FileItem: + """ + 创建文件项 + """ + try: + # 检查是否为目录 + is_directory = smbclient.path.isdir(file_path) + + # 处理路径 + relative_path = file_path.replace(self._server_path, "").replace("\\", "/") + if not relative_path.startswith("/"): + relative_path = "/" + relative_path + + if is_directory and not relative_path.endswith("/"): + relative_path += "/" + + # 获取时间戳 + try: + modify_time = int(stat_result.st_mtime) + except (AttributeError, TypeError): + modify_time = int(time.time()) + + if is_directory: + return schemas.FileItem( + storage=self.schema.value, + type="dir", + path=relative_path, + name=name, + basename=name, + modify_time=modify_time + ) + else: + return schemas.FileItem( + storage=self.schema.value, + type="file", + path=relative_path, + name=name, + basename=Path(name).stem, + extension=Path(name).suffix[1:] if Path(name).suffix else None, + size=getattr(stat_result, 'st_size', 0), + modify_time=modify_time + ) + except Exception as e: + logger.error(f"【SMB】创建文件项失败:{e}") + # 返回基本的文件项信息 + return schemas.FileItem( + storage=self.schema.value, + type="file", + path=file_path.replace(self._server_path, "").replace("\\", "/"), + name=name, + basename=Path(name).stem, + modify_time=int(time.time()) + ) + + def init_storage(self): + """ + 初始化存储 + """ + # 重置连接缓存 + reset_connection_cache() + self._init_connection() + + def check(self) -> bool: + """ + 检查存储是否可用 + """ + if not self._connected: + return False + + try: + self._test_connection() + return True + except Exception as e: + logger.debug(f"【SMB】连接检查失败:{e}") + self._connected = False + return False + + def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: + """ + 浏览文件 + """ + try: + self._check_connection() + + if fileitem.type == "file": + item = self.detail(fileitem) + if item: + return [item] + return [] + + # 构建SMB路径 + smb_path = self._normalize_path(fileitem.path.rstrip("/")) + + # 列出目录内容 + try: + entries = smbclient.listdir(smb_path) + except SMBResponseException as e: + logger.error(f"【SMB】列出目录失败: {smb_path} - {e}") + return [] + except SMBException as e: + logger.error(f"【SMB】列出目录失败: {smb_path} - {e}") + return [] + + items = [] + for entry in entries: + if entry in [".", ".."]: + continue + + entry_path = f"{smb_path}\\{entry}" + try: + stat_result = smbclient.stat(entry_path) + item = self._create_fileitem(stat_result, entry_path, entry) + items.append(item) + except Exception as e: + logger.debug(f"【SMB】获取文件信息失败: {entry_path} - {e}") + continue + + return items + except Exception as e: + logger.error(f"【SMB】列出文件失败: {e}") + return [] + + def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: + """ + 创建目录 + """ + try: + self._check_connection() + + parent_path = self._normalize_path(fileitem.path.rstrip("/")) + new_path = f"{parent_path}\\{name}" + + # 创建目录 + smbclient.mkdir(new_path) + + # 返回创建的目录信息 + return schemas.FileItem( + storage=self.schema.value, + type="dir", + path=f"{fileitem.path.rstrip('/')}/{name}/", + name=name, + basename=name, + modify_time=int(time.time()) + ) + except Exception as e: + logger.error(f"【SMB】创建目录失败: {e}") + return None + + def get_folder(self, path: Path) -> Optional[schemas.FileItem]: + """ + 获取目录,如目录不存在则创建 + """ + # 检查目录是否存在 + folder = self.get_item(path) + if folder: + return folder + + # 逐级创建目录 + parts = path.parts + current_path = Path("/") + + for part in parts[1:]: # 跳过根目录 + current_path = current_path / part + folder = self.get_item(current_path) + if not folder: + parent_folder = self.get_item(current_path.parent) + if not parent_folder: + logger.error(f"【SMB】父目录不存在: {current_path.parent}") + return None + folder = self.create_folder(parent_folder, part) + if not folder: + return None + + return folder + + def get_item(self, path: Path) -> Optional[schemas.FileItem]: + """ + 获取文件或目录,不存在返回None + """ + try: + self._check_connection() + + # 处理根目录 + if str(path) == "/": + return schemas.FileItem( + storage=self.schema.value, + type="dir", + path="/", + name="", + basename="", + modify_time=int(time.time()) + ) + + smb_path = self._normalize_path(str(path).rstrip("/")) + + # 检查路径是否存在 + if not smbclient.path.exists(smb_path): + return None + + stat_result = smbclient.stat(smb_path) + file_name = Path(path).name + + return self._create_fileitem(stat_result, smb_path, file_name) + + except Exception as e: + logger.debug(f"【SMB】获取文件项失败: {e}") + return None + + def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: + """ + 获取文件详情 + """ + return self.get_item(Path(fileitem.path)) + + def delete(self, fileitem: schemas.FileItem) -> bool: + """ + 删除文件或目录 + """ + try: + self._check_connection() + + smb_path = self._normalize_path(fileitem.path.rstrip("/")) + + if fileitem.type == "dir": + # 删除目录 + smbclient.rmdir(smb_path) + else: + # 删除文件 + smbclient.remove(smb_path) + + logger.info(f"【SMB】删除成功: {fileitem.path}") + return True + except Exception as e: + logger.error(f"【SMB】删除失败: {e}") + return False + + def rename(self, fileitem: schemas.FileItem, name: str) -> bool: + """ + 重命名文件 + """ + try: + self._check_connection() + + old_path = self._normalize_path(fileitem.path.rstrip("/")) + parent_path = Path(fileitem.path).parent + new_path = self._normalize_path(str(parent_path / name)) + + # 重命名 + smbclient.rename(old_path, new_path) + + logger.info(f"【SMB】重命名成功: {fileitem.path} -> {name}") + return True + except Exception as e: + logger.error(f"【SMB】重命名失败: {e}") + return False + + def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: + """ + 下载文件 + """ + try: + self._check_connection() + + smb_path = self._normalize_path(fileitem.path) + local_path = path or settings.TEMP_PATH / fileitem.name + + # 确保本地目录存在 + local_path.parent.mkdir(parents=True, exist_ok=True) + + # 使用更高效的文件传输方式 + with smbclient.open_file(smb_path, mode="rb") as src_file: + with open(local_path, "wb") as dst_file: + # 使用更大的缓冲区提高性能 + buffer_size = 1024 * 1024 # 1MB + while True: + chunk = src_file.read(buffer_size) + if not chunk: + break + dst_file.write(chunk) + + logger.info(f"【SMB】下载成功: {fileitem.path} -> {local_path}") + return local_path + except Exception as e: + logger.error(f"【SMB】下载失败: {e}") + return None + + def upload(self, fileitem: schemas.FileItem, path: Path, + new_name: Optional[str] = None) -> Optional[schemas.FileItem]: + """ + 上传文件 + """ + try: + self._check_connection() + + target_name = new_name or path.name + target_path = Path(fileitem.path) / target_name + smb_path = self._normalize_path(str(target_path)) + + # 使用更高效的文件传输方式 + with open(path, "rb") as src_file: + with smbclient.open_file(smb_path, mode="wb") as dst_file: + # 使用更大的缓冲区提高性能 + buffer_size = 1024 * 1024 # 1MB + while True: + chunk = src_file.read(buffer_size) + if not chunk: + break + dst_file.write(chunk) + + logger.info(f"【SMB】上传成功: {path} -> {target_path}") + + # 返回上传后的文件信息 + return self.get_item(target_path) + except Exception as e: + logger.error(f"【SMB】上传失败: {e}") + return None + + def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + """ + 复制文件 + """ + try: + # 下载到临时文件 + temp_file = self.download(fileitem) + if not temp_file: + return False + + # 获取目标目录 + target_folder = self.get_item(path) + if not target_folder: + return False + + # 上传到目标位置 + result = self.upload(target_folder, temp_file, new_name) + + # 删除临时文件 + if temp_file.exists(): + temp_file.unlink() + + return result is not None + except Exception as e: + logger.error(f"【SMB】复制失败: {e}") + return False + + def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: + """ + 移动文件 + """ + try: + # 先复制 + if not self.copy(fileitem, path, new_name): + return False + + # 再删除原文件 + if not self.delete(fileitem): + logger.warn(f"【SMB】删除原文件失败: {fileitem.path}") + return False + + return True + except Exception as e: + logger.error(f"【SMB】移动失败: {e}") + return False + + def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + """ + 硬链接文件(SMB不支持,返回False) + """ + logger.warn("【SMB】不支持硬链接操作") + return False + + def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: + """ + 软链接文件(SMB不支持,返回False) + """ + logger.warn("【SMB】不支持软链接操作") + return False + + def usage(self) -> Optional[schemas.StorageUsage]: + """ + 存储使用情况 + """ + try: + self._check_connection() + + # 获取磁盘使用情况 + try: + stat_result = smbclient.stat(self._server_path) + + if hasattr(stat_result, 'f_frsize') and hasattr(stat_result, 'f_blocks'): + block_size = stat_result.f_frsize + total_blocks = stat_result.f_blocks + free_blocks = stat_result.f_bavail + + total = total_blocks * block_size + available = free_blocks * block_size + + return schemas.StorageUsage( + total=total, + available=available + ) + except Exception as e: + logger.debug(f"【SMB】获取磁盘使用情况失败: {e}") + # 返回默认值 + return schemas.StorageUsage( + total=0, + available=0 + ) + except Exception as e: + logger.error(f"【SMB】获取存储使用情况失败: {e}") + + return None + + def __del__(self): + """ + 析构函数,清理连接 + """ + try: + # smbclient 自动管理连接池,但我们可以重置缓存 + if hasattr(self, '_connected') and self._connected: + reset_connection_cache() + except Exception as e: + logger.debug(f"【SMB】清理连接失败: {e}") diff --git a/app/schemas/types.py b/app/schemas/types.py index 4235be22..75191cd5 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -290,6 +290,7 @@ class StorageSchema(Enum): U115 = "u115" Rclone = "rclone" Alist = "alist" + SMB = "smb" # 模块类型 diff --git a/requirements.in b/requirements.in index 5965cf0d..9ffb623b 100644 --- a/requirements.in +++ b/requirements.in @@ -69,3 +69,4 @@ oss2~=2.19.1 tqdm~=4.67.1 setuptools~=78.1.0 pympler~=1.1 +smbprotocol~=1.15.0