From 1d26646a96fe807c86920eef755125575a18f6b7 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 16 Jul 2026 14:14:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(collaboration):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E6=96=87=E6=A1=A3=E5=8D=8F=E4=BD=9C=E4=B8=8E?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。 --- .../20260714_01_add_collaboration_module.py | 136 + ...260715_01_add_collaboration_share_links.py | 53 + ...d_collaboration_share_export_permission.py | 25 + ..._03_add_collaboration_revision_deletion.py | 43 + ...04_add_collaboration_file_export_policy.py | 35 + ..._05_remove_share_link_export_permission.py | 33 + ...15_06_add_collaboration_access_controls.py | 66 + .../20260716_01_add_generic_notifications.py | 53 + ...fill_pending_edit_request_notifications.py | 75 + ...03_backfill_pending_edit_request_owners.py | 65 + backend/app/api/v1/collaboration.py | 629 ++++ backend/app/api/v1/notifications.py | 65 +- backend/app/api/v1/onlyoffice.py | 22 + backend/app/api/v1/router.py | 4 +- backend/app/core/api_permissions.py | 43 + backend/app/core/config.py | 1 + backend/app/db/base.py | 11 + backend/app/main.py | 2 + backend/app/models/collaboration.py | 212 ++ backend/app/models/notification.py | 49 + backend/app/schemas/collaboration.py | 285 ++ backend/app/schemas/notification.py | 22 + backend/app/schemas/onlyoffice.py | 2 +- backend/app/services/collaboration_service.py | 1350 ++++++++ .../services/collaboration_share_service.py | 302 ++ backend/app/services/notification_service.py | 186 ++ .../onlyoffice_collaboration_service.py | 422 +++ backend/app/services/onlyoffice_service.py | 30 +- .../app/services/project_reminder_service.py | 72 + backend/tests/test_api_permissions.py | 12 + backend/tests/test_collaboration_service.py | 1064 +++++++ backend/tests/test_notification_service.py | 139 + docker-compose.yaml | 1 + docs/onlyoffice-collaboration.md | 76 + frontend/public/onlyoffice-host.js | 109 +- frontend/scripts/verify-desktop-release.mjs | 14 + frontend/scripts/verify-runtime-boundary.mjs | 10 +- frontend/src-tauri/src/lib.rs | 46 +- frontend/src/api/axios.test.ts | 24 + frontend/src/api/axios.ts | 6 +- frontend/src/api/collaboration.test.ts | 174 ++ frontend/src/api/collaboration.ts | 198 ++ frontend/src/api/notifications.test.ts | 33 + frontend/src/api/notifications.ts | 19 +- frontend/src/api/onlyoffice.ts | 9 + .../src/components/ApiEndpointPermissions.vue | 3 +- frontend/src/components/DesktopLayout.vue | 134 +- .../src/components/Layout.desktop.test.ts | 27 +- .../src/components/OnlyOfficeViewer.test.ts | 96 + frontend/src/components/OnlyOfficeViewer.vue | 83 +- .../components/WebLayout.fullscreen.test.ts | 26 + frontend/src/components/WebLayout.vue | 241 +- .../CollaborationDownloadDialog.vue | 101 + .../CollaborationSaveAsDialog.vue | 237 ++ .../src/components/layout/navigation.test.ts | 24 +- frontend/src/components/layout/navigation.ts | 9 + .../useProjectNotifications.test.ts | 20 + .../composables/useProjectNotifications.ts | 116 + frontend/src/locales/zh-CN.ts | 16 +- frontend/src/router.test.ts | 28 +- frontend/src/router/index.ts | 36 +- frontend/src/runtime/appMetadata.test.ts | 6 +- frontend/src/runtime/desktopMenu.test.ts | 4 +- .../src/runtime/desktopServerConfig.test.ts | 4 +- .../src/runtime/desktopUiPreferences.test.ts | 4 +- frontend/src/runtime/files.test.ts | 13 + frontend/src/runtime/files.ts | 12 + frontend/src/runtime/index.ts | 8 + frontend/src/runtime/notifications.test.ts | 4 +- frontend/src/runtime/platform.test.ts | 20 + frontend/src/runtime/platform.ts | 14 +- .../src/runtime/savedLoginCredentials.test.ts | 8 +- .../src/runtime/secureSessionStorage.test.ts | 4 +- .../src/runtime/webFullscreen.desktop.test.ts | 61 + frontend/src/runtime/webFullscreen.test.ts | 94 + frontend/src/runtime/webFullscreen.ts | 92 + frontend/src/styles/main.css | 4 +- frontend/src/types/collaboration.ts | 146 + frontend/src/types/notifications.ts | 20 + frontend/src/types/onlyoffice.ts | 9 +- frontend/src/utils/fileTaskFeedback.test.ts | 29 + frontend/src/utils/fileTaskFeedback.ts | 25 + frontend/src/utils/permission.ts | 18 + .../src/utils/projectRoutePermissions.test.ts | 2 + frontend/src/utils/projectRoutePermissions.ts | 2 + .../src/views/OfficePreviewWorkspace.test.ts | 2 + frontend/src/views/OfficePreviewWorkspace.vue | 23 +- .../knowledge/CollaborationLibrary.test.ts | 332 ++ .../views/knowledge/CollaborationLibrary.vue | 2704 +++++++++++++++++ .../CollaborationShareWorkspace.test.ts | 41 + .../knowledge/CollaborationShareWorkspace.vue | 368 +++ .../knowledge/CollaborationWorkspace.test.ts | 94 + .../knowledge/CollaborationWorkspace.vue | 617 ++++ nginx/nginx.conf | 2 +- nginx/nginx.dev.conf | 2 +- onlyoffice/Dockerfile | 8 + scripts/onlyoffice-dev-up.sh | 7 + 97 files changed, 11911 insertions(+), 316 deletions(-) create mode 100644 backend/alembic/versions/20260714_01_add_collaboration_module.py create mode 100644 backend/alembic/versions/20260715_01_add_collaboration_share_links.py create mode 100644 backend/alembic/versions/20260715_02_add_collaboration_share_export_permission.py create mode 100644 backend/alembic/versions/20260715_03_add_collaboration_revision_deletion.py create mode 100644 backend/alembic/versions/20260715_04_add_collaboration_file_export_policy.py create mode 100644 backend/alembic/versions/20260715_05_remove_share_link_export_permission.py create mode 100644 backend/alembic/versions/20260715_06_add_collaboration_access_controls.py create mode 100644 backend/alembic/versions/20260716_01_add_generic_notifications.py create mode 100644 backend/alembic/versions/20260716_02_backfill_pending_edit_request_notifications.py create mode 100644 backend/alembic/versions/20260716_03_backfill_pending_edit_request_owners.py create mode 100644 backend/app/api/v1/collaboration.py create mode 100644 backend/app/models/collaboration.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/schemas/collaboration.py create mode 100644 backend/app/services/collaboration_service.py create mode 100644 backend/app/services/collaboration_share_service.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/app/services/onlyoffice_collaboration_service.py create mode 100644 backend/app/services/project_reminder_service.py create mode 100644 backend/tests/test_collaboration_service.py create mode 100644 backend/tests/test_notification_service.py create mode 100644 docs/onlyoffice-collaboration.md create mode 100644 frontend/src/api/collaboration.test.ts create mode 100644 frontend/src/api/collaboration.ts create mode 100644 frontend/src/api/notifications.test.ts create mode 100644 frontend/src/components/WebLayout.fullscreen.test.ts create mode 100644 frontend/src/components/collaboration/CollaborationDownloadDialog.vue create mode 100644 frontend/src/components/collaboration/CollaborationSaveAsDialog.vue create mode 100644 frontend/src/composables/useProjectNotifications.test.ts create mode 100644 frontend/src/composables/useProjectNotifications.ts create mode 100644 frontend/src/runtime/platform.test.ts create mode 100644 frontend/src/runtime/webFullscreen.desktop.test.ts create mode 100644 frontend/src/runtime/webFullscreen.test.ts create mode 100644 frontend/src/runtime/webFullscreen.ts create mode 100644 frontend/src/types/collaboration.ts create mode 100644 frontend/src/views/knowledge/CollaborationLibrary.test.ts create mode 100644 frontend/src/views/knowledge/CollaborationLibrary.vue create mode 100644 frontend/src/views/knowledge/CollaborationShareWorkspace.test.ts create mode 100644 frontend/src/views/knowledge/CollaborationShareWorkspace.vue create mode 100644 frontend/src/views/knowledge/CollaborationWorkspace.test.ts create mode 100644 frontend/src/views/knowledge/CollaborationWorkspace.vue diff --git a/backend/alembic/versions/20260714_01_add_collaboration_module.py b/backend/alembic/versions/20260714_01_add_collaboration_module.py new file mode 100644 index 00000000..e5be7b8d --- /dev/null +++ b/backend/alembic/versions/20260714_01_add_collaboration_module.py @@ -0,0 +1,136 @@ +"""Add the independent shared-library collaboration module. + +Revision ID: 20260714_01 +Revises: 20260713_02 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "20260714_01" +down_revision = "20260713_02" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + uuid_type = postgresql.UUID(as_uuid=True) + op.create_table( + "collaboration_folders", + sa.Column("id", uuid_type, primary_key=True), + sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False), + sa.Column("parent_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")), + sa.Column("name", sa.String(120), nullable=False), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_collaboration_folders_study_parent", "collaboration_folders", ["study_id", "parent_id"]) + + op.create_table( + "collaboration_files", + sa.Column("id", uuid_type, primary_key=True), + sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False), + sa.Column("folder_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("file_type", sa.String(16), nullable=False), + sa.Column("extension", sa.String(16), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"), + sa.Column("owner_id", uuid_type, sa.ForeignKey("users.id"), nullable=False), + sa.Column("current_revision_id", uuid_type), + sa.Column("generation", sa.Integer(), nullable=False, server_default="1"), + sa.Column("deleted_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_collaboration_files_study_folder", "collaboration_files", ["study_id", "folder_id"]) + op.create_index("ix_collaboration_files_study_status", "collaboration_files", ["study_id", "status"]) + + op.create_table( + "collaboration_members", + sa.Column("id", uuid_type, primary_key=True), + sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", uuid_type, sa.ForeignKey("users.id"), nullable=False), + sa.Column("role", sa.String(16), nullable=False), + sa.Column("invited_by", uuid_type, sa.ForeignKey("users.id"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"), + ) + op.create_index("ix_collaboration_members_user", "collaboration_members", ["user_id"]) + + op.create_table( + "collaboration_revisions", + sa.Column("id", uuid_type, primary_key=True), + sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("revision_no", sa.Integer(), nullable=False), + sa.Column("parent_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")), + sa.Column("file_uri", sa.String(500), nullable=False), + sa.Column("original_filename", sa.String(255), nullable=False), + sa.Column("file_hash", sa.String(128), nullable=False), + sa.Column("file_size", sa.BigInteger(), nullable=False), + sa.Column("mime_type", sa.String(100), nullable=False), + sa.Column("source", sa.String(24), nullable=False), + sa.Column("change_summary", sa.Text()), + sa.Column("created_by", uuid_type, sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"), + ) + op.create_index("ix_collaboration_revisions_file_created", "collaboration_revisions", ["file_id", "created_at"]) + op.create_foreign_key( + "fk_collaboration_files_current_revision", + "collaboration_files", + "collaboration_revisions", + ["current_revision_id"], + ["id"], + ondelete="SET NULL", + ) + + op.create_table( + "collaboration_sessions", + sa.Column("id", uuid_type, primary_key=True), + sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("base_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id"), nullable=False), + sa.Column("document_key", sa.String(128), nullable=False), + sa.Column("generation", sa.Integer(), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"), + sa.Column("started_by", uuid_type, sa.ForeignKey("users.id"), nullable=False), + sa.Column("active_users", sa.Text()), + sa.Column("last_callback_at", sa.DateTime(timezone=True)), + sa.Column("closed_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("document_key", name="uq_collaboration_session_document_key"), + ) + op.create_index("ix_collaboration_sessions_file_status", "collaboration_sessions", ["file_id", "status"]) + + op.create_table( + "collaboration_callback_receipts", + sa.Column("id", uuid_type, primary_key=True), + sa.Column("session_id", uuid_type, sa.ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False), + sa.Column("fingerprint", sa.String(128), nullable=False), + sa.Column("callback_status", sa.Integer(), nullable=False), + sa.Column("result", sa.String(24), nullable=False), + sa.Column("revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"), + ) + + +def downgrade() -> None: + op.drop_table("collaboration_callback_receipts") + op.drop_index("ix_collaboration_sessions_file_status", table_name="collaboration_sessions") + op.drop_table("collaboration_sessions") + op.drop_constraint("fk_collaboration_files_current_revision", "collaboration_files", type_="foreignkey") + op.drop_index("ix_collaboration_revisions_file_created", table_name="collaboration_revisions") + op.drop_table("collaboration_revisions") + op.drop_index("ix_collaboration_members_user", table_name="collaboration_members") + op.drop_table("collaboration_members") + op.drop_index("ix_collaboration_files_study_status", table_name="collaboration_files") + op.drop_index("ix_collaboration_files_study_folder", table_name="collaboration_files") + op.drop_table("collaboration_files") + op.drop_index("ix_collaboration_folders_study_parent", table_name="collaboration_folders") + op.drop_table("collaboration_folders") diff --git a/backend/alembic/versions/20260715_01_add_collaboration_share_links.py b/backend/alembic/versions/20260715_01_add_collaboration_share_links.py new file mode 100644 index 00000000..19eac159 --- /dev/null +++ b/backend/alembic/versions/20260715_01_add_collaboration_share_links.py @@ -0,0 +1,53 @@ +"""Add protected public links for collaboration files. + +Revision ID: 20260715_01 +Revises: 20260714_01 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "20260715_01" +down_revision = "20260714_01" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + uuid_type = postgresql.UUID(as_uuid=True) + op.create_table( + "collaboration_share_links", + sa.Column("id", uuid_type, primary_key=True), + sa.Column( + "file_id", + uuid_type, + sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("access_mode", sa.String(12), nullable=False, server_default="VIEW"), + sa.Column("expiry_policy", sa.String(16), nullable=False, server_default="SEVEN_DAYS"), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("password_hash", sa.String(255)), + sa.Column("token_version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_failed_at", sa.DateTime(timezone=True)), + sa.Column("locked_until", sa.DateTime(timezone=True)), + sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False), + sa.Column("updated_by", uuid_type, sa.ForeignKey("users.id"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("file_id", name="uq_collaboration_share_link_file"), + ) + op.create_index( + "ix_collaboration_share_links_enabled_expiry", + "collaboration_share_links", + ["enabled", "expires_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_collaboration_share_links_enabled_expiry", table_name="collaboration_share_links") + op.drop_table("collaboration_share_links") diff --git a/backend/alembic/versions/20260715_02_add_collaboration_share_export_permission.py b/backend/alembic/versions/20260715_02_add_collaboration_share_export_permission.py new file mode 100644 index 00000000..d1182ec7 --- /dev/null +++ b/backend/alembic/versions/20260715_02_add_collaboration_share_export_permission.py @@ -0,0 +1,25 @@ +"""Add effective export controls to collaboration share links. + +Revision ID: 20260715_02 +Revises: 20260715_01 +""" + +import sqlalchemy as sa +from alembic import op + + +revision = "20260715_02" +down_revision = "20260715_01" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "collaboration_share_links", + sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade() -> None: + op.drop_column("collaboration_share_links", "allow_export") diff --git a/backend/alembic/versions/20260715_03_add_collaboration_revision_deletion.py b/backend/alembic/versions/20260715_03_add_collaboration_revision_deletion.py new file mode 100644 index 00000000..3fa7146d --- /dev/null +++ b/backend/alembic/versions/20260715_03_add_collaboration_revision_deletion.py @@ -0,0 +1,43 @@ +"""Add soft deletion metadata to collaboration revisions. + +Revision ID: 20260715_03 +Revises: 20260715_02 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "20260715_03" +down_revision = "20260715_02" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "collaboration_revisions", + sa.Column("deleted_by", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "collaboration_revisions", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_foreign_key( + "fk_collaboration_revision_deleted_by", + "collaboration_revisions", + "users", + ["deleted_by"], + ["id"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_collaboration_revision_deleted_by", + "collaboration_revisions", + type_="foreignkey", + ) + op.drop_column("collaboration_revisions", "deleted_at") + op.drop_column("collaboration_revisions", "deleted_by") diff --git a/backend/alembic/versions/20260715_04_add_collaboration_file_export_policy.py b/backend/alembic/versions/20260715_04_add_collaboration_file_export_policy.py new file mode 100644 index 00000000..96706f2c --- /dev/null +++ b/backend/alembic/versions/20260715_04_add_collaboration_file_export_policy.py @@ -0,0 +1,35 @@ +"""Move collaboration export control to the file. + +Revision ID: 20260715_04 +Revises: 20260715_03 +""" + +import sqlalchemy as sa +from alembic import op + + +revision = "20260715_04" +down_revision = "20260715_03" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "collaboration_files", + sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + # Preserve links that already granted export: after consolidation the same + # setting applies to authenticated collaborators and anonymous visitors. + op.execute( + """ + UPDATE collaboration_files AS file + SET allow_export = true + FROM collaboration_share_links AS link + WHERE link.file_id = file.id AND link.allow_export = true + """ + ) + + +def downgrade() -> None: + op.drop_column("collaboration_files", "allow_export") diff --git a/backend/alembic/versions/20260715_05_remove_share_link_export_permission.py b/backend/alembic/versions/20260715_05_remove_share_link_export_permission.py new file mode 100644 index 00000000..04d13ae9 --- /dev/null +++ b/backend/alembic/versions/20260715_05_remove_share_link_export_permission.py @@ -0,0 +1,33 @@ +"""Remove the redundant share-link export permission. + +Revision ID: 20260715_05 +Revises: 20260715_04 +""" + +import sqlalchemy as sa +from alembic import op + + +revision = "20260715_05" +down_revision = "20260715_04" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("collaboration_share_links", "allow_export") + + +def downgrade() -> None: + op.add_column( + "collaboration_share_links", + sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.execute( + """ + UPDATE collaboration_share_links AS link + SET allow_export = file.allow_export + FROM collaboration_files AS file + WHERE file.id = link.file_id + """ + ) diff --git a/backend/alembic/versions/20260715_06_add_collaboration_access_controls.py b/backend/alembic/versions/20260715_06_add_collaboration_access_controls.py new file mode 100644 index 00000000..d311df7c --- /dev/null +++ b/backend/alembic/versions/20260715_06_add_collaboration_access_controls.py @@ -0,0 +1,66 @@ +"""Add collaboration edit requests and ownership controls. + +Revision ID: 20260715_06 +Revises: 20260715_05 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "20260715_06" +down_revision = "20260715_05" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "collaboration_files", + sa.Column("allow_edit_request", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + "collaboration_files", + sa.Column("allow_sheet_structure_edit", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column( + "collaboration_files", + sa.Column("sheet_structure_protection_backup", sa.Text(), nullable=True), + ) + op.create_table( + "collaboration_edit_requests", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("requester_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False, server_default="PENDING"), + sa.Column("resolved_by", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["file_id"], ["collaboration_files.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["requester_id"], ["users.id"]), + sa.ForeignKeyConstraint(["resolved_by"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_collaboration_edit_requests_file_status", + "collaboration_edit_requests", + ["file_id", "status"], + ) + op.create_index( + "uq_collaboration_edit_requests_pending_user", + "collaboration_edit_requests", + ["file_id", "requester_id"], + unique=True, + postgresql_where=sa.text("status = 'PENDING'"), + ) + + +def downgrade() -> None: + op.drop_index("uq_collaboration_edit_requests_pending_user", table_name="collaboration_edit_requests") + op.drop_index("ix_collaboration_edit_requests_file_status", table_name="collaboration_edit_requests") + op.drop_table("collaboration_edit_requests") + op.drop_column("collaboration_files", "sheet_structure_protection_backup") + op.drop_column("collaboration_files", "allow_sheet_structure_edit") + op.drop_column("collaboration_files", "allow_edit_request") diff --git a/backend/alembic/versions/20260716_01_add_generic_notifications.py b/backend/alembic/versions/20260716_01_add_generic_notifications.py new file mode 100644 index 00000000..ab681850 --- /dev/null +++ b/backend/alembic/versions/20260716_01_add_generic_notifications.py @@ -0,0 +1,53 @@ +"""Add recipient-scoped generic notifications. + +Revision ID: 20260716_01 +Revises: 20260715_06 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "20260716_01" +down_revision = "20260715_06" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "notifications", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("recipient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("category", sa.String(length=64), nullable=False), + sa.Column("priority", sa.String(length=16), nullable=False, server_default="NORMAL"), + sa.Column("title", sa.String(length=180), nullable=False), + sa.Column("message", sa.String(length=500), nullable=False), + sa.Column("action_path", sa.Text(), nullable=True), + sa.Column("source_type", sa.String(length=64), nullable=False), + sa.Column("source_id", sa.String(length=100), nullable=False), + sa.Column("source_version", sa.String(length=100), nullable=True), + sa.Column("dedupe_key", sa.String(length=255), nullable=False), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["recipient_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"), + ) + op.create_index( + "ix_notifications_recipient_study_state", + "notifications", + ["recipient_id", "study_id", "resolved_at", "read_at", "created_at"], + ) + op.create_index("ix_notifications_source", "notifications", ["source_type", "source_id"]) + + +def downgrade() -> None: + op.drop_index("ix_notifications_source", table_name="notifications") + op.drop_index("ix_notifications_recipient_study_state", table_name="notifications") + op.drop_table("notifications") diff --git a/backend/alembic/versions/20260716_02_backfill_pending_edit_request_notifications.py b/backend/alembic/versions/20260716_02_backfill_pending_edit_request_notifications.py new file mode 100644 index 00000000..fcfba70d --- /dev/null +++ b/backend/alembic/versions/20260716_02_backfill_pending_edit_request_notifications.py @@ -0,0 +1,75 @@ +"""Backfill notifications for pending collaboration edit requests. + +Revision ID: 20260716_02 +Revises: 20260716_01 +""" + +from alembic import op + + +revision = "20260716_02" +down_revision = "20260716_01" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + INSERT INTO notifications ( + id, + study_id, + recipient_id, + category, + priority, + title, + message, + action_path, + source_type, + source_id, + dedupe_key, + created_at, + updated_at + ) + SELECT + gen_random_uuid(), + collaboration_files.study_id, + study_members.user_id, + 'COLLABORATION_EDIT_REQUEST', + 'NORMAL', + '新的编辑权限申请', + concat( + coalesce(nullif(users.full_name, ''), users.email, '项目成员'), + ' 申请编辑“', + collaboration_files.title, + '”' + ), + concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text), + 'COLLABORATION_EDIT_REQUEST', + collaboration_edit_requests.id::text, + concat('collaboration-edit-request:', collaboration_edit_requests.id::text), + collaboration_edit_requests.created_at, + collaboration_edit_requests.updated_at + FROM collaboration_edit_requests + JOIN collaboration_files + ON collaboration_files.id = collaboration_edit_requests.file_id + JOIN users + ON users.id = collaboration_edit_requests.requester_id + JOIN study_members + ON study_members.study_id = collaboration_files.study_id + AND study_members.is_active IS TRUE + LEFT JOIN collaboration_members + ON collaboration_members.file_id = collaboration_files.id + AND collaboration_members.user_id = study_members.user_id + WHERE collaboration_edit_requests.status = 'PENDING' + AND collaboration_files.deleted_at IS NULL + AND ( + study_members.user_id = collaboration_files.owner_id + OR collaboration_members.role = 'MANAGER' + ) + ON CONFLICT (recipient_id, dedupe_key) DO NOTHING + """) + + +def downgrade() -> None: + # 这是业务通知数据迁移;降级时保留已读/未读状态,避免删除升级后新产生的同源通知。 + pass diff --git a/backend/alembic/versions/20260716_03_backfill_pending_edit_request_owners.py b/backend/alembic/versions/20260716_03_backfill_pending_edit_request_owners.py new file mode 100644 index 00000000..5c0f3651 --- /dev/null +++ b/backend/alembic/versions/20260716_03_backfill_pending_edit_request_owners.py @@ -0,0 +1,65 @@ +"""Backfill pending edit request notifications for file owners. + +Revision ID: 20260716_03 +Revises: 20260716_02 +""" + +from alembic import op + + +revision = "20260716_03" +down_revision = "20260716_02" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + INSERT INTO notifications ( + id, + study_id, + recipient_id, + category, + priority, + title, + message, + action_path, + source_type, + source_id, + dedupe_key, + created_at, + updated_at + ) + SELECT + gen_random_uuid(), + collaboration_files.study_id, + collaboration_files.owner_id, + 'COLLABORATION_EDIT_REQUEST', + 'NORMAL', + '新的编辑权限申请', + concat( + coalesce(nullif(users.full_name, ''), users.email, '项目成员'), + ' 申请编辑“', + collaboration_files.title, + '”' + ), + concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text), + 'COLLABORATION_EDIT_REQUEST', + collaboration_edit_requests.id::text, + concat('collaboration-edit-request:', collaboration_edit_requests.id::text), + collaboration_edit_requests.created_at, + collaboration_edit_requests.updated_at + FROM collaboration_edit_requests + JOIN collaboration_files + ON collaboration_files.id = collaboration_edit_requests.file_id + JOIN users + ON users.id = collaboration_edit_requests.requester_id + WHERE collaboration_edit_requests.status = 'PENDING' + AND collaboration_files.deleted_at IS NULL + ON CONFLICT (recipient_id, dedupe_key) DO NOTHING + """) + + +def downgrade() -> None: + # 与上一数据迁移一致,降级时保留已产生的业务通知状态。 + pass diff --git a/backend/app/api/v1/collaboration.py b/backend/app/api/v1/collaboration.py new file mode 100644 index 00000000..69731f90 --- /dev/null +++ b/backend/app/api/v1/collaboration.py @@ -0,0 +1,629 @@ +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, File, Form, Header, Request, Response, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session, require_api_permission +from app.schemas.collaboration import ( + CollaborationCallbackPayload, + CollaborationCandidateRead, + CollaborationEditorConfigRead, + CollaborationEditRequestRead, + CollaborationEditRequestResolve, + CollaborationExportRecord, + CollaborationFileCreate, + CollaborationFileRead, + CollaborationFileUpdate, + CollaborationFolderCreate, + CollaborationFolderRead, + CollaborationFolderUpdate, + CollaborationMemberRead, + CollaborationMemberUpsert, + CollaborationOwnershipTransferRequest, + CollaborationPublicEditorConfigRequest, + CollaborationPublicShareMetadata, + CollaborationRestoreRequest, + CollaborationRevisionCopyRequest, + CollaborationRevisionRead, + CollaborationRevisionUpdate, + CollaborationShareAccessGrant, + CollaborationShareLinkRead, + CollaborationShareLinkUpdate, + CollaborationSharePasswordRequest, +) +from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead +from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service + +router = APIRouter() +public_router = APIRouter() +internal_router = APIRouter(include_in_schema=False) + + +@router.get( + "/folders", + response_model=list[CollaborationFolderRead], + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def list_folders(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)): + return await collaboration_service.list_folders(db, study_id) + + +@router.post( + "/folders", + response_model=CollaborationFolderRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_api_permission("collaboration:manage"))], +) +async def create_folder( + study_id: uuid.UUID, + payload: CollaborationFolderCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + return await collaboration_service.create_folder(db, study_id, payload, current_user) + + +@router.patch( + "/folders/{folder_id}", + response_model=CollaborationFolderRead, + dependencies=[Depends(require_api_permission("collaboration:manage"))], +) +async def update_folder( + study_id: uuid.UUID, + folder_id: uuid.UUID, + payload: CollaborationFolderUpdate, + db: AsyncSession = Depends(get_db_session), +): + return await collaboration_service.update_folder(db, study_id, folder_id, payload) + + +@router.delete( + "/folders/{folder_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_api_permission("collaboration:manage"))], +) +async def delete_folder(study_id: uuid.UUID, folder_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)): + await collaboration_service.delete_folder(db, study_id, folder_id) + + +@router.get( + "/files", + response_model=list[CollaborationFileRead], + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def list_files( + study_id: uuid.UUID, + folder_id: uuid.UUID | None = None, + keyword: str | None = None, + deleted: bool = False, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + return await collaboration_service.list_files( + db, study_id, current_user, folder_id=folder_id, keyword=keyword, deleted=deleted + ) + + +@router.post( + "/files", + response_model=CollaborationFileRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_api_permission("collaboration:create"))], +) +async def create_file( + study_id: uuid.UUID, + payload: CollaborationFileCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.create_blank_file(db, study_id, payload, current_user) + return await collaboration_service.file_read(db, item, current_user) + + +@router.post( + "/files/import", + response_model=CollaborationFileRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_api_permission("collaboration:create"))], +) +async def import_file( + study_id: uuid.UUID, + file: UploadFile = File(...), + folder_id: uuid.UUID | None = Form(None), + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.import_file(db, study_id, folder_id, file, current_user) + return await collaboration_service.file_read(db, item, current_user) + + +@router.post( + "/files/{file_id}/copy", + response_model=CollaborationFileRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_api_permission("collaboration:create"))], +) +async def copy_file( + study_id: uuid.UUID, + file_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + source = await collaboration_service.get_file_or_404(db, study_id, file_id) + item = await collaboration_service.copy_file(db, source, current_user) + return await collaboration_service.file_read(db, item, current_user) + + +@router.get( + "/files/{file_id}", + response_model=CollaborationFileRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def get_file( + study_id: uuid.UUID, + file_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.file_read(db, item, current_user) + + +@router.get( + "/files/{file_id}/download", + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def download_file( + study_id: uuid.UUID, + file_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + revision = await collaboration_service.prepare_download(db, item, current_user) + return FileResponse( + path=revision.file_uri, + media_type=revision.mime_type, + filename=item.title, + content_disposition_type="attachment", + headers={"Cache-Control": "no-store"}, + ) + + +@router.patch( + "/files/{file_id}", + response_model=CollaborationFileRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def update_file( + study_id: uuid.UUID, + file_id: uuid.UUID, + payload: CollaborationFileUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + item = await collaboration_service.update_file(db, item, payload, current_user) + return await collaboration_service.file_read(db, item, current_user) + + +@router.delete( + "/files/{file_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def trash_file( + study_id: uuid.UUID, + file_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + await collaboration_service.move_to_trash(db, item, current_user) + + +@router.post( + "/files/{file_id}/restore", + response_model=CollaborationFileRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def restore_file( + study_id: uuid.UUID, + file_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id, include_deleted=True) + item = await collaboration_service.restore_file(db, item, current_user) + return await collaboration_service.file_read(db, item, current_user) + + +@router.get( + "/files/{file_id}/members", + response_model=list[CollaborationMemberRead], + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def list_members(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.list_members(db, item) + + +@router.put( + "/files/{file_id}/members", + response_model=CollaborationMemberRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def upsert_member( + study_id: uuid.UUID, + file_id: uuid.UUID, + payload: CollaborationMemberUpsert, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.upsert_member(db, item, payload, current_user) + + +@router.delete( + "/files/{file_id}/members/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def remove_member( + study_id: uuid.UUID, + file_id: uuid.UUID, + user_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + await collaboration_service.remove_member(db, item, user_id, current_user) + + +@router.post( + "/files/{file_id}/edit-requests", + response_model=CollaborationEditRequestRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def create_edit_request( + study_id: uuid.UUID, + file_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.create_edit_request(db, item, current_user) + + +@router.get( + "/files/{file_id}/edit-requests", + response_model=list[CollaborationEditRequestRead], + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def list_edit_requests( + study_id: uuid.UUID, + file_id: uuid.UUID, + pending_only: bool = True, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.list_edit_requests(db, item, current_user, pending_only=pending_only) + + +@router.post( + "/files/{file_id}/edit-requests/{request_id}/resolve", + response_model=CollaborationEditRequestRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def resolve_edit_request( + study_id: uuid.UUID, + file_id: uuid.UUID, + request_id: uuid.UUID, + payload: CollaborationEditRequestResolve, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.resolve_edit_request(db, item, request_id, payload, current_user) + + +@router.post( + "/files/{file_id}/transfer-ownership", + response_model=CollaborationFileRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def transfer_ownership( + study_id: uuid.UUID, + file_id: uuid.UUID, + payload: CollaborationOwnershipTransferRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + item = await collaboration_service.transfer_ownership(db, item, payload, current_user) + return await collaboration_service.file_read(db, item, current_user) + + +@router.get( + "/files/{file_id}/share-link", + response_model=CollaborationShareLinkRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def get_share_link( + study_id: uuid.UUID, + file_id: uuid.UUID, + response: Response, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + result = await collaboration_share_service.get_share_link(db, item, current_user) + response.headers["Cache-Control"] = "no-store" + return result + + +@router.put( + "/files/{file_id}/share-link", + response_model=CollaborationShareLinkRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def update_share_link( + study_id: uuid.UUID, + file_id: uuid.UUID, + payload: CollaborationShareLinkUpdate, + response: Response, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + result = await collaboration_share_service.update_share_link(db, item, payload, current_user) + response.headers["Cache-Control"] = "no-store" + return result + + +@router.get( + "/member-candidates", + response_model=list[CollaborationCandidateRead], + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def list_member_candidates(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)): + return await collaboration_service.list_candidates(db, study_id) + + +@router.get( + "/files/{file_id}/revisions", + response_model=list[CollaborationRevisionRead], + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def list_revisions(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.list_revisions(db, item) + + +@router.patch( + "/files/{file_id}/revisions/{revision_id}", + response_model=CollaborationRevisionRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def update_revision( + study_id: uuid.UUID, + file_id: uuid.UUID, + revision_id: uuid.UUID, + payload: CollaborationRevisionUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.update_revision(db, item, revision_id, payload, current_user) + + +@router.delete( + "/files/{file_id}/revisions/{revision_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def delete_revision( + study_id: uuid.UUID, + file_id: uuid.UUID, + revision_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + await collaboration_service.delete_revision(db, item, revision_id, current_user) + + +@router.post( + "/files/{file_id}/revisions/{revision_id}/copy", + response_model=CollaborationFileRead, + status_code=status.HTTP_201_CREATED, + dependencies=[ + Depends(require_api_permission("collaboration:create")), + Depends(require_api_permission("collaboration:read")), + ], +) +async def copy_revision( + study_id: uuid.UUID, + file_id: uuid.UUID, + revision_id: uuid.UUID, + payload: CollaborationRevisionCopyRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + copied = await collaboration_service.copy_revision(db, item, revision_id, payload, current_user) + return await collaboration_service.file_read(db, copied, current_user) + + +@router.get( + "/files/{file_id}/revisions/{revision_id}/preview-config", + response_model=OnlyOfficePreviewConfigRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def get_revision_preview_config( + study_id: uuid.UUID, + file_id: uuid.UUID, + revision_id: uuid.UUID, + response: Response, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, current_user) + await onlyoffice_service.ensure_onlyoffice_available() + result = onlyoffice_service.build_preview_config( + resource_type="collaboration_revision", + resource_id=revision.id, + file_name=item.title, + file_hash=revision.file_hash, + user_id=current_user.id, + user_name=current_user.full_name, + ) + response.headers["Cache-Control"] = "no-store" + return result + + +@router.post( + "/files/{file_id}/revisions/{revision_id}/restore", + response_model=CollaborationRevisionRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def restore_revision( + study_id: uuid.UUID, + file_id: uuid.UUID, + revision_id: uuid.UUID, + payload: CollaborationRestoreRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + return await collaboration_service.restore_revision( + db, item, revision_id, current_user, payload.change_summary + ) + + +@router.get( + "/files/{file_id}/editor-config", + response_model=CollaborationEditorConfigRead, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def get_editor_config( + study_id: uuid.UUID, + file_id: uuid.UUID, + response: Response, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + result = await onlyoffice_collaboration_service.build_editor_config(db, item, current_user) + response.headers["Cache-Control"] = "no-store" + return result + + +@router.post( + "/files/{file_id}/exports", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def record_export( + study_id: uuid.UUID, + file_id: uuid.UUID, + payload: CollaborationExportRecord, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + await collaboration_service.record_export(db, item, current_user, payload.file_type) + + +@router.post( + "/files/{file_id}/downloads", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_api_permission("collaboration:read"))], +) +async def record_download( + study_id: uuid.UUID, + file_id: uuid.UUID, + payload: CollaborationExportRecord, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + item = await collaboration_service.get_file_or_404(db, study_id, file_id) + await collaboration_service.record_download(db, item, current_user, payload.file_type) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@public_router.get("/metadata", response_model=CollaborationPublicShareMetadata) +async def get_public_share_metadata( + response: Response, + x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"), + db: AsyncSession = Depends(get_db_session), +): + result = await collaboration_share_service.public_metadata(db, x_ctms_share_token) + response.headers["Cache-Control"] = "no-store" + return result + + +@public_router.post("/access", response_model=CollaborationShareAccessGrant) +async def verify_public_share_password( + payload: CollaborationSharePasswordRequest, + response: Response, + x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"), + db: AsyncSession = Depends(get_db_session), +): + result = await collaboration_share_service.verify_share_password( + db, x_ctms_share_token, payload.password + ) + response.headers["Cache-Control"] = "no-store" + return result + + +@public_router.post("/editor-config", response_model=CollaborationEditorConfigRead) +async def get_public_share_editor_config( + payload: CollaborationPublicEditorConfigRequest, + response: Response, + x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"), + db: AsyncSession = Depends(get_db_session), +): + link, item = await collaboration_share_service.resolve_active_share(db, x_ctms_share_token) + collaboration_share_service.validate_access_grant(link, payload.access_token) + result = await onlyoffice_collaboration_service.build_shared_editor_config( + db, + item, + link, + client_id=payload.client_id, + display_name=payload.display_name, + ) + response.headers["Cache-Control"] = "no-store" + return result + + +@internal_router.get("/internal/onlyoffice/collaboration/sessions/{session_id}/content") +async def get_session_content(session_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session)): + revision, item = await onlyoffice_collaboration_service.get_session_content( + db, session_id, request.headers.get("AuthorizationJwt") + ) + return FileResponse( + path=revision.file_uri, + media_type=revision.mime_type, + filename=item.title, + content_disposition_type="inline", + ) + + +@internal_router.post("/internal/onlyoffice/collaboration/sessions/{session_id}/callback") +async def collaboration_callback( + session_id: uuid.UUID, + payload: CollaborationCallbackPayload, + request: Request, + db: AsyncSession = Depends(get_db_session), +): + onlyoffice_collaboration_service.validate_callback_token( + request.headers.get("AuthorizationJwt"), payload + ) + return await onlyoffice_collaboration_service.process_callback(db, session_id, payload) diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py index 7889b3e1..9e915a39 100644 --- a/backend/app/api/v1/notifications.py +++ b/backend/app/api/v1/notifications.py @@ -1,13 +1,15 @@ +import logging import uuid -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Response, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_db_session, get_current_user, require_study_member -from app.schemas.notification import NotificationItem -from app.services import document_service +from app.schemas.notification import GeneralNotificationFeed, GeneralNotificationRead, NotificationItem +from app.services import document_service, notification_service, project_reminder_service router = APIRouter() +logger = logging.getLogger(__name__) @router.get( @@ -29,3 +31,60 @@ async def list_notifications( skip=skip, limit=limit, ) + + +@router.get( + "/notifications/feed", + response_model=GeneralNotificationFeed, + dependencies=[Depends(require_study_member())], +) +async def list_general_notifications( + study_id: uuid.UUID, + limit: int = 10, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> GeneralNotificationFeed: + try: + await project_reminder_service.sync_project_reminders(db, study_id, current_user) + except Exception: + await db.rollback() + logger.warning("Failed to synchronize legacy project reminders", exc_info=True) + return await notification_service.list_feed( + db, + study_id=study_id, + recipient_id=current_user.id, + limit=limit, + ) + + +@router.post( + "/notifications/{notification_id}/read", + response_model=GeneralNotificationRead, + dependencies=[Depends(require_study_member())], +) +async def mark_general_notification_read( + study_id: uuid.UUID, + notification_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> GeneralNotificationRead: + return await notification_service.mark_read( + db, + study_id=study_id, + recipient_id=current_user.id, + notification_id=notification_id, + ) + + +@router.post( + "/notifications/read-all", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_study_member())], +) +async def mark_all_general_notifications_read( + study_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> Response: + await notification_service.mark_all_read(db, study_id=study_id, recipient_id=current_user.id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/v1/onlyoffice.py b/backend/app/api/v1/onlyoffice.py index 278d1b67..33cffdf0 100644 --- a/backend/app/api/v1/onlyoffice.py +++ b/backend/app/api/v1/onlyoffice.py @@ -16,6 +16,7 @@ from app.crud import attachment as attachment_crud from app.crud import audit as audit_crud from app.crud import document as document_crud from app.crud import document_version as version_crud +from app.models.collaboration import CollaborationRevision from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead from app.services import document_service, onlyoffice_service @@ -210,3 +211,24 @@ async def get_internal_version_content( media_type=version.mime_type or "application/octet-stream", headers={"Content-Disposition": _content_disposition(file_name)}, ) + + +@internal_router.get("/internal/onlyoffice/collaboration-revisions/{revision_id}/content") +async def get_internal_collaboration_revision_content( + revision_id: uuid.UUID, + request: Request, + db: AsyncSession = Depends(get_db_session), +) -> FileResponse: + expected_url = onlyoffice_service.onlyoffice_content_url("collaboration_revision", revision_id) + _authorize_internal_file_request(request, expected_url) + revision = await db.get(CollaborationRevision, revision_id) + file_path = Path(revision.file_uri) if revision else None + if not revision or getattr(revision, "deleted_at", None) is not None or not file_path or not file_path.exists(): + raise onlyoffice_service.onlyoffice_error( + "COLLABORATION_REVISION_NOT_FOUND", "协作修订不存在", status.HTTP_404_NOT_FOUND + ) + return FileResponse( + path=str(file_path), + media_type=revision.mime_type or "application/octet-stream", + headers={"Content-Disposition": _content_disposition(revision.original_filename)}, + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 30eaeb55..e9f0f25e 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice +from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice, collaboration api_router = APIRouter() @@ -30,6 +30,8 @@ api_router.include_router(material_equipments.router, prefix="/studies/{study_id api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"]) api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"]) api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"]) +api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"]) +api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"]) api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"]) api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"]) api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"]) diff --git a/backend/app/core/api_permissions.py b/backend/app/core/api_permissions.py index 28fbc53e..320f4cf7 100644 --- a/backend/app/core/api_permissions.py +++ b/backend/app/core/api_permissions.py @@ -643,6 +643,43 @@ API_ENDPOINT_PERMISSIONS = { "description": "删除文档", "default_roles": ["PM"], }, + # 共享库在线协作(文档级邀请权限在接口权限之后继续校验) + "collaboration:create": { + "module": "shared_library", + "action": "write", + "description": "创建在线协作文件", + "default_roles": ["PM", "CRA", "PV", "QA"], + }, + "collaboration:read": { + "module": "shared_library", + "action": "read", + "description": "查看在线协作文件", + "default_roles": ["PM", "CRA", "PV", "QA", "CTA"], + }, + "collaboration:edit": { + "module": "shared_library", + "action": "write", + "description": "编辑受邀在线协作文件", + "default_roles": ["PM", "CRA", "PV", "QA", "CTA"], + }, + "collaboration:manage": { + "module": "shared_library", + "action": "write", + "description": "管理在线协作文件成员与目录", + "default_roles": ["PM", "CRA", "PV", "QA"], + }, + "collaboration:export": { + "module": "shared_library", + "action": "export", + "description": "另存为在线协作文件副本", + "default_roles": ["PM", "CRA", "PV", "QA"], + }, + "collaboration:delete": { + "module": "shared_library", + "action": "write", + "description": "移入或恢复在线协作文件", + "default_roles": ["PM", "CRA", "PV", "QA"], + }, } def _operation_read_candidates(operation_key: str) -> list[str]: @@ -843,6 +880,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = { "precautions:read", "faq:read", "faq_category:read", + "collaboration:read", ], "write": [ "precautions:create", @@ -858,6 +896,11 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = { "faq_reply:create", "faq_reply:delete", "faq_attachments:delete", + "collaboration:create", + "collaboration:edit", + "collaboration:manage", + "collaboration:export", + "collaboration:delete", ], }, } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ed90ac6c..3ddee4d5 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -57,6 +57,7 @@ class Settings(BaseSettings): ONLYOFFICE_STORAGE_BASE_URL: str = "http://backend:8000" ONLYOFFICE_INSTANCE_ID: Optional[str] = None ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900) + COLLABORATION_MAX_FILE_BYTES: int = Field(default=50 * 1024 * 1024, ge=1024, le=500 * 1024 * 1024) @lru_cache diff --git a/backend/app/db/base.py b/backend/app/db/base.py index b39b03d6..b9c2a24a 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -10,6 +10,16 @@ from app.models.audit_log import AuditLog # noqa: F401 from app.models.etmf import EtmfNode # noqa: F401 from app.models.document import Document # noqa: F401 from app.models.document_version import DocumentVersion # noqa: F401 +from app.models.collaboration import ( # noqa: F401 + CollaborationCallbackReceipt, + CollaborationEditRequest, + CollaborationFile, + CollaborationFolder, + CollaborationMember, + CollaborationRevision, + CollaborationSession, + CollaborationShareLink, +) from app.models.distribution import Distribution # noqa: F401 from app.models.acknowledgement import Acknowledgement # noqa: F401 from app.models.milestone import Milestone # noqa: F401 @@ -48,4 +58,5 @@ from app.models.desktop_notification import ( # noqa: F401 DesktopNotificationDelivery, DesktopNotificationSubscription, ) +from app.models.notification import Notification # noqa: F401 from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401 diff --git a/backend/app/main.py b/backend/app/main.py index 97901745..2e74be0c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from sqlalchemy import text from app.api.v1.router import api_router from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router +from app.api.v1.collaboration import internal_router as collaboration_internal_router from app.core.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration from app.core.exceptions import register_exception_handlers from app.core.login_crypto import validate_login_crypto_configuration @@ -300,6 +301,7 @@ def create_app() -> FastAPI: app.include_router(api_router, prefix="/api/v1") app.include_router(onlyoffice_internal_router) + app.include_router(collaboration_internal_router) return app diff --git a/backend/app/models/collaboration.py b/backend/app/models/collaboration.py new file mode 100644 index 00000000..f1727b01 --- /dev/null +++ b/backend/app/models/collaboration.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class CollaborationFolder(Base): + __tablename__ = "collaboration_folders" + __table_args__ = ( + Index("ix_collaboration_folders_study_parent", "study_id", "parent_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False) + parent_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class CollaborationFile(Base): + __tablename__ = "collaboration_files" + __table_args__ = ( + Index("ix_collaboration_files_study_folder", "study_id", "folder_id"), + Index("ix_collaboration_files_study_status", "study_id", "status"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False) + folder_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + file_type: Mapped[str] = mapped_column(String(16), nullable=False) + extension: Mapped[str] = mapped_column(String(16), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE") + owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + current_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True + ) + generation: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") + allow_export: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + allow_edit_request: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + allow_sheet_structure_edit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + sheet_structure_protection_backup: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class CollaborationMember(Base): + __tablename__ = "collaboration_members" + __table_args__ = ( + UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"), + Index("ix_collaboration_members_user", "user_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + file_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + role: Mapped[str] = mapped_column(String(16), nullable=False) + invited_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class CollaborationEditRequest(Base): + __tablename__ = "collaboration_edit_requests" + __table_args__ = ( + Index("ix_collaboration_edit_requests_file_status", "file_id", "status"), + Index( + "uq_collaboration_edit_requests_pending_user", + "file_id", + "requester_id", + unique=True, + postgresql_where=text("status = 'PENDING'"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + file_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False + ) + requester_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", server_default="PENDING") + resolved_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class CollaborationShareLink(Base): + __tablename__ = "collaboration_share_links" + __table_args__ = ( + UniqueConstraint("file_id", name="uq_collaboration_share_link_file"), + Index("ix_collaboration_share_links_enabled_expiry", "enabled", "expires_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + file_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False + ) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + access_mode: Mapped[str] = mapped_column(String(12), nullable=False, default="VIEW", server_default="VIEW") + expiry_policy: Mapped[str] = mapped_column( + String(16), nullable=False, default="SEVEN_DAYS", server_default="SEVEN_DAYS" + ) + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + token_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") + failed_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + last_failed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + updated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class CollaborationRevision(Base): + __tablename__ = "collaboration_revisions" + __table_args__ = ( + UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"), + Index("ix_collaboration_revisions_file_created", "file_id", "created_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + file_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False + ) + revision_no: Mapped[int] = mapped_column(Integer, nullable=False) + parent_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True + ) + file_uri: Mapped[str] = mapped_column(String(500), nullable=False) + original_filename: Mapped[str] = mapped_column(String(255), nullable=False) + file_hash: Mapped[str] = mapped_column(String(128), nullable=False) + file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) + mime_type: Mapped[str] = mapped_column(String(100), nullable=False) + source: Mapped[str] = mapped_column(String(24), nullable=False) + change_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + + +class CollaborationSession(Base): + __tablename__ = "collaboration_sessions" + __table_args__ = ( + UniqueConstraint("document_key", name="uq_collaboration_session_document_key"), + Index("ix_collaboration_sessions_file_status", "file_id", "status"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + file_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False + ) + base_revision_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_revisions.id"), nullable=False + ) + document_key: Mapped[str] = mapped_column(String(128), nullable=False) + generation: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE") + started_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + active_users: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + last_callback_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class CollaborationCallbackReceipt(Base): + __tablename__ = "collaboration_callback_receipts" + __table_args__ = ( + UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False + ) + fingerprint: Mapped[str] = mapped_column(String(128), nullable=False) + callback_status: Mapped[int] = mapped_column(Integer, nullable=False) + result: Mapped[str] = mapped_column(String(24), nullable=False) + revision_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 00000000..0d350757 --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class Notification(Base): + __tablename__ = "notifications" + __table_args__ = ( + UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"), + Index( + "ix_notifications_recipient_study_state", + "recipient_id", + "study_id", + "resolved_at", + "read_at", + "created_at", + ), + Index("ix_notifications_source", "source_type", "source_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("studies.id", ondelete="CASCADE"), nullable=False + ) + recipient_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + category: Mapped[str] = mapped_column(String(64), nullable=False) + priority: Mapped[str] = mapped_column(String(16), nullable=False, default="NORMAL", server_default="NORMAL") + title: Mapped[str] = mapped_column(String(180), nullable=False) + message: Mapped[str] = mapped_column(String(500), nullable=False) + action_path: Mapped[str | None] = mapped_column(Text, nullable=True) + source_type: Mapped[str] = mapped_column(String(64), nullable=False) + source_id: Mapped[str] = mapped_column(String(100), nullable=False) + source_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + dedupe_key: Mapped[str] = mapped_column(String(255), nullable=False) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/schemas/collaboration.py b/backend/app/schemas/collaboration.py new file mode 100644 index 00000000..5a52c549 --- /dev/null +++ b/backend/app/schemas/collaboration.py @@ -0,0 +1,285 @@ +import uuid +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +CollaborationFileType = Literal["word", "cell", "slide"] +CollaborationMemberRole = Literal["EDITOR", "MANAGER"] +CollaborationFileStatus = Literal["ACTIVE", "ARCHIVED", "DELETED"] +CollaborationShareAccessMode = Literal["VIEW", "EDIT"] +CollaborationShareExpiryPolicy = Literal["ONE_DAY", "SEVEN_DAYS", "THIRTY_DAYS", "PERMANENT"] +CollaborationEditRequestStatus = Literal["PENDING", "APPROVED", "REJECTED"] + + +class CollaborationFolderCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + parent_id: Optional[uuid.UUID] = None + sort_order: int = 0 + + @field_validator("name") + @classmethod + def normalize_name(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("文件夹名称不能为空") + return value + + +class CollaborationFolderUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=120) + parent_id: Optional[uuid.UUID] = None + sort_order: Optional[int] = None + + +class CollaborationFolderRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + parent_id: Optional[uuid.UUID] + name: str + sort_order: int + created_by: uuid.UUID + deleted_at: Optional[datetime] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CollaborationFileCreate(BaseModel): + title: str = Field(min_length=1, max_length=240) + file_type: CollaborationFileType + folder_id: Optional[uuid.UUID] = None + + +class CollaborationFileUpdate(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=240) + folder_id: Optional[uuid.UUID] = None + status: Optional[Literal["ACTIVE", "ARCHIVED"]] = None + allow_export: Optional[bool] = None + allow_edit_request: Optional[bool] = None + allow_sheet_structure_edit: Optional[bool] = None + + +class CollaborationRevisionRead(BaseModel): + id: uuid.UUID + file_id: uuid.UUID + revision_no: int + parent_revision_id: Optional[uuid.UUID] + original_filename: str + file_hash: str + file_size: int + mime_type: str + source: str + change_summary: Optional[str] + created_by: Optional[uuid.UUID] + created_by_name: Optional[str] = None + created_by_avatar_url: Optional[str] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CollaborationRevisionUpdate(BaseModel): + change_summary: str = Field(min_length=1, max_length=240) + + @field_validator("change_summary") + @classmethod + def normalize_change_summary(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("版本名称不能为空") + return value + + +class CollaborationRevisionCopyRequest(BaseModel): + title: str = Field(min_length=1, max_length=240) + folder_id: Optional[uuid.UUID] = None + + @field_validator("title") + @classmethod + def normalize_title(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("文件名不能为空") + return value + + +class CollaborationFileCollaboratorRead(BaseModel): + user_id: uuid.UUID + full_name: str + role: CollaborationMemberRole + avatar_url: Optional[str] = None + + +class CollaborationFileRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + folder_id: Optional[uuid.UUID] + title: str + file_type: CollaborationFileType + extension: str + status: CollaborationFileStatus + owner_id: uuid.UUID + current_revision_id: Optional[uuid.UUID] + generation: int + allow_export: bool = False + allow_edit_request: bool = False + allow_sheet_structure_edit: bool = True + deleted_at: Optional[datetime] + created_at: datetime + updated_at: datetime + owner_name: Optional[str] = None + folder_name: Optional[str] = None + current_revision_no: Optional[int] = None + current_revision_file_size: Optional[int] = None + current_revision_mime_type: Optional[str] = None + current_revision_created_at: Optional[datetime] = None + collaboration_role: Optional[CollaborationMemberRole] = None + collaborators: list[CollaborationFileCollaboratorRead] = Field(default_factory=list) + can_edit: bool = False + can_manage: bool = False + can_export: bool = False + can_request_edit: bool = False + edit_request_status: Optional[CollaborationEditRequestStatus] = None + can_transfer_ownership: bool = False + + model_config = ConfigDict(from_attributes=True) + + +class CollaborationMemberUpsert(BaseModel): + user_id: uuid.UUID + role: CollaborationMemberRole + + +class CollaborationMemberRead(BaseModel): + id: uuid.UUID + file_id: uuid.UUID + user_id: uuid.UUID + role: CollaborationMemberRole + invited_by: uuid.UUID + full_name: str + email: str + created_at: datetime + + +class CollaborationEditRequestRead(BaseModel): + id: uuid.UUID + file_id: uuid.UUID + requester_id: uuid.UUID + requester_name: str + requester_email: str + status: CollaborationEditRequestStatus + resolved_by: Optional[uuid.UUID] + resolved_at: Optional[datetime] + created_at: datetime + + +class CollaborationEditRequestResolve(BaseModel): + status: Literal["APPROVED", "REJECTED"] + + +class CollaborationOwnershipTransferRequest(BaseModel): + new_owner_id: uuid.UUID + + +class CollaborationShareLinkUpdate(BaseModel): + enabled: bool + access_mode: CollaborationShareAccessMode = "VIEW" + expiry_policy: CollaborationShareExpiryPolicy = "SEVEN_DAYS" + password_mode: Literal["KEEP", "SET", "CLEAR"] = "KEEP" + password: Optional[str] = Field(default=None, min_length=4, max_length=64) + + @model_validator(mode="after") + def validate_password_change(self): + if self.password_mode == "SET" and not self.password: + raise ValueError("设置链接密码时必须提供密码") + if self.password_mode != "SET" and self.password is not None: + raise ValueError("仅在设置链接密码时允许提交密码") + return self + + +class CollaborationShareLinkRead(BaseModel): + id: uuid.UUID + file_id: uuid.UUID + enabled: bool + access_mode: CollaborationShareAccessMode + expiry_policy: CollaborationShareExpiryPolicy + expires_at: Optional[datetime] + has_password: bool + share_path: str = "/collaboration/share" + share_token: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class CollaborationPublicShareMetadata(BaseModel): + file_name: str + file_type: CollaborationFileType + access_mode: Literal["view", "edit"] + allow_export: bool + requires_password: bool + expires_at: Optional[datetime] + + +class CollaborationSharePasswordRequest(BaseModel): + password: str = Field(min_length=1, max_length=64) + + +class CollaborationShareAccessGrant(BaseModel): + access_token: str + expires_at: datetime + + +class CollaborationPublicEditorConfigRequest(BaseModel): + access_token: Optional[str] = Field(default=None, max_length=2048) + client_id: str = Field(min_length=8, max_length=64, pattern=r"^[A-Za-z0-9_-]+$") + display_name: str = Field(default="链接访客", min_length=1, max_length=40) + + @field_validator("display_name") + @classmethod + def normalize_display_name(cls, value: str) -> str: + return value.strip() or "链接访客" + + +class CollaborationCandidateRead(BaseModel): + user_id: uuid.UUID + full_name: str + email: str + role_in_study: str + can_be_editor: bool + can_be_manager: bool + + +class CollaborationEditorConfigRead(BaseModel): + file_id: uuid.UUID + file_name: str + access_mode: Literal["view", "edit"] + can_save_as: bool = False + can_download: bool = False + can_request_edit: bool = False + host_path: str = "/onlyoffice-host.html" + expires_at: datetime + config: dict + + +class CollaborationExportRecord(BaseModel): + file_type: str = Field(min_length=1, max_length=16, pattern=r"^[a-z0-9]+$") + + +class CollaborationCallbackPayload(BaseModel): + key: str + status: int + url: Optional[str] = None + changesurl: Optional[str] = None + filetype: Optional[str] = None + forcesavetype: Optional[int] = None + userdata: Optional[str] = None + users: list[str] = Field(default_factory=list) + actions: list[dict] = Field(default_factory=list) + history: Optional[dict] = None + + +class CollaborationRestoreRequest(BaseModel): + change_summary: Optional[str] = Field(default=None, max_length=500) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index 45f52062..cad32d48 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -46,3 +46,25 @@ class DesktopNotificationClaimResponse(BaseModel): class DesktopNotificationAckRequest(BaseModel): claim_token: uuid.UUID delivered_ids: list[uuid.UUID] + + +class GeneralNotificationRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + recipient_id: uuid.UUID + category: str + priority: str + title: str + message: str + action_path: str | None = None + source_type: str + source_id: str + read_at: datetime | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class GeneralNotificationFeed(BaseModel): + unread_count: int + items: list[GeneralNotificationRead] diff --git a/backend/app/schemas/onlyoffice.py b/backend/app/schemas/onlyoffice.py index 3c3d48f2..a88db6c3 100644 --- a/backend/app/schemas/onlyoffice.py +++ b/backend/app/schemas/onlyoffice.py @@ -6,7 +6,7 @@ from pydantic import BaseModel class OnlyOfficePreviewConfigRead(BaseModel): - resource_type: Literal["attachment", "version"] + resource_type: Literal["attachment", "version", "collaboration_revision"] resource_id: uuid.UUID file_name: str host_path: str = "/onlyoffice-host.html" diff --git a/backend/app/services/collaboration_service.py b/backend/app/services/collaboration_service.py new file mode 100644 index 00000000..9e13af89 --- /dev/null +++ b/backend/app/services/collaboration_service.py @@ -0,0 +1,1350 @@ +from __future__ import annotations + +import hashlib +import io +import json +import uuid +import zipfile +import base64 +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from pathlib import Path + +import aiofiles +from fastapi import HTTPException, UploadFile, status +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.deps import get_operator_role_label, is_system_admin +from app.core.project_permissions import role_has_api_permission +from app.crud import audit as audit_crud +from app.crud import member as member_crud +from app.models.collaboration import ( + CollaborationEditRequest, + CollaborationFile, + CollaborationFolder, + CollaborationMember, + CollaborationRevision, +) +from app.models.study_member import StudyMember +from app.models.user import User +from app.schemas.collaboration import ( + CollaborationCandidateRead, + CollaborationEditRequestRead, + CollaborationEditRequestResolve, + CollaborationFileCollaboratorRead, + CollaborationFileCreate, + CollaborationFileRead, + CollaborationFileUpdate, + CollaborationFolderCreate, + CollaborationFolderUpdate, + CollaborationMemberRead, + CollaborationMemberUpsert, + CollaborationOwnershipTransferRequest, + CollaborationRevisionCopyRequest, + CollaborationRevisionRead, + CollaborationRevisionUpdate, +) +from app.services import notification_service + +COLLABORATION_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "collaboration" +FILE_TYPE_EXTENSION = {"word": "docx", "cell": "xlsx", "slide": "pptx"} +EXTENSION_FILE_TYPE = {value: key for key, value in FILE_TYPE_EXTENSION.items()} +MIME_TYPES = { + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +} +ROLE_ASSIGNMENT_PERMISSIONS = { + "EDITOR": ("collaboration:read", "collaboration:edit"), + "MANAGER": ( + "collaboration:read", + "collaboration:create", + "collaboration:edit", + "collaboration:manage", + "collaboration:export", + "collaboration:delete", + ), +} +WORKBOOK_NAMESPACE = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +RELATIONSHIP_NAMESPACE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + + +def _workbook_structure_password(file_id: uuid.UUID) -> str: + # The password is never exposed to clients. It exists only to prevent an + # editor from bypassing the file-level policy by clicking "Unprotect". + seed = f"{settings.ONLYOFFICE_JWT_SECRET or ''}:sheet-structure:{file_id}" + return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:28] + + +def apply_workbook_structure_policy( + content: bytes, + *, + file_id: uuid.UUID, + allow_sheet_structure_edit: bool, + protection_backup: str | None, +) -> tuple[bytes, str | None]: + """Protect or restore an XLSX workbook structure without rewriting cell data.""" + if allow_sheet_structure_edit and protection_backup is None: + return content, None + if not zipfile.is_zipfile(io.BytesIO(content)): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Excel 文件格式无效") + source = io.BytesIO(content) + destination = io.BytesIO() + ET.register_namespace("", WORKBOOK_NAMESPACE) + ET.register_namespace("r", RELATIONSHIP_NAMESPACE) + with zipfile.ZipFile(source, "r") as reader: + try: + workbook_xml = reader.read("xl/workbook.xml") + except KeyError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Excel 工作簿结构无效") from exc + root = ET.fromstring(workbook_xml) + protection_tag = f"{{{WORKBOOK_NAMESPACE}}}workbookProtection" + existing = root.find(protection_tag) + next_backup = protection_backup + if allow_sheet_structure_edit: + if existing is not None: + root.remove(existing) + if protection_backup: + try: + backup_payload = json.loads(protection_backup) + backup_index = max(0, int(backup_payload["index"])) + backup_xml = str(backup_payload["xml"]) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + # Compatibility with an early development backup that only + # stored the element bytes. + backup_index = 0 + backup_xml = protection_backup + root.insert( + min(backup_index, len(root)), + ET.fromstring(base64.b64decode(backup_xml.encode("ascii"))), + ) + next_backup = None + else: + if protection_backup is None: + if existing is None: + next_backup = "" + else: + next_backup = json.dumps({ + "index": list(root).index(existing), + "xml": base64.b64encode(ET.tostring(existing, encoding="utf-8")).decode("ascii"), + }, separators=(",", ":")) + if existing is not None: + root.remove(existing) + # ONLYOFFICE honours OOXML workbook structure protection for add, + # delete, move, hide and rename operations. + from openpyxl.utils.protection import hash_password + + protected = ET.Element(protection_tag, { + "lockStructure": "1", + "workbookPassword": hash_password(_workbook_structure_password(file_id)), + }) + insert_at = next( + (index for index, child in enumerate(root) if child.tag in { + f"{{{WORKBOOK_NAMESPACE}}}bookViews", + f"{{{WORKBOOK_NAMESPACE}}}sheets", + }), + 0, + ) + root.insert(insert_at, protected) + rewritten = ET.tostring(root, encoding="utf-8", xml_declaration=True) + with zipfile.ZipFile(destination, "w") as writer: + for entry in reader.infolist(): + writer.writestr(entry, rewritten if entry.filename == "xl/workbook.xml" else reader.read(entry.filename)) + return destination.getvalue(), next_backup + + +def _safe_title(value: str, extension: str | None = None) -> str: + title = (value or "").replace("\\", "/").rsplit("/", 1)[-1].strip() + title = "".join(char for char in title if ord(char) >= 32 and ord(char) != 127) + title = title[:240].strip(". ") or "未命名文件" + if extension: + suffix = f".{extension}" + if not title.lower().endswith(suffix): + title = f"{title}{suffix}" + return title + + +def _write_package(parts: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, content in parts.items(): + archive.writestr(name, content.strip()) + return buffer.getvalue() + + +def _blank_docx() -> bytes: + return _write_package({ + "[Content_Types].xml": """ + + + + + """, + "_rels/.rels": """ + + + """, + "word/document.xml": """ + + + """, + }) + + +def _blank_xlsx() -> bytes: + return _write_package({ + "[Content_Types].xml": """ + + + + + + """, + "_rels/.rels": """ + + + """, + "xl/workbook.xml": """ + + + """, + "xl/_rels/workbook.xml.rels": """ + + + """, + "xl/worksheets/sheet1.xml": """ + """, + }) + + +def _blank_pptx() -> bytes: + return _write_package({ + "[Content_Types].xml": """ + + + + + + + + """, + "_rels/.rels": """ + """, + "ppt/presentation.xml": """ + + + """, + "ppt/_rels/presentation.xml.rels": """ + + + + """, + "ppt/slides/slide1.xml": """ + """, + "ppt/slides/_rels/slide1.xml.rels": """ + """, + "ppt/slideLayouts/slideLayout1.xml": """ + """, + "ppt/slideLayouts/_rels/slideLayout1.xml.rels": """ + """, + "ppt/slideMasters/slideMaster1.xml": """ + """, + "ppt/slideMasters/_rels/slideMaster1.xml.rels": """ + """, + "ppt/theme/theme1.xml": """ + """, + }) + + +def blank_file_bytes(file_type: str) -> bytes: + if file_type == "word": + return _blank_docx() + if file_type == "cell": + return _blank_xlsx() + if file_type == "slide": + return _blank_pptx() + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="不支持的协作文件类型") + + +async def _folder_or_404(db: AsyncSession, study_id: uuid.UUID, folder_id: uuid.UUID) -> CollaborationFolder: + folder = await db.get(CollaborationFolder, folder_id) + if not folder or folder.study_id != study_id or folder.deleted_at: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件夹不存在") + return folder + + +async def get_file_or_404( + db: AsyncSession, study_id: uuid.UUID, file_id: uuid.UUID, *, include_deleted: bool = False +) -> CollaborationFile: + item = await db.get(CollaborationFile, file_id) + if not item or item.study_id != study_id or (item.deleted_at and not include_deleted): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在") + return item + + +async def _member_role(db: AsyncSession, file_id: uuid.UUID, user_id: uuid.UUID) -> str | None: + return await db.scalar( + select(CollaborationMember.role).where( + CollaborationMember.file_id == file_id, + CollaborationMember.user_id == user_id, + ) + ) + + +async def _role_is_assignable( + db: AsyncSession, + study_id: uuid.UUID, + user, + membership, + role: str, +) -> bool: + if is_system_admin(user): + return True + if not membership or not membership.is_active: + return False + required_permissions = ROLE_ASSIGNMENT_PERMISSIONS.get(role, ()) + return all([ + await role_has_api_permission( + db, + study_id, + membership.role_in_study, + permission, + ) + for permission in required_permissions + ]) + + +async def _require_role_assignable( + db: AsyncSession, + study_id: uuid.UUID, + user, + membership, + role: str, +) -> None: + if await _role_is_assignable(db, study_id, user, membership, role): + return + role_label = "管理者或所有者" if role == "MANAGER" else "编辑者" + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"该账号的项目角色不具备{role_label}资格", + ) + + +async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool: + if is_system_admin(user): + return True + membership = await member_crud.get_member(db, item.study_id, user.id) + if not membership or not membership.is_active: + return False + role = await _member_role(db, item.id, user.id) + return item.owner_id == user.id or role in {"EDITOR", "MANAGER"} + + +async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bool: + if is_system_admin(user): + return True + membership = await member_crud.get_member(db, item.study_id, user.id) + if not membership or not membership.is_active: + return False + return item.owner_id == user.id or await _member_role(db, item.id, user.id) == "MANAGER" + + +async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bool: + if is_system_admin(user): + return True + membership = await member_crud.get_member(db, item.study_id, user.id) + if not membership or not membership.is_active: + return False + role = await _member_role(db, item.id, user.id) + is_manager = item.owner_id == user.id or role == "MANAGER" + return is_manager or bool(item.allow_export and role == "EDITOR") + + +async def can_create_file(db: AsyncSession, item: CollaborationFile, user) -> bool: + if is_system_admin(user): + return True + membership = await member_crud.get_member(db, item.study_id, user.id) + if not membership or not membership.is_active: + return False + return await role_has_api_permission( + db, item.study_id, membership.role_in_study, "collaboration:create" + ) + + +async def edit_request_status( + db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID +) -> str | None: + return await db.scalar( + select(CollaborationEditRequest.status) + .where( + CollaborationEditRequest.file_id == item.id, + CollaborationEditRequest.requester_id == user_id, + ) + .order_by(CollaborationEditRequest.created_at.desc()) + .limit(1) + ) + + +async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool: + if not item.allow_edit_request or await can_edit_file(db, item, user): + return False + membership = await member_crud.get_member(db, item.study_id, user.id) + if not membership or not membership.is_active: + return False + if not await role_has_api_permission(db, item.study_id, membership.role_in_study, "collaboration:read"): + return False + return await edit_request_status(db, item, user.id) != "PENDING" + + +async def can_transfer_ownership(db: AsyncSession, item: CollaborationFile, user) -> bool: + if is_system_admin(user): + return True + membership = await member_crud.get_member(db, item.study_id, user.id) + if not membership or not membership.is_active: + return False + return item.owner_id == user.id + + +async def require_file_editor(db: AsyncSession, item: CollaborationFile, user) -> None: + if not await can_edit_file(db, item, user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您未被邀请编辑此协作文件") + + +async def require_file_manager(db: AsyncSession, item: CollaborationFile, user) -> None: + if not await can_manage_file(db, item, user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有管理此协作文件的权限") + + +async def require_file_exporter(db: AsyncSession, item: CollaborationFile, user) -> None: + if not await can_export_file(db, item, user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有下载或另存此协作文件的权限") + + +async def require_ownership_transfer(db: AsyncSession, item: CollaborationFile, user) -> None: + if not await can_transfer_ownership(db, item, user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有转让此协作文件所有权的权限") + + +async def _audit(db: AsyncSession, item: CollaborationFile, action: str, user, extra: dict | None = None) -> None: + detail = {"file_id": str(item.id), "title": item.title} + if extra: + detail.update(extra) + await audit_crud.log_action( + db, + study_id=item.study_id, + entity_type="COLLABORATION_FILE", + entity_id=item.id, + action=action, + detail=json.dumps(detail, ensure_ascii=False), + operator_id=user.id, + operator_role=await get_operator_role_label(db, item.study_id, user), + auto_commit=False, + ) + + +async def create_folder(db: AsyncSession, study_id: uuid.UUID, payload: CollaborationFolderCreate, user) -> CollaborationFolder: + if payload.parent_id: + await _folder_or_404(db, study_id, payload.parent_id) + duplicate = await db.scalar( + select(CollaborationFolder.id).where( + CollaborationFolder.study_id == study_id, + CollaborationFolder.parent_id == payload.parent_id, + CollaborationFolder.name == payload.name, + CollaborationFolder.deleted_at.is_(None), + ) + ) + if duplicate: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="同级文件夹名称已存在") + folder = CollaborationFolder(study_id=study_id, created_by=user.id, **payload.model_dump()) + db.add(folder) + await db.commit() + await db.refresh(folder) + return folder + + +async def list_folders(db: AsyncSession, study_id: uuid.UUID) -> list[CollaborationFolder]: + rows = await db.scalars( + select(CollaborationFolder) + .where(CollaborationFolder.study_id == study_id, CollaborationFolder.deleted_at.is_(None)) + .order_by(CollaborationFolder.sort_order, CollaborationFolder.name) + ) + return list(rows.all()) + + +async def update_folder( + db: AsyncSession, study_id: uuid.UUID, folder_id: uuid.UUID, payload: CollaborationFolderUpdate +) -> CollaborationFolder: + folder = await _folder_or_404(db, study_id, folder_id) + values = payload.model_dump(exclude_unset=True) + parent_id = values.get("parent_id", folder.parent_id) + if parent_id == folder.id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="文件夹不能作为自身的上级") + if parent_id: + parent = await _folder_or_404(db, study_id, parent_id) + seen = {folder.id} + while parent.parent_id: + if parent.id in seen: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="不能形成循环文件夹") + seen.add(parent.id) + parent = await _folder_or_404(db, study_id, parent.parent_id) + if values.get("name") is not None: + values["name"] = values["name"].strip() + if not values["name"]: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="文件夹名称不能为空") + target_name = values.get("name", folder.name) + duplicate = await db.scalar( + select(CollaborationFolder.id).where( + CollaborationFolder.study_id == study_id, + CollaborationFolder.parent_id == parent_id, + CollaborationFolder.name == target_name, + CollaborationFolder.id != folder.id, + CollaborationFolder.deleted_at.is_(None), + ) + ) + if duplicate: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="同级文件夹名称已存在") + for key, value in values.items(): + setattr(folder, key, value) + await db.commit() + await db.refresh(folder) + return folder + + +async def delete_folder(db: AsyncSession, study_id: uuid.UUID, folder_id: uuid.UUID) -> None: + folder = await _folder_or_404(db, study_id, folder_id) + has_children = await db.scalar( + select(CollaborationFolder.id).where( + CollaborationFolder.parent_id == folder.id, + CollaborationFolder.deleted_at.is_(None), + ).limit(1) + ) + has_files = await db.scalar( + select(CollaborationFile.id).where( + CollaborationFile.folder_id == folder.id, + CollaborationFile.deleted_at.is_(None), + ).limit(1) + ) + if has_children or has_files: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="文件夹非空,无法删除") + folder.deleted_at = datetime.now(timezone.utc) + await db.commit() + + +async def _persist_revision_bytes( + db: AsyncSession, + item: CollaborationFile, + content: bytes, + *, + source: str, + created_by: uuid.UUID | None, + change_summary: str | None = None, + original_filename: str | None = None, +) -> tuple[CollaborationRevision, bool]: + locked = await db.scalar(select(CollaborationFile).where(CollaborationFile.id == item.id).with_for_update()) + if not locked: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在") + digest = hashlib.sha256(content).hexdigest() + current = await db.get(CollaborationRevision, locked.current_revision_id) if locked.current_revision_id else None + if current and current.file_hash == digest: + return current, False + last_no = await db.scalar( + select(func.max(CollaborationRevision.revision_no)).where(CollaborationRevision.file_id == locked.id) + ) + revision_no = int(last_no or 0) + 1 + directory = COLLABORATION_ROOT / str(locked.study_id) / str(locked.id) + directory.mkdir(parents=True, exist_ok=True) + destination = directory / f"{uuid.uuid4()}.{locked.extension}" + async with aiofiles.open(destination, "wb") as stream: + await stream.write(content) + revision = CollaborationRevision( + file_id=locked.id, + revision_no=revision_no, + parent_revision_id=locked.current_revision_id, + file_uri=str(destination), + original_filename=_safe_title(original_filename or locked.title, locked.extension), + file_hash=digest, + file_size=len(content), + mime_type=MIME_TYPES[locked.extension], + source=source, + change_summary=change_summary, + created_by=created_by, + ) + db.add(revision) + await db.flush() + locked.current_revision_id = revision.id + locked.updated_at = datetime.now(timezone.utc) + return revision, True + + +async def append_revision( + db: AsyncSession, + item: CollaborationFile, + content: bytes, + *, + source: str, + created_by: uuid.UUID | None, + change_summary: str | None = None, +) -> tuple[CollaborationRevision, bool]: + if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="协作文件大小超出限制") + return await _persist_revision_bytes( + db, item, content, source=source, created_by=created_by, change_summary=change_summary + ) + + +async def _replace_revision_bytes(revision: CollaborationRevision, content: bytes) -> None: + """Update the stored representation without creating a content-history entry.""" + destination = Path(revision.file_uri) + temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.tmp") + try: + async with aiofiles.open(temporary, "wb") as stream: + await stream.write(content) + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + revision.file_hash = hashlib.sha256(content).hexdigest() + revision.file_size = len(content) + + +async def _create_file_from_bytes( + db: AsyncSession, + study_id: uuid.UUID, + *, + title: str, + file_type: str, + folder_id: uuid.UUID | None, + content: bytes, + source: str, + user, + source_file_id: uuid.UUID | None = None, +) -> CollaborationFile: + membership = await member_crud.get_member(db, study_id, user.id) + await _require_role_assignable(db, study_id, user, membership, "MANAGER") + if folder_id: + await _folder_or_404(db, study_id, folder_id) + extension = FILE_TYPE_EXTENSION[file_type] + item = CollaborationFile( + study_id=study_id, + folder_id=folder_id, + title=_safe_title(title, extension), + file_type=file_type, + extension=extension, + owner_id=user.id, + ) + db.add(item) + await db.flush() + db.add(CollaborationMember(file_id=item.id, user_id=user.id, role="MANAGER", invited_by=user.id)) + await _persist_revision_bytes(db, item, content, source=source, created_by=user.id, original_filename=item.title) + audit_detail = {"source": source} + if source_file_id: + audit_detail["source_file_id"] = str(source_file_id) + await _audit(db, item, "COLLABORATION_FILE_CREATED", user, audit_detail) + await db.commit() + await db.refresh(item) + return item + + +async def create_blank_file( + db: AsyncSession, study_id: uuid.UUID, payload: CollaborationFileCreate, user +) -> CollaborationFile: + return await _create_file_from_bytes( + db, + study_id, + title=payload.title, + file_type=payload.file_type, + folder_id=payload.folder_id, + content=blank_file_bytes(payload.file_type), + source="CREATE", + user=user, + ) + + +async def import_file( + db: AsyncSession, study_id: uuid.UUID, folder_id: uuid.UUID | None, upload: UploadFile, user +) -> CollaborationFile: + filename = _safe_title(upload.filename or "") + extension = Path(filename).suffix.lower().lstrip(".") + file_type = EXTENSION_FILE_TYPE.get(extension) + if not file_type: + raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="仅支持 DOCX、XLSX、PPTX") + content = await upload.read(settings.COLLABORATION_MAX_FILE_BYTES + 1) + if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="协作文件为空或大小超出限制") + if not zipfile.is_zipfile(io.BytesIO(content)): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Office 文件格式无效或已损坏") + return await _create_file_from_bytes( + db, + study_id, + title=filename, + file_type=file_type, + folder_id=folder_id, + content=content, + source="IMPORT", + user=user, + ) + + +async def _collaborators_by_file( + db: AsyncSession, + file_ids: list[uuid.UUID], +) -> dict[uuid.UUID, list[CollaborationFileCollaboratorRead]]: + if not file_ids: + return {} + rows = (await db.execute( + select(CollaborationMember, User) + .join(User, User.id == CollaborationMember.user_id) + .where(CollaborationMember.file_id.in_(file_ids)) + .order_by(CollaborationMember.file_id, CollaborationMember.role.desc(), User.full_name) + )).all() + result: dict[uuid.UUID, list[CollaborationFileCollaboratorRead]] = { + file_id: [] for file_id in file_ids + } + for member, collaborator in rows: + result[member.file_id].append(CollaborationFileCollaboratorRead( + user_id=member.user_id, + full_name=collaborator.full_name, + role=member.role, + avatar_url=collaborator.avatar_url, + )) + return result + + +async def _file_read( + db: AsyncSession, + item: CollaborationFile, + user, + *, + collaborators: list[CollaborationFileCollaboratorRead] | None = None, +) -> CollaborationFileRead: + owner = await db.get(User, item.owner_id) + folder = await db.get(CollaborationFolder, item.folder_id) if item.folder_id else None + revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None + role = "MANAGER" if is_system_admin(user) else await _member_role(db, item.id, user.id) + can_edit = await can_edit_file(db, item, user) + request_status = None if can_edit else await edit_request_status(db, item, user.id) + if collaborators is None: + collaborators = (await _collaborators_by_file(db, [item.id])).get(item.id, []) + return CollaborationFileRead.model_validate(item).model_copy(update={ + "owner_name": owner.full_name if owner else None, + "folder_name": folder.name if folder else None, + "current_revision_no": revision.revision_no if revision else None, + "current_revision_file_size": revision.file_size if revision else None, + "current_revision_mime_type": revision.mime_type if revision else None, + "current_revision_created_at": revision.created_at if revision else None, + "collaboration_role": role, + "collaborators": collaborators, + "can_edit": can_edit, + "can_manage": await can_manage_file(db, item, user), + "can_export": await can_export_file(db, item, user), + "can_request_edit": await can_request_edit_file(db, item, user), + "edit_request_status": request_status, + "can_transfer_ownership": await can_transfer_ownership(db, item, user), + }) + + +async def file_read(db: AsyncSession, item: CollaborationFile, user) -> CollaborationFileRead: + return await _file_read(db, item, user) + + +async def list_files( + db: AsyncSession, + study_id: uuid.UUID, + user, + *, + folder_id: uuid.UUID | None = None, + keyword: str | None = None, + deleted: bool = False, +) -> list[CollaborationFileRead]: + stmt = select(CollaborationFile).where(CollaborationFile.study_id == study_id) + stmt = stmt.where(CollaborationFile.deleted_at.is_not(None) if deleted else CollaborationFile.deleted_at.is_(None)) + if folder_id: + stmt = stmt.where(CollaborationFile.folder_id == folder_id) + if keyword and keyword.strip(): + stmt = stmt.where(CollaborationFile.title.ilike(f"%{keyword.strip()}%")) + rows = list((await db.scalars(stmt.order_by(CollaborationFile.updated_at.desc()))).all()) + collaborators = await _collaborators_by_file(db, [item.id for item in rows]) + return [ + await _file_read(db, item, user, collaborators=collaborators.get(item.id, [])) + for item in rows + ] + + +async def update_file( + db: AsyncSession, item: CollaborationFile, payload: CollaborationFileUpdate, user +) -> CollaborationFile: + await require_file_manager(db, item, user) + previous_title = item.title + previous_folder_id = item.folder_id + previous_allow_export = item.allow_export + previous_allow_edit_request = item.allow_edit_request + previous_allow_sheet_structure_edit = item.allow_sheet_structure_edit + values = payload.model_dump(exclude_unset=True) + if "folder_id" in values and values["folder_id"]: + await _folder_or_404(db, item.study_id, values["folder_id"]) + if "title" in values: + values["title"] = _safe_title(values["title"], item.extension) + requested_sheet_policy = values.pop("allow_sheet_structure_edit", None) + if requested_sheet_policy is not None and requested_sheet_policy != item.allow_sheet_structure_edit: + if item.file_type != "cell": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="仅 Excel 协作文件支持工作表增删权限", + ) + revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None + if not revision or not Path(revision.file_uri).is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Excel 协作文件内容不存在") + async with aiofiles.open(revision.file_uri, "rb") as stream: + content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1) + protected_content, backup = apply_workbook_structure_policy( + content, + file_id=item.id, + allow_sheet_structure_edit=requested_sheet_policy, + protection_backup=item.sheet_structure_protection_backup, + ) + item.allow_sheet_structure_edit = requested_sheet_policy + item.sheet_structure_protection_backup = backup + if protected_content != content: + await _replace_revision_bytes(revision, protected_content) + item.generation += 1 + for key, value in values.items(): + setattr(item, key, value) + title_changed = item.title != previous_title + folder_changed = item.folder_id != previous_folder_id + if title_changed and not folder_changed: + action = "COLLABORATION_FILE_RENAMED" + elif folder_changed and not title_changed: + action = "COLLABORATION_FILE_MOVED" + else: + action = "COLLABORATION_FILE_UPDATED" + await _audit(db, item, action, user, { + "previous_title": previous_title, + "previous_folder_id": str(previous_folder_id) if previous_folder_id else None, + "folder_id": str(item.folder_id) if item.folder_id else None, + "previous_allow_export": previous_allow_export, + "allow_export": item.allow_export, + "previous_allow_edit_request": previous_allow_edit_request, + "allow_edit_request": item.allow_edit_request, + "previous_allow_sheet_structure_edit": previous_allow_sheet_structure_edit, + "allow_sheet_structure_edit": item.allow_sheet_structure_edit, + }) + await db.commit() + await db.refresh(item) + return item + + +async def _next_copy_title(db: AsyncSession, item: CollaborationFile) -> str: + suffix = f".{item.extension}" + stem = item.title[:-len(suffix)] if item.title.lower().endswith(suffix) else item.title + for index in range(1, 1001): + marker = " - 副本" if index == 1 else f" - 副本 ({index})" + candidate = _safe_title(f"{stem}{marker}", item.extension) + duplicate = await db.scalar( + select(CollaborationFile.id).where( + CollaborationFile.study_id == item.study_id, + CollaborationFile.folder_id == item.folder_id, + CollaborationFile.title == candidate, + CollaborationFile.deleted_at.is_(None), + ).limit(1) + ) + if not duplicate: + return candidate + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="无法生成可用的副本文件名") + + +async def copy_file(db: AsyncSession, item: CollaborationFile, user) -> CollaborationFile: + await require_file_exporter(db, item, user) + revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None + if not revision: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可复制版本") + source = Path(revision.file_uri) + if not source.is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件当前版本不存在") + async with aiofiles.open(source, "rb") as stream: + content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1) + if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="协作文件大小超出限制") + return await _create_file_from_bytes( + db, + item.study_id, + title=await _next_copy_title(db, item), + file_type=item.file_type, + folder_id=item.folder_id, + content=content, + source="COPY", + user=user, + source_file_id=item.id, + ) + + +async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> CollaborationRevision: + await require_file_exporter(db, item, user) + revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None + if not revision: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可下载版本") + if not Path(revision.file_uri).is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件当前版本不存在") + await _audit( + db, + item, + "COLLABORATION_FILE_DOWNLOADED", + user, + {"revision_id": str(revision.id), "revision_no": revision.revision_no}, + ) + await db.commit() + return revision + + +async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None: + await require_file_manager(db, item, user) + item.status = "DELETED" + item.deleted_at = datetime.now(timezone.utc) + await _audit(db, item, "COLLABORATION_FILE_TRASHED", user) + await db.commit() + + +async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> CollaborationFile: + await require_file_manager(db, item, user) + item.status = "ACTIVE" + item.deleted_at = None + await _audit(db, item, "COLLABORATION_FILE_RESTORED", user) + await db.commit() + await db.refresh(item) + return item + + +async def record_export(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None: + await require_file_exporter(db, item, user) + await _audit(db, item, "COLLABORATION_FILE_EXPORTED", user, {"file_type": file_type}) + await db.commit() + + +async def record_download(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None: + await require_file_exporter(db, item, user) + await _audit(db, item, "COLLABORATION_FILE_DOWNLOADED", user, {"file_type": file_type}) + await db.commit() + + +async def list_revisions(db: AsyncSession, item: CollaborationFile) -> list[CollaborationRevisionRead]: + rows = (await db.execute( + select(CollaborationRevision, User) + .outerjoin(User, User.id == CollaborationRevision.created_by) + .where( + CollaborationRevision.file_id == item.id, + CollaborationRevision.deleted_at.is_(None), + CollaborationRevision.source != "PERMISSION_CHANGE", + ) + .order_by(CollaborationRevision.revision_no.desc()) + )).all() + return [ + CollaborationRevisionRead.model_validate(revision).model_copy(update={ + "created_by_name": creator.full_name if creator else None, + "created_by_avatar_url": creator.avatar_url if creator else None, + }) + for revision, creator in rows + ] + + +async def get_revision_or_404( + db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID +) -> CollaborationRevision: + revision = await db.get(CollaborationRevision, revision_id) + if not revision or revision.file_id != item.id or getattr(revision, "deleted_at", None) is not None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订不存在") + return revision + + +async def delete_revision( + db: AsyncSession, + item: CollaborationFile, + revision_id: uuid.UUID, + user, +) -> None: + await require_file_manager(db, item, user) + revision = await get_revision_or_404(db, item, revision_id) + if item.current_revision_id == revision.id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前版本不能删除") + revision.deleted_at = datetime.now(timezone.utc) + revision.deleted_by = user.id + await _audit(db, item, "COLLABORATION_REVISION_DELETED", user, { + "revision_id": str(revision.id), + "revision_no": revision.revision_no, + }) + await db.commit() + + +async def update_revision( + db: AsyncSession, + item: CollaborationFile, + revision_id: uuid.UUID, + payload: CollaborationRevisionUpdate, + user, +) -> CollaborationRevision: + await require_file_editor(db, item, user) + revision = await get_revision_or_404(db, item, revision_id) + revision.change_summary = payload.change_summary + await _audit( + db, + item, + "COLLABORATION_REVISION_NAMED", + user, + {"revision_id": str(revision.id), "revision_no": revision.revision_no}, + ) + await db.commit() + await db.refresh(revision) + return revision + + +async def prepare_revision_preview( + db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID, user +) -> CollaborationRevision: + revision = await get_revision_or_404(db, item, revision_id) + if not Path(revision.file_uri).is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订内容不存在") + await _audit( + db, + item, + "COLLABORATION_REVISION_PREVIEWED", + user, + {"revision_id": str(revision.id), "revision_no": revision.revision_no}, + ) + await db.commit() + return revision + + +async def copy_revision( + db: AsyncSession, + item: CollaborationFile, + revision_id: uuid.UUID, + payload: CollaborationRevisionCopyRequest, + user, +) -> CollaborationFile: + await require_file_exporter(db, item, user) + revision = await get_revision_or_404(db, item, revision_id) + source = Path(revision.file_uri) + if not source.is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订内容不存在") + async with aiofiles.open(source, "rb") as stream: + content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1) + if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="协作文件大小超出限制") + if payload.folder_id: + await _folder_or_404(db, item.study_id, payload.folder_id) + return await _create_file_from_bytes( + db, + item.study_id, + title=payload.title, + file_type=item.file_type, + folder_id=payload.folder_id, + content=content, + source="COPY", + user=user, + source_file_id=item.id, + ) + + +async def restore_revision( + db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID, user, change_summary: str | None +) -> CollaborationRevision: + await require_file_editor(db, item, user) + revision = await get_revision_or_404(db, item, revision_id) + async with aiofiles.open(revision.file_uri, "rb") as stream: + content = await stream.read() + restored, created = await append_revision( + db, + item, + content, + source="RESTORE", + created_by=user.id, + change_summary=change_summary or f"恢复到修订 {revision.revision_no}", + ) + if created: + locked = await db.get(CollaborationFile, item.id) + if locked: + locked.generation += 1 + await _audit(db, item, "COLLABORATION_REVISION_RESTORED", user, {"revision_no": revision.revision_no}) + await db.commit() + await db.refresh(restored) + return restored + + +async def list_members(db: AsyncSession, item: CollaborationFile) -> list[CollaborationMemberRead]: + rows = (await db.execute( + select(CollaborationMember, User) + .join(User, User.id == CollaborationMember.user_id) + .where(CollaborationMember.file_id == item.id) + .order_by(CollaborationMember.role.desc(), User.full_name) + )).all() + return [CollaborationMemberRead( + id=member.id, + file_id=member.file_id, + user_id=member.user_id, + role=member.role, + invited_by=member.invited_by, + full_name=user.full_name, + email=user.email, + created_at=member.created_at, + ) for member, user in rows] + + +async def upsert_member( + db: AsyncSession, item: CollaborationFile, payload: CollaborationMemberUpsert, user +) -> CollaborationMemberRead: + await require_file_manager(db, item, user) + if payload.user_id == item.owner_id and payload.role != "MANAGER": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件所有者必须保留管理者权限") + study_member = await member_crud.get_member(db, item.study_id, payload.user_id) + if not study_member or not study_member.is_active: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能邀请当前项目的有效成员") + target = await db.get(User, payload.user_id) + if not target: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="授权账号不存在") + await _require_role_assignable(db, item.study_id, target, study_member, payload.role) + member = await db.scalar(select(CollaborationMember).where( + CollaborationMember.file_id == item.id, + CollaborationMember.user_id == payload.user_id, + )) + if member: + member.role = payload.role + member.invited_by = user.id + else: + member = CollaborationMember(file_id=item.id, user_id=payload.user_id, role=payload.role, invited_by=user.id) + db.add(member) + await _audit(db, item, "COLLABORATION_MEMBER_UPSERTED", user, {"member_id": str(payload.user_id), "role": payload.role}) + await db.commit() + await db.refresh(member) + return CollaborationMemberRead( + id=member.id, file_id=member.file_id, user_id=member.user_id, role=member.role, + invited_by=member.invited_by, full_name=target.full_name if target else "", + email=target.email if target else "", created_at=member.created_at, + ) + + +async def remove_member(db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID, user) -> None: + await require_file_manager(db, item, user) + if user_id == item.owner_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="不能移除协作文件所有者") + member = await db.scalar(select(CollaborationMember).where( + CollaborationMember.file_id == item.id, + CollaborationMember.user_id == user_id, + )) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作成员不存在") + await db.delete(member) + await _audit(db, item, "COLLABORATION_MEMBER_REMOVED", user, {"member_id": str(user_id)}) + await db.commit() + + +async def list_candidates(db: AsyncSession, study_id: uuid.UUID) -> list[CollaborationCandidateRead]: + rows = (await db.execute( + select(StudyMember, User) + .join(User, User.id == StudyMember.user_id) + .where(StudyMember.study_id == study_id, StudyMember.is_active.is_(True)) + .order_by(User.full_name) + )).all() + result: list[CollaborationCandidateRead] = [] + for membership, user in rows: + result.append(CollaborationCandidateRead( + user_id=user.id, + full_name=user.full_name, + email=user.email, + role_in_study=membership.role_in_study, + can_be_editor=await _role_is_assignable(db, study_id, user, membership, "EDITOR"), + can_be_manager=await _role_is_assignable(db, study_id, user, membership, "MANAGER"), + )) + return result + + +def _edit_request_read(request: CollaborationEditRequest, requester: User) -> CollaborationEditRequestRead: + return CollaborationEditRequestRead( + id=request.id, + file_id=request.file_id, + requester_id=request.requester_id, + requester_name=requester.full_name, + requester_email=requester.email, + status=request.status, + resolved_by=request.resolved_by, + resolved_at=request.resolved_at, + created_at=request.created_at, + ) + + +async def create_edit_request( + db: AsyncSession, item: CollaborationFile, user +) -> CollaborationEditRequestRead: + if await can_edit_file(db, item, user): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="您已拥有此文件的编辑权限") + if not item.allow_edit_request: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="此文件未开放编辑权限申请") + membership = await member_crud.get_member(db, item.study_id, user.id) + if not membership or not membership.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限") + pending = await db.scalar( + select(CollaborationEditRequest).where( + CollaborationEditRequest.file_id == item.id, + CollaborationEditRequest.requester_id == user.id, + CollaborationEditRequest.status == "PENDING", + ) + ) + if pending: + return _edit_request_read(pending, user) + request = CollaborationEditRequest(file_id=item.id, requester_id=user.id) + db.add(request) + await db.flush() + await _audit(db, item, "COLLABORATION_EDIT_REQUESTED", user, {"request_id": str(request.id)}) + manager_ids = set((await db.scalars( + select(StudyMember.user_id) + .outerjoin( + CollaborationMember, + and_( + CollaborationMember.file_id == item.id, + CollaborationMember.user_id == StudyMember.user_id, + ), + ) + .where( + StudyMember.study_id == item.study_id, + StudyMember.is_active.is_(True), + or_( + StudyMember.user_id == item.owner_id, + CollaborationMember.role == "MANAGER", + ), + ) + )).all()) + manager_ids.add(item.owner_id) + requester_name = str(user.full_name or user.email or "项目成员") + await notification_service.create_recipient_notifications( + db, + study_id=item.study_id, + recipient_ids=manager_ids, + category="COLLABORATION_EDIT_REQUEST", + priority="NORMAL", + title="新的编辑权限申请", + message=f"{requester_name} 申请编辑“{item.title}”", + action_path=f"/knowledge/collaboration?editRequestFile={item.id}", + source_type="COLLABORATION_EDIT_REQUEST", + source_id=str(request.id), + dedupe_key=f"collaboration-edit-request:{request.id}", + ) + await db.commit() + await db.refresh(request) + return _edit_request_read(request, user) + + +async def list_edit_requests( + db: AsyncSession, item: CollaborationFile, user, *, pending_only: bool = True +) -> list[CollaborationEditRequestRead]: + await require_file_manager(db, item, user) + stmt = ( + select(CollaborationEditRequest, User) + .join(User, User.id == CollaborationEditRequest.requester_id) + .where(CollaborationEditRequest.file_id == item.id) + ) + if pending_only: + stmt = stmt.where(CollaborationEditRequest.status == "PENDING") + rows = (await db.execute(stmt.order_by(CollaborationEditRequest.created_at.desc()))).all() + return [_edit_request_read(request, requester) for request, requester in rows] + + +async def resolve_edit_request( + db: AsyncSession, + item: CollaborationFile, + request_id: uuid.UUID, + payload: CollaborationEditRequestResolve, + user, +) -> CollaborationEditRequestRead: + await require_file_manager(db, item, user) + request = await db.scalar( + select(CollaborationEditRequest) + .where(CollaborationEditRequest.id == request_id, CollaborationEditRequest.file_id == item.id) + .with_for_update() + ) + if not request: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="编辑权限申请不存在") + if request.status != "PENDING": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="编辑权限申请已处理") + requester = await db.get(User, request.requester_id) + membership = await member_crud.get_member(db, item.study_id, request.requester_id) + if not requester or not membership or not membership.is_active: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员") + if payload.status == "APPROVED": + await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR") + member = await db.scalar(select(CollaborationMember).where( + CollaborationMember.file_id == item.id, + CollaborationMember.user_id == request.requester_id, + )) + if not member: + db.add(CollaborationMember( + file_id=item.id, + user_id=request.requester_id, + role="EDITOR", + invited_by=user.id, + )) + elif member.role != "MANAGER": + member.role = "EDITOR" + member.invited_by = user.id + request.status = payload.status + request.resolved_by = user.id + request.resolved_at = datetime.now(timezone.utc) + await notification_service.resolve_source_notifications( + db, + source_type="COLLABORATION_EDIT_REQUEST", + source_id=str(request.id), + ) + await _audit(db, item, "COLLABORATION_EDIT_REQUEST_RESOLVED", user, { + "request_id": str(request.id), + "requester_id": str(request.requester_id), + "status": payload.status, + }) + await db.commit() + await db.refresh(request) + return _edit_request_read(request, requester) + + +async def transfer_ownership( + db: AsyncSession, + item: CollaborationFile, + payload: CollaborationOwnershipTransferRequest, + user, +) -> CollaborationFile: + await require_ownership_transfer(db, item, user) + locked = await db.scalar(select(CollaborationFile).where(CollaborationFile.id == item.id).with_for_update()) + if not locked: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在") + if payload.new_owner_id == locked.owner_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="所选联系人已经是文档所有者") + membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id) + target = await db.get(User, payload.new_owner_id) + if not target or not membership or not membership.is_active: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员") + await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER") + previous_owner_id = locked.owner_id + member = await db.scalar(select(CollaborationMember).where( + CollaborationMember.file_id == locked.id, + CollaborationMember.user_id == payload.new_owner_id, + )) + if member: + member.role = "MANAGER" + member.invited_by = user.id + else: + db.add(CollaborationMember( + file_id=locked.id, + user_id=payload.new_owner_id, + role="MANAGER", + invited_by=user.id, + )) + previous_owner_member = await db.scalar(select(CollaborationMember).where( + CollaborationMember.file_id == locked.id, + CollaborationMember.user_id == previous_owner_id, + )) + if previous_owner_member: + previous_owner_member.role = "MANAGER" + locked.owner_id = payload.new_owner_id + locked.updated_at = datetime.now(timezone.utc) + await _audit(db, locked, "COLLABORATION_OWNERSHIP_TRANSFERRED", user, { + "previous_owner_id": str(previous_owner_id), + "new_owner_id": str(payload.new_owner_id), + }) + await db.commit() + await db.refresh(locked) + return locked diff --git a/backend/app/services/collaboration_share_service.py b/backend/app/services/collaboration_share_service.py new file mode 100644 index 00000000..766ea3c1 --- /dev/null +++ b/backend/app/services/collaboration_share_service.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import uuid +from datetime import datetime, timedelta, timezone + +from anyio import to_thread +from fastapi import HTTPException, status +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.security import hash_password, verify_password +from app.models.collaboration import CollaborationFile, CollaborationShareLink +from app.schemas.collaboration import ( + CollaborationPublicShareMetadata, + CollaborationShareAccessGrant, + CollaborationShareLinkRead, + CollaborationShareLinkUpdate, +) +from app.services import collaboration_service + + +SHARE_PATH = "/collaboration/share" +SHARE_ACCESS_TTL_SECONDS = 30 * 60 +PASSWORD_FAILURE_WINDOW = timedelta(minutes=15) +PASSWORD_LOCK_DURATION = timedelta(minutes=15) +PASSWORD_FAILURE_LIMIT = 5 +_ACCESS_PURPOSE = "ctms-collaboration-share-access" +_EXPIRY_DURATIONS = { + "ONE_DAY": timedelta(days=1), + "SEVEN_DAYS": timedelta(days=7), + "THIRTY_DAYS": timedelta(days=30), + "PERMANENT": None, +} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _signing_key() -> bytes: + return hmac.new( + settings.JWT_SECRET_KEY.encode("utf-8"), + b"ctms-collaboration-share-v1", + hashlib.sha256, + ).digest() + + +def _b64encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _b64decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii")) + + +def share_token(link: CollaborationShareLink) -> str: + payload = f"{link.id}.{link.token_version}".encode("ascii") + signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest() + return f"{_b64encode(payload)}.{_b64encode(signature)}" + + +def _decode_share_token(value: str | None) -> tuple[uuid.UUID, int]: + token = (value or "").strip() + if not token or len(token) > 256 or token.count(".") != 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") + encoded_payload, encoded_signature = token.split(".", 1) + try: + payload = _b64decode(encoded_payload) + actual_signature = _b64decode(encoded_signature) + if ( + not hmac.compare_digest(_b64encode(payload), encoded_payload) + or not hmac.compare_digest(_b64encode(actual_signature), encoded_signature) + ): + raise ValueError("non-canonical token encoding") + expected_signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest() + if not hmac.compare_digest(actual_signature, expected_signature): + raise ValueError("signature mismatch") + raw_id, raw_version = payload.decode("ascii").split(".", 1) + return uuid.UUID(raw_id), int(raw_version) + except (ValueError, UnicodeError, TypeError, binascii.Error) as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") from exc + + +def _expiry_for_policy(policy: str, now: datetime) -> datetime | None: + duration = _EXPIRY_DURATIONS[policy] + return now + duration if duration else None + + +def _is_expired(link: CollaborationShareLink, now: datetime | None = None) -> bool: + return bool(link.expires_at and link.expires_at <= (now or _now())) + + +async def _link_for_file( + db: AsyncSession, + item: CollaborationFile, + user, + *, + create: bool, + lock: bool = False, +) -> CollaborationShareLink | None: + await collaboration_service.require_file_manager(db, item, user) + statement = select(CollaborationShareLink).where(CollaborationShareLink.file_id == item.id) + if lock: + statement = statement.with_for_update() + link = await db.scalar(statement) + if link or not create: + return link + now = _now() + link = CollaborationShareLink( + file_id=item.id, + enabled=False, + access_mode="VIEW", + expiry_policy="SEVEN_DAYS", + expires_at=now + timedelta(days=7), + created_by=user.id, + updated_by=user.id, + ) + db.add(link) + await db.flush() + return link + + +def share_link_read(link: CollaborationShareLink) -> CollaborationShareLinkRead: + return CollaborationShareLinkRead( + id=link.id, + file_id=link.file_id, + enabled=link.enabled, + access_mode=link.access_mode, + expiry_policy=link.expiry_policy, + expires_at=link.expires_at, + has_password=bool(link.password_hash), + share_path=SHARE_PATH, + share_token=share_token(link) if link.enabled else None, + created_at=link.created_at, + updated_at=link.updated_at, + ) + + +async def get_share_link( + db: AsyncSession, item: CollaborationFile, user +) -> CollaborationShareLinkRead: + link = await _link_for_file(db, item, user, create=True) + assert link is not None + await db.commit() + await db.refresh(link) + return share_link_read(link) + + +async def update_share_link( + db: AsyncSession, + item: CollaborationFile, + payload: CollaborationShareLinkUpdate, + user, +) -> CollaborationShareLinkRead: + link = await _link_for_file(db, item, user, create=True, lock=True) + assert link is not None + now = _now() + link.enabled = payload.enabled + link.access_mode = payload.access_mode + link.expiry_policy = payload.expiry_policy + link.expires_at = _expiry_for_policy(payload.expiry_policy, now) + link.updated_by = user.id + if payload.password_mode == "SET": + link.password_hash = await to_thread.run_sync(hash_password, payload.password or "") + link.failed_attempts = 0 + link.last_failed_at = None + link.locked_until = None + elif payload.password_mode == "CLEAR": + link.password_hash = None + link.failed_attempts = 0 + link.last_failed_at = None + link.locked_until = None + await collaboration_service._audit( + db, + item, + "COLLABORATION_SHARE_LINK_UPDATED", + user, + { + "enabled": link.enabled, + "access_mode": link.access_mode, + "file_allow_export": item.allow_export, + "expiry_policy": link.expiry_policy, + "has_password": bool(link.password_hash), + }, + ) + await db.commit() + await db.refresh(link) + return share_link_read(link) + + +async def resolve_active_share( + db: AsyncSession, + token: str | None, + *, + lock: bool = False, +) -> tuple[CollaborationShareLink, CollaborationFile]: + link_id, version = _decode_share_token(token) + statement = select(CollaborationShareLink).where(CollaborationShareLink.id == link_id) + if lock: + statement = statement.with_for_update() + link = await db.scalar(statement) + if not link or link.token_version != version or not link.enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") + if _is_expired(link): + raise HTTPException(status_code=status.HTTP_410_GONE, detail="共享链接已过期") + item = await db.get(CollaborationFile, link.file_id) + if not item or item.deleted_at or item.status != "ACTIVE": + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件不存在或已停止共享") + return link, item + + +async def public_metadata( + db: AsyncSession, token: str | None +) -> CollaborationPublicShareMetadata: + link, item = await resolve_active_share(db, token) + return CollaborationPublicShareMetadata( + file_name=item.title, + file_type=item.file_type, + access_mode="edit" if link.access_mode == "EDIT" else "view", + allow_export=item.allow_export, + requires_password=bool(link.password_hash), + expires_at=link.expires_at, + ) + + +def _grant_token(link: CollaborationShareLink) -> CollaborationShareAccessGrant: + now = _now() + expires_at = now + timedelta(seconds=SHARE_ACCESS_TTL_SECONDS) + if link.expires_at and link.expires_at < expires_at: + expires_at = link.expires_at + value = jwt.encode( + { + "purpose": _ACCESS_PURPOSE, + "sub": str(link.id), + "ver": link.token_version, + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + }, + _signing_key().hex(), + algorithm="HS256", + ) + return CollaborationShareAccessGrant(access_token=value, expires_at=expires_at) + + +async def verify_share_password( + db: AsyncSession, + token: str | None, + password: str, +) -> CollaborationShareAccessGrant: + link, _ = await resolve_active_share(db, token, lock=True) + if not link.password_hash: + return _grant_token(link) + now = _now() + if link.locked_until and link.locked_until > now: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="密码尝试次数过多,请稍后再试", + ) + if link.last_failed_at and now - link.last_failed_at > PASSWORD_FAILURE_WINDOW: + link.failed_attempts = 0 + valid = await to_thread.run_sync(verify_password, password, link.password_hash) + if not valid: + link.failed_attempts += 1 + link.last_failed_at = now + if link.failed_attempts >= PASSWORD_FAILURE_LIMIT: + link.locked_until = now + PASSWORD_LOCK_DURATION + await db.commit() + if link.locked_until: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="密码尝试次数过多,请稍后再试", + ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接密码不正确") + link.failed_attempts = 0 + link.last_failed_at = None + link.locked_until = None + await db.commit() + return _grant_token(link) + + +def validate_access_grant(link: CollaborationShareLink, value: str | None) -> None: + if not link.password_hash: + return + if not value: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请输入链接密码") + try: + payload = jwt.decode(value, _signing_key().hex(), algorithms=["HS256"]) + if ( + payload.get("purpose") != _ACCESS_PURPOSE + or not hmac.compare_digest(str(payload.get("sub") or ""), str(link.id)) + or payload.get("ver") != link.token_version + ): + raise JWTError("share grant mismatch") + except JWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接访问凭证已失效") from exc diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 00000000..dd938e45 --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Iterable + +from fastapi import HTTPException, status +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.notification import Notification +from app.schemas.notification import GeneralNotificationFeed + + +async def create_recipient_notifications( + db: AsyncSession, + *, + study_id: uuid.UUID, + recipient_ids: Iterable[uuid.UUID], + category: str, + priority: str, + title: str, + message: str, + action_path: str | None, + source_type: str, + source_id: str, + dedupe_key: str, +) -> None: + recipients = set(recipient_ids) + if not recipients: + return + existing = set((await db.scalars( + select(Notification.recipient_id).where( + Notification.recipient_id.in_(recipients), + Notification.dedupe_key == dedupe_key, + ) + )).all()) + for recipient_id in recipients - existing: + db.add(Notification( + study_id=study_id, + recipient_id=recipient_id, + category=category, + priority=priority, + title=title, + message=message, + action_path=action_path, + source_type=source_type, + source_id=source_id, + dedupe_key=dedupe_key, + )) + + +async def sync_aggregate_notification( + db: AsyncSession, + *, + study_id: uuid.UUID, + recipient_id: uuid.UUID, + category: str, + priority: str, + title: str, + message: str, + action_path: str, + source_type: str, + source_id: str, + count: int, +) -> None: + dedupe_key = f"aggregate:{source_type}:{source_id}" + item = await db.scalar(select(Notification).where( + Notification.recipient_id == recipient_id, + Notification.dedupe_key == dedupe_key, + )) + now = datetime.now(timezone.utc) + if count <= 0: + if item and item.resolved_at is None: + item.resolved_at = now + item.read_at = item.read_at or now + return + version = str(count) + if item is None: + db.add(Notification( + study_id=study_id, + recipient_id=recipient_id, + category=category, + priority=priority, + title=title, + message=message, + action_path=action_path, + source_type=source_type, + source_id=source_id, + source_version=version, + dedupe_key=dedupe_key, + )) + return + previous_count = int(item.source_version or 0) + item.priority = priority + item.title = title + item.message = message + item.action_path = action_path + item.source_version = version + if item.resolved_at is not None or count > previous_count: + item.read_at = None + item.created_at = now + item.resolved_at = None + + +async def resolve_source_notifications( + db: AsyncSession, + *, + source_type: str, + source_id: str, +) -> None: + now = datetime.now(timezone.utc) + await db.execute( + update(Notification) + .where( + Notification.source_type == source_type, + Notification.source_id == source_id, + Notification.resolved_at.is_(None), + ) + .values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now)) + ) + + +async def list_feed( + db: AsyncSession, + *, + study_id: uuid.UUID, + recipient_id: uuid.UUID, + limit: int = 10, +) -> GeneralNotificationFeed: + active_filter = ( + Notification.study_id == study_id, + Notification.recipient_id == recipient_id, + Notification.resolved_at.is_(None), + ) + unread_count = int(await db.scalar( + select(func.count(Notification.id)).where(*active_filter, Notification.read_at.is_(None)) + ) or 0) + items = (await db.scalars( + select(Notification) + .where(*active_filter) + .order_by(Notification.read_at.is_not(None), Notification.created_at.desc()) + .limit(max(1, min(limit, 50))) + )).all() + return GeneralNotificationFeed(unread_count=unread_count, items=list(items)) + + +async def mark_read( + db: AsyncSession, + *, + study_id: uuid.UUID, + recipient_id: uuid.UUID, + notification_id: uuid.UUID, +) -> Notification: + item = await db.scalar(select(Notification).where( + Notification.id == notification_id, + Notification.study_id == study_id, + Notification.recipient_id == recipient_id, + Notification.resolved_at.is_(None), + )) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在") + if item.read_at is None: + item.read_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(item) + return item + + +async def mark_all_read( + db: AsyncSession, + *, + study_id: uuid.UUID, + recipient_id: uuid.UUID, +) -> None: + await db.execute( + update(Notification) + .where( + Notification.study_id == study_id, + Notification.recipient_id == recipient_id, + Notification.resolved_at.is_(None), + Notification.read_at.is_(None), + ) + .values(read_at=datetime.now(timezone.utc)) + ) + await db.commit() diff --git a/backend/app/services/onlyoffice_collaboration_service.py b/backend/app/services/onlyoffice_collaboration_service.py new file mode 100644 index 00000000..c1757fb0 --- /dev/null +++ b/backend/app/services/onlyoffice_collaboration_service.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import httpx +from fastapi import HTTPException, Request, status +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models.collaboration import ( + CollaborationCallbackReceipt, + CollaborationFile, + CollaborationRevision, + CollaborationSession, + CollaborationShareLink, +) +from app.models.user import User +from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead +from app.services import collaboration_service, onlyoffice_service + + +def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str: + fingerprint = f"{settings.ONLYOFFICE_INSTANCE_ID or ''}:collaboration:{file_id}:{generation}" + return f"ctms-collab-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}" + + +def _content_url(session_id: uuid.UUID) -> str: + return ( + f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}" + f"/internal/onlyoffice/collaboration/sessions/{session_id}/content" + ) + + +def _callback_url(session_id: uuid.UUID) -> str: + return ( + f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}" + f"/internal/onlyoffice/collaboration/sessions/{session_id}/callback" + ) + + +async def _active_session( + db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID +) -> CollaborationSession: + session = await db.scalar( + select(CollaborationSession).where( + CollaborationSession.file_id == item.id, + CollaborationSession.generation == item.generation, + ).order_by(CollaborationSession.created_at.desc()) + ) + if session: + if session.status != "ACTIVE": + session.status = "ACTIVE" + session.closed_at = None + await db.commit() + await db.refresh(session) + return session + if not item.current_revision_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容") + session = CollaborationSession( + file_id=item.id, + base_revision_id=item.current_revision_id, + document_key=collaboration_document_key(item.id, item.generation), + generation=item.generation, + started_by=user_id, + ) + db.add(session) + await db.commit() + await db.refresh(session) + return session + + +async def build_editor_config( + db: AsyncSession, item: CollaborationFile, user +) -> CollaborationEditorConfigRead: + await onlyoffice_service.ensure_onlyoffice_available() + revision = await db.get(CollaborationRevision, item.current_revision_id) + if not revision or not Path(revision.file_uri).exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在") + can_edit = await collaboration_service.can_edit_file(db, item, user) + can_request_edit = await collaboration_service.can_request_edit_file(db, item, user) + can_download = await collaboration_service.can_export_file(db, item, user) + can_save_as = can_download and await collaboration_service.can_create_file(db, item, user) + session = await _active_session(db, item, user.id) + now = datetime.now(timezone.utc) + expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS) + config: dict[str, Any] = { + "type": "desktop", + "documentType": item.file_type, + "document": { + "fileType": item.extension, + "key": session.document_key, + "title": item.title, + "url": _content_url(session.id), + "permissions": { + "chat": False, + "copy": can_download, + "comment": can_edit, + "download": can_download, + # In view mode ONLYOFFICE displays "Edit current file" only + # when edit=true and onRequestEditRights is registered. CTMS + # handles that event as an approval request, not an escalation. + "edit": can_edit or can_request_edit, + "fillForms": False, + "modifyContentControl": can_edit, + "modifyFilter": can_edit, + "print": can_download, + "protect": False, + "review": False, + }, + }, + "editorConfig": { + "callbackUrl": _callback_url(session.id), + "coEditing": {"mode": "fast", "change": False}, + "customization": { + "autosave": True, + "chat": False, + "comments": can_edit, + "forcesave": can_edit, + "help": False, + "plugins": False, + }, + "lang": "zh-CN", + "mode": "edit" if can_edit else "view", + "user": {"id": str(user.id), "name": user.full_name}, + }, + } + config["token"] = jwt.encode( + {**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())}, + settings.ONLYOFFICE_JWT_SECRET or "", + algorithm="HS256", + ) + return CollaborationEditorConfigRead( + file_id=item.id, + file_name=item.title, + access_mode="edit" if can_edit else "view", + can_save_as=can_save_as, + can_download=can_download, + can_request_edit=can_request_edit, + expires_at=expires_at, + config=config, + ) + + +async def build_shared_editor_config( + db: AsyncSession, + item: CollaborationFile, + link: CollaborationShareLink, + *, + client_id: str, + display_name: str, +) -> CollaborationEditorConfigRead: + await onlyoffice_service.ensure_onlyoffice_available() + revision = await db.get(CollaborationRevision, item.current_revision_id) + if not revision or not Path(revision.file_uri).exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件内容不存在") + can_edit = link.access_mode == "EDIT" + session = await _active_session(db, item, item.owner_id) + now = datetime.now(timezone.utc) + expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS) + if link.expires_at and link.expires_at < expires_at: + expires_at = link.expires_at + external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}" + config: dict[str, Any] = { + "type": "desktop", + "documentType": item.file_type, + "document": { + "fileType": item.extension, + "key": session.document_key, + "title": item.title, + "url": _content_url(session.id), + "permissions": { + "chat": False, + "copy": item.allow_export, + "comment": can_edit, + "download": item.allow_export, + "edit": can_edit, + "fillForms": False, + "modifyContentControl": can_edit, + "modifyFilter": can_edit, + "print": item.allow_export, + "protect": False, + "review": False, + }, + }, + "editorConfig": { + "callbackUrl": _callback_url(session.id), + "coEditing": {"mode": "fast", "change": False}, + "customization": { + "autosave": can_edit, + "chat": False, + "comments": can_edit, + "forcesave": can_edit, + "help": False, + "plugins": False, + }, + "lang": "zh-CN", + "mode": "edit" if can_edit else "view", + "user": {"id": external_user_id, "name": display_name}, + }, + } + config["token"] = jwt.encode( + {**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())}, + settings.ONLYOFFICE_JWT_SECRET or "", + algorithm="HS256", + ) + return CollaborationEditorConfigRead( + file_id=item.id, + file_name=item.title, + access_mode="edit" if can_edit else "view", + can_save_as=False, + can_download=item.allow_export, + expires_at=expires_at, + config=config, + ) + + +async def get_session_content( + db: AsyncSession, session_id: uuid.UUID, authorization: str | None +) -> tuple[CollaborationRevision, CollaborationFile]: + session = await db.get(CollaborationSession, session_id) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在") + onlyoffice_service.validate_outbox_token(authorization, _content_url(session_id)) + revision = await db.get(CollaborationRevision, session.base_revision_id) + item = await db.get(CollaborationFile, session.file_id) + if not revision or not item or not Path(revision.file_uri).exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在") + return revision, item + + +def validate_callback_token(token: str | None, payload: CollaborationCallbackPayload) -> dict[str, Any]: + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少 ONLYOFFICE 回调签名") + value = token.strip() + if " " in value: + scheme, credential = value.split(" ", 1) + if scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名格式无效") + value = credential.strip() + try: + decoded = onlyoffice_service.decode_onlyoffice_token(value) + except JWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名无效") from exc + signed = decoded.get("payload") if isinstance(decoded.get("payload"), dict) else decoded + if not isinstance(signed, dict): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调载荷无效") + signed_key = signed.get("key") + signed_status = signed.get("status") + if not isinstance(signed_key, str) or not hmac.compare_digest(signed_key, payload.key): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调 key 不匹配") + if not isinstance(signed_status, int) or signed_status != payload.status: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调状态不匹配") + if payload.url: + signed_url = signed.get("url") + if not isinstance(signed_url, str) or not hmac.compare_digest(signed_url, payload.url): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调文件地址不匹配") + return decoded + + +def _callback_fingerprint(payload: CollaborationCallbackPayload) -> str: + normalized = payload.model_dump(mode="json", exclude_none=True) + return hashlib.sha256(json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def _url_origin_matches(actual, expected) -> bool: + actual_port = actual.port or (443 if actual.scheme == "https" else 80) + expected_port = expected.port or (443 if expected.scheme == "https" else 80) + return ( + actual.scheme == expected.scheme + and actual.hostname + and actual.hostname.lower() == (expected.hostname or "").lower() + and actual_port == expected_port + ) + + +def _validate_result_url(url: str) -> str: + actual = urlsplit(url) + if ( + actual.scheme not in {"http", "https"} + or actual.username + or actual.password + or actual.fragment + or not actual.hostname + ): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任") + + internal = urlsplit(settings.ONLYOFFICE_INTERNAL_URL.rstrip("/")) + if _url_origin_matches(actual, internal): + return urlunsplit((internal.scheme, internal.netloc, actual.path, actual.query, "")) + + public = urlsplit(settings.FRONTEND_PUBLIC_URL.rstrip("/")) + proxy_prefix = "/onlyoffice/" + if not _url_origin_matches(actual, public) or not actual.path.startswith(proxy_prefix): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任") + internal_path = f"{internal.path.rstrip('/')}/{actual.path[len(proxy_prefix):]}" + return urlunsplit((internal.scheme, internal.netloc, internal_path, actual.query, "")) + + +async def _download_result(url: str) -> bytes: + download_url = _validate_result_url(url) + try: + async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client: + async with client.stream("GET", download_url) as response: + if response.status_code != status.HTTP_200_OK: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败") + content = bytearray() + async for chunk in response.aiter_bytes(): + content.extend(chunk) + if len(content) > settings.COLLABORATION_MAX_FILE_BYTES: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="ONLYOFFICE 保存文件超出限制") + except httpx.HTTPError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败") from exc + return bytes(content) + + +async def _callback_user( + db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession +) -> User | None: + has_public_share_user = False + for value in payload.users: + if value.startswith("share-"): + has_public_share_user = True + continue + try: + user = await db.get(User, uuid.UUID(value)) + except (ValueError, TypeError): + user = None + if user: + return user + if has_public_share_user: + return None + user = await db.get(User, session.started_by) + if not user: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话用户不存在") + return user + + +async def process_callback( + db: AsyncSession, + session_id: uuid.UUID, + payload: CollaborationCallbackPayload, +) -> dict[str, int]: + session = await db.scalar( + select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update() + ) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在") + if not hmac.compare_digest(session.document_key, payload.key): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话 key 不匹配") + fingerprint = _callback_fingerprint(payload) + duplicate = await db.scalar(select(CollaborationCallbackReceipt.id).where( + CollaborationCallbackReceipt.session_id == session.id, + CollaborationCallbackReceipt.fingerprint == fingerprint, + )) + if duplicate: + return {"error": 0} + + item = await db.get(CollaborationFile, session.file_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在") + session.last_callback_at = datetime.now(timezone.utc) + session.active_users = json.dumps(payload.users, ensure_ascii=True) + result = "ACKNOWLEDGED" + saved_revision_id = None + + if payload.status in {2, 6}: + if not payload.url: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址") + if session.generation != item.generation: + result = "STALE" + else: + content = await _download_result(payload.url) + actor = await _callback_user(db, payload, session) + source = "SESSION_CLOSE" if payload.status == 2 else "FORCE_SAVE" + if actor is None: + source = "SHARE_SESSION_CLOSE" if payload.status == 2 else "SHARE_FORCE_SAVE" + revision, created = await collaboration_service.append_revision( + db, item, content, source=source, created_by=actor.id if actor else None + ) + saved_revision_id = revision.id + result = "SAVED" if created else "UNCHANGED" + if payload.status == 2: + item.generation += 1 + session.status = "CLOSED" + session.closed_at = datetime.now(timezone.utc) + else: + # 强制保存不结束当前共同编辑会话;同步基线可保证 Document + # Server 缓存重建时仍从最近一次持久化内容恢复。 + session.base_revision_id = revision.id + if created and actor: + await collaboration_service._audit( + db, item, "COLLABORATION_REVISION_SAVED", actor, + {"revision_no": revision.revision_no, "source": source}, + ) + elif payload.status == 4: + session.status = "CLOSED" + session.closed_at = datetime.now(timezone.utc) + result = "UNCHANGED" + elif payload.status in {3, 7}: + session.status = "ERROR" + result = "ERROR" + + db.add(CollaborationCallbackReceipt( + session_id=session.id, + fingerprint=fingerprint, + callback_status=payload.status, + result=result, + revision_id=saved_revision_id, + )) + await db.commit() + # ONLYOFFICE 要求回调处理器在接收并记录状态后固定确认成功。 + # status 3/7 表示文档服务自身保存失败,不应通过 error=1 制造重试环。 + return {"error": 0} diff --git a/backend/app/services/onlyoffice_service.py b/backend/app/services/onlyoffice_service.py index 577e4e72..240a5563 100644 --- a/backend/app/services/onlyoffice_service.py +++ b/backend/app/services/onlyoffice_service.py @@ -18,7 +18,7 @@ from app.core.exceptions import AppException from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead OnlyOfficeDocumentType = Literal["word", "cell", "slide"] -OnlyOfficeResourceType = Literal["attachment", "version"] +OnlyOfficeResourceType = Literal["attachment", "version", "collaboration_revision"] WORD_FORMATS = frozenset({ "doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt", @@ -52,7 +52,11 @@ def office_format_for_filename(filename: str) -> tuple[str, OnlyOfficeDocumentTy def onlyoffice_content_url(resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID) -> str: - plural = "attachments" if resource_type == "attachment" else "versions" + plural = { + "attachment": "attachments", + "version": "versions", + "collaboration_revision": "collaboration-revisions", + }[resource_type] return ( f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}" f"/internal/onlyoffice/{plural}/{resource_id}/content" @@ -200,15 +204,7 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any ) token = credential.strip() try: - header = jwt.get_unverified_header(token) - if header.get("alg") != "HS256": - raise JWTError("unexpected algorithm") - payload = jwt.decode( - token, - settings.ONLYOFFICE_JWT_SECRET or "", - algorithms=["HS256"], - options={"verify_aud": False}, - ) + payload = decode_onlyoffice_token(token) except JWTError as exc: raise onlyoffice_error( "ONLYOFFICE_SOURCE_UNAUTHORIZED", @@ -225,3 +221,15 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any status.HTTP_401_UNAUTHORIZED, ) return payload + + +def decode_onlyoffice_token(token: str) -> dict[str, Any]: + header = jwt.get_unverified_header(token) + if header.get("alg") != "HS256": + raise JWTError("unexpected algorithm") + return jwt.decode( + token, + settings.ONLYOFFICE_JWT_SECRET or "", + algorithms=["HS256"], + options={"verify_aud": False}, + ) diff --git a/backend/app/services/project_reminder_service.py b/backend/app/services/project_reminder_service.py new file mode 100644 index 00000000..46bf610f --- /dev/null +++ b/backend/app/services/project_reminder_service.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_cra_site_scope, is_system_admin +from app.core.project_permissions import role_has_api_permission +from app.crud import ae as ae_crud +from app.crud import member as member_crud +from app.crud import monitoring_visit_issue as monitoring_issue_crud +from app.services import notification_service + + +async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) -> None: + membership = await member_crud.get_member(db, study_id, user.id) + if not is_system_admin(user) and (not membership or not membership.is_active): + return + role = membership.role_in_study if membership else "" + can_read_aes = is_system_admin(user) or await role_has_api_permission( + db, study_id, role, "subject_aes:read" + ) + can_read_monitoring = is_system_admin(user) or await role_has_api_permission( + db, study_id, role, "monitoring_issues:read" + ) + + overdue_aes = 0 + if can_read_aes: + cra_scope = await get_cra_site_scope(db, study_id, user) + items = await ae_crud.list_ae( + db, + study_id, + overdue=True, + site_ids=cra_scope[0] if cra_scope else None, + ) + overdue_aes = len(items) + await notification_service.sync_aggregate_notification( + db, + study_id=study_id, + recipient_id=user.id, + category="RISK_OVERDUE_AE", + priority="HIGH", + title="逾期 AE 待处理", + message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进", + action_path="/risk-issues/sae", + source_type="RISK_OVERDUE_AE", + source_id=str(study_id), + count=overdue_aes if can_read_aes else 0, + ) + + overdue_monitoring = 0 + if can_read_monitoring: + overdue_monitoring = len(await monitoring_issue_crud.list_issues( + db, + study_id, + overdue=True, + limit=2000, + )) + await notification_service.sync_aggregate_notification( + db, + study_id=study_id, + recipient_id=user.id, + category="RISK_OVERDUE_MONITORING", + priority="HIGH", + title="监查问题已逾期", + message=f"当前有 {overdue_monitoring} 条监查问题已超过计划解决日期", + action_path="/risk-issues/monitoring-visits", + source_type="RISK_OVERDUE_MONITORING", + source_id=str(study_id), + count=overdue_monitoring if can_read_monitoring else 0, + ) + await db.commit() diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index d4c42ae9..60341832 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -272,6 +272,18 @@ def test_faq_and_precautions_are_shared_library_sibling_sections(): assert API_ENDPOINT_PERMISSIONS["precautions:read"]["module"] == "shared_library" +def test_collaboration_is_an_independent_shared_library_section(): + shared_library_keys = OPERATION_TO_ENDPOINTS["shared_library"] + assert "collaboration:read" in shared_library_keys["read"] + assert "collaboration:create" in shared_library_keys["write"] + assert "collaboration:edit" in shared_library_keys["write"] + assert "collaboration:manage" in shared_library_keys["write"] + assert "collaboration:export" in shared_library_keys["write"] + assert "collaboration:delete" in shared_library_keys["write"] + assert API_ENDPOINT_PERMISSIONS["collaboration:read"]["module"] == "shared_library" + assert API_ENDPOINT_PERMISSIONS["collaboration:read"]["default_roles"] == ["PM", "CRA", "PV", "QA", "CTA"] + + def test_precautions_runtime_code_uses_business_entity_names(): """运行时代码不应继续使用 knowledge_note 作为注意事项实体命名。""" backend_root = Path(__file__).resolve().parents[1] / "app" diff --git a/backend/tests/test_collaboration_service.py b/backend/tests/test_collaboration_service.py new file mode 100644 index 00000000..21848d70 --- /dev/null +++ b/backend/tests/test_collaboration_service.py @@ -0,0 +1,1064 @@ +import hashlib +import io +import uuid +import zipfile +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import ANY, AsyncMock + +import pytest +from fastapi import HTTPException +from jose import jwt + +from app.core.config import settings +from app.models.collaboration import ( + CollaborationFile, + CollaborationFolder, + CollaborationRevision, + CollaborationShareLink, +) +from app.schemas.collaboration import ( + CollaborationCallbackPayload, + CollaborationFileUpdate, + CollaborationRevisionCopyRequest, + CollaborationRevisionUpdate, + CollaborationShareLinkUpdate, +) +from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service + + +@pytest.fixture(autouse=True) +def collaboration_settings(monkeypatch): + monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True) + monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "collaboration-secret-that-is-long-enough") + monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-collaboration-test") + monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://onlyoffice") + monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000") + monkeypatch.setattr(settings, "ONLYOFFICE_CONFIG_TTL_SECONDS", 300) + + +@pytest.mark.parametrize( + ("file_type", "expected_extension", "required_part"), + [ + ("word", "docx", "word/document.xml"), + ("cell", "xlsx", "xl/worksheets/sheet1.xml"), + ("slide", "pptx", "ppt/slides/slide1.xml"), + ], +) +def test_blank_collaboration_templates_are_ooxml_packages(file_type, expected_extension, required_part): + content = collaboration_service.blank_file_bytes(file_type) + assert zipfile.is_zipfile(io.BytesIO(content)) + with zipfile.ZipFile(io.BytesIO(content)) as package: + assert "[Content_Types].xml" in package.namelist() + assert required_part in package.namelist() + assert collaboration_service.FILE_TYPE_EXTENSION[file_type] == expected_extension + + +def test_collaboration_key_is_stable_per_generation_and_is_independent_from_document_versions(): + file_id = uuid.uuid4() + first = onlyoffice_collaboration_service.collaboration_document_key(file_id, 1) + repeated = onlyoffice_collaboration_service.collaboration_document_key(file_id, 1) + next_generation = onlyoffice_collaboration_service.collaboration_document_key(file_id, 2) + assert first == repeated + assert first != next_generation + assert first.startswith("ctms-collab-") + assert len(first) <= 128 + + +def test_collaboration_revision_preview_config_is_read_only(): + revision_id = uuid.uuid4() + config = onlyoffice_service.build_preview_config( + resource_type="collaboration_revision", + resource_id=revision_id, + file_name="方案-R2.docx", + file_hash="revision-hash", + user_id=uuid.uuid4(), + user_name="测试用户", + ) + assert config.resource_type == "collaboration_revision" + assert config.config["editorConfig"]["mode"] == "view" + assert config.config["document"]["permissions"]["edit"] is False + assert f"/internal/onlyoffice/collaboration-revisions/{revision_id}/content" in config.config["document"]["url"] + + +def test_revision_action_payloads_trim_user_input(): + assert CollaborationRevisionUpdate(change_summary=" 伦理送审版 ").change_summary == "伦理送审版" + folder_id = uuid.uuid4() + payload = CollaborationRevisionCopyRequest(title=" 方案-R2.docx ", folder_id=folder_id) + assert payload.title == "方案-R2.docx" + assert payload.folder_id == folder_id + + +@pytest.mark.asyncio +async def test_copy_revision_saves_to_the_selected_workspace_folder(monkeypatch, tmp_path): + source = tmp_path / "revision.docx" + content = collaboration_service.blank_file_bytes("word") + source.write_bytes(content) + revision_id = uuid.uuid4() + target_folder_id = uuid.uuid4() + item = SimpleNamespace( + id=uuid.uuid4(), + study_id=uuid.uuid4(), + folder_id=uuid.uuid4(), + file_type="word", + current_revision_id=uuid.uuid4(), + ) + revision = SimpleNamespace( + id=revision_id, + file_id=item.id, + revision_no=2, + file_uri=str(source), + deleted_at=None, + ) + copied = SimpleNamespace(id=uuid.uuid4()) + db = SimpleNamespace(get=AsyncMock(return_value=revision)) + require_exporter = AsyncMock() + folder_lookup = AsyncMock() + create_copy = AsyncMock(return_value=copied) + monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter) + monkeypatch.setattr(collaboration_service, "_folder_or_404", folder_lookup) + monkeypatch.setattr(collaboration_service, "_create_file_from_bytes", create_copy) + user = SimpleNamespace(id=uuid.uuid4()) + + result = await collaboration_service.copy_revision( + db, + item, + revision_id, + CollaborationRevisionCopyRequest(title="送审版.docx", folder_id=target_folder_id), + user, + ) + + assert result is copied + require_exporter.assert_awaited_once_with(db, item, user) + folder_lookup.assert_awaited_once_with(db, item.study_id, target_folder_id) + create_copy.assert_awaited_once_with( + db, + item.study_id, + title="送审版.docx", + file_type="word", + folder_id=target_folder_id, + content=content, + source="COPY", + user=user, + source_file_id=item.id, + ) + + +@pytest.mark.asyncio +async def test_delete_revision_soft_deletes_a_non_current_version(monkeypatch): + revision_id = uuid.uuid4() + current_revision_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4()) + item = SimpleNamespace(id=uuid.uuid4(), current_revision_id=current_revision_id) + revision = SimpleNamespace( + id=revision_id, + file_id=item.id, + revision_no=2, + deleted_at=None, + deleted_by=None, + ) + db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock()) + require_manager = AsyncMock() + audit = AsyncMock() + monkeypatch.setattr(collaboration_service, "require_file_manager", require_manager) + monkeypatch.setattr(collaboration_service, "_audit", audit) + + await collaboration_service.delete_revision(db, item, revision_id, user) + + require_manager.assert_awaited_once_with(db, item, user) + assert revision.deleted_at is not None + assert revision.deleted_by == user.id + audit.assert_awaited_once_with( + db, + item, + "COLLABORATION_REVISION_DELETED", + user, + {"revision_id": str(revision_id), "revision_no": 2}, + ) + db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_delete_revision_rejects_the_current_version(monkeypatch): + revision_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4()) + item = SimpleNamespace(id=uuid.uuid4(), current_revision_id=revision_id) + revision = SimpleNamespace( + id=revision_id, + file_id=item.id, + revision_no=4, + deleted_at=None, + deleted_by=None, + ) + db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock()) + monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock()) + + with pytest.raises(HTTPException) as exc: + await collaboration_service.delete_revision(db, item, revision_id, user) + + assert exc.value.status_code == 409 + db.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_revision_history_includes_creator_display_metadata(): + file_id = uuid.uuid4() + creator_id = uuid.uuid4() + now = datetime.now(timezone.utc) + revision = SimpleNamespace( + id=uuid.uuid4(), + file_id=file_id, + revision_no=3, + parent_revision_id=None, + original_filename="方案.docx", + file_hash="hash", + file_size=2048, + mime_type="application/docx", + source="SESSION_CLOSE", + change_summary="自动保存", + created_by=creator_id, + created_at=now, + ) + creator = SimpleNamespace(full_name="测试用户", avatar_url="/avatars/test.png") + result = SimpleNamespace(all=lambda: [(revision, creator)]) + db = SimpleNamespace(execute=AsyncMock(return_value=result)) + + history = await collaboration_service.list_revisions(db, SimpleNamespace(id=file_id)) + + assert len(history) == 1 + assert history[0].created_by_name == "测试用户" + assert history[0].created_by_avatar_url == "/avatars/test.png" + statement = db.execute.await_args.args[0] + assert "collaboration_revisions.source !=" in str(statement) + + +@pytest.mark.asyncio +async def test_sheet_permission_change_updates_current_file_without_creating_revision(monkeypatch, tmp_path): + original = collaboration_service.blank_file_bytes("cell") + revision_path = tmp_path / "current.xlsx" + revision_path.write_bytes(original) + revision = SimpleNamespace( + id=uuid.uuid4(), + file_uri=str(revision_path), + file_hash="old-hash", + file_size=len(original), + ) + item = SimpleNamespace( + id=uuid.uuid4(), + study_id=uuid.uuid4(), + file_type="cell", + extension="xlsx", + title="协作表.xlsx", + folder_id=None, + current_revision_id=revision.id, + generation=1, + allow_export=True, + allow_edit_request=False, + allow_sheet_structure_edit=True, + sheet_structure_protection_backup=None, + ) + user = SimpleNamespace(id=uuid.uuid4()) + db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock(), refresh=AsyncMock()) + monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock()) + monkeypatch.setattr(collaboration_service, "_audit", AsyncMock()) + persist_revision = AsyncMock() + monkeypatch.setattr(collaboration_service, "_persist_revision_bytes", persist_revision) + + result = await collaboration_service.update_file( + db, + item, + CollaborationFileUpdate(allow_sheet_structure_edit=False), + user, + ) + + assert result is item + assert item.allow_sheet_structure_edit is False + assert item.generation == 2 + persist_revision.assert_not_awaited() + assert revision.file_hash == hashlib.sha256(revision_path.read_bytes()).hexdigest() + assert revision.file_size == revision_path.stat().st_size + with zipfile.ZipFile(revision_path) as package: + workbook = package.read("xl/workbook.xml").decode("utf-8") + assert "workbookProtection" in workbook + + +def test_callback_token_is_bound_to_key_status_and_result_url(): + result_url = "http://onlyoffice/cache/result.docx" + payload = CollaborationCallbackPayload(key="collaboration-key", status=2, url=result_url) + token = jwt.encode( + {"payload": {"key": payload.key, "status": payload.status, "url": payload.url}}, + settings.ONLYOFFICE_JWT_SECRET, + algorithm="HS256", + ) + decoded = onlyoffice_collaboration_service.validate_callback_token(token, payload) + assert decoded["payload"]["key"] == payload.key + + forged = payload.model_copy(update={"url": "http://onlyoffice/cache/other.docx"}) + with pytest.raises(HTTPException) as mismatch: + onlyoffice_collaboration_service.validate_callback_token(token, forged) + assert mismatch.value.status_code == 401 + + config_token = jwt.encode( + {"document": {"key": payload.key}, "editorConfig": {"mode": "edit"}}, + settings.ONLYOFFICE_JWT_SECRET, + algorithm="HS256", + ) + with pytest.raises(HTTPException): + onlyoffice_collaboration_service.validate_callback_token(config_token, payload) + + +def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(monkeypatch): + monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "http://localhost:8888") + assert onlyoffice_collaboration_service._validate_result_url( + "http://onlyoffice/cache/result.docx?token=signed" + ) == "http://onlyoffice/cache/result.docx?token=signed" + assert onlyoffice_collaboration_service._validate_result_url( + "http://localhost:8888/onlyoffice/cache/result.docx?token=signed" + ) == "http://onlyoffice/cache/result.docx?token=signed" + for value in ( + "http://backend:8000/internal/file", + "http://onlyoffice.evil.example/cache/result.docx", + "http://user:password@onlyoffice/cache/result.docx", + "http://onlyoffice:8080/cache/result.docx", + "http://localhost:8888/api/v1/files/result.docx", + "http://localhost.evil.example:8888/onlyoffice/cache/result.docx", + ): + with pytest.raises(HTTPException): + onlyoffice_collaboration_service._validate_result_url(value) + + +@pytest.mark.asyncio +async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path): + user_id = uuid.uuid4() + file_id = uuid.uuid4() + revision_id = uuid.uuid4() + source = tmp_path / "协作表.xlsx" + source.write_bytes(collaboration_service.blank_file_bytes("cell")) + item = CollaborationFile( + id=file_id, + study_id=uuid.uuid4(), + title="协作表.xlsx", + file_type="cell", + extension="xlsx", + owner_id=user_id, + current_revision_id=revision_id, + generation=1, + allow_export=False, + ) + revision = SimpleNamespace(id=revision_id, file_uri=str(source)) + session = SimpleNamespace(id=uuid.uuid4(), document_key="ctms-collab-key") + db = SimpleNamespace(get=AsyncMock(return_value=revision)) + user = SimpleNamespace(id=user_id, full_name="测试用户") + monkeypatch.setattr(onlyoffice_collaboration_service.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) + monkeypatch.setattr(onlyoffice_collaboration_service.collaboration_service, "can_edit_file", AsyncMock(return_value=True)) + can_request_edit = AsyncMock(return_value=False) + monkeypatch.setattr( + onlyoffice_collaboration_service.collaboration_service, + "can_request_edit_file", + can_request_edit, + ) + can_export = AsyncMock(return_value=True) + monkeypatch.setattr(onlyoffice_collaboration_service.collaboration_service, "can_export_file", can_export) + can_create = AsyncMock(return_value=True) + monkeypatch.setattr(onlyoffice_collaboration_service.collaboration_service, "can_create_file", can_create) + monkeypatch.setattr(onlyoffice_collaboration_service, "_active_session", AsyncMock(return_value=session)) + + result = await onlyoffice_collaboration_service.build_editor_config(db, item, user) + assert result.access_mode == "edit" + assert result.can_save_as is True + assert result.can_download is True + assert result.config["editorConfig"]["mode"] == "edit" + assert result.config["editorConfig"]["coEditing"] == {"mode": "fast", "change": False} + assert result.config["document"]["permissions"]["edit"] is True + assert result.config["document"]["permissions"]["download"] is True + assert result.config["document"]["permissions"]["print"] is True + assert "/internal/onlyoffice/collaboration/sessions/" in result.config["document"]["url"] + assert "callbackUrl" in result.config["editorConfig"] + + onlyoffice_collaboration_service.collaboration_service.can_edit_file.return_value = False + can_request_edit.return_value = True + requestable = await onlyoffice_collaboration_service.build_editor_config(db, item, user) + assert requestable.access_mode == "view" + assert requestable.can_request_edit is True + assert requestable.config["editorConfig"]["mode"] == "view" + assert requestable.config["document"]["permissions"]["edit"] is True + onlyoffice_collaboration_service.collaboration_service.can_edit_file.return_value = True + can_request_edit.return_value = False + + can_create.return_value = False + restricted = await onlyoffice_collaboration_service.build_editor_config(db, item, user) + assert restricted.can_save_as is False + assert restricted.can_download is True + assert restricted.config["document"]["permissions"]["download"] is True + + can_export.return_value = False + export_restricted = await onlyoffice_collaboration_service.build_editor_config(db, item, user) + assert export_restricted.can_save_as is False + assert export_restricted.can_download is False + assert export_restricted.config["document"]["permissions"]["download"] is False + + +@pytest.mark.asyncio +async def test_shared_editor_config_respects_link_mode_and_disables_export(monkeypatch, tmp_path): + owner_id = uuid.uuid4() + revision_id = uuid.uuid4() + source = tmp_path / "共享表.xlsx" + source.write_bytes(collaboration_service.blank_file_bytes("cell")) + item = CollaborationFile( + id=uuid.uuid4(), + study_id=uuid.uuid4(), + title="共享表.xlsx", + file_type="cell", + extension="xlsx", + owner_id=owner_id, + current_revision_id=revision_id, + generation=1, + allow_export=False, + ) + link = CollaborationShareLink( + id=uuid.uuid4(), + file_id=item.id, + enabled=True, + access_mode="EDIT", + expiry_policy="SEVEN_DAYS", + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + created_by=owner_id, + updated_by=owner_id, + ) + revision = SimpleNamespace(id=revision_id, file_uri=str(source)) + session = SimpleNamespace(id=uuid.uuid4(), document_key="ctms-public-share-key") + db = SimpleNamespace(get=AsyncMock(return_value=revision)) + monkeypatch.setattr(onlyoffice_collaboration_service.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) + monkeypatch.setattr(onlyoffice_collaboration_service, "_active_session", AsyncMock(return_value=session)) + + result = await onlyoffice_collaboration_service.build_shared_editor_config( + db, + item, + link, + client_id="visitor-client-1", + display_name="链接访客", + ) + + assert result.access_mode == "edit" + assert result.can_save_as is False + assert result.can_download is False + assert result.config["document"]["permissions"]["edit"] is True + assert result.config["document"]["permissions"]["copy"] is False + assert result.config["document"]["permissions"]["download"] is False + assert result.config["document"]["permissions"]["print"] is False + assert result.config["editorConfig"]["user"]["id"].startswith("share-") + + item.allow_export = True + export_result = await onlyoffice_collaboration_service.build_shared_editor_config( + db, + item, + link, + client_id="visitor-client-2", + display_name="链接访客", + ) + assert export_result.can_save_as is False + assert export_result.can_download is True + assert export_result.config["document"]["permissions"]["copy"] is True + assert export_result.config["document"]["permissions"]["download"] is True + assert export_result.config["document"]["permissions"]["print"] is True + + +@pytest.mark.asyncio +async def test_share_tokens_are_stable_revocable_and_not_accepted_after_expiry(monkeypatch): + monkeypatch.setattr(settings, "JWT_SECRET_KEY", "share-token-test-secret") + now = datetime.now(timezone.utc) + link = CollaborationShareLink( + id=uuid.uuid4(), + file_id=uuid.uuid4(), + enabled=True, + access_mode="VIEW", + expiry_policy="SEVEN_DAYS", + expires_at=now + timedelta(days=7), + token_version=2, + created_by=uuid.uuid4(), + updated_by=uuid.uuid4(), + created_at=now, + updated_at=now, + ) + item = SimpleNamespace( + id=link.file_id, + title="方案.docx", + file_type="word", + status="ACTIVE", + allow_export=False, + deleted_at=None, + ) + db = SimpleNamespace(scalar=AsyncMock(return_value=link), get=AsyncMock(return_value=item)) + token = collaboration_share_service.share_token(link) + + repeated = collaboration_share_service.share_token(link) + metadata = await collaboration_share_service.public_metadata(db, token) + assert token == repeated + assert metadata.file_name == "方案.docx" + assert metadata.access_mode == "view" + assert metadata.allow_export is False + + with pytest.raises(HTTPException) as tampered: + replacement = "A" if token[-1] != "A" else "B" + await collaboration_share_service.public_metadata(db, f"{token[:-1]}{replacement}") + assert tampered.value.status_code == 404 + + link.token_version += 1 + with pytest.raises(HTTPException) as revoked: + await collaboration_share_service.public_metadata(db, token) + assert revoked.value.status_code == 404 + + current = collaboration_share_service.share_token(link) + link.expires_at = now - timedelta(seconds=1) + with pytest.raises(HTTPException) as expired: + await collaboration_share_service.public_metadata(db, current) + assert expired.value.status_code == 410 + + +@pytest.mark.asyncio +async def test_share_link_url_stays_immutable_when_disabled_and_reenabled(monkeypatch): + monkeypatch.setattr(settings, "JWT_SECRET_KEY", "immutable-share-link-test-secret") + now = datetime.now(timezone.utc) + link = CollaborationShareLink( + id=uuid.uuid4(), + file_id=uuid.uuid4(), + enabled=True, + access_mode="VIEW", + expiry_policy="PERMANENT", + expires_at=None, + token_version=4, + created_by=uuid.uuid4(), + updated_by=uuid.uuid4(), + created_at=now, + updated_at=now, + ) + item = SimpleNamespace(id=link.file_id, allow_export=False) + user = SimpleNamespace(id=uuid.uuid4()) + db = SimpleNamespace( + scalar=AsyncMock(return_value=link), + commit=AsyncMock(), + refresh=AsyncMock(), + ) + monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock()) + monkeypatch.setattr(collaboration_service, "_audit", AsyncMock()) + original_token = collaboration_share_service.share_token(link) + + await collaboration_share_service.update_share_link( + db, + item, + CollaborationShareLinkUpdate(enabled=False, expiry_policy="PERMANENT"), + user, + ) + assert link.enabled is False + assert link.token_version == 4 + assert collaboration_share_service.share_token(link) == original_token + + await collaboration_share_service.update_share_link( + db, + item, + CollaborationShareLinkUpdate( + enabled=True, + access_mode="EDIT", + expiry_policy="THIRTY_DAYS", + ), + user, + ) + assert link.enabled is True + assert link.access_mode == "EDIT" + assert item.allow_export is False + assert link.token_version == 4 + assert collaboration_share_service.share_token(link) == original_token + + +@pytest.mark.asyncio +async def test_share_password_failures_lock_the_link_and_grants_are_version_bound(monkeypatch): + monkeypatch.setattr(settings, "JWT_SECRET_KEY", "share-password-test-secret") + now = datetime.now(timezone.utc) + link = CollaborationShareLink( + id=uuid.uuid4(), + file_id=uuid.uuid4(), + enabled=True, + access_mode="VIEW", + expiry_policy="PERMANENT", + password_hash="hashed-password", + token_version=3, + failed_attempts=0, + created_by=uuid.uuid4(), + updated_by=uuid.uuid4(), + created_at=now, + updated_at=now, + ) + item = SimpleNamespace(id=link.file_id, status="ACTIVE", deleted_at=None) + db = SimpleNamespace( + scalar=AsyncMock(return_value=link), + get=AsyncMock(return_value=item), + commit=AsyncMock(), + ) + monkeypatch.setattr(collaboration_share_service, "verify_password", lambda *_: False) + token = collaboration_share_service.share_token(link) + + for _ in range(collaboration_share_service.PASSWORD_FAILURE_LIMIT - 1): + with pytest.raises(HTTPException) as wrong: + await collaboration_share_service.verify_share_password(db, token, "wrong") + assert wrong.value.status_code == 401 + with pytest.raises(HTTPException) as locked: + await collaboration_share_service.verify_share_password(db, token, "wrong") + assert locked.value.status_code == 429 + assert link.locked_until is not None + + link.locked_until = None + link.failed_attempts = 0 + monkeypatch.setattr(collaboration_share_service, "verify_password", lambda *_: True) + grant = await collaboration_share_service.verify_share_password(db, token, "correct") + collaboration_share_service.validate_access_grant(link, grant.access_token) + link.token_version += 1 + with pytest.raises(HTTPException) as stale: + collaboration_share_service.validate_access_grant(link, grant.access_token) + assert stale.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_public_share_callback_is_not_attributed_to_the_file_owner(): + owner_id = uuid.uuid4() + db = SimpleNamespace(get=AsyncMock()) + session = SimpleNamespace(started_by=owner_id) + payload = CollaborationCallbackPayload( + key="shared-key", + status=6, + users=["share-0123456789ab-visitor-client"], + ) + + actor = await onlyoffice_collaboration_service._callback_user(db, payload, session) + + assert actor is None + db.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_file_roles_define_effective_edit_manage_and_export_permissions(monkeypatch): + user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) + item = SimpleNamespace( + id=uuid.uuid4(), + study_id=uuid.uuid4(), + owner_id=uuid.uuid4(), + allow_export=False, + ) + membership = SimpleNamespace(is_active=True, role_in_study="CRA") + member_role = AsyncMock(return_value="EDITOR") + monkeypatch.setattr(collaboration_service.member_crud, "get_member", AsyncMock(return_value=membership)) + monkeypatch.setattr(collaboration_service, "_member_role", member_role) + + assert await collaboration_service.can_edit_file(SimpleNamespace(), item, user) is True + assert await collaboration_service.can_manage_file(SimpleNamespace(), item, user) is False + assert await collaboration_service.can_export_file(SimpleNamespace(), item, user) is False + + item.allow_export = True + assert await collaboration_service.can_export_file(SimpleNamespace(), item, user) is True + + item.allow_export = False + member_role.return_value = "MANAGER" + assert await collaboration_service.can_edit_file(SimpleNamespace(), item, user) is True + assert await collaboration_service.can_manage_file(SimpleNamespace(), item, user) is True + assert await collaboration_service.can_export_file(SimpleNamespace(), item, user) is True + + member_role.return_value = None + item.owner_id = user.id + assert await collaboration_service.can_edit_file(SimpleNamespace(), item, user) is True + assert await collaboration_service.can_manage_file(SimpleNamespace(), item, user) is True + assert await collaboration_service.can_export_file(SimpleNamespace(), item, user) is True + assert await collaboration_service.can_transfer_ownership(SimpleNamespace(), item, user) is True + + +@pytest.mark.asyncio +async def test_project_permissions_only_control_role_assignment_eligibility(monkeypatch): + user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) + membership = SimpleNamespace(is_active=True, role_in_study="CTA") + permission_check = AsyncMock( + side_effect=lambda _db, _study_id, _role, permission: permission in { + "collaboration:read", + "collaboration:edit", + } + ) + monkeypatch.setattr(collaboration_service, "role_has_api_permission", permission_check) + + assert await collaboration_service._role_is_assignable( + SimpleNamespace(), uuid.uuid4(), user, membership, "EDITOR" + ) is True + assert await collaboration_service._role_is_assignable( + SimpleNamespace(), uuid.uuid4(), user, membership, "MANAGER" + ) is False + + with pytest.raises(HTTPException) as exc_info: + await collaboration_service._require_role_assignable( + SimpleNamespace(), uuid.uuid4(), user, membership, "MANAGER" + ) + assert exc_info.value.status_code == 422 + assert "不具备管理者或所有者资格" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_manager_assignment_rejects_an_unqualified_project_member(monkeypatch): + target_id = uuid.uuid4() + item = SimpleNamespace(id=uuid.uuid4(), study_id=uuid.uuid4(), owner_id=uuid.uuid4()) + actor = SimpleNamespace(id=uuid.uuid4()) + target = SimpleNamespace(id=target_id, is_admin=False) + membership = SimpleNamespace(is_active=True, role_in_study="CTA") + db = SimpleNamespace(get=AsyncMock(return_value=target)) + role_check = AsyncMock(side_effect=HTTPException(status_code=422, detail="资格不足")) + monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock()) + monkeypatch.setattr(collaboration_service.member_crud, "get_member", AsyncMock(return_value=membership)) + monkeypatch.setattr(collaboration_service, "_require_role_assignable", role_check) + + with pytest.raises(HTTPException) as exc_info: + await collaboration_service.upsert_member( + db, + item, + SimpleNamespace(user_id=target_id, role="MANAGER"), + actor, + ) + + assert exc_info.value.status_code == 422 + role_check.assert_awaited_once_with(db, item.study_id, target, membership, "MANAGER") + + +@pytest.mark.asyncio +async def test_ownership_transfer_rejects_an_unqualified_new_owner(monkeypatch): + target_id = uuid.uuid4() + item = SimpleNamespace(id=uuid.uuid4(), study_id=uuid.uuid4(), owner_id=uuid.uuid4()) + target = SimpleNamespace(id=target_id, is_admin=False) + membership = SimpleNamespace(is_active=True, role_in_study="CTA") + db = SimpleNamespace( + scalar=AsyncMock(return_value=item), + get=AsyncMock(return_value=target), + ) + role_check = AsyncMock(side_effect=HTTPException(status_code=422, detail="资格不足")) + monkeypatch.setattr(collaboration_service, "require_ownership_transfer", AsyncMock()) + monkeypatch.setattr(collaboration_service.member_crud, "get_member", AsyncMock(return_value=membership)) + monkeypatch.setattr(collaboration_service, "_require_role_assignable", role_check) + + with pytest.raises(HTTPException) as exc_info: + await collaboration_service.transfer_ownership( + db, + item, + SimpleNamespace(new_owner_id=target_id), + SimpleNamespace(id=item.owner_id), + ) + + assert exc_info.value.status_code == 422 + role_check.assert_awaited_once_with(db, item.study_id, target, membership, "MANAGER") + + +@pytest.mark.asyncio +async def test_workspace_save_as_also_requires_create_permission(monkeypatch): + user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) + item = SimpleNamespace(study_id=uuid.uuid4()) + membership = SimpleNamespace(is_active=True, role_in_study="CRA") + permission_check = AsyncMock(return_value=False) + monkeypatch.setattr(collaboration_service.member_crud, "get_member", AsyncMock(return_value=membership)) + monkeypatch.setattr(collaboration_service, "role_has_api_permission", permission_check) + + assert await collaboration_service.can_create_file(SimpleNamespace(), item, user) is False + permission_check.assert_awaited_once_with( + ANY, item.study_id, membership.role_in_study, "collaboration:create" + ) + + permission_check.return_value = True + assert await collaboration_service.can_create_file(SimpleNamespace(), item, user) is True + + +@pytest.mark.asyncio +async def test_copy_creates_an_independent_r1_from_the_current_revision(monkeypatch, tmp_path): + source = tmp_path / "source.xlsx" + content = collaboration_service.blank_file_bytes("cell") + source.write_bytes(content) + revision = SimpleNamespace(file_uri=str(source)) + item = SimpleNamespace( + id=uuid.uuid4(), + study_id=uuid.uuid4(), + folder_id=uuid.uuid4(), + title="研究数据.xlsx", + file_type="cell", + extension="xlsx", + current_revision_id=uuid.uuid4(), + ) + target = SimpleNamespace(id=uuid.uuid4(), title="研究数据 - 副本.xlsx") + db = SimpleNamespace(get=AsyncMock(return_value=revision)) + user = SimpleNamespace(id=uuid.uuid4()) + require_exporter = AsyncMock() + create_copy = AsyncMock(return_value=target) + monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter) + monkeypatch.setattr(collaboration_service, "_next_copy_title", AsyncMock(return_value=target.title)) + monkeypatch.setattr(collaboration_service, "_create_file_from_bytes", create_copy) + + result = await collaboration_service.copy_file(db, item, user) + + assert result is target + require_exporter.assert_awaited_once_with(db, item, user) + create_copy.assert_awaited_once_with( + db, + item.study_id, + title=target.title, + file_type="cell", + folder_id=item.folder_id, + content=content, + source="COPY", + user=user, + source_file_id=item.id, + ) + + +@pytest.mark.asyncio +async def test_download_returns_current_revision_and_records_audit(monkeypatch, tmp_path): + source = tmp_path / "source.docx" + source.write_bytes(collaboration_service.blank_file_bytes("word")) + revision = SimpleNamespace( + id=uuid.uuid4(), + revision_no=3, + file_uri=str(source), + mime_type=collaboration_service.MIME_TYPES["docx"], + ) + item = SimpleNamespace(id=uuid.uuid4(), current_revision_id=revision.id) + user = SimpleNamespace(id=uuid.uuid4()) + db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock()) + require_exporter = AsyncMock() + audit = AsyncMock() + monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter) + monkeypatch.setattr(collaboration_service, "_audit", audit) + + result = await collaboration_service.prepare_download(db, item, user) + + assert result is revision + require_exporter.assert_awaited_once_with(db, item, user) + audit.assert_awaited_once_with( + db, + item, + "COLLABORATION_FILE_DOWNLOADED", + user, + {"revision_id": str(revision.id), "revision_no": 3}, + ) + db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_file_read_includes_information_and_export_capability(monkeypatch): + now = datetime.now(timezone.utc) + owner_id = uuid.uuid4() + folder_id = uuid.uuid4() + revision_id = uuid.uuid4() + item = CollaborationFile( + id=uuid.uuid4(), + study_id=uuid.uuid4(), + folder_id=folder_id, + title="研究方案.docx", + file_type="word", + extension="docx", + status="ACTIVE", + owner_id=owner_id, + current_revision_id=revision_id, + generation=2, + allow_export=False, + allow_edit_request=False, + allow_sheet_structure_edit=True, + created_at=now, + updated_at=now, + ) + owner = SimpleNamespace(full_name="文件所有者") + folder = CollaborationFolder(id=folder_id, name="方案", study_id=item.study_id, created_by=owner_id) + revision = CollaborationRevision( + id=revision_id, + file_id=item.id, + revision_no=4, + file_uri="/hidden/revision.docx", + original_filename=item.title, + file_hash="hash", + file_size=4096, + mime_type=collaboration_service.MIME_TYPES["docx"], + source="SESSION_CLOSE", + created_at=now, + ) + + async def get_model(model, identifier): + if model is CollaborationFolder and identifier == folder_id: + return folder + if model is CollaborationRevision and identifier == revision_id: + return revision + return owner + + db = SimpleNamespace(get=AsyncMock(side_effect=get_model)) + user = SimpleNamespace(id=owner_id, is_admin=False) + monkeypatch.setattr(collaboration_service, "_member_role", AsyncMock(return_value="MANAGER")) + monkeypatch.setattr(collaboration_service, "can_edit_file", AsyncMock(return_value=True)) + monkeypatch.setattr(collaboration_service, "can_manage_file", AsyncMock(return_value=True)) + monkeypatch.setattr(collaboration_service, "can_export_file", AsyncMock(return_value=True)) + monkeypatch.setattr(collaboration_service, "edit_request_status", AsyncMock(return_value=None)) + monkeypatch.setattr(collaboration_service, "can_request_edit_file", AsyncMock(return_value=False)) + monkeypatch.setattr(collaboration_service, "can_transfer_ownership", AsyncMock(return_value=True)) + + result = await collaboration_service._file_read(db, item, user, collaborators=[]) + + assert result.folder_name == "方案" + assert result.current_revision_no == 4 + assert result.current_revision_file_size == 4096 + assert result.current_revision_mime_type == collaboration_service.MIME_TYPES["docx"] + assert result.current_revision_created_at == now + assert result.can_export is True + assert result.can_transfer_ownership is True + + +def test_workbook_structure_policy_locks_and_restores_sheet_operations(): + file_id = uuid.uuid4() + original = collaboration_service.blank_file_bytes("cell") + + protected, backup = collaboration_service.apply_workbook_structure_policy( + original, + file_id=file_id, + allow_sheet_structure_edit=False, + protection_backup=None, + ) + assert backup == "" + with zipfile.ZipFile(io.BytesIO(protected)) as package: + workbook = package.read("xl/workbook.xml").decode("utf-8") + assert "workbookProtection" in workbook + assert 'lockStructure="1"' in workbook + assert "workbookPassword=" in workbook + + restored, restored_backup = collaboration_service.apply_workbook_structure_policy( + protected, + file_id=file_id, + allow_sheet_structure_edit=True, + protection_backup=backup, + ) + assert restored_backup is None + with zipfile.ZipFile(io.BytesIO(restored)) as package: + workbook = package.read("xl/workbook.xml").decode("utf-8") + assert "workbookProtection" not in workbook + + +def test_collaboration_models_do_not_reference_attachment_or_document_tables(): + foreign_keys = { + foreign_key.target_fullname + for table in ( + CollaborationFile.__table__, + ) + for foreign_key in table.foreign_keys + } + assert all(not target.startswith("attachments.") for target in foreign_keys) + assert all(not target.startswith("documents.") for target in foreign_keys) + assert all(not target.startswith("document_versions.") for target in foreign_keys) + + +@pytest.mark.asyncio +async def test_collaborator_summaries_are_loaded_in_one_batch_for_the_file_list(): + first_file_id = uuid.uuid4() + second_file_id = uuid.uuid4() + first_member = SimpleNamespace( + file_id=first_file_id, + user_id=uuid.uuid4(), + role="MANAGER", + ) + second_member = SimpleNamespace( + file_id=second_file_id, + user_id=uuid.uuid4(), + role="EDITOR", + ) + first_user = SimpleNamespace(full_name="文件所有者", avatar_url="/avatars/owner.png") + second_user = SimpleNamespace(full_name="协作编辑者", avatar_url=None) + query_result = SimpleNamespace(all=lambda: [(first_member, first_user), (second_member, second_user)]) + db = SimpleNamespace(execute=AsyncMock(return_value=query_result)) + + result = await collaboration_service._collaborators_by_file(db, [first_file_id, second_file_id]) + + db.execute.assert_awaited_once() + assert result[first_file_id][0].full_name == "文件所有者" + assert result[first_file_id][0].avatar_url == "/avatars/owner.png" + assert result[second_file_id][0].role == "EDITOR" + + +class _CallbackDb: + def __init__(self, session, item): + self._scalar_results = iter((session, None)) + self._item = item + self.added = [] + + async def scalar(self, _statement): + return next(self._scalar_results) + + async def get(self, model, _identifier): + return self._item if model is CollaborationFile else None + + def add(self, value): + self.added.append(value) + + async def commit(self): + return None + + +@pytest.mark.asyncio +async def test_callback_error_status_is_recorded_and_acknowledged(): + session = SimpleNamespace( + id=uuid.uuid4(), + file_id=uuid.uuid4(), + document_key="ctms-collab-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=3), + ) + + assert result == {"error": 0} + assert session.status == "ERROR" + assert db.added[0].result == "ERROR" + + +@pytest.mark.asyncio +async def test_force_save_updates_session_recovery_revision(monkeypatch): + session = SimpleNamespace( + id=uuid.uuid4(), + file_id=uuid.uuid4(), + document_key="ctms-collab-force-save-key", + generation=2, + status="ACTIVE", + active_users=None, + last_callback_at=None, + base_revision_id=uuid.uuid4(), + ) + item = SimpleNamespace(id=session.file_id, generation=2) + actor = SimpleNamespace(id=uuid.uuid4()) + revision = SimpleNamespace(id=uuid.uuid4(), revision_no=4) + db = _CallbackDb(session, item) + monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", AsyncMock(return_value=b"saved")) + monkeypatch.setattr(onlyoffice_collaboration_service, "_callback_user", AsyncMock(return_value=actor)) + monkeypatch.setattr( + onlyoffice_collaboration_service.collaboration_service, + "append_revision", + AsyncMock(return_value=(revision, True)), + ) + monkeypatch.setattr( + onlyoffice_collaboration_service.collaboration_service, + "_audit", + AsyncMock(), + ) + + result = await onlyoffice_collaboration_service.process_callback( + db, + session.id, + CollaborationCallbackPayload( + key=session.document_key, + status=6, + url="http://onlyoffice/cache/force-save.docx", + users=[str(actor.id)], + ), + ) + + assert result == {"error": 0} + assert session.base_revision_id == revision.id + assert session.status == "ACTIVE" + assert db.added[0].revision_id == revision.id diff --git a/backend/tests/test_notification_service.py b/backend/tests/test_notification_service.py new file mode 100644 index 00000000..c6a7001e --- /dev/null +++ b/backend/tests/test_notification_service.py @@ -0,0 +1,139 @@ +import uuid +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.models.notification import Notification +from app.models.collaboration import CollaborationEditRequest +from app.services import collaboration_service, notification_service, project_reminder_service + + +def test_generic_notification_table_has_recipient_dedupe_and_state_indexes(): + table = Notification.__table__ + assert table.name == "notifications" + assert {"study_id", "recipient_id", "category", "action_path", "source_type", "source_id", "read_at", "resolved_at"} <= set(table.columns.keys()) + assert any(constraint.name == "uq_notifications_recipient_dedupe" for constraint in table.constraints) + assert {index.name for index in table.indexes} >= { + "ix_notifications_recipient_study_state", + "ix_notifications_source", + } + + +@pytest.mark.asyncio +async def test_recipient_notification_creation_is_deduplicated_per_recipient(): + existing_id = uuid.uuid4() + new_id = uuid.uuid4() + result = SimpleNamespace(all=lambda: [existing_id]) + db = SimpleNamespace(scalars=AsyncMock(return_value=result), add=Mock()) + + await notification_service.create_recipient_notifications( + db, + study_id=uuid.uuid4(), + recipient_ids=[existing_id, new_id, new_id], + category="COLLABORATION_EDIT_REQUEST", + priority="NORMAL", + title="新的编辑权限申请", + message="申请编辑文件", + action_path="/knowledge/collaboration?editRequestFile=file-id", + source_type="COLLABORATION_EDIT_REQUEST", + source_id="request-id", + dedupe_key="collaboration-edit-request:request-id", + ) + + db.add.assert_called_once() + created = db.add.call_args.args[0] + assert created.recipient_id == new_id + assert created.source_id == "request-id" + + +@pytest.mark.asyncio +async def test_resolving_a_source_closes_every_recipient_notification(): + db = SimpleNamespace(execute=AsyncMock()) + await notification_service.resolve_source_notifications( + db, + source_type="COLLABORATION_EDIT_REQUEST", + source_id="request-id", + ) + db.execute.assert_awaited_once() + statement = str(db.execute.await_args.args[0]) + assert "UPDATE notifications" in statement + assert "source_type" in statement + assert "source_id" in statement + + +@pytest.mark.asyncio +async def test_project_risks_are_materialized_into_the_generic_notification_table(monkeypatch): + study_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) + membership = SimpleNamespace(is_active=True, role_in_study="PM") + db = SimpleNamespace(commit=AsyncMock()) + sync = AsyncMock() + monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=membership)) + monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True)) + monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None)) + monkeypatch.setattr(project_reminder_service.ae_crud, "list_ae", AsyncMock(return_value=[object(), object()])) + monkeypatch.setattr(project_reminder_service.monitoring_issue_crud, "list_issues", AsyncMock(return_value=[object()])) + monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync) + + await project_reminder_service.sync_project_reminders(db, study_id, user) + + assert sync.await_count == 2 + assert sync.await_args_list[0].kwargs["count"] == 2 + assert sync.await_args_list[1].kwargs["count"] == 1 + db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypatch): + study_id = uuid.uuid4() + file_id = uuid.uuid4() + owner_id = uuid.uuid4() + manager_id = uuid.uuid4() + request_id = uuid.uuid4() + item = SimpleNamespace( + id=file_id, + study_id=study_id, + owner_id=owner_id, + title="PK_示例.xlsx", + allow_edit_request=True, + ) + user = SimpleNamespace(id=uuid.uuid4(), full_name="周成成", email="member@example.com") + added = [] + + def add(value): + added.append(value) + + async def flush(): + request = next(value for value in added if isinstance(value, CollaborationEditRequest)) + request.id = request_id + request.status = "PENDING" + request.resolved_by = None + request.resolved_at = None + request.created_at = datetime.now(timezone.utc) + + db = SimpleNamespace( + scalar=AsyncMock(return_value=None), + scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: [owner_id, manager_id])), + add=Mock(side_effect=add), + flush=AsyncMock(side_effect=flush), + commit=AsyncMock(), + refresh=AsyncMock(), + ) + notify = AsyncMock() + monkeypatch.setattr(collaboration_service, "can_edit_file", AsyncMock(return_value=False)) + monkeypatch.setattr( + collaboration_service.member_crud, + "get_member", + AsyncMock(return_value=SimpleNamespace(is_active=True)), + ) + monkeypatch.setattr(collaboration_service, "_audit", AsyncMock()) + monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify) + + result = await collaboration_service.create_edit_request(db, item, user) + + assert result.id == request_id + assert set(notify.await_args.kwargs["recipient_ids"]) == {owner_id, manager_id} + assert notify.await_args.kwargs["action_path"] == f"/knowledge/collaboration?editRequestFile={file_id}" + assert notify.await_args.kwargs["source_id"] == str(request_id) diff --git a/docker-compose.yaml b/docker-compose.yaml index 6da1bc5a..03257ae8 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -52,6 +52,7 @@ services: ONLYOFFICE_STORAGE_BASE_URL: ${ONLYOFFICE_STORAGE_BASE_URL:-http://backend:8000} ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:-} ONLYOFFICE_CONFIG_TTL_SECONDS: ${ONLYOFFICE_CONFIG_TTL_SECONDS:-300} + COLLABORATION_MAX_FILE_BYTES: ${COLLABORATION_MAX_FILE_BYTES:-52428800} depends_on: db: condition: service_healthy diff --git a/docs/onlyoffice-collaboration.md b/docs/onlyoffice-collaboration.md new file mode 100644 index 00000000..f807af18 --- /dev/null +++ b/docs/onlyoffice-collaboration.md @@ -0,0 +1,76 @@ +# ONLYOFFICE 在线协作模块 + +## 范围 + +在线协作是共享库下的独立业务模块,不复用附件、文档管理、文件版本管理或 eTMF 的数据表和文件标识。当前支持新建或导入 `DOCX`、`XLSX`、`PPTX`,通过 ONLYOFFICE 进行多人共同编辑,并把保存结果固化为不可变修订。 + +原有附件与文档版本仍保持只读预览流程,不会自动进入协作空间,也不会因协作回调被覆盖。 + +## 数据与权限边界 + +- `collaboration_files` 保存协作文件元数据、当前修订指针和统一的内容操作权限设置。 +- `collaboration_revisions` 保存不可变文件修订;每次有效保存或历史恢复都会创建新修订。 +- `collaboration_members` 保存文件级编辑者和管理者。 +- `collaboration_edit_requests` 保存系统内项目成员发起的编辑权限申请及审批结果;同一成员、同一文件同时只允许存在一条待处理申请。 +- `notifications` 是收件人级通用通知表,统一保存通知类别、优先级、内部操作路径、业务来源、去重键、已读和业务关闭状态。编辑权限申请只通知当前有效的文件所有者和文件管理者;申请处理后,同一业务来源的所有收件人通知一并关闭。 +- `collaboration_sessions` 使用稳定的文件代次生成 ONLYOFFICE `document.key`,同一代次的用户进入同一共同编辑会话。 +- `collaboration_callback_receipts` 对回调做幂等确认。 +- `collaboration_share_links` 保存单文件公开链接的启停状态、查看/编辑权限、有效期策略和密码哈希;不保存明文密码或共享令牌。 +- 项目接口权限只控制在线协作模块入口、项目级新建/导入/文件夹操作,以及账号能否被授予文件角色;文件创建后的编辑、管理、导出和所有权转让统一由文件身份判定,项目角色不再隐式覆盖文件角色。 +- 文件所有者始终拥有编辑、管理、导出和转让能力;文件管理者始终拥有编辑、管理和导出能力,但不能转让所有权;文件编辑者可以编辑,导出能力由文件“权限设置”开关控制。系统管理员继续保留平台级完整访问能力。 +- 授予编辑者、管理者或转让所有权时,目标账号必须是当前项目的有效成员,并具备对应项目角色资格;不合格账号在联系人列表中不可选择,后端同时拒绝绕过界面的授权请求。 +- 文件管理者可开启“允许申请编辑权限”。只读项目成员可从 CTMS 顶栏或 ONLYOFFICE 的“请求编辑”入口提交申请;批准后写入文件级编辑者授权,拒绝或重复处理均有明确状态和审计记录。匿名链接访问者不能申请系统账号权限。 +- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”。原逾期 AE 和逾期监查问题也先同步为通用通知,再由同一 Feed 返回,不再由网页和桌面布局分别临时汇总。 +- Excel 文件可通过“允许所有协作者添加、删除工作表”控制工作簿结构。关闭时 CTMS 在新的不可变修订中启用工作簿结构保护并推进文件代次;重新开启时恢复文件原有的工作簿保护状态。该开关作用于所有协作者且不影响单元格内容编辑权限。 +- 仅文档所有者和系统管理员可转让所有权。目标联系人必须具备文件管理者资格;转让后目标联系人升级为文件管理者,原所有者保留管理者身份,所有权变更写入审计。 +- 文件编辑器“下载为”由文件导出规则判定:所有者和管理者始终允许,编辑者受文件开关控制,并由 CTMS 网页/桌面文件运行时保存到本机且记录下载审计。“另存为…”还会在项目工作区创建独立协作文件,因此额外要求项目级 `collaboration:create` 权限。 + +## 公开分享链接 + +- 文件管理者可开启链接,并选择只读或协作编辑、1 天/7 天/30 天/永久有效及可选访问密码;设置修改后自动保存,无需额外提交整张表单。 +- 文件操作菜单以一个“访问与权限”弹窗统一管理账号授权、匿名访问和“权限设置”;下载、打印、另存和复制不在两种访问方式下重复配置。 +- “权限设置”中的内容操作开关只作用于已授权编辑者和匿名访问者。所有者、文件管理者和系统管理员始终保留导出能力;外部链接不具备向项目工作区另存副本的权限。 +- 共享令牌只放在网页 URL 的 fragment(`#...`)中,不进入服务器请求路径、查询参数或本地缓存;前端调用公开 API 时通过会被审计层脱敏的请求头传递。 +- 每个文件首次创建分享记录后使用固定共享地址;修改权限、密码、有效期或关闭后重新开启都不会改变 URL。关闭期间同一地址暂停访问,重新开启后恢复访问。 +- 链接密码仅保存 bcrypt 哈希;验证成功后签发短时、链接记录绑定的内存访问凭证。连续错误达到阈值后链接密码验证会临时锁定。 +- 外部共享页不依赖 CTMS 登录状态,不提供工作区“另存为”;开启允许导出后可下载到访问者本机,编辑链接仍通过现有 ONLYOFFICE 会话写入不可变修订。 +- `JWT_SECRET_KEY` 轮换属于平台级全局失效操作,会使现有公开分享链接和短时访问凭证失效;生产环境必须按安全变更流程评估影响并提前通知链接使用者。 + +## 保存流程 + +1. 浏览器请求 `/api/v1/studies/{study_id}/collaboration/files/{file_id}/editor-config`。 +2. 后端生成带 JWT 的编辑配置、内部内容地址和回调地址。 +3. Document Server 从内部内容接口读取当前修订。 +4. 共同编辑期间使用 fast 模式;强制保存产生新修订并更新会话恢复基线。 +5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。 +6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。 + +内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。 + +## 本地开发 + +执行: + +```bash +bash scripts/onlyoffice-dev-up.sh +``` + +脚本会生成或复用独立的开发 JWT 密钥,启用 `office` Profile,并验证只读预览及在线协作的后端路由。协作修订保存在现有后端上传卷下的独立 `collaboration/` 目录。 + +关键配置: + +- `ONLYOFFICE_ENABLED` +- `ONLYOFFICE_JWT_SECRET` +- `ONLYOFFICE_INTERNAL_URL` +- `ONLYOFFICE_STORAGE_BASE_URL` +- `ONLYOFFICE_INSTANCE_ID` +- `ONLYOFFICE_CONFIG_TTL_SECONDS` +- `COLLABORATION_MAX_FILE_BYTES` + +生产环境仍默认关闭 ONLYOFFICE;启用前必须按既有发布要求确认镜像许可、JWT 密钥、内部网络、备份与监控。 + +参考: + +- [ONLYOFFICE 共同编辑模式](https://api.onlyoffice.com/docs/docs-api/get-started/how-it-works/co-editing/) +- [ONLYOFFICE 回调处理器](https://api.onlyoffice.com/docs/docs-api/usage-api/callback-handler/) +- [ONLYOFFICE 文档权限](https://api.onlyoffice.com/docs/docs-api/usage-api/config/document/permissions/) diff --git a/frontend/public/onlyoffice-host.js b/frontend/public/onlyoffice-host.js index 63bc5ab8..b138b6da 100644 --- a/frontend/public/onlyoffice-host.js +++ b/frontend/public/onlyoffice-host.js @@ -7,8 +7,15 @@ DOCUMENT_READY: "ctms.onlyoffice.document-ready", WARNING: "ctms.onlyoffice.warning", ERROR: "ctms.onlyoffice.error", + DOCUMENT_STATE_CHANGE: "ctms.onlyoffice.document-state-change", + SAVE_AS: "ctms.onlyoffice.save-as", + SAVE_AS_ERROR: "ctms.onlyoffice.save-as-error", + DOWNLOAD: "ctms.onlyoffice.download", + DOWNLOAD_ERROR: "ctms.onlyoffice.download-error", + REQUEST_EDIT_RIGHTS: "ctms.onlyoffice.request-edit-rights", }); const API_SCRIPT_PATH = "/onlyoffice/web-apps/apps/api/documents/api.js"; + const MAX_SAVE_AS_BYTES = 64 * 1024 * 1024; const ALLOWED_TAURI_ORIGINS = new Set([ "tauri://localhost", "http://tauri.localhost", @@ -21,6 +28,7 @@ let apiScriptPromise = null; let readyTimer = null; let readyDeadlineTimer = null; + let documentTitle = "download"; const isLoopbackOrigin = (origin) => { try { @@ -34,9 +42,9 @@ const isAllowedParentOrigin = (origin) => origin === window.location.origin || ALLOWED_TAURI_ORIGINS.has(origin) || isLoopbackOrigin(origin); - const postToParent = (type, detail) => { + const postToParent = (type, detail, transfer = []) => { if (!parentOrigin || !requestNonce) return; - window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin); + window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin, transfer); }; const safeEventDetail = (event) => { @@ -67,6 +75,83 @@ return apiScriptPromise; }; + const saveAsEventDetail = (event) => { + const raw = event && typeof event === "object" && "data" in event ? event.data : null; + if (!raw || typeof raw !== "object") throw new Error("另存为数据格式不正确"); + const fileType = typeof raw.fileType === "string" ? raw.fileType.toLowerCase() : ""; + const title = typeof raw.title === "string" ? raw.title.trim().slice(0, 240) : ""; + if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || typeof raw.url !== "string") { + throw new Error("另存为文件信息不完整"); + } + const url = new URL(raw.url, window.location.origin); + if (url.origin !== window.location.origin || !url.pathname.startsWith("/onlyoffice/")) { + throw new Error("另存为文件地址不可信"); + } + return { fileType, title, url }; + }; + + const handleRequestSaveAs = async (event) => { + try { + const { fileType, title, url } = saveAsEventDetail(event); + const response = await fetch(url, { + cache: "no-store", + credentials: "omit", + redirect: "error", + }); + if (!response.ok) throw new Error("另存为文件生成失败"); + const declaredSize = Number(response.headers.get("content-length") || "0"); + if (declaredSize > MAX_SAVE_AS_BYTES) throw new Error("另存为文件超出大小限制"); + const data = await response.arrayBuffer(); + if (!data.byteLength || data.byteLength > MAX_SAVE_AS_BYTES) throw new Error("另存为文件内容无效"); + const mimeType = (response.headers.get("content-type") || "application/octet-stream").slice(0, 120); + postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data]); + } catch (error) { + postToParent(MESSAGE.SAVE_AS_ERROR, { + message: error instanceof Error ? error.message.slice(0, 200) : "另存为失败", + }); + } + }; + + const downloadEventDetail = (event) => { + const raw = event && typeof event === "object" && "data" in event ? event.data : null; + if (!raw || typeof raw !== "object") throw new Error("下载文件数据格式不正确"); + const fileType = typeof raw.fileType === "string" ? raw.fileType.toLowerCase() : ""; + if (!/^[a-z0-9]{1,16}$/.test(fileType) || typeof raw.url !== "string") { + throw new Error("下载文件信息不完整"); + } + const url = new URL(raw.url, window.location.origin); + if (url.origin !== window.location.origin || !url.pathname.startsWith("/onlyoffice/")) { + throw new Error("下载文件地址不可信"); + } + const suffix = `.${fileType}`; + const stem = documentTitle.toLowerCase().endsWith(suffix) + ? documentTitle.slice(0, -suffix.length) + : documentTitle.replace(/\.[^.]+$/, ""); + return { fileType, title: `${stem}${suffix}`, url }; + }; + + const handleDownloadAs = async (event) => { + try { + const { fileType, title, url } = downloadEventDetail(event); + const response = await fetch(url, { + cache: "no-store", + credentials: "omit", + redirect: "error", + }); + if (!response.ok) throw new Error("下载文件生成失败"); + const declaredSize = Number(response.headers.get("content-length") || "0"); + if (declaredSize > MAX_SAVE_AS_BYTES) throw new Error("下载文件超出大小限制"); + const data = await response.arrayBuffer(); + if (!data.byteLength || data.byteLength > MAX_SAVE_AS_BYTES) throw new Error("下载文件内容无效"); + const mimeType = (response.headers.get("content-type") || "application/octet-stream").slice(0, 120); + postToParent(MESSAGE.DOWNLOAD, { fileType, title, mimeType, data }, [data]); + } catch (error) { + postToParent(MESSAGE.DOWNLOAD_ERROR, { + message: error instanceof Error ? error.message.slice(0, 200) : "下载失败", + }); + } + }; + const destroyEditor = () => { if (editor && typeof editor.destroyEditor === "function") { try { @@ -95,14 +180,24 @@ throw new Error("预览配置格式不正确"); } requestNonce = message.nonce; + documentTitle = typeof config.document?.title === "string" && config.document.title.trim() + ? config.document.title.trim().slice(0, 240) + : "download"; await loadOnlyOfficeApi(); + const events = { + onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY), + onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)), + onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)), + onDocumentStateChange: (event) => postToParent(MESSAGE.DOCUMENT_STATE_CHANGE, { + changed: Boolean(event && typeof event === "object" && "data" in event ? event.data : event), + }), + onRequestEditRights: () => postToParent(MESSAGE.REQUEST_EDIT_RIGHTS), + }; + if (message?.allowSaveAs === true) events.onRequestSaveAs = handleRequestSaveAs; + if (message?.allowDownload === true) events.onDownloadAs = handleDownloadAs; const editorConfig = { ...config, - events: { - onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY), - onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)), - onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)), - }, + events, }; editor = new window.DocsAPI.DocEditor("onlyoffice-editor", editorConfig); }; diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index 364c4e68..a2c7ba6e 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -206,6 +206,11 @@ const verifyOnlyOfficeBoundary = async () => { assert(hostSource.includes("!isAllowedParentOrigin(event.origin)"), "ONLYOFFICE host must validate the parent origin."); assert(hostSource.includes("initialized ||"), "ONLYOFFICE host must only accept one initialization."); assert(hostSource.includes("message?.nonce"), "ONLYOFFICE host must require the in-memory nonce."); + assert(hostSource.includes("url.origin !== window.location.origin"), "ONLYOFFICE save-as URL must remain same-origin."); + assert(hostSource.includes('url.pathname.startsWith("/onlyoffice/")'), "ONLYOFFICE save-as URL must remain under the fixed proxy path."); + assert(hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])"), "ONLYOFFICE save-as bridge must transfer validated file bytes."); + assert(!hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, url"), "ONLYOFFICE save-as bridge must not expose the signed result URL to business pages."); + assert(viewerSource.includes("allowSaveAs: props.allowSaveAs"), "ONLYOFFICE save-as must stay behind an explicit server capability."); assert(!/\b(?:localStorage|sessionStorage|console\.)\b/.test(hostSource), "ONLYOFFICE host must not persist or log signed configuration."); assert(apiSource.includes("cache: false"), "ONLYOFFICE signed config must not enter the desktop data cache."); assert(apiSource.includes("disableRequestDedupe: true"), "ONLYOFFICE signed config requests must not be deduplicated."); @@ -290,22 +295,31 @@ const verifyRustBoundary = async () => { "updates::desktop_update_install", "desktop_menu_set_shortcuts", "desktop_window_set_theme", + "desktop_window_get_fullscreen", + "desktop_window_set_fullscreen", ]; const unexpected = commands.filter((command) => !allowedCommands.includes(command)); const missing = allowedCommands.filter((command) => !commands.includes(command)); assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || ""}.`); assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || ""}.`); + assert(libSource.includes("window.is_fullscreen()"), "Desktop fullscreen state must be read from the native window."); + assert(libSource.includes(".set_fullscreen(fullscreen)"), "Desktop fullscreen changes must target the native window."); + assert(libSource.includes("DESKTOP_FULLSCREEN_CHANGED_EVENT"), "Native fullscreen state must be emitted back to the WebView."); }; const verifyDesktopUiNativeSync = async () => { const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8"); const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.ts"), "utf8"); + const fullscreenSource = await readFile(resolve(sourceDir, "runtime/webFullscreen.ts"), "utf8"); const appSource = await readFile(resolve(sourceDir, "App.vue"), "utf8"); assert(menuSource.includes('invoke("desktop_menu_set_shortcuts"'), "Desktop shortcuts must sync through the controlled Tauri command."); assert(menuSource.includes("DESKTOP_SHORTCUTS_CHANGED_EVENT"), "Native menu shortcuts must track preference changes."); assert(preferencesSource.includes('"system" | "light" | "dark"'), "Desktop theme must support following the system appearance."); assert(preferencesSource.includes('invoke("desktop_window_set_theme"'), "Desktop window theme must sync through the controlled Tauri command."); + assert(fullscreenSource.includes('invoke("desktop_window_get_fullscreen"'), "Desktop fullscreen state must use the controlled Tauri query command."); + assert(fullscreenSource.includes('invoke("desktop_window_set_fullscreen"'), "Desktop fullscreen changes must use the controlled Tauri mutation command."); + assert(fullscreenSource.includes("ctms:desktop-fullscreen-changed"), "Desktop fullscreen changes must synchronize native window state back to the WebView."); assert(preferencesSource.includes('matchMedia("(prefers-color-scheme: dark)")'), "System theme changes must update the WebView appearance."); assert(appSource.includes("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup."); assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut synchronization must initialize at app startup."); diff --git a/frontend/scripts/verify-runtime-boundary.mjs b/frontend/scripts/verify-runtime-boundary.mjs index 545338a3..65d4be37 100644 --- a/frontend/scripts/verify-runtime-boundary.mjs +++ b/frontend/scripts/verify-runtime-boundary.mjs @@ -9,6 +9,14 @@ const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]); const violations = []; const toPosixPath = (path) => path.split("\\").join("/"); +const platformSource = await readFile(resolve(runtimeDir, "platform.ts"), "utf8"); +if (!platformSource.includes("runtimeGlobal.isTauri")) { + violations.push("src/runtime/platform.ts: Tauri v2 runtime detection must use the official isTauri marker"); +} +if (platformSource.includes("__TAURI")) { + violations.push("src/runtime/platform.ts: Tauri runtime detection must not depend on legacy or internal markers"); +} + const walk = async (directory) => { const entries = await readdir(directory, { withFileTypes: true }); return ( @@ -28,7 +36,7 @@ for (const path of await walk(sourceDir)) { const source = await readFile(path, "utf8"); const file = toPosixPath(relative(frontendDir, path)); - if (source.includes("@tauri-apps/") || source.includes("__TAURI")) { + if (source.includes("@tauri-apps/") || source.includes("__TAURI") || /(?:globalThis|window)\.isTauri/.test(source)) { violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`); } if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) { diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 23e14c7a..7b7a9bc7 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -7,9 +7,9 @@ use serde::Deserialize; use tauri::menu::{ AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, }; -#[cfg(target_os = "macos")] -use tauri::RunEvent; -use tauri::{Emitter, Manager, Runtime, Theme, WebviewWindow, WebviewWindowBuilder}; +use tauri::{ + Emitter, Manager, RunEvent, Runtime, Theme, WebviewWindow, WebviewWindowBuilder, WindowEvent, +}; const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command"; const VIEW_MENU_ID: &str = "ctms.menu.view"; @@ -18,6 +18,7 @@ const REFRESH_COMMAND_ID: &str = "ctms.desktop.refresh"; const TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar"; const BACK_COMMAND_ID: &str = "ctms.desktop.back"; const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward"; +const DESKTOP_FULLSCREEN_CHANGED_EVENT: &str = "ctms:desktop-fullscreen-changed"; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -343,6 +344,25 @@ fn desktop_window_set_theme( .map_err(|error| format!("更新桌面窗口主题失败:{error}")) } +#[tauri::command] +fn desktop_window_get_fullscreen(window: WebviewWindow) -> Result { + window + .is_fullscreen() + .map_err(|error| format!("读取桌面窗口全屏状态失败:{error}")) +} + +#[tauri::command] +fn desktop_window_set_fullscreen( + window: WebviewWindow, + fullscreen: bool, +) -> Result { + window + .set_fullscreen(fullscreen) + .map_err(|error| format!("切换桌面窗口全屏状态失败:{error}"))?; + let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen); + Ok(fullscreen) +} + pub fn run() { let app = tauri::Builder::default() .menu(desktop_menu) @@ -372,19 +392,33 @@ pub fn run() { updates::desktop_update_install, desktop_menu_set_shortcuts, desktop_window_set_theme, + desktop_window_get_fullscreen, + desktop_window_set_fullscreen, ]) .build(tauri::generate_context!()) .expect("error while building CTMS desktop application"); app.run(|app, event| { #[cfg(target_os = "macos")] - if let RunEvent::Reopen { .. } = event { + if let RunEvent::Reopen { .. } = &event { if let Err(error) = restore_main_window(app) { eprintln!("{error}"); } } - #[cfg(not(target_os = "macos"))] - let _ = (app, event); + if let RunEvent::WindowEvent { + label, + event: WindowEvent::Resized(_), + .. + } = &event + { + if label == "main" { + if let Some(window) = app.get_webview_window(label) { + if let Ok(fullscreen) = window.is_fullscreen() { + let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen); + } + } + } + } }); } diff --git a/frontend/src/api/axios.test.ts b/frontend/src/api/axios.test.ts index 27e7d72d..60ae74ec 100644 --- a/frontend/src/api/axios.test.ts +++ b/frontend/src/api/axios.test.ts @@ -110,6 +110,30 @@ describe("api axios retry", () => { expect(adapter).toHaveBeenCalledTimes(2); }); + it("does not attach the CTMS login token to public share requests", async () => { + const auth = await import("../utils/auth"); + vi.mocked(auth.getToken).mockReturnValue("private-login-token"); + const api = (await import("./axios")).default; + const adapter = vi.fn(async (config) => ({ + data: { ok: true }, + status: 200, + statusText: "OK", + headers: {}, + config: config as InternalAxiosRequestConfig, + })); + + await api.get("/api/v1/collaboration/shares/metadata", { + adapter, + publicRequest: true, + headers: { "X-CTMS-Share-Token": "share-token" }, + } as any); + + const config = adapter.mock.calls[0]?.[0] as InternalAxiosRequestConfig; + expect(config.headers.Authorization).toBeUndefined(); + expect(config.headers["X-CTMS-Share-Token"]).toBe("share-token"); + vi.mocked(auth.getToken).mockReturnValue(null); + }); + it("keeps request headers in the dedupe key when they can affect the response", async () => { const { apiGet } = await import("./axios"); const adapter = vi.fn(async (config) => ({ diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 3f5b7f61..10bfbba2 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -20,6 +20,7 @@ const DEFAULT_GET_CACHE_TTL_MS = 30_000; const API_RESPONSE_CACHE_SCHEMA_VERSION = 1; export type ApiRequestConfig = AxiosRequestConfig & { + publicRequest?: boolean; suppressErrorMessage?: boolean; _retry?: boolean; _networkRetryCount?: number; @@ -225,7 +226,7 @@ instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiReque config.headers = config.headers || {}; Object.assign(config.headers, getAppMetadataHeaders()); const token = getToken(); - if (token) { + if (token && !config.publicRequest) { (config.headers as Record).Authorization = `Bearer ${token}`; } return config; @@ -237,12 +238,13 @@ instance.interceptors.response.use( const status = error.response?.status; const data = error.response?.data; const reqUrl = error.config?.url || ""; + const isPublicRequest = Boolean((error.config as ApiRequestConfig | undefined)?.publicRequest); const isAuthEndpoint = reqUrl.includes("/api/v1/auth/login") || reqUrl.includes("/api/v1/auth/register") || reqUrl.includes("/api/v1/auth/password-reset") || reqUrl.includes("/api/v1/auth/extend"); - if (isAuthEndpoint) { + if (isAuthEndpoint || isPublicRequest) { // 认证相关的错误由具体页面自行处理,避免重复提示 return Promise.reject(error); } diff --git a/frontend/src/api/collaboration.test.ts b/frontend/src/api/collaboration.test.ts new file mode 100644 index 00000000..a053c867 --- /dev/null +++ b/frontend/src/api/collaboration.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiPut = vi.fn(); +const apiPatch = vi.fn(); +const apiDelete = vi.fn(); + +vi.mock("./axios", () => ({ apiGet, apiPost, apiPut, apiPatch, apiDelete })); + +describe("collaboration API", () => { + beforeEach(() => vi.clearAllMocks()); + + it("keeps editor configuration outside request dedupe and desktop cache", async () => { + const { fetchCollaborationEditorConfig } = await import("./collaboration"); + fetchCollaborationEditorConfig("study-1", "file-1"); + expect(apiGet).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/editor-config", + { + cache: false, + disableRequestDedupe: true, + disableNetworkRetry: true, + suppressErrorMessage: true, + }, + ); + }); + + it("uses an independent collaboration namespace", async () => { + const { createCollaborationFile, fetchCollaborationFiles } = await import("./collaboration"); + createCollaborationFile("study-1", { title: "方案", file_type: "word" }); + fetchCollaborationFiles("study-1", { keyword: "方案" }); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files", + { title: "方案", file_type: "word" }, + ); + expect(apiGet).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files", + { params: { keyword: "方案" } }, + ); + }); + + it("records a completed Save As operation for audit", async () => { + const { recordCollaborationExport } = await import("./collaboration"); + recordCollaborationExport("study-1", "file-1", "xlsx"); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/exports", + { file_type: "xlsx" }, + ); + }); + + it("records a completed local download separately from workspace Save As", async () => { + const { recordCollaborationDownload } = await import("./collaboration"); + recordCollaborationDownload("study-1", "file-1", "xlsx"); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/downloads", + { file_type: "xlsx" }, + ); + }); + + it("copies and downloads collaboration files through authenticated endpoints", async () => { + const { copyCollaborationFile, downloadCollaborationFile } = await import("./collaboration"); + copyCollaborationFile("study-1", "file-1"); + downloadCollaborationFile("study-1", "file-1"); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/copy", + ); + expect(apiGet).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/download", + { + responseType: "blob", + cache: false, + suppressErrorMessage: true, + }, + ); + }); + + it("always reloads revision history without cache or pending-request reuse", async () => { + const { fetchCollaborationRevisions } = await import("./collaboration"); + fetchCollaborationRevisions("study-1", "file-1"); + expect(apiGet).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/revisions", + { cache: false, disableRequestDedupe: true }, + ); + }); + + it("supports naming, copying, and deleting a selected historical revision", async () => { + const { copyCollaborationRevision, deleteCollaborationRevision, updateCollaborationRevision } = await import("./collaboration"); + updateCollaborationRevision("study-1", "file-1", "revision-2", { change_summary: "送审版" }); + copyCollaborationRevision("study-1", "file-1", "revision-2", { title: "方案-R2.docx", folder_id: "folder-2" }); + deleteCollaborationRevision("study-1", "file-1", "revision-2"); + expect(apiPatch).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2", + { change_summary: "送审版" }, + ); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2/copy", + { title: "方案-R2.docx", folder_id: "folder-2" }, + ); + expect(apiDelete).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2", + ); + }); + + it("keeps share tokens in a redacted request header and public requests outside auth recovery", async () => { + const { + fetchCollaborationShareLink, + fetchPublicCollaborationShareMetadata, + updateCollaborationShareLink, + verifyPublicCollaborationSharePassword, + } = await import("./collaboration"); + fetchCollaborationShareLink("study-1", "file-1"); + updateCollaborationShareLink("study-1", "file-1", { + enabled: true, + access_mode: "EDIT", + expiry_policy: "SEVEN_DAYS", + password_mode: "SET", + password: "1234", + }); + fetchPublicCollaborationShareMetadata("share-secret"); + verifyPublicCollaborationSharePassword("share-secret", "1234"); + + expect(apiGet).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/share-link", + { cache: false, disableRequestDedupe: true }, + ); + expect(apiPut).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/share-link", + expect.objectContaining({ enabled: true, expiry_policy: "SEVEN_DAYS", password: "1234" }), + ); + const publicConfig = expect.objectContaining({ + headers: { "X-CTMS-Share-Token": "share-secret" }, + publicRequest: true, + cache: false, + disableRequestDedupe: true, + disableNetworkRetry: true, + suppressErrorMessage: true, + invalidateCache: false, + }); + expect(apiGet).toHaveBeenCalledWith("/api/v1/collaboration/shares/metadata", publicConfig); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/collaboration/shares/access", + { password: "1234" }, + publicConfig, + ); + }); + + it("supports edit-request approval and contact ownership transfer", async () => { + const { + createCollaborationEditRequest, + fetchCollaborationEditRequests, + resolveCollaborationEditRequest, + transferCollaborationOwnership, + } = await import("./collaboration"); + createCollaborationEditRequest("study-1", "file-1"); + fetchCollaborationEditRequests("study-1", "file-1"); + resolveCollaborationEditRequest("study-1", "file-1", "request-1", "APPROVED"); + transferCollaborationOwnership("study-1", "file-1", "user-2"); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/edit-requests", + ); + expect(apiGet).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/edit-requests", + { cache: false, disableRequestDedupe: true }, + ); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/edit-requests/request-1/resolve", + { status: "APPROVED" }, + ); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/collaboration/files/file-1/transfer-ownership", + { new_owner_id: "user-2" }, + ); + }); +}); diff --git a/frontend/src/api/collaboration.ts b/frontend/src/api/collaboration.ts new file mode 100644 index 00000000..d810d6f8 --- /dev/null +++ b/frontend/src/api/collaboration.ts @@ -0,0 +1,198 @@ +import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./axios"; +import type { + CollaborationCandidate, + CollaborationEditorConfig, + CollaborationEditRequest, + CollaborationFile, + CollaborationFileType, + CollaborationFolder, + CollaborationMember, + CollaborationMemberRole, + CollaborationPublicShareMetadata, + CollaborationRevision, + CollaborationShareAccessGrant, + CollaborationShareAccessMode, + CollaborationShareExpiryPolicy, + CollaborationShareLink, +} from "../types/collaboration"; + +const baseUrl = (studyId: string) => `/api/v1/studies/${studyId}/collaboration`; + +export const fetchCollaborationFolders = (studyId: string) => + apiGet(`${baseUrl(studyId)}/folders`); + +export const createCollaborationFolder = (studyId: string, payload: { name: string; parent_id?: string | null }) => + apiPost(`${baseUrl(studyId)}/folders`, payload); + +export const updateCollaborationFolder = (studyId: string, folderId: string, payload: Record) => + apiPatch(`${baseUrl(studyId)}/folders/${folderId}`, payload); + +export const deleteCollaborationFolder = (studyId: string, folderId: string) => + apiDelete(`${baseUrl(studyId)}/folders/${folderId}`); + +export const fetchCollaborationFiles = ( + studyId: string, + params?: { folder_id?: string; keyword?: string; deleted?: boolean }, +) => apiGet(`${baseUrl(studyId)}/files`, { params }); + +export const fetchCollaborationFile = (studyId: string, fileId: string) => + apiGet(`${baseUrl(studyId)}/files/${fileId}`, { + cache: false, + disableRequestDedupe: true, + }); + +export const createCollaborationFile = ( + studyId: string, + payload: { title: string; file_type: CollaborationFileType; folder_id?: string | null }, +) => apiPost(`${baseUrl(studyId)}/files`, payload); + +export const importCollaborationFile = (studyId: string, payload: FormData) => + apiPost(`${baseUrl(studyId)}/files/import`, payload, { + headers: { "Content-Type": "multipart/form-data" }, + }); + +export const copyCollaborationFile = (studyId: string, fileId: string) => + apiPost(`${baseUrl(studyId)}/files/${fileId}/copy`); + +export const downloadCollaborationFile = (studyId: string, fileId: string) => + apiGet(`${baseUrl(studyId)}/files/${fileId}/download`, { + responseType: "blob", + cache: false, + suppressErrorMessage: true, + }); + +export const updateCollaborationFile = (studyId: string, fileId: string, payload: Record) => + apiPatch(`${baseUrl(studyId)}/files/${fileId}`, payload); + +export const trashCollaborationFile = (studyId: string, fileId: string) => + apiDelete(`${baseUrl(studyId)}/files/${fileId}`); + +export const restoreCollaborationFile = (studyId: string, fileId: string) => + apiPost(`${baseUrl(studyId)}/files/${fileId}/restore`); + +export const fetchCollaborationMembers = (studyId: string, fileId: string) => + apiGet(`${baseUrl(studyId)}/files/${fileId}/members`); + +export const fetchCollaborationCandidates = (studyId: string) => + apiGet(`${baseUrl(studyId)}/member-candidates`); + +export const upsertCollaborationMember = ( + studyId: string, + fileId: string, + payload: { user_id: string; role: CollaborationMemberRole }, +) => apiPut(`${baseUrl(studyId)}/files/${fileId}/members`, payload); + +export const removeCollaborationMember = (studyId: string, fileId: string, userId: string) => + apiDelete(`${baseUrl(studyId)}/files/${fileId}/members/${userId}`); + +export const createCollaborationEditRequest = (studyId: string, fileId: string) => + apiPost(`${baseUrl(studyId)}/files/${fileId}/edit-requests`); + +export const fetchCollaborationEditRequests = (studyId: string, fileId: string) => + apiGet(`${baseUrl(studyId)}/files/${fileId}/edit-requests`, { + cache: false, + disableRequestDedupe: true, + }); + +export const resolveCollaborationEditRequest = ( + studyId: string, + fileId: string, + requestId: string, + status: "APPROVED" | "REJECTED", +) => apiPost( + `${baseUrl(studyId)}/files/${fileId}/edit-requests/${requestId}/resolve`, + { status }, +); + +export const transferCollaborationOwnership = (studyId: string, fileId: string, newOwnerId: string) => + apiPost(`${baseUrl(studyId)}/files/${fileId}/transfer-ownership`, { + new_owner_id: newOwnerId, + }); + +export const fetchCollaborationShareLink = (studyId: string, fileId: string) => + apiGet(`${baseUrl(studyId)}/files/${fileId}/share-link`, { + cache: false, + disableRequestDedupe: true, + }); + +export const updateCollaborationShareLink = ( + studyId: string, + fileId: string, + payload: { + enabled: boolean; + access_mode: CollaborationShareAccessMode; + expiry_policy: CollaborationShareExpiryPolicy; + password_mode: "KEEP" | "SET" | "CLEAR"; + password?: string; + }, +) => apiPut(`${baseUrl(studyId)}/files/${fileId}/share-link`, payload); + +export const fetchCollaborationRevisions = (studyId: string, fileId: string) => + apiGet(`${baseUrl(studyId)}/files/${fileId}/revisions`, { + cache: false, + disableRequestDedupe: true, + }); + +export const restoreCollaborationRevision = (studyId: string, fileId: string, revisionId: string) => + apiPost(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}/restore`, {}); + +export const updateCollaborationRevision = ( + studyId: string, + fileId: string, + revisionId: string, + payload: { change_summary: string }, +) => apiPatch(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}`, payload); + +export const deleteCollaborationRevision = (studyId: string, fileId: string, revisionId: string) => + apiDelete(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}`); + +export const copyCollaborationRevision = ( + studyId: string, + fileId: string, + revisionId: string, + payload: { title: string; folder_id?: string | null }, +) => apiPost(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}/copy`, payload); + +export const fetchCollaborationEditorConfig = (studyId: string, fileId: string) => + apiGet(`${baseUrl(studyId)}/files/${fileId}/editor-config`, { + cache: false, + disableRequestDedupe: true, + disableNetworkRetry: true, + suppressErrorMessage: true, + }); + +export const recordCollaborationExport = (studyId: string, fileId: string, fileType: string) => + apiPost(`${baseUrl(studyId)}/files/${fileId}/exports`, { file_type: fileType }); + +export const recordCollaborationDownload = (studyId: string, fileId: string, fileType: string) => + apiPost(`${baseUrl(studyId)}/files/${fileId}/downloads`, { file_type: fileType }); + +const publicShareHeaders = (shareToken: string) => ({ "X-CTMS-Share-Token": shareToken }); +const publicShareRequestConfig = (shareToken: string) => ({ + headers: publicShareHeaders(shareToken), + publicRequest: true, + cache: false, + disableRequestDedupe: true, + disableNetworkRetry: true, + suppressErrorMessage: true, + invalidateCache: false, +}); + +export const fetchPublicCollaborationShareMetadata = (shareToken: string) => + apiGet("/api/v1/collaboration/shares/metadata", publicShareRequestConfig(shareToken)); + +export const verifyPublicCollaborationSharePassword = (shareToken: string, password: string) => + apiPost( + "/api/v1/collaboration/shares/access", + { password }, + publicShareRequestConfig(shareToken), + ); + +export const fetchPublicCollaborationEditorConfig = ( + shareToken: string, + payload: { access_token?: string; client_id: string; display_name?: string }, +) => apiPost( + "/api/v1/collaboration/shares/editor-config", + payload, + publicShareRequestConfig(shareToken), +); diff --git a/frontend/src/api/notifications.test.ts b/frontend/src/api/notifications.test.ts new file mode 100644 index 00000000..33eae35b --- /dev/null +++ b/frontend/src/api/notifications.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); + +vi.mock("./axios", () => ({ apiGet, apiPost })); + +describe("general notification API", () => { + beforeEach(() => vi.clearAllMocks()); + + it("loads an uncached recipient feed and updates read state", async () => { + const { + listGeneralNotifications, + markAllGeneralNotificationsRead, + markGeneralNotificationRead, + } = await import("./notifications"); + + listGeneralNotifications("study-1", 8); + markGeneralNotificationRead("study-1", "notification-1"); + markAllGeneralNotificationsRead("study-1"); + + expect(apiGet).toHaveBeenCalledWith( + "/api/v1/studies/study-1/notifications/feed", + { params: { limit: 8 }, cache: false, disableRequestDedupe: true }, + ); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/notifications/notification-1/read", + ); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/notifications/read-all", + ); + }); +}); diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index e11d86ca..503bf9eb 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,5 +1,20 @@ -import { apiGet } from "./axios"; -import type { NotificationItem } from "../types/notifications"; +import { apiGet, apiPost } from "./axios"; +import type { GeneralNotificationFeed, GeneralNotificationItem, NotificationItem } from "../types/notifications"; export const listNotifications = (studyId: string, params?: Record) => apiGet(`/api/v1/studies/${studyId}/notifications`, { params }); + +export const listGeneralNotifications = (studyId: string, limit = 10) => + apiGet(`/api/v1/studies/${studyId}/notifications/feed`, { + params: { limit }, + cache: false, + disableRequestDedupe: true, + }); + +export const markGeneralNotificationRead = (studyId: string, notificationId: string) => + apiPost( + `/api/v1/studies/${studyId}/notifications/${notificationId}/read`, + ); + +export const markAllGeneralNotificationsRead = (studyId: string) => + apiPost(`/api/v1/studies/${studyId}/notifications/read-all`); diff --git a/frontend/src/api/onlyoffice.ts b/frontend/src/api/onlyoffice.ts index 05441d38..27e250cd 100644 --- a/frontend/src/api/onlyoffice.ts +++ b/frontend/src/api/onlyoffice.ts @@ -13,3 +13,12 @@ export const fetchAttachmentOnlyOfficeConfig = (attachmentId: string) => export const fetchVersionOnlyOfficeConfig = (versionId: string) => apiGet(`/api/v1/onlyoffice/versions/${versionId}/config`, noStoreConfig); + +export const fetchCollaborationRevisionOnlyOfficeConfig = ( + studyId: string, + fileId: string, + revisionId: string, +) => apiGet( + `/api/v1/studies/${studyId}/collaboration/files/${fileId}/revisions/${revisionId}/preview-config`, + noStoreConfig, +); diff --git a/frontend/src/components/ApiEndpointPermissions.vue b/frontend/src/components/ApiEndpointPermissions.vue index 367ba2db..4990012b 100644 --- a/frontend/src/components/ApiEndpointPermissions.vue +++ b/frontend/src/components/ApiEndpointPermissions.vue @@ -384,12 +384,13 @@ const SECTION_LABELS: Record = { faq_category: "医学咨询/FAQ", faq_reply: "医学咨询/FAQ", faq_attachments: "医学咨询/FAQ", + collaboration: "在线协作", }; const SECTION_ORDER_BY_MODULE: Record = { subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"], risk_issues: ["AE/SAE", "PD", "监查访视问题"], - shared_library: ["医学咨询/FAQ", "注意事项"], + shared_library: ["医学咨询/FAQ", "注意事项", "在线协作"], }; const getOperationSection = (row: Operation): string => { diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index d35a23d9..9add6de9 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -4,10 +4,11 @@ :class="{ 'is-sidebar-hidden': !desktopSidebarVisible, 'is-macos': desktopMetadata.platform === 'macos', + 'is-immersive-workspace': route.meta.immersiveWorkspace, }" @click="closeWorkspaceTabMenu" > -