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"
>
-