From 88bd0c5942979f8bc16c460c26fb5f75171c8610 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 16 Jul 2026 15:29:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=AE=A1=E8=AE=A1):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E5=BA=93=E5=AE=A1=E8=AE=A1=E4=B8=8E=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=99=AA=E5=A3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0260716_04_remove_shared_library_audits.py | 35 ++++++ backend/app/api/v1/attachments.py | 64 +++++----- backend/app/api/v1/faq_categories.py | 37 +----- backend/app/api/v1/faqs.py | 69 +---------- backend/app/api/v1/onlyoffice.py | 41 +------ backend/app/api/v1/precautions.py | 40 +----- backend/app/services/collaboration_service.py | 116 +++--------------- .../services/collaboration_share_service.py | 13 -- .../onlyoffice_collaboration_service.py | 5 - backend/tests/test_api_permissions.py | 40 +++--- backend/tests/test_collaboration_service.py | 46 +++---- backend/tests/test_notification_service.py | 1 - backend/tests/test_onlyoffice_api.py | 4 - 13 files changed, 134 insertions(+), 377 deletions(-) create mode 100644 backend/alembic/versions/20260716_04_remove_shared_library_audits.py diff --git a/backend/alembic/versions/20260716_04_remove_shared_library_audits.py b/backend/alembic/versions/20260716_04_remove_shared_library_audits.py new file mode 100644 index 00000000..3fb31d75 --- /dev/null +++ b/backend/alembic/versions/20260716_04_remove_shared_library_audits.py @@ -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 diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index 05e98136..01199fb9 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -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,16 +240,17 @@ async def upload_attachment( content_type=file.content_type, uploaded_by=current_user.id, ) - await audit_crud.log_action( - db, - study_id=study_id, - entity_type=entity_type, - entity_id=entity_id, - action="UPLOAD_FILE", - detail=f"文件已上传:{file.filename}", - operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), - ) + if entity_type not in SHARED_LIBRARY_ENTITY_TYPES: + await audit_crud.log_action( + db, + study_id=study_id, + entity_type=entity_type, + entity_id=entity_id, + action="UPLOAD_FILE", + detail=f"文件已上传:{file.filename}", + operator_id=current_user.id, + operator_role=await get_operator_role_label(db, study_id, current_user), + ) return AttachmentRead( id=attachment.id, filename=attachment.filename, @@ -442,16 +444,17 @@ 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) - await audit_crud.log_action( - db, - study_id=attachment.study_id, - entity_type=attachment.entity_type, - entity_id=attachment.entity_id, - action="DELETE_ATTACHMENT", - detail=f"文件已删除:{attachment.filename}", - operator_id=user.id, - operator_role=await get_operator_role_label(db, attachment.study_id, user), - ) + if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES: + await audit_crud.log_action( + db, + study_id=attachment.study_id, + entity_type=attachment.entity_type, + entity_id=attachment.entity_id, + action="DELETE_ATTACHMENT", + detail=f"文件已删除:{attachment.filename}", + operator_id=user.id, + operator_role=await get_operator_role_label(db, attachment.study_id, user), + ) @router.delete( @@ -494,13 +497,14 @@ async def delete_attachment( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") await attachment_crud.soft_delete_attachment(db, attachment) - await audit_crud.log_action( - db, - study_id=study_id, - entity_type=entity_type, - entity_id=entity_id, - action="DELETE_ATTACHMENT", - detail=f"文件已删除:{attachment.filename}", - operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), - ) + if entity_type not in SHARED_LIBRARY_ENTITY_TYPES: + await audit_crud.log_action( + db, + study_id=study_id, + entity_type=entity_type, + entity_id=entity_id, + action="DELETE_ATTACHMENT", + detail=f"文件已删除:{attachment.filename}", + operator_id=current_user.id, + operator_role=await get_operator_role_label(db, study_id, current_user), + ) diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index 355058bf..99e2b3a0 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -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), - ) diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index 6f7f72af..4ac80c0e 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -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), - ) diff --git a/backend/app/api/v1/onlyoffice.py b/backend/app/api/v1/onlyoffice.py index 33cffdf0..47920c06 100644 --- a/backend/app/api/v1/onlyoffice.py +++ b/backend/app/api/v1/onlyoffice.py @@ -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 diff --git a/backend/app/api/v1/precautions.py b/backend/app/api/v1/precautions.py index 38ed964b..39f81d78 100644 --- a/backend/app/api/v1/precautions.py +++ b/backend/app/api/v1/precautions.py @@ -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), - ) diff --git a/backend/app/services/collaboration_service.py b/backend/app/services/collaboration_service.py index 9e13af89..2d342172 100644 --- a/backend/app/services/collaboration_service.py +++ b/backend/app/services/collaboration_service.py @@ -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 diff --git a/backend/app/services/collaboration_share_service.py b/backend/app/services/collaboration_share_service.py index 766ea3c1..3745f861 100644 --- a/backend/app/services/collaboration_share_service.py +++ b/backend/app/services/collaboration_share_service.py @@ -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) diff --git a/backend/app/services/onlyoffice_collaboration_service.py b/backend/app/services/onlyoffice_collaboration_service.py index c1757fb0..434461d8 100644 --- a/backend/app/services/onlyoffice_collaboration_service.py +++ b/backend/app/services/onlyoffice_collaboration_service.py @@ -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) diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index 60341832..883ddc12 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -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"] diff --git a/backend/tests/test_collaboration_service.py b/backend/tests/test_collaboration_service.py index 21848d70..2f27fbe7 100644 --- a/backend/tests/test_collaboration_service.py +++ b/backend/tests/test_collaboration_service.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, diff --git a/backend/tests/test_notification_service.py b/backend/tests/test_notification_service.py index c6a7001e..946709b1 100644 --- a/backend/tests/test_notification_service.py +++ b/backend/tests/test_notification_service.py @@ -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) diff --git a/backend/tests/test_onlyoffice_api.py b/backend/tests/test_onlyoffice_api.py index ee16031b..34767f53 100644 --- a/backend/tests/test_onlyoffice_api.py +++ b/backend/tests/test_onlyoffice_api.py @@ -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)