新增全局协作台账并统一工作台管理界面

新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。
复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。
补充在线文档字体构建支持、数据库表结构升级和回归测试。
不包含本地台账内容、账号授权记录或历史文件迁移数据。
This commit is contained in:
Cheng Zhou
2026-09-03 16:55:08 +08:00
parent 46a6dda753
commit c1dd8def2c
41 changed files with 4155 additions and 1815 deletions
+121 -69
View File
@@ -46,6 +46,7 @@ from app.schemas.collaboration import (
CollaborationRevisionUpdate,
)
from app.services import notification_service
from app.services import ledger_access
COLLABORATION_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "collaboration"
FILE_TYPE_EXTENSION = {"word": "docx", "cell": "xlsx", "slide": "pptx"}
@@ -332,6 +333,8 @@ async def _require_role_assignable(
async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_edit(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -342,6 +345,8 @@ async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool
async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_manage(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -351,6 +356,8 @@ async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bo
async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_export(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -362,6 +369,8 @@ async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bo
async def can_create_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return False
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -387,6 +396,12 @@ async def edit_request_status(
async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return (
item.status == "ACTIVE" and item.allow_edit_request
and await ledger_access.role_for(db, item, user) == "VIEWER"
and await edit_request_status(db, item, user.id) != "PENDING"
)
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)
@@ -398,6 +413,8 @@ async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user)
async def can_transfer_ownership(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return bool(user and user.is_active and (is_system_admin(user) or item.owner_id == user.id))
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -535,7 +552,7 @@ async def _persist_revision_bytes(
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 = COLLABORATION_ROOT / ("ledgers" if ledger_access.is_ledger(locked) else 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:
@@ -841,6 +858,8 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None:
if ledger_access.is_ledger(item):
raise HTTPException(403, "台账禁止删除,请使用归档")
await require_file_manager(db, item, user)
item.status = "DELETED"
item.deleted_at = datetime.now(timezone.utc)
@@ -899,6 +918,8 @@ async def delete_revision(
revision_id: uuid.UUID,
user,
) -> None:
if ledger_access.is_ledger(item):
raise HTTPException(403, "台账历史版本禁止删除")
await require_file_manager(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
if item.current_revision_id == revision.id:
@@ -965,10 +986,19 @@ async def copy_revision(
async def restore_revision(
db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID, user, change_summary: str | None
) -> CollaborationRevision:
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user, manage=True)
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()
if ledger_access.is_ledger(item):
content, _ = apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
# A historical revision may still carry CTMS protection after the
# current ledger was unlocked. Apply today's policy on restoration.
protection_backup=item.sheet_structure_protection_backup or "",
)
restored, created = await append_revision(
db,
item,
@@ -1092,9 +1122,14 @@ async def create_edit_request(
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="只有当前项目成员可以申请编辑权限")
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user)
if item.status != "ACTIVE":
raise HTTPException(409, "已归档台账不能申请编辑权限")
else:
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,
@@ -1107,40 +1142,41 @@ async def create_edit_request(
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
db.add(request)
await db.flush()
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
CollaborationMember,
and_(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == StudyMember.user_id,
),
if not ledger_access.is_ledger(item):
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}",
source_version="PENDING",
)
.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}",
source_version="PENDING",
)
await db.commit()
await db.refresh(request)
return _edit_request_read(request, user)
@@ -1179,11 +1215,18 @@ async def resolve_edit_request(
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 ledger_access.is_ledger(item):
if item.status != "ACTIVE" or not await ledger_access.role_for(db, item, requester):
raise HTTPException(422, "申请人已无台账访问权限或台账已归档")
else:
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")
if ledger_access.is_ledger(item):
item.generation += 1
else:
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,
@@ -1201,30 +1244,31 @@ async def resolve_edit_request(
request.status = payload.status
request.resolved_by = user.id
request.resolved_at = datetime.now(timezone.utc)
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
if not ledger_access.is_ledger(item):
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
await db.commit()
await db.refresh(request)
return _edit_request_read(request, requester)
@@ -1242,11 +1286,16 @@ async def transfer_ownership(
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")
if ledger_access.is_ledger(locked):
if not target or not target.is_active:
raise HTTPException(422, "只能转让给有效的系统账号")
locked.generation += 1
else:
membership = await member_crud.get_member(db, locked.study_id, 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,
@@ -1268,6 +1317,9 @@ async def transfer_ownership(
))
if previous_owner_member:
previous_owner_member.role = "MANAGER"
elif ledger_access.is_ledger(locked):
db.add(CollaborationMember(file_id=locked.id, user_id=previous_owner_id,
role="MANAGER", invited_by=user.id))
locked.owner_id = payload.new_owner_id
locked.updated_at = datetime.now(timezone.utc)
await db.commit()