6 Commits

Author SHA1 Message Date
Cheng Zhou 8755553f7d release(main): 修复云端台账保存与备份重开
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-09-04 11:20:08 +08:00
Cheng Zhou 54d395974f release(main): 同步台账恢复与字体合规调整
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-09-04 09:54:16 +08:00
chengchengzhou7 6c45cef4b7 同步全局协作台账与工作台界面优化
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
工作台新增与项目平级的全局协作台账,复用共享库在线协作能力。两本台账使用合同台账明细表、临床运营项目编号的空白模板,支持独立账号授权、在线编辑和历史版本保留,禁止删除整个台账及历史版本。

台账管理拆分为信息维护、访问与权限、历史版本三个独立入口;后两者与项目共享组件。统一项目和台账的卡片高度、数量徽标与标题布局,缩小工作台顶部留白,优化信息维护弹窗,并修复空用途说明和版本命名的默认提示。同步现有飞线图白色残影修复,以及在线文档的字体构建支持。

已验证:前端五百五十四项测试、后端台账与在线文档七十四项测试、类型检查、界面约束、运行时边界、桌面发布静态检查、网页构建和本地桌面应用构建均通过。数据库表结构升级已完成离线脚本生成检查,存储持久化检查通过。桌面凭据测试显式隔离构建环境,并覆盖使用默认服务器地址读取凭据的场景;在注入默认服务器地址的环境中完成全部前端测试。

提交包含必需的表结构升级和只有表头的初始模板;不包含本地台账业务记录、数据库导出、账号授权运行数据或上传目录中的历史文件。字体文件沿用部署方单独提供的方式。
2026-09-04 08:42:21 +08:00
Cheng Zhou 9c533b8c37 release(main): align v0.1.0 asset policy
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
2026-07-17 12:41:00 +08:00
Cheng Zhou 72b907947b release(main): promote v0.1.0 candidate
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Desktop Release Candidate / Resolve desktop release signing policy (push) Has been cancelled
Desktop Release Candidate / macOS release candidate (push) Has been cancelled
Desktop Release Candidate / Windows release candidate (push) Has been cancelled
Desktop Release Candidate / Verify combined desktop release directory (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-17 11:34:26 +08:00
chengchengzhou7 403776cc1c release(main): 同步 v0.1.0 updater 信任根 (#9)
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
* docs: add desktop project plan

* feat(desktop): implement phase 1 tauri client

* refactor(client): unify web and desktop release workflow

* feat(desktop): implement phase 2 native capabilities

* 完善桌面端交互体验与发布检查

* 完善桌面端界面、发布检查与邮箱域名同步

* fix(deploy): 修复数据库初始化复用旧镜像

* fix(deploy): 增加部署更新实时进度

* fix(auth): 支持无邮箱后缀时手动输入

* feat(desktop): 稳定桌面端界面与文件操作反馈

- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试

* feat(desktop): 支持桌面端三十天免登录

* 完善桌面端发布稳定化门禁

* 完善桌面端端到端回归收口

* 补齐桌面端附件文件流回归

* 完善桌面端回归与安全边界复审

* 完善桌面体验与系统通知收口

* feat(desktop): 收口桌面工作台视觉与活动反馈

* 优化桌面端界面布局

* style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标

* 功能(桌面端):增加在线辅助本地缓存

* 优化桌面端标签导航与后台交互

* ci: 新增 Windows 桌面端内测构建

* fix: 修复桌面检查脚本的 Windows 路径判断

* test: 兼容 Windows 换行的桌面布局断言

* test: 兼容 Windows 换行的路由断言

* ci: 修复 Windows 内测构建配置传参

* ci: 避免 Windows 安装器构建交互等待

* 修复桌面端界面显示与稳定性问题

* feat(网页端): 完善登录后工作台与项目管理体验

* docs(desktop): 精简桌面端约束入口

* feat(admin): 完善审计访问上下文与后台布局

* fix(git): 跟踪原生图标资源

* fix(web): 修正工作台端侧标识

* feat(监控): 完善系统监控、登录状态与访问审计能力

* 修复(权限管理):统一 PM 系统导航与权限校验

* feat(工作台): 优化入口布局与连接安全状态

* feat(桌面与监控): 完善工作台导航和登录活动定位

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖

* 功能(文档与桌面):完善文件预览下载与客户端构建基线

- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档

* 功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验

新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。

* feat(collaboration): 完善在线文档协作与通知闭环

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。

* refactor(frontend): 按需加载页面并清理未使用代码

- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。

* fix(审计): 移除共享库审计与预览噪声

* 功能(提醒):统一项目提醒中心与桌面通知链路

增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。

* feat(deploy): 默认安装 ONLYOFFICE 标准组件

* build(release): 加固 v0.1.0 桌面发布链路 (#3)

* build(release): 轮换 v0.1.0 updater 公钥 (#7)
2026-07-17 10:58:22 +08:00
9 changed files with 386 additions and 42 deletions
@@ -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:
@@ -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()
+96 -1
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
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()
+72 -1
View File
@@ -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")
+4
View File
@@ -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
+11 -5
View File
@@ -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
+4 -1
View File
@@ -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`
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+43 -7
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} 健康检查使用的访问地址
${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"