Files
ctms/frontend/src/audit/index.ts
T

774 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { auditDict as localizedAuditDict } from "./auditDict";
import { roleDict, getDictLabel, statusDict } from "../dictionaries";
import { TEXT } from "../locales";
export interface AuditEvent {
eventType: string;
eventLabel: string;
actorId: string;
actorName: string;
actorAccount?: string;
actorRole: string;
actorRoleLabel: string;
targetType: string;
targetTypeLabel: string;
targetId: string;
targetName?: string;
actionText: string;
before?: Record<string, any>;
after?: Record<string, any>;
diffText?: string[];
reason?: string;
result: "SUCCESS" | "FAIL";
resultLabel: string;
timestamp: string;
clientIp?: string;
ipLocation?: string;
ipCountry?: string;
ipProvince?: string;
ipCity?: string;
ipIsp?: string;
clientType?: string;
clientVersion?: string;
clientPlatform?: string;
buildChannel?: string;
buildCommit?: string;
userAgent?: string;
clientSourceLabel?: string;
}
const entityTypeLabelMap: Record<string, string> = {
study_setup_config: "立项配置",
study: "项目",
subject: "参与者",
visit: "访视",
ae: "不良事件",
contract_fee: "合同费用条目",
contract_fee_payment: "合同费用回款",
drug_shipment: "药品流向",
startup_feasibility: "立项记录",
startup_ethics: "伦理记录",
startup_kickoff: "启动会",
training_authorization: "培训授权",
precaution: "注意事项",
subject_history: "病史记录",
faq_category: "医学咨询分类",
faq_item: "医学咨询问题",
faq_reply: "医学咨询回复",
DOCUMENT: "文档",
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> = {
projectMilestones: "项目里程碑",
enrollmentPlan: "项目入组计划",
siteMilestones: "中心里程碑",
siteEnrollmentPlans: "中心入组计划",
centerConfirm: "中心确认",
};
const toReadableEntityType = (value?: string): string => {
const key = String(value || "").trim();
if (!key) return TEXT.audit.targetFallback;
return entityTypeLabelMap[key] || key;
};
const normalizeSetupModuleText = (raw: string): string => {
const text = String(raw || "").trim();
if (!text) return "无";
return text
.split(/[,]/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => setupModuleLabelMap[item] || item)
.join("、") || "无";
};
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();
const statusLabel = status === "success" ? "成功" : status === "failed" ? "失败" : status;
return `发布同步结果:${statusLabel}`;
}
const studyUpdatedMatch = text.match(/^study_updated=(.+)$/i);
if (studyUpdatedMatch) {
const rawValue = studyUpdatedMatch[1].trim().toLowerCase();
return `已同步项目主信息:${rawValue === "true" ? "是" : rawValue === "false" ? "否" : rawValue}`;
}
const siteUpdatedMatch = text.match(/^site_updated=(\d+)$/i);
if (siteUpdatedMatch) return `已同步中心数:${siteUpdatedMatch[1]}`;
const siteSkippedMatch = text.match(/^site_skipped=(\d+)$/i);
if (siteSkippedMatch) return `未同步中心数:${siteSkippedMatch[1]}`;
const warningsMatch = text.match(/^warnings=(.*)$/i);
if (warningsMatch) {
const value = warningsMatch[1].trim();
return `同步告警:${value === "-" ? "无" : value}`;
}
const skippedReasonMatch = text.match(/^skipped_reason_counts=(.*)$/i);
if (skippedReasonMatch) {
const value = skippedReasonMatch[1].trim();
return `未同步原因统计:${value === "-" ? "无" : value}`;
}
const setupModuleMatch = text.match(/^变更模块[:]\s*(.*)$/);
if (setupModuleMatch) {
return `变更模块:${normalizeSetupModuleText(setupModuleMatch[1])}`;
}
const detailMatch = text.match(/^变更明细[:]\s*(.*)$/);
if (detailMatch) {
const detailText = detailMatch[1].trim();
return detailText;
}
if (text === "立项配置已发布") return "立项配置已发布";
if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划");
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 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 [];
return text
.split(/[;\n]/)
.map((item) => item.trim())
.filter(Boolean);
};
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: "监查类型",
monitor_item: "监查项",
category: "问题分类",
recommendation: "建议措施",
subject_name: "受试者",
subject_code: "受试者缩写号",
subject_no: "参与者编号",
description: "问题描述",
action_taken: "采取措施",
follow_up_progress: "跟进计划及进展",
found_date: "发现时间",
due_at: "预计解决日期",
actual_resolve_date: "实际解决日期",
closed_at: "关闭日期",
responsible_name: "责任人",
creator_name: "创建者",
status: "状态",
is_active: "启用状态",
role: "角色",
role_in_study: "项目角色",
full_name: "姓名",
username: "账号",
clinical_department: "科室",
email: "邮箱",
name: "名称",
code: "编号",
site_name: "中心名称",
pi_name: "PI",
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 => {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, any>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${stableSerialize(v)}`);
return `{${entries.join(",")}}`;
}
return String(value);
};
const isSameValue = (prev: any, next: any): boolean => stableSerialize(prev) === stableSerialize(next);
const compactObjectValue = (value: Record<string, any>): string => {
const preferredKeys = ["name", "label", "title", "code", "id", "value"];
for (const key of preferredKeys) {
const text = String(value[key] ?? "").trim();
if (text) return text;
}
const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== "");
if (entries.length === 0) return "未填写";
const rendered = entries
.slice(0, 3)
.map(([k, v]) => {
const valStr = typeof v === "object" ? JSON.stringify(v) : String(v);
return `${toReadableFieldLabel(k)}=${valStr}`;
})
.join("");
return entries.length > 3 ? `${rendered}${entries.length}项` : rendered;
};
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 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 ? "启用" : "停用";
return value ? "是" : "否";
}
if (typeof value === "number") return String(value);
if (typeof value === "string") {
const text = value.trim();
if (!text) return "未填写";
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, userMap)).join("、");
return value.length > 3 ? `${rendered}${value.length}项` : rendered;
}
if (typeof value === "object") {
return compactObjectValue(value as Record<string, any>);
}
return String(value);
};
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 || {};
const nextObj = after || {};
Object.keys({ ...prevObj, ...nextObj }).forEach((key) => {
const prev = prevObj[key];
const next = nextObj[key];
if (isSameValue(prev, next)) return;
const label = toReadableFieldLabel(key, entityType);
const prevText = toReadableValue(key, prev, userMap);
const nextText = toReadableValue(key, next, userMap);
lines.push(`${label}${prevText} -> ${nextText}`);
});
return lines;
};
const fallbackIpLocation = (ip: string | null | undefined): string => {
if (!ip) return "";
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
return "";
};
export const formatAuditIpLocation = (raw: {
ip_country?: string | null;
ip_province?: string | null;
ip_city?: string | null;
ip_isp?: string | null;
ip_location?: string | null;
client_ip?: string | null;
clientIp?: string | null;
}): string => {
const parts = [
raw.ip_country && raw.ip_country !== "中国" ? raw.ip_country : "",
raw.ip_province,
raw.ip_city,
raw.ip_isp,
].filter(Boolean);
if (parts.length) return parts.join(" / ");
return raw.ip_location || fallbackIpLocation(raw.client_ip || raw.clientIp) || "";
};
const isAutomationUserAgent = (userAgent = ""): boolean =>
/curl|wget|python-requests|httpie|go-http-client|node-fetch|axios|postman|insomnia|okhttp|java|powershell|libwww/i.test(userAgent);
const isBrowserUserAgent = (userAgent = ""): boolean =>
/mozilla|chrome|safari|firefox|edg\//i.test(userAgent);
export const resolveAuditClientSourceLabel = (raw: {
client_type?: string | null;
client_platform?: string | null;
user_agent?: string | null;
clientType?: string | null;
clientPlatform?: string | null;
userAgent?: string | null;
}): string => {
const clientType = String(raw.client_type || raw.clientType || "").trim().toLowerCase();
const platform = String(raw.client_platform || raw.clientPlatform || "").trim();
const userAgent = String(raw.user_agent || raw.userAgent || "").trim();
if (clientType === "web") return "网页端";
if (clientType === "desktop") return platform ? `桌面端 / ${platform}` : "桌面端";
if (clientType) return `未知客户端 / ${clientType}`;
if (isAutomationUserAgent(userAgent)) return "脚本/命令行";
if (isBrowserUserAgent(userAgent)) return "浏览器(未标识)";
return "未知来源";
};
export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => {
const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type);
let detailObj: any = {};
if (raw.detail) {
try {
detailObj = JSON.parse(raw.detail);
} catch {
detailObj = { description: raw.detail };
}
}
const before = detailObj.before;
const after = detailObj.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 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,
actorId: raw.operator_id,
actorName: raw.operator_name || userMap[raw.operator_id] || raw.operator_id,
actorAccount: raw.operator_email || raw.operator_id,
actorRole: raw.operator_role,
actorRoleLabel: getDictLabel(roleDict, raw.operator_role),
targetType: raw.entity_type || "",
targetTypeLabel: dict.targetLabel,
targetId: raw.entity_id || "",
targetName,
actionText,
before,
after,
diffText: readableDiffText,
reason: detailObj.reason,
result: detailObj.result || "SUCCESS",
resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess,
timestamp: raw.created_at,
clientIp: raw.client_ip || "",
ipLocation: formatAuditIpLocation(raw),
ipCountry: raw.ip_country || "",
ipProvince: raw.ip_province || "",
ipCity: raw.ip_city || "",
ipIsp: raw.ip_isp || "",
clientType: raw.client_type || "",
clientVersion: raw.client_version || "",
clientPlatform: raw.client_platform || "",
buildChannel: raw.build_channel || "",
buildCommit: raw.build_commit || "",
userAgent: raw.user_agent || "",
clientSourceLabel: resolveAuditClientSourceLabel(raw),
};
};
// 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;
void payload;
return Promise.resolve();
};