feat(collaboration): 完善在线文档协作与通知闭环
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
Reference in New Issue
Block a user