fix(台账): 更新合同模板并恢复文档服务器备份

This commit is contained in:
Cheng Zhou
2026-09-04 09:49:51 +08:00
parent 5eb50c704f
commit 49b32dc20f
6 changed files with 215 additions and 6 deletions
@@ -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)