同步全局协作台账与工作台界面优化
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
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:
@@ -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)
|
||||
|
||||
@@ -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, "仅台账管理人员可以执行此操作")
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user