Compare commits
3 Commits
5eb50c704f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 90573f050f | |||
| 684b8b51bb | |||
| 49b32dc20f |
@@ -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,55 @@ async def _active_session(
|
||||
).order_by(CollaborationSession.created_at.desc())
|
||||
)
|
||||
if session:
|
||||
if session.status != "ACTIVE":
|
||||
session.status = "ACTIVE"
|
||||
session.closed_at = None
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
should_recover = session.status == "ERROR"
|
||||
if session.status == "ACTIVE":
|
||||
created_at = session.created_at
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
if (
|
||||
session.last_callback_at is None
|
||||
and datetime.now(timezone.utc) - created_at < timedelta(seconds=15)
|
||||
):
|
||||
# The editor config can be requested twice before the first
|
||||
# browser has connected and emitted status 1. Keep a short
|
||||
# connection grace period so the second request does not retire
|
||||
# the freshly issued key as a false stale session.
|
||||
return session
|
||||
live_users = await _document_server_users(session.document_key)
|
||||
if live_users:
|
||||
return session
|
||||
# A service restart or rejected final callback can leave the row
|
||||
# ACTIVE after Document Server has already retired the editing
|
||||
# process. Reusing that key opens its forgotten copy as an
|
||||
# unbound server backup, so retire it exactly like a final callback.
|
||||
should_recover = True
|
||||
|
||||
# A final callback ends the editing lifecycle for this key. Never
|
||||
# reactivate it: Document Server can retain a cached or forgotten copy
|
||||
# for the old key, especially across a container restart.
|
||||
recovered = (
|
||||
await _recover_forgotten_content(session.document_key, item.file_type)
|
||||
if should_recover
|
||||
else None
|
||||
)
|
||||
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 retired key can still point to Document Server's cache. A new
|
||||
# generation must use a new key or a later open can fall back to the
|
||||
# same server-side copy again.
|
||||
item.generation += 1
|
||||
session.status = "RECOVERED" if recovered is not None else "CLOSED"
|
||||
session.closed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
session = None
|
||||
if not item.current_revision_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
|
||||
session = CollaborationSession(
|
||||
@@ -339,6 +384,118 @@ 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 _document_server_users(document_key: str) -> list[str]:
|
||||
"""Return live editor ids for a key without trusting stale database state."""
|
||||
command = {"c": "info", "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:
|
||||
# The database can retain an ACTIVE row after an interrupted callback,
|
||||
# while Document Server no longer has a live editing process for it.
|
||||
return []
|
||||
users = payload.get("users") if isinstance(payload, dict) else None
|
||||
if (
|
||||
error != 0
|
||||
or payload.get("key") != document_key
|
||||
or not isinstance(users, list)
|
||||
or any(not isinstance(user_id, str) or not user_id for user_id in users)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="在线文档服务器返回的会话信息无效",
|
||||
)
|
||||
return list(dict.fromkeys(users))
|
||||
|
||||
|
||||
async def list_live_editing_sessions(db: AsyncSession) -> list[tuple[str, int]]:
|
||||
"""List file titles and live editor counts for deployment safety checks."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(CollaborationSession.document_key, CollaborationFile.title)
|
||||
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
|
||||
.where(CollaborationSession.status == "ACTIVE")
|
||||
.order_by(CollaborationFile.title)
|
||||
)
|
||||
).all()
|
||||
active: list[tuple[str, int]] = []
|
||||
for document_key, title in rows:
|
||||
users = await _document_server_users(document_key)
|
||||
if users:
|
||||
active.append((title, len(users)))
|
||||
return active
|
||||
|
||||
|
||||
async def _callback_user(
|
||||
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
|
||||
) -> User | None:
|
||||
@@ -452,9 +609,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.
@@ -0,0 +1,44 @@
|
||||
"""Abort a deployment when ONLYOFFICE still has live collaborative editors."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
from app.db.session import SessionLocal # noqa: E402
|
||||
from app.services.onlyoffice_collaboration_service import list_live_editing_sessions # noqa: E402
|
||||
|
||||
|
||||
async def async_main() -> int:
|
||||
if not settings.ONLYOFFICE_ENABLED:
|
||||
print("ONLYOFFICE 未启用,跳过在线编辑会话检查")
|
||||
return 0
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
active = await list_live_editing_sessions(db)
|
||||
except HTTPException as exc:
|
||||
print(f"无法确认 ONLYOFFICE 在线编辑状态:{exc.detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not active:
|
||||
print("未检测到 ONLYOFFICE 在线编辑者")
|
||||
return 0
|
||||
print("检测到仍在进行的 ONLYOFFICE 在线编辑,会中止本次部署:", file=sys.stderr)
|
||||
for title, count in active:
|
||||
print(f"- {title}:{count} 人在线", file=sys.stderr)
|
||||
print("请通知用户退出编辑器,等待最终保存完成后重新执行部署。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit(asyncio.run(async_main()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, AsyncMock
|
||||
from unittest.mock import ANY, AsyncMock, call
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -304,6 +304,18 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
|
||||
assert onlyoffice_collaboration_service._validate_result_url(
|
||||
"http://localhost:8888/onlyoffice/cache/result.docx?token=signed"
|
||||
) == "http://onlyoffice/cache/result.docx?token=signed"
|
||||
with pytest.raises(HTTPException) as mismatched_origin:
|
||||
onlyoffice_collaboration_service._validate_result_url(
|
||||
"https://ctms.example.com/onlyoffice/cache/result.xlsx?token=signed"
|
||||
)
|
||||
assert mismatched_origin.value.status_code == 422
|
||||
|
||||
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "https://ctms.example.com")
|
||||
assert onlyoffice_collaboration_service._validate_result_url(
|
||||
"https://ctms.example.com/onlyoffice/cache/result.xlsx?token=signed"
|
||||
) == "http://onlyoffice/cache/result.xlsx?token=signed"
|
||||
|
||||
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "http://localhost:8888")
|
||||
for value in (
|
||||
"http://backend:8000/internal/file",
|
||||
"http://onlyoffice.evil.example/cache/result.docx",
|
||||
@@ -316,6 +328,157 @@ 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_document_server_info_command_returns_unique_live_users(monkeypatch):
|
||||
key = "ctms-collab-live-key"
|
||||
request = {}
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"error": 0, "key": key, "users": ["user-1", "user-1", "user-2"]}
|
||||
|
||||
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(),
|
||||
)
|
||||
|
||||
users = await onlyoffice_collaboration_service._document_server_users(key)
|
||||
|
||||
assert users == ["user-1", "user-2"]
|
||||
assert request["url"] == "http://onlyoffice/command"
|
||||
assert request["params"] == {"shardkey": key}
|
||||
assert jwt.decode(
|
||||
request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"]
|
||||
) == {"c": "info", "key": key}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_server_info_command_treats_unknown_key_as_no_live_users(monkeypatch):
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"error": 1}
|
||||
|
||||
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(),
|
||||
)
|
||||
|
||||
assert await onlyoffice_collaboration_service._document_server_users("retired-key") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_editing_session_check_filters_stale_active_rows(monkeypatch):
|
||||
rows = SimpleNamespace(all=lambda: [
|
||||
("live-key", "正在编辑.xlsx"),
|
||||
("stale-key", "陈旧记录.xlsx"),
|
||||
])
|
||||
db = SimpleNamespace(execute=AsyncMock(return_value=rows))
|
||||
lookup = AsyncMock(side_effect=[["user-1", "user-2"], []])
|
||||
monkeypatch.setattr(onlyoffice_collaboration_service, "_document_server_users", lookup)
|
||||
|
||||
active = await onlyoffice_collaboration_service.list_live_editing_sessions(db)
|
||||
|
||||
assert active == [("正在编辑.xlsx", 2)]
|
||||
assert lookup.await_args_list == [call("live-key"), call("stale-key")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path):
|
||||
user_id = uuid.uuid4()
|
||||
@@ -1016,6 +1179,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(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import io
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import timezone
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -44,6 +44,7 @@ async def env(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(collaboration, "COLLABORATION_ROOT", tmp_path)
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "ledger-test-secret-long-enough-for-tests")
|
||||
monkeypatch.setattr(onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
monkeypatch.setattr(office, "_document_server_users", AsyncMock(return_value=["live-user"]))
|
||||
async with AsyncSession(engine, expire_on_commit=False) as db:
|
||||
users = [User(id=uuid.uuid4(), email=f"ledger-{i}@example.com", password_hash="hash",
|
||||
full_name=f"Ledger user {i}", clinical_department="test", is_admin=i == 0,
|
||||
@@ -86,6 +87,111 @@ 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_closed_ledger_session_starts_a_new_key_instead_of_reopening_server_cache(env, monkeypatch):
|
||||
item = await env.db.get(CollaborationFile, env.initial[0].id)
|
||||
first = await office.build_editor_config(env.db, item, env.admin)
|
||||
closed = await env.db.scalar(select(CollaborationSession).where(
|
||||
CollaborationSession.file_id == item.id,
|
||||
CollaborationSession.generation == item.generation,
|
||||
))
|
||||
await office.process_callback(env.db, closed.id, CollaborationCallbackPayload(
|
||||
key=closed.document_key,
|
||||
status=4,
|
||||
))
|
||||
recovery = AsyncMock(return_value=None)
|
||||
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(closed)
|
||||
sessions = (await env.db.scalars(select(CollaborationSession).where(
|
||||
CollaborationSession.file_id == item.id,
|
||||
).order_by(CollaborationSession.generation))).all()
|
||||
|
||||
assert first.config["document"]["key"] != reopened.config["document"]["key"]
|
||||
assert item.generation == 2
|
||||
assert closed.status == "CLOSED"
|
||||
assert [session.generation for session in sessions] == [1, 2]
|
||||
recovery.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_active_ledger_session_starts_a_new_key_when_document_server_has_no_users(
|
||||
env, monkeypatch
|
||||
):
|
||||
item = await env.db.get(CollaborationFile, env.initial[0].id)
|
||||
first = await office.build_editor_config(env.db, item, env.admin)
|
||||
stale = await env.db.scalar(select(CollaborationSession).where(
|
||||
CollaborationSession.file_id == item.id,
|
||||
CollaborationSession.generation == item.generation,
|
||||
))
|
||||
stale.last_callback_at = datetime.now(timezone.utc)
|
||||
await env.db.commit()
|
||||
live_users = AsyncMock(return_value=[])
|
||||
recovery = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(office, "_document_server_users", live_users)
|
||||
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(stale)
|
||||
|
||||
assert first.config["document"]["key"] != reopened.config["document"]["key"]
|
||||
assert item.generation == 2
|
||||
assert stale.status == "CLOSED"
|
||||
live_users.assert_awaited_once_with(stale.document_key)
|
||||
recovery.assert_awaited_once_with(stale.document_key, "cell")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_active_session_is_reused_during_browser_connection_grace(env, monkeypatch):
|
||||
item = await env.db.get(CollaborationFile, env.initial[0].id)
|
||||
first = await office.build_editor_config(env.db, item, env.admin)
|
||||
live_users = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(office, "_document_server_users", live_users)
|
||||
|
||||
repeated = await office.build_editor_config(env.db, item, env.admin)
|
||||
|
||||
assert first.config["document"]["key"] == repeated.config["document"]["key"]
|
||||
live_users.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_grants_are_independent_and_all_file_routes_require_access(env):
|
||||
await grant(env, env.editor, "EDITOR")
|
||||
|
||||
@@ -119,6 +119,10 @@ services:
|
||||
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
|
||||
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
|
||||
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
|
||||
ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-true}
|
||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?请先运行安装脚本生成 ONLYOFFICE_JWT_SECRET}
|
||||
ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice}
|
||||
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:?请先运行安装脚本生成 ONLYOFFICE_INSTANCE_ID}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -137,7 +137,9 @@ bash scripts/install.sh release --base-url https://ctms.example.com
|
||||
可选参数:
|
||||
|
||||
```text
|
||||
--base-url <url> 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。
|
||||
--base-url <url> 对外访问基址,同时用于健康检查及 ONLYOFFICE 保存地址校验。dev/main 默认
|
||||
http://127.0.0.1:8888;已有环境优先复用 .env 的 CTMS_BASE_URL,
|
||||
首次 release 安装必填或交互输入。只接受协议与主机组成的 origin。
|
||||
--yes 跳过交互确认,适合自动化执行。
|
||||
--skip-build 跳过镜像构建,仍会执行 docker compose up -d。
|
||||
--skip-migrate 跳过 alembic upgrade head。
|
||||
@@ -147,12 +149,14 @@ bash scripts/install.sh release --base-url https://ctms.example.com
|
||||
|
||||
```text
|
||||
1. 检查 docker、docker compose、openssl、curl。
|
||||
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建。
|
||||
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建;将
|
||||
`CTMS_BASE_URL` 与 `FRONTEND_PUBLIC_URL` 同步为 `--base-url`,确保公开缓存 URL 可通过保存回调校验。
|
||||
3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
|
||||
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
|
||||
5. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
|
||||
6. 执行 docker compose run --rm backend python -m alembic upgrade head。
|
||||
7. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
|
||||
5. 在重启服务前通过 ONLYOFFICE `info` 命令检查真实在线编辑者;存在在线编辑时中止部署,待用户退出并完成最终保存后重试。
|
||||
6. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
|
||||
7. 执行 docker compose run --rm backend python -m alembic upgrade head。
|
||||
8. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
|
||||
```
|
||||
|
||||
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
|
||||
@@ -193,6 +197,8 @@ cat > .env <<'EOF'
|
||||
COMPOSE_PROJECT_NAME=ctms_dev
|
||||
ENV=development
|
||||
JWT_SECRET_KEY=dev-secret
|
||||
CTMS_BASE_URL=http://127.0.0.1:8888
|
||||
FRONTEND_PUBLIC_URL=http://127.0.0.1:8888
|
||||
LOGIN_RSA_KEY_ID=default
|
||||
LOGIN_RSA_PRIVATE_KEY=
|
||||
ONLYOFFICE_ENABLED=true
|
||||
|
||||
@@ -28,31 +28,15 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret
|
||||
|
||||
轮换会强制重建相关容器,已签发但尚未使用的短时预览配置会立即失效。生产环境不使用该自动生成流程,仍必须由部署密钥管理系统显式提供密钥。
|
||||
|
||||
可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像增加 Noto Sans CJK/Noto Serif CJK;其他字体由上游镜像或部署方提供。
|
||||
可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只额外增加 Noto Sans CJK/Noto Serif CJK;其他字体仅继承自获准使用的上游镜像。
|
||||
|
||||
## 导入宋体和 Times New Roman
|
||||
## 字体许可边界
|
||||
|
||||
将获准使用的字体放到 `onlyoffice/fonts/`,文件名为 `simsun.ttc`、`times.ttf`、`timesbd.ttf`、`timesi.ttf`、`timesbi.ttf`。宋体集合包含 SimSun(宋体)和 NSimSun(新宋体),Times New Roman 的四个文件分别提供常规、粗体、斜体和粗斜体。该目录的字体文件被 Git 忽略,必须在每台构建主机上单独提供;构建得到的镜像会包含这些字体。
|
||||
项目只在派生镜像中额外安装可再分发的 Noto CJK 字体,不打包、复制、下载或自动注入部署方提供的宋体、Times New Roman 等专有字体。文档指定了未安装字体时,ONLYOFFICE 会使用可用字体替代,版式可能产生差异。
|
||||
|
||||
从仓库根目录执行以下命令;如果运行的是独立开发栈,为每条 Compose 命令增加 `-f docker-compose.dev.yaml -p ctms_dev`:
|
||||
部署方不得将来源不明或没有服务器部署及再分发许可的字体放入项目目录、Docker 构建上下文或 `onlyoffice_data` 卷。确需增加其他字体时,应先取得相应授权并独立完成合规评估;当前安装和更新脚本不提供专有字体注入入口。
|
||||
|
||||
```bash
|
||||
docker compose build onlyoffice
|
||||
docker compose exec -T onlyoffice documentserver-prepare4shutdown.sh
|
||||
docker compose up -d --no-deps onlyoffice
|
||||
```
|
||||
|
||||
维护前先完成文档保存。上述关闭准备会等待在线文档保存,随后替换 ONLYOFFICE 容器;启动脚本自动重建字体索引。构建脚本仅在四个 Times New Roman 文件齐全时移除上游同名字体,避免编辑器继续选用旧版本。未提供字体的环境沿用原有字体。
|
||||
|
||||
健康检查通过后,重新打开在线文档,在字体列表中查找 `SimSun`(宋体)和 `Times New Roman`。可用以下命令核对字体来源:
|
||||
|
||||
```bash
|
||||
docker compose exec -T onlyoffice fc-match SimSun
|
||||
docker compose exec -T onlyoffice fc-match 'Times New Roman'
|
||||
docker compose exec -T onlyoffice curl -fsS http://127.0.0.1/healthcheck
|
||||
```
|
||||
|
||||
补充说明:当前版本的生成器也会扫描 `onlyoffice_data` 卷中的 `/var/www/onlyoffice/Data/custom-fonts/`。放在该处的字体可跨容器重建保留,但同名字体仍可能被上游版本优先选中;替换 Times New Roman 时使用上述镜像构建流程。迁移部署时应单独携带字体来源文件。字体索引刷新方式参见 [ONLYOFFICE 官方字体安装说明](https://helpcenter.onlyoffice.com/docs/installation/docs-install-fonts-docker.aspx)。
|
||||
`ONLYOFFICE_IMAGE` 上游基础镜像可能自带其他字体;其内容和许可不属于项目字体注入流程。分发派生镜像前仍应单独审查获准使用的上游镜像,不能以本项目已移除自定义字体作为上游字体合规结论。
|
||||
|
||||
## 配置边界
|
||||
|
||||
|
||||
@@ -45,8 +45,12 @@
|
||||
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。
|
||||
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
|
||||
|
||||
状态 2、3 或 4 的最终回调结束当前编辑生命周期,已关闭会话不得重新激活或复用原 `document.key`。数据库仍标记为 `ACTIVE`、但已接收过回调且 Document Server 的 `info` 命令确认没有在线编辑者时,同样按中断会话退役;刚签发但尚未收到首次连接回调的 key 保留 15 秒连接宽限,避免并发配置请求误判。对于保存错误或这类中断会话,再次打开文件时后端通过 ONLYOFFICE `getForgotten` 命令检查服务器是否保留了备份副本;存在备份时,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。正常的无修改关闭只推进代次,不做不必要的备份查询。如果文档服务器没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。
|
||||
|
||||
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
|
||||
|
||||
安装和更新流程在重启服务前使用 ONLYOFFICE `info` 命令核实数据库中 `ACTIVE` 会话的真实在线用户。仍有编辑者时部署会在服务重启前终止,并只显示文件名和在线人数;应通知用户退出编辑器并等待最终保存后重试。数据库中的陈旧 `ACTIVE` 记录若已不在 Document Server 中存在,不会误阻塞部署。不要通过强制重启或删除 ONLYOFFICE 数据卷绕过检查。
|
||||
|
||||
## 本地开发
|
||||
|
||||
标准开发安装会直接启动 ONLYOFFICE:
|
||||
@@ -60,6 +64,7 @@ bash scripts/install.sh dev
|
||||
关键配置:
|
||||
|
||||
- `ONLYOFFICE_ENABLED`
|
||||
- `FRONTEND_PUBLIC_URL`(必须与用户访问 CTMS 的 origin 一致;安装脚本会同步为 `--base-url`)
|
||||
- `ONLYOFFICE_JWT_SECRET`
|
||||
- `ONLYOFFICE_INTERNAL_URL`
|
||||
- `ONLYOFFICE_STORAGE_BASE_URL`
|
||||
|
||||
@@ -227,6 +227,7 @@ function sourceLabel(source: string) {
|
||||
COPY: "复制创建",
|
||||
SHARE_FORCE_SAVE: "链接协作保存",
|
||||
SHARE_SESSION_CLOSE: "链接协作关闭",
|
||||
SERVER_RECOVERY: "服务器备份恢复",
|
||||
} as Record<string, string>)[source] || source;
|
||||
}
|
||||
|
||||
|
||||
+2
-19
@@ -3,8 +3,8 @@ FROM ${ONLYOFFICE_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
# Use redistributable Noto CJK fonts for Chinese document preview. Proprietary
|
||||
# Microsoft fonts must be supplied separately by an authorized deployment.
|
||||
# Use redistributable Noto CJK fonts for Chinese document preview. Do not add
|
||||
# deployment-supplied proprietary Microsoft or Zhongyi fonts to this build.
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -16,20 +16,3 @@ RUN locale_root=/var/www/onlyoffice/documentserver/web-apps/apps \
|
||||
&& test -n "$matches" \
|
||||
&& sed -i 's/另存副本为/另存为/g' $matches \
|
||||
&& ! grep -RIl '另存副本为' "$locale_root"/*/main/locale/zh.json
|
||||
|
||||
# Deployment-supplied fonts are kept out of Git, but included in local builds.
|
||||
COPY fonts/ /usr/local/share/fonts/ctms/
|
||||
|
||||
# The upstream image contains an older Times New Roman family. Remove those
|
||||
# duplicates only when the deployment supplies all four replacement faces.
|
||||
RUN if [ -s /usr/local/share/fonts/ctms/times.ttf ] \
|
||||
&& [ -s /usr/local/share/fonts/ctms/timesbd.ttf ] \
|
||||
&& [ -s /usr/local/share/fonts/ctms/timesi.ttf ] \
|
||||
&& [ -s /usr/local/share/fonts/ctms/timesbi.ttf ]; then \
|
||||
rm -f /usr/share/fonts/truetype/msttcorefonts/Times_New_Roman*.ttf \
|
||||
/usr/share/fonts/truetype/msttcorefonts/times.ttf \
|
||||
/usr/share/fonts/truetype/msttcorefonts/timesbd.ttf \
|
||||
/usr/share/fonts/truetype/msttcorefonts/timesi.ttf \
|
||||
/usr/share/fonts/truetype/msttcorefonts/timesbi.ttf; \
|
||||
fi \
|
||||
&& fc-cache -f
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
+43
-7
@@ -62,9 +62,9 @@ ${C_BOLD}环境:${C_RESET}
|
||||
${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对)
|
||||
|
||||
${C_BOLD}选项:${C_RESET}
|
||||
${C_WHITE}--base-url <url>${C_RESET} 健康检查使用的访问地址
|
||||
${C_WHITE}--base-url <url>${C_RESET} 对外访问基址(同时用于健康检查与 ONLYOFFICE 回调校验)
|
||||
dev/main 默认 http://127.0.0.1:8888
|
||||
release 必须通过此参数或交互输入提供
|
||||
已有环境优先复用 .env;首次 release 安装必须提供
|
||||
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
|
||||
${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d)
|
||||
${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head)
|
||||
@@ -179,16 +179,25 @@ generate_onlyoffice_instance_id() {
|
||||
|
||||
# ── 地址解析 ─────────────────────────────────
|
||||
resolve_base_url() {
|
||||
if [[ -z "$BASE_URL" && -f "$ENV_FILE" ]]; then
|
||||
BASE_URL="$(read_env_value CTMS_BASE_URL || true)"
|
||||
[[ -n "$BASE_URL" ]] || BASE_URL="$(read_env_value FRONTEND_PUBLIC_URL || true)"
|
||||
fi
|
||||
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
|
||||
|
||||
if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then
|
||||
printf ' %s▸%s 请输入 release 健康检查域名(例如 https://ctms.example.com): ' \
|
||||
printf ' %s▸%s 请输入 release 对外访问基址(例如 https://ctms.example.com): ' \
|
||||
"${C_YELLOW}${C_BOLD}" "${C_RESET}"
|
||||
read -r BASE_URL || true
|
||||
fi
|
||||
|
||||
[[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址"
|
||||
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
if [[ ! "$BASE_URL" =~ ^https?://[^/?#]+$ || "$BASE_URL" == *"@"* ]]; then
|
||||
fail "对外访问基址必须是仅包含协议和主机的 HTTP(S) origin,例如 https://ctms.example.com"
|
||||
fi
|
||||
|
||||
if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then
|
||||
fail "release 环境的访问地址必须使用 HTTPS"
|
||||
fi
|
||||
@@ -222,7 +231,7 @@ confirm_install() {
|
||||
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
|
||||
row "运行模式" "$runtime_env"
|
||||
row "Compose 项目" "$project_name"
|
||||
row "健康检查地址" "$BASE_URL" "${CC_INFO}"
|
||||
row "对外访问基址" "$BASE_URL" "${CC_INFO}"
|
||||
row ".env 文件" "$env_status"
|
||||
row "pg_data 目录" "$pg_status"
|
||||
row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}"
|
||||
@@ -376,6 +385,23 @@ run_backend_init() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_onlyoffice_active_sessions() {
|
||||
step "检查 ONLYOFFICE 在线编辑会话"
|
||||
local running output
|
||||
running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)"
|
||||
if ! printf '%s\n' "$running" | grep -qx "onlyoffice"; then
|
||||
ok "ONLYOFFICE 尚未运行,跳过在线编辑会话检查"
|
||||
return 0
|
||||
fi
|
||||
if output="$(compose_cmd run --rm --no-deps backend-init \
|
||||
python scripts/check_onlyoffice_active_sessions.py 2>&1)"; then
|
||||
ok "$output"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$output" >&2
|
||||
fail "部署前置检查未通过;为避免在线文件保存失败,尚未重启任何业务服务"
|
||||
}
|
||||
|
||||
run_build_and_start() {
|
||||
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||||
step "启动服务(跳过镜像构建)"
|
||||
@@ -423,7 +449,7 @@ check_container_status() {
|
||||
|
||||
check_backend_environment() {
|
||||
step "校验后端运行时环境变量"
|
||||
local expected_env="$1" expected_key_id="$2" expect_rsa="$3"
|
||||
local expected_env="$1" expected_key_id="$2" expect_rsa="$3" expected_public_url="$4"
|
||||
local check_code
|
||||
check_code=$(cat <<'PY'
|
||||
import os, sys
|
||||
@@ -433,6 +459,7 @@ from app.core.login_crypto import _normalize_pem
|
||||
expected_env = os.environ["EXPECTED_ENV"]
|
||||
expected_key_id = os.environ["EXPECTED_KEY_ID"]
|
||||
expect_rsa = os.environ["EXPECT_RSA"] == "1"
|
||||
expected_public_url = os.environ["EXPECTED_PUBLIC_URL"].rstrip("/")
|
||||
|
||||
if settings.ENV != expected_env:
|
||||
sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}")
|
||||
@@ -448,6 +475,11 @@ if settings.ONLYOFFICE_JWT_SECRET == settings.JWT_SECRET_KEY:
|
||||
sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥")
|
||||
if not settings.ONLYOFFICE_INSTANCE_ID:
|
||||
sys.exit("ONLYOFFICE_INSTANCE_ID 未配置")
|
||||
if settings.FRONTEND_PUBLIC_URL.rstrip("/") != expected_public_url:
|
||||
sys.exit(
|
||||
"FRONTEND_PUBLIC_URL 不匹配;ONLYOFFICE 公开缓存地址会被保存回调拒绝: "
|
||||
f"{settings.FRONTEND_PUBLIC_URL} != {expected_public_url}"
|
||||
)
|
||||
if expect_rsa:
|
||||
if not settings.LOGIN_RSA_PRIVATE_KEY:
|
||||
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
|
||||
@@ -462,6 +494,7 @@ PY
|
||||
-e EXPECTED_ENV="$expected_env" \
|
||||
-e EXPECTED_KEY_ID="$expected_key_id" \
|
||||
-e EXPECT_RSA="$expect_rsa" \
|
||||
-e EXPECTED_PUBLIC_URL="$expected_public_url" \
|
||||
backend python -c "$check_code"
|
||||
}
|
||||
|
||||
@@ -508,7 +541,7 @@ run_health_checks() {
|
||||
[[ "$runtime_env" == "production" ]] && expect_rsa=1
|
||||
check_container_status
|
||||
check_dev_nginx_mode
|
||||
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
|
||||
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa" "$BASE_URL"
|
||||
step "探测 HTTP 接口可用性"
|
||||
check_http_endpoint "/health"
|
||||
check_http_endpoint "/readyz"
|
||||
@@ -555,10 +588,13 @@ main() {
|
||||
confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV"
|
||||
prepare_env_file "$project_name" "$runtime_env" "$login_key_id"
|
||||
sync_frontend_build_env "$runtime_env"
|
||||
# 健康检查地址持久化到 .env(单一事实来源);独立 upsert,绕开 prepare_env_file 对已存在 .env 的跳过。
|
||||
# 对外访问基址同时用于健康检查和后端校验 ONLYOFFICE 返回的公开缓存 URL。
|
||||
# 两项必须同步;否则生产域名下的最终保存回调会被当作不受信任地址拒绝。
|
||||
ctms_upsert_env_value CTMS_BASE_URL "$BASE_URL"
|
||||
ctms_upsert_env_value FRONTEND_PUBLIC_URL "$BASE_URL"
|
||||
run_compose_config
|
||||
run_backend_init
|
||||
check_onlyoffice_active_sessions
|
||||
run_build_and_start
|
||||
run_migrations
|
||||
resolve_effective_config "$project_name" "$runtime_env" "$login_key_id"
|
||||
|
||||
Reference in New Issue
Block a user