23 KiB
审计模块审计报告 v2
审计范围:
src/modules/audit/**、src/app/(dashboard)/admin/audit-logs/**、src/app/api/export/route.ts(审计导出分支)、src/app/api/cron/audit-cleanup/route.ts、src/shared/lib/audit-logger.ts、src/shared/lib/change-logger.ts、src/shared/i18n/messages/{zh-CN,en}/audit.json审计日期:2026-06-25 审计依据:docs/architecture/004_architecture_impact_map.md(2.15 节)、docs/architecture/005_architecture_data.json(audit 节点)、docs/standards/coding-standards.md、项目project_rules.md前置报告:docs/architecture/audit/audit-module-audit-report.md(v1,2026-06-24)
一、现有实现概要
1.1 文件分布
v1 审计后已完成两轮重构(P0-P2),当前文件分布如下:
| 层 | 文件 | 行数 | 职责 |
|---|---|---|---|
| 路由 | app/(dashboard)/admin/audit-logs/page.tsx |
79 | 操作日志列表页(RSC,调 data-access) |
| 路由 | app/(dashboard)/admin/audit-logs/login-logs/page.tsx |
79 | 登录日志列表页 |
| 路由 | app/(dashboard)/admin/audit-logs/data-changes/page.tsx |
83 | 数据变更日志列表页 |
| 路由 | app/(dashboard)/admin/audit-logs/overview/page.tsx |
34 | 审计概览仪表盘(✅ v1 P2-4 新增) |
| 路由 | 4 个 loading.tsx + 4 个 error.tsx |
— | 骨架屏 + 错误边界(✅ v1 P0-2 已修复) |
| 路由 | app/api/export/route.ts |
202 | 统一导出 API(含 audit/login/dataChange 三分支) |
| 路由 | app/api/cron/audit-cleanup/route.ts |
75 | 定时清理 cron(✅ v1 P2-5 新增) |
| actions | modules/audit/actions.ts |
195 | 7 个 Server Action(1 查询 + 3 导出 + 3 保留策略) |
| data-access | modules/audit/data-access.ts |
387 | 12 个查询函数(分页 + 导出 + 选项 + 统计 + 趋势) |
| export | modules/audit/export.ts |
133 | Excel 列定义 + 行映射 + buildExport(✅ v1 P1-3 已抽取) |
| retention | modules/audit/retention.ts |
123 | 保留策略配置读写 + 清理 + 纯函数(✅ v1 P2-5 新增) |
| types | modules/audit/types.ts |
145 | 类型定义 + 状态映射常量 |
| services | modules/audit/services/audit-service.tsx |
124 | AuditService 接口 + Context + hook(✅ v1 P2-8 新增) |
| services | modules/audit/services/admin-audit-service.ts |
40 | 管理员默认实现 |
| services | modules/audit/services/mock-audit-service.ts |
57 | 测试用 Mock 实现 |
| hooks | modules/audit/hooks/use-log-pagination.ts |
24 | 分页 URL 状态 hook(✅ v1 P1-2 已抽取) |
| 组件 | 15 个组件文件 | 19-191 | 表格/筛选器/视图/详情对话框/概览/图表/保留配置/骨架屏/错误边界 |
| 测试 | export.test.ts / retention.test.ts |
191/81 | 纯函数单测(✅ v1 P2-6 已补充) |
| i18n | shared/i18n/messages/{zh-CN,en}/audit.json |
130 | 完整翻译键(table/filter/empty/export/error/detail/overview/retention) |
| shared | shared/lib/audit-logger.ts / change-logger.ts |
— | logAudit() / logDataChange() 写入日志 |
1.2 数据流
page.tsx (RSC)
├─ requirePermission(AUDIT_LOG_READ)
├─ getAuditLogs/getLoginLogs/getDataChangeLogs (data-access 直调)
└─ <AuditErrorBoundary>
<AuditLogView items={...} /> (Client Component)
├─ <AuditLogFilters> (nuqs URL 状态)
└─ <AuditLogTable> (分页 + 空状态 + 详情对话框)
overview/page.tsx (RSC)
├─ requirePermission(AUDIT_LOG_READ)
├─ getAuditOverviewStats / getAuditTrend / getDataChangeActionStats
└─ <AuditOverviewView> (RSC)
├─ <AuditOverviewStatsBar>
├─ <AuditActivityTrendChart>
├─ <DataChangeDistributionChart>
└─ <AuditRetentionSettings> (Client Component,直调 Server Actions)
导出流:<AuditLogExportButton> → fetch /api/export → exportAuditLogsAction → getAuditLogsForExport → buildAuditLogExport → Excel buffer。
定时清理流:/api/cron/audit-cleanup(CRON_SECRET 鉴权)→ getAuditRetentionConfig → purgeExpiredAuditLogs。
1.3 架构图记录情况
- 004(2.15 节):记录较完整,含 services/hooks/export/retention 节点,行数标注准确。
- 005(audit 节点):data-access/actions/types 记录完整,含 services/hooks/export/retention 节点。
- 已知不一致:services 层的
AuditServiceProvider在架构图中标注为"已接入",但实际未被任何页面使用(详见 P0-1)。
二、现存问题与原因分析
P0 — 严重违规
P0-1 Service 抽象层定义但从未使用(虚假完成)
- 位置:
services/audit-service.tsx(AuditServiceProvider、useAuditService、useAuditAnalytics)、services/admin-audit-service.ts、services/mock-audit-service.ts - 违反规则:任务要求 → "完全解耦:通过定义 TypeScript 接口抽象数据依赖,使用 React Context(或组合 Provider)注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access";
project_rules.md→ "架构图优先规则:改码必同步图" - 问题:
- 全局搜索
AuditServiceProvider的实际使用:仅在audit-service.tsx和mock-audit-service.ts的 JSDoc@example中出现,没有任何页面或组件实际注入 - 4 个
page.tsx均直接import { getAuditLogs, ... } from "@/modules/audit/data-access" audit-retention-settings.tsx直接import { getAuditRetentionConfigAction, ... } from "@/modules/audit/actions"audit-log-export-button.tsx直接fetch("/api/export")绕过 Service 层- 架构图 004 第 1468 行声称"✅ P2-8 已修复:
无 Service 接口抽象",实际为"定义但未接线"
- 全局搜索
- 原因:P2-8 仅创建了接口文件,未在页面层注入 Provider、未将组件改造为从 Context 获取 service
- 后果:
- DI 架构形同虚设,组件仍硬编码依赖 data-access,无法在测试中注入 mock
- 架构图声称已修复,误导后续审计与维护决策
mockAuditService完全无引用(死代码)
P0-2 mock-audit-service.ts 使用 9 处 as 类型断言
- 位置:
services/mock-audit-service.ts第 29、31、33、36、45、46、47、53、60 行 - 违反规则:
project_rules.md→ "禁止as断言(除类型收窄外)";project_memory.md→ "TypeScript strict mode: noany, noasassertions (except for type narrowing)" - 示例:
getAuditLogs: async () => ({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) as PaginatedResult<AuditLog>, - 原因:对象字面量缺少
items: [] as AuditLog[]类型标注,导致需要as断言整个返回值 - 后果:违反 TS 严格规则;lint 通过但 code review 应拦截
P0-3 全部 7 个 Server Action 缺少 revalidatePath
- 位置:
actions.ts所有 7 个 Action(getDataChangeLogsAction、exportAuditLogsAction、exportLoginLogsAction、exportDataChangeLogsAction、getAuditRetentionConfigAction、saveAuditRetentionConfigAction、purgeAuditLogsAction) - 违反规则:
project_rules.md→ "Server Action 规范:使用revalidatePath精确刷新缓存" - 问题:
saveAuditRetentionConfigAction和purgeAuditLogsAction修改数据后未刷新 overview 页面缓存,用户保存保留策略后看到的仍是旧配置 - 原因:遗漏
- 后果:保留策略变更后概览页缓存不刷新,显示过期数据
P1 — 重要缺陷
P1-1 trackEvent event 名称误用(保留策略操作误标为导出)
- 位置:
actions.ts第 170、199 行(saveAuditRetentionConfigAction、purgeAuditLogsAction)、app/api/cron/audit-cleanup/route.ts第 54 行 - 违反规则:任务要求 → "监控:方案中预留关键操作埋点接口"
- 问题:保留策略的保存/清理操作使用
event: "audit.exported",语义错误// saveAuditRetentionConfigAction await trackEvent({ event: "audit.exported", ... properties: { action: "save_config" } }) // purgeAuditLogsAction void trackEvent({ event: "audit.exported", ... properties: { action: "purge" } }) // cron route void trackEvent({ event: "audit.exported", ... properties: { action: "cron_purge" } }) - 后果:分析平台中所有保留策略操作被归类为"导出",无法区分导出与清理行为
P1-2 AuditErrorBoundary 使用错误的 i18n namespace
- 位置:
components/audit-error-boundary.tsx第 19 行namespace="common" - 违反规则:
project_rules.md→ "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键" - 问题:
SectionErrorBoundary读取t("error.boundaryTitle")/t("error.boundaryDescription")/t("error.retry")。audit.json定义了error.title/error.description/error.retry(键名不匹配),而common.json有error.boundaryTitle/error.boundaryDescription。当前传"common"可工作但使用的是通用错误文案,未利用 audit 专属的error.description("数据加载时发生错误,请稍后重试。") - 后果:区块级错误边界显示通用错误文案而非审计模块专属文案
P1-3 AuditAnalytics 接口定义为死代码
- 位置:
services/audit-service.tsx第 64-84 行(AuditAnalytics接口 +noopAnalytics+AuditAnalyticsContext+useAuditAnalyticshook) - 违反规则:任务要求 → "监控:方案中预留关键操作埋点接口"
- 问题:
useAuditAnalyticshook 全局搜索仅出现在定义处,无任何组件调用。trackLogView/trackExport/trackOverviewView/trackRetentionConfigChange/trackPurge五个埋点方法均为死代码 - 后果:客户端层面无日志查看/导出/概览查看埋点,无法统计用户行为
P1-4 data-access 错误处理不一致
- 位置:
data-access.ts- 吞没错误(返回空数组):
getAuditModuleOptions(第 152 行)、getDataChangeTableOptions(第 237 行) - 抛出错误:
getAuditLogs、getLoginLogs、getDataChangeLogs、getDataChangeStats、getAuditOverviewStats、getAuditTrend、getDataChangeActionStats
- 吞没错误(返回空数组):
- 违反规则:
project_rules.md→ "错误与边界处理:明确处理空数据、无权限、网络异常等边界状态" - 后果:DB 故障时筛选器选项静默显示为空,用户误认为"无数据"而非"查询失败"
P1-5 日期筛选器 aria-label 不区分起止
- 位置:
audit-log-filters.tsx第 94、101 行;login-log-filters.tsx第 78、85 行;data-change-log-filters.tsx第 117、124 行 - 违反规则:
project_rules.md→ "可访问性(a11y):语义化标签、ARIA 属性、键盘导航" - 问题:两个日期 Input 均使用
aria-label={t("table.time")}("时间"),屏幕阅读器无法区分"开始日期"与"结束日期" - 后果:视障用户无法区分两个日期输入框
P1-6 AuditLogDetailDialog DialogDescription 重复标题
- 位置:
components/audit-log-detail-dialog.tsx第 135-137 行 - 问题:
Description 与 Title 完全相同,无信息增量
<DialogTitle>{t("detail.title")}</DialogTitle> <DialogDescription className="sr-only">{t("detail.title")}</DialogDescription> - 后果:无障碍辅助技术读出重复信息
P1-7 骨架屏缺少 aria-hidden
- 位置:
components/audit-log-table-skeleton.tsx(全文件)、4 个loading.tsx - 违反规则:
project_rules.md→ "可访问性(a11y)" - 问题:Skeleton 元素无
aria-hidden="true",屏幕阅读器会逐个朗读骨架占位块 - 后果:加载期间屏幕阅读器体验差
P1-8 window.confirm 阻塞式确认
- 位置:
components/audit-retention-settings.tsx第 80 行 - 问题:
window.confirm(t("purgeConfirm"))使用浏览器原生确认框,不可自定义样式、阻塞主线程、a11y 差 - 后果:与其他模块(使用 AlertDialog 组件)交互不一致
P2 — 改进项
P2-1 数据变更 diff 无可视化
- 位置:
data-change-log-table.tsx第 118-129 行使用<pre>纯文本展示 oldValue / newValue - 差距:PowerSchool / Google Workspace Audit / Microsoft Purview 均提供 JSON diff 高亮(增行绿、删行红、左右对比)
P2-2 无失败登录异常告警
- 位置:全模块
- 差距:行业产品支持阈值告警(如 5 分钟内同一 IP 失败登录 > 10 次触发告警)
P2-3 无 IP 地理位置
- 位置:表格仅展示 IP 字符串
- 差距:行业产品将 IP 解析为地理位置(城市/国家),辅助判断异地登录
P2-4 概览趋势仅 7 天不可配置
- 位置:
overview/page.tsx第 29 行getAuditTrend(7)硬编码 7 天 - 差距:行业产品支持 7/30/90 天切换
P2-5 多学校数据隔离缺失
- 位置:
data-access.ts所有查询无schoolId过滤 - 说明:
audit_logs/login_logs/data_change_logs表无school_id字段(schema 确认),audit 模块设计为系统级跨校审计。当前AUDIT_LOG_READ权限若仅授予超级管理员则可接受,但需在文档中明确标注此设计决策 - 风险:若未来授予校级管理员
AUDIT_LOG_READ,将导致跨校数据泄露
P2-6 useLogPagination hook 无单测
- 位置:
hooks/use-log-pagination.ts - 违反规则:
project_rules.md→ "可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks" - 说明:v1 已补充 export.test.ts 和 retention.test.ts,但 hook 层无单测
P2-7 导出按钮无进度反馈
- 位置:
audit-log-export-button.tsx - 问题:大范围导出仅显示 spinner,无进度条/计数
- 差距:行业产品显示"已导出 X / Y 条"
三、行业差距对比
| 能力 | PowerSchool / Veracross | Google Workspace Audit | Microsoft Purview | 本系统现状 | 影响 |
|---|---|---|---|---|---|
| 统一审计概览仪表盘 | ✅ | ✅ | ✅ | ✅ 已有概览页 | — |
| 按用户/模块/动作/状态筛选 | ✅ | ✅ | ✅ | ✅ 已有完整筛选器 | — |
| 日志详情视图 | ✅ 点击展开完整 JSON | ✅ 详情面板 | ✅ 活动详情 | ✅ 已有详情对话框 | — |
| 数据变更 diff 可视化 | ✅ 左右对比 + 高亮 | ✅ | ✅ 差异高亮 | ❌ 纯文本 pre 展示 | 变更审查效率低 |
| 失败登录监控/告警 | ✅ 异常登录告警 | ✅ 可疑活动检测 | ✅ 实时告警 | ❌ 仅展示无告警 | 无法及时发现暴力破解 |
| 导出调度/定时 | ✅ 计划报告 | ✅ 导出 + 邮件 | ✅ 合规报告 | ❌ 仅手动导出 | 合规审计需人工操作 |
| 数据保留策略 | ✅ 可配置保留期 | ✅ | ✅ | ✅ 已有保留策略配置 | — |
| IP 地理位置 | ✅ | ✅ | ✅ | ❌ 仅显示 IP | 无法判断异地登录 |
| 多角色审计视图 | ✅ 管理员/合规官分级 | ✅ | ✅ | ⚠️ 仅 admin 单角色 + Service 接口预留 | 合规官角色需独立视图 |
| i18n | ✅ 多语言 | ✅ | ✅ | ✅ 已完整 i18n | — |
| 概览趋势可配置时间范围 | ✅ 7/30/90 天 | ✅ | ✅ | ❌ 硬编码 7 天 | 无法查看长期趋势 |
| DI 架构(可测试) | — | — | — | ⚠️ 定义未接线 | 组件无法 mock 数据源 |
四、改进优先级建议
P0 — 立即修复(合规与架构正确性)
| 编号 | 问题 | 改进方向 |
|---|---|---|
| P0-1 | Service 抽象层未接线 | overview 页面接入 AuditServiceProvider,AuditOverviewView 内组件改用 useAuditService() 获取数据;或若评估后认为 RSC + props 模式已满足需求则删除未使用的 services 层避免误导 |
| P0-2 | mock-audit-service.ts 9 处 as 断言 | 为对象字面量添加元素类型标注(如 items: [] as AuditLog[] → 改用 items: new Array<AuditLog>() 或显式标注返回类型) |
| P0-3 | 7 个 Action 缺 revalidatePath | 写操作(saveAuditRetentionConfigAction、purgeAuditLogsAction)添加 revalidatePath("/admin/audit-logs/overview") |
P1 — 本轮实施(规范与体验)
| 编号 | 问题 | 改进方向 |
|---|---|---|
| P1-1 | trackEvent event 误标 | 保留策略操作改用 event: "audit.retention" |
| P1-2 | ErrorBoundary namespace 错误 | audit.json 新增 error.boundaryTitle / error.boundaryDescription,AuditErrorBoundary 传 namespace="audit" |
| P1-3 | AuditAnalytics 死代码 | 组件中调用 useAuditAnalytics() 接入客户端埋点(日志查看、概览查看、导出、保留策略变更) |
| P1-4 | data-access 错误处理不一致 | getAuditModuleOptions / getDataChangeTableOptions 改为抛出错误 |
| P1-5 | 日期 aria-label 不区分 | 新增 i18n filter.startDate / filter.endDate,替换 t("table.time") |
| P1-6 | DialogDescription 重复 | 新增 detail.description 翻译键 |
| P1-7 | 骨架屏缺 aria-hidden | Skeleton 容器添加 aria-hidden="true" |
| P1-8 | window.confirm | 替换为 AlertDialog 组件(与其他模块一致) |
P2 — 中长期迭代(功能增强)
| 编号 | 问题 | 改进方向 |
|---|---|---|
| P2-1 | diff 无可视化 | 引入 JSON diff 组件(左右对比 + 增删高亮) |
| P2-2 | 无失败登录告警 | 新增异常检测规则 + 告警通知 |
| P2-3 | 无 IP 地理位置 | 接入 IP 反查服务(如 MaxMind GeoLite2) |
| P2-4 | 趋势不可配置 | 概览页新增 7/30/90 天切换按钮 |
| P2-5 | 多校数据隔离 | 文档标注"系统级审计"设计决策;若需校级审计则新增 school_id 字段 |
| P2-6 | hook 无单测 | 为 useLogPagination 补充单测 |
| P2-7 | 导出无进度 | 大范围导出显示进度条 |
五、架构图同步说明
本次审计发现架构图以下遗漏/不一致,需在实现后同步更新:
004_architecture_impact_map.md(2.15 节)
- 修正 services 层状态:第 1468 行"✅ P2-8 已修复"改为"P2-8 已定义接口,P0-1 待接线"(实施后改回"已接线")
- 修正 trackEvent 埋点描述:保留策略操作的 event 名称从
audit.exported改为audit.retention - 新增 revalidatePath 说明:actions 节标注 saveAuditRetentionConfigAction / purgeAuditLogsAction 已添加 revalidatePath
005_architecture_data.json(audit 节点)
- services 节点:标注
AuditServiceProvider的usedBy(实施后从空改为 overview page) - actions 节点:更新 saveAuditRetentionConfigAction / purgeAuditLogsAction 的 trackEvent event 名称
- components 节点:更新
audit-error-boundary.tsx的 namespace 配置
六、重构方案设计
a. P0-1 Service 抽象层接线方案
决策:保留 Service 接口(满足任务要求的 DI 架构),在 overview 页面接线。
由于 AuditOverviewView 是 RSC(async server component),而 AuditServiceProvider 是 Client Component("use client"),无法直接在 RSC 中使用 Context Provider。因此采用混合模式:
- RSC 页面:仍由 page.tsx 调用 data-access 获取初始数据(保留 SSR 性能优势)
- 客户端交互组件(
AuditRetentionSettings):通过AuditServiceProvider注入 service,组件内部用useAuditService()获取数据 adminAuditService改为可被 Client Component 引用的轻量包装(仅委托给 Server Action,不直接 import server-only 的 data-access)
// services/admin-audit-service.ts —— 改造为 Client-safe 实现
// 不 import "server-only",改为委托 Server Actions
import { getAuditRetentionConfigAction, saveAuditRetentionConfigAction, purgeAuditLogsAction } from "../actions"
export const adminAuditService: AuditService = {
// 保留策略相关:通过 Server Action 调用
getAuditRetentionConfig: async () => {
const res = await getAuditRetentionConfigAction()
if (!res.success || !res.data) throw new Error(res.message ?? "Failed")
return res.data
},
saveAuditRetentionConfig: async (config) => {
const res = await saveAuditRetentionConfigAction(config)
if (!res.success || !res.data) throw new Error(res.message ?? "Failed")
},
purgeExpiredAuditLogs: async (retentionDays) => {
const res = await purgeAuditLogsAction(retentionDays)
if (!res.success || !res.data) throw new Error(res.message ?? "Failed")
return res.data
},
// 查询类:RSC 页面已通过 props 传入,客户端不需要
// ...其余方法可暂不实现或抛错
}
AuditRetentionSettings 改造:
// 在组件内部使用 useAuditService() 而非直接调 Server Action
const service = useAuditService()
const res = await service.getAuditRetentionConfig()
页面注入:
// overview/page.tsx
import { AuditServiceProvider } from "@/modules/audit/services/audit-service"
import { adminAuditService } from "@/modules/audit/services/admin-audit-service"
return (
<AuditServiceProvider service={adminAuditService}>
<AuditOverviewView stats={stats} trend={trend} distribution={distribution} />
</AuditServiceProvider>
)
b. P0-2 mock-audit-service 类型标注修复
// 修复前
getAuditLogs: async () =>
({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) as PaginatedResult<AuditLog>,
// 修复后 —— 为返回值添加显式类型标注,无需 as
getAuditLogs: async (): Promise<PaginatedResult<AuditLog>> => ({
items: [],
total: 0,
page: 1,
pageSize: 20,
totalPages: 0,
}),
c. P0-3 revalidatePath
import { revalidatePath } from "next/cache"
// saveAuditRetentionConfigAction 末尾
revalidatePath("/admin/audit-logs/overview")
return { success: true, data: config }
// purgeAuditLogsAction 末尾
revalidatePath("/admin/audit-logs/overview")
revalidatePath("/admin/audit-logs")
return { success: true, data: result }
d. P1-1 trackEvent event 修正
// 保留策略操作
await trackEvent({
event: "audit.retention", // 原: "audit.exported"
userId: session?.user?.id,
targetType: "audit_retention",
properties: { action: "save_config", ... },
})
e. P1-2 ErrorBoundary namespace 修复
audit.json 新增:
"error": {
"title": "加载失败",
"description": "数据加载时发生错误,请稍后重试。",
"retry": "重试",
"boundaryTitle": "审计数据加载失败",
"boundaryDescription": "审计数据区块加载时发生错误。"
}
audit-error-boundary.tsx:
<SectionErrorBoundary namespace="audit">
f. i18n 翻译键补充
"filter": {
"startDate": "开始日期",
"endDate": "结束日期"
},
"detail": {
"description": "查看日志的完整字段信息。"
}
g. 最终检查
- 该模块不存在对其他业务模块的直接 import(仅依赖 shared/* 和 settings data-access for retention config)
- 没有使用
any或硬编码角色字符串 - 所有 actions 包含
requirePermission调用(7 个 Action 均有) - 文件行数未超过建议上限(最大 data-access.ts 387 行 < 800)
- 架构影响地图需同步更新(见第五节)