规范审计日志目标名称展示
- 药品运输审计日志写入运单号、批号或中心名称作为可读目标名\n- FAQ 分类审计日志写入分类名称,删除时提前保留展示名称\n- 前端审计归一化隐藏 UUID-only 目标名,并清理旧文本中的 UUID 标识\n- 新增审计归一化测试覆盖 FAQ 分类和药品运输记录
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -18,6 +19,10 @@ from app.schemas.drug_shipment import (
|
||||
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):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
@@ -59,7 +64,10 @@ async def create_shipment(
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment.id,
|
||||
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_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
@@ -169,7 +177,10 @@ async def update_shipment(
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment_id,
|
||||
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_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]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await _ensure_center_active(db, study_id, shipment.center_id)
|
||||
shipment_name = _shipment_audit_name(shipment)
|
||||
await shipment_crud.delete_shipment(db, shipment)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
@@ -202,7 +214,7 @@ async def delete_shipment(
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment_id,
|
||||
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_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -48,7 +49,7 @@ async def create_category(
|
||||
entity_type="faq_category",
|
||||
entity_id=category.id,
|
||||
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_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_id=category_id,
|
||||
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_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)
|
||||
if item_count > 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
|
||||
category_name = category.name
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
await audit_crud.log_action(
|
||||
@@ -148,7 +150,7 @@ async def delete_category(
|
||||
entity_type="faq_category",
|
||||
entity_id=category_id,
|
||||
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_role=await get_operator_role_label(db, category.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,17 @@ const normalizeLegacyDetailLine = (line: string): string => {
|
||||
}
|
||||
if (text === "立项配置已发布") return "立项配置已发布";
|
||||
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[] => {
|
||||
@@ -280,7 +290,7 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
targetType: raw.entity_type || "",
|
||||
targetTypeLabel: dict.targetLabel,
|
||||
targetId: raw.entity_id || "",
|
||||
targetName: detailObj.targetName || raw.entity_id || "",
|
||||
targetName: detailObj.targetName || (isUuidText(raw.entity_id) ? "" : raw.entity_id || ""),
|
||||
actionText: dict.actionText,
|
||||
before,
|
||||
after,
|
||||
|
||||
Reference in New Issue
Block a user