diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py index 4b661710..e7fccb54 100644 --- a/backend/app/api/v1/aes.py +++ b/backend/app/api/v1/aes.py @@ -44,6 +44,15 @@ def _is_overdue(ae: AERead) -> bool: return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED") +def _ae_audit_name(ae) -> str: + return str(getattr(ae, "term", "") or "").strip() or "AE 记录" + + +def _ae_audit_detail(action: str, ae) -> str: + name = _ae_audit_name(ae) + return json.dumps({"targetName": name, "description": f"{action}AE“{name}”"}, ensure_ascii=False) + + @router.post( "/", response_model=AERead, @@ -77,7 +86,7 @@ async def create_ae( entity_type="ae", entity_id=ae.id, action="CREATE_AE", - detail=f"AE {ae.id} 已创建", + detail=_ae_audit_detail("创建", ae), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -247,7 +256,16 @@ async def update_ae( entity_type="ae", entity_id=ae_id, action=action, - detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str), + detail=json.dumps( + { + "targetName": _ae_audit_name(updated), + "description": f"{'变更' if action == 'AE_STATUS_CHANGE' else '更新'}AE“{_ae_audit_name(updated)}”", + "before": detail_before, + "after": detail_after, + }, + ensure_ascii=False, + default=str, + ), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -272,6 +290,7 @@ async def delete_ae( if not ae or ae.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在") await _ensure_ae_visible(db, study_id, AERead.model_validate(ae)) + ae_detail = _ae_audit_detail("删除", ae) await ae_crud.delete_ae(db, ae) await audit_crud.log_action( db, @@ -279,7 +298,7 @@ async def delete_ae( entity_type="ae", entity_id=ae_id, action="DELETE_AE", - detail=f"AE {ae_id} 已删除", + detail=ae_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/audit_logs.py b/backend/app/api/v1/audit_logs.py index 51cd39a3..dc6526ae 100644 --- a/backend/app/api/v1/audit_logs.py +++ b/backend/app/api/v1/audit_logs.py @@ -1,15 +1,34 @@ +import json import uuid 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_roles, require_api_permission +from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission from app.crud import audit as audit_crud +from app.crud import study as study_crud from app.schemas.audit import AuditEventCreate, AuditLogRead router = APIRouter() +async def _build_export_audit_detail(db: AsyncSession, study_id: uuid.UUID, action: str) -> str: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + scope = "system" if action == "AUDIT_EXPORT_SYSTEM" else "project" + description = "导出了系统审计日志" if scope == "system" else f"导出了项目审计日志:{study.name}" + return json.dumps( + { + "targetName": "系统审计日志" if scope == "system" else study.name, + "scope": scope, + "description": description, + "result": "SUCCESS", + }, + ensure_ascii=False, + ) + + @router.get( "/", response_model=list[AuditLogRead], @@ -38,22 +57,6 @@ async def list_audit_logs( return list(logs) -@router.delete( - "/{log_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_roles(["ADMIN"]))], -) -async def delete_audit_log( - study_id: uuid.UUID, - log_id: uuid.UUID, - db: AsyncSession = Depends(get_db_session), -): - log = await audit_crud.get_log(db, log_id) - if not log or log.study_id != study_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在") - await audit_crud.delete_log(db, log) - - @router.post( "/events", status_code=status.HTTP_201_CREATED, @@ -71,10 +74,10 @@ async def create_audit_event( await audit_crud.log_action( db, study_id=study_id, - entity_type=payload.entity_type or "audit_log", - entity_id=payload.entity_id, + entity_type="audit_log", + entity_id=None, action=payload.action, - detail=payload.detail, + detail=await _build_export_audit_detail(db, study_id, payload.action), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/drug_shipments.py b/backend/app/api/v1/drug_shipments.py index f38fca80..9604478d 100644 --- a/backend/app/api/v1/drug_shipments.py +++ b/backend/app/api/v1/drug_shipments.py @@ -23,6 +23,11 @@ def _shipment_audit_name(shipment) -> str: return shipment.tracking_no or shipment.batch_no or shipment.site_name or "药品运输记录" +def _shipment_audit_description(action: str, shipment) -> str: + name = _shipment_audit_name(shipment) + return f"{action}药品流向“{name}”" + + async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): study = await study_crud.get(db, study_id) if not study: @@ -65,7 +70,7 @@ async def create_shipment( entity_id=shipment.id, action="CREATE_DRUG_SHIPMENT", detail=json.dumps( - {"targetName": _shipment_audit_name(shipment), "description": f"药品运输 {_shipment_audit_name(shipment)} 已创建"}, + {"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("创建", shipment)}, ensure_ascii=False, ), operator_id=current_user.id, @@ -178,7 +183,7 @@ async def update_shipment( entity_id=shipment_id, action="UPDATE_DRUG_SHIPMENT", detail=json.dumps( - {"targetName": _shipment_audit_name(shipment), "description": f"药品运输 {_shipment_audit_name(shipment)} 已更新"}, + {"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("更新", shipment)}, ensure_ascii=False, ), operator_id=current_user.id, @@ -214,7 +219,7 @@ async def delete_shipment( entity_type="drug_shipment", entity_id=shipment_id, action="DELETE_DRUG_SHIPMENT", - detail=json.dumps({"targetName": shipment_name, "description": f"药品运输 {shipment_name} 已删除"}, ensure_ascii=False), + detail=json.dumps({"targetName": shipment_name, "description": f"删除药品流向“{shipment_name}”"}, ensure_ascii=False), 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 4523beaa..355058bf 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -53,7 +53,7 @@ async def create_category( entity_type="faq_category", entity_id=category.id, action="CREATE_FAQ_CATEGORY", - detail=json.dumps({"targetName": category.name, "description": f"FAQ 分类 {category.name} 已创建"}, ensure_ascii=False), + 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), ) @@ -118,7 +118,7 @@ async def update_category( entity_type="faq_category", entity_id=category_id, action="UPDATE_FAQ_CATEGORY", - detail=json.dumps({"targetName": updated.name, "description": f"FAQ 分类 {updated.name} 已更新"}, ensure_ascii=False), + 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), ) @@ -157,7 +157,7 @@ async def delete_category( entity_type="faq_category", entity_id=category_id, action="DELETE_FAQ_CATEGORY", - detail=json.dumps({"targetName": category_name, "description": f"FAQ 分类 {category_name} 已删除"}, ensure_ascii=False), + 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 b36f1c49..6f7f72af 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -1,3 +1,4 @@ +import json import uuid from fastapi import APIRouter, Depends, HTTPException, status @@ -30,6 +31,13 @@ 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, @@ -66,13 +74,14 @@ 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="FAQ 已创建", + 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), ) @@ -170,7 +179,8 @@ async def update_faq( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") updated = await faq_crud.update_item(db, item, payload) action = "UPDATE_FAQ_ITEM" - detail = "FAQ updated" + 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, @@ -327,13 +337,14 @@ 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="FAQ 已回复", + 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), ) @@ -369,6 +380,7 @@ 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() @@ -378,7 +390,7 @@ async def delete_faq( entity_type="faq_item", entity_id=item_id, action="DELETE_FAQ_ITEM", - detail="FAQ 已删除", + 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), ) @@ -403,6 +415,7 @@ 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="回复不存在") @@ -431,7 +444,7 @@ async def delete_reply( entity_type="faq_reply", entity_id=reply_id, action="DELETE_FAQ_REPLY", - detail="FAQ 回复已删除", + 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/fees_contracts.py b/backend/app/api/v1/fees_contracts.py index ddbb12a1..5924dd56 100644 --- a/backend/app/api/v1/fees_contracts.py +++ b/backend/app/api/v1/fees_contracts.py @@ -1,4 +1,5 @@ import uuid +import json from decimal import Decimal from typing import Any @@ -52,6 +53,34 @@ async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用") +async def _contract_audit_name(db: AsyncSession, contract) -> str: + site = await site_crud.get_site(db, contract.center_id) + center_name = site.name if site else "中心" + contract_no = str(contract.contract_no or "").strip() + return f"{center_name} / {contract_no}" if contract_no else center_name + + +async def _contract_audit_detail(db: AsyncSession, action: str, contract) -> str: + name = await _contract_audit_name(db, contract) + return json.dumps({"targetName": name, "description": f"{action}合同费用“{name}”"}, ensure_ascii=False) + + +async def _payment_audit_name(db: AsyncSession, contract, payment) -> str: + contract_name = await _contract_audit_name(db, contract) + seq = getattr(payment, "seq", None) + amount = getattr(payment, "amount", None) + if seq: + return f"{contract_name} / 第{seq}期" + if amount is not None: + return f"{contract_name} / {amount}" + return contract_name + + +async def _payment_audit_detail(db: AsyncSession, action: str, contract, payment) -> str: + name = await _payment_audit_name(db, contract, payment) + return json.dumps({"targetName": name, "description": f"{action}合同费用分期“{name}”"}, ensure_ascii=False) + + def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None: if not value: return None @@ -261,7 +290,7 @@ async def create_contract_fee( entity_type="contract_fee", entity_id=contract.id, action="CREATE_CONTRACT_FEE", - detail="合同费用已创建", + detail=await _contract_audit_detail(db, "创建", contract), operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) @@ -291,7 +320,7 @@ async def update_contract_fee( entity_type="contract_fee", entity_id=contract_id, action="UPDATE_CONTRACT_FEE", - detail="合同费用已更新", + detail=await _contract_audit_detail(db, "更新", contract), operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) @@ -313,6 +342,7 @@ async def delete_contract_fee( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete") await _ensure_center_active(db, contract.study_id, contract.center_id) + contract_detail = await _contract_audit_detail(db, "删除", contract) await contract_fee_crud.delete_contract_fee(db, contract) await audit_crud.log_action( db, @@ -320,7 +350,7 @@ async def delete_contract_fee( entity_type="contract_fee", entity_id=contract_id, action="DELETE_CONTRACT_FEE", - detail="合同费用已删除", + detail=contract_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) @@ -350,7 +380,7 @@ async def create_contract_payment( entity_type="contract_fee_payment", entity_id=payment.id, action="CREATE_CONTRACT_FEE_PAYMENT", - detail="合同费用分期已创建", + detail=await _payment_audit_detail(db, "创建", contract, payment), operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) @@ -391,7 +421,7 @@ async def update_contract_payment( entity_type="contract_fee_payment", entity_id=payment_id, action="UPDATE_CONTRACT_FEE_PAYMENT", - detail="合同费用分期已更新", + detail=await _payment_audit_detail(db, "更新", contract, payment), operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) @@ -415,6 +445,7 @@ async def delete_contract_payment( if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update") + payment_detail = await _payment_audit_detail(db, "删除", contract, payment) await payment_crud.delete_payment(db, payment) await payment_crud.resequence_payments(db, contract.id) await audit_crud.log_action( @@ -423,7 +454,7 @@ async def delete_contract_payment( entity_type="contract_fee_payment", entity_id=payment_id, action="DELETE_CONTRACT_FEE_PAYMENT", - detail="合同费用分期已删除", + detail=payment_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) diff --git a/backend/app/api/v1/material_equipments.py b/backend/app/api/v1/material_equipments.py index 7a8b87e0..ab9df610 100644 --- a/backend/app/api/v1/material_equipments.py +++ b/backend/app/api/v1/material_equipments.py @@ -1,4 +1,5 @@ import uuid +import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -24,6 +25,18 @@ def _validate_calibration(need_calibration: bool, calibration_cycle_days: int | raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0") +def _equipment_audit_name(item) -> str: + parts = [item.name, item.spec_model, item.brand] + return " / ".join(str(part).strip() for part in parts if str(part or "").strip()) or "设备记录" + + +def _equipment_audit_detail(action: str, item) -> str: + return json.dumps( + {"targetName": _equipment_audit_name(item), "description": f"{action}设备“{_equipment_audit_name(item)}”"}, + ensure_ascii=False, + ) + + @router.post( "/equipment", response_model=MaterialEquipmentRead, @@ -47,7 +60,7 @@ async def create_equipment( entity_type="material_equipment", entity_id=item.id, action="CREATE_MATERIAL_EQUIPMENT", - detail=f"设备 {item.id} 已创建", + detail=_equipment_audit_detail("创建", item), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -118,7 +131,7 @@ async def update_equipment( entity_type="material_equipment", entity_id=equipment_id, action="UPDATE_MATERIAL_EQUIPMENT", - detail=f"设备 {equipment_id} 已更新", + detail=_equipment_audit_detail("更新", item), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -140,6 +153,7 @@ async def delete_equipment( item = await equipment_crud.get_equipment(db, equipment_id) if not item or item.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在") + equipment_detail = _equipment_audit_detail("删除", item) await equipment_crud.delete_equipment(db, item) await audit_crud.log_action( db, @@ -147,7 +161,7 @@ async def delete_equipment( entity_type="material_equipment", entity_id=equipment_id, action="DELETE_MATERIAL_EQUIPMENT", - detail=f"设备 {equipment_id} 已删除", + detail=equipment_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index e4de7b0f..1fe0a0b5 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -43,6 +43,20 @@ def _role_rank(role: str | None) -> int: return PROJECT_ROLE_RANK.get(role or "", 0) +def _user_audit_name(user, user_id: uuid.UUID) -> str: + if user: + for attr in ("full_name", "username", "email"): + value = getattr(user, attr, None) + if value: + return str(value) + return str(user_id) + + +async def _member_audit_name(db: AsyncSession, user_id: uuid.UUID) -> str: + user = await user_crud.get_by_id(db, user_id) + return _user_audit_name(user, user_id) + + async def _ensure_member_mutation_allowed( db: AsyncSession, study_id: uuid.UUID, @@ -116,7 +130,7 @@ async def add_member( entity_id=updated.id, action="PROJECT_MEMBER_UPDATED", detail=json.dumps({ - "targetName": str(updated.user_id), + "targetName": await _member_audit_name(db, updated.user_id), "before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study}, "after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study}, }, ensure_ascii=False), @@ -132,7 +146,10 @@ async def add_member( entity_type="study_member", entity_id=member.id, action="PROJECT_MEMBER_ADDED", - detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False), + detail=json.dumps({ + "targetName": await _member_audit_name(db, member.user_id), + "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}, + }, ensure_ascii=False), operator_id=current_user.id, operator_role=await _operator_project_role(db, study_id, current_user), ) @@ -247,7 +264,11 @@ async def update_member( entity_type="study_member", entity_id=updated.id, action="PROJECT_MEMBER_UPDATED", - detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False), + detail=json.dumps({ + "targetName": await _member_audit_name(db, updated.user_id), + "before": before_data, + "after": after_data, + }, ensure_ascii=False), operator_id=current_user.id, operator_role=await _operator_project_role(db, study_id, current_user), ) @@ -288,7 +309,11 @@ async def remove_member( entity_type="study_member", entity_id=removed.id, action="PROJECT_MEMBER_REMOVED", - detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False), + detail=json.dumps({ + "targetName": _user_audit_name(target_user, removed.user_id), + "before": before_data, + "after": {"is_active": removed.is_active}, + }, ensure_ascii=False), operator_id=current_user.id, operator_role=await _operator_project_role(db, study_id, current_user), ) diff --git a/backend/app/api/v1/precautions.py b/backend/app/api/v1/precautions.py index 3c06770f..38ed964b 100644 --- a/backend/app/api/v1/precautions.py +++ b/backend/app/api/v1/precautions.py @@ -1,4 +1,5 @@ import uuid +import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -28,6 +29,11 @@ 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,7 +55,7 @@ async def create_precaution( entity_type="precaution", entity_id=precaution.id, action="CREATE_PRECAUTION", - detail=f"注意事项 {precaution.title} 已创建", + detail=_precaution_audit_detail("创建", precaution), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -115,7 +121,7 @@ async def update_precaution( entity_type="precaution", entity_id=precaution_id, action="UPDATE_PRECAUTION", - detail=f"注意事项 {precaution_id} 已更新", + detail=_precaution_audit_detail("更新", precaution), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -138,6 +144,7 @@ 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, @@ -145,7 +152,7 @@ async def delete_precaution( entity_type="precaution", entity_id=precaution_id, action="DELETE_PRECAUTION", - detail=f"注意事项 {precaution_id} 已删除", + 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/api/v1/startup.py b/backend/app/api/v1/startup.py index dc145e04..6a9ff7ee 100644 --- a/backend/app/api/v1/startup.py +++ b/backend/app/api/v1/startup.py @@ -1,4 +1,5 @@ import uuid +import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -49,6 +50,41 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用") +async def _site_label(db: AsyncSession, site_id: uuid.UUID | None, fallback: str = "项目级") -> str: + if not site_id: + return fallback + site = await site_crud.get_site(db, site_id) + return site.name if site else fallback + + +async def _feasibility_audit_detail(db: AsyncSession, action: str, record) -> str: + site_name = await _site_label(db, record.site_id) + project_no = str(record.project_no or "").strip() + name = f"{site_name} / {project_no}" if project_no else site_name + return json.dumps({"targetName": name, "description": f"{action}立项记录“{name}”"}, ensure_ascii=False) + + +async def _ethics_audit_detail(db: AsyncSession, action: str, record) -> str: + site_name = await _site_label(db, record.site_id) + approval_no = str(record.approval_no or "").strip() + name = f"{site_name} / {approval_no}" if approval_no else site_name + return json.dumps({"targetName": name, "description": f"{action}伦理记录“{name}”"}, ensure_ascii=False) + + +async def _kickoff_audit_detail(db: AsyncSession, action: str, meeting) -> str: + site_name = await _site_label(db, meeting.site_id) + date_text = meeting.kickoff_date.isoformat() if meeting.kickoff_date else "" + name = f"{site_name} / {date_text}" if date_text else site_name + return json.dumps({"targetName": name, "description": f"{action}启动会记录“{name}”"}, ensure_ascii=False) + + +def _training_audit_detail(action: str, record) -> str: + name = str(record.name or "").strip() or "培训授权人员" + site_name = str(record.site_name or "").strip() + target_name = f"{name} / {site_name}" if site_name else name + return json.dumps({"targetName": target_name, "description": f"{action}培训授权人员“{target_name}”"}, ensure_ascii=False) + + @router.post( "/feasibility", response_model=StartupFeasibilityRead, @@ -73,7 +109,7 @@ async def create_feasibility( entity_type="startup_feasibility", entity_id=record.id, action="CREATE_STARTUP_FEASIBILITY", - detail="立项记录已创建", + detail=await _feasibility_audit_detail(db, "创建", record), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -147,7 +183,7 @@ async def update_feasibility( entity_type="startup_feasibility", entity_id=record_id, action="UPDATE_STARTUP_FEASIBILITY", - detail="立项记录已更新", + detail=await _feasibility_audit_detail(db, "更新", record), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -173,6 +209,7 @@ async def delete_feasibility( if cra_scope and record.site_id not in cra_scope[0]: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") await _ensure_site_active(db, record.site_id) + record_detail = await _feasibility_audit_detail(db, "删除", record) await startup_crud.delete_feasibility(db, record) await audit_crud.log_action( db, @@ -180,7 +217,7 @@ async def delete_feasibility( entity_type="startup_feasibility", entity_id=record_id, action="DELETE_STARTUP_FEASIBILITY", - detail="立项记录已删除", + detail=record_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -210,7 +247,7 @@ async def create_ethics( entity_type="startup_ethics", entity_id=record.id, action="CREATE_STARTUP_ETHICS", - detail="伦理记录已创建", + detail=await _ethics_audit_detail(db, "创建", record), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -284,7 +321,7 @@ async def update_ethics( entity_type="startup_ethics", entity_id=record_id, action="UPDATE_STARTUP_ETHICS", - detail="伦理记录已更新", + detail=await _ethics_audit_detail(db, "更新", record), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -310,6 +347,7 @@ async def delete_ethics( if cra_scope and record.site_id not in cra_scope[0]: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") await _ensure_site_active(db, record.site_id) + record_detail = await _ethics_audit_detail(db, "删除", record) await startup_crud.delete_ethics(db, record) await audit_crud.log_action( db, @@ -317,7 +355,7 @@ async def delete_ethics( entity_type="startup_ethics", entity_id=record_id, action="DELETE_STARTUP_ETHICS", - detail="伦理记录已删除", + detail=record_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -347,7 +385,7 @@ async def create_kickoff( entity_type="startup_kickoff", entity_id=meeting.id, action="CREATE_KICKOFF_MEETING", - detail="启动会记录已创建", + detail=await _kickoff_audit_detail(db, "创建", meeting), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -421,7 +459,7 @@ async def update_kickoff( entity_type="startup_kickoff", entity_id=meeting_id, action="UPDATE_KICKOFF_MEETING", - detail="启动会记录已更新", + detail=await _kickoff_audit_detail(db, "更新", meeting), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -452,7 +490,7 @@ async def create_training_authorization( entity_type="training_authorization", entity_id=record.id, action="CREATE_TRAINING_AUTH", - detail="培训授权人员已创建", + detail=_training_audit_detail("创建", record), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -527,7 +565,7 @@ async def update_training_authorization( entity_type="training_authorization", entity_id=record_id, action="UPDATE_TRAINING_AUTH", - detail="培训授权人员已更新", + detail=_training_audit_detail("更新", record), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -553,6 +591,7 @@ async def delete_training_authorization( if cra_scope and record.site_name not in cra_scope[1]: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") await _ensure_site_name_active(db, study_id, record.site_name) + record_detail = _training_audit_detail("删除", record) await startup_crud.delete_training_authorization(db, record) await audit_crud.log_action( db, @@ -560,7 +599,7 @@ async def delete_training_authorization( entity_type="training_authorization", entity_id=record_id, action="DELETE_TRAINING_AUTH", - detail="培训授权人员已删除", + detail=record_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index e0bba359..491c097e 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -610,7 +610,6 @@ _SETUP_MODULE_LABELS = { } _SETUP_FIELD_LABELS = { - "id": "ID", "name": "名称", "milestone": "里程碑", "planDate": "计划日期", @@ -634,6 +633,8 @@ _SETUP_FIELD_LABELS = { "confirmDate": "确认日期", } +_SETUP_TECHNICAL_FIELDS = {"id"} + def _setup_value_text(value: Any) -> str: if value is None: @@ -690,6 +691,8 @@ def _setup_collect_diff_lines( new_dict = new_value if isinstance(new_value, dict) else {} keys = sorted(set(old_dict.keys()) | set(new_dict.keys())) for key in keys: + if key in _SETUP_TECHNICAL_FIELDS: + continue _setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines) return @@ -718,11 +721,7 @@ def _top_level_diff_summary(old: dict | None, new: dict | None) -> str: if not detail_lines: return module_summary - max_lines = 20 - visible_lines = detail_lines[:max_lines] - if len(detail_lines) > max_lines: - visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略") - return f"{module_summary}; 变更明细:" + ";".join(visible_lines) + return f"{module_summary}; 变更明细:" + ";".join(detail_lines) @router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))]) diff --git a/backend/app/api/v1/subject_histories.py b/backend/app/api/v1/subject_histories.py index 33ad91f0..fd51fa60 100644 --- a/backend/app/api/v1/subject_histories.py +++ b/backend/app/api/v1/subject_histories.py @@ -1,4 +1,5 @@ import uuid +import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +15,26 @@ from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead router = APIRouter() +def _compact_text(value: str | None, max_length: int = 30) -> str: + text = " ".join(str(value or "").split()) + if len(text) <= max_length: + return text + return f"{text[:max_length]}..." + + +def _history_audit_name(subject, history) -> str: + subject_no = getattr(subject, "subject_no", None) or "参与者" + record_date = getattr(history, "record_date", None) + content = _compact_text(getattr(history, "content", None)) + suffix = record_date.isoformat() if record_date else content + return f"{subject_no} / {suffix}" if suffix else subject_no + + +def _history_audit_detail(action: str, subject, history) -> str: + name = _history_audit_name(subject, history) + return json.dumps({"targetName": name, "description": f"{action}病史记录“{name}”"}, ensure_ascii=False) + + async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): study = await study_crud.get(db, study_id) if not study: @@ -56,7 +77,7 @@ async def create_history( entity_type="subject_history", entity_id=history.id, action="CREATE_SUBJECT_HISTORY", - detail="病史记录已创建", + detail=_history_audit_detail("创建", subject, history), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -131,7 +152,7 @@ async def update_history( entity_type="subject_history", entity_id=history_id, action="UPDATE_SUBJECT_HISTORY", - detail="病史记录已更新", + detail=_history_audit_detail("更新", subject, history), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -157,6 +178,7 @@ async def delete_history( history = await history_crud.get_history(db, history_id) if not history or history.study_id != study_id or history.subject_id != subject_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在") + history_detail = _history_audit_detail("删除", subject, history) await history_crud.delete_history(db, history) await audit_crud.log_action( db, @@ -164,7 +186,7 @@ async def delete_history( entity_type="subject_history", entity_id=history_id, action="DELETE_SUBJECT_HISTORY", - detail="病史记录已删除", + detail=history_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index 57cac496..33c9381f 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -1,3 +1,4 @@ +import json import uuid from fastapi import APIRouter, Depends, HTTPException, status @@ -15,6 +16,29 @@ from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate router = APIRouter() +def _format_date(value): + return value.isoformat() if value else None + + +async def _subject_audit_snapshot(db: AsyncSession, subject) -> dict: + site_name = None + if subject.site_id: + site = await site_crud.get_site(db, subject.site_id) + site_name = site.name if site else None + return { + "subject_no": subject.subject_no, + "site_name": site_name, + "status": subject.status, + "screening_date": _format_date(subject.screening_date), + "consent_date": _format_date(subject.consent_date), + "enrollment_date": _format_date(subject.enrollment_date), + "baseline_date": _format_date(subject.baseline_date), + "completion_date": _format_date(subject.completion_date), + "actual_medication_count": subject.actual_medication_count, + "drop_reason": subject.drop_reason, + } + + async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): study = await study_crud.get(db, study_id) if not study: @@ -50,13 +74,22 @@ async def create_subject( subject = await subject_crud.create_subject(db, study_id, subject_in) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + after_snapshot = await _subject_audit_snapshot(db, subject) await audit_crud.log_action( db, study_id=study_id, entity_type="subject", entity_id=subject.id, action="CREATE_SUBJECT", - detail=f"参与者 {subject.subject_no} 已创建", + detail=json.dumps( + { + "targetName": subject.subject_no, + "description": f"创建参与者 {subject.subject_no}", + "before": None, + "after": after_snapshot, + }, + ensure_ascii=False, + ), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -172,21 +205,29 @@ async def update_subject( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") await _ensure_subject_active(db, subject) old_status = subject.status + before_snapshot = await _subject_audit_snapshot(db, subject) updated = await subject_crud.update_subject(db, subject, subject_in) # 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。 if updated.baseline_date: await subject_crud.sync_visits_from_baseline(db, updated) updated = await subject_crud.sync_subject_status(db, updated) - detail = None - if updated.status != old_status: - detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {updated.status}" + after_snapshot = await _subject_audit_snapshot(db, updated) + description = f"变更参与者 {updated.subject_no} 状态" if updated.status != old_status else f"更新参与者 {updated.subject_no}" await audit_crud.log_action( db, study_id=study_id, entity_type="subject", entity_id=subject_id, - action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT", - detail=detail or "参与者已更新", + action="SUBJECT_STATUS_CHANGE" if updated.status != old_status else "UPDATE_SUBJECT", + detail=json.dumps( + { + "targetName": updated.subject_no, + "description": description, + "before": before_snapshot, + "after": after_snapshot, + }, + ensure_ascii=False, + ), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -211,6 +252,8 @@ async def delete_subject( cra_scope = await get_cra_site_scope(db, study_id, current_user) if cra_scope and subject.site_id not in cra_scope[0]: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + before_snapshot = await _subject_audit_snapshot(db, subject) + target_name = subject.subject_no await subject_crud.delete_subject(db, subject) await audit_crud.log_action( db, @@ -218,7 +261,15 @@ async def delete_subject( entity_type="subject", entity_id=subject_id, action="DELETE_SUBJECT", - detail=f"参与者 {subject_id} 已删除", + detail=json.dumps( + { + "targetName": target_name, + "description": f"删除参与者 {target_name}", + "before": before_snapshot, + "after": None, + }, + ensure_ascii=False, + ), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index 78f1c12c..553f1d70 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -1,4 +1,5 @@ import uuid +import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -15,6 +16,17 @@ from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, Vi router = APIRouter() +def _visit_audit_name(subject, visit) -> str: + subject_no = getattr(subject, "subject_no", None) or "参与者" + visit_code = getattr(visit, "visit_code", None) or "访视" + return f"{subject_no} / {visit_code}" + + +def _visit_audit_detail(action: str, subject, visit) -> str: + name = _visit_audit_name(subject, visit) + return json.dumps({"targetName": name, "description": f"{action}访视“{name}”"}, ensure_ascii=False) + + async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID): subject = await subject_crud.get_subject(db, subject_id) if not subject or subject.study_id != study_id: @@ -105,7 +117,7 @@ async def create_visit( entity_type="visit", entity_id=visit.id, action="CREATE_VISIT", - detail=f"访视 {visit.visit_code} 已创建", + detail=_visit_audit_detail("创建", subject, visit), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -158,7 +170,10 @@ async def create_early_termination( entity_type="visit", entity_id=visit.id, action="CREATE_EARLY_TERMINATION", - detail=f"参与者 {subject.subject_no} 已提前终止", + detail=json.dumps( + {"targetName": subject.subject_no, "description": f"创建参与者 {subject.subject_no} 提前终止访视"}, + ensure_ascii=False, + ), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -191,18 +206,27 @@ async def update_visit( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期") if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期") + old_status = visit.status updated = await visit_crud.update_visit(db, visit, visit_in) await subject_crud.sync_subject_status(db, subject) detail = None if visit_in.status: - detail = f"访视 {visit.visit_code} {visit.status} -> {visit_in.status}" + detail = json.dumps( + { + "targetName": _visit_audit_name(subject, updated), + "description": f"变更访视“{_visit_audit_name(subject, updated)}”状态", + "before": {"status": old_status}, + "after": {"status": visit_in.status}, + }, + ensure_ascii=False, + ) await audit_crud.log_action( db, study_id=study_id, entity_type="visit", entity_id=visit_id, action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT", - detail=detail or "访视已更新", + detail=detail or _visit_audit_detail("更新", subject, updated), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -226,6 +250,7 @@ async def delete_visit( visit = await visit_crud.get_visit(db, visit_id) if not visit or visit.subject_id != subject_id or visit.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在") + visit_detail = _visit_audit_detail("删除", subject, visit) await visit_crud.delete_visit(db, visit) await subject_crud.sync_subject_status(db, subject) await audit_crud.log_action( @@ -234,7 +259,7 @@ async def delete_visit( entity_type="visit", entity_id=visit_id, action="DELETE_VISIT", - detail=f"访视 {visit_id} 已删除", + detail=visit_detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/crud/audit.py b/backend/app/crud/audit.py index 6f71dc02..4581098e 100644 --- a/backend/app/crud/audit.py +++ b/backend/app/crud/audit.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from typing import Sequence @@ -65,8 +67,3 @@ async def list_logs( async def get_log(db: AsyncSession, log_id: uuid.UUID) -> AuditLog | None: result = await db.execute(select(AuditLog).where(AuditLog.id == log_id)) return result.scalar_one_or_none() - - -async def delete_log(db: AsyncSession, log: AuditLog) -> None: - await db.delete(log) - await db.commit() diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py index e8cae9ee..6acc093a 100644 --- a/backend/app/crud/study.py +++ b/backend/app/crud/study.py @@ -121,11 +121,10 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: from app.models.study_monitoring_strategy import StudyMonitoringStrategy from app.models.study_center_confirm import StudyCenterConfirm - # 按依赖关系顺序删除关联数据 - # 1. 删除审计日志 - await db.execute(sa_delete(AuditLog).where(AuditLog.study_id == study_id)) + # 按依赖关系顺序删除关联数据。审计日志必须保留,只解除项目外键,避免硬删除项目时破坏审计链。 + await db.execute(sa_update(AuditLog).where(AuditLog.study_id == study_id).values(study_id=None)) - # 2. 删除FAQ相关(可能有依赖关系) + # 1. 删除FAQ相关(可能有依赖关系) await db.execute(sa_delete(FaqReply).where(FaqReply.study_id == study_id)) await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id)) await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id)) diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 4d8c71ca..8c459631 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -56,23 +56,25 @@ def _audit_detail(before: dict | None, after: dict | None) -> str: def _doc_snapshot(doc: Document) -> dict: + status = doc.status.value if hasattr(doc.status, "value") else str(doc.status) return { "id": str(doc.id), "trial_id": str(doc.trial_id), "site_id": str(doc.site_id) if doc.site_id else None, "etmf_node_id": str(doc.etmf_node_id) if doc.etmf_node_id else None, "doc_no": doc.doc_no, - "status": str(doc.status), + "status": status, "current_effective_version_id": str(doc.current_effective_version_id) if doc.current_effective_version_id else None, } def _version_snapshot(version: DocumentVersion) -> dict: + status = version.status.value if hasattr(version.status, "value") else str(version.status) return { "id": str(version.id), "document_id": str(version.document_id), "version_no": version.version_no, - "status": str(version.status), + "status": status, "file_hash": version.file_hash, } diff --git a/backend/app/services/etmf_service.py b/backend/app/services/etmf_service.py index 704b9cd1..9cfb4e59 100644 --- a/backend/app/services/etmf_service.py +++ b/backend/app/services/etmf_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +import json from collections import defaultdict from typing import Iterable @@ -18,6 +19,17 @@ from app.schemas.document import DocumentCreate, DocumentSummary from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate +def _node_audit_snapshot(node: EtmfNode) -> dict[str, object | None]: + return { + "code": node.code, + "name": node.name, + "parent_id": str(node.parent_id) if node.parent_id else None, + "scope_type": node.scope_type, + "required": node.required, + "is_active": node.is_active, + } + + def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus: docs = list(documents) if not node.is_active: @@ -102,7 +114,10 @@ async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) - entity_type="ETMF_NODE", entity_id=node.id, action="ETMF_NODE_CREATED", - detail=f'{{"code":"{node.code}","name":"{node.name}"}}', + detail=json.dumps( + {"targetName": node.name, "after": _node_audit_snapshot(node)}, + ensure_ascii=False, + ), operator_id=current_user.id, operator_role=await get_operator_role_label(db, payload.study_id, current_user), ) @@ -124,14 +139,20 @@ async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpd parent = await etmf_crud.get_node(db, values["parent_id"]) if not parent or parent.study_id != node.study_id or parent.id == node.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不合法") + before = _node_audit_snapshot(node) await etmf_crud.update_node(db, node_id, values, commit=False) + updated_snapshot = {**before, **{key: value for key, value in values.items() if key in before}} db.add( AuditLog( study_id=node.study_id, entity_type="ETMF_NODE", entity_id=node.id, action="ETMF_NODE_UPDATED", - detail="{}", + detail=json.dumps( + {"targetName": updated_snapshot.get("name") or before.get("name"), "before": before, "after": updated_snapshot}, + ensure_ascii=False, + default=str, + ), operator_id=current_user.id, operator_role=await get_operator_role_label(db, node.study_id, current_user), ) diff --git a/backend/tests/test_admin_pm_permissions.py b/backend/tests/test_admin_pm_permissions.py index 1733fd7e..dc0e444d 100644 --- a/backend/tests/test_admin_pm_permissions.py +++ b/backend/tests/test_admin_pm_permissions.py @@ -1,4 +1,5 @@ import uuid +import json from dataclasses import dataclass import pytest @@ -7,11 +8,11 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.api.v1.api_permissions import get_my_study_api_permissions, update_study_api_permissions -from app.api.v1.members import update_member +from app.api.v1.members import add_member, update_member from app.api.v1.permission_monitoring import resolve_monitoring_scope from app.api.v1.system_permissions import list_system_permissions from app.core.deps import require_admin_or_any_project_pm, require_system_permission -from app.schemas.member import StudyMemberUpdate +from app.schemas.member import StudyMemberCreate, StudyMemberUpdate @dataclass @@ -40,6 +41,26 @@ async def _seed_user(db: AsyncSession, user_id: uuid.UUID, *, is_admin: bool = F ) +async def _seed_named_user(db: AsyncSession, user_id: uuid.UUID, full_name: str, *, is_admin: bool = False) -> None: + await db.execute( + text( + """ + INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) + VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) + """ + ), + { + "id": str(user_id), + "email": f"{user_id.hex}@example.com", + "password_hash": "hash", + "full_name": full_name, + "clinical_department": "Clinical", + "is_admin": is_admin, + "status": "ACTIVE", + }, + ) + + async def _seed_study(db: AsyncSession, study_id: uuid.UUID, code: str) -> None: await db.execute( text( @@ -289,3 +310,77 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession): ) assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_project_member_add_audit_uses_member_display_name(db_session: AsyncSession): + study_id = uuid.uuid4() + admin_id = uuid.uuid4() + target_user_id = uuid.uuid4() + await _seed_study(db_session, study_id, "MEMBER-AUDIT-NAME") + await _seed_user(db_session, admin_id, is_admin=True) + await _seed_named_user(db_session, target_user_id, "张三") + await db_session.commit() + + await add_member( + study_id=study_id, + member_in=StudyMemberCreate(user_id=target_user_id, role_in_study="CRA"), + current_user=UserStub(id=admin_id, is_admin=True), + db=db_session, + ) + + row = ( + await db_session.execute( + text( + """ + SELECT detail + FROM audit_logs + WHERE study_id = :study_id AND action = 'PROJECT_MEMBER_ADDED' + ORDER BY created_at DESC + LIMIT 1 + """ + ), + {"study_id": str(study_id)}, + ) + ).one() + detail = json.loads(row.detail) + assert detail["targetName"] == "张三" + assert str(target_user_id) not in detail["targetName"] + + +@pytest.mark.asyncio +async def test_project_member_update_audit_uses_member_display_name(db_session: AsyncSession): + study_id = uuid.uuid4() + admin_id = uuid.uuid4() + target_user_id = uuid.uuid4() + await _seed_study(db_session, study_id, "MEMBER-UPDATE-AUDIT-NAME") + await _seed_user(db_session, admin_id, is_admin=True) + await _seed_named_user(db_session, target_user_id, "李四") + member_id = await _seed_member_return_id(db_session, study_id, target_user_id, "CRA") + await db_session.commit() + + await update_member( + study_id=study_id, + member_id=member_id, + member_in=StudyMemberUpdate(role_in_study="CTA"), + current_user=UserStub(id=admin_id, is_admin=True), + db=db_session, + ) + + row = ( + await db_session.execute( + text( + """ + SELECT detail + FROM audit_logs + WHERE study_id = :study_id AND action = 'PROJECT_MEMBER_UPDATED' + ORDER BY created_at DESC + LIMIT 1 + """ + ), + {"study_id": str(study_id)}, + ) + ).one() + detail = json.loads(row.detail) + assert detail["targetName"] == "李四" + assert str(target_user_id) not in detail["targetName"] diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index e8da4d92..0ded8ed5 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -588,6 +588,155 @@ def test_audit_export_event_uses_export_permission(): assert "require_study_member()" not in event_chunk +def test_audit_export_event_does_not_trust_client_detail(): + """导出审计事件的业务描述应由后端生成,不能信任客户端 detail。""" + route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py" + source = route_path.read_text() + event_chunk = source[source.index("async def create_audit_event") :] + + assert "detail=payload.detail" not in event_chunk + assert "entity_type=payload.entity_type" not in event_chunk + assert "entity_id=payload.entity_id" not in event_chunk + assert "_build_export_audit_detail" in event_chunk + + +def test_audit_logs_do_not_expose_delete_endpoint(): + """审计日志不能提供单条删除接口,避免破坏审计不可篡改性。""" + route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py" + source = route_path.read_text() + + assert "@router.delete" not in source + assert "delete_audit_log" not in source + + +def test_etmf_audit_details_are_structured_json(): + """eTMF 审计详情不能手拼 JSON 或写空对象。""" + service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "etmf_service.py" + source = service_path.read_text() + + assert "json.dumps" in source + assert "detail=f'{{" not in source + assert 'detail="{}"' not in source + + +def test_setup_config_diff_summary_keeps_all_business_changes_and_skips_ids(): + """立项配置审计明细应完整保留业务字段,跳过行内技术 ID。""" + route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "studies.py" + source = route_path.read_text() + summary_chunk = source[source.index("def _setup_collect_diff_lines") : source.index("@router.post")] + + assert '_SETUP_TECHNICAL_FIELDS = {"id"}' in source + assert "if key in _SETUP_TECHNICAL_FIELDS:" in summary_chunk + assert "已省略" not in summary_chunk + assert "max_lines" not in summary_chunk + + +def test_subject_audit_uses_structured_business_snapshot(): + """参与者审计应把编号放入对象,把业务变化放入详情。""" + route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "subjects.py" + source = route_path.read_text() + create_subject_source = source[source.index("async def create_subject") : source.index("@router.get")] + update_subject_source = source[source.index("async def update_subject") : source.index("@router.delete")] + delete_subject_source = source[source.index("async def delete_subject") :] + + assert "_subject_audit_snapshot" in source + assert '"targetName": subject.subject_no' in create_subject_source + assert '"description": f"创建参与者 {subject.subject_no}"' in create_subject_source + assert '"targetName": updated.subject_no' in update_subject_source + assert '"description": description' in update_subject_source + assert '"targetName": target_name' in delete_subject_source + assert '"description": f"删除参与者 {target_name}"' in delete_subject_source + assert '"before": before_snapshot' in delete_subject_source + assert 'detail=f"参与者 {subject.subject_no} 已创建"' not in source + assert 'f"参与者 {updated.subject_no} 状态' not in source + assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source + + +def test_faq_audit_uses_business_descriptions(): + """医学咨询审计详情应使用业务描述,避免 FAQ 技术文案。""" + 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() + + 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 + + +def test_domain_audits_use_business_object_names(): + """药品、设备、访视、AE、费用、立项等审计应记录具体业务对象。""" + api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" + sources = { + name: (api_dir / name).read_text() + for name in [ + "drug_shipments.py", + "material_equipments.py", + "visits.py", + "aes.py", + "precautions.py", + "fees_contracts.py", + "startup.py", + "subject_histories.py", + ] + } + + assert "_shipment_audit_description" in sources["drug_shipments.py"] + 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"] + assert "_ethics_audit_detail" in sources["startup.py"] + assert "_kickoff_audit_detail" in sources["startup.py"] + assert "_training_audit_detail" in sources["startup.py"] + assert "_history_audit_detail" in sources["subject_histories.py"] + + forbidden_fragments = [ + 'detail=f"访视 {visit_id} 已删除"', + 'detail=f"AE {ae.id} 已创建"', + 'detail=f"AE {ae_id} 已删除"', + 'detail=f"设备 {item.id} 已创建"', + 'detail=f"设备 {equipment_id} 已更新"', + 'detail=f"设备 {equipment_id} 已删除"', + 'detail=f"注意事项 {precaution_id} 已更新"', + 'detail=f"注意事项 {precaution_id} 已删除"', + 'detail="合同费用已创建"', + 'detail="合同费用已更新"', + 'detail="合同费用已删除"', + 'detail="合同费用分期已创建"', + 'detail="合同费用分期已更新"', + 'detail="合同费用分期已删除"', + 'detail="立项记录已创建"', + 'detail="立项记录已更新"', + 'detail="立项记录已删除"', + 'detail="伦理记录已创建"', + 'detail="伦理记录已更新"', + 'detail="伦理记录已删除"', + 'detail="启动会记录已创建"', + 'detail="启动会记录已更新"', + 'detail="培训授权人员已创建"', + 'detail="培训授权人员已更新"', + 'detail="培训授权人员已删除"', + 'detail="病史记录已创建"', + 'detail="病史记录已更新"', + 'detail="病史记录已删除"', + ] + combined_source = "\n".join(sources.values()) + for fragment in forbidden_fragments: + assert fragment not in combined_source + + def test_user_role_contains_current_project_roles(): assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES) diff --git a/backend/tests/test_study_delete.py b/backend/tests/test_study_delete.py index 370de375..118d1712 100644 --- a/backend/tests/test_study_delete.py +++ b/backend/tests/test_study_delete.py @@ -8,12 +8,14 @@ from app.crud import study as study_crud class _FakeDb: def __init__(self): self.tables = [] + self.operations = [] self.committed = False async def execute(self, stmt): table = getattr(getattr(stmt, "table", None), "name", None) if table: self.tables.append(table) + self.operations.append((getattr(stmt, "__visit_name__", ""), table)) async def commit(self): self.committed = True @@ -29,3 +31,13 @@ async def test_delete_study_removes_contract_fee_payments_before_contract_fees() assert "contract_fees" in db.tables assert db.tables.index("contract_fee_payments") < db.tables.index("contract_fees") assert db.committed is True + + +@pytest.mark.asyncio +async def test_delete_study_preserves_audit_logs(): + db = _FakeDb() + + await study_crud.delete(db, uuid.uuid4()) + + assert ("delete", "audit_logs") not in db.operations + assert ("update", "audit_logs") in db.operations diff --git a/frontend/src/api/auditLogs.ts b/frontend/src/api/auditLogs.ts index 7fc8488d..411c04b2 100644 --- a/frontend/src/api/auditLogs.ts +++ b/frontend/src/api/auditLogs.ts @@ -1,11 +1,8 @@ -import { apiDelete, apiGet, apiPost } from "./axios"; +import { apiGet, apiPost } from "./axios"; import type { ApiListResponse } from "../types/api"; export const fetchAuditLogs = (studyId: string, params?: Record) => apiGet>(`/api/v1/studies/${studyId}/audit-logs/`, { params }); -export const deleteAuditLog = (studyId: string, logId: string) => - apiDelete(`/api/v1/studies/${studyId}/audit-logs/${logId}`); - export const createAuditEvent = (studyId: string, payload: Record) => apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload); diff --git a/frontend/src/audit/auditNormalize.test.ts b/frontend/src/audit/auditNormalize.test.ts index 2d46559c..0bf889a0 100644 --- a/frontend/src/audit/auditNormalize.test.ts +++ b/frontend/src/audit/auditNormalize.test.ts @@ -16,10 +16,34 @@ describe("normalizeAuditEvent", () => { {} ); - expect(event.diffText).toEqual(["FAQ 分类已删除"]); + expect(event.diffText).toEqual(["删除分类(历史日志未记录分类名称)"]); expect(event.targetName).toBe(""); }); + it("renders legacy FAQ category actions as business descriptions", () => { + const event = normalizeAuditEvent( + { + action: "CREATE_FAQ_CATEGORY", + detail: JSON.stringify({ + targetName: "入排标准", + description: "FAQ 分类 入排标准 已创建", + }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "faq_category", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T11:16:04Z", + }, + {} + ); + + expect(event.eventLabel).toBe("新增医学咨询分类"); + expect(event.targetTypeLabel).toBe("医学咨询分类"); + expect(event.targetName).toBe("入排标准"); + expect(event.diffText).toEqual(["创建“入排标准”分类"]); + expect(event.actionText).toBe("创建“入排标准”分类"); + }); + it("keeps readable shipment identifiers but hides UUID-only shipment identifiers", () => { const event = normalizeAuditEvent( { @@ -34,7 +58,436 @@ describe("normalizeAuditEvent", () => { {} ); - expect(event.diffText).toEqual(["药品运输已创建"]); + expect(event.diffText).toEqual(["创建药品流向(历史日志未记录对象信息)"]); expect(event.targetName).toBe(""); }); + + it("moves legacy subject numbers into the target column", () => { + const event = normalizeAuditEvent( + { + action: "CREATE_SUBJECT", + detail: "参与者 S001 已创建", + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "subject", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-08T15:44:48Z", + }, + {} + ); + + expect(event.eventLabel).toBe("新增参与者"); + expect(event.targetTypeLabel).toBe("参与者"); + expect(event.targetName).toBe("S001"); + expect(event.diffText).toEqual(["创建参与者 S001"]); + expect(event.actionText).toBe("创建参与者 S001"); + }); + + + it("maps legacy project member target UUIDs to user display names", () => { + const userId = "169ede81-f2cb-4b44-84c5-c11111111111"; + const event = normalizeAuditEvent( + { + action: "PROJECT_MEMBER_UPDATED", + detail: JSON.stringify({ + targetName: userId, + before: { role_in_study: "CRA" }, + after: { role_in_study: "PM" }, + }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "study_member", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T10:27:02Z", + }, + { [userId]: "张三" } + ); + + expect(event.targetName).toBe("张三"); + expect(event.targetName).not.toContain(userId); + }); + + it("maps user UUIDs inside audit detail lines to display names", () => { + const userId = "94dccdc4-5592-4e59-b743-c33333333333"; + const event = normalizeAuditEvent( + { + action: "SITE_UPDATED", + detail: JSON.stringify({ + targetName: "重庆市璧山区人民医院", + before: { contact: userId }, + after: { contact: "汤成泳" }, + }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "site", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T10:27:02Z", + }, + { [userId]: "李四" } + ); + + expect(event.diffText).toEqual(["负责人:李四 -> 汤成泳"]); + }); + + it("does not expose unmapped UUID-only target names", () => { + const event = normalizeAuditEvent( + { + action: "PROJECT_MEMBER_ADDED", + detail: JSON.stringify({ + targetName: "307e9a37-530c-4894-8d19-f22222222222", + after: { role_in_study: "PM" }, + }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "study_member", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T10:27:02Z", + }, + {} + ); + + expect(event.targetName).toBe(""); + }); + + it("translates unknown CRUD action names into readable Chinese labels", () => { + const event = normalizeAuditEvent( + { + action: "CREATE_FAQ_ITEM", + detail: JSON.stringify({ targetName: "药物保存咨询" }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "faq_item", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T10:27:02Z", + }, + {} + ); + + expect(event.eventLabel).toBe("新增医学咨询问题"); + expect(event.actionText).toBe("新增了医学咨询问题"); + expect(event.eventLabel).not.toContain("CREATE_FAQ_ITEM"); + expect(event.actionText).not.toContain("CREATE_FAQ_ITEM"); + }); + + it("uses FAQ question titles in generic structured FAQ descriptions", () => { + const event = normalizeAuditEvent( + { + action: "CREATE_FAQ_ITEM", + detail: JSON.stringify({ + targetName: "试验期间是否允许接种疫苗?", + description: "创建医学咨询问题", + }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "faq_item", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-09T08:55:44Z", + }, + {} + ); + + expect(event.targetName).toBe("试验期间是否允许接种疫苗?"); + expect(event.diffText).toEqual(["创建医学咨询问题“试验期间是否允许接种疫苗?”"]); + expect(event.actionText).toBe("创建医学咨询问题“试验期间是否允许接种疫苗?”"); + }); + + it("keeps action text focused on the business operation when target names exist", () => { + const event = normalizeAuditEvent( + { + action: "CREATE_FAQ_ITEM", + detail: JSON.stringify({ targetName: "药物保存咨询" }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "faq_item", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T10:27:02Z", + }, + {} + ); + + expect(event.eventLabel).toBe("新增医学咨询问题"); + expect(event.actionText).toBe("新增了医学咨询问题"); + expect(event.targetName).toBe("药物保存咨询"); + }); + + it("uses changed fields as action text when the target name duplicates the operation", () => { + const event = normalizeAuditEvent( + { + action: "UPDATE_FAQ_CATEGORY", + detail: JSON.stringify({ + targetName: "医学咨询分类", + before: { name: "旧分类" }, + after: { name: "新分类" }, + }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "faq_category", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T10:27:02Z", + }, + {} + ); + + expect(event.eventLabel).toBe("更新医学咨询分类"); + expect(event.actionText).toBe("名称:旧分类 -> 新分类"); + }); + + it("keeps setup module summary out of change detail lines", () => { + const event = normalizeAuditEvent( + { + action: "SAVE_STUDY_SETUP_CONFIG", + detail: JSON.stringify({ + description: + "变更模块:项目里程碑; 变更明细:项目里程碑 / 参研中心调研+方案讨论会 / 耗时(天):30 -> 28;项目里程碑 / 参研中心调研+方案讨论会 / 计划日期:2026-06-01 -> 2026-06-03;项目里程碑 / 参研中心调研+方案讨论会 / 开始日期:2026-06-01 -> 2026-06-03", + }), + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "study_setup_config", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-28T09:15:23Z", + }, + {} + ); + + expect(event.actionText).toBe("变更模块:项目里程碑"); + expect(event.diffText).toEqual([ + "项目里程碑 / 参研中心调研+方案讨论会 / 耗时(天):30 -> 28", + "项目里程碑 / 参研中心调研+方案讨论会 / 计划日期:2026-06-01 -> 2026-06-03", + "项目里程碑 / 参研中心调研+方案讨论会 / 开始日期:2026-06-01 -> 2026-06-03", + ]); + }); + + it("hides legacy setup technical ID lines and omission placeholders", () => { + const event = normalizeAuditEvent( + { + action: "UPDATE_SETUP_CONFIG", + detail: + "变更模块:项目里程碑; 变更明细:项目里程碑 / 参研中心调研+方案讨论会 / ID:未填写 -> 1778477970865_08roj;项目里程碑 / 参研中心调研+方案讨论会 / 名称:未填写 -> 参研中心调研+方案讨论会;其余 115 项变更已省略", + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "study_setup_config", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-28T09:15:23Z", + }, + {} + ); + + expect(event.diffText).toEqual([ + "项目里程碑 / 参研中心调研+方案讨论会 / 名称:未填写 -> 参研中心调研+方案讨论会", + ]); + expect((event.diffText || []).join(" ")).not.toContain("ID"); + expect((event.diffText || []).join(" ")).not.toContain("已省略"); + }); + + it("renders document version audit details in business language", () => { + const event = normalizeAuditEvent( + { + action: "VERSION_CREATED", + detail: JSON.stringify({ + before: null, + after: { + id: "307e9a37-530c-4894-8d19-f22222222222", + document_id: "169ede81-f2cb-4b44-84c5-c11111111111", + version_no: "1.0", + status: "EFFECTIVE", + file_hash: "95ac0061c81793d533906155921871182076c2dd5bbdd1be0b0e09ebdff17894", + }, + }), + operator_id: "operator-1", + operator_role: "PM", + entity_type: "DOCUMENT_VERSION", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-26T09:42:21Z", + }, + {} + ); + + expect(event.eventLabel).toBe("新增文档版本"); + expect(event.targetTypeLabel).toBe("文档版本"); + expect(event.actionText).toBe("记录标识:未填写 -> 已生成"); + expect(event.diffText).toEqual([ + "记录标识:未填写 -> 已生成", + "所属文档:未填写 -> 已关联文档", + "版本号:未填写 -> V1.0", + "版本状态:未填写 -> 已生效", + "文件校验值:未填写 -> 已记录校验值", + ]); + const detailText = (event.diffText || []).join(" "); + expect(detailText).not.toContain("字段(id)"); + expect(detailText).not.toContain("未识别对象"); + expect(detailText).not.toContain("DocumentVersionStatus"); + expect(detailText).not.toContain("95ac0061"); + }); + + it("renders enum class-prefixed document version statuses in business language", () => { + const event = normalizeAuditEvent( + { + action: "VERSION_CREATED", + detail: JSON.stringify({ + before: null, + after: { + status: "DocumentVersionStatus.EFFECTIVE", + }, + }), + operator_id: "operator-1", + operator_role: "PM", + entity_type: "DOCUMENT_VERSION", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-26T09:42:21Z", + }, + {} + ); + + expect(event.diffText).toEqual(["版本状态:未填写 -> 已生效"]); + expect((event.diffText || []).join(" ")).not.toContain("DocumentVersionStatus"); + }); + + it("renders structured subject delete audits with subject business context", () => { + const event = normalizeAuditEvent( + { + action: "DELETE_SUBJECT", + detail: JSON.stringify({ + targetName: "SUBJ-001", + description: "删除参与者 SUBJ-001", + before: { + subject_no: "SUBJ-001", + site_name: "北京协和医院", + status: "SCREENING", + consent_date: "2026-05-01", + baseline_date: null, + }, + after: null, + }), + operator_id: "operator-1", + operator_role: "PM", + entity_type: "subject", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-26T09:42:21Z", + }, + {} + ); + + expect(event.eventLabel).toBe("删除参与者"); + expect(event.targetName).toBe("SUBJ-001"); + expect(event.actionText).toBe("删除参与者 SUBJ-001"); + expect(event.diffText).toContain("参与者编号:SUBJ-001 -> 未填写"); + expect(event.diffText).toContain("中心名称:北京协和医院 -> 未填写"); + expect(event.diffText).toContain("知情同意日期:2026-05-01 -> 未填写"); + expect((event.diffText || []).join(" ")).not.toContain("字段(subject_no)"); + }); + + it("explains legacy subject delete logs that only stored UUIDs", () => { + const event = normalizeAuditEvent( + { + action: "DELETE_SUBJECT", + detail: "参与者 307e9a37-530c-4894-8d19-f22222222222 已删除", + operator_id: "operator-1", + operator_role: "PM", + entity_type: "subject", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-26T09:42:21Z", + }, + {} + ); + + expect(event.diffText).toEqual(["删除参与者(历史日志未记录参与者编号)"]); + expect(event.actionText).toBe("删除参与者(历史日志未记录参与者编号)"); + }); + + it("renders legacy drug and equipment audit details with business context", () => { + const shipmentEvent = normalizeAuditEvent( + { + action: "CREATE_DRUG_SHIPMENT", + detail: JSON.stringify({ + targetName: "TRACK-001", + description: "药品运输 TRACK-001 已创建", + }), + operator_id: "operator-1", + operator_role: "PM", + entity_type: "drug_shipment", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-26T09:37:58Z", + }, + {} + ); + const equipmentEvent = normalizeAuditEvent( + { + action: "CREATE_MATERIAL_EQUIPMENT", + detail: "设备 307e9a37-530c-4894-8d19-f22222222222 已创建", + operator_id: "operator-1", + operator_role: "PM", + entity_type: "material_equipment", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-26T09:36:19Z", + }, + {} + ); + + expect(shipmentEvent.targetName).toBe("TRACK-001"); + expect(shipmentEvent.actionText).toBe("创建药品流向“TRACK-001”"); + expect(equipmentEvent.actionText).toBe("创建设备(历史日志未记录设备名称)"); + expect(equipmentEvent.targetName).toBe(""); + }); + + it("marks generic legacy fee and startup audit details as missing object context", () => { + const feeEvent = normalizeAuditEvent( + { + action: "CREATE_CONTRACT_FEE", + detail: "合同费用已创建", + operator_id: "operator-1", + operator_role: "PM", + entity_type: "contract_fee", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-26T09:36:19Z", + }, + {} + ); + const startupEvent = normalizeAuditEvent( + { + action: "UPDATE_STARTUP_FEASIBILITY", + detail: "立项记录已更新", + operator_id: "operator-1", + operator_role: "PM", + entity_type: "startup_feasibility", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-05-26T09:33:41Z", + }, + {} + ); + + expect(feeEvent.actionText).toBe("创建合同费用(历史日志未记录对象信息)"); + expect(startupEvent.actionText).toBe("更新立项记录(历史日志未记录对象信息)"); + }); + + it("renders high-value non-CRUD setup and lock actions in business language", () => { + const lockEvent = normalizeAuditEvent( + { + action: "LOCK_STUDY", + detail: "项目已锁定:真实世界研究 (RWS-001)", + operator_id: "operator-1", + operator_role: "ADMIN", + entity_type: "study", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T10:27:02Z", + }, + {} + ); + const rollbackEvent = normalizeAuditEvent( + { + action: "ROLLBACK_SETUP_CONFIG", + detail: "立项配置已回滚并替换当前发布为 v3,草稿分支已切换到对应分支基线", + operator_id: "operator-1", + operator_role: "PM", + entity_type: "study_setup_config", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + created_at: "2026-06-04T10:27:02Z", + }, + {} + ); + + expect(lockEvent.eventLabel).toBe("锁定项目"); + expect(lockEvent.actionText).toBe("锁定了项目"); + expect(rollbackEvent.eventLabel).toBe("回滚立项配置"); + expect(rollbackEvent.actionText).toBe("回滚了立项配置"); + }); }); diff --git a/frontend/src/audit/export/auditExportFormatter.test.ts b/frontend/src/audit/export/auditExportFormatter.test.ts new file mode 100644 index 00000000..65848d67 --- /dev/null +++ b/frontend/src/audit/export/auditExportFormatter.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { formatAuditRow } from "./auditExportFormatter"; +import type { AuditEvent } from ".."; + +const baseEvent: AuditEvent = { + eventType: "DOCUMENT_UPDATED", + eventLabel: "更新文档", + actorId: "operator-1", + actorName: "张三", + actorRole: "PM", + actorRoleLabel: "项目经理", + targetType: "DOCUMENT", + targetTypeLabel: "文档", + targetId: "doc-001", + actionText: "更新了文档", + result: "SUCCESS", + resultLabel: "成功", + timestamp: "2026-06-04T10:27:02Z", +}; + +describe("formatAuditRow", () => { + it("keeps target identifiers inside a complete Chinese parenthesis pair", () => { + const row = formatAuditRow(baseEvent); + + expect(row.description).toContain("对象:文档(doc-001)"); + expect(row.description).not.toContain("doc-001)"); + }); +}); diff --git a/frontend/src/audit/export/auditExportFormatter.ts b/frontend/src/audit/export/auditExportFormatter.ts index 15d07792..50de3ca9 100644 --- a/frontend/src/audit/export/auditExportFormatter.ts +++ b/frontend/src/audit/export/auditExportFormatter.ts @@ -19,9 +19,11 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => { const descriptionParts: string[] = []; descriptionParts.push(`${event.actorName} ${event.actionText}`); if (event.targetTypeLabel || event.targetName || event.targetId) { + const targetIdentifier = event.targetName || event.targetId; descriptionParts.push( `${TEXT.audit.objectLabel}${event.targetTypeLabel || ""}${ - event.targetName ? `(${event.targetName})` : `(${event.targetId}`})` + targetIdentifier ? `(${targetIdentifier})` : "" + }` ); } if (event.diffText?.length) { diff --git a/frontend/src/audit/index.ts b/frontend/src/audit/index.ts index c71448ea..a7755c8f 100644 --- a/frontend/src/audit/index.ts +++ b/frontend/src/audit/index.ts @@ -1,4 +1,4 @@ -import { auditDict } from "./auditDict"; +import { auditDict as localizedAuditDict } from "./auditDict"; import { roleDict, getDictLabel, statusDict } from "../dictionaries"; import { TEXT } from "../locales"; @@ -45,6 +45,94 @@ const entityTypeLabelMap: Record = { DOCUMENT_VERSION: "文档版本", DISTRIBUTION: "文档分发", ACKNOWLEDGEMENT: "文档签收", + ETMF_NODE: "eTMF 目录", +}; + +const auditActionVerbMap: Record = { + CREATE: { labelPrefix: "新增", actionPrefix: "新增了" }, + UPDATE: { labelPrefix: "更新", actionPrefix: "更新了" }, + DELETE: { labelPrefix: "删除", actionPrefix: "删除了" }, +}; + +const auditActionTargetMap: Record = { + FAQ_CATEGORY: "医学咨询分类", + FAQ_ITEM: "医学咨询问题", + FAQ_REPLY: "医学咨询回复", + PRECAUTION: "注意事项", + STARTUP_FEASIBILITY: "立项记录", + STARTUP_ETHICS: "伦理记录", + KICKOFF_MEETING: "启动会", + TRAINING_AUTH: "培训授权", + SITE: "中心", + SUBJECT: "参与者", + VISIT: "访视", + AE: "不良事件", + SUBJECT_PD: "PD 记录", + SUBJECT_HISTORY: "病史记录", + DRUG_SHIPMENT: "药品流向", + MATERIAL_EQUIPMENT: "设备", + MONITORING_VISIT_ISSUE: "监查访视问题", + CONTRACT_FEE: "合同费用条目", + CONTRACT_FEE_PAYMENT: "合同费用回款", + ATTACHMENT: "附件", + SETUP_CONFIG_VERSION: "立项配置版本", + PROJECT_MILESTONE: "项目里程碑", + DOCUMENT: "文档", + DOCUMENT_VERSION: "文档版本", + DISTRIBUTION: "文档分发", + ACKNOWLEDGEMENT: "文档签收", +}; + +const explicitAuditActionMap: Record = { + LOCK_STUDY: { label: "锁定项目", actionText: "锁定了项目", targetLabel: "项目" }, + UNLOCK_STUDY: { label: "解锁项目", actionText: "解锁了项目", targetLabel: "项目" }, + ROLLBACK_SETUP_CONFIG: { label: "回滚立项配置", actionText: "回滚了立项配置", targetLabel: "立项配置" }, + CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT: { label: "切换立项配置草稿分支", actionText: "切换了立项配置草稿分支", targetLabel: "立项配置" }, + CLEAR_SETUP_CONFIG_DRAFT: { label: "清空立项配置草稿", actionText: "清空了立项配置草稿", targetLabel: "立项配置" }, + REFILL_SETUP_CONFIG_DRAFT: { label: "回填立项配置草稿", actionText: "从当前发布版本回填了立项配置草稿", targetLabel: "立项配置" }, + MERGE_SETUP_CONFIG_TO_MAIN: { label: "合并立项配置主版本", actionText: "合并了立项配置主版本", targetLabel: "立项配置" }, + DELETE_SETUP_CONFIG_VERSION: { label: "删除立项配置版本", actionText: "删除了立项配置版本", targetLabel: "立项配置版本" }, + CREATE_EARLY_TERMINATION: { label: "创建提前终止访视", actionText: "创建了提前终止访视", targetLabel: "访视" }, + VISIT_STATUS_CHANGE: { label: "访视状态变更", actionText: "变更了访视状态", targetLabel: "访视" }, + SUBJECT_STATUS_CHANGE: { label: "参与者状态变更", actionText: "变更了参与者状态", targetLabel: "参与者" }, + AE_STATUS_CHANGE: { label: "AE 状态变更", actionText: "变更了 AE 状态", targetLabel: "不良事件" }, + SITE_STATUS_CHANGED: { label: "中心状态变更", actionText: "变更了中心状态", targetLabel: "中心" }, + UPLOAD_FILE: { label: "上传附件", actionText: "上传了附件", targetLabel: "附件" }, + IMPORT_MONITORING_VISIT_ISSUE: { label: "导入监查访视问题", actionText: "导入了监查访视问题", targetLabel: "监查访视问题" }, + DOCUMENT_CREATED: { label: "新增文档", actionText: "新增了文档", targetLabel: "文档" }, + DOCUMENT_UPDATED: { label: "更新文档", actionText: "更新了文档", targetLabel: "文档" }, + DOCUMENT_ARCHIVED: { label: "归档文档", actionText: "归档了文档", targetLabel: "文档" }, + VERSION_CREATED: { label: "新增文档版本", actionText: "新增了文档版本", targetLabel: "文档版本" }, + VERSION_DELETED: { label: "删除文档版本", actionText: "删除了文档版本", targetLabel: "文档版本" }, + DISTRIBUTION_CREATED: { label: "发起文档分发", actionText: "发起了文档分发", targetLabel: "文档分发" }, + ACK_CREATED: { label: "提交文档回执", actionText: "提交了文档回执", targetLabel: "文档签收" }, + ETMF_NODE_CREATED: { label: "新增 eTMF 目录", actionText: "新增了 eTMF 目录", targetLabel: "eTMF 目录" }, + ETMF_NODE_UPDATED: { label: "更新 eTMF 目录", actionText: "更新了 eTMF 目录", targetLabel: "eTMF 目录" }, +}; + +export const auditDict: Record = { + ...localizedAuditDict, + ...explicitAuditActionMap, +}; + +const buildReadableAuditDict = (action?: string, entityType?: string) => { + const actionKey = String(action || "").trim(); + if (explicitAuditActionMap[actionKey]) return explicitAuditActionMap[actionKey]; + const match = actionKey.match(/^(CREATE|UPDATE|DELETE)_(.+)$/); + if (match) { + const verb = auditActionVerbMap[match[1]]; + const target = auditActionTargetMap[match[2]] || toReadableEntityType(entityType); + return { + label: `${verb.labelPrefix}${target}`, + actionText: `${verb.actionPrefix}${target}`, + targetLabel: target, + }; + } + return { + label: actionKey ? TEXT.audit.eventFallback : TEXT.audit.eventFallback, + actionText: TEXT.audit.actionFallback, + targetLabel: toReadableEntityType(entityType), + }; }; const setupModuleLabelMap: Record = { @@ -75,6 +163,86 @@ const normalizeSetupModuleText = (raw: string): string => { const normalizeLegacyDetailLine = (line: string): string => { const text = String(line || "").trim(); if (!text) return ""; + if (/^其余\s+\d+\s+项变更已省略$/.test(text)) return ""; + if (/\/\s*ID[::]/.test(text) || /^ID[::]/.test(text)) return ""; + const legacyFaqCategoryActionMatch = text.match(/^FAQ\s*分类\s+(.+?)\s+已(创建|更新|删除)$/i); + if (legacyFaqCategoryActionMatch) { + const categoryName = legacyFaqCategoryActionMatch[1].trim(); + const verb = legacyFaqCategoryActionMatch[2]; + const actionVerb = verb === "创建" ? "创建" : verb === "更新" ? "更新" : "删除"; + return isUuidText(categoryName) ? `${actionVerb}分类(历史日志未记录分类名称)` : `${actionVerb}“${categoryName}”分类`; + } + if (/^FAQ\s*已创建$/i.test(text)) return "创建医学咨询问题"; + if (/^FAQ\s*updated$/i.test(text) || /^FAQ\s*已更新$/i.test(text)) return "更新医学咨询问题"; + if (/^FAQ\s*已删除$/i.test(text)) return "删除医学咨询问题"; + if (/^FAQ\s*已回复$/i.test(text)) return "回复医学咨询问题"; + if (/^FAQ\s*回复已删除$/i.test(text)) return "删除医学咨询回复"; + const legacyDrugShipmentMatch = text.match(/^药品运输\s+(.+?)\s+已(创建|更新|删除)$/); + if (legacyDrugShipmentMatch) { + const name = legacyDrugShipmentMatch[1].trim(); + const verb = legacyDrugShipmentMatch[2]; + return isUuidText(name) ? `${verb}药品流向(历史日志未记录对象信息)` : `${verb}药品流向“${name}”`; + } + const legacyEquipmentMatch = text.match(/^设备\s+(.+?)\s+已(创建|更新|删除)$/); + if (legacyEquipmentMatch) { + const name = legacyEquipmentMatch[1].trim(); + const verb = legacyEquipmentMatch[2]; + return isUuidText(name) ? `${verb}设备(历史日志未记录设备名称)` : `${verb}设备“${name}”`; + } + const legacyPrecautionMatch = text.match(/^注意事项\s+(.+?)\s+已(创建|更新|删除)$/); + if (legacyPrecautionMatch) { + const name = legacyPrecautionMatch[1].trim(); + const verb = legacyPrecautionMatch[2]; + return isUuidText(name) ? `${verb}注意事项(历史日志未记录标题)` : `${verb}注意事项“${name}”`; + } + const legacyVisitMatch = text.match(/^访视\s+(.+?)\s+已(创建|更新|删除)$/); + if (legacyVisitMatch) { + const name = legacyVisitMatch[1].trim(); + const verb = legacyVisitMatch[2]; + return isUuidText(name) ? `${verb}访视(历史日志未记录访视号)` : `${verb}访视“${name}”`; + } + const legacyAeMatch = text.match(/^AE\s+(.+?)\s+已(创建|删除)$/i); + if (legacyAeMatch) { + const name = legacyAeMatch[1].trim(); + const verb = legacyAeMatch[2]; + return isUuidText(name) ? `${verb}AE(历史日志未记录 AE 术语)` : `${verb}AE“${name}”`; + } + const genericLegacyActionMap: Record = { + 药品运输已创建: "创建药品流向(历史日志未记录对象信息)", + 药品运输已更新: "更新药品流向(历史日志未记录对象信息)", + 药品运输已删除: "删除药品流向(历史日志未记录对象信息)", + 设备已创建: "创建设备(历史日志未记录设备名称)", + 设备已更新: "更新设备(历史日志未记录设备名称)", + 设备已删除: "删除设备(历史日志未记录设备名称)", + 合同费用已创建: "创建合同费用(历史日志未记录对象信息)", + 合同费用已更新: "更新合同费用(历史日志未记录对象信息)", + 合同费用已删除: "删除合同费用(历史日志未记录对象信息)", + 合同费用分期已创建: "创建合同费用分期(历史日志未记录对象信息)", + 合同费用分期已更新: "更新合同费用分期(历史日志未记录对象信息)", + 合同费用分期已删除: "删除合同费用分期(历史日志未记录对象信息)", + 立项记录已创建: "创建立项记录(历史日志未记录对象信息)", + 立项记录已更新: "更新立项记录(历史日志未记录对象信息)", + 立项记录已删除: "删除立项记录(历史日志未记录对象信息)", + 伦理记录已创建: "创建伦理记录(历史日志未记录对象信息)", + 伦理记录已更新: "更新伦理记录(历史日志未记录对象信息)", + 伦理记录已删除: "删除伦理记录(历史日志未记录对象信息)", + 启动会记录已创建: "创建启动会记录(历史日志未记录对象信息)", + 启动会记录已更新: "更新启动会记录(历史日志未记录对象信息)", + 培训授权人员已创建: "创建培训授权人员(历史日志未记录人员姓名)", + 培训授权人员已更新: "更新培训授权人员(历史日志未记录人员姓名)", + 培训授权人员已删除: "删除培训授权人员(历史日志未记录人员姓名)", + 病史记录已创建: "创建病史记录(历史日志未记录参与者)", + 病史记录已更新: "更新病史记录(历史日志未记录参与者)", + 病史记录已删除: "删除病史记录(历史日志未记录参与者)", + 访视已更新: "更新访视(历史日志未记录访视号)", + }; + if (genericLegacyActionMap[text]) return genericLegacyActionMap[text]; + const legacySubjectActionMatch = text.match(/^参与者\s+(.+?)\s+已(创建|更新|删除)$/); + if (legacySubjectActionMatch) { + const subjectNo = legacySubjectActionMatch[1].trim(); + const verb = legacySubjectActionMatch[2]; + return isUuidText(subjectNo) ? `${verb}参与者(历史日志未记录参与者编号)` : `${verb}参与者 ${subjectNo}`; + } const projectionStatusMatch = text.match(/^projection_status=(.+)$/i); if (projectionStatusMatch) { const status = projectionStatusMatch[1].trim().toLowerCase(); @@ -107,7 +275,7 @@ const normalizeLegacyDetailLine = (line: string): string => { const detailMatch = text.match(/^变更明细[::]\s*(.*)$/); if (detailMatch) { const detailText = detailMatch[1].trim(); - return `变更明细:${detailText || "无"}`; + return detailText; } if (text === "立项配置已发布") return "立项配置已发布"; if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划"); @@ -124,6 +292,89 @@ const isUuidText = (value: unknown): boolean => { return new RegExp(`^${uuidTextPattern}$`).test(String(value || "").trim()); }; +const replaceUuidReferences = (text: string, userMap: Record): string => { + return String(text || "").replace(new RegExp(uuidTextPattern, "g"), (uuid) => userMap[uuid] || "未识别对象"); +}; + +const extractLegacySubjectTargetName = (description: string): string => { + const match = String(description || "").trim().match(/^参与者\s+(.+?)\s+已(?:创建|更新|删除)$/); + if (!match) return ""; + const subjectNo = match[1].trim(); + return isUuidText(subjectNo) ? "" : subjectNo; +}; + +const extractLegacyFaqCategoryTargetName = (description: string): string => { + const match = String(description || "").trim().match(/^FAQ\s*分类\s+(.+?)\s+已(?:创建|更新|删除)$/i); + if (!match) return ""; + const categoryName = match[1].trim(); + return isUuidText(categoryName) ? "" : categoryName; +}; + +const enrichStructuredDescription = (action: string, description: string, targetName: string): string => { + const desc = String(description || "").trim(); + const target = String(targetName || "").trim(); + if (!desc || !target || desc.includes("“")) return desc; + if (action === "CREATE_FAQ_ITEM" && desc === "创建医学咨询问题") return `创建医学咨询问题“${target}”`; + if (action === "UPDATE_FAQ_ITEM" && desc === "更新医学咨询问题") return `更新医学咨询问题“${target}”`; + if (action === "DELETE_FAQ_ITEM" && desc === "删除医学咨询问题") return `删除医学咨询问题“${target}”`; + if (action === "CREATE_FAQ_REPLY" && desc === "回复医学咨询问题") return `回复医学咨询问题“${target}”`; + if (action === "DELETE_FAQ_REPLY" && desc === "删除医学咨询回复") return `删除医学咨询问题“${target}”的回复`; + const genericStructuredActionMap: Record = { + CREATE_DRUG_SHIPMENT: "创建药品流向", + UPDATE_DRUG_SHIPMENT: "更新药品流向", + DELETE_DRUG_SHIPMENT: "删除药品流向", + CREATE_MATERIAL_EQUIPMENT: "创建设备", + UPDATE_MATERIAL_EQUIPMENT: "更新设备", + DELETE_MATERIAL_EQUIPMENT: "删除设备", + CREATE_PRECAUTION: "创建注意事项", + UPDATE_PRECAUTION: "更新注意事项", + DELETE_PRECAUTION: "删除注意事项", + CREATE_VISIT: "创建访视", + UPDATE_VISIT: "更新访视", + DELETE_VISIT: "删除访视", + CREATE_AE: "创建AE", + UPDATE_AE: "更新AE", + DELETE_AE: "删除AE", + CREATE_CONTRACT_FEE: "创建合同费用", + UPDATE_CONTRACT_FEE: "更新合同费用", + DELETE_CONTRACT_FEE: "删除合同费用", + CREATE_CONTRACT_FEE_PAYMENT: "创建合同费用分期", + UPDATE_CONTRACT_FEE_PAYMENT: "更新合同费用分期", + DELETE_CONTRACT_FEE_PAYMENT: "删除合同费用分期", + CREATE_STARTUP_FEASIBILITY: "创建立项记录", + UPDATE_STARTUP_FEASIBILITY: "更新立项记录", + DELETE_STARTUP_FEASIBILITY: "删除立项记录", + CREATE_STARTUP_ETHICS: "创建伦理记录", + UPDATE_STARTUP_ETHICS: "更新伦理记录", + DELETE_STARTUP_ETHICS: "删除伦理记录", + CREATE_KICKOFF_MEETING: "创建启动会记录", + UPDATE_KICKOFF_MEETING: "更新启动会记录", + CREATE_TRAINING_AUTH: "创建培训授权人员", + UPDATE_TRAINING_AUTH: "更新培训授权人员", + DELETE_TRAINING_AUTH: "删除培训授权人员", + CREATE_SUBJECT_HISTORY: "创建病史记录", + UPDATE_SUBJECT_HISTORY: "更新病史记录", + DELETE_SUBJECT_HISTORY: "删除病史记录", + }; + const structuredPrefix = genericStructuredActionMap[action]; + if (structuredPrefix && desc === structuredPrefix) return `${structuredPrefix}“${target}”`; + return desc; +}; + +const isSetupModuleSummaryLine = (line: string): boolean => /^变更模块[::]/.test(String(line || "").trim()); + +const buildActionText = ( + dictActionText: string, + diffLines: string[], + summaryText = "", + preferDictAction = false +): string => { + if (summaryText) return summaryText; + if (preferDictAction) return dictActionText; + if (diffLines.length > 0) return diffLines[0]; + return dictActionText; +}; + const splitAuditDetailSegments = (line: string): string[] => { const text = String(line || "").trim(); if (!text) return []; @@ -134,6 +385,19 @@ const splitAuditDetailSegments = (line: string): string[] => { }; const auditFieldLabelMap: Record = { + id: "记录标识", + trial_id: "所属项目", + study_id: "所属项目", + document_id: "所属文档", + version_id: "文档版本", + distribution_id: "分发记录", + target_id: "分发对象", + site_id: "中心", + etmf_node_id: "eTMF 目录", + current_effective_version_id: "当前生效版本", + parent_version_id: "上一版本", + derived_from_version_id: "来源版本", + owner_id: "负责人", issue_no: "问题编号", source: "问题来源", monitor_type: "监查类型", @@ -142,6 +406,7 @@ const auditFieldLabelMap: Record = { recommendation: "建议措施", subject_name: "受试者", subject_code: "受试者缩写号", + subject_no: "参与者编号", description: "问题描述", action_taken: "采取措施", follow_up_progress: "跟进计划及进展", @@ -166,8 +431,75 @@ const auditFieldLabelMap: Record = { city: "城市", contact: "负责人", phone: "电话", + doc_no: "文档编号", + doc_type: "文档类型", + title: "文档标题", + scope_type: "文档范围", + version_no: "版本号", + file_hash: "文件校验值", + file_uri: "文件位置", + file_size: "文件大小", + mime_type: "文件类型", + change_summary: "变更说明", + effective_at: "生效时间", + superseded_at: "替代时间", + submitted_at: "提交时间", + approved_at: "批准时间", + withdrawn_at: "撤回时间", + created_at: "创建时间", + created_by: "创建人", + target_type: "分发范围", + ack_type: "回执类型", + acked_at: "回执时间", + note: "备注", plan_start_date: "计划开始日期", plan_end_date: "计划结束日期", + screening_date: "筛选日期", + consent_date: "知情同意日期", + enrollment_date: "入组日期", + baseline_date: "基线日期", + completion_date: "完成日期", + actual_medication_count: "实际给药次数", + drop_reason: "脱落原因", +}; + +const documentVersionStatusLabelMap: Record = { + DRAFT: "草稿", + SUBMITTED: "已提交", + APPROVED: "已批准", + EFFECTIVE: "已生效", + SUPERSEDED: "已被替代", + ARCHIVED: "已归档", + WITHDRAWN: "已撤回", +}; + +const documentStatusLabelMap: Record = { + ACTIVE: "使用中", + ARCHIVED: "已归档", +}; + +const documentScopeTypeLabelMap: Record = { + GLOBAL: "项目级文档", + SITE: "中心级文档", + DERIVED: "派生文档", +}; + +const distributionTargetTypeLabelMap: Record = { + SITE: "中心", + ROLE: "角色", + USER: "指定人员", +}; + +const acknowledgementTypeLabelMap: Record = { + RECEIVED: "已接收", + READ: "已阅读", + TRAINED: "已培训", +}; + +const normalizeEnumToken = (text: string): string => { + const trimmed = String(text || "").trim(); + const parts = trimmed.split("."); + return parts[parts.length - 1] || trimmed; }; const stableSerialize = (value: any): string => { @@ -202,14 +534,44 @@ const compactObjectValue = (value: Record): string => { return entries.length > 3 ? `${rendered} 等${entries.length}项` : rendered; }; -const toReadableFieldLabel = (key: string): string => { +const toReadableFieldLabel = (key: string, entityType = ""): string => { const normalized = String(key || "").trim(); if (!normalized) return "字段"; + if (normalized === "status" && entityType === "DOCUMENT_VERSION") return "版本状态"; + if (normalized === "status" && entityType === "DOCUMENT") return "文档状态"; + if (normalized === "due_at" && (entityType === "DISTRIBUTION" || entityType === "ACKNOWLEDGEMENT")) return "截止日期"; if (auditFieldLabelMap[normalized]) return auditFieldLabelMap[normalized]; return `字段(${normalized})`; }; -const toReadableValue = (key: string, value: any): string => { +const readableUuidPlaceholder = (key: string): string => { + const normalized = String(key || "").trim(); + if (normalized === "id") return "已生成"; + if (normalized === "trial_id" || normalized === "study_id") return "已关联项目"; + if (normalized === "document_id") return "已关联文档"; + if (normalized === "version_id" || normalized.endsWith("_version_id")) return "已关联版本"; + if (normalized === "distribution_id") return "已关联分发记录"; + if (normalized === "site_id") return "已关联中心"; + if (normalized === "owner_id" || normalized === "created_by" || normalized.endsWith("_user_id")) return "已关联人员"; + if (normalized.endsWith("_id")) return "已关联对象"; + return "系统记录"; +}; + +const toReadableEnumValue = (key: string, text: string): string => { + const normalizedText = normalizeEnumToken(text); + if (key === "status") { + return documentVersionStatusLabelMap[normalizedText] || documentStatusLabelMap[normalizedText] || getDictLabel(statusDict, normalizedText) || text; + } + if (key === "scope_type") return documentScopeTypeLabelMap[normalizedText] || text; + if (key === "target_type") return distributionTargetTypeLabelMap[normalizedText] || text; + if (key === "ack_type") return acknowledgementTypeLabelMap[normalizedText] || text; + if (key.toLowerCase().includes("status")) { + return documentVersionStatusLabelMap[normalizedText] || documentStatusLabelMap[normalizedText] || getDictLabel(statusDict, normalizedText) || text; + } + return text; +}; + +const toReadableValue = (key: string, value: any, userMap: Record): string => { if (value === null || value === undefined || value === "") return "未填写"; if (typeof value === "boolean") { if (key === "is_active") return value ? "启用" : "停用"; @@ -219,14 +581,14 @@ const toReadableValue = (key: string, value: any): string => { if (typeof value === "string") { const text = value.trim(); if (!text) return "未填写"; - if (key.toLowerCase().includes("status")) { - return getDictLabel(statusDict, text) || text; - } - return text; + if (key === "file_hash") return "已记录校验值"; + if (isUuidText(text)) return userMap[text] || readableUuidPlaceholder(key); + if (key === "version_no") return text.startsWith("V") ? text : `V${text}`; + return toReadableEnumValue(key, text); } if (Array.isArray(value)) { if (value.length === 0) return "未填写"; - const rendered = value.slice(0, 3).map((item) => toReadableValue(key, item)).join("、"); + const rendered = value.slice(0, 3).map((item) => toReadableValue(key, item, userMap)).join("、"); return value.length > 3 ? `${rendered} 等${value.length}项` : rendered; } if (typeof value === "object") { @@ -235,7 +597,12 @@ const toReadableValue = (key: string, value: any): string => { return String(value); }; -const buildDiffText = (before?: Record, after?: Record) => { +const buildDiffText = ( + before: Record | undefined, + after: Record | undefined, + userMap: Record, + entityType = "" +) => { if (!before && !after) return []; const lines: string[] = []; const prevObj = before || {}; @@ -244,20 +611,16 @@ const buildDiffText = (before?: Record, after?: Record const prev = prevObj[key]; const next = nextObj[key]; if (isSameValue(prev, next)) return; - const label = toReadableFieldLabel(key); - const prevText = toReadableValue(key, prev); - const nextText = toReadableValue(key, next); + const label = toReadableFieldLabel(key, entityType); + const prevText = toReadableValue(key, prev, userMap); + const nextText = toReadableValue(key, next, userMap); lines.push(`${label}:${prevText} -> ${nextText}`); }); return lines; }; export const normalizeAuditEvent = (raw: any, userMap: Record): AuditEvent => { - const dict = auditDict[raw.action] || { - label: raw.action ? `${TEXT.audit.eventFallback}(${raw.action})` : TEXT.audit.eventFallback, - actionText: raw.action ? `执行了${raw.action}` : TEXT.audit.actionFallback, - targetLabel: toReadableEntityType(raw.entity_type), - }; + const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type); let detailObj: any = {}; if (raw.detail) { try { @@ -268,18 +631,39 @@ export const normalizeAuditEvent = (raw: any, userMap: Record): } const before = detailObj.before; const after = detailObj.after; - const fallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : ""; - let diffText = detailObj.diffText || buildDiffText(before, after); + const detailTargetName = String(detailObj.targetName || "").trim(); + const rawFallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : ""; + const fallbackDescription = enrichStructuredDescription(raw.action, rawFallbackDescription, isUuidText(detailTargetName) ? "" : detailTargetName); + let diffText = detailObj.diffText || buildDiffText(before, after, userMap, raw.entity_type || ""); if ((!Array.isArray(diffText) || diffText.length === 0) && fallbackDescription) { diffText = fallbackDescription .split(/[;;\n]/) .map((item: string) => item.trim()) .filter(Boolean); } - const readableDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : []) + const normalizedDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : []) .flatMap((line) => splitAuditDetailSegments(String(line))) .map((line) => normalizeLegacyDetailLine(line)) + .map((line) => replaceUuidReferences(line, userMap)) .filter(Boolean); + const setupModuleSummary = normalizedDiffText.find((line) => isSetupModuleSummaryLine(line)) || ""; + const readableDiffText = normalizedDiffText.filter((line) => !isSetupModuleSummaryLine(line)); + const legacySubjectTargetName = extractLegacySubjectTargetName(fallbackDescription); + const legacyFaqCategoryTargetName = extractLegacyFaqCategoryTargetName(fallbackDescription); + const targetName = isUuidText(detailTargetName) + ? userMap[detailTargetName] || "" + : detailObj.targetName || legacySubjectTargetName || legacyFaqCategoryTargetName || (isUuidText(raw.entity_id) ? "" : raw.entity_id || ""); + const preferDictAction = Boolean(explicitAuditActionMap[raw.action] && fallbackDescription); + const hasStructuredDetail = typeof raw.detail === "string" && raw.detail.trim().startsWith("{"); + const actionText = + raw.action === "DELETE_SUBJECT" && hasStructuredDetail && fallbackDescription + ? fallbackDescription + : buildActionText( + dict.actionText, + readableDiffText, + setupModuleSummary, + preferDictAction + ); return { eventType: raw.action, eventLabel: dict.label, @@ -290,8 +674,8 @@ export const normalizeAuditEvent = (raw: any, userMap: Record): targetType: raw.entity_type || "", targetTypeLabel: dict.targetLabel, targetId: raw.entity_id || "", - targetName: detailObj.targetName || (isUuidText(raw.entity_id) ? "" : raw.entity_id || ""), - actionText: dict.actionText, + targetName, + actionText, before, after, diffText: readableDiffText, @@ -302,8 +686,6 @@ export const normalizeAuditEvent = (raw: any, userMap: Record): }; }; -export { auditDict }; - // Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage. export const logAudit = async (eventType: string, payload: Record) => { void eventType; diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 099345ca..14aa07c4 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -238,8 +238,12 @@ export const TEXT = { ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" }, UNAUTHORIZED_ACTION_ATTEMPT: { label: "越权操作尝试", actionText: "尝试执行无权限操作", targetLabel: "系统资源" }, INVALID_STATE_TRANSITION_ATTEMPT: { label: "非法状态流转尝试", actionText: "尝试执行非法状态流转", targetLabel: "业务对象" }, + PROJECT_MEMBER_ADDED: { label: "项目成员新增", actionText: "添加了项目成员", targetLabel: "项目成员" }, PROJECT_MEMBER_UPDATED: { label: "项目成员变更", actionText: "调整了项目成员", targetLabel: "项目成员" }, + PROJECT_MEMBER_REMOVED: { label: "项目成员移除", actionText: "移除了项目成员", targetLabel: "项目成员" }, SITE_CRA_BOUND: { label: "中心 CRA 绑定", actionText: "调整了中心与 CRA 的绑定", targetLabel: "中心" }, + SITE_CREATED: { label: "新增中心", actionText: "新增了中心", targetLabel: "中心" }, + SITE_UPDATED: { label: "更新中心", actionText: "更新了中心", targetLabel: "中心" }, SITE_STATUS_CHANGED: { label: "中心状态变更", actionText: "更新了中心信息", targetLabel: "中心" }, AUDIT_EXPORT_SYSTEM: { label: "导出系统审计", actionText: "导出了系统审计日志", targetLabel: "审计日志" }, AUDIT_EXPORT_PROJECT: { label: "导出项目审计", actionText: "导出了项目审计日志", targetLabel: "审计日志" }, diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index 48a19db2..7ffd2158 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -29,6 +29,19 @@ describe("admin project route permissions", () => { expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]"); }); + it("checks admin project permissions against query project id when present", () => { + const source = readRouter(); + const accessStart = source.indexOf("const ensureAdminProjectAccess"); + const accessEnd = source.indexOf("const ensureRouteProjectMember", accessStart); + const accessBlock = source.slice(accessStart, accessEnd); + + expect(accessBlock).toContain("const targetProjectId = getTargetProjectId(to);"); + expect(accessBlock).toContain("studyStore.currentStudy?.id !== targetProjectId"); + expect(accessBlock).toContain("fetchStudyDetail(targetProjectId)"); + expect(accessBlock).toContain("fetchApiEndpointPermissions(targetProjectId)"); + expect(accessBlock).not.toContain("typeof to.params.projectId"); + }); + it("guards project permission configuration with the system-level project config permission", () => { const source = readRouter(); const projectPermissionRouteStart = source.indexOf('name: "AdminPermissionsProject"'); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 0214ea89..e955917c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -454,11 +454,11 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => { const permission = to.meta.adminProjectPermission as { module: string; action: "read" | "write" } | undefined; if (!permission) return true; - const routeProjectId = typeof to.params.projectId === "string" ? to.params.projectId : ""; - if (routeProjectId && studyStore.currentStudy?.id !== routeProjectId) { + const targetProjectId = getTargetProjectId(to); + if (targetProjectId && studyStore.currentStudy?.id !== targetProjectId) { const [{ data: project }, { data: permissions }] = await Promise.all([ - fetchStudyDetail(routeProjectId), - fetchApiEndpointPermissions(routeProjectId), + fetchStudyDetail(targetProjectId), + fetchApiEndpointPermissions(targetProjectId), ]); studyStore.setCurrentStudy(project); studyStore.currentPermissions = permissions; diff --git a/frontend/src/views/admin/AuditLogs.test.ts b/frontend/src/views/admin/AuditLogs.test.ts index aaac54be..d4661ff9 100644 --- a/frontend/src/views/admin/AuditLogs.test.ts +++ b/frontend/src/views/admin/AuditLogs.test.ts @@ -5,6 +5,29 @@ import { resolve } from "node:path"; const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8"); describe("audit logs access", () => { + it("loads audit logs from the selected project context", () => { + const source = readAuditLogsView(); + + expect(source).toContain("const selectedStudy = ref(null)"); + expect(source).toContain("const selectedStudyId = computed({"); + expect(source).toContain("get: () => selectedStudy.value?.id || \"\""); + expect(source).toContain("set: (studyId: string) => {"); + expect(source).toContain("await fetchAuditLogs(selectedStudyId.value, params)"); + expect(source).toContain("await fetchApiEndpointPermissions(selectedStudyId.value)"); + expect(source).toContain("await createAuditEvent(selectedStudyId.value,"); + expect(source).not.toContain("await fetchAuditLogs(study.currentStudy.id, params)"); + }); + + it("limits non-admin project choices to PM-owned projects", () => { + const source = readAuditLogsView(); + + expect(source).toContain('const availableItems = isAdmin.value ? items : items.filter((item) => item.role_in_study === "PM");'); + expect(source).toContain("studies.value = availableItems;"); + expect(source).toContain("availableItems.find((item) => item.id === queryProjectId)"); + expect(source).toContain("availableItems.find((item) => item.id === study.currentStudy?.id)"); + expect(source).toContain("availableItems[0]"); + }); + it("allows only admins or authorized PMs to use audit export", () => { const source = readAuditLogsView(); @@ -16,11 +39,75 @@ describe("audit logs access", () => { expect(source).toContain("isApiPermissionAllowed"); expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]'); expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]'); - expect(source).toContain('if (scope === "project" && !canProjectExport.value) return;'); + expect(source).toContain("if (!canProjectExport.value) return;"); + expect(source).toContain('action: "AUDIT_EXPORT_PROJECT"'); + expect(source).not.toContain('command="system"'); expect(source).not.toMatch(/auth\.user\?\.role/); expect(source).not.toContain('!== "ADMIN"'); }); + it("exports all matching audit log pages instead of a fixed first page", () => { + const source = readAuditLogsView(); + + expect(source).toContain("const fetchAuditLogPages = async"); + expect(source).toContain("while (true)"); + expect(source).toContain("if (items.length < batchLimit) break;"); + expect(source).not.toContain("limit: 2000"); + }); + + it("keeps the audit table compact by removing the duplicate content column", () => { + const source = readAuditLogsView(); + + expect(source).not.toContain('prop="actionText"'); + expect(source).not.toContain("TEXT.modules.adminAuditLogs.columns.action"); + expect(source).toContain("TEXT.modules.adminAuditLogs.columns.target"); + expect(source).toContain("TEXT.modules.adminAuditLogs.columns.diff"); + }); + + it("shows actor names without avatar icons in the audit table", () => { + const source = readAuditLogsView(); + + expect(source).toContain('{{ scope.row.actorName }}'); + expect(source).not.toContain("actor-avatar"); + expect(source).not.toContain("getInitials(scope.row.actorName)"); + }); + + it("lets target and detail columns absorb remaining table width", () => { + const source = readAuditLogsView(); + + expect(source).toContain(''); + expect(source).toContain(''); + expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.target" width="240"'); + expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.diff" width="360"'); + }); + + it("parses diff lines by the last field separator before the arrow", () => { + const source = readAuditLogsView(); + + expect(source).toMatch(/line\.match\(\/\^\(\.\+\)\[::\]\\s\*\(\.\*\?\)\\s\*->\\s\*\(\.\+\)\$\/\)/); + expect(source).not.toMatch(/line\.match\(\/\^\(\.\+\?\):\(\.\+\?\)\\s\*->\\s\*\(\.\+\)\$\/\)/); + }); + + it("renders detail object and action as two cards in one row", () => { + const source = readAuditLogsView(); + + expect(source).toContain('对象'); + expect(source).toContain('动作'); + expect(source).not.toContain("detail-meta-full"); + expect(source).not.toContain("meta-card-full"); + }); + + it("groups setup diff detail rows by business item path", () => { + const source = readAuditLogsView(); + + expect(source).toContain("const buildDetailDiffGroups = (lines: any[]): DetailDiffGroup[] => {"); + expect(source).toContain('const groupedIndex = new Map();'); + expect(source).toContain('title: parts.slice(0, -1).join(" / "),'); + expect(source).toContain("field: parts[parts.length - 1],"); + expect(source).toContain('class="diff-group-title"'); + expect(source).toContain("{{ selectedDiffGroups.length }} 项"); + }); + it("does not expose legacy startup ethics business labels", () => { const auditSource = readFileSync(resolve(__dirname, "../../audit/index.ts"), "utf8"); const overviewSource = readFileSync(resolve(__dirname, "../ia/project-overview/overview.adapter.ts"), "utf8"); diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 4c07179b..706f525e 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -44,6 +44,11 @@
+
+ + + +
@@ -81,7 +86,6 @@ @@ -92,26 +96,24 @@
- + - + - + - - + - + - +