新增全局协作台账并统一工作台管理界面
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。 复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。 补充在线文档字体构建支持、数据库表结构升级和回归测试。 不包含本地台账内容、账号授权记录或历史文件迁移数据。
This commit is contained in:
@@ -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