5 Commits

Author SHA1 Message Date
Cheng Zhou 684b8b51bb build(onlyoffice): 移除未授权字体注入入口
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-09-04 09:50:08 +08:00
Cheng Zhou 49b32dc20f fix(台账): 更新合同模板并恢复文档服务器备份 2026-09-04 09:49:51 +08:00
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
9 changed files with 42 additions and 386 deletions
@@ -59,37 +59,8 @@ async def _active_session(
).order_by(CollaborationSession.created_at.desc())
)
if 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 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,
@@ -100,14 +71,21 @@ async def _active_session(
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.
# 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
if not item.current_revision_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
session = CollaborationSession(
@@ -435,67 +413,6 @@ 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:
@@ -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 -96
View File
@@ -4,7 +4,7 @@ import uuid
import zipfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, call
from unittest.mock import ANY, AsyncMock
import pytest
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(
"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",
@@ -396,89 +384,6 @@ 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()
+1 -72
View File
@@ -1,7 +1,7 @@
import io
import uuid
import zipfile
from datetime import datetime, timezone
from datetime import timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -44,7 +44,6 @@ 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,
@@ -122,76 +121,6 @@ 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")
-4
View File
@@ -119,10 +119,6 @@ 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
+5 -11
View File
@@ -137,9 +137,7 @@ bash scripts/install.sh release --base-url https://ctms.example.com
可选参数:
```text
--base-url <url> 对外访问基址,同时用于健康检查及 ONLYOFFICE 保存地址校验。dev/main 默认
http://127.0.0.1:8888;已有环境优先复用 .env 的 CTMS_BASE_URL,
首次 release 安装必填或交互输入。只接受协议与主机组成的 origin。
--base-url <url> 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。
--yes 跳过交互确认,适合自动化执行。
--skip-build 跳过镜像构建,仍会执行 docker compose up -d。
--skip-migrate 跳过 alembic upgrade head。
@@ -149,14 +147,12 @@ bash scripts/install.sh release --base-url https://ctms.example.com
```text
1. 检查 docker、docker compose、openssl、curl。
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建;将
`CTMS_BASE_URL` 与 `FRONTEND_PUBLIC_URL` 同步为 `--base-url`,确保公开缓存 URL 可通过保存回调校验。
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建。
3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
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。
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。
```
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
@@ -197,8 +193,6 @@ 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
+1 -4
View File
@@ -45,12 +45,10 @@
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
状态 2、3 或 4 的最终回调结束当前编辑生命周期,已关闭会话不得重新激活或复用原 `document.key`。数据库仍标记为 `ACTIVE`、但已接收过回调且 Document Server 的 `info` 命令确认没有在线编辑者时,同样按中断会话退役;刚签发但尚未收到首次连接回调的 key 保留 15 秒连接宽限,避免并发配置请求误判。对于保存错误或这类中断会话,再次打开文件时后端通过 ONLYOFFICE `getForgotten` 命令检查服务器是否保留了备份副本;存在备份时,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。正常的无修改关闭只推进代次,不做不必要的备份查询。如果文档服务器没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。
如果最终保存返回状态 3,后端将会话标记为保存失败。再次打开文件时,先通过 ONLYOFFICE `getForgotten` 命令取回服务器保留的备份副本,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。如果文档服务器已经没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
安装和更新流程在重启服务前使用 ONLYOFFICE `info` 命令核实数据库中 `ACTIVE` 会话的真实在线用户。仍有编辑者时部署会在服务重启前终止,并只显示文件名和在线人数;应通知用户退出编辑器并等待最终保存后重试。数据库中的陈旧 `ACTIVE` 记录若已不在 Document Server 中存在,不会误阻塞部署。不要通过强制重启或删除 ONLYOFFICE 数据卷绕过检查。
## 本地开发
标准开发安装会直接启动 ONLYOFFICE:
@@ -64,7 +62,6 @@ bash scripts/install.sh dev
关键配置:
- `ONLYOFFICE_ENABLED`
- `FRONTEND_PUBLIC_URL`(必须与用户访问 CTMS 的 origin 一致;安装脚本会同步为 `--base-url`)
- `ONLYOFFICE_JWT_SECRET`
- `ONLYOFFICE_INTERNAL_URL`
- `ONLYOFFICE_STORAGE_BASE_URL`
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
+7 -43
View File
@@ -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} 对外访问基址(同时用于健康检查与 ONLYOFFICE 回调校验)
${C_WHITE}--base-url <url>${C_RESET} 健康检查使用的访问地址
dev/main 默认 http://127.0.0.1:8888
已有环境优先复用 .env;首次 release 安装必须提供
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,25 +179,16 @@ 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
@@ -231,7 +222,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}"
@@ -385,23 +376,6 @@ 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 "启动服务(跳过镜像构建)"
@@ -449,7 +423,7 @@ check_container_status() {
check_backend_environment() {
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
check_code=$(cat <<'PY'
import os, sys
@@ -459,7 +433,6 @@ 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}")
@@ -475,11 +448,6 @@ 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 未配置")
@@ -494,7 +462,6 @@ 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"
}
@@ -541,7 +508,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" "$BASE_URL"
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
step "探测 HTTP 接口可用性"
check_http_endpoint "/health"
check_http_endpoint "/readyz"
@@ -588,13 +555,10 @@ 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"
# 对外访问基址同时用于健康检查和后端校验 ONLYOFFICE 返回的公开缓存 URL。
# 两项必须同步;否则生产域名下的最终保存回调会被当作不受信任地址拒绝。
# 健康检查地址持久化到 .env(单一事实来源);独立 upsert,绕开 prepare_env_file 对已存在 .env 的跳过。
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"