fix(审计): 移除共享库审计与预览噪声
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""Remove shared-library and passive Office preview audit noise.
|
||||
|
||||
Revision ID: 20260716_04
|
||||
Revises: 20260716_03
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260716_04"
|
||||
down_revision = "20260716_03"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
DELETE FROM audit_logs
|
||||
WHERE action = 'OFFICE_PREVIEW_OPEN'
|
||||
OR lower(entity_type) IN (
|
||||
'faq_category',
|
||||
'faq_item',
|
||||
'faq_reply',
|
||||
'faq_replies',
|
||||
'precaution',
|
||||
'knowledge_note',
|
||||
'knowledge_notes',
|
||||
'collaboration_file'
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 被清理的是明确排除出审计范围的共享库和被动预览记录,无法也不应伪造恢复。
|
||||
pass
|
||||
@@ -77,6 +77,7 @@ FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
|
||||
"read": "faq:read",
|
||||
"delete": "faq_attachments:delete",
|
||||
}
|
||||
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
|
||||
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
|
||||
"startup_kickoff",
|
||||
"startup_kickoff_minutes",
|
||||
@@ -239,6 +240,7 @@ async def upload_attachment(
|
||||
content_type=file.content_type,
|
||||
uploaded_by=current_user.id,
|
||||
)
|
||||
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
@@ -442,6 +444,7 @@ async def global_delete_attachment(
|
||||
if not can_delete:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
await attachment_crud.soft_delete_attachment(db, attachment)
|
||||
if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=attachment.study_id,
|
||||
@@ -494,6 +497,7 @@ async def delete_attachment(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
|
||||
await attachment_crud.soft_delete_attachment(db, attachment)
|
||||
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.crud import faq_category as category_crud
|
||||
from app.crud import faq_item as item_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
|
||||
from app.utils.pagination import paginate
|
||||
@@ -47,16 +43,6 @@ async def create_category(
|
||||
if dup:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||
category = await category_crud.create_category(db, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=payload.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category.id,
|
||||
action="CREATE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
||||
)
|
||||
return CategoryRead.model_validate(category)
|
||||
|
||||
|
||||
@@ -112,16 +98,6 @@ async def update_category(
|
||||
if not target_study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
updated = await category_crud.update_category(db, category, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=updated.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category_id,
|
||||
action="UPDATE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
|
||||
)
|
||||
return CategoryRead.model_validate(updated)
|
||||
|
||||
|
||||
@@ -148,16 +124,5 @@ async def delete_category(
|
||||
item_count = await item_crud.count_items_by_category(db, category_id)
|
||||
if item_count > 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
|
||||
category_name = category.name
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=category.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category_id,
|
||||
action="DELETE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, category.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.crud import faq_category as category_crud
|
||||
from app.crud import faq_item as faq_crud
|
||||
from app.crud import faq_reply as reply_crud
|
||||
@@ -31,13 +28,6 @@ def _is_system_admin(current_user) -> bool:
|
||||
return is_system_admin(current_user)
|
||||
|
||||
|
||||
def _compact_text(value: str | None, max_length: int = 40) -> str:
|
||||
text = " ".join(str(value or "").split())
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return f"{text[:max_length]}..."
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=FaqRead,
|
||||
@@ -74,17 +64,6 @@ async def create_faq(
|
||||
reply_in=FaqReplyCreate(content=payload.answer),
|
||||
)
|
||||
await faq_crud.set_status(db, item.id, "PROCESSING")
|
||||
question_name = _compact_text(item.question)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=payload.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item.id,
|
||||
action="CREATE_FAQ_ITEM",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
||||
)
|
||||
return FaqRead.model_validate(item)
|
||||
|
||||
|
||||
@@ -178,19 +157,6 @@ async def update_faq(
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
updated = await faq_crud.update_item(db, item, payload)
|
||||
action = "UPDATE_FAQ_ITEM"
|
||||
question_name = _compact_text(updated.question)
|
||||
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}”"}, ensure_ascii=False)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item_id,
|
||||
action=action,
|
||||
detail=detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
return FaqRead.model_validate(updated)
|
||||
|
||||
|
||||
@@ -337,17 +303,6 @@ async def create_reply(
|
||||
if item.status != "RESOLVED":
|
||||
await faq_crud.set_status(db, item.id, "PROCESSING")
|
||||
await faq_crud.touch_item(db, item.id)
|
||||
question_name = _compact_text(item.question)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_reply",
|
||||
entity_id=reply.id,
|
||||
action="CREATE_FAQ_REPLY",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
data = FaqReplyRead.model_validate(reply)
|
||||
if quote:
|
||||
if quote.is_deleted:
|
||||
@@ -380,20 +335,9 @@ async def delete_faq(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
question_name = _compact_text(item.question)
|
||||
await reply_crud.delete_replies_by_faq_id(db, item.id)
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item_id,
|
||||
action="DELETE_FAQ_ITEM",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -415,7 +359,6 @@ async def delete_reply(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
question_name = _compact_text(item.question)
|
||||
reply = await reply_crud.get_reply(db, reply_id)
|
||||
if not reply or reply.faq_id != item.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
|
||||
@@ -438,13 +381,3 @@ async def delete_reply(
|
||||
resolved_by_confirm=False,
|
||||
)
|
||||
await faq_crud.touch_item(db, item.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_reply",
|
||||
entity_id=reply_id,
|
||||
action="DELETE_FAQ_REPLY",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -11,9 +10,8 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.crud import attachment as attachment_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import document as document_crud
|
||||
from app.crud import document_version as version_crud
|
||||
from app.models.collaboration import CollaborationRevision
|
||||
@@ -33,29 +31,6 @@ def _content_disposition(filename: str) -> str:
|
||||
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
|
||||
|
||||
|
||||
async def _log_preview_open(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
resource_type: str,
|
||||
resource_id: uuid.UUID,
|
||||
current_user,
|
||||
) -> None:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="ATTACHMENT" if resource_type == "attachment" else "DOCUMENT_VERSION",
|
||||
entity_id=resource_id,
|
||||
action="OFFICE_PREVIEW_OPEN",
|
||||
detail=json.dumps(
|
||||
{"resource_type": resource_type, "resource_id": str(resource_id), "result": "issued"},
|
||||
ensure_ascii=True,
|
||||
),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attachments/{attachment_id}/config",
|
||||
response_model=OnlyOfficePreviewConfigRead,
|
||||
@@ -98,13 +73,6 @@ async def get_attachment_preview_config(
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.full_name,
|
||||
)
|
||||
await _log_preview_open(
|
||||
db,
|
||||
study_id=attachment.study_id,
|
||||
resource_type="attachment",
|
||||
resource_id=attachment.id,
|
||||
current_user=current_user,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
@@ -151,13 +119,6 @@ async def get_version_preview_config(
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.full_name,
|
||||
)
|
||||
await _log_preview_open(
|
||||
db,
|
||||
study_id=document.trial_id,
|
||||
resource_type="version",
|
||||
resource_id=version.id,
|
||||
current_user=current_user,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import precaution as precaution_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
@@ -29,11 +27,6 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||
|
||||
|
||||
def _precaution_audit_detail(action: str, precaution) -> str:
|
||||
title = str(precaution.title or "").strip() or "注意事项"
|
||||
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}”"}, ensure_ascii=False)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/precautions",
|
||||
response_model=PrecautionRead,
|
||||
@@ -49,16 +42,6 @@ async def create_precaution(
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_site_name_active(db, study_id, precaution_in.site_name)
|
||||
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution.id,
|
||||
action="CREATE_PRECAUTION",
|
||||
detail=_precaution_audit_detail("创建", precaution),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return PrecautionRead.model_validate(precaution)
|
||||
|
||||
|
||||
@@ -115,16 +98,6 @@ async def update_precaution(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
||||
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution_id,
|
||||
action="UPDATE_PRECAUTION",
|
||||
detail=_precaution_audit_detail("更新", precaution),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return PrecautionRead.model_validate(precaution)
|
||||
|
||||
|
||||
@@ -144,15 +117,4 @@ async def delete_precaution(
|
||||
if not precaution or precaution.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
||||
precaution_detail = _precaution_audit_detail("删除", precaution)
|
||||
await precaution_crud.delete_precaution(db, precaution)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution_id,
|
||||
action="DELETE_PRECAUTION",
|
||||
detail=precaution_detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -16,9 +16,8 @@ from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_operator_role_label, is_system_admin
|
||||
from app.core.deps import is_system_admin
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.models.collaboration import (
|
||||
CollaborationEditRequest,
|
||||
@@ -427,23 +426,6 @@ async def require_ownership_transfer(db: AsyncSession, item: CollaborationFile,
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有转让此协作文件所有权的权限")
|
||||
|
||||
|
||||
async def _audit(db: AsyncSession, item: CollaborationFile, action: str, user, extra: dict | None = None) -> None:
|
||||
detail = {"file_id": str(item.id), "title": item.title}
|
||||
if extra:
|
||||
detail.update(extra)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="COLLABORATION_FILE",
|
||||
entity_id=item.id,
|
||||
action=action,
|
||||
detail=json.dumps(detail, ensure_ascii=False),
|
||||
operator_id=user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, user),
|
||||
auto_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def create_folder(db: AsyncSession, study_id: uuid.UUID, payload: CollaborationFolderCreate, user) -> CollaborationFolder:
|
||||
if payload.parent_id:
|
||||
await _folder_or_404(db, study_id, payload.parent_id)
|
||||
@@ -618,7 +600,6 @@ async def _create_file_from_bytes(
|
||||
content: bytes,
|
||||
source: str,
|
||||
user,
|
||||
source_file_id: uuid.UUID | None = None,
|
||||
) -> CollaborationFile:
|
||||
membership = await member_crud.get_member(db, study_id, user.id)
|
||||
await _require_role_assignable(db, study_id, user, membership, "MANAGER")
|
||||
@@ -637,10 +618,6 @@ async def _create_file_from_bytes(
|
||||
await db.flush()
|
||||
db.add(CollaborationMember(file_id=item.id, user_id=user.id, role="MANAGER", invited_by=user.id))
|
||||
await _persist_revision_bytes(db, item, content, source=source, created_by=user.id, original_filename=item.title)
|
||||
audit_detail = {"source": source}
|
||||
if source_file_id:
|
||||
audit_detail["source_file_id"] = str(source_file_id)
|
||||
await _audit(db, item, "COLLABORATION_FILE_CREATED", user, audit_detail)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -775,11 +752,6 @@ async def update_file(
|
||||
db: AsyncSession, item: CollaborationFile, payload: CollaborationFileUpdate, user
|
||||
) -> CollaborationFile:
|
||||
await require_file_manager(db, item, user)
|
||||
previous_title = item.title
|
||||
previous_folder_id = item.folder_id
|
||||
previous_allow_export = item.allow_export
|
||||
previous_allow_edit_request = item.allow_edit_request
|
||||
previous_allow_sheet_structure_edit = item.allow_sheet_structure_edit
|
||||
values = payload.model_dump(exclude_unset=True)
|
||||
if "folder_id" in values and values["folder_id"]:
|
||||
await _folder_or_404(db, item.study_id, values["folder_id"])
|
||||
@@ -810,25 +782,6 @@ async def update_file(
|
||||
item.generation += 1
|
||||
for key, value in values.items():
|
||||
setattr(item, key, value)
|
||||
title_changed = item.title != previous_title
|
||||
folder_changed = item.folder_id != previous_folder_id
|
||||
if title_changed and not folder_changed:
|
||||
action = "COLLABORATION_FILE_RENAMED"
|
||||
elif folder_changed and not title_changed:
|
||||
action = "COLLABORATION_FILE_MOVED"
|
||||
else:
|
||||
action = "COLLABORATION_FILE_UPDATED"
|
||||
await _audit(db, item, action, user, {
|
||||
"previous_title": previous_title,
|
||||
"previous_folder_id": str(previous_folder_id) if previous_folder_id else None,
|
||||
"folder_id": str(item.folder_id) if item.folder_id else None,
|
||||
"previous_allow_export": previous_allow_export,
|
||||
"allow_export": item.allow_export,
|
||||
"previous_allow_edit_request": previous_allow_edit_request,
|
||||
"allow_edit_request": item.allow_edit_request,
|
||||
"previous_allow_sheet_structure_edit": previous_allow_sheet_structure_edit,
|
||||
"allow_sheet_structure_edit": item.allow_sheet_structure_edit,
|
||||
})
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -874,7 +827,6 @@ async def copy_file(db: AsyncSession, item: CollaborationFile, user) -> Collabor
|
||||
content=content,
|
||||
source="COPY",
|
||||
user=user,
|
||||
source_file_id=item.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -885,14 +837,6 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可下载版本")
|
||||
if not Path(revision.file_uri).is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件当前版本不存在")
|
||||
await _audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_FILE_DOWNLOADED",
|
||||
user,
|
||||
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
|
||||
)
|
||||
await db.commit()
|
||||
return revision
|
||||
|
||||
|
||||
@@ -900,7 +844,6 @@ async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None
|
||||
await require_file_manager(db, item, user)
|
||||
item.status = "DELETED"
|
||||
item.deleted_at = datetime.now(timezone.utc)
|
||||
await _audit(db, item, "COLLABORATION_FILE_TRASHED", user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -908,7 +851,6 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
|
||||
await require_file_manager(db, item, user)
|
||||
item.status = "ACTIVE"
|
||||
item.deleted_at = None
|
||||
await _audit(db, item, "COLLABORATION_FILE_RESTORED", user)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -916,14 +858,10 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
|
||||
|
||||
async def record_export(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
|
||||
await require_file_exporter(db, item, user)
|
||||
await _audit(db, item, "COLLABORATION_FILE_EXPORTED", user, {"file_type": file_type})
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def record_download(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
|
||||
await require_file_exporter(db, item, user)
|
||||
await _audit(db, item, "COLLABORATION_FILE_DOWNLOADED", user, {"file_type": file_type})
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_revisions(db: AsyncSession, item: CollaborationFile) -> list[CollaborationRevisionRead]:
|
||||
@@ -967,10 +905,6 @@ async def delete_revision(
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前版本不能删除")
|
||||
revision.deleted_at = datetime.now(timezone.utc)
|
||||
revision.deleted_by = user.id
|
||||
await _audit(db, item, "COLLABORATION_REVISION_DELETED", user, {
|
||||
"revision_id": str(revision.id),
|
||||
"revision_no": revision.revision_no,
|
||||
})
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -984,13 +918,6 @@ async def update_revision(
|
||||
await require_file_editor(db, item, user)
|
||||
revision = await get_revision_or_404(db, item, revision_id)
|
||||
revision.change_summary = payload.change_summary
|
||||
await _audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_REVISION_NAMED",
|
||||
user,
|
||||
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(revision)
|
||||
return revision
|
||||
@@ -1002,14 +929,6 @@ async def prepare_revision_preview(
|
||||
revision = await get_revision_or_404(db, item, revision_id)
|
||||
if not Path(revision.file_uri).is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订内容不存在")
|
||||
await _audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_REVISION_PREVIEWED",
|
||||
user,
|
||||
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
|
||||
)
|
||||
await db.commit()
|
||||
return revision
|
||||
|
||||
|
||||
@@ -1040,7 +959,6 @@ async def copy_revision(
|
||||
content=content,
|
||||
source="COPY",
|
||||
user=user,
|
||||
source_file_id=item.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1063,7 +981,6 @@ async def restore_revision(
|
||||
locked = await db.get(CollaborationFile, item.id)
|
||||
if locked:
|
||||
locked.generation += 1
|
||||
await _audit(db, item, "COLLABORATION_REVISION_RESTORED", user, {"revision_no": revision.revision_no})
|
||||
await db.commit()
|
||||
await db.refresh(restored)
|
||||
return restored
|
||||
@@ -1111,7 +1028,6 @@ async def upsert_member(
|
||||
else:
|
||||
member = CollaborationMember(file_id=item.id, user_id=payload.user_id, role=payload.role, invited_by=user.id)
|
||||
db.add(member)
|
||||
await _audit(db, item, "COLLABORATION_MEMBER_UPSERTED", user, {"member_id": str(payload.user_id), "role": payload.role})
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
return CollaborationMemberRead(
|
||||
@@ -1132,7 +1048,6 @@ async def remove_member(db: AsyncSession, item: CollaborationFile, user_id: uuid
|
||||
if not member:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作成员不存在")
|
||||
await db.delete(member)
|
||||
await _audit(db, item, "COLLABORATION_MEMBER_REMOVED", user, {"member_id": str(user_id)})
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1192,7 +1107,6 @@ async def create_edit_request(
|
||||
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
|
||||
db.add(request)
|
||||
await db.flush()
|
||||
await _audit(db, item, "COLLABORATION_EDIT_REQUESTED", user, {"request_id": str(request.id)})
|
||||
manager_ids = set((await db.scalars(
|
||||
select(StudyMember.user_id)
|
||||
.outerjoin(
|
||||
@@ -1286,16 +1200,30 @@ 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),
|
||||
)
|
||||
await _audit(db, item, "COLLABORATION_EDIT_REQUEST_RESOLVED", user, {
|
||||
"request_id": str(request.id),
|
||||
"requester_id": str(request.requester_id),
|
||||
"status": payload.status,
|
||||
})
|
||||
await db.commit()
|
||||
await db.refresh(request)
|
||||
return _edit_request_read(request, requester)
|
||||
@@ -1341,10 +1269,6 @@ async def transfer_ownership(
|
||||
previous_owner_member.role = "MANAGER"
|
||||
locked.owner_id = payload.new_owner_id
|
||||
locked.updated_at = datetime.now(timezone.utc)
|
||||
await _audit(db, locked, "COLLABORATION_OWNERSHIP_TRANSFERRED", user, {
|
||||
"previous_owner_id": str(previous_owner_id),
|
||||
"new_owner_id": str(payload.new_owner_id),
|
||||
})
|
||||
await db.commit()
|
||||
await db.refresh(locked)
|
||||
return locked
|
||||
|
||||
@@ -177,19 +177,6 @@ async def update_share_link(
|
||||
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)
|
||||
|
||||
@@ -396,11 +396,6 @@ async def process_callback(
|
||||
# 强制保存不结束当前共同编辑会话;同步基线可保证 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)
|
||||
|
||||
@@ -674,24 +674,30 @@ def test_subject_audit_uses_structured_business_snapshot():
|
||||
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
|
||||
|
||||
|
||||
def test_faq_audit_uses_business_descriptions():
|
||||
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案。"""
|
||||
def test_shared_library_does_not_write_audits():
|
||||
"""共享库各模块及其附件不应写入业务审计。"""
|
||||
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
|
||||
category_source = (api_dir / "faq_categories.py").read_text()
|
||||
item_source = (api_dir / "faqs.py").read_text()
|
||||
services_dir = Path(__file__).resolve().parents[1] / "app" / "services"
|
||||
non_audited_sources = [
|
||||
(api_dir / "faq_categories.py").read_text(),
|
||||
(api_dir / "faqs.py").read_text(),
|
||||
(api_dir / "precautions.py").read_text(),
|
||||
(services_dir / "collaboration_service.py").read_text(),
|
||||
(services_dir / "collaboration_share_service.py").read_text(),
|
||||
(services_dir / "onlyoffice_collaboration_service.py").read_text(),
|
||||
]
|
||||
|
||||
assert 'description": f"创建“{category.name}”分类"' in category_source
|
||||
assert 'description": f"更新“{updated.name}”分类"' in category_source
|
||||
assert 'description": f"删除“{category_name}”分类"' in category_source
|
||||
assert 'f"创建医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"更新医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"回复医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"删除医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source
|
||||
assert "FAQ 分类 {category.name} 已创建" not in category_source
|
||||
assert 'detail="FAQ 已创建"' not in item_source
|
||||
assert 'detail = "FAQ updated"' not in item_source
|
||||
assert '"description": "创建医学咨询问题"' not in item_source
|
||||
for source in non_audited_sources:
|
||||
assert "audit_crud" not in source
|
||||
assert "._audit(" not in source
|
||||
|
||||
attachment_source = (api_dir / "attachments.py").read_text()
|
||||
assert 'SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}' in attachment_source
|
||||
assert "if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
|
||||
assert "if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
|
||||
|
||||
onlyoffice_source = (api_dir / "onlyoffice.py").read_text()
|
||||
assert "OFFICE_PREVIEW_OPEN" not in onlyoffice_source
|
||||
|
||||
|
||||
def test_domain_audits_use_business_object_names():
|
||||
@@ -704,7 +710,6 @@ def test_domain_audits_use_business_object_names():
|
||||
"material_equipments.py",
|
||||
"visits.py",
|
||||
"aes.py",
|
||||
"precautions.py",
|
||||
"fees_contracts.py",
|
||||
"startup.py",
|
||||
"subject_histories.py",
|
||||
@@ -715,7 +720,6 @@ def test_domain_audits_use_business_object_names():
|
||||
assert "_equipment_audit_detail" in sources["material_equipments.py"]
|
||||
assert "_visit_audit_detail" in sources["visits.py"]
|
||||
assert "_ae_audit_detail" in sources["aes.py"]
|
||||
assert "_precaution_audit_detail" in sources["precautions.py"]
|
||||
assert "_contract_audit_detail" in sources["fees_contracts.py"]
|
||||
assert "_payment_audit_detail" in sources["fees_contracts.py"]
|
||||
assert "_feasibility_audit_detail" in sources["startup.py"]
|
||||
|
||||
@@ -140,7 +140,6 @@ async def test_copy_revision_saves_to_the_selected_workspace_folder(monkeypatch,
|
||||
content=content,
|
||||
source="COPY",
|
||||
user=user,
|
||||
source_file_id=item.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -159,22 +158,13 @@ async def test_delete_revision_soft_deletes_a_non_current_version(monkeypatch):
|
||||
)
|
||||
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
|
||||
require_manager = AsyncMock()
|
||||
audit = AsyncMock()
|
||||
monkeypatch.setattr(collaboration_service, "require_file_manager", require_manager)
|
||||
monkeypatch.setattr(collaboration_service, "_audit", audit)
|
||||
|
||||
await collaboration_service.delete_revision(db, item, revision_id, user)
|
||||
|
||||
require_manager.assert_awaited_once_with(db, item, user)
|
||||
assert revision.deleted_at is not None
|
||||
assert revision.deleted_by == user.id
|
||||
audit.assert_awaited_once_with(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_REVISION_DELETED",
|
||||
user,
|
||||
{"revision_id": str(revision_id), "revision_no": 2},
|
||||
)
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -260,7 +250,6 @@ async def test_sheet_permission_change_updates_current_file_without_creating_rev
|
||||
user = SimpleNamespace(id=uuid.uuid4())
|
||||
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock(), refresh=AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
|
||||
persist_revision = AsyncMock()
|
||||
monkeypatch.setattr(collaboration_service, "_persist_revision_bytes", persist_revision)
|
||||
|
||||
@@ -540,7 +529,6 @@ async def test_share_link_url_stays_immutable_when_disabled_and_reenabled(monkey
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
|
||||
original_token = collaboration_share_service.share_token(link)
|
||||
|
||||
await collaboration_share_service.update_share_link(
|
||||
@@ -803,12 +791,11 @@ async def test_copy_creates_an_independent_r1_from_the_current_revision(monkeypa
|
||||
content=content,
|
||||
source="COPY",
|
||||
user=user,
|
||||
source_file_id=item.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_returns_current_revision_and_records_audit(monkeypatch, tmp_path):
|
||||
async def test_download_returns_current_revision_without_writing_audit(monkeypatch, tmp_path):
|
||||
source = tmp_path / "source.docx"
|
||||
source.write_bytes(collaboration_service.blank_file_bytes("word"))
|
||||
revision = SimpleNamespace(
|
||||
@@ -821,22 +808,33 @@ async def test_download_returns_current_revision_and_records_audit(monkeypatch,
|
||||
user = SimpleNamespace(id=uuid.uuid4())
|
||||
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
|
||||
require_exporter = AsyncMock()
|
||||
audit = AsyncMock()
|
||||
monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter)
|
||||
monkeypatch.setattr(collaboration_service, "_audit", audit)
|
||||
|
||||
result = await collaboration_service.prepare_download(db, item, user)
|
||||
|
||||
assert result is revision
|
||||
require_exporter.assert_awaited_once_with(db, item, user)
|
||||
audit.assert_awaited_once_with(
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revision_preview_does_not_write_passive_audit(monkeypatch, tmp_path):
|
||||
revision_path = tmp_path / "revision.docx"
|
||||
revision_path.write_bytes(b"office")
|
||||
revision = SimpleNamespace(id=uuid.uuid4(), file_uri=str(revision_path), revision_no=2)
|
||||
item = SimpleNamespace(id=uuid.uuid4())
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service, "get_revision_or_404", AsyncMock(return_value=revision))
|
||||
|
||||
result = await collaboration_service.prepare_revision_preview(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_FILE_DOWNLOADED",
|
||||
user,
|
||||
{"revision_id": str(revision.id), "revision_no": 3},
|
||||
revision.id,
|
||||
SimpleNamespace(id=uuid.uuid4()),
|
||||
)
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
assert result is revision
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1041,12 +1039,6 @@ async def test_force_save_updates_session_recovery_revision(monkeypatch):
|
||||
"append_revision",
|
||||
AsyncMock(return_value=(revision, True)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
onlyoffice_collaboration_service.collaboration_service,
|
||||
"_audit",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
result = await onlyoffice_collaboration_service.process_callback(
|
||||
db,
|
||||
session.id,
|
||||
|
||||
@@ -128,7 +128,6 @@ async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypa
|
||||
"get_member",
|
||||
AsyncMock(return_value=SimpleNamespace(is_active=True)),
|
||||
)
|
||||
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify)
|
||||
|
||||
result = await collaboration_service.create_edit_request(db, item, user)
|
||||
|
||||
@@ -39,12 +39,10 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
permission = AsyncMock()
|
||||
audit = AsyncMock()
|
||||
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission)
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", audit)
|
||||
response = Response()
|
||||
db = object()
|
||||
|
||||
@@ -54,7 +52,6 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
|
||||
assert result.file_name == "研究方案最终版.DOCX"
|
||||
assert result.config["document"]["title"] == "研究方案最终版.DOCX"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
audit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -96,7 +93,6 @@ async def test_version_config_reuses_document_access_and_version_hash(monkeypatc
|
||||
monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document))
|
||||
monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access)
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", AsyncMock())
|
||||
|
||||
result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user