From 8755553f7d6566665758de779b6316bc697f6c05 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Fri, 4 Sep 2026 11:20:08 +0800 Subject: [PATCH] =?UTF-8?q?release(main):=20=E4=BF=AE=E5=A4=8D=E4=BA=91?= =?UTF-8?q?=E7=AB=AF=E5=8F=B0=E8=B4=A6=E4=BF=9D=E5=AD=98=E4=B8=8E=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E9=87=8D=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onlyoffice_collaboration_service.py | 137 ++++++++++++++---- .../check_onlyoffice_active_sessions.py | 44 ++++++ backend/tests/test_collaboration_service.py | 97 ++++++++++++- backend/tests/test_ledgers.py | 73 +++++++++- docker-compose.yaml | 4 + .../guides/branch-environment-installation.md | 16 +- docs/onlyoffice-collaboration.md | 5 +- scripts/install.sh | 50 ++++++- 8 files changed, 384 insertions(+), 42 deletions(-) create mode 100644 backend/scripts/check_onlyoffice_active_sessions.py diff --git a/backend/app/services/onlyoffice_collaboration_service.py b/backend/app/services/onlyoffice_collaboration_service.py index b8386a07..cf49e7f0 100644 --- a/backend/app/services/onlyoffice_collaboration_service.py +++ b/backend/app/services/onlyoffice_collaboration_service.py @@ -59,33 +59,55 @@ async def _active_session( ).order_by(CollaborationSession.created_at.desc()) ) if session: - 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() - session = None - else: - 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( @@ -413,6 +435,67 @@ async def _recover_forgotten_content(document_key: str, file_type: str) -> bytes 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: diff --git a/backend/scripts/check_onlyoffice_active_sessions.py b/backend/scripts/check_onlyoffice_active_sessions.py new file mode 100644 index 00000000..201e2f8e --- /dev/null +++ b/backend/scripts/check_onlyoffice_active_sessions.py @@ -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() diff --git a/backend/tests/test_collaboration_service.py b/backend/tests/test_collaboration_service.py index d4b76cf1..264b2bfc 100644 --- a/backend/tests/test_collaboration_service.py +++ b/backend/tests/test_collaboration_service.py @@ -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", @@ -384,6 +396,89 @@ async def test_forgotten_document_command_rejects_a_damaged_backup(monkeypatch): 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() diff --git a/backend/tests/test_ledgers.py b/backend/tests/test_ledgers.py index 3389c9a1..c6da7457 100644 --- a/backend/tests/test_ledgers.py +++ b/backend/tests/test_ledgers.py @@ -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, @@ -121,6 +122,76 @@ async def test_failed_ledger_session_recovers_server_backup_under_a_new_document 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") diff --git a/docker-compose.yaml b/docker-compose.yaml index 446383d7..24190b9b 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/docs/guides/branch-environment-installation.md b/docs/guides/branch-environment-installation.md index fe7daa55..d10b316e 100644 --- a/docs/guides/branch-environment-installation.md +++ b/docs/guides/branch-environment-installation.md @@ -137,7 +137,9 @@ bash scripts/install.sh release --base-url https://ctms.example.com 可选参数: ```text ---base-url 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。 +--base-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 diff --git a/docs/onlyoffice-collaboration.md b/docs/onlyoffice-collaboration.md index 6b74eedf..f37a09b2 100644 --- a/docs/onlyoffice-collaboration.md +++ b/docs/onlyoffice-collaboration.md @@ -45,10 +45,12 @@ 5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。 6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。 -如果最终保存返回状态 3,后端将会话标记为保存失败。再次打开文件时,先通过 ONLYOFFICE `getForgotten` 命令取回服务器保留的备份副本,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。如果文档服务器已经没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。 +状态 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: @@ -62,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` diff --git a/scripts/install.sh b/scripts/install.sh index f35823e0..973129b0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -62,9 +62,9 @@ ${C_BOLD}环境:${C_RESET} ${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对) ${C_BOLD}选项:${C_RESET} - ${C_WHITE}--base-url ${C_RESET} 健康检查使用的访问地址 + ${C_WHITE}--base-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"