规范审计日志目标名称展示

- 药品运输审计日志写入运单号、批号或中心名称作为可读目标名\n- FAQ 分类审计日志写入分类名称,删除时提前保留展示名称\n- 前端审计归一化隐藏 UUID-only 目标名,并清理旧文本中的 UUID 标识\n- 新增审计归一化测试覆盖 FAQ 分类和药品运输记录
This commit is contained in:
Cheng Zhou
2026-06-04 16:30:54 +08:00
parent 4ea0e88c98
commit 0b3a4d4d31
4 changed files with 72 additions and 8 deletions
+15 -3
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +19,10 @@ from app.schemas.drug_shipment import (
router = APIRouter() router = APIRouter()
def _shipment_audit_name(shipment) -> str:
return shipment.tracking_no or shipment.batch_no or shipment.site_name or "药品运输记录"
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
@@ -59,7 +64,10 @@ async def create_shipment(
entity_type="drug_shipment", entity_type="drug_shipment",
entity_id=shipment.id, entity_id=shipment.id,
action="CREATE_DRUG_SHIPMENT", action="CREATE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment.id} 已创建", detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": f"药品运输 {_shipment_audit_name(shipment)} 已创建"},
ensure_ascii=False,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -169,7 +177,10 @@ async def update_shipment(
entity_type="drug_shipment", entity_type="drug_shipment",
entity_id=shipment_id, entity_id=shipment_id,
action="UPDATE_DRUG_SHIPMENT", action="UPDATE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment_id} 已更新", detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": f"药品运输 {_shipment_audit_name(shipment)} 已更新"},
ensure_ascii=False,
),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -195,6 +206,7 @@ async def delete_shipment(
if cra_scope and shipment.center_id not in cra_scope[0]: if cra_scope and shipment.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_center_active(db, study_id, shipment.center_id) await _ensure_center_active(db, study_id, shipment.center_id)
shipment_name = _shipment_audit_name(shipment)
await shipment_crud.delete_shipment(db, shipment) await shipment_crud.delete_shipment(db, shipment)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -202,7 +214,7 @@ async def delete_shipment(
entity_type="drug_shipment", entity_type="drug_shipment",
entity_id=shipment_id, entity_id=shipment_id,
action="DELETE_DRUG_SHIPMENT", action="DELETE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment_id} 已删除", detail=json.dumps({"targetName": shipment_name, "description": f"药品运输 {shipment_name} 已删除"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user), operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+5 -3
View File
@@ -1,4 +1,5 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,7 +49,7 @@ async def create_category(
entity_type="faq_category", entity_type="faq_category",
entity_id=category.id, entity_id=category.id,
action="CREATE_FAQ_CATEGORY", action="CREATE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category.name} 已创建", detail=json.dumps({"targetName": category.name, "description": f"FAQ 分类 {category.name} 已创建"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user), operator_role=await get_operator_role_label(db, payload.study_id, current_user),
) )
@@ -110,7 +111,7 @@ async def update_category(
entity_type="faq_category", entity_type="faq_category",
entity_id=category_id, entity_id=category_id,
action="UPDATE_FAQ_CATEGORY", action="UPDATE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category_id} 已更新", detail=json.dumps({"targetName": updated.name, "description": f"FAQ 分类 {updated.name} 已更新"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, updated.study_id, current_user), operator_role=await get_operator_role_label(db, updated.study_id, current_user),
) )
@@ -140,6 +141,7 @@ async def delete_category(
item_count = await item_crud.count_items_by_category(db, category_id) item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0: if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
category_name = category.name
await db.delete(category) await db.delete(category)
await db.commit() await db.commit()
await audit_crud.log_action( await audit_crud.log_action(
@@ -148,7 +150,7 @@ async def delete_category(
entity_type="faq_category", entity_type="faq_category",
entity_id=category_id, entity_id=category_id,
action="DELETE_FAQ_CATEGORY", action="DELETE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category_id} 已删除", detail=json.dumps({"targetName": category_name, "description": f"FAQ 分类 {category_name} 已删除"}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=await get_operator_role_label(db, category.study_id, current_user), operator_role=await get_operator_role_label(db, category.study_id, current_user),
) )
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { normalizeAuditEvent } from ".";
describe("normalizeAuditEvent", () => {
it("removes UUID tokens from legacy audit detail text", () => {
const event = normalizeAuditEvent(
{
action: "DELETE_FAQ_CATEGORY",
detail: "FAQ 分类 295c4072-3868-416e-9a35-24a63a3f97fb 已删除",
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "faq_category",
entity_id: "295c4072-3868-416e-9a35-24a63a3f97fb",
created_at: "2026-06-04T11:16:04Z",
},
{}
);
expect(event.diffText).toEqual(["FAQ 分类已删除"]);
expect(event.targetName).toBe("");
});
it("keeps readable shipment identifiers but hides UUID-only shipment identifiers", () => {
const event = normalizeAuditEvent(
{
action: "CREATE_DRUG_SHIPMENT",
detail: "药品运输 835d6a00-f696-4aec-a35d-8c359be201d2 已创建",
operator_id: "operator-1",
operator_role: "ADMIN",
entity_type: "drug_shipment",
entity_id: "835d6a00-f696-4aec-a35d-8c359be201d2",
created_at: "2026-06-04T10:27:02Z",
},
{}
);
expect(event.diffText).toEqual(["药品运输已创建"]);
expect(event.targetName).toBe("");
});
});
+12 -2
View File
@@ -111,7 +111,17 @@ const normalizeLegacyDetailLine = (line: string): string => {
} }
if (text === "立项配置已发布") return "立项配置已发布"; if (text === "立项配置已发布") return "立项配置已发布";
if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划"); if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划");
return text; return removeUuidOnlyIdentifier(text);
};
const uuidTextPattern = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
const removeUuidOnlyIdentifier = (text: string): string => {
return text.replace(new RegExp(`\\s+${uuidTextPattern}\\s+(?=已(?:创建|更新|删除))`, "g"), "");
};
const isUuidText = (value: unknown): boolean => {
return new RegExp(`^${uuidTextPattern}$`).test(String(value || "").trim());
}; };
const splitAuditDetailSegments = (line: string): string[] => { const splitAuditDetailSegments = (line: string): string[] => {
@@ -280,7 +290,7 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
targetType: raw.entity_type || "", targetType: raw.entity_type || "",
targetTypeLabel: dict.targetLabel, targetTypeLabel: dict.targetLabel,
targetId: raw.entity_id || "", targetId: raw.entity_id || "",
targetName: detailObj.targetName || raw.entity_id || "", targetName: detailObj.targetName || (isUuidText(raw.entity_id) ? "" : raw.entity_id || ""),
actionText: dict.actionText, actionText: dict.actionText,
before, before,
after, after,