3 Commits

Author SHA1 Message Date
Cheng Zhou 5eb50c704f 修复桌面凭据测试对构建环境的依赖
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
为桌面会话测试显式隔离默认服务器地址,避免持续集成环境影响未配置服务器场景的断言。
补充默认服务器地址下的凭据恢复验证,并确认注入默认地址时全部前端测试通过。
2026-09-03 17:00:39 +08:00
Cheng Zhou c1dd8def2c 新增全局协作台账并统一工作台管理界面
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。
复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。
补充在线文档字体构建支持、数据库表结构升级和回归测试。
不包含本地台账内容、账号授权记录或历史文件迁移数据。
2026-09-03 16:55:08 +08:00
Cheng Zhou 46a6dda753 fix(monitoring): 修复飞线图白色渲染残影 2026-07-20 11:10:08 +08:00
12 changed files with 61 additions and 579 deletions
@@ -2,10 +2,8 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import io
import json import json
import uuid import uuid
import zipfile
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -59,55 +57,12 @@ async def _active_session(
).order_by(CollaborationSession.created_at.desc()) ).order_by(CollaborationSession.created_at.desc())
) )
if session: if session:
should_recover = session.status == "ERROR" if session.status != "ACTIVE":
if session.status == "ACTIVE": session.status = "ACTIVE"
created_at = session.created_at session.closed_at = None
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() await db.commit()
session = None await db.refresh(session)
return session
if not item.current_revision_id: if not item.current_revision_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
session = CollaborationSession( session = CollaborationSession(
@@ -384,118 +339,6 @@ async def _download_result(url: str) -> bytes:
return bytes(content) 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( async def _callback_user(
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
) -> User | None: ) -> User | None:
@@ -609,13 +452,9 @@ async def process_callback(
session.status = "CLOSED" session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc) session.closed_at = datetime.now(timezone.utc)
result = "UNCHANGED" result = "UNCHANGED"
elif payload.status == 3: elif payload.status in {3, 7}:
session.status = "ERROR" session.status = "ERROR"
result = "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): if payload.users or not ledger_access.is_ledger(item):
session.active_users = json.dumps(payload.users, ensure_ascii=True) session.active_users = json.dumps(payload.users, ensure_ascii=True)
Binary file not shown.
@@ -1,44 +0,0 @@
"""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()
+1 -189
View File
@@ -4,7 +4,7 @@ import uuid
import zipfile import zipfile
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, call from unittest.mock import ANY, AsyncMock
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
@@ -304,18 +304,6 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
assert onlyoffice_collaboration_service._validate_result_url( assert onlyoffice_collaboration_service._validate_result_url(
"http://localhost:8888/onlyoffice/cache/result.docx?token=signed" "http://localhost:8888/onlyoffice/cache/result.docx?token=signed"
) == "http://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 ( for value in (
"http://backend:8000/internal/file", "http://backend:8000/internal/file",
"http://onlyoffice.evil.example/cache/result.docx", "http://onlyoffice.evil.example/cache/result.docx",
@@ -328,157 +316,6 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
onlyoffice_collaboration_service._validate_result_url(value) 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 @pytest.mark.asyncio
async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path): async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path):
user_id = uuid.uuid4() user_id = uuid.uuid4()
@@ -1179,31 +1016,6 @@ async def test_callback_error_status_is_recorded_and_acknowledged():
assert db.added[0].result == "ERROR" 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 @pytest.mark.asyncio
async def test_force_save_updates_session_recovery_revision(monkeypatch): async def test_force_save_updates_session_recovery_revision(monkeypatch):
session = SimpleNamespace( session = SimpleNamespace(
+1 -107
View File
@@ -1,7 +1,7 @@
import io import io
import uuid import uuid
import zipfile import zipfile
from datetime import datetime, timezone from datetime import timezone
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -44,7 +44,6 @@ async def env(monkeypatch, tmp_path):
monkeypatch.setattr(collaboration, "COLLABORATION_ROOT", tmp_path) monkeypatch.setattr(collaboration, "COLLABORATION_ROOT", tmp_path)
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "ledger-test-secret-long-enough-for-tests") monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "ledger-test-secret-long-enough-for-tests")
monkeypatch.setattr(onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) 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: async with AsyncSession(engine, expire_on_commit=False) as db:
users = [User(id=uuid.uuid4(), email=f"ledger-{i}@example.com", password_hash="hash", 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, full_name=f"Ledger user {i}", clinical_department="test", is_admin=i == 0,
@@ -87,111 +86,6 @@ async def test_initialization_preserves_both_uploaded_templates_and_is_idempoten
assert "ledgers" in Path(revision.file_uri).parts 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 @pytest.mark.asyncio
async def test_account_grants_are_independent_and_all_file_routes_require_access(env): async def test_account_grants_are_independent_and_all_file_routes_require_access(env):
await grant(env, env.editor, "EDITOR") await grant(env, env.editor, "EDITOR")
-4
View File
@@ -119,10 +119,6 @@ services:
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888} FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-} LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default} 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+5 -11
View File
@@ -137,9 +137,7 @@ bash scripts/install.sh release --base-url https://ctms.example.com
可选参数: 可选参数:
```text ```text
--base-url <url> 对外访问基址,同时用于健康检查及 ONLYOFFICE 保存地址校验。dev/main 默认 --base-url <url> 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。
http://127.0.0.1:8888;已有环境优先复用 .env 的 CTMS_BASE_URL,
首次 release 安装必填或交互输入。只接受协议与主机组成的 origin。
--yes 跳过交互确认,适合自动化执行。 --yes 跳过交互确认,适合自动化执行。
--skip-build 跳过镜像构建,仍会执行 docker compose up -d。 --skip-build 跳过镜像构建,仍会执行 docker compose up -d。
--skip-migrate 跳过 alembic upgrade head。 --skip-migrate 跳过 alembic upgrade head。
@@ -149,14 +147,12 @@ bash scripts/install.sh release --base-url https://ctms.example.com
```text ```text
1. 检查 docker、docker compose、openssl、curl。 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 私钥。 3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。 4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
5. 在重启服务前通过 ONLYOFFICE `info` 命令检查真实在线编辑者;存在在线编辑时中止部署,待用户退出并完成最终保存后重试。 5. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
6. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。 6. 执行 docker compose run --rm backend python -m alembic upgrade head。
7. 执行 docker compose run --rm backend python -m alembic upgrade head。 7. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
8. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
``` ```
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。 更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
@@ -197,8 +193,6 @@ cat > .env <<'EOF'
COMPOSE_PROJECT_NAME=ctms_dev COMPOSE_PROJECT_NAME=ctms_dev
ENV=development ENV=development
JWT_SECRET_KEY=dev-secret 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_KEY_ID=default
LOGIN_RSA_PRIVATE_KEY= LOGIN_RSA_PRIVATE_KEY=
ONLYOFFICE_ENABLED=true ONLYOFFICE_ENABLED=true
+21 -5
View File
@@ -28,15 +28,31 @@ 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
项目只在派生镜像中额外安装可再分发的 Noto CJK 字体,不打包、复制、下载或自动注入部署方提供的宋体、Times New Roman 等专有字体。文档指定了未安装字体时,ONLYOFFICE 会使用可用字体替代,版式可能产生差异。 将获准使用的字体放到 `onlyoffice/fonts/`,文件名为 `simsun.ttc`、`times.ttf`、`timesbd.ttf`、`timesi.ttf`、`timesbi.ttf`。宋体集合包含 SimSun(宋体)和 NSimSun(新宋体),Times New Roman 的四个文件分别提供常规、粗体、斜体和粗斜体。该目录的字体文件被 Git 忽略,必须在每台构建主机上单独提供;构建得到的镜像会包含这些字体。
部署方不得将来源不明或没有服务器部署及再分发许可的字体放入项目目录、Docker 构建上下文或 `onlyoffice_data` 卷。确需增加其他字体时,应先取得相应授权并独立完成合规评估;当前安装和更新脚本不提供专有字体注入入口。 从仓库根目录执行以下命令;如果运行的是独立开发栈,为每条 Compose 命令增加 `-f docker-compose.dev.yaml -p ctms_dev`:
`ONLYOFFICE_IMAGE` 上游基础镜像可能自带其他字体;其内容和许可不属于项目字体注入流程。分发派生镜像前仍应单独审查获准使用的上游镜像,不能以本项目已移除自定义字体作为上游字体合规结论。 ```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)。
## 配置边界 ## 配置边界
-5
View File
@@ -45,12 +45,8 @@
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。 5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。 6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
状态 2、3 或 4 的最终回调结束当前编辑生命周期,已关闭会话不得重新激活或复用原 `document.key`。数据库仍标记为 `ACTIVE`、但已接收过回调且 Document Server 的 `info` 命令确认没有在线编辑者时,同样按中断会话退役;刚签发但尚未收到首次连接回调的 key 保留 15 秒连接宽限,避免并发配置请求误判。对于保存错误或这类中断会话,再次打开文件时后端通过 ONLYOFFICE `getForgotten` 命令检查服务器是否保留了备份副本;存在备份时,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。正常的无修改关闭只推进代次,不做不必要的备份查询。如果文档服务器没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。 内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
安装和更新流程在重启服务前使用 ONLYOFFICE `info` 命令核实数据库中 `ACTIVE` 会话的真实在线用户。仍有编辑者时部署会在服务重启前终止,并只显示文件名和在线人数;应通知用户退出编辑器并等待最终保存后重试。数据库中的陈旧 `ACTIVE` 记录若已不在 Document Server 中存在,不会误阻塞部署。不要通过强制重启或删除 ONLYOFFICE 数据卷绕过检查。
## 本地开发 ## 本地开发
标准开发安装会直接启动 ONLYOFFICE: 标准开发安装会直接启动 ONLYOFFICE:
@@ -64,7 +60,6 @@ bash scripts/install.sh dev
关键配置: 关键配置:
- `ONLYOFFICE_ENABLED` - `ONLYOFFICE_ENABLED`
- `FRONTEND_PUBLIC_URL`(必须与用户访问 CTMS 的 origin 一致;安装脚本会同步为 `--base-url`)
- `ONLYOFFICE_JWT_SECRET` - `ONLYOFFICE_JWT_SECRET`
- `ONLYOFFICE_INTERNAL_URL` - `ONLYOFFICE_INTERNAL_URL`
- `ONLYOFFICE_STORAGE_BASE_URL` - `ONLYOFFICE_STORAGE_BASE_URL`
@@ -227,7 +227,6 @@ function sourceLabel(source: string) {
COPY: "复制创建", COPY: "复制创建",
SHARE_FORCE_SAVE: "链接协作保存", SHARE_FORCE_SAVE: "链接协作保存",
SHARE_SESSION_CLOSE: "链接协作关闭", SHARE_SESSION_CLOSE: "链接协作关闭",
SERVER_RECOVERY: "服务器备份恢复",
} as Record<string, string>)[source] || source; } as Record<string, string>)[source] || source;
} }
+19 -2
View File
@@ -3,8 +3,8 @@ FROM ${ONLYOFFICE_IMAGE}
USER root USER root
# Use redistributable Noto CJK fonts for Chinese document preview. Do not add # Use redistributable Noto CJK fonts for Chinese document preview. Proprietary
# deployment-supplied proprietary Microsoft or Zhongyi fonts to this build. # Microsoft fonts must be supplied separately by an authorized deployment.
RUN apt-get update \ RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -16,3 +16,20 @@ RUN locale_root=/var/www/onlyoffice/documentserver/web-apps/apps \
&& test -n "$matches" \ && test -n "$matches" \
&& sed -i 's/另存副本为/另存为/g' $matches \ && sed -i 's/另存副本为/另存为/g' $matches \
&& ! grep -RIl '另存副本为' "$locale_root"/*/main/locale/zh.json && ! 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
+7 -43
View File
@@ -62,9 +62,9 @@ ${C_BOLD}环境:${C_RESET}
${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对) ${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对)
${C_BOLD}选项:${C_RESET} ${C_BOLD}选项:${C_RESET}
${C_WHITE}--base-url <url>${C_RESET} 对外访问基址(同时用于健康检查与 ONLYOFFICE 回调校验) ${C_WHITE}--base-url <url>${C_RESET} 健康检查使用的访问地址
dev/main 默认 http://127.0.0.1:8888 dev/main 默认 http://127.0.0.1:8888
已有环境优先复用 .env;首次 release 安装必须提供 release 必须通过此参数或交互输入提供
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行 ${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d) ${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d)
${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head) ${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head)
@@ -179,25 +179,16 @@ generate_onlyoffice_instance_id() {
# ── 地址解析 ───────────────────────────────── # ── 地址解析 ─────────────────────────────────
resolve_base_url() { 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)" [[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then 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}" "${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r BASE_URL || true read -r BASE_URL || true
fi fi
[[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址" [[ -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 if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then
fail "release 环境的访问地址必须使用 HTTPS" fail "release 环境的访问地址必须使用 HTTPS"
fi fi
@@ -231,7 +222,7 @@ confirm_install() {
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}" row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
row "运行模式" "$runtime_env" row "运行模式" "$runtime_env"
row "Compose 项目" "$project_name" row "Compose 项目" "$project_name"
row "对外访问基址" "$BASE_URL" "${CC_INFO}" row "健康检查地址" "$BASE_URL" "${CC_INFO}"
row ".env 文件" "$env_status" row ".env 文件" "$env_status"
row "pg_data 目录" "$pg_status" row "pg_data 目录" "$pg_status"
row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}" row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}"
@@ -385,23 +376,6 @@ run_backend_init() {
fi 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() { run_build_and_start() {
if [[ "$SKIP_BUILD" -eq 1 ]]; then if [[ "$SKIP_BUILD" -eq 1 ]]; then
step "启动服务(跳过镜像构建)" step "启动服务(跳过镜像构建)"
@@ -449,7 +423,7 @@ check_container_status() {
check_backend_environment() { check_backend_environment() {
step "校验后端运行时环境变量" step "校验后端运行时环境变量"
local expected_env="$1" expected_key_id="$2" expect_rsa="$3" expected_public_url="$4" local expected_env="$1" expected_key_id="$2" expect_rsa="$3"
local check_code local check_code
check_code=$(cat <<'PY' check_code=$(cat <<'PY'
import os, sys import os, sys
@@ -459,7 +433,6 @@ from app.core.login_crypto import _normalize_pem
expected_env = os.environ["EXPECTED_ENV"] expected_env = os.environ["EXPECTED_ENV"]
expected_key_id = os.environ["EXPECTED_KEY_ID"] expected_key_id = os.environ["EXPECTED_KEY_ID"]
expect_rsa = os.environ["EXPECT_RSA"] == "1" expect_rsa = os.environ["EXPECT_RSA"] == "1"
expected_public_url = os.environ["EXPECTED_PUBLIC_URL"].rstrip("/")
if settings.ENV != expected_env: if settings.ENV != expected_env:
sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}") sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}")
@@ -475,11 +448,6 @@ if settings.ONLYOFFICE_JWT_SECRET == settings.JWT_SECRET_KEY:
sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥") sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥")
if not settings.ONLYOFFICE_INSTANCE_ID: if not settings.ONLYOFFICE_INSTANCE_ID:
sys.exit("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 expect_rsa:
if not settings.LOGIN_RSA_PRIVATE_KEY: if not settings.LOGIN_RSA_PRIVATE_KEY:
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置") sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
@@ -494,7 +462,6 @@ PY
-e EXPECTED_ENV="$expected_env" \ -e EXPECTED_ENV="$expected_env" \
-e EXPECTED_KEY_ID="$expected_key_id" \ -e EXPECTED_KEY_ID="$expected_key_id" \
-e EXPECT_RSA="$expect_rsa" \ -e EXPECT_RSA="$expect_rsa" \
-e EXPECTED_PUBLIC_URL="$expected_public_url" \
backend python -c "$check_code" backend python -c "$check_code"
} }
@@ -541,7 +508,7 @@ run_health_checks() {
[[ "$runtime_env" == "production" ]] && expect_rsa=1 [[ "$runtime_env" == "production" ]] && expect_rsa=1
check_container_status check_container_status
check_dev_nginx_mode check_dev_nginx_mode
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa" "$BASE_URL" check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
step "探测 HTTP 接口可用性" step "探测 HTTP 接口可用性"
check_http_endpoint "/health" check_http_endpoint "/health"
check_http_endpoint "/readyz" check_http_endpoint "/readyz"
@@ -588,13 +555,10 @@ main() {
confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV" confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV"
prepare_env_file "$project_name" "$runtime_env" "$login_key_id" prepare_env_file "$project_name" "$runtime_env" "$login_key_id"
sync_frontend_build_env "$runtime_env" sync_frontend_build_env "$runtime_env"
# 对外访问基址同时用于健康检查和后端校验 ONLYOFFICE 返回的公开缓存 URL。 # 健康检查地址持久化到 .env(单一事实来源);独立 upsert,绕开 prepare_env_file 对已存在 .env 的跳过。
# 两项必须同步;否则生产域名下的最终保存回调会被当作不受信任地址拒绝。
ctms_upsert_env_value CTMS_BASE_URL "$BASE_URL" ctms_upsert_env_value CTMS_BASE_URL "$BASE_URL"
ctms_upsert_env_value FRONTEND_PUBLIC_URL "$BASE_URL"
run_compose_config run_compose_config
run_backend_init run_backend_init
check_onlyoffice_active_sessions
run_build_and_start run_build_and_start
run_migrations run_migrations
resolve_effective_config "$project_name" "$runtime_env" "$login_key_id" resolve_effective_config "$project_name" "$runtime_env" "$login_key_id"