完善审计日志展示与导出权限

This commit is contained in:
Cheng Zhou
2026-06-10 10:20:04 +08:00
parent 1fef12cd86
commit ea3f19e241
33 changed files with 1862 additions and 240 deletions
+22 -3
View File
@@ -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),
)
+23 -20
View File
@@ -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),
)
+8 -3
View File
@@ -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),
)
+3 -3
View File
@@ -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),
)
+18 -5
View File
@@ -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),
)
+37 -6
View File
@@ -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),
)
+17 -3
View File
@@ -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),
)
+29 -4
View File
@@ -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),
)
+10 -3
View File
@@ -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),
)
+50 -11
View File
@@ -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),
)
+5 -6
View File
@@ -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"]))])
+25 -3
View File
@@ -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),
)
+58 -7
View File
@@ -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),
)
+30 -5
View File
@@ -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),
)
+2 -5
View File
@@ -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()
+3 -4
View File
@@ -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))
+4 -2
View File
@@ -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,
}
+23 -2
View File
@@ -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),
)
+97 -2
View File
@@ -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"]
+149
View File
@@ -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)
+12
View File
@@ -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
+1 -4
View File
@@ -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<string, any>) =>
apiGet<ApiListResponse<any>>(`/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<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
+455 -2
View File
@@ -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("回滚了立项配置");
});
});
@@ -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)");
});
});
@@ -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) {
+407 -25
View File
@@ -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<string, string> = {
DOCUMENT_VERSION: "文档版本",
DISTRIBUTION: "文档分发",
ACKNOWLEDGEMENT: "文档签收",
ETMF_NODE: "eTMF 目录",
};
const auditActionVerbMap: Record<string, { labelPrefix: string; actionPrefix: string }> = {
CREATE: { labelPrefix: "新增", actionPrefix: "新增了" },
UPDATE: { labelPrefix: "更新", actionPrefix: "更新了" },
DELETE: { labelPrefix: "删除", actionPrefix: "删除了" },
};
const auditActionTargetMap: Record<string, string> = {
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<string, { label: string; actionText: string; targetLabel: string }> = {
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<string, { label: string; actionText: string; targetLabel: string }> = {
...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<string, string> = {
@@ -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<string, string> = {
: "创建药品流向(历史日志未记录对象信息)",
: "更新药品流向(历史日志未记录对象信息)",
: "删除药品流向(历史日志未记录对象信息)",
: "创建设备(历史日志未记录设备名称)",
: "更新设备(历史日志未记录设备名称)",
: "删除设备(历史日志未记录设备名称)",
: "创建合同费用(历史日志未记录对象信息)",
: "更新合同费用(历史日志未记录对象信息)",
: "删除合同费用(历史日志未记录对象信息)",
: "创建合同费用分期(历史日志未记录对象信息)",
: "更新合同费用分期(历史日志未记录对象信息)",
: "删除合同费用分期(历史日志未记录对象信息)",
: "创建立项记录(历史日志未记录对象信息)",
: "更新立项记录(历史日志未记录对象信息)",
: "删除立项记录(历史日志未记录对象信息)",
: "创建伦理记录(历史日志未记录对象信息)",
: "更新伦理记录(历史日志未记录对象信息)",
: "删除伦理记录(历史日志未记录对象信息)",
: "创建启动会记录(历史日志未记录对象信息)",
: "更新启动会记录(历史日志未记录对象信息)",
: "创建培训授权人员(历史日志未记录人员姓名)",
: "更新培训授权人员(历史日志未记录人员姓名)",
: "删除培训授权人员(历史日志未记录人员姓名)",
: "创建病史记录(历史日志未记录参与者)",
: "更新病史记录(历史日志未记录参与者)",
: "删除病史记录(历史日志未记录参与者)",
访: "更新访视(历史日志未记录访视号)",
};
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, string>): 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<string, string> = {
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<string, string> = {
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<string, string> = {
recommendation: "建议措施",
subject_name: "受试者",
subject_code: "受试者缩写号",
subject_no: "参与者编号",
description: "问题描述",
action_taken: "采取措施",
follow_up_progress: "跟进计划及进展",
@@ -166,8 +431,75 @@ const auditFieldLabelMap: Record<string, string> = {
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<string, string> = {
DRAFT: "草稿",
SUBMITTED: "已提交",
APPROVED: "已批准",
EFFECTIVE: "已生效",
SUPERSEDED: "已被替代",
ARCHIVED: "已归档",
WITHDRAWN: "已撤回",
};
const documentStatusLabelMap: Record<string, string> = {
ACTIVE: "使用中",
ARCHIVED: "已归档",
};
const documentScopeTypeLabelMap: Record<string, string> = {
GLOBAL: "项目级文档",
SITE: "中心级文档",
DERIVED: "派生文档",
};
const distributionTargetTypeLabelMap: Record<string, string> = {
SITE: "中心",
ROLE: "角色",
USER: "指定人员",
};
const acknowledgementTypeLabelMap: Record<string, string> = {
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, any>): 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, string>): 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<string, any>, after?: Record<string, any>) => {
const buildDiffText = (
before: Record<string, any> | undefined,
after: Record<string, any> | undefined,
userMap: Record<string, string>,
entityType = ""
) => {
if (!before && !after) return [];
const lines: string[] = [];
const prevObj = before || {};
@@ -244,20 +611,16 @@ const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>
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<string, string>): 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<string, string>):
}
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<string, string>):
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<string, string>):
};
};
export { auditDict };
// Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage.
export const logAudit = async (eventType: string, payload: Record<string, any>) => {
void eventType;
+4
View File
@@ -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: "审计日志" },
+13
View File
@@ -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"');
+4 -4
View File
@@ -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;
+88 -1
View File
@@ -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<Study | null>(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('<span class="actor-name">{{ scope.row.actorName }}</span>');
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('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">');
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">');
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('<span class="meta-label">对象</span>');
expect(source).toContain('<span class="meta-label">动作</span>');
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<string, DetailDiffGroup>();');
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");
+227 -105
View File
@@ -44,6 +44,11 @@
<!-- 筛选栏 -->
<div class="audit-toolbar unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item-form">
<el-select v-model="selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project">
<el-option v-for="project in studies" :key="project.id" :label="project.name" :value="project.id" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp">
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
@@ -81,7 +86,6 @@
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="isAdmin" command="system">{{ TEXT.modules.adminAuditLogs.exportSystem }}</el-dropdown-item>
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -92,26 +96,24 @@
<!-- 日志表格 -->
<div class="unified-section table-section">
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="155">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="150">
<template #default="scope">
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="130">
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="120">
<template #default="scope">
<div class="actor-cell">
<div class="actor-avatar">{{ getInitials(scope.row.actorName) }}</div>
<span class="actor-name">{{ scope.row.actorName }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="140">
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="145">
<template #default="scope">
<span class="event-tag">{{ scope.row.eventLabel }}</span>
</template>
</el-table-column>
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" show-overflow-tooltip />
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target">
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">
<template #default="scope">
<div class="target-cell">
<span v-if="scope.row.targetTypeLabel" class="target-text">
@@ -121,7 +123,7 @@
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff">
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">
<template #default="scope">
<div v-if="scope.row.diffText?.length" class="diff-container">
<div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ formatDiffLine(line) }}</div>
@@ -133,7 +135,7 @@
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="80" align="center">
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="76" align="center">
<template #default="scope">
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
{{ scope.row.resultLabel }}
@@ -199,12 +201,12 @@
</span>
</div>
</div>
<div class="detail-meta-grid detail-meta-full">
<div class="meta-card meta-card-full">
<div class="detail-meta-grid">
<div class="meta-card">
<span class="meta-label">对象</span>
<span class="meta-value">{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">{{ selectedLog.targetName }}</span></span>
</div>
<div class="meta-card meta-card-full">
<div class="meta-card">
<span class="meta-label">动作</span>
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div>
@@ -215,22 +217,27 @@
<div class="detail-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
变更明细
<span class="diff-count" v-if="selectedLog.diffText?.length">{{ selectedLog.diffText.length }} </span>
<span class="diff-count" v-if="selectedDiffGroups.length">{{ selectedDiffGroups.length }} </span>
</div>
<el-scrollbar max-height="55vh">
<div v-if="selectedLog.diffText?.length" class="detail-lines">
<div v-for="(line, idx) in selectedLog.diffText" :key="idx" class="detail-line-row">
<div v-if="selectedDiffGroups.length" class="detail-lines">
<div v-for="(group, idx) in selectedDiffGroups" :key="`${group.title}-${idx}`" class="detail-line-row">
<span class="detail-line-index">{{ Number(idx) + 1 }}</span>
<div class="detail-line-content">
<template v-if="parseDiffLine(formatDiffLine(line))">
<span class="diff-field">{{ parseDiffLine(formatDiffLine(line))!.field }}</span>
<div class="diff-values">
<span class="diff-old">{{ parseDiffLine(formatDiffLine(line))!.oldVal }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" class="diff-arrow"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
<span class="diff-new">{{ parseDiffLine(formatDiffLine(line))!.newVal }}</span>
</div>
</template>
<span v-else class="detail-line-text">{{ formatDiffLine(line) }}</span>
<span v-if="group.title" class="diff-group-title">{{ group.title }}</span>
<div class="diff-group-lines">
<template v-for="(item, itemIdx) in group.items" :key="itemIdx">
<div v-if="item.parsed" class="diff-item-row">
<span class="diff-field">{{ item.parsed.field }}</span>
<div class="diff-values">
<span class="diff-old">{{ item.parsed.oldVal }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" class="diff-arrow"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
<span class="diff-new">{{ item.parsed.newVal }}</span>
</div>
</div>
<span v-else class="detail-line-text">{{ item.text }}</span>
</template>
</div>
</div>
</div>
</div>
@@ -247,10 +254,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
import { fetchStudies } from "../../api/studies";
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
@@ -263,15 +271,19 @@ import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
import type { Study } from "../../types/api";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const exportLoading = ref(false);
const logs = ref<any[]>([]);
const users = ref<any[]>([]);
const studies = ref<Study[]>([]);
const selectedStudy = ref<Study | null>(null);
const allLogs = ref<any[]>([]);
const permissionMatrix = ref<any | null>(null);
const page = ref(1);
@@ -292,13 +304,6 @@ const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCC
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size);
const getInitials = (name: string) => {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
};
const formatDiffLine = (line: any): string => {
if (typeof line === 'string') return line;
if (line === null || line === undefined) return '-';
@@ -310,11 +315,62 @@ const formatDiffLine = (line: any): string => {
};
const parseDiffLine = (line: string): { field: string; oldVal: string; newVal: string } | null => {
const match = line.match(/^(.+?)(.+?)\s*->\s*(.+)$/);
const match = line.match(/^(.+)[:]\s*(.*?)\s*->\s*(.+)$/);
if (!match) return null;
return { field: match[1], oldVal: match[2], newVal: match[3] };
};
type DetailDiffItem = {
text: string;
parsed: { field: string; oldVal: string; newVal: string } | null;
};
type DetailDiffGroup = {
title: string;
items: DetailDiffItem[];
};
const splitGroupedDiffField = (field: string): { title: string; field: string } | null => {
const parts = String(field || "")
.split("/")
.map((item) => item.trim())
.filter(Boolean);
if (parts.length < 3) return null;
return {
title: parts.slice(0, -1).join(" / "),
field: parts[parts.length - 1],
};
};
const buildDetailDiffGroups = (lines: any[]): DetailDiffGroup[] => {
const groups: DetailDiffGroup[] = [];
const groupedIndex = new Map<string, DetailDiffGroup>();
lines.forEach((line) => {
const text = formatDiffLine(line);
const parsed = parseDiffLine(text);
const groupedField = parsed ? splitGroupedDiffField(parsed.field) : null;
if (!parsed || !groupedField) {
groups.push({ title: "", items: [{ text, parsed }] });
return;
}
let group = groupedIndex.get(groupedField.title);
if (!group) {
group = { title: groupedField.title, items: [] };
groupedIndex.set(groupedField.title, group);
groups.push(group);
}
group.items.push({
text,
parsed: { ...parsed, field: groupedField.field },
});
});
return groups;
};
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value,
label: cfg.label,
@@ -324,8 +380,20 @@ const resolveUserDisplayName = (u: any): string => {
};
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
const selectedDiffGroups = computed(() => buildDetailDiffGroups(selectedLog.value?.diffText || []));
const selectedStudyId = computed({
get: () => selectedStudy.value?.id || "",
set: (studyId: string) => {
selectedStudy.value = studies.value.find((item) => item.id === studyId) || null;
},
});
const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const projectRole = computed(() => {
const role = selectedStudy.value?.id === study.currentStudy?.id
? study.currentStudyRole
: (selectedStudy.value as any)?.role_in_study || null;
return getProjectRole(selectedStudy.value, role);
});
const canProjectExport = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]);
@@ -336,14 +404,31 @@ const canAccessAuditLogs = computed(() => {
});
const ensureAccess = () => {
if (!selectedStudy.value) {
router.replace("/admin/projects");
return;
}
if (!canAccessAuditLogs.value) {
router.replace(study.currentStudy ? "/project/overview" : "/admin/projects");
}
};
const loadAvailableStudies = async () => {
const { data } = await fetchStudies();
const items = ((data as any).items || []) as Study[];
const availableItems = isAdmin.value ? items : items.filter((item) => item.role_in_study === "PM");
studies.value = availableItems;
const queryProjectId = typeof route.query.projectId === "string" ? route.query.projectId : "";
selectedStudy.value =
availableItems.find((item) => item.id === queryProjectId) ||
availableItems.find((item) => item.id === study.currentStudy?.id) ||
availableItems[0] ||
null;
};
const loadPermissionMatrix = async () => {
if (!study.currentStudy) return;
const { data } = await fetchApiEndpointPermissions(study.currentStudy.id);
if (!selectedStudyId.value) return;
const { data } = await fetchApiEndpointPermissions(selectedStudyId.value);
permissionMatrix.value = data;
};
@@ -352,8 +437,8 @@ const loadUsers = async () => {
if (isAdmin.value) {
const { data } = await fetchUsers({ limit: 500 });
users.value = Array.isArray(data) ? data : (data as any).items || [];
} else if (study.currentStudy) {
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
} else if (selectedStudyId.value) {
const { data } = await listMembers(selectedStudyId.value, { limit: 500 });
const items = Array.isArray(data) ? data : (data as any).items || [];
users.value = items.map((m: any) => ({
id: m.user_id,
@@ -365,30 +450,37 @@ const loadUsers = async () => {
}
};
const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
const batchLimit = 500;
let skip = 0;
const allItems: any[] = [];
const params: Record<string, any> = {
...baseParams,
skip,
limit: batchLimit,
};
while (true) {
params.skip = skip;
const { data } = await fetchAuditLogs(selectedStudyId.value, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
allItems.push(...items);
if (items.length < batchLimit) break;
skip += items.length;
}
return allItems;
};
const loadLogs = async () => {
if (!study.currentStudy) return;
if (!selectedStudyId.value) return;
loading.value = true;
try {
if (!permissionMatrix.value) {
await loadPermissionMatrix();
}
const batchLimit = 500;
let skip = 0;
const allItems: any[] = [];
const params: Record<string, any> = {
skip,
limit: batchLimit,
const allItems = await fetchAuditLogPages({
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
};
while (true) {
params.skip = skip;
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
allItems.push(...items);
if (items.length < batchLimit) break;
skip += items.length;
}
});
enrichLogs(allItems);
refreshPagedLogs();
} catch (e: any) {
@@ -448,6 +540,23 @@ const onLocalFilterChange = () => {
refreshPagedLogs();
};
const onProjectChange = async () => {
permissionMatrix.value = null;
users.value = [];
allLogs.value = [];
logs.value = [];
total.value = 0;
page.value = 1;
await router.replace({
path: "/admin/audit-logs",
query: { ...route.query, projectId: selectedStudyId.value || undefined },
});
await loadPermissionMatrix().catch(() => {});
ensureAccess();
await loadUsers();
await loadLogs();
};
const onPageChange = (p: number) => {
page.value = p;
refreshPagedLogs();
@@ -470,17 +579,13 @@ const openDetail = (log: any) => {
};
const fetchAllForExport = async () => {
if (!study.currentStudy) return [];
if (!selectedStudyId.value) return [];
exportLoading.value = true;
try {
const params: Record<string, any> = {
skip: 0,
limit: 2000,
const items = await fetchAuditLogPages({
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
};
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
});
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur);
return acc;
@@ -504,13 +609,13 @@ const fetchAllForExport = async () => {
};
const handleExportCommand = (command: string) => {
confirmExport(command as "system" | "project");
if (command === "project") confirmExport();
};
const confirmExport = async (scope: "system" | "project") => {
const currentStudy = study.currentStudy;
const confirmExport = async () => {
const currentStudy = selectedStudy.value;
if (!currentStudy) return;
if (scope === "project" && !canProjectExport.value) return;
if (!canProjectExport.value) return;
const ok = await ElMessageBox.confirm(
TEXT.modules.adminAuditLogs.exportConfirm,
TEXT.modules.adminAuditLogs.exportConfirmTitle,
@@ -522,20 +627,10 @@ const confirmExport = async (scope: "system" | "project") => {
ElMessage.warning(TEXT.modules.adminAuditLogs.exportEmpty);
return;
}
const fileName =
scope === "system"
? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
const fileName = `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
exportAuditCsv(events, { fileName });
await createAuditEvent(currentStudy.id, {
action: scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT",
entity_type: "audit_log",
entity_id: null,
detail: JSON.stringify(
{ targetName: currentStudy.name, scope, exportedCount: events.length },
null,
0
),
await createAuditEvent(selectedStudyId.value, {
action: "AUDIT_EXPORT_PROJECT",
}).catch(() => null);
};
@@ -543,6 +638,7 @@ onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadAvailableStudies().catch(() => {});
await loadPermissionMatrix().catch(() => {});
ensureAccess();
await loadUsers();
@@ -615,6 +711,7 @@ onMounted(async () => {
}
.filter-select-comp { width: 132px; }
.filter-select-project { width: 180px; }
.filter-select-result { width: 96px; }
.date-range-picker-comp { width: 248px !important; }
.filter-spacer { flex: 1; }
@@ -637,6 +734,12 @@ onMounted(async () => {
/* 表格 */
.table-section { padding: 0; }
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
.audit-table :deep(.el-table__cell) {
padding: 8px 0;
}
.audit-table :deep(.cell) {
padding: 0 12px;
}
.time-text {
font-size: 12px;
@@ -645,26 +748,11 @@ onMounted(async () => {
}
.actor-cell {
display: flex;
align-items: center;
gap: 8px;
}
.actor-avatar {
width: 26px;
height: 26px;
border-radius: 7px;
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
min-width: 0;
}
.actor-name {
display: block;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
@@ -692,6 +780,23 @@ onMounted(async () => {
font-size: 13px;
}
.target-text {
display: inline-flex;
align-items: baseline;
max-width: 100%;
min-width: 0;
}
.target-text strong {
flex-shrink: 0;
}
.target-text .text-muted {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted { color: var(--ctms-text-secondary); font-size: 12px; }
.result-badge {
@@ -712,6 +817,7 @@ onMounted(async () => {
color: var(--ctms-text-regular);
line-height: 1.5;
overflow: hidden;
max-width: 100%;
}
.diff-line {
@@ -775,10 +881,6 @@ onMounted(async () => {
margin-bottom: 10px;
}
.detail-meta-full {
grid-template-columns: 1fr;
}
.meta-card {
display: flex;
flex-direction: column;
@@ -789,10 +891,6 @@ onMounted(async () => {
background: var(--ctms-neutral-100);
}
.meta-card-full {
grid-column: 1 / -1;
}
.meta-label {
font-size: 11px;
color: var(--ctms-text-secondary);
@@ -875,12 +973,35 @@ onMounted(async () => {
min-width: 0;
}
.diff-group-title {
display: block;
font-size: 13px;
font-weight: 700;
color: var(--ctms-primary);
margin-bottom: 8px;
word-break: break-word;
}
.diff-group-lines {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-row {
display: grid;
grid-template-columns: minmax(72px, 128px) minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.diff-field {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: var(--ctms-primary);
margin-bottom: 4px;
color: var(--ctms-text-regular);
line-height: 1.8;
word-break: break-word;
}
.diff-values {
@@ -936,5 +1057,6 @@ onMounted(async () => {
@media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.filter-form { flex-wrap: wrap; }
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
}
</style>
@@ -120,4 +120,10 @@ describe("project management access", () => {
expect(projects).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
expect(router).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
});
it("opens audit logs with an explicit project id", () => {
const projects = readProjects();
expect(projects).toContain('await enterStudy(row, `/admin/audit-logs?projectId=${row.id}`)');
});
});
+1 -1
View File
@@ -258,7 +258,7 @@ const goPermissions = (row: Study) => {
};
const goAuditLogs = async (row: Study) => {
await enterStudy(row, "/admin/audit-logs");
await enterStudy(row, `/admin/audit-logs?projectId=${row.id}`);
};
const handleDelete = async (study: Study) => {