同步全局协作台账与工作台界面优化
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

工作台新增与项目平级的全局协作台账,复用共享库在线协作能力。两本台账使用合同台账明细表、临床运营项目编号的空白模板,支持独立账号授权、在线编辑和历史版本保留,禁止删除整个台账及历史版本。

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

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

提交包含必需的表结构升级和只有表头的初始模板;不包含本地台账业务记录、数据库导出、账号授权运行数据或上传目录中的历史文件。字体文件沿用部署方单独提供的方式。
This commit is contained in:
chengchengzhou7
2026-09-04 08:42:21 +08:00
committed by GitHub
parent 9c533b8c37
commit 6c45cef4b7
44 changed files with 4182 additions and 1819 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()
@@ -22,7 +22,7 @@ from app.schemas.collaboration import (
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
)
from app.services import collaboration_service
from app.services import collaboration_service, ledger_access
SHARE_PATH = "/collaboration/share"
@@ -177,6 +177,11 @@ async def update_share_link(
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
if ledger_access.is_ledger(item):
# Ledger routes hold the same file lock as callbacks before changing a link.
item.generation += 1
item.updated_at = now
link.token_version += 1
await db.commit()
await db.refresh(link)
return share_link_read(link)
+41
View File
@@ -0,0 +1,41 @@
"""Account-level ledger permissions, independent of study membership."""
from sqlalchemy import select
from fastapi import HTTPException
from app.core.deps import is_system_admin
from app.models.collaboration import CollaborationMember
def is_ledger(item) -> bool:
return getattr(item, "scope", "PROJECT") == "LEDGER"
async def role_for(db, item, user) -> str | None:
if not user or not user.is_active:
return None
if is_system_admin(user) or item.owner_id == user.id:
return "MANAGER"
return await db.scalar(select(CollaborationMember.role).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user.id,
))
async def can_edit(db, item, user) -> bool:
return item.status == "ACTIVE" and await role_for(db, item, user) in {"EDITOR", "MANAGER"}
async def can_manage(db, item, user) -> bool:
return await role_for(db, item, user) == "MANAGER"
async def can_export(db, item, user) -> bool:
role = await role_for(db, item, user)
return role == "MANAGER" or (role in {"EDITOR", "VIEWER"} and item.allow_export)
async def require_access(db, item, user, *, manage=False):
role = await role_for(db, item, user)
if role not in {"VIEWER", "EDITOR", "MANAGER"}:
raise HTTPException(404, "台账不存在或未获授权")
if manage and role != "MANAGER":
raise HTTPException(403, "仅台账管理人员可以执行此操作")
+259
View File
@@ -0,0 +1,259 @@
"""Global ledger metadata and grants; reuse collaboration revisions and sessions."""
import io
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
import aiofiles
from fastapi import HTTPException
from sqlalchemy import or_, select, text
from app.core.config import settings
from app.core.deps import is_system_admin
from app.models.collaboration import CollaborationFile, CollaborationMember, CollaborationRevision
from app.models.user import User, UserStatus
from app.schemas.ledger import LedgerRead, LedgerSettings
from app.services import collaboration_service as collaboration
from app.services import ledger_access
LEDGER_TEMPLATES = (
("ledger-1", "医学事务部合同台账明细表.xlsx", "contract-ledger.xlsx", "记录合同编号、项目代码与合同内容"),
("ledger-2", "医学事务部临床运营项目编号.xlsx", "clinical-project-register.xlsx", "记录项目编号、产品信息与生效日期"),
)
TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates" / "ledgers"
async def get_ledger(db, ledger_id, user, *, manage=False, lock=False):
stmt = select(CollaborationFile).where(
CollaborationFile.id == ledger_id, CollaborationFile.scope == "LEDGER",
).execution_options(populate_existing=True)
if lock:
stmt = stmt.with_for_update()
item = await db.scalar(stmt)
if not item:
raise HTTPException(404, "台账不存在或未获授权")
await ledger_access.require_access(db, item, user, manage=manage)
return item
async def read_ledger(db, item, user):
role = await ledger_access.role_for(db, item, user)
await ledger_access.require_access(db, item, user)
owner = await db.get(User, item.owner_id)
revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None
return LedgerRead(
id=item.id, title=item.title, description=item.description, status=item.status,
owner_id=item.owner_id, owner_name=owner.full_name if owner else "",
generation=item.generation, updated_at=item.updated_at,
current_revision_id=item.current_revision_id,
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,
role=role, can_edit=await ledger_access.can_edit(db, item, user),
can_manage=role == "MANAGER", can_export=await ledger_access.can_export(db, item, user),
allow_export=item.allow_export, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
allow_edit_request=item.allow_edit_request,
can_request_edit=await collaboration.can_request_edit_file(db, item, user),
edit_request_status=await collaboration.edit_request_status(db, item, user.id),
can_transfer_ownership=await collaboration.can_transfer_ownership(db, item, user),
)
async def list_ledgers(db, user):
stmt = select(CollaborationFile).where(CollaborationFile.scope == "LEDGER")
if not is_system_admin(user):
granted = select(CollaborationMember.file_id).where(
CollaborationMember.user_id == user.id,
CollaborationMember.role.in_(["VIEWER", "EDITOR", "MANAGER"]),
)
stmt = stmt.where(or_(CollaborationFile.owner_id == user.id, CollaborationFile.id.in_(granted)))
rows = (await db.scalars(stmt.order_by(CollaborationFile.ledger_key))).all()
return [await read_ledger(db, item, user) for item in rows]
async def initialize_ledgers(db, user):
if not is_system_admin(user):
raise HTTPException(403, "仅系统管理员可以初始化台账")
# Serialize initialization across administrators; unique keys also prevent duplicates.
if db.get_bind().dialect.name == "postgresql":
await db.execute(text("SELECT pg_advisory_xact_lock(2026090301)"))
for key, title, template_name, description in LEDGER_TEMPLATES:
if await db.scalar(select(CollaborationFile.id).where(CollaborationFile.ledger_key == key)):
continue
item = CollaborationFile(
id=uuid.uuid4(), scope="LEDGER", ledger_key=key, study_id=None, folder_id=None,
title=title, description=description, file_type="cell", extension="xlsx", owner_id=user.id,
status="ACTIVE", generation=1, allow_sheet_structure_edit=False, allow_export=False,
)
db.add(item)
await db.flush()
content, backup = collaboration.apply_workbook_structure_policy(
(TEMPLATE_ROOT / template_name).read_bytes(), file_id=item.id,
allow_sheet_structure_edit=False, protection_backup=None,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="CREATE", created_by=user.id)
await db.commit()
return await list_ledgers(db, user)
def check_generation(item, generation):
if item.generation != generation:
raise HTTPException(409, "台账已发生变化,请刷新后重试")
def advance_generation(item):
item.generation += 1
item.updated_at = datetime.now(timezone.utc)
async def active_user(db, user_id):
user = await db.get(User, user_id)
if not user or not user.is_active:
raise HTTPException(422, "请选择有效的系统账号")
return user
async def update_settings(db, item, payload, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, payload.generation)
if payload.owner_id != item.owner_id:
if not is_system_admin(user):
raise HTTPException(403, "仅系统管理员可以变更台账负责人")
await active_user(db, payload.owner_id)
if payload.allow_sheet_structure_edit != item.allow_sheet_structure_edit:
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).is_file():
raise HTTPException(404, "台账内容不存在")
async with aiofiles.open(revision.file_uri, "rb") as stream:
content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
content, backup = collaboration.apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=payload.allow_sheet_structure_edit,
protection_backup=item.sheet_structure_protection_backup,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="PERMISSION_CHANGE", created_by=user.id)
item.title = collaboration._safe_title(payload.title, "xlsx")
item.description = payload.description.strip()
item.status = payload.status
item.owner_id = payload.owner_id
item.allow_export = payload.allow_export
if payload.allow_edit_request is not None:
item.allow_edit_request = payload.allow_edit_request
item.allow_sheet_structure_edit = payload.allow_sheet_structure_edit
advance_generation(item)
await db.commit()
return await read_ledger(db, item, user)
async def update_access_settings(db, item, payload, user):
values = dict(title=item.title, description=item.description, status=item.status,
owner_id=item.owner_id, allow_export=item.allow_export,
allow_edit_request=item.allow_edit_request,
allow_sheet_structure_edit=item.allow_sheet_structure_edit)
values.update(payload.model_dump(exclude_none=True))
return await update_settings(db, item, LedgerSettings(**values), user)
async def list_members(db, item):
rows = (await db.execute(select(CollaborationMember, User).join(
User, User.id == CollaborationMember.user_id,
).where(CollaborationMember.file_id == item.id).order_by(User.full_name))).all()
grants = {user.id: dict(user_id=user.id, full_name=user.full_name, email=user.email,
role=member.role, is_active=user.is_active) for member, user in rows}
implicit = (await db.scalars(select(User).where(or_(
User.id == item.owner_id,
(User.is_admin.is_(True)) & (User.status == UserStatus.ACTIVE),
)).order_by(User.full_name))).all()
for user in implicit:
grants[user.id] = dict(user_id=user.id, full_name=user.full_name, email=user.email,
role="MANAGER", is_active=user.is_active,
protected_label="所有者" if user.id == item.owner_id else "系统管理员")
return sorted(grants.values(), key=lambda member: (
member["user_id"] != item.owner_id, member["role"] != "MANAGER", member["full_name"],
))
async def set_member(db, item, payload, user):
await ledger_access.require_access(db, item, user, manage=True)
target = await active_user(db, payload.user_id)
if target.id == item.owner_id or is_system_admin(target):
raise HTTPException(422, "负责人和系统管理员已拥有管理权限")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == target.id,
))
if member and member.role == payload.role:
return
if member:
member.role = payload.role
else:
db.add(CollaborationMember(file_id=item.id, user_id=target.id, role=payload.role, invited_by=user.id))
advance_generation(item)
await db.commit()
async def remove_member(db, item, user_id, user):
await ledger_access.require_access(db, item, user, manage=True)
if user_id == item.owner_id:
raise HTTPException(422, "不能移除台账负责人")
target = await db.get(User, user_id)
if target and is_system_admin(target):
raise HTTPException(422, "系统管理员始终拥有管理权限")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user_id,
))
if member:
await db.delete(member)
advance_generation(item)
await db.commit()
async def candidates(db, keyword):
stmt = select(User).where(User.status == UserStatus.ACTIVE)
if keyword:
pattern = f"%{keyword}%"
stmt = stmt.where(or_(User.full_name.ilike(pattern), User.email.ilike(pattern)))
rows = (await db.scalars(stmt.order_by(User.full_name, User.id))).all()
return [dict(user_id=user.id, full_name=user.full_name, email=user.email,
role_in_study="系统管理员" if user.is_admin else "系统账号") for user in rows]
async def import_content(db, item, upload, generation, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, generation)
if item.status != "ACTIVE":
raise HTTPException(409, "请先启用台账再导入")
if Path(upload.filename or "").suffix.lower() != ".xlsx":
raise HTTPException(415, "台账仅支持 XLSX 文件")
content = await upload.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
raise HTTPException(413, "台账文件为空或超出大小限制")
try:
with zipfile.ZipFile(io.BytesIO(content)) as package:
if "xl/workbook.xml" not in package.namelist():
raise ValueError()
if sum(info.file_size for info in package.infolist()) > settings.COLLABORATION_MAX_FILE_BYTES * 20:
raise ValueError()
if any("vbaproject" in name.lower() for name in package.namelist()):
raise ValueError()
except (zipfile.BadZipFile, ValueError):
raise HTTPException(422, "XLSX 文件无效、包含宏或解压后过大")
content, backup = collaboration.apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
protection_backup=None,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="IMPORT", created_by=user.id)
advance_generation(item)
await db.commit()
return await read_ledger(db, item, user)
async def restore_revision(db, item, revision_id, generation, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, generation)
if item.status != "ACTIVE":
raise HTTPException(409, "请先启用台账再恢复版本")
return await collaboration.restore_revision(db, item, revision_id, user, None)
@@ -25,7 +25,7 @@ from app.models.collaboration import (
)
from app.models.user import User
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
from app.services import collaboration_service, onlyoffice_service
from app.services import collaboration_service, ledger_access, onlyoffice_service
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
@@ -81,6 +81,8 @@ async def _active_session(
async def build_editor_config(
db: AsyncSession, item: CollaborationFile, user
) -> CollaborationEditorConfigRead:
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user)
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():
@@ -158,6 +160,15 @@ async def build_shared_editor_config(
client_id: str,
display_name: str,
) -> CollaborationEditorConfigRead:
if ledger_access.is_ledger(item):
version = link.token_version
item = await db.scalar(select(CollaborationFile).where(
CollaborationFile.id == item.id,
).with_for_update().execution_options(populate_existing=True))
await db.refresh(link)
if (not item or item.status != "ACTIVE" or not link.enabled or link.token_version != version
or (link.expires_at and link.expires_at <= datetime.now(timezone.utc))):
raise HTTPException(404, "共享链接不存在或已失效")
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():
@@ -169,6 +180,10 @@ async def build_shared_editor_config(
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]}"
if ledger_access.is_ledger(item):
known_users = json.loads(session.active_users or "[]")
session.active_users = json.dumps(list(dict.fromkeys([*known_users, external_user_id])))
await db.commit()
config: dict[str, Any] = {
"type": "desktop",
"documentType": item.file_type,
@@ -234,6 +249,8 @@ async def get_session_content(
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="协作文件内容不存在")
if ledger_access.is_ledger(item) and session.generation != item.generation:
raise HTTPException(403, "台账会话已失效,请重新打开")
return revision, item
@@ -326,7 +343,7 @@ async def _callback_user(
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
) -> User | None:
has_public_share_user = False
for value in payload.users:
for value in payload.users or json.loads(session.active_users or "[]"):
if value.startswith("share-"):
has_public_share_user = True
continue
@@ -344,13 +361,40 @@ async def _callback_user(
return user
async def _ledger_callback_can_edit(db, item, payload, session):
users = payload.users or json.loads(session.active_users or "[]")
if not users:
return await ledger_access.can_edit(db, item, await _callback_user(db, payload, session))
for value in users:
try:
actor = await db.get(User, uuid.UUID(value))
except (ValueError, TypeError):
continue
if await ledger_access.can_edit(db, item, actor):
return True
link = await db.scalar(select(CollaborationShareLink).where(
CollaborationShareLink.file_id == item.id,
CollaborationShareLink.enabled.is_(True),
CollaborationShareLink.access_mode == "EDIT",
))
if not link or (link.expires_at and link.expires_at <= datetime.now(timezone.utc)):
return False
prefix = f"share-{link.id.hex[:12]}-"
return any(value.startswith(prefix) for value in users)
async def process_callback(
db: AsyncSession,
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
) -> dict[str, int]:
# Lock the file before writing sessions, matching editor initialization and
# ledger permission changes. This also serializes callbacks across generations.
session = await db.scalar(
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
select(CollaborationSession)
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
.where(CollaborationSession.id == session_id)
.with_for_update(of=CollaborationFile)
)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
@@ -367,8 +411,12 @@ async def process_callback(
item = await db.get(CollaborationFile, session.file_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
if ledger_access.is_ledger(item):
# Same lock as grant/settings changes: a stale callback cannot race a revocation.
item = await db.scalar(select(CollaborationFile).where(
CollaborationFile.id == item.id,
).with_for_update().execution_options(populate_existing=True))
session.last_callback_at = datetime.now(timezone.utc)
session.active_users = json.dumps(payload.users, ensure_ascii=True)
result = "ACKNOWLEDGED"
saved_revision_id = None
@@ -377,6 +425,10 @@ async def process_callback(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
if session.generation != item.generation:
result = "STALE"
elif ledger_access.is_ledger(item) and (
item.status != "ACTIVE" or not await _ledger_callback_can_edit(db, item, payload, session)
):
result = "ACCESS_REVOKED"
else:
content = await _download_result(payload.url)
actor = await _callback_user(db, payload, session)
@@ -404,6 +456,8 @@ async def process_callback(
session.status = "ERROR"
result = "ERROR"
if payload.users or not ledger_access.is_ledger(item):
session.active_users = json.dumps(payload.users, ensure_ascii=True)
db.add(CollaborationCallbackReceipt(
session_id=session.id,
fingerprint=fingerprint,