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, global_vars from app.log import logger from app.modules.filemanager import StorageBase from app.modules.filemanager.storages import transfer_process from app.schemas.types import StorageSchema from app.utils.singleton import WeakSingleton lock = threading.Lock() class SMBConnectionError(Exception): """ SMB 连接错误 """ pass class SMB(StorageBase, metaclass=WeakSingleton): """ SMB网络挂载存储相关操作 - 使用 smbclient 高级接口 """ # 存储类型 schema = StorageSchema.SMB # 支持的整理方式 transtype = { "move": "移动", "copy": "复制", "link": "硬链接", } # 文件块大小,默认10MB chunk_size = 10 * 1024 * 1024 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, share]): logger.error("【SMB】缺少必要的连接参数:host 和 share") 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=False, # 匿名访问时可能需要关闭安全协商 ) # 注册会话以启用连接池 register_session( self._host, username=self._username, password=self._password, port=port, encrypt=False, # 根据需要启用加密 connection_timeout=60, ) # 测试连接 self._test_connection() self._connected = True # 判断是否为匿名访问 if self._is_anonymous_access(): logger.info(f"【SMB】匿名连接成功:{self._server_path}") else: logger.info( f"【SMB】认证连接成功:{self._server_path} (用户:{self._username})" ) 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 _is_anonymous_access(self) -> bool: """ 检查是否为匿名访问 """ return not self._username and not self._password 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("/")) logger.info(f"【SMB】开始删除: {fileitem.path} (类型: {fileitem.type})") # 先检查路径是否存在 if not smbclient.path.exists(smb_path): logger.warn(f"【SMB】路径不存在,跳过删除: {fileitem.path}") return True if fileitem.type == "dir": # 递归删除目录及其内容 logger.debug(f"【SMB】递归删除目录: {smb_path}") self._recursive_delete(smb_path) else: # 删除文件 logger.debug(f"【SMB】删除文件: {smb_path}") smbclient.remove(smb_path) logger.info(f"【SMB】删除成功: {fileitem.path}") return True except SMBConnectionError as e: logger.error(f"【SMB】删除失败 - 连接错误: {fileitem.path} - {e}") return False except SMBResponseException as e: logger.error(f"【SMB】删除失败 - SMB响应错误: {fileitem.path} - {e}") return False except SMBException as e: logger.error(f"【SMB】删除失败 - SMB错误: {fileitem.path} - {e}") return False except Exception as e: logger.error(f"【SMB】删除失败 - 未知错误: {fileitem.path} - {e}") return False def _recursive_delete(self, smb_path: str): """ 递归删除目录及其所有内容 """ try: # 检查路径是否存在 if not smbclient.path.exists(smb_path): logger.debug(f"【SMB】路径不存在,跳过删除: {smb_path}") return # 如果是文件,直接删除 if smbclient.path.isfile(smb_path): logger.debug(f"【SMB】删除文件: {smb_path}") smbclient.remove(smb_path) return # 如果是目录,先删除其内容 if smbclient.path.isdir(smb_path): logger.debug(f"【SMB】开始删除目录内容: {smb_path}") try: # 列出目录内容 entries = smbclient.listdir(smb_path) logger.debug(f"【SMB】目录 {smb_path} 包含 {len(entries)} 个项目") for entry in entries: if entry in [".", ".."]: continue entry_path = f"{smb_path}\\{entry}" logger.debug(f"【SMB】递归删除子项: {entry_path}") # 递归删除子项 self._recursive_delete(entry_path) # 删除空目录 logger.debug(f"【SMB】删除空目录: {smb_path}") smbclient.rmdir(smb_path) logger.debug(f"【SMB】目录删除成功: {smb_path}") except SMBResponseException as e: # 如果目录不为空,尝试强制删除 logger.warn(f"【SMB】目录不为空,尝试强制删除: {smb_path} - {e}") # 使用remove方法尝试删除(某些SMB服务器支持) try: smbclient.remove(smb_path) logger.info(f"【SMB】强制删除目录成功: {smb_path}") except Exception as remove_error: # 如果还是失败,记录错误并抛出异常 logger.error( f"【SMB】无法删除非空目录: {smb_path} - {remove_error}" ) raise SMBConnectionError( f"无法删除非空目录 {smb_path}: {remove_error}" ) except SMBException as e: logger.error(f"【SMB】SMB操作失败: {smb_path} - {e}") raise SMBConnectionError(f"SMB操作失败 {smb_path}: {e}") except SMBConnectionError: # 重新抛出SMB连接错误 raise except Exception as e: logger.error(f"【SMB】递归删除失败: {smb_path} - {e}") raise SMBConnectionError(f"递归删除失败 {smb_path}: {e}") 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]: """ 带实时进度显示的下载 """ local_path = (path or settings.TEMP_PATH) / fileitem.name smb_path = self._normalize_path(fileitem.path) try: self._check_connection() # 确保本地目录存在 local_path.parent.mkdir(parents=True, exist_ok=True) # 获取文件大小 file_size = fileitem.size # 初始化进度条 logger.info(f"【SMB】开始下载: {fileitem.name} -> {local_path}") progress_callback = transfer_process(Path(fileitem.path).as_posix()) # 使用更高效的文件传输方式 with smbclient.open_file(smb_path, mode="rb") as src_file: with open(local_path, "wb") as dst_file: downloaded_size = 0 while True: if global_vars.is_transfer_stopped(fileitem.path): logger.info(f"【SMB】{fileitem.path} 下载已取消!") return None chunk = src_file.read(self.chunk_size) if not chunk: break dst_file.write(chunk) downloaded_size += len(chunk) # 更新进度 if file_size: progress = (downloaded_size * 100) / file_size progress_callback(progress) # 完成下载 progress_callback(100) logger.info(f"【SMB】下载完成: {fileitem.name}") return local_path except Exception as e: logger.error(f"【SMB】下载失败: {fileitem.name} - {e}") # 删除可能部分下载的文件 if local_path.exists(): local_path.unlink() return None def upload( self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None ) -> Optional[schemas.FileItem]: """ 带实时进度显示的上传 """ target_name = new_name or path.name target_path = Path(fileitem.path) / target_name smb_path = self._normalize_path(str(target_path)) try: self._check_connection() # 获取文件大小 file_size = path.stat().st_size # 初始化进度条 logger.info(f"【SMB】开始上传: {path} -> {target_path}") progress_callback = transfer_process(path.as_posix()) # 使用更高效的文件传输方式 with open(path, "rb") as src_file: with smbclient.open_file(smb_path, mode="wb") as dst_file: uploaded_size = 0 while True: if global_vars.is_transfer_stopped(path.as_posix()): logger.info(f"【SMB】{path} 上传已取消!") return None chunk = src_file.read(self.chunk_size) if not chunk: break dst_file.write(chunk) uploaded_size += len(chunk) # 更新进度 if file_size: progress = (uploaded_size * 100) / file_size progress_callback(progress) # 完成上传 progress_callback(100) logger.info(f"【SMB】上传完成: {target_name}") # 返回上传后的文件信息 return self.get_item(target_path) except Exception as e: logger.error(f"【SMB】上传失败: {target_name} - {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: """ 硬链接文件 Samba服务器需要开启 unix extensions 支持 """ try: self._check_connection() src_path = self._normalize_path(fileitem.path) dst_path = self._normalize_path(target_file) # 检查源文件是否存在 if not smbclient.path.exists(src_path): raise FileNotFoundError(f"源文件不存在: {src_path}") # 确保目标路径的父目录存在 dst_parent = "\\".join(dst_path.rsplit("\\", 1)[:-1]) if dst_parent and not smbclient.path.exists(dst_parent): logger.info(f"【SMB】创建目标目录: {dst_parent}") smbclient.makedirs(dst_parent, exist_ok=True) # 尝试创建硬链接 smbclient.link(src_path, dst_path) logger.info(f"【SMB】硬链接创建成功: {src_path} -> {dst_path}") return True except SMBResponseException as e: # SMB协议错误,可能不支持硬链接 logger.error(f"【SMB】创建硬链接失败(当前Samba服务器可能不支持硬链接): {e}") return False except Exception as e: logger.error(f"【SMB】创建硬链接失败: {e}") return False def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def usage(self) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ try: self._check_connection() volume_stat = smbclient.stat_volume(self._server_path) return schemas.StorageUsage( total=volume_stat.total_size, available=volume_stat.caller_available_size, ) except Exception as e: logger.error(f"【SMB】获取存储使用情况失败: {e}") return None def __del__(self): """ 析构函数,清理连接 """ try: if self._connected: reset_connection_cache() except Exception as e: logger.debug(f"【SMB】清理连接失败: {e}")