diff --git a/mcp_docx_server.py b/mcp_docx_server.py index b3986c6..57f89f7 100644 --- a/mcp_docx_server.py +++ b/mcp_docx_server.py @@ -475,6 +475,40 @@ def _cleanup_temp_file(file_path: str) -> None: pass +def _compute_file_etag(file_path: str) -> str: + """计算文件内容的 SHA-256 哈希,作为并发控制的 ETag。""" + h = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +# 内部版本注册表:记录每个文件最后一次 upload 或 edit_docx 之后的 etag。 +# 所有读写必须持有对应文件的 _file_lock,无需额外线程锁。 +_file_etag_registry: Dict[str, str] = {} + + +def _register_etag(abs_path: str, etag: str) -> None: + _file_etag_registry[abs_path] = etag + + +def _check_etag(abs_path: str) -> None: + """ + 在文件锁内调用:若注册表中存在该文件的 etag,则校验当前磁盘文件是否匹配。 + 不匹配说明文件在本次操作排队期间已被其他操作(如并发 upload)修改。 + """ + known = _file_etag_registry.get(abs_path) + if not known: + return + current = _compute_file_etag(abs_path) + if current != known: + raise ValueError( + f"文件已被其他操作修改(版本冲突),请确认最新上传后重试。" + f"已知: {known[:12]}…,当前: {current[:12]}…" + ) + + def _validate_docx_file(file_path: str) -> None: if not os.path.exists(file_path): raise FileNotFoundError(f"输入 DOCX 文件不存在: {file_path}") @@ -536,7 +570,7 @@ def _edit_docx_core( 对 DOCX 文件进行编辑(与 HTTP /edit_docx 共用逻辑)。 返回: - - {"output_path": 绝对路径, "output_url": URL 或 None} + - {"output_path": 绝对路径, "output_url": URL 或 None, "etag": 新文件哈希} """ print(f"edit_docx: input_docx_path: {input_docx_path}, replacements: {replacements}") upload_dir = _get_upload_dir() @@ -550,6 +584,9 @@ def _edit_docx_core( _validate_docx_file(local_input) + # 版本校验:在锁内对比注册表 etag,检测并发 upload 导致的版本冲突 + _check_etag(os.path.abspath(local_input)) + if replacements is None: replacements = [] @@ -591,10 +628,13 @@ def _edit_docx_core( os.replace(output_docx, local_input) abs_out = os.path.abspath(local_input) + new_etag = _compute_file_etag(abs_out) + _register_etag(abs_out, new_etag) return { "output_path": abs_out, "output_url": _build_output_url(abs_out), + "etag": new_etag, } except Exception: if 'output_docx' in locals() and os.path.exists(output_docx): @@ -758,8 +798,10 @@ async def upload_handler(request: Request): "success": False, "message": f"上传文件为空: {filename}" }, status_code=400) + abs_file_path = os.path.abspath(file_path) with _file_lock(file_path): _write_bytes_atomic(file_path, content) + _register_etag(abs_file_path, _compute_file_etag(abs_file_path)) return JSONResponse({ "success": True,