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 22a4c1f4..9b06edf7 100644 Binary files a/backend/app/templates/ledgers/contract-ledger.xlsx and b/backend/app/templates/ledgers/contract-ledger.xlsx differ 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; }