fix(台账): 更新合同模板并恢复文档服务器备份
This commit is contained in:
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。
|
||||
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
|
||||
|
||||
如果最终保存返回状态 3,后端将会话标记为保存失败。再次打开文件时,先通过 ONLYOFFICE `getForgotten` 命令取回服务器保留的备份副本,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。如果文档服务器已经没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。
|
||||
|
||||
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -227,6 +227,7 @@ function sourceLabel(source: string) {
|
||||
COPY: "复制创建",
|
||||
SHARE_FORCE_SAVE: "链接协作保存",
|
||||
SHARE_SESSION_CLOSE: "链接协作关闭",
|
||||
SERVER_RECOVERY: "服务器备份恢复",
|
||||
} as Record<string, string>)[source] || source;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user