feat(collaboration): 完善在线文档协作与通知闭环
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from anyio import to_thread
|
||||
from fastapi import HTTPException, status
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.models.collaboration import CollaborationFile, CollaborationShareLink
|
||||
from app.schemas.collaboration import (
|
||||
CollaborationPublicShareMetadata,
|
||||
CollaborationShareAccessGrant,
|
||||
CollaborationShareLinkRead,
|
||||
CollaborationShareLinkUpdate,
|
||||
)
|
||||
from app.services import collaboration_service
|
||||
|
||||
|
||||
SHARE_PATH = "/collaboration/share"
|
||||
SHARE_ACCESS_TTL_SECONDS = 30 * 60
|
||||
PASSWORD_FAILURE_WINDOW = timedelta(minutes=15)
|
||||
PASSWORD_LOCK_DURATION = timedelta(minutes=15)
|
||||
PASSWORD_FAILURE_LIMIT = 5
|
||||
_ACCESS_PURPOSE = "ctms-collaboration-share-access"
|
||||
_EXPIRY_DURATIONS = {
|
||||
"ONE_DAY": timedelta(days=1),
|
||||
"SEVEN_DAYS": timedelta(days=7),
|
||||
"THIRTY_DAYS": timedelta(days=30),
|
||||
"PERMANENT": None,
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _signing_key() -> bytes:
|
||||
return hmac.new(
|
||||
settings.JWT_SECRET_KEY.encode("utf-8"),
|
||||
b"ctms-collaboration-share-v1",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def _b64encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
|
||||
|
||||
|
||||
def share_token(link: CollaborationShareLink) -> str:
|
||||
payload = f"{link.id}.{link.token_version}".encode("ascii")
|
||||
signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
||||
return f"{_b64encode(payload)}.{_b64encode(signature)}"
|
||||
|
||||
|
||||
def _decode_share_token(value: str | None) -> tuple[uuid.UUID, int]:
|
||||
token = (value or "").strip()
|
||||
if not token or len(token) > 256 or token.count(".") != 1:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
||||
encoded_payload, encoded_signature = token.split(".", 1)
|
||||
try:
|
||||
payload = _b64decode(encoded_payload)
|
||||
actual_signature = _b64decode(encoded_signature)
|
||||
if (
|
||||
not hmac.compare_digest(_b64encode(payload), encoded_payload)
|
||||
or not hmac.compare_digest(_b64encode(actual_signature), encoded_signature)
|
||||
):
|
||||
raise ValueError("non-canonical token encoding")
|
||||
expected_signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(actual_signature, expected_signature):
|
||||
raise ValueError("signature mismatch")
|
||||
raw_id, raw_version = payload.decode("ascii").split(".", 1)
|
||||
return uuid.UUID(raw_id), int(raw_version)
|
||||
except (ValueError, UnicodeError, TypeError, binascii.Error) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") from exc
|
||||
|
||||
|
||||
def _expiry_for_policy(policy: str, now: datetime) -> datetime | None:
|
||||
duration = _EXPIRY_DURATIONS[policy]
|
||||
return now + duration if duration else None
|
||||
|
||||
|
||||
def _is_expired(link: CollaborationShareLink, now: datetime | None = None) -> bool:
|
||||
return bool(link.expires_at and link.expires_at <= (now or _now()))
|
||||
|
||||
|
||||
async def _link_for_file(
|
||||
db: AsyncSession,
|
||||
item: CollaborationFile,
|
||||
user,
|
||||
*,
|
||||
create: bool,
|
||||
lock: bool = False,
|
||||
) -> CollaborationShareLink | None:
|
||||
await collaboration_service.require_file_manager(db, item, user)
|
||||
statement = select(CollaborationShareLink).where(CollaborationShareLink.file_id == item.id)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
link = await db.scalar(statement)
|
||||
if link or not create:
|
||||
return link
|
||||
now = _now()
|
||||
link = CollaborationShareLink(
|
||||
file_id=item.id,
|
||||
enabled=False,
|
||||
access_mode="VIEW",
|
||||
expiry_policy="SEVEN_DAYS",
|
||||
expires_at=now + timedelta(days=7),
|
||||
created_by=user.id,
|
||||
updated_by=user.id,
|
||||
)
|
||||
db.add(link)
|
||||
await db.flush()
|
||||
return link
|
||||
|
||||
|
||||
def share_link_read(link: CollaborationShareLink) -> CollaborationShareLinkRead:
|
||||
return CollaborationShareLinkRead(
|
||||
id=link.id,
|
||||
file_id=link.file_id,
|
||||
enabled=link.enabled,
|
||||
access_mode=link.access_mode,
|
||||
expiry_policy=link.expiry_policy,
|
||||
expires_at=link.expires_at,
|
||||
has_password=bool(link.password_hash),
|
||||
share_path=SHARE_PATH,
|
||||
share_token=share_token(link) if link.enabled else None,
|
||||
created_at=link.created_at,
|
||||
updated_at=link.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_share_link(
|
||||
db: AsyncSession, item: CollaborationFile, user
|
||||
) -> CollaborationShareLinkRead:
|
||||
link = await _link_for_file(db, item, user, create=True)
|
||||
assert link is not None
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
return share_link_read(link)
|
||||
|
||||
|
||||
async def update_share_link(
|
||||
db: AsyncSession,
|
||||
item: CollaborationFile,
|
||||
payload: CollaborationShareLinkUpdate,
|
||||
user,
|
||||
) -> CollaborationShareLinkRead:
|
||||
link = await _link_for_file(db, item, user, create=True, lock=True)
|
||||
assert link is not None
|
||||
now = _now()
|
||||
link.enabled = payload.enabled
|
||||
link.access_mode = payload.access_mode
|
||||
link.expiry_policy = payload.expiry_policy
|
||||
link.expires_at = _expiry_for_policy(payload.expiry_policy, now)
|
||||
link.updated_by = user.id
|
||||
if payload.password_mode == "SET":
|
||||
link.password_hash = await to_thread.run_sync(hash_password, payload.password or "")
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
elif payload.password_mode == "CLEAR":
|
||||
link.password_hash = None
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
await collaboration_service._audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_SHARE_LINK_UPDATED",
|
||||
user,
|
||||
{
|
||||
"enabled": link.enabled,
|
||||
"access_mode": link.access_mode,
|
||||
"file_allow_export": item.allow_export,
|
||||
"expiry_policy": link.expiry_policy,
|
||||
"has_password": bool(link.password_hash),
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
return share_link_read(link)
|
||||
|
||||
|
||||
async def resolve_active_share(
|
||||
db: AsyncSession,
|
||||
token: str | None,
|
||||
*,
|
||||
lock: bool = False,
|
||||
) -> tuple[CollaborationShareLink, CollaborationFile]:
|
||||
link_id, version = _decode_share_token(token)
|
||||
statement = select(CollaborationShareLink).where(CollaborationShareLink.id == link_id)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
link = await db.scalar(statement)
|
||||
if not link or link.token_version != version or not link.enabled:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
||||
if _is_expired(link):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="共享链接已过期")
|
||||
item = await db.get(CollaborationFile, link.file_id)
|
||||
if not item or item.deleted_at or item.status != "ACTIVE":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件不存在或已停止共享")
|
||||
return link, item
|
||||
|
||||
|
||||
async def public_metadata(
|
||||
db: AsyncSession, token: str | None
|
||||
) -> CollaborationPublicShareMetadata:
|
||||
link, item = await resolve_active_share(db, token)
|
||||
return CollaborationPublicShareMetadata(
|
||||
file_name=item.title,
|
||||
file_type=item.file_type,
|
||||
access_mode="edit" if link.access_mode == "EDIT" else "view",
|
||||
allow_export=item.allow_export,
|
||||
requires_password=bool(link.password_hash),
|
||||
expires_at=link.expires_at,
|
||||
)
|
||||
|
||||
|
||||
def _grant_token(link: CollaborationShareLink) -> CollaborationShareAccessGrant:
|
||||
now = _now()
|
||||
expires_at = now + timedelta(seconds=SHARE_ACCESS_TTL_SECONDS)
|
||||
if link.expires_at and link.expires_at < expires_at:
|
||||
expires_at = link.expires_at
|
||||
value = jwt.encode(
|
||||
{
|
||||
"purpose": _ACCESS_PURPOSE,
|
||||
"sub": str(link.id),
|
||||
"ver": link.token_version,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
},
|
||||
_signing_key().hex(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
return CollaborationShareAccessGrant(access_token=value, expires_at=expires_at)
|
||||
|
||||
|
||||
async def verify_share_password(
|
||||
db: AsyncSession,
|
||||
token: str | None,
|
||||
password: str,
|
||||
) -> CollaborationShareAccessGrant:
|
||||
link, _ = await resolve_active_share(db, token, lock=True)
|
||||
if not link.password_hash:
|
||||
return _grant_token(link)
|
||||
now = _now()
|
||||
if link.locked_until and link.locked_until > now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="密码尝试次数过多,请稍后再试",
|
||||
)
|
||||
if link.last_failed_at and now - link.last_failed_at > PASSWORD_FAILURE_WINDOW:
|
||||
link.failed_attempts = 0
|
||||
valid = await to_thread.run_sync(verify_password, password, link.password_hash)
|
||||
if not valid:
|
||||
link.failed_attempts += 1
|
||||
link.last_failed_at = now
|
||||
if link.failed_attempts >= PASSWORD_FAILURE_LIMIT:
|
||||
link.locked_until = now + PASSWORD_LOCK_DURATION
|
||||
await db.commit()
|
||||
if link.locked_until:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="密码尝试次数过多,请稍后再试",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接密码不正确")
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
await db.commit()
|
||||
return _grant_token(link)
|
||||
|
||||
|
||||
def validate_access_grant(link: CollaborationShareLink, value: str | None) -> None:
|
||||
if not link.password_hash:
|
||||
return
|
||||
if not value:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请输入链接密码")
|
||||
try:
|
||||
payload = jwt.decode(value, _signing_key().hex(), algorithms=["HS256"])
|
||||
if (
|
||||
payload.get("purpose") != _ACCESS_PURPOSE
|
||||
or not hmac.compare_digest(str(payload.get("sub") or ""), str(link.id))
|
||||
or payload.get("ver") != link.token_version
|
||||
):
|
||||
raise JWTError("share grant mismatch")
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接访问凭证已失效") from exc
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.schemas.notification import GeneralNotificationFeed
|
||||
|
||||
|
||||
async def create_recipient_notifications(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_ids: Iterable[uuid.UUID],
|
||||
category: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
message: str,
|
||||
action_path: str | None,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
dedupe_key: str,
|
||||
) -> None:
|
||||
recipients = set(recipient_ids)
|
||||
if not recipients:
|
||||
return
|
||||
existing = set((await db.scalars(
|
||||
select(Notification.recipient_id).where(
|
||||
Notification.recipient_id.in_(recipients),
|
||||
Notification.dedupe_key == dedupe_key,
|
||||
)
|
||||
)).all())
|
||||
for recipient_id in recipients - existing:
|
||||
db.add(Notification(
|
||||
study_id=study_id,
|
||||
recipient_id=recipient_id,
|
||||
category=category,
|
||||
priority=priority,
|
||||
title=title,
|
||||
message=message,
|
||||
action_path=action_path,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
dedupe_key=dedupe_key,
|
||||
))
|
||||
|
||||
|
||||
async def sync_aggregate_notification(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
category: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
message: str,
|
||||
action_path: str,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
count: int,
|
||||
) -> None:
|
||||
dedupe_key = f"aggregate:{source_type}:{source_id}"
|
||||
item = await db.scalar(select(Notification).where(
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.dedupe_key == dedupe_key,
|
||||
))
|
||||
now = datetime.now(timezone.utc)
|
||||
if count <= 0:
|
||||
if item and item.resolved_at is None:
|
||||
item.resolved_at = now
|
||||
item.read_at = item.read_at or now
|
||||
return
|
||||
version = str(count)
|
||||
if item is None:
|
||||
db.add(Notification(
|
||||
study_id=study_id,
|
||||
recipient_id=recipient_id,
|
||||
category=category,
|
||||
priority=priority,
|
||||
title=title,
|
||||
message=message,
|
||||
action_path=action_path,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_version=version,
|
||||
dedupe_key=dedupe_key,
|
||||
))
|
||||
return
|
||||
previous_count = int(item.source_version or 0)
|
||||
item.priority = priority
|
||||
item.title = title
|
||||
item.message = message
|
||||
item.action_path = action_path
|
||||
item.source_version = version
|
||||
if item.resolved_at is not None or count > previous_count:
|
||||
item.read_at = None
|
||||
item.created_at = now
|
||||
item.resolved_at = None
|
||||
|
||||
|
||||
async def resolve_source_notifications(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.source_type == source_type,
|
||||
Notification.source_id == source_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
)
|
||||
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
|
||||
)
|
||||
|
||||
|
||||
async def list_feed(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
limit: int = 10,
|
||||
) -> GeneralNotificationFeed:
|
||||
active_filter = (
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
)
|
||||
unread_count = int(await db.scalar(
|
||||
select(func.count(Notification.id)).where(*active_filter, Notification.read_at.is_(None))
|
||||
) or 0)
|
||||
items = (await db.scalars(
|
||||
select(Notification)
|
||||
.where(*active_filter)
|
||||
.order_by(Notification.read_at.is_not(None), Notification.created_at.desc())
|
||||
.limit(max(1, min(limit, 50)))
|
||||
)).all()
|
||||
return GeneralNotificationFeed(unread_count=unread_count, items=list(items))
|
||||
|
||||
|
||||
async def mark_read(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
) -> Notification:
|
||||
item = await db.scalar(select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
))
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
|
||||
if item.read_at is None:
|
||||
item.read_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
async def mark_all_read(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,422 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request, status
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.collaboration import (
|
||||
CollaborationCallbackReceipt,
|
||||
CollaborationFile,
|
||||
CollaborationRevision,
|
||||
CollaborationSession,
|
||||
CollaborationShareLink,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
|
||||
from app.services import collaboration_service, onlyoffice_service
|
||||
|
||||
|
||||
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
|
||||
fingerprint = f"{settings.ONLYOFFICE_INSTANCE_ID or ''}:collaboration:{file_id}:{generation}"
|
||||
return f"ctms-collab-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}"
|
||||
|
||||
|
||||
def _content_url(session_id: uuid.UUID) -> str:
|
||||
return (
|
||||
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||
f"/internal/onlyoffice/collaboration/sessions/{session_id}/content"
|
||||
)
|
||||
|
||||
|
||||
def _callback_url(session_id: uuid.UUID) -> str:
|
||||
return (
|
||||
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||
f"/internal/onlyoffice/collaboration/sessions/{session_id}/callback"
|
||||
)
|
||||
|
||||
|
||||
async def _active_session(
|
||||
db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID
|
||||
) -> CollaborationSession:
|
||||
session = await db.scalar(
|
||||
select(CollaborationSession).where(
|
||||
CollaborationSession.file_id == item.id,
|
||||
CollaborationSession.generation == item.generation,
|
||||
).order_by(CollaborationSession.created_at.desc())
|
||||
)
|
||||
if session:
|
||||
if session.status != "ACTIVE":
|
||||
session.status = "ACTIVE"
|
||||
session.closed_at = None
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
if not item.current_revision_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
|
||||
session = CollaborationSession(
|
||||
file_id=item.id,
|
||||
base_revision_id=item.current_revision_id,
|
||||
document_key=collaboration_document_key(item.id, item.generation),
|
||||
generation=item.generation,
|
||||
started_by=user_id,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
async def build_editor_config(
|
||||
db: AsyncSession, item: CollaborationFile, user
|
||||
) -> CollaborationEditorConfigRead:
|
||||
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():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
|
||||
can_edit = await collaboration_service.can_edit_file(db, item, user)
|
||||
can_request_edit = await collaboration_service.can_request_edit_file(db, item, user)
|
||||
can_download = await collaboration_service.can_export_file(db, item, user)
|
||||
can_save_as = can_download and await collaboration_service.can_create_file(db, item, user)
|
||||
session = await _active_session(db, item, user.id)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
||||
config: dict[str, Any] = {
|
||||
"type": "desktop",
|
||||
"documentType": item.file_type,
|
||||
"document": {
|
||||
"fileType": item.extension,
|
||||
"key": session.document_key,
|
||||
"title": item.title,
|
||||
"url": _content_url(session.id),
|
||||
"permissions": {
|
||||
"chat": False,
|
||||
"copy": can_download,
|
||||
"comment": can_edit,
|
||||
"download": can_download,
|
||||
# In view mode ONLYOFFICE displays "Edit current file" only
|
||||
# when edit=true and onRequestEditRights is registered. CTMS
|
||||
# handles that event as an approval request, not an escalation.
|
||||
"edit": can_edit or can_request_edit,
|
||||
"fillForms": False,
|
||||
"modifyContentControl": can_edit,
|
||||
"modifyFilter": can_edit,
|
||||
"print": can_download,
|
||||
"protect": False,
|
||||
"review": False,
|
||||
},
|
||||
},
|
||||
"editorConfig": {
|
||||
"callbackUrl": _callback_url(session.id),
|
||||
"coEditing": {"mode": "fast", "change": False},
|
||||
"customization": {
|
||||
"autosave": True,
|
||||
"chat": False,
|
||||
"comments": can_edit,
|
||||
"forcesave": can_edit,
|
||||
"help": False,
|
||||
"plugins": False,
|
||||
},
|
||||
"lang": "zh-CN",
|
||||
"mode": "edit" if can_edit else "view",
|
||||
"user": {"id": str(user.id), "name": user.full_name},
|
||||
},
|
||||
}
|
||||
config["token"] = jwt.encode(
|
||||
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
|
||||
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||
algorithm="HS256",
|
||||
)
|
||||
return CollaborationEditorConfigRead(
|
||||
file_id=item.id,
|
||||
file_name=item.title,
|
||||
access_mode="edit" if can_edit else "view",
|
||||
can_save_as=can_save_as,
|
||||
can_download=can_download,
|
||||
can_request_edit=can_request_edit,
|
||||
expires_at=expires_at,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
async def build_shared_editor_config(
|
||||
db: AsyncSession,
|
||||
item: CollaborationFile,
|
||||
link: CollaborationShareLink,
|
||||
*,
|
||||
client_id: str,
|
||||
display_name: str,
|
||||
) -> CollaborationEditorConfigRead:
|
||||
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():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件内容不存在")
|
||||
can_edit = link.access_mode == "EDIT"
|
||||
session = await _active_session(db, item, item.owner_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
||||
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]}"
|
||||
config: dict[str, Any] = {
|
||||
"type": "desktop",
|
||||
"documentType": item.file_type,
|
||||
"document": {
|
||||
"fileType": item.extension,
|
||||
"key": session.document_key,
|
||||
"title": item.title,
|
||||
"url": _content_url(session.id),
|
||||
"permissions": {
|
||||
"chat": False,
|
||||
"copy": item.allow_export,
|
||||
"comment": can_edit,
|
||||
"download": item.allow_export,
|
||||
"edit": can_edit,
|
||||
"fillForms": False,
|
||||
"modifyContentControl": can_edit,
|
||||
"modifyFilter": can_edit,
|
||||
"print": item.allow_export,
|
||||
"protect": False,
|
||||
"review": False,
|
||||
},
|
||||
},
|
||||
"editorConfig": {
|
||||
"callbackUrl": _callback_url(session.id),
|
||||
"coEditing": {"mode": "fast", "change": False},
|
||||
"customization": {
|
||||
"autosave": can_edit,
|
||||
"chat": False,
|
||||
"comments": can_edit,
|
||||
"forcesave": can_edit,
|
||||
"help": False,
|
||||
"plugins": False,
|
||||
},
|
||||
"lang": "zh-CN",
|
||||
"mode": "edit" if can_edit else "view",
|
||||
"user": {"id": external_user_id, "name": display_name},
|
||||
},
|
||||
}
|
||||
config["token"] = jwt.encode(
|
||||
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
|
||||
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||
algorithm="HS256",
|
||||
)
|
||||
return CollaborationEditorConfigRead(
|
||||
file_id=item.id,
|
||||
file_name=item.title,
|
||||
access_mode="edit" if can_edit else "view",
|
||||
can_save_as=False,
|
||||
can_download=item.allow_export,
|
||||
expires_at=expires_at,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
async def get_session_content(
|
||||
db: AsyncSession, session_id: uuid.UUID, authorization: str | None
|
||||
) -> tuple[CollaborationRevision, CollaborationFile]:
|
||||
session = await db.get(CollaborationSession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
||||
onlyoffice_service.validate_outbox_token(authorization, _content_url(session_id))
|
||||
revision = await db.get(CollaborationRevision, session.base_revision_id)
|
||||
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="协作文件内容不存在")
|
||||
return revision, item
|
||||
|
||||
|
||||
def validate_callback_token(token: str | None, payload: CollaborationCallbackPayload) -> dict[str, Any]:
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少 ONLYOFFICE 回调签名")
|
||||
value = token.strip()
|
||||
if " " in value:
|
||||
scheme, credential = value.split(" ", 1)
|
||||
if scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名格式无效")
|
||||
value = credential.strip()
|
||||
try:
|
||||
decoded = onlyoffice_service.decode_onlyoffice_token(value)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名无效") from exc
|
||||
signed = decoded.get("payload") if isinstance(decoded.get("payload"), dict) else decoded
|
||||
if not isinstance(signed, dict):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调载荷无效")
|
||||
signed_key = signed.get("key")
|
||||
signed_status = signed.get("status")
|
||||
if not isinstance(signed_key, str) or not hmac.compare_digest(signed_key, payload.key):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调 key 不匹配")
|
||||
if not isinstance(signed_status, int) or signed_status != payload.status:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调状态不匹配")
|
||||
if payload.url:
|
||||
signed_url = signed.get("url")
|
||||
if not isinstance(signed_url, str) or not hmac.compare_digest(signed_url, payload.url):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调文件地址不匹配")
|
||||
return decoded
|
||||
|
||||
|
||||
def _callback_fingerprint(payload: CollaborationCallbackPayload) -> str:
|
||||
normalized = payload.model_dump(mode="json", exclude_none=True)
|
||||
return hashlib.sha256(json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _url_origin_matches(actual, expected) -> bool:
|
||||
actual_port = actual.port or (443 if actual.scheme == "https" else 80)
|
||||
expected_port = expected.port or (443 if expected.scheme == "https" else 80)
|
||||
return (
|
||||
actual.scheme == expected.scheme
|
||||
and actual.hostname
|
||||
and actual.hostname.lower() == (expected.hostname or "").lower()
|
||||
and actual_port == expected_port
|
||||
)
|
||||
|
||||
|
||||
def _validate_result_url(url: str) -> str:
|
||||
actual = urlsplit(url)
|
||||
if (
|
||||
actual.scheme not in {"http", "https"}
|
||||
or actual.username
|
||||
or actual.password
|
||||
or actual.fragment
|
||||
or not actual.hostname
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
|
||||
|
||||
internal = urlsplit(settings.ONLYOFFICE_INTERNAL_URL.rstrip("/"))
|
||||
if _url_origin_matches(actual, internal):
|
||||
return urlunsplit((internal.scheme, internal.netloc, actual.path, actual.query, ""))
|
||||
|
||||
public = urlsplit(settings.FRONTEND_PUBLIC_URL.rstrip("/"))
|
||||
proxy_prefix = "/onlyoffice/"
|
||||
if not _url_origin_matches(actual, public) or not actual.path.startswith(proxy_prefix):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
|
||||
internal_path = f"{internal.path.rstrip('/')}/{actual.path[len(proxy_prefix):]}"
|
||||
return urlunsplit((internal.scheme, internal.netloc, internal_path, actual.query, ""))
|
||||
|
||||
|
||||
async def _download_result(url: str) -> bytes:
|
||||
download_url = _validate_result_url(url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
|
||||
async with client.stream("GET", download_url) as response:
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败")
|
||||
content = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
content.extend(chunk)
|
||||
if len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
|
||||
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="ONLYOFFICE 保存文件超出限制")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败") from exc
|
||||
return bytes(content)
|
||||
|
||||
|
||||
async def _callback_user(
|
||||
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
|
||||
) -> User | None:
|
||||
has_public_share_user = False
|
||||
for value in payload.users:
|
||||
if value.startswith("share-"):
|
||||
has_public_share_user = True
|
||||
continue
|
||||
try:
|
||||
user = await db.get(User, uuid.UUID(value))
|
||||
except (ValueError, TypeError):
|
||||
user = None
|
||||
if user:
|
||||
return user
|
||||
if has_public_share_user:
|
||||
return None
|
||||
user = await db.get(User, session.started_by)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
async def process_callback(
|
||||
db: AsyncSession,
|
||||
session_id: uuid.UUID,
|
||||
payload: CollaborationCallbackPayload,
|
||||
) -> dict[str, int]:
|
||||
session = await db.scalar(
|
||||
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
||||
if not hmac.compare_digest(session.document_key, payload.key):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话 key 不匹配")
|
||||
fingerprint = _callback_fingerprint(payload)
|
||||
duplicate = await db.scalar(select(CollaborationCallbackReceipt.id).where(
|
||||
CollaborationCallbackReceipt.session_id == session.id,
|
||||
CollaborationCallbackReceipt.fingerprint == fingerprint,
|
||||
))
|
||||
if duplicate:
|
||||
return {"error": 0}
|
||||
|
||||
item = await db.get(CollaborationFile, session.file_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
|
||||
session.last_callback_at = datetime.now(timezone.utc)
|
||||
session.active_users = json.dumps(payload.users, ensure_ascii=True)
|
||||
result = "ACKNOWLEDGED"
|
||||
saved_revision_id = None
|
||||
|
||||
if payload.status in {2, 6}:
|
||||
if not payload.url:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
|
||||
if session.generation != item.generation:
|
||||
result = "STALE"
|
||||
else:
|
||||
content = await _download_result(payload.url)
|
||||
actor = await _callback_user(db, payload, session)
|
||||
source = "SESSION_CLOSE" if payload.status == 2 else "FORCE_SAVE"
|
||||
if actor is None:
|
||||
source = "SHARE_SESSION_CLOSE" if payload.status == 2 else "SHARE_FORCE_SAVE"
|
||||
revision, created = await collaboration_service.append_revision(
|
||||
db, item, content, source=source, created_by=actor.id if actor else None
|
||||
)
|
||||
saved_revision_id = revision.id
|
||||
result = "SAVED" if created else "UNCHANGED"
|
||||
if payload.status == 2:
|
||||
item.generation += 1
|
||||
session.status = "CLOSED"
|
||||
session.closed_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
|
||||
# Server 缓存重建时仍从最近一次持久化内容恢复。
|
||||
session.base_revision_id = revision.id
|
||||
if created and actor:
|
||||
await collaboration_service._audit(
|
||||
db, item, "COLLABORATION_REVISION_SAVED", actor,
|
||||
{"revision_no": revision.revision_no, "source": source},
|
||||
)
|
||||
elif payload.status == 4:
|
||||
session.status = "CLOSED"
|
||||
session.closed_at = datetime.now(timezone.utc)
|
||||
result = "UNCHANGED"
|
||||
elif payload.status in {3, 7}:
|
||||
session.status = "ERROR"
|
||||
result = "ERROR"
|
||||
|
||||
db.add(CollaborationCallbackReceipt(
|
||||
session_id=session.id,
|
||||
fingerprint=fingerprint,
|
||||
callback_status=payload.status,
|
||||
result=result,
|
||||
revision_id=saved_revision_id,
|
||||
))
|
||||
await db.commit()
|
||||
# ONLYOFFICE 要求回调处理器在接收并记录状态后固定确认成功。
|
||||
# status 3/7 表示文档服务自身保存失败,不应通过 error=1 制造重试环。
|
||||
return {"error": 0}
|
||||
@@ -18,7 +18,7 @@ from app.core.exceptions import AppException
|
||||
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||
|
||||
OnlyOfficeDocumentType = Literal["word", "cell", "slide"]
|
||||
OnlyOfficeResourceType = Literal["attachment", "version"]
|
||||
OnlyOfficeResourceType = Literal["attachment", "version", "collaboration_revision"]
|
||||
|
||||
WORD_FORMATS = frozenset({
|
||||
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt",
|
||||
@@ -52,7 +52,11 @@ def office_format_for_filename(filename: str) -> tuple[str, OnlyOfficeDocumentTy
|
||||
|
||||
|
||||
def onlyoffice_content_url(resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID) -> str:
|
||||
plural = "attachments" if resource_type == "attachment" else "versions"
|
||||
plural = {
|
||||
"attachment": "attachments",
|
||||
"version": "versions",
|
||||
"collaboration_revision": "collaboration-revisions",
|
||||
}[resource_type]
|
||||
return (
|
||||
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||
f"/internal/onlyoffice/{plural}/{resource_id}/content"
|
||||
@@ -200,15 +204,7 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any
|
||||
)
|
||||
token = credential.strip()
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "HS256":
|
||||
raise JWTError("unexpected algorithm")
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||
algorithms=["HS256"],
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
payload = decode_onlyoffice_token(token)
|
||||
except JWTError as exc:
|
||||
raise onlyoffice_error(
|
||||
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
|
||||
@@ -225,3 +221,15 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def decode_onlyoffice_token(token: str) -> dict[str, Any]:
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "HS256":
|
||||
raise JWTError("unexpected algorithm")
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||
algorithms=["HS256"],
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, is_system_admin
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import ae as ae_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import monitoring_visit_issue as monitoring_issue_crud
|
||||
from app.services import notification_service
|
||||
|
||||
|
||||
async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) -> None:
|
||||
membership = await member_crud.get_member(db, study_id, user.id)
|
||||
if not is_system_admin(user) and (not membership or not membership.is_active):
|
||||
return
|
||||
role = membership.role_in_study if membership else ""
|
||||
can_read_aes = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "subject_aes:read"
|
||||
)
|
||||
can_read_monitoring = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "monitoring_issues:read"
|
||||
)
|
||||
|
||||
overdue_aes = 0
|
||||
if can_read_aes:
|
||||
cra_scope = await get_cra_site_scope(db, study_id, user)
|
||||
items = await ae_crud.list_ae(
|
||||
db,
|
||||
study_id,
|
||||
overdue=True,
|
||||
site_ids=cra_scope[0] if cra_scope else None,
|
||||
)
|
||||
overdue_aes = len(items)
|
||||
await notification_service.sync_aggregate_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="RISK_OVERDUE_AE",
|
||||
priority="HIGH",
|
||||
title="逾期 AE 待处理",
|
||||
message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进",
|
||||
action_path="/risk-issues/sae",
|
||||
source_type="RISK_OVERDUE_AE",
|
||||
source_id=str(study_id),
|
||||
count=overdue_aes if can_read_aes else 0,
|
||||
)
|
||||
|
||||
overdue_monitoring = 0
|
||||
if can_read_monitoring:
|
||||
overdue_monitoring = len(await monitoring_issue_crud.list_issues(
|
||||
db,
|
||||
study_id,
|
||||
overdue=True,
|
||||
limit=2000,
|
||||
))
|
||||
await notification_service.sync_aggregate_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="RISK_OVERDUE_MONITORING",
|
||||
priority="HIGH",
|
||||
title="监查问题已逾期",
|
||||
message=f"当前有 {overdue_monitoring} 条监查问题已超过计划解决日期",
|
||||
action_path="/risk-issues/monitoring-visits",
|
||||
source_type="RISK_OVERDUE_MONITORING",
|
||||
source_id=str(study_id),
|
||||
count=overdue_monitoring if can_read_monitoring else 0,
|
||||
)
|
||||
await db.commit()
|
||||
Reference in New Issue
Block a user