From a5bfd778a349e3177489e2242832a26ca1b1bc24 Mon Sep 17 00:00:00 2001 From: sladro Date: Wed, 4 Feb 2026 11:16:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E7=AE=A1=E7=90=86=E5=99=A8=E3=80=81?= =?UTF-8?q?CAD=E8=BD=AF=E4=BB=B6=E9=85=8D=E7=BD=AE=E5=8F=8A=E5=88=9D?= =?UTF-8?q?=E5=A7=8BAPI=E7=AB=AF=E7=82=B9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/files.py | 199 +++++++ app/api/v1/websocket.py | 263 +++++++++ app/config.py | 12 + app/core/rename_manager.py | 366 ++++++++++++ app/main.py | 4 +- configs/software_config.yaml | 4 + websocket-file-api-docs.md | 1007 ++++++++++++++++++++++++++++++++++ 7 files changed, 1854 insertions(+), 1 deletion(-) create mode 100644 app/api/v1/files.py create mode 100644 app/core/rename_manager.py create mode 100644 websocket-file-api-docs.md diff --git a/app/api/v1/files.py b/app/api/v1/files.py new file mode 100644 index 0000000..28ba336 --- /dev/null +++ b/app/api/v1/files.py @@ -0,0 +1,199 @@ +""" +文件管理API路由 +提供CAD文件列表和下载功能 +""" +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse, StreamingResponse +from typing import List +from pathlib import Path +import os +import zipfile +import io +from app.config import settings, software_config + +router = APIRouter() + +# CAD文件扩展名映射 +CAD_EXTENSIONS = { + 'creo': ['.prt', '.asm', '.drw'], # Creo文件,包括.prt.1等版本文件 + 'pdms': ['.rvm', '.dri'], # PDMS文件 + 'revit': ['.rvt', '.rfa', '.rte'] # Revit文件 +} + + +def get_cad_files_path() -> Path: + """获取CAD文件存储路径""" + cad_path = software_config.get_cad_files_path() + path = Path(cad_path) + + if not path.exists(): + raise HTTPException(status_code=404, detail=f"CAD文件路径不存在: {cad_path}") + + return path + + +def is_cad_file(filename: str) -> bool: + """判断文件是否为CAD文件(包括版本号后缀)""" + filename_lower = filename.lower() + + # 检查所有CAD扩展名 + for software, extensions in CAD_EXTENSIONS.items(): + for ext in extensions: + # 检查标准扩展名 + if filename_lower.endswith(ext): + return True + # 检查带版本号的扩展名(如 .prt.1, .prt.2 等) + if ext in filename_lower and '.' in filename_lower.split(ext)[-1]: + # 验证版本号部分是否为数字 + version_part = filename_lower.split(ext)[-1] + if version_part.startswith('.') and version_part[1:].isdigit(): + return True + + return False + + +def scan_cad_files(base_path: Path) -> List[dict]: + """扫描目录下的所有CAD文件""" + cad_files = [] + + try: + for root, dirs, files in os.walk(base_path): + for file in files: + if is_cad_file(file): + file_path = Path(root) / file + relative_path = file_path.relative_to(base_path) + + # 获取文件信息 + stat = file_path.stat() + + cad_files.append({ + 'filename': file, + 'relative_path': str(relative_path).replace('\\', '/'), + 'absolute_path': str(file_path), + 'size': stat.st_size, + 'modified_time': stat.st_mtime, + 'extension': get_file_extension(file) + }) + except Exception as e: + raise HTTPException(status_code=500, detail=f"扫描文件失败: {str(e)}") + + return cad_files + + +def get_file_extension(filename: str) -> str: + """获取文件扩展名(包括版本号)""" + filename_lower = filename.lower() + + # 检查是否有版本号 + for software, extensions in CAD_EXTENSIONS.items(): + for ext in extensions: + if ext in filename_lower: + idx = filename_lower.find(ext) + return filename[idx:] + + # 如果没有匹配,返回标准扩展名 + return Path(filename).suffix + + +@router.get("/files/list") +async def list_cad_files(): + """ + 获取CAD文件列表 + 返回所有Creo、PDMS、Revit格式的文件 + """ + base_path = get_cad_files_path() + files = scan_cad_files(base_path) + + return { + 'success': True, + 'base_path': str(base_path), + 'total_count': len(files), + 'files': files + } + + +@router.get("/files/download/{file_path:path}") +async def download_file(file_path: str): + """ + 下载单个文件 + + Args: + file_path: 文件的相对路径 + """ + base_path = get_cad_files_path() + full_path = base_path / file_path + + # 安全检查:确保文件在允许的目录内 + try: + full_path = full_path.resolve() + base_path = base_path.resolve() + + if not str(full_path).startswith(str(base_path)): + raise HTTPException(status_code=403, detail="访问被拒绝") + except Exception: + raise HTTPException(status_code=400, detail="无效的文件路径") + + # 检查文件是否存在 + if not full_path.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + + if not full_path.is_file(): + raise HTTPException(status_code=400, detail="不是有效的文件") + + # 检查是否为CAD文件 + if not is_cad_file(full_path.name): + raise HTTPException(status_code=403, detail="只能下载CAD文件") + + return FileResponse( + path=str(full_path), + filename=full_path.name, + media_type='application/octet-stream' + ) + + +@router.post("/files/download/batch") +async def download_batch_files(file_paths: List[str]): + """ + 批量下载文件(打包为ZIP) + + Args: + file_paths: 文件相对路径列表 + """ + if not file_paths: + raise HTTPException(status_code=400, detail="文件列表不能为空") + + base_path = get_cad_files_path() + + # 创建内存中的ZIP文件 + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for file_path in file_paths: + full_path = base_path / file_path + + # 安全检查 + try: + full_path = full_path.resolve() + base_path_resolved = base_path.resolve() + + if not str(full_path).startswith(str(base_path_resolved)): + continue # 跳过不安全的路径 + except Exception: + continue + + # 检查文件是否存在且为CAD文件 + if full_path.exists() and full_path.is_file() and is_cad_file(full_path.name): + # 使用相对路径作为ZIP内的路径 + arcname = file_path.replace('\\', '/') + zip_file.write(str(full_path), arcname=arcname) + + # 重置缓冲区位置 + zip_buffer.seek(0) + + return StreamingResponse( + zip_buffer, + media_type='application/zip', + headers={ + 'Content-Disposition': 'attachment; filename="cad_files.zip"' + } + ) diff --git a/app/api/v1/websocket.py b/app/api/v1/websocket.py index 636c6fe..01eaa51 100644 --- a/app/api/v1/websocket.py +++ b/app/api/v1/websocket.py @@ -25,6 +25,14 @@ class WSMessageType: GET_LOG_STATS = "get_log_stats" CLEANUP_LOGS = "cleanup_logs" GET_OPERATION_TYPES = "get_operation_types" + # 文件管理相关消息类型 + GET_FILE_LIST = "get_file_list" + DOWNLOAD_FILE = "download_file" + DOWNLOAD_BATCH = "download_batch" + # 文件重命名相关消息类型 + GET_RENAME_STRATEGIES = "get_rename_strategies" + PREVIEW_RENAME = "preview_rename" + RENAME_FILES = "rename_files" router = APIRouter() @@ -116,6 +124,12 @@ async def handle_client_message(message: dict, client_id: str, user_id: str): - get_log_stats: 获取日志统计信息 - cleanup_logs: 清理过期日志 - get_operation_types: 获取操作类型列表 + - get_file_list: 获取CAD文件列表 + - download_file: 获取单个文件下载URL + - download_batch: 获取批量文件下载URL + - get_rename_strategies: 获取可用的重命名策略列表 + - preview_rename: 预览重命名结果 + - rename_files: 执行批量重命名 """ from app.core.software_manager import software_manager from app.core.log_manager import log_manager @@ -499,6 +513,255 @@ async def handle_client_message(message: dict, client_id: str, user_id: str): "message": f"获取操作类型失败: {str(e)}", "timestamp": websocket_manager._get_timestamp() }, client_id) + + elif message_type == WSMessageType.GET_FILE_LIST: + # 获取CAD文件列表 + try: + from app.api.v1.files import get_cad_files_path, scan_cad_files + + base_path = get_cad_files_path() + files = scan_cad_files(base_path) + + await websocket_manager.send_personal_message({ + "type": MessageType.INFO, + "message": "获取文件列表成功", + "data": { + "base_path": str(base_path), + "total_count": len(files), + "files": files + }, + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + except Exception as e: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": f"获取文件列表失败: {str(e)}", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + elif message_type == WSMessageType.DOWNLOAD_FILE: + # 获取单个文件下载URL + file_path = message.get("file_path") + if not file_path: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": "缺少参数: file_path", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + return + + try: + from app.api.v1.files import get_cad_files_path, is_cad_file + from pathlib import Path + + base_path = get_cad_files_path() + full_path = base_path / file_path + + # 安全检查 + full_path = full_path.resolve() + base_path = base_path.resolve() + + if not str(full_path).startswith(str(base_path)): + raise ValueError("访问被拒绝") + + if not full_path.exists(): + raise FileNotFoundError("文件不存在") + + if not is_cad_file(full_path.name): + raise ValueError("只能下载CAD文件") + + # 返回下载URL(使用HTTP接口) + download_url = f"/api/v1/files/download/{file_path}" + + await websocket_manager.send_personal_message({ + "type": MessageType.INFO, + "message": "文件下载链接已生成", + "data": { + "file_path": file_path, + "filename": full_path.name, + "download_url": download_url, + "file_size": full_path.stat().st_size + }, + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + except Exception as e: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": f"生成下载链接失败: {str(e)}", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + elif message_type == WSMessageType.DOWNLOAD_BATCH: + # 获取批量文件下载URL + file_paths = message.get("file_paths") + if not file_paths or not isinstance(file_paths, list): + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": "缺少参数: file_paths (必须是数组)", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + return + + try: + # 返回批量下载URL(使用HTTP接口) + download_url = "/api/v1/files/download/batch" + + await websocket_manager.send_personal_message({ + "type": MessageType.INFO, + "message": "批量下载链接已生成", + "data": { + "file_count": len(file_paths), + "file_paths": file_paths, + "download_url": download_url, + "method": "POST", + "note": "请使用POST方法,将file_paths作为JSON数组发送到此URL" + }, + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + except Exception as e: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": f"生成批量下载链接失败: {str(e)}", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + elif message_type == WSMessageType.GET_RENAME_STRATEGIES: + # 获取可用的重命名策略列表 + try: + from app.core.rename_manager import rename_manager + + strategies = rename_manager.get_available_strategies() + + await websocket_manager.send_personal_message({ + "type": MessageType.INFO, + "message": "获取重命名策略成功", + "data": { + "strategies": strategies, + "total_count": len(strategies) + }, + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + except Exception as e: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": f"获取重命名策略失败: {str(e)}", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + elif message_type == WSMessageType.PREVIEW_RENAME: + # 预览重命名结果 + file_paths = message.get("file_paths") + strategy_name = message.get("strategy") + params = message.get("params", {}) + + if not file_paths or not isinstance(file_paths, list): + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": "缺少参数: file_paths (必须是数组)", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + return + + if not strategy_name: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": "缺少参数: strategy", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + return + + try: + from app.core.rename_manager import rename_manager + from pathlib import Path + + # 提取文件名 + filenames = [Path(fp).name for fp in file_paths] + + # 预览重命名 + preview_results = rename_manager.preview_rename( + filenames=filenames, + strategy_name=strategy_name, + params=params + ) + + await websocket_manager.send_personal_message({ + "type": MessageType.INFO, + "message": "重命名预览生成成功", + "data": { + "strategy": strategy_name, + "params": params, + "preview": preview_results, + "total_count": len(preview_results) + }, + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + except Exception as e: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": f"预览重命名失败: {str(e)}", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + elif message_type == WSMessageType.RENAME_FILES: + # 执行批量重命名 + file_paths = message.get("file_paths") + strategy_name = message.get("strategy") + params = message.get("params", {}) + + if not file_paths or not isinstance(file_paths, list): + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": "缺少参数: file_paths (必须是数组)", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + return + + if not strategy_name: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": "缺少参数: strategy", + "timestamp": websocket_manager._get_timestamp() + }, client_id) + return + + try: + from app.core.rename_manager import rename_manager + from app.api.v1.files import get_cad_files_path + + base_path = get_cad_files_path() + + # 执行重命名 + results = rename_manager.execute_rename( + base_path=base_path, + file_paths=file_paths, + strategy_name=strategy_name, + params=params + ) + + await websocket_manager.send_personal_message({ + "type": MessageType.INFO, + "message": f"批量重命名完成: 成功 {results['success_count']} 个, 失败 {results['failed_count']} 个", + "data": { + "strategy": strategy_name, + "params": params, + "success_count": results["success_count"], + "failed_count": results["failed_count"], + "results": results["results"] + }, + "timestamp": websocket_manager._get_timestamp() + }, client_id) + + except Exception as e: + await websocket_manager.send_personal_message({ + "type": MessageType.ERROR, + "message": f"批量重命名失败: {str(e)}", + "timestamp": websocket_manager._get_timestamp() + }, client_id) else: # 未知消息类型 diff --git a/app/config.py b/app/config.py index 08042a3..3c44596 100644 --- a/app/config.py +++ b/app/config.py @@ -35,6 +35,9 @@ class Settings(BaseSettings): software_config_path: str = str(BASE_DIR / "configs" / "software_config.yaml") users_config_path: str = str(BASE_DIR / "configs" / "users.json") + # CAD文件存储路径 + cad_files_path: str = r"C:\Users\Public\Documents" + # CORS配置 cors_origins: List[str] = ["*"] @@ -82,6 +85,15 @@ class SoftwareConfig: def validate_software_exists(self, software_id: str) -> bool: """验证软件是否存在于配置中""" return software_id in self.get_software_list() + + def get_cad_files_path(self) -> str: + """获取CAD文件存储路径""" + if not self._config: + self.load_config() + + file_storage = self._config.get('file_storage', {}) + return file_storage.get('cad_files_path', r'C:\Users\Public\Documents') + # 创建全局配置实例 diff --git a/app/core/rename_manager.py b/app/core/rename_manager.py new file mode 100644 index 0000000..5b9de3c --- /dev/null +++ b/app/core/rename_manager.py @@ -0,0 +1,366 @@ +""" +文件重命名管理器 +提供批量重命名策略和执行功能 +""" +from pathlib import Path +from typing import List, Dict, Optional, Callable +from datetime import datetime +import re +import logging + +logger = logging.getLogger(__name__) + + +class RenameStrategy: + """重命名策略基类""" + + def __init__(self, name: str, description: str): + self.name = name + self.description = description + + def apply(self, filename: str, index: int, params: dict) -> str: + """ + 应用重命名策略 + + Args: + filename: 原文件名 + index: 文件索引(从0开始) + params: 策略参数 + + Returns: + 新文件名 + """ + raise NotImplementedError + + +class AddPrefixStrategy(RenameStrategy): + """添加前缀策略""" + + def __init__(self): + super().__init__( + name="add_prefix", + description="在文件名前添加前缀" + ) + + def apply(self, filename: str, index: int, params: dict) -> str: + prefix = params.get("prefix", "") + return f"{prefix}{filename}" + + +class AddSuffixStrategy(RenameStrategy): + """添加后缀策略(在扩展名前)""" + + def __init__(self): + super().__init__( + name="add_suffix", + description="在扩展名前添加后缀" + ) + + def apply(self, filename: str, index: int, params: dict) -> str: + suffix = params.get("suffix", "") + # 分离文件名和扩展名(包括版本号) + name_part, ext_part = self._split_name_ext(filename) + return f"{name_part}{suffix}{ext_part}" + + @staticmethod + def _split_name_ext(filename: str) -> tuple: + """分离文件名和扩展名(保留版本号)""" + # 处理如 file.prt.1 这样的文件 + parts = filename.split('.') + if len(parts) > 1: + # 检查最后一部分是否为数字(版本号) + if parts[-1].isdigit() and len(parts) > 2: + # 有版本号:file.prt.1 -> file, .prt.1 + name = parts[0] + ext = '.' + '.'.join(parts[1:]) + else: + # 无版本号:file.prt -> file, .prt + name = '.'.join(parts[:-1]) + ext = '.' + parts[-1] + else: + name = filename + ext = '' + return name, ext + + +class SequenceStrategy(RenameStrategy): + """序号重命名策略""" + + def __init__(self): + super().__init__( + name="sequence", + description="使用序号重命名文件" + ) + + def apply(self, filename: str, index: int, params: dict) -> str: + base_name = params.get("base_name", "file") + start_number = params.get("start_number", 1) + digits = params.get("digits", 3) + separator = params.get("separator", "_") + + # 保留扩展名 + _, ext_part = AddSuffixStrategy._split_name_ext(filename) + + # 生成序号 + number = start_number + index + number_str = str(number).zfill(digits) + + return f"{base_name}{separator}{number_str}{ext_part}" + + +class ReplaceTextStrategy(RenameStrategy): + """文本替换策略""" + + def __init__(self): + super().__init__( + name="replace_text", + description="替换文件名中的文本" + ) + + def apply(self, filename: str, index: int, params: dict) -> str: + search_text = params.get("search", "") + replace_text = params.get("replace", "") + case_sensitive = params.get("case_sensitive", True) + + if not search_text: + return filename + + if case_sensitive: + return filename.replace(search_text, replace_text) + else: + # 不区分大小写的替换 + pattern = re.compile(re.escape(search_text), re.IGNORECASE) + return pattern.sub(replace_text, filename) + + +class AddDateTimeStrategy(RenameStrategy): + """添加日期时间策略""" + + def __init__(self): + super().__init__( + name="add_datetime", + description="添加日期时间戳" + ) + + def apply(self, filename: str, index: int, params: dict) -> str: + format_str = params.get("format", "%Y%m%d_%H%M%S") + position = params.get("position", "suffix") # prefix or suffix + separator = params.get("separator", "_") + + timestamp = datetime.now().strftime(format_str) + name_part, ext_part = AddSuffixStrategy._split_name_ext(filename) + + if position == "prefix": + return f"{timestamp}{separator}{name_part}{ext_part}" + else: + return f"{name_part}{separator}{timestamp}{ext_part}" + + +class ChangeCaseStrategy(RenameStrategy): + """大小写转换策略""" + + def __init__(self): + super().__init__( + name="change_case", + description="转换文件名大小写" + ) + + def apply(self, filename: str, index: int, params: dict) -> str: + case_type = params.get("case_type", "lower") # lower, upper, title + + name_part, ext_part = AddSuffixStrategy._split_name_ext(filename) + + if case_type == "lower": + name_part = name_part.lower() + elif case_type == "upper": + name_part = name_part.upper() + elif case_type == "title": + name_part = name_part.title() + + return f"{name_part}{ext_part}" + + +class RenameManager: + """重命名管理器""" + + def __init__(self): + self.strategies: Dict[str, RenameStrategy] = {} + self._register_default_strategies() + + def _register_default_strategies(self): + """注册默认策略""" + strategies = [ + AddPrefixStrategy(), + AddSuffixStrategy(), + SequenceStrategy(), + ReplaceTextStrategy(), + AddDateTimeStrategy(), + ChangeCaseStrategy() + ] + + for strategy in strategies: + self.strategies[strategy.name] = strategy + + def get_available_strategies(self) -> List[Dict[str, str]]: + """获取可用的重命名策略列表""" + return [ + { + "name": strategy.name, + "description": strategy.description + } + for strategy in self.strategies.values() + ] + + def preview_rename( + self, + filenames: List[str], + strategy_name: str, + params: dict + ) -> List[Dict[str, str]]: + """ + 预览重命名结果 + + Args: + filenames: 文件名列表 + strategy_name: 策略名称 + params: 策略参数 + + Returns: + 预览结果列表,包含原文件名和新文件名 + """ + if strategy_name not in self.strategies: + raise ValueError(f"未知的重命名策略: {strategy_name}") + + strategy = self.strategies[strategy_name] + results = [] + + for index, filename in enumerate(filenames): + try: + new_name = strategy.apply(filename, index, params) + results.append({ + "original": filename, + "new": new_name, + "success": True + }) + except Exception as e: + results.append({ + "original": filename, + "new": filename, + "success": False, + "error": str(e) + }) + + return results + + def execute_rename( + self, + base_path: Path, + file_paths: List[str], + strategy_name: str, + params: dict + ) -> Dict[str, any]: + """ + 执行批量重命名 + + Args: + base_path: 基础路径 + file_paths: 相对文件路径列表 + strategy_name: 策略名称 + params: 策略参数 + + Returns: + 执行结果 + """ + if strategy_name not in self.strategies: + raise ValueError(f"未知的重命名策略: {strategy_name}") + + strategy = self.strategies[strategy_name] + results = { + "success_count": 0, + "failed_count": 0, + "results": [] + } + + for index, file_path in enumerate(file_paths): + try: + # 获取完整路径 + full_path = base_path / file_path + + # 安全检查 + full_path = full_path.resolve() + base_path_resolved = base_path.resolve() + + if not str(full_path).startswith(str(base_path_resolved)): + raise ValueError("访问被拒绝") + + if not full_path.exists(): + raise FileNotFoundError("文件不存在") + + # 生成新文件名 + original_filename = full_path.name + new_filename = strategy.apply(original_filename, index, params) + + # 处理文件名冲突 + new_path = full_path.parent / new_filename + new_path = self._resolve_conflict(new_path) + + # 执行重命名 + full_path.rename(new_path) + + results["results"].append({ + "original_path": file_path, + "original_name": original_filename, + "new_name": new_path.name, + "new_path": str(new_path.relative_to(base_path_resolved)).replace('\\', '/'), + "success": True + }) + results["success_count"] += 1 + + logger.info(f"重命名成功: {original_filename} -> {new_path.name}") + + except Exception as e: + results["results"].append({ + "original_path": file_path, + "original_name": Path(file_path).name, + "success": False, + "error": str(e) + }) + results["failed_count"] += 1 + + logger.error(f"重命名失败: {file_path}, 错误: {str(e)}") + + return results + + def _resolve_conflict(self, path: Path) -> Path: + """ + 解决文件名冲突 + + Args: + path: 目标路径 + + Returns: + 解决冲突后的路径 + """ + if not path.exists(): + return path + + # 分离文件名和扩展名 + name_part, ext_part = AddSuffixStrategy._split_name_ext(path.name) + parent = path.parent + + # 添加数字后缀 + counter = 1 + while True: + new_name = f"{name_part}_{counter}{ext_part}" + new_path = parent / new_name + if not new_path.exists(): + return new_path + counter += 1 + + # 防止无限循环 + if counter > 1000: + raise ValueError("无法解决文件名冲突") + + +# 全局实例 +rename_manager = RenameManager() diff --git a/app/main.py b/app/main.py index b609eae..7a25aa4 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,7 @@ FastAPI主入口文件 """ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.v1 import websocket +from app.api.v1 import websocket, files from app.core.websocket_manager import websocket_manager from app.core.software_manager import software_manager from app.core.log_manager import log_manager @@ -41,6 +41,8 @@ app.add_middleware( # 注册API路由 app.include_router(websocket.router, prefix="/api/v1/ws", tags=["WebSocket"]) +app.include_router(files.router, prefix="/api/v1", tags=["Files"]) + @app.get("/") diff --git a/configs/software_config.yaml b/configs/software_config.yaml index a906b07..b42ecf5 100644 --- a/configs/software_config.yaml +++ b/configs/software_config.yaml @@ -1,3 +1,7 @@ +# 文件存储路径配置 +file_storage: + cad_files_path: "C:\\Users\\Public\\Documents" + software: creo: name: "PTC Creo" diff --git a/websocket-file-api-docs.md b/websocket-file-api-docs.md new file mode 100644 index 0000000..4b359e5 --- /dev/null +++ b/websocket-file-api-docs.md @@ -0,0 +1,1007 @@ +# WebSocket 文件管理API文档 + +## 概述 +通过WebSocket连接实现CAD文件的列表查询和下载功能。支持Creo、PDMS、Revit三种CAD软件的文件格式。 + +## WebSocket连接 + +**连接地址**: `ws://localhost:8000/api/v1/ws/connect` + +**连接参数**: +- `client_id`: 客户端ID(可选,不提供会自动生成) +- `user_id`: 用户ID(可选) + +**连接示例**: +```javascript +const ws = new WebSocket('ws://localhost:8000/api/v1/ws/connect?user_id=user123'); +``` + +--- + +## 支持的文件格式 + +### 📐 Creo 文件 +- `.prt` - 零件文件(Part) +- `.asm` - 装配文件(Assembly) +- `.drw` - 工程图文件(Drawing) +- **特别支持版本号后缀**:`.prt.1`, `.prt.2`, `.asm.1` 等 + +### 🏭 PDMS 文件 +- `.rvm` - 3D模型文件 +- `.dri` - 图纸文件 + +### 🏗️ Revit 文件 +- `.rvt` - Revit项目文件 +- `.rfa` - Revit族文件(Family) +- `.rte` - Revit模板文件 + +--- + +## WebSocket消息格式 + +### 1. 获取CAD文件列表 + +**发送消息**: +```json +{ + "type": "get_file_list" +} +``` + +**响应消息**: +```json +{ + "type": "info", + "message": "获取文件列表成功", + "data": { + "base_path": "C:\\Users\\Public\\Documents", + "total_count": 15, + "files": [ + { + "filename": "part1.prt.1", + "relative_path": "creo/part1.prt.1", + "absolute_path": "C:\\Users\\Public\\Documents\\creo\\part1.prt.1", + "size": 1024000, + "modified_time": 1706950800.0, + "extension": ".prt.1" + }, + { + "filename": "building.rvt", + "relative_path": "revit/building.rvt", + "absolute_path": "C:\\Users\\Public\\Documents\\revit\\building.rvt", + "size": 5120000, + "modified_time": 1706951000.0, + "extension": ".rvt" + } + ] + }, + "timestamp": "2026-02-04T10:30:00" +} +``` + +--- + +### 2. 获取单个文件下载链接 + +**发送消息**: +```json +{ + "type": "download_file", + "file_path": "creo/part1.prt.1" +} +``` + +**响应消息**: +```json +{ + "type": "info", + "message": "文件下载链接已生成", + "data": { + "file_path": "creo/part1.prt.1", + "filename": "part1.prt.1", + "download_url": "/api/v1/files/download/creo/part1.prt.1", + "file_size": 1024000 + }, + "timestamp": "2026-02-04T10:30:00" +} +``` + +**使用下载链接**: +```javascript +// 获取到download_url后,使用HTTP GET请求下载 +const downloadUrl = `http://localhost:8000${data.download_url}`; +window.open(downloadUrl, '_blank'); +``` + +--- + +### 3. 获取批量下载链接 + +**发送消息**: +```json +{ + "type": "download_batch", + "file_paths": [ + "creo/part1.prt.1", + "creo/assembly.asm", + "revit/building.rvt" + ] +} +``` + +**响应消息**: +```json +{ + "type": "info", + "message": "批量下载链接已生成", + "data": { + "file_count": 3, + "file_paths": [ + "creo/part1.prt.1", + "creo/assembly.asm", + "revit/building.rvt" + ], + "download_url": "/api/v1/files/download/batch", + "method": "POST", + "note": "请使用POST方法,将file_paths作为JSON数组发送到此URL" + }, + "timestamp": "2026-02-04T10:30:00" +} +``` + +**使用批量下载**: +```javascript +// 使用fetch API进行POST请求 +const response = await fetch('http://localhost:8000/api/v1/files/download/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data.file_paths) +}); + +const blob = await response.blob(); +const url = window.URL.createObjectURL(blob); +const a = document.createElement('a'); +a.href = url; +a.download = 'cad_files.zip'; +a.click(); +``` + +--- + +## 完整前端示例 + +### JavaScript/WebSocket + +```javascript +class CADFileManager { + constructor(serverUrl = 'ws://localhost:8000/api/v1/ws/connect') { + this.ws = null; + this.serverUrl = serverUrl; + this.httpBaseUrl = 'http://localhost:8000'; + } + + // 连接WebSocket + connect(userId = 'user123') { + return new Promise((resolve, reject) => { + this.ws = new WebSocket(`${this.serverUrl}?user_id=${userId}`); + + this.ws.onopen = () => { + console.log('WebSocket连接成功'); + resolve(); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket错误:', error); + reject(error); + }; + + this.ws.onmessage = (event) => { + const message = JSON.parse(event.data); + this.handleMessage(message); + }; + }); + } + + // 处理服务器消息 + handleMessage(message) { + console.log('收到消息:', message); + + if (message.type === 'info' && message.data) { + // 处理不同类型的响应 + if (message.data.files) { + this.onFileListReceived(message.data); + } else if (message.data.download_url) { + this.onDownloadUrlReceived(message.data); + } + } else if (message.type === 'error') { + console.error('错误:', message.message); + } + } + + // 获取文件列表 + getFileList() { + this.ws.send(JSON.stringify({ + type: 'get_file_list' + })); + } + + // 文件列表回调(可以被覆盖) + onFileListReceived(data) { + console.log('文件列表:', data.files); + console.log('总数:', data.total_count); + } + + // 请求单个文件下载 + requestDownload(filePath) { + this.ws.send(JSON.stringify({ + type: 'download_file', + file_path: filePath + })); + } + + // 下载URL回调 + onDownloadUrlReceived(data) { + const fullUrl = `${this.httpBaseUrl}${data.download_url}`; + console.log('下载链接:', fullUrl); + + // 自动触发下载 + window.open(fullUrl, '_blank'); + } + + // 请求批量下载 + async requestBatchDownload(filePaths) { + // 批量下载直接使用HTTP POST + const response = await fetch(`${this.httpBaseUrl}/api/v1/files/download/batch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(filePaths) + }); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'cad_files.zip'; + a.click(); + window.URL.revokeObjectURL(url); + } + + // 断开连接 + disconnect() { + if (this.ws) { + this.ws.close(); + } + } +} + +// 使用示例 +async function main() { + const manager = new CADFileManager(); + + // 连接 + await manager.connect('user123'); + + // 自定义文件列表回调 + manager.onFileListReceived = (data) => { + console.log(`找到 ${data.total_count} 个文件`); + data.files.forEach(file => { + console.log(`- ${file.filename} (${(file.size / 1024).toFixed(2)} KB)`); + }); + }; + + // 获取文件列表 + manager.getFileList(); + + // 下载单个文件 + setTimeout(() => { + manager.requestDownload('creo/part1.prt.1'); + }, 2000); + + // 批量下载 + setTimeout(async () => { + await manager.requestBatchDownload([ + 'creo/part1.prt.1', + 'revit/building.rvt' + ]); + }, 4000); +} + +main(); +``` + +--- + +### React示例 + +```jsx +import React, { useState, useEffect, useRef } from 'react'; + +function CADFileManager() { + const [files, setFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useState([]); + const [connected, setConnected] = useState(false); + const wsRef = useRef(null); + + useEffect(() => { + // 连接WebSocket + const ws = new WebSocket('ws://localhost:8000/api/v1/ws/connect?user_id=user123'); + + ws.onopen = () => { + console.log('WebSocket连接成功'); + setConnected(true); + + // 连接成功后立即获取文件列表 + ws.send(JSON.stringify({ type: 'get_file_list' })); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.type === 'info' && message.data?.files) { + setFiles(message.data.files); + } else if (message.type === 'info' && message.data?.download_url) { + // 自动打开下载链接 + window.open(`http://localhost:8000${message.data.download_url}`, '_blank'); + } + }; + + wsRef.current = ws; + + return () => { + ws.close(); + }; + }, []); + + // 刷新文件列表 + const refreshFileList = () => { + if (wsRef.current && connected) { + wsRef.current.send(JSON.stringify({ type: 'get_file_list' })); + } + }; + + // 下载单个文件 + const downloadFile = (filePath) => { + if (wsRef.current && connected) { + wsRef.current.send(JSON.stringify({ + type: 'download_file', + file_path: filePath + })); + } + }; + + // 批量下载 + const downloadBatch = async () => { + const response = await fetch('http://localhost:8000/api/v1/files/download/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(selectedFiles) + }); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'cad_files.zip'; + a.click(); + window.URL.revokeObjectURL(url); + }; + + // 切换文件选择 + const toggleFileSelection = (filePath) => { + setSelectedFiles(prev => + prev.includes(filePath) + ? prev.filter(f => f !== filePath) + : [...prev, filePath] + ); + }; + + return ( +
+

CAD文件管理器

+ +
+ 连接状态: {connected ? '✅ 已连接' : '❌ 未连接'} + + +
+ +
+

文件列表 (共 {files.length} 个)

+ + + + + + + + + + + + + {files.map(file => ( + + + + + + + + + ))} + +
选择文件名路径大小扩展名操作
+ toggleFileSelection(file.relative_path)} + /> + {file.filename}{file.relative_path}{(file.size / 1024).toFixed(2)} KB{file.extension} + +
+
+
+ ); +} + +export default CADFileManager; +``` + +--- + +## 错误处理 + +**错误响应格式**: +```json +{ + "type": "error", + "message": "错误描述", + "timestamp": "2026-02-04T10:30:00" +} +``` + +**常见错误**: +- `"缺少参数: file_path"` - 未提供文件路径 +- `"缺少参数: file_paths (必须是数组)"` - 批量下载参数格式错误 +- `"访问被拒绝"` - 文件路径不安全 +- `"文件不存在"` - 请求的文件不存在 +- `"只能下载CAD文件"` - 尝试下载非CAD格式文件 +- `"获取文件列表失败"` - 扫描文件系统失败 + +--- + +## 注意事项 + +1. **WebSocket用于控制,HTTP用于下载** + - 文件列表通过WebSocket返回 + - 文件下载使用HTTP接口(因为WebSocket不适合传输大文件) + +2. **路径安全** + - 所有文件路径都会进行安全检查 + - 只能访问配置目录内的文件 + +3. **文件格式限制** + - 只能下载Creo、PDMS、Revit格式的文件 + - 自动识别带版本号的Creo文件 + +4. **批量下载** + - 文件打包为ZIP格式 + - 保持原有目录结构 + - 自动跳过不存在或不安全的文件 + +5. **配置路径** + - 默认路径:`C:\Users\Public\Documents` + - 可在 `configs/software_config.yaml` 中修改 + +--- + +## 文件批量重命名功能 + +### 4. 获取重命名策略列表 + +**发送消息**: +```json +{ + "type": "get_rename_strategies" +} +``` + +**响应消息**: +```json +{ + "type": "info", + "message": "获取重命名策略成功", + "data": { + "strategies": [ + { + "name": "add_prefix", + "description": "在文件名前添加前缀" + }, + { + "name": "add_suffix", + "description": "在扩展名前添加后缀" + }, + { + "name": "sequence", + "description": "使用序号重命名文件" + }, + { + "name": "replace_text", + "description": "替换文件名中的文本" + }, + { + "name": "add_datetime", + "description": "添加日期时间戳" + }, + { + "name": "change_case", + "description": "转换文件名大小写" + } + ], + "total_count": 6 + }, + "timestamp": "2026-02-04T11:10:00" +} +``` + +--- + +### 5. 预览重命名结果 + +**发送消息**: +```json +{ + "type": "preview_rename", + "file_paths": [ + "creo/part1.prt.1", + "creo/assembly.asm", + "revit/building.rvt" + ], + "strategy": "add_prefix", + "params": { + "prefix": "NEW_" + } +} +``` + +**响应消息**: +```json +{ + "type": "info", + "message": "重命名预览生成成功", + "data": { + "strategy": "add_prefix", + "params": { + "prefix": "NEW_" + }, + "preview": [ + { + "original": "part1.prt.1", + "new": "NEW_part1.prt.1", + "success": true + }, + { + "original": "assembly.asm", + "new": "NEW_assembly.asm", + "success": true + }, + { + "original": "building.rvt", + "new": "NEW_building.rvt", + "success": true + } + ], + "total_count": 3 + }, + "timestamp": "2026-02-04T11:10:00" +} +``` + +--- + +### 6. 执行批量重命名 + +**发送消息**: +```json +{ + "type": "rename_files", + "file_paths": [ + "creo/part1.prt.1", + "creo/assembly.asm" + ], + "strategy": "sequence", + "params": { + "base_name": "component", + "start_number": 1, + "digits": 3, + "separator": "_" + } +} +``` + +**响应消息**: +```json +{ + "type": "info", + "message": "批量重命名完成: 成功 2 个, 失败 0 个", + "data": { + "strategy": "sequence", + "params": { + "base_name": "component", + "start_number": 1, + "digits": 3, + "separator": "_" + }, + "success_count": 2, + "failed_count": 0, + "results": [ + { + "original_path": "creo/part1.prt.1", + "original_name": "part1.prt.1", + "new_name": "component_001.prt.1", + "new_path": "creo/component_001.prt.1", + "success": true + }, + { + "original_path": "creo/assembly.asm", + "original_name": "assembly.asm", + "new_name": "component_002.asm", + "new_path": "creo/component_002.asm", + "success": true + } + ] + }, + "timestamp": "2026-02-04T11:10:00" +} +``` + +--- + +## 重命名策略参数说明 + +### 1. add_prefix(添加前缀) +```json +{ + "strategy": "add_prefix", + "params": { + "prefix": "NEW_" + } +} +``` + +### 2. add_suffix(添加后缀) +```json +{ + "strategy": "add_suffix", + "params": { + "suffix": "_backup" + } +} +``` + +### 3. sequence(序号重命名) +```json +{ + "strategy": "sequence", + "params": { + "base_name": "file", + "start_number": 1, + "digits": 3, + "separator": "_" + } +} +``` +- `base_name`: 基础文件名 +- `start_number`: 起始序号(默认1) +- `digits`: 序号位数(默认3,如001) +- `separator`: 分隔符(默认下划线) + +### 4. replace_text(文本替换) +```json +{ + "strategy": "replace_text", + "params": { + "search": "old", + "replace": "new", + "case_sensitive": true + } +} +``` +- `search`: 要搜索的文本 +- `replace`: 替换为的文本 +- `case_sensitive`: 是否区分大小写(默认true) + +### 5. add_datetime(添加日期时间) +```json +{ + "strategy": "add_datetime", + "params": { + "format": "%Y%m%d_%H%M%S", + "position": "suffix", + "separator": "_" + } +} +``` +- `format`: 日期时间格式(默认:`%Y%m%d_%H%M%S`) +- `position`: 位置,`prefix`或`suffix`(默认suffix) +- `separator`: 分隔符(默认下划线) + +### 6. change_case(大小写转换) +```json +{ + "strategy": "change_case", + "params": { + "case_type": "lower" + } +} +``` +- `case_type`: 转换类型,可选值:`lower`(小写)、`upper`(大写)、`title`(标题格式) + +--- + +## 重命名功能使用示例 + +### JavaScript示例 + +```javascript +// 扩展之前的 CADFileManager 类 +class CADFileManager { + // ... 之前的代码 ... + + // 获取重命名策略列表 + getRenameStrategies() { + this.ws.send(JSON.stringify({ + type: 'get_rename_strategies' + })); + } + + // 预览重命名 + previewRename(filePaths, strategy, params) { + this.ws.send(JSON.stringify({ + type: 'preview_rename', + file_paths: filePaths, + strategy: strategy, + params: params + })); + } + + // 执行批量重命名 + renameFiles(filePaths, strategy, params) { + this.ws.send(JSON.stringify({ + type: 'rename_files', + file_paths: filePaths, + strategy: strategy, + params: params + })); + } +} + +// 使用示例 +async function renameExample() { + const manager = new CADFileManager(); + await manager.connect('user123'); + + // 1. 获取策略列表 + manager.getRenameStrategies(); + + // 2. 预览重命名(添加前缀) + setTimeout(() => { + manager.previewRename( + ['creo/part1.prt.1', 'creo/assembly.asm'], + 'add_prefix', + { prefix: 'V2_' } + ); + }, 1000); + + // 3. 执行重命名(序号重命名) + setTimeout(() => { + manager.renameFiles( + ['creo/part1.prt.1', 'creo/part2.prt.1'], + 'sequence', + { + base_name: 'component', + start_number: 1, + digits: 3, + separator: '_' + } + ); + }, 3000); +} +``` + +### React示例 + +```jsx +function CADFileRenamer() { + const [files, setFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useState([]); + const [strategies, setStrategies] = useState([]); + const [selectedStrategy, setSelectedStrategy] = useState(''); + const [renameParams, setRenameParams] = useState({}); + const [previewResults, setPreviewResults] = useState([]); + const wsRef = useRef(null); + + useEffect(() => { + const ws = new WebSocket('ws://localhost:8000/api/v1/ws/connect?user_id=user123'); + + ws.onopen = () => { + // 获取文件列表和策略列表 + ws.send(JSON.stringify({ type: 'get_file_list' })); + ws.send(JSON.stringify({ type: 'get_rename_strategies' })); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.type === 'info') { + if (message.data?.files) { + setFiles(message.data.files); + } else if (message.data?.strategies) { + setStrategies(message.data.strategies); + } else if (message.data?.preview) { + setPreviewResults(message.data.preview); + } else if (message.data?.results) { + // 重命名完成,刷新文件列表 + ws.send(JSON.stringify({ type: 'get_file_list' })); + setPreviewResults([]); + setSelectedFiles([]); + } + } + }; + + wsRef.current = ws; + return () => ws.close(); + }, []); + + // 预览重命名 + const handlePreview = () => { + if (wsRef.current && selectedFiles.length > 0 && selectedStrategy) { + wsRef.current.send(JSON.stringify({ + type: 'preview_rename', + file_paths: selectedFiles, + strategy: selectedStrategy, + params: renameParams + })); + } + }; + + // 执行重命名 + const handleRename = () => { + if (wsRef.current && selectedFiles.length > 0 && selectedStrategy) { + if (confirm(`确定要重命名 ${selectedFiles.length} 个文件吗?`)) { + wsRef.current.send(JSON.stringify({ + type: 'rename_files', + file_paths: selectedFiles, + strategy: selectedStrategy, + params: renameParams + })); + } + } + }; + + return ( +
+

CAD文件批量重命名

+ + {/* 策略选择 */} +
+ + +
+ + {/* 参数输入(根据策略动态显示) */} + {selectedStrategy === 'add_prefix' && ( +
+ + setRenameParams({...renameParams, prefix: e.target.value})} + /> +
+ )} + + {/* 操作按钮 */} +
+ + +
+ + {/* 预览结果 */} + {previewResults.length > 0 && ( +
+

预览结果

+ {previewResults.map((result, idx) => ( +
+ {result.original} → {result.new} +
+ ))} +
+ )} + + {/* 文件列表 */} +

文件列表

+ + + + + + + + + + {files.map(file => ( + + + + + + ))} + +
选择文件名路径
+ { + if (e.target.checked) { + setSelectedFiles([...selectedFiles, file.relative_path]); + } else { + setSelectedFiles(selectedFiles.filter(f => f !== file.relative_path)); + } + }} + /> + {file.filename}{file.relative_path}
+
+ ); +} +``` + +--- + +## 重命名功能注意事项 + +1. **文件名冲突处理** + - 如果重命名后的文件名已存在,系统会自动添加数字后缀(如 `file_1`, `file_2`) + - 最多尝试1000次,避免无限循环 + +2. **扩展名保留** + - 所有重命名策略都会保留原有的文件扩展名 + - 支持带版本号的扩展名(如 `.prt.1`, `.prt.2`) + +3. **预览功能** + - 建议在执行重命名前先使用预览功能 + - 预览不会修改实际文件,只返回重命名结果 + +4. **错误处理** + - 批量重命名时,单个文件失败不会影响其他文件 + - 返回结果中会详细说明每个文件的成功/失败状态 + +5. **安全性** + - 只能重命名配置目录内的CAD文件 + - 路径安全检查防止目录遍历攻击