From 49b32dc20faedecbc96b09670ec128e06c123883 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Fri, 4 Sep 2026 09:49:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=8F=B0=E8=B4=A6):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=90=88=E5=90=8C=E6=A8=A1=E6=9D=BF=E5=B9=B6=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onlyoffice_collaboration_service.py | 90 +++++++++++++++-- .../templates/ledgers/contract-ledger.xlsx | Bin 11607 -> 9180 bytes backend/tests/test_collaboration_service.py | 93 ++++++++++++++++++ backend/tests/test_ledgers.py | 35 +++++++ docs/onlyoffice-collaboration.md | 2 + .../CollaborationHistoryDialog.vue | 1 + 6 files changed, 215 insertions(+), 6 deletions(-) diff --git a/backend/app/services/onlyoffice_collaboration_service.py b/backend/app/services/onlyoffice_collaboration_service.py index 653574ea..b8386a07 100644 --- a/backend/app/services/onlyoffice_collaboration_service.py +++ b/backend/app/services/onlyoffice_collaboration_service.py @@ -2,8 +2,10 @@ from __future__ import annotations import hashlib import hmac +import io import json import uuid +import zipfile from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -57,12 +59,33 @@ async def _active_session( ).order_by(CollaborationSession.created_at.desc()) ) if session: - if session.status != "ACTIVE": - session.status = "ACTIVE" - session.closed_at = None + if session.status == "ERROR": + recovered = await _recover_forgotten_content(session.document_key, item.file_type) + if recovered is not None: + revision, _ = await collaboration_service.append_revision( + db, + item, + recovered, + source="SERVER_RECOVERY", + created_by=user_id, + change_summary="自动恢复在线文档服务器备份", + ) + session.base_revision_id = revision.id + # The failed key points to Document Server's recovery cache. A new + # generation must use a new key or every subsequent open falls back + # to the same unsaved backup again. + item.generation += 1 + session.status = "RECOVERED" if recovered is not None else "CLOSED" + session.closed_at = datetime.now(timezone.utc) await db.commit() - await db.refresh(session) - return session + session = None + else: + if session.status != "ACTIVE": + session.status = "ACTIVE" + session.closed_at = None + await db.commit() + await db.refresh(session) + return session if not item.current_revision_id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容") session = CollaborationSession( @@ -339,6 +362,57 @@ async def _download_result(url: str) -> bytes: return bytes(content) +def _validate_recovered_content(content: bytes, file_type: str) -> None: + required_part = { + "word": "word/document.xml", + "cell": "xl/workbook.xml", + "slide": "ppt/presentation.xml", + }.get(file_type) + if not required_part or not zipfile.is_zipfile(io.BytesIO(content)): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份格式无效") + try: + with zipfile.ZipFile(io.BytesIO(content)) as package: + if required_part not in package.namelist() or package.testzip() is not None: + raise ValueError + except (zipfile.BadZipFile, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份已损坏") from exc + + +async def _recover_forgotten_content(document_key: str, file_type: str) -> bytes | None: + """Download a Document Server backup left behind by a failed final save.""" + command = {"c": "getForgotten", "key": document_key} + token = jwt.encode(command, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256") + command_url = f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/command" + try: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client: + response = await client.post( + command_url, + params={"shardkey": document_key}, + json={**command, "token": token}, + ) + if response.status_code != status.HTTP_200_OK: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用") + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用") from exc + + error = payload.get("error") if isinstance(payload, dict) else None + if error == 1: + # Document Server no longer has a forgotten copy. Starting from the + # last confirmed CTMS revision is then the only recoverable state. + return None + if ( + error != 0 + or payload.get("key") != document_key + or not isinstance(payload.get("url"), str) + or not payload["url"] + ): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器返回的备份信息无效") + content = await _download_result(payload["url"]) + _validate_recovered_content(content, file_type) + return content + + async def _callback_user( db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession ) -> User | None: @@ -452,9 +526,13 @@ async def process_callback( session.status = "CLOSED" session.closed_at = datetime.now(timezone.utc) result = "UNCHANGED" - elif payload.status in {3, 7}: + elif payload.status == 3: session.status = "ERROR" result = "ERROR" + elif payload.status == 7: + # A force-save error does not close the live co-editing session. The + # final status 2 callback can still persist the document normally. + result = "ERROR" if payload.users or not ledger_access.is_ledger(item): session.active_users = json.dumps(payload.users, ensure_ascii=True) diff --git a/backend/app/templates/ledgers/contract-ledger.xlsx b/backend/app/templates/ledgers/contract-ledger.xlsx index 22a4c1f443041ebfaccc1cb617187915458ee361..9b06edf78fe85a434deee877f064188f168dfed5 100644 GIT binary patch delta 3270 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 KDJl5>4*mxPL+eTa delta 5756 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 NbsP}95Wc@-{{~75p+Eot diff --git a/backend/tests/test_collaboration_service.py b/backend/tests/test_collaboration_service.py index 2f27fbe7..d4b76cf1 100644 --- a/backend/tests/test_collaboration_service.py +++ b/backend/tests/test_collaboration_service.py @@ -316,6 +316,74 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m onlyoffice_collaboration_service._validate_result_url(value) +@pytest.mark.asyncio +async def test_forgotten_document_command_downloads_and_validates_server_backup(monkeypatch): + key = "ctms-collab-forgotten-key" + recovered = collaboration_service.blank_file_bytes("cell") + request = {} + + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return {"error": 0, "key": key, "url": "http://onlyoffice/cache/forgotten.xlsx"} + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def post(self, url, *, params, json): + request.update(url=url, params=params, body=json) + return FakeResponse() + + monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient()) + download = AsyncMock(return_value=recovered) + monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", download) + + result = await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell") + + assert result == recovered + assert request["url"] == "http://onlyoffice/command" + assert request["params"] == {"shardkey": key} + assert jwt.decode( + request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"] + ) == {"c": "getForgotten", "key": key} + download.assert_awaited_once_with("http://onlyoffice/cache/forgotten.xlsx") + + +@pytest.mark.asyncio +async def test_forgotten_document_command_rejects_a_damaged_backup(monkeypatch): + key = "ctms-collab-damaged-key" + + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return {"error": 0, "key": key, "url": "http://onlyoffice/cache/damaged.xlsx"} + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def post(self, *_args, **_kwargs): + return FakeResponse() + + monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient()) + monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", AsyncMock(return_value=b"broken")) + + with pytest.raises(HTTPException) as error: + await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell") + assert error.value.status_code == 502 + + @pytest.mark.asyncio async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path): user_id = uuid.uuid4() @@ -1016,6 +1084,31 @@ async def test_callback_error_status_is_recorded_and_acknowledged(): assert db.added[0].result == "ERROR" +@pytest.mark.asyncio +async def test_force_save_error_keeps_the_live_session_active(): + session = SimpleNamespace( + id=uuid.uuid4(), + file_id=uuid.uuid4(), + document_key="ctms-collab-force-save-error-key", + generation=1, + status="ACTIVE", + active_users=None, + last_callback_at=None, + ) + item = SimpleNamespace(id=session.file_id, generation=1) + db = _CallbackDb(session, item) + + result = await onlyoffice_collaboration_service.process_callback( + db, + session.id, + CollaborationCallbackPayload(key=session.document_key, status=7), + ) + + assert result == {"error": 0} + assert session.status == "ACTIVE" + assert db.added[0].result == "ERROR" + + @pytest.mark.asyncio async def test_force_save_updates_session_recovery_revision(monkeypatch): session = SimpleNamespace( diff --git a/backend/tests/test_ledgers.py b/backend/tests/test_ledgers.py index ad280099..3389c9a1 100644 --- a/backend/tests/test_ledgers.py +++ b/backend/tests/test_ledgers.py @@ -86,6 +86,41 @@ async def test_initialization_preserves_both_uploaded_templates_and_is_idempoten assert "ledgers" in Path(revision.file_uri).parts +@pytest.mark.asyncio +async def test_failed_ledger_session_recovers_server_backup_under_a_new_document_key(env, monkeypatch): + item = await env.db.get(CollaborationFile, env.initial[0].id) + first = await office.build_editor_config(env.db, item, env.admin) + failed = await env.db.scalar(select(CollaborationSession).where( + CollaborationSession.file_id == item.id, + CollaborationSession.generation == item.generation, + )) + failed.status = "ERROR" + await env.db.commit() + + current = await env.db.get(CollaborationRevision, item.current_revision_id) + recovered_buffer = io.BytesIO(Path(current.file_uri).read_bytes()) + with zipfile.ZipFile(recovered_buffer, "a") as package: + package.comment = b"document-server-recovery" + recovery = AsyncMock(return_value=recovered_buffer.getvalue()) + monkeypatch.setattr(office, "_recover_forgotten_content", recovery) + + reopened = await office.build_editor_config(env.db, item, env.admin) + await env.db.refresh(item) + await env.db.refresh(failed) + sessions = (await env.db.scalars(select(CollaborationSession).where( + CollaborationSession.file_id == item.id, + ).order_by(CollaborationSession.generation))).all() + recovered_revision = await env.db.get(CollaborationRevision, item.current_revision_id) + + assert first.config["document"]["key"] != reopened.config["document"]["key"] + assert item.generation == 2 + assert failed.status == "RECOVERED" + assert [session.generation for session in sessions] == [1, 2] + assert recovered_revision.source == "SERVER_RECOVERY" + assert recovered_revision.change_summary == "自动恢复在线文档服务器备份" + recovery.assert_awaited_once_with(failed.document_key, "cell") + + @pytest.mark.asyncio async def test_account_grants_are_independent_and_all_file_routes_require_access(env): await grant(env, env.editor, "EDITOR") diff --git a/docs/onlyoffice-collaboration.md b/docs/onlyoffice-collaboration.md index 74d72657..6b74eedf 100644 --- a/docs/onlyoffice-collaboration.md +++ b/docs/onlyoffice-collaboration.md @@ -45,6 +45,8 @@ 5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。 6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。 +如果最终保存返回状态 3,后端将会话标记为保存失败。再次打开文件时,先通过 ONLYOFFICE `getForgotten` 命令取回服务器保留的备份副本,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。如果文档服务器已经没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。 + 内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。 ## 本地开发 diff --git a/frontend/src/components/collaboration/CollaborationHistoryDialog.vue b/frontend/src/components/collaboration/CollaborationHistoryDialog.vue index 6eb27836..c7e7d475 100644 --- a/frontend/src/components/collaboration/CollaborationHistoryDialog.vue +++ b/frontend/src/components/collaboration/CollaborationHistoryDialog.vue @@ -227,6 +227,7 @@ function sourceLabel(source: string) { COPY: "复制创建", SHARE_FORCE_SAVE: "链接协作保存", SHARE_SESSION_CLOSE: "链接协作关闭", + SERVER_RECOVERY: "服务器备份恢复", } as Record)[source] || source; }