Files
NextEdu/docs/architecture/audit/archive/performance-budget-audit-report.md

65 KiB
Raw Blame History

性能预算重构专项 - 完整审计报告

审计日期: 2026-07-05 审计范围: e:\Desktop\CICD 全项目Next.js 16.0.10 + React 19.2.1 + Turbopack + Drizzle ORM + MySQL + NextAuth v5 审计维度: ① Bundle 体积预算(首期重点)② Core Web Vitals ③ 组件渲染性能 ④ Server Action / 数据层 审计方式: 静态代码搜索 + 依赖图分析 + 源码路径追踪(未运行时采样) 交付形式: 完整审计报告(不做实施) 说明: 当前 next build 因 Turbopack 编译错误未成功出包,量化指标为基于依赖图与库体积经验值的合理推断值,需在修复 P0 项后重新构建获取实际产物体积进行第二轮校准。


目录


执行摘要

本次性能预算审计覆盖 4 个维度,共发现 24 个 P0/P1 问题、22 个 P2 问题、14 个 P3 问题,全部为静态发现,未做运行时验证。

P0 严重问题(影响生产稳定性 / 必须先修)

# 维度 问题 文件位置
1 Bundle KnowledgeGraph@xyflow/react未动态导入textbook 路由 chunk 膨胀 ~120 KB src/modules/textbooks/components/knowledge-graph.tsx:5-14
2 Bundle ExamRichEditor / RichTextEditor / rich-text-block 三处 tiptap 未动态导入,编辑器路由 chunk 膨胀 ~180 KB src/shared/components/ui/rich-text-editor.tsx:5-8src/modules/exams/editor/exam-rich-editor.tsx:4-6src/modules/lesson-preparation/components/blocks/rich-text-block.tsx:3-6
3 Bundle next.config.ts 未启用 experimental.optimizePackageImportslucide-recharts-radix-tiptap 全部未优化) next.config.ts:6-11
4 Bundle serverExternalPackages 仅含 mysql2未追加 tencentcloud-sdk-nodejs2-3 MB 误打包风险) next.config.ts:10
5 Bundle src/modules/exams/editor/index.ts barrel 已实际造成构建错误(代码中已有显式绕过注释) src/modules/exams/editor/index.ts
6 CWV 无 Web Vitals 上报闭环,生产环境零真实用户性能数据 src/app/layout.tsx(缺失 useReportWebVitals
7 组件 React Compilerbabel-plugin-react-compiler未安装memoization 缺失无法自动补全 package.json + next.config.ts
8 组件 SidebarProvider value 内联对象 + toggleSidebar 未 useCallback + resize 监听未防抖,全站 Header/Sidebar 高频重渲染 src/modules/layout/components/sidebar-provider.tsx:63-66
9 组件 备课编辑器 LessonPlanEditor 通过 useLessonPlanEditor() 订阅全 store每次按键触发三栏全量重渲染 src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62
10 组件 题库表 QuestionDataTable 无虚拟化1000+ 题时首屏渲染秒级卡顿 src/modules/questions/components/question-data-table.tsx:45-59
11 数据层 copyCoursePlanToClasses 双层循环单条 INSERT无事务无批量无大小限制 src/modules/course-plans/data-access.ts:347-406
12 数据层 跨请求数据缓存完全缺失0 处 unstable_cache),低变更频率数据每次重查 DB 全项目
13 数据层 每次 Server Action 调用必查 DB 解析 DataScopeadmin 0 次 / teacher 2 次 / parent 2 次串行) src/shared/lib/auth-guard.ts:44-47
14 数据层 searchQuestions JSON 字段 CAST 后 LIKE %kw% 全表扫描 src/modules/search/data-access.ts:74-75
15 数据层 getAllUserIds 无 LIMIT 全表返回5000+ 用户场景公告 fan-out 内存爆炸 src/modules/users/data-access.ts:348-351

P1 高优先级问题(影响高频接口性能)

详见各分章节,共 9 项关键 P1recharts barrel import、tencentcloud-sdk serverExternalPackages、exams/editor barrel 拆分、loading.tsx 缺失、force-dynamic 滥用、Lighthouse CI 缺失、layout 串行 await、autoGradeSubmission 循环 UPDATE、exams 索引缺失等)。

关键优化杠杆点ROI 排序)

  1. React Compiler 启用:单点改动,自动解决第 3 部分列出的多数 memoization 反模式
  2. next.config.ts 完善单点改动optimizePackageImports + serverExternalPackages 扩展
  3. tiptap / ReactFlow 动态导入3 处文件改动,编辑器路由 chunk 减少 ~300 KB
  4. 备课编辑器 store 订阅拆分:单文件改动,编辑器按键响应速度预计提升 5-10x
  5. unstable_cache 引入DataScope + 低变更数据缓存,单次 Server Action DB 查询数减半

第一部分Bundle 体积预算审计(首期重点)

1.1 大体积依赖清单

依赖 版本 估算体积min+gzip 用途 服务端/客户端 严重度
recharts ^3.6.0 ~95-110 KB 图表库13 处使用) 客户端 P1
@xyflow/react ^12.11.0 ~120-140 KB ReactFlow 知识图谱 客户端 P1
react-force-graph-2d ^1.29.1 ~80-100 KB含 d3-force 引力图谱 客户端 P2已 dynamic
tencentcloud-sdk-nodejs ^4.1.254 ~2-3 MB(巨型 SDK 腾讯云短信 服务端 P0
exceljs ^4.4.0 ~250-300 KB Excel 导入导出 服务端 P1
@tiptap/*5 包 + tiptap-markdown 3.x ~180-220 KB 富文本编辑器3 处) 客户端 P0
@dnd-kit/*core+sortable+utilities 6.x/10.x ~30-40 KB 拖拽 客户端 P2
lucide-react ^0.562.0 单图标 ~1 KB全集 ~800 KB 图标库100+ 处) 客户端 P1
radix-ui/*16 个包) 各版本 单包 ~3-8 KB UI 原语 客户端 P2
openai ^6.25.0 ~60-80 KB AI SDK 服务端 P2
react-markdown + remark-* + rehype-* 4.x-10.x ~50-70 KB Markdown 渲染 客户端 P2
@tanstack/react-query + react-table 5.x/8.x ~35-45 KB 数据层 客户端 P3

1.2 "use client" 指令分布

指标 数值
"use client" 文件总数 553
src/**/*.{ts,tsx} 文件总数 >700
客户端组件占比(估算) ~70-75%
shared/components/ui/ 下 "use client" 文件 40 个

典型反例

文件 问题 严重度
src/shared/components/ui/status-badge.tsx:1 仅渲染 Badge 无 hook 无事件,过度标记为 client P2
src/shared/components/ui/label.tsx:1 仅 forwardRef + cva可考虑改 server P3

1.3 动态导入使用情况

模式 数量 位置
next/dynamic 2 force-graph.tsx:18homework-assignment-exam-error-explorer-lazy.tsx:5
React.lazy 0
服务端动态 import() 1 sms-channel.ts:186tencentcloud

应使用但未使用动态导入的重型组件

[P0] ReactFlow@xyflow/react未动态导入

  • 位置:src/modules/textbooks/components/knowledge-graph.tsx:5-14
  • 代码:import { ReactFlow, Background, ... } from "@xyflow/react" 顶层静态导入
  • 影响路由:teacher/textbooks/[id]student/learning/textbooks/[id]
  • 估算影响:每个 textbook 路由 chunk 增加 ~120-140 KB
  • 修复参考:force-graph.tsx:74-80 已有的 dynamic(() => import(...), { ssr: false, loading: ... }) 模式

[P0] tiptap 富文本编辑器未动态导入3 处)

  • 位置 1src/shared/components/ui/rich-text-editor.tsx:5-8
  • 位置 2src/modules/exams/editor/exam-rich-editor.tsx:4-6
  • 位置 3src/modules/lesson-preparation/components/blocks/rich-text-block.tsx:3-6
  • 影响路由:teacher/exams/[id]/edit-richteacher/exams/new、教材阅读页链路、备课编辑页
  • 估算影响:每个含编辑器路由增加 ~180-220 KB

1.4 大体积第三方库使用模式

lucide-react100+ 处使用)

  • 全部使用具名导入 import { IconName } from "lucide-react"
  • 缺少 experimental.optimizePackageImports: ["lucide-react"]Turbopack 内建 tree-shaking 可能不彻底)
  • 严重度P1

recharts13 处使用)

  • 业务侧具名导入
  • [P1 反例] src/shared/components/ui/chart.tsx:5import * as RechartsPrimitive from "recharts" barrel 导入
  • 影响chart.tsx 被 12 个图表组件引用barrel 导入让 Turbopack 难以精确剔除未用子模块
  • 修复:改为按需具名导入

@xyflow/react4 处使用,仅 textbooks 模块)

  • 模块隔离良好
  • 但未动态导入导致 textbook 路由 chunk 膨胀

tencentcloud-sdk-nodejs服务端专用已正确隔离

  • 位置:src/modules/notifications/channels/sms-channel.ts:186
  • 模式:await import("tencentcloud-sdk-nodejs")
  • [P0] 建议追加 serverExternalPackages: ["tencentcloud-sdk-nodejs"] 防止误打包

exceljs1 处使用)

  • 位置:src/shared/lib/excel.ts:3
  • 模式:静态 import ExcelJS from "exceljs"(值导入)
  • 风险:若被 client 组件误引用将导致 250-300 KB 打包

1.5 next.config.ts 配置审计

当前完整内容11 行):

const nextConfig: NextConfig = {
  output: "standalone",
  serverExternalPackages: ["mysql2"],
};

缺失配置项

缺失配置 影响 严重度
experimental.optimizePackageImports lucide-react/recharts/@xyflow/react/@radix-ui/* /@tiptap/* 未启用按需优化 P0
serverExternalPackages 未含 tencentcloud-sdk-nodejs 2-3 MB SDK 误打包风险 P0
serverExternalPackages 未含 exceljs 250-300 KB 误打包风险 P1
自定义 webpack/turbopack manualChunks 无 splitChunks 自定义 P2
experimental.webVitalsAttribution 无法精确定位 Web Vitals 来源元素 P2

建议补充配置

const nextConfig: NextConfig = {
  output: "standalone",
  serverExternalPackages: ["mysql2", "tencentcloud-sdk-nodejs", "exceljs"],
  experimental: {
    optimizePackageImports: [
      "lucide-react",
      "recharts",
      "@xyflow/react",
      "@tiptap/react",
      "@tiptap/starter-kit",
      "@tiptap/extension-placeholder",
      "@tiptap/extension-image",
      "@radix-ui/react-dialog",
      "@radix-ui/react-dropdown-menu",
      "@radix-ui/react-select",
      // ... 其余 radix 包
    ],
  },
};

1.6 Barrel File 风险

路径 导出 风险
src/shared/components/ui/ 无 index.ts barrel 良好
src/modules/exams/editor/index.ts ExamRichEditor + 6 个 tiptap 扩展 + 类型 P0 高风险
src/modules/exams/ai-pipeline/index.ts
src/shared/hooks/index.ts 7 个 hook

[P0] exams/editor/index.ts 高风险 barrel

  • 位置:src/modules/exams/editor/index.ts:1-26
  • 已造成实际问题:src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx:9-14 已有显式注释绕过 barrel
  • 但同文件第 15 行同步导入 ExamRichForm(其内部第 22-32 行又通过 barrel 导入)→ 绕过失效
  • 错误信息:'Class extends value undefined is not a constructor or null'Server Component 连带加载 @tiptap/react 客户端扩展)
  • 修复方向:拆分为 editor/types.ts(仅类型,可被 server 引用)+ editor/index.ts(仅值导出,禁止 server 引用)

1.7 图片/字体/静态资源

资源 现状 评估
next/image 使用 4 处exam-preview、scan-uploader、scan-image-viewer、exam-rich-form 良好
原生 <img> 1 处scan-image-viewer.tsx:143已加 eslint-disable 注释) ⚠️ 见 CLS-1
next/font 1 个Interdisplay: "swap" + variable 良好
manifest.ts 完整 良好
public/ 5 个 SVG 良好

1.8 Bundle 预算建议基线表

因当前 next build 失败无法获取实际产物体积,以下为基于依赖估算的 K12 教务系统推荐基线gzip 后)。

路由类别 First Load JS 阈值 当前估算 严重度
落地页 / 登录 / 注册((auth)/* ≤ 180 KB ~150-180 KB P2
学生端 Dashboard ≤ 250 KB ~250-320 KB P2
教师端 Dashboard ≤ 280 KB ~280-350 KB P2
列表/详情页(公告/作业/成绩等) ≤ 300 KB ~300-400 KBrecharts 拉入) P1
教材阅读页(含 ReactFlow + tiptap + force-graph ≤ 600 KB ~700-900 KB(全量静态导入) P0
试卷富文本编辑页(含 tiptap 全家桶) ≤ 500 KB ~600-750 KB P0
备课编辑页(含 tiptap + dnd-kit ≤ 550 KB ~600-800 KB P0
单路由增量 chunk动态加载组件 ≤ 200 KB force-graph 单独 chunk ~80 KB
共享框架 chunkreact/react-dom/next ≤ 130 KB ~130 KB标准
单文件体积上限lint 规则) ≤ 1000 行 已有项目规则约束

第二部分Core Web Vitals 审计

2.1 LCP最大内容绘制

[P1] LCP-1根 layout.tsx 串行 await

  • 位置src/app/layout.tsx:45-50
  • 问题:每个 SSR 请求串行调用 getLocale()getMessages()auth(),三者无数据依赖
  • 影响TTFB、LCP、FCP每个页面渲染都被推迟
  • 修复const [locale, messages, session] = await Promise.all([getLocale(), getMessages(), auth()])

[P1] LCP-3teacher/student/parent/management 根路由缺失 loading.tsx

  • 问题:通过 Glob 搜索 **/loading.tsx 仅找到 admin/loading.tsx4 个角色根路由段下无 loading.tsx
  • 影响:角色切换/首次进入时白屏闪烁LCP/CLS 退化
  • 建议:为 4 个角色根路由分别创建 loading.tsx,复用 DashboardLoadingSkeleton

[P1] LCP-5force-dynamic 滥用,无 ISR 缓存收益

  • 问题:全项目共 96 处 export const dynamic = "force-dynamic",即使是相对静态的页面(学校列表、部门列表、教材目录)也强制动态
  • 影响:每请求都重新 SSR + DB 查询TTFB/LCP 无静态缓存命中加速
  • 建议:对低频变更页面用 revalidate = 3600 替代 force-dynamic

[P2] LCP-2备课编辑器 textbook → chapters 数据瀑布

  • 位置src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx:34-46
  • 问题:第 34-46 行串行调用 getTextbookById()getChaptersByTextbookId()
  • 参考parent/lesson-plans/[planId]/view/page.tsx:49-52 已修复为 Promise.all

正面发现

  • teacher/lesson-plans/page.tsxteacher/exams/[id]/build/page.tsxteacher/exams/[id]/analytics/page.tsxparent/lesson-plans/[planId]/view/page.tsx 均用 Promise.all
  • 4 个角色 dashboard 均用 React use() + Suspense 流式渲染
  • DashboardSection 封装 SectionErrorBoundary + Suspense + 骨架屏,分区流式渲染

2.2 CLS累计布局偏移

[P2] CLS-1scan-image-viewer.tsx 使用 <img> 且未指定宽高

  • 位置src/modules/homework/components/scan-image-viewer.tsx:142-148
  • 问题:原生 <img>(已加 eslint-disable 注释)仅设 maxHeight: "80vh",无 width/height 也无 aspect-ratio
  • 影响:图片加载完成时推动布局
  • 修复:用 next/image 替换(同文件第 194-200 行缩略图已正确用 next/image fill sizes="48px"),或为 <img>aspect-ratio 内联样式

正面发现

  • 字体配置正确(display: "swap" + variable
  • <iframe> 全项目零使用
  • Sonner Toaster 使用 fixed 定位
  • ThemeProvider 配置 disableTransitionOnChange

2.3 INP交互到下一次绘制

[P2] INP-3AiAssistantWidget 未懒加载

  • 位置src/app/(dashboard)/layout.tsx:7,29
  • 问题<AiAssistantWidget /> 在 dashboard layout 同步渲染,依赖 useFloatingBall(拖拽位置计算 + localStorage 持久化 + 屏幕边缘吸附)+ AiChatPanel + Sheet,未通过 next/dynamic 懒加载
  • 影响INP主线程占用、FCPhydration 时间)
  • 建议:用 next/dynamic 包裹,ssr: false,初始仅渲染悬浮球,点击后再加载 chat panel

[P3] INP-4AppSidebar 未 memo 化导航项

  • 位置src/modules/layout/components/app-sidebar.tsx:31-60
  • 问题:每次渲染对 allNavItemsfilter + map 重新计算 navItems,未使用 useMemo
  • 建议useMemo 包裹 navItems 计算,依赖 [allNavItems, permissions]

正面发现

  • 全项目 44 处 useTransition / useDeferredValue 使用覆盖良好exam-assembly 防抖、exam-rich-form 双 transition、scan-uploader 等)
  • 备课编辑器使用 zustand store无大对象 setState
  • react-force-graph-2d 已正确动态加载

2.4 FCP / TTFB

[P1] FCP-1根 layout 串行 await同 LCP-1

见 LCP-1。

[P2] FCP-4next.config.ts 未启用 optimizePackageImports

见第一部分 1.5。

正面发现

  • proxy.ts 中间件无数据库同步调用,纯 JWT 位图运算
  • instrumentation.ts 启动时一次性执行
  • globals.css 多层 @import 均为本地资源无外部阻塞

2.5 资源加载

[P2] RES-1metadata 缺 metadataBase / openGraph / robots

  • 位置src/app/layout.tsx:15-24
  • 问题metadata 仅含 title/description/manifest/appleWebApp,缺 metadataBaseopenGraphtwitterrobotssrc/app/icon.svg 文件可能不存在(public/ 下仅有 5 个 SVG
  • 影响FCPOG image 加载、SEO、build 时 warn
  • 建议:补充 metadataBase: new URL(process.env.NEXTAUTH_URL ?? "http://localhost:3000")

正面发现

  • viewport 导出符合 Next.js 16 规范
  • manifest.ts 配置完整
  • <Script> 标签零使用,无第三方脚本阻塞
  • next/image 使用合规4 处)

2.6 性能监控缺失

[P0] MON-1无 Web Vitals 上报闭环

  • 问题Grep web-vitals|reportWebVitals|useReportWebVitals|onCLS|onINP|onLCPsrc/ 下零匹配;package.jsonweb-vitals 依赖
  • 影响:生产环境无法追踪真实用户 LCP/CLS/INP/TTFB/FCP所有性能问题只能靠经验推断
  • 建议
    1. 安装 web-vitals
    2. src/app/layout.tsx 或新建 src/instrumentation-client.ts 实现 useReportWebVitals,上报到 /api/web-vitals 或第三方Vercel Analytics / PostHog / Sentry

[P1] MON-2无 Lighthouse CI 配置

  • 问题:项目根目录无 .lighthouserc.json / .lighthouserc.jspackage.json@lh3/cli 依赖;.gitea/workflows/ci.yml 无 lighthouse step
  • 影响CI 流水线无 Web Vitals 回归门槛,性能劣化无法自动拦截
  • 建议:在 .gitea/workflows/ 新增 lighthouse.yml,配置 Lighthouse CI 对关键路由(/dashboard/teacher/lesson-plans/admin/questions)做采样断言

[P2] MON-4Playwright 无性能断言

  • 问题playwright.config.ts 已配置 E2E + 视觉回归,但未使用 page.metrics()@playwright/test-performance 做性能断言
  • 建议:在 tests/e2e/ 新增 performance.spec.ts,使用 page.evaluate(() => performance.getEntriesByType('navigation')) 断言关键路由 LCP

2.7 路由级性能风险

路由 LCP 风险 INP 风险 严重度
备课编辑器 teacher/lesson-plans/[planId]/edit waterfall + ReactFlow + tiptap 中(多 useTransition + zustand P2
试卷富文本编辑器 teacher/exams/[id]/edit-rich 中(两 await 无 Promise.all tiptap + 多状态) P2
教材详情/知识图谱 teacher/textbooks/[id] 低(已 Promise.all react-force-graph + @xyflow P2
题库管理 admin/questions 200 题一次拉) 200 题列表渲染) P2
4 个角色 dashboard 低(已流式渲染) recharts P3

2.8 Web Vitals 预算建议表

指标 Good达标 Needs Improvement Poor 建议预算P95
LCP ≤ 2.5s 2.5s - 4.0s > 4.0s ≤ 2.5s(备课编辑器允许 ≤ 3.0s
INP ≤ 200ms 200ms - 500ms > 500ms ≤ 200ms仪表盘图表允许 ≤ 300ms
CLS ≤ 0.1 0.1 - 0.25 > 0.25 ≤ 0.05
TTFB ≤ 800ms 800ms - 1800ms > 1800ms ≤ 800msdashboard 路由)
FCP ≤ 1.8s 1.8s - 3.0s > 3.0s ≤ 1.8s

按路由细分预算建议

路由 LCP INP TTFB 主要风险
/login ≤ 1.5s ≤ 100ms ≤ 500ms
/admin/dashboard ≤ 2.5s ≤ 300ms ≤ 800ms recharts 多图表 + 6 个并行数据流
/teacher/dashboard ≤ 2.5s ≤ 300ms ≤ 800ms 同上
/student/dashboard ≤ 2.0s ≤ 200ms ≤ 800ms 数据较少
/parent/dashboard ≤ 2.5s ≤ 250ms ≤ 800ms 多子女卡片 + AI 摘要
/teacher/lesson-plans/[id]/edit ≤ 3.0s ≤ 400ms ≤ 1000ms ReactFlow + tiptap + 数据瀑布
/teacher/exams/[id]/edit-rich ≤ 3.0s ≤ 400ms ≤ 1000ms tiptap 富文本
/teacher/textbooks/[id] ≤ 2.5s ≤ 400ms ≤ 800ms react-force-graph-2d动态加载
/admin/questions ≤ 2.5s ≤ 400ms ≤ 800ms 200 题列表

第三部分:组件渲染性能审计

3.1 Memoization 缺失审计

全局使用统计

指标 数量 备注
React.memo / memo() 4 处 / 4 文件 严重偏低(项目有 100+ 客户端组件)
useMemo 113 处 / 70 文件 中等覆盖
useCallback 129 处 / 53 文件 中等覆盖
style={{ ... }} 内联 122 处 / 36 文件 大量内联对象每次渲染重建

memo / useMemo 比例 = 4 / 113 ≈ 3.5%,远低于企业级项目 30%+ 常见水平。

React.memo 实际使用位置(仅 4 处)

  • src/modules/ai/components/ai-markdown-renderer.tsx:162AiMarkdownRenderer 正确 memo
  • src/modules/textbooks/components/graph-prerequisite-edge.tsx
  • src/modules/textbooks/components/graph-kp-node.tsx
  • src/modules/student/components/student-courses-view.tsx

[P1] 反例 1recharts 内联对象 props

  • 位置src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx:39-61
  • 代码
    <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
      <XAxis tick={{ fontSize: 12 }} />
      <Tooltip contentStyle={{ backgroundColor: "hsl(var(--background))", ... }} />
      <Line dot={{ fill: "hsl(var(--primary))", r: 3 }} />
    </LineChart>
    
  • 影响margin / tick / contentStyle / dot 每次渲染均为新对象引用,触发 recharts 内部 SVG 元素重渲染
  • 修复:提升为模块级常量

[P1] 反例 2内联事件回调每行重建

  • 位置src/modules/messaging/components/message-list.tsx:319-321
  • 代码<button onClick={(e) => void handleToggleStar(e, m.id, isStarred)} ... />
  • 影响20 条消息 × 多个内联回调,父组件每次 setTab/setKeyword 都重建 20+ 函数

[P2] 反例 3style 内联 122 处

  • 位置lesson-preparation/components/detail-panel/qa-editor.tsx16 处)、paper-editor/paper-context-menu.tsx11 处)、paper-editor/inline-node.tsx11 处)、detail-panel/detail-head.tsx7 处)、detail-panel/detail-panel.tsx7 处)
  • 影响:备课编辑器子组件每次父级渲染都重算样式对象

[P2] 反例 4昂贵计算未 memo

  • 位置src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx:36
  • 代码const textbookNode = doc.nodes.find((n) => n.type === "textbook_content");
  • 影响doc 引用变化(任意节点更新)都重算
  • 同样问题src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:24

3.2 Context 嵌套审计

全部 Context Provider 清单(共 17 个)

根布局链路providers.tsx + dashboard layout.tsx

QueryClientProvider (1)
└─ NextIntlClientProvider (2)
   └─ ThemeProvider (3)
      └─ SessionProvider (4)
         └─ NuqsAdapter (5)
            └─ AiClientProvider (6, dashboard 层)
               └─ SidebarProvider (7, dashboard 层)
                  └─ children

7 层嵌套,未达到 5+ 警戒线的"严重"标准但有提升空间。

[P0] 关键反例SidebarProvider value 内联对象

  • 位置src/modules/layout/components/sidebar-provider.tsx:63-66
  • 代码
    <SidebarContext.Provider
      value={{ expanded, setExpanded, isMobile, toggleSidebar }}
    >
    
  • 问题
    1. value 是每次渲染新建的对象字面量,引用必然不同
    2. toggleSidebar 不是 useCallback,每次重建
    3. useSidebar() 的所有 consumerSiteHeaderAppSidebar 等)在 SidebarProvider 任意 state 变化时全部重渲染
    4. 第 51 行 window.addEventListener("resize", checkMobile) 未防抖,拖动窗口时连续触发 setIsMobile
  • 影响:全站所有 dashboard 页面 Header/Sidebar 重渲染频率高
  • 修复valueuseMemo([expanded, isMobile])toggleSidebaruseCallbackresize 加防抖

3.3 大列表虚拟化审计

关键发现package.json 中未安装任何虚拟化库

  • @tanstack/react-virtual
  • react-window
  • react-virtuoso

[P0] 重点反例:题库表无虚拟化

  • 位置src/modules/questions/components/question-data-table.tsx:45-59
  • 代码
    const table = useReactTable({
      data,  // 题库可能上千条
      columns,
      getCoreRowModel: getCoreRowModel(),
      getPaginationRowModel: getPaginationRowModel(),
      getSortedRowModel: getSortedRowModel(),
      getFilteredRowModel: getFilteredRowModel(),  // 客户端全量过滤
      // 无 enableRowVirtualization / getVirtualizedRowModel
    })
    
  • 影响:题库 1000+ 条时,即使分页每页 10 条,getFilteredRowModel 仍要遍历全部数据;切换筛选条件时一次性 diff 大量 DOM
  • 预期数据量1000-10000+ 题

其他大列表

列表 文件:行号 渲染方式 预期数据量 严重度
考试列表 src/modules/exams/components/exam-data-table.tsx:78-87 全量渲染 100-1000+ P1
出勤表 src/modules/attendance/components/attendance-sheet.tsx 一次性渲染全班学生 × 6 状态按钮 30-60 学生/班 × 6 = 180-360 控件 P1
审计日志表 src/modules/audit/components/audit-log-table.tsx:61-105 服务端分页 + 每行 AuditLogDetailDialog 10-50/页 P2
消息列表 src/modules/messaging/components/message-list.tsx:263-352 客户端分页 PAGE_SIZE=20每行 Card 较重 20/页 P2
用户列表 src/modules/users/components/admin-users-view.tsx:219-265 服务端分页 + 每行 DropdownMenu 10-50/页 P2
备课结构树 src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:79-107 全量渲染所有节点 5-50 节点 P3
教材章节图 src/modules/textbooks/components/knowledge-graph.tsx + force-graph.tsx @xyflow/react + react-force-graph-2d 100+ 节点 P2

3.4 Zustand Store 性能审计

Store 清单(共 2 个)

Store 文件 slices 使用情况
useUiStore src/shared/stores/ui-store.ts 单一 未被任何组件使用(死代码)
useLessonPlanEditor src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts 5 个 sliceseditor / selection / version / history / expanded 备课编辑器全量使用

[P1] 反例 1useModal 返回新对象引用(当前为死代码)

  • 位置src/shared/stores/ui-store.ts:73-78
  • 代码
    return useUiStore((s) => ({
      open: Boolean(s.modals[name]),
      props: s.modals[name]?.props,
      openModal: (props?: ModalProps) => s.openModal(name, props),
      closeModal: () => s.closeModal(name),
    }))
    
  • 问题Zustand v5 默认使用 Object.is 比较selector 返回新对象字面量意味着每次 store 变化都触发组件重渲染
  • 当前状态Grep 显示 useUiStore / useModal 仅在 ui-store.ts 自身出现,项目其他位置未使用,是预备的 modal registry 但未落地。一旦采用会立刻触发性能问题
  • 修复:用 useShallow 包裹

[P0] 反例 2备课编辑器无 selector 订阅全 store

  • 位置4 处直接调用 useLessonPlanEditor() 不带 selector
文件:行号 解构 影响
src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62 const editor = useLessonPlanEditor() 订阅全 store每次按键/选区/展开/撤销/保存状态变化都重渲染整个三栏布局
src/modules/lesson-preparation/components/blocks/text-study-block.tsx:22 const { updateNode } = useLessonPlanEditor() updateNode 引用稳定,但订阅粒度过粗
src/modules/lesson-preparation/components/blocks/exercise-block.tsx:31 const { updateNode, planId } = useLessonPlanEditor() 同上
src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx:40 多重解构 同上
  • 影响:备课编辑器是高频交互场景(键盘输入 + 节点选中 + 撤销重做),订阅全 store 导致每次按键都重渲染 StructureTree + PaperEditor + DetailPanel 三个子树
  • 修复:拆为多个细粒度 selectoruseLessonPlanEditor((s) => s.title)s.isSavings.isDirtyblock 组件改为 const updateNode = useLessonPlanEditor((s) => s.updateNode)

正面发现

  • useLessonPlanEditor 已正确拆分为 5 个 slice便于按 slice 订阅
  • Slice 文件使用 import type { StateCreator } 类型导入,无运行时循环依赖
  • 备课模块其余 25 处 useLessonPlanEditor((s) => s.xxx) 使用了正确 selector 模式

3.5 Server / Client 边界审计

优秀实践

  • Server Component 路由分发:src/app/(dashboard)/dashboard/page.tsx
  • Server Component + Suspense + Streamsadmin-dashboard.tsx
  • Server Component 嵌套 Providerdashboard layout 在 server 函数体内 createFullAiClientService(),避免模块级单例
  • 100+ 页面 Server Component
  • Server → Client 通过 ReactNode slot<SidebarProvider sidebar={<AppSidebar />}>
  • Server Action + useFormStatus5 处
  • Dialog 内部状态管理lesson-preparation providers 用 useMemo 包裹 service value

[P2] 反例Server → Client 数据传递过大对象

传递点 文件 数据量 评估
messages prop → MessageList src/app/(dashboard)/messages/page.tsxmessage-list.tsx 全部 messages含 content、senderName 等) ⚠️ 可能过大,无字段裁剪
messages prop → NextIntlClientProvider providers.tsx:46 全量 i18n 字典 ⚠️ 应按 namespace 拆分

3.6 React 19 特定优化审计

关键缺失

特性 是否启用 严重度 备注
React Compiler (babel-plugin-react-compiler) 未安装 P0 package.json 无依赖next.config.ts 无配置
use() Hook 处理 Promise 0 处使用 P2 admin-dashboard 注释声称使用,实际是 Suspense + await
useOptimistic 0 处使用 P1 消息星标、出勤状态、批改提交等多处应使用
useFormStatus 5 处 attendance-sheet、attendance-rules-form、create-chapter-dialog、textbook-form-dialog、password-change-form 等
useActionState 3 处 password-change-form、leave-request-form、leave-review-dialog

[P0] React Compiler 缺失

  • 问题
    • package.jsonbabel-plugin-react-compiler 依赖
    • next.config.ts 未配置 reactCompiler: true
    • eslint-plugin-react-compiler 未安装
  • 影响React Compiler 可自动 memoize 组件内部状态和派生值,可一次性解决第 3.1 节列出的所有内联对象/未 memo 问题。React 19 + Next.js 16 是启用 Compiler 的最佳时机
  • 修复:安装 babel-plugin-react-compiler + eslint-plugin-react-compiler,在 next.config.tsexperimental: { reactCompiler: true } 或使用 Next.js 16 的稳定 API

[P1] useOptimistic 缺失场景

场景 1消息星标切换
  • 位置src/modules/messaging/components/message-list.tsx:119-148
  • 现状:用 starredOverride state + try/catch 回滚模拟乐观更新
  • 修复
    const optimisticMessages = useOptimistic(messages, (state, { id, isStarred }) =>
      state.map(m => m.id === id ? { ...m, isStarred } : m)
    )
    
场景 2出勤状态切换
  • 位置src/modules/attendance/components/attendance-sheet.tsx:129-140
  • 现状statuses state 实际是乐观值容器
  • 修复:用 useOptimistic 表达
场景 3成绩录入
  • 位置src/modules/grades/components/batch-grade-entry.tsxgrade-record-form.tsx

3.7 重渲染热点审计

[P0] 7.1 备课编辑器lesson-preparation— 重灾区

7.1.1 LessonPlanEditor 订阅全 store
  • 位置src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62
  • 代码const editor = useLessonPlanEditor();
  • 影响:该组件渲染顶部工具栏(标题输入框、保存/撤销/重做/发布等 10+ 按钮)+ 三栏布局StructureTree + PaperEditor + DetailPanel+ 5 个 Dialog。editor 任意字段变化(每次按键改 title、每次选区变化、每次撤销重做、每次保存状态切换都触发整个组件树重渲染
  • 额外问题editor.canUndo() / editor.canRedo() 在 render 中直接调用(第 189-190 行),不是 selector每次渲染都执行函数调用且依赖整个 history slice
7.1.2 PaperEditor 订阅 doc 整体
  • 位置src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx:25
  • 代码const doc = useLessonPlanEditor((s) => s.doc);
  • 影响doc 是不可变对象,任意 node 更新都生成新 doc 引用,导致 PaperEditor 全量重渲染
  • 额外问题:第 36 行 doc.nodes.find() 无 memo第 54-65 行 onPaperContextMenu / onInlineNodeContextMenu / 第 86 行 onContextMenu 内联回调;第 133-141 行 expandedNodes.map 内联函数
7.1.3 StructureTree 同样问题
  • 位置src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:12-14
  • 影响:订阅 doc + expandedNodeIds + addNode,每次 doc 变化都重渲染所有 TreeNodeRow。第 79-107 行 teachingNodes.map 内联 childItems 数组与 onToggleChildren 回调
7.1.4 tree-node-row.tsx 内联 style 11 处
  • 位置src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx
  • 影响11 处 style={{}} 内联,每次父级 doc 变化都重算

[P1] 7.3 表格组件(@tanstack/react-table

7.3.1 题库表 QuestionDataTableP0
  • 位置src/modules/questions/components/question-data-table.tsx:36-155
  • 问题:无虚拟化(见 3.3+ 第 78-93 行 table.getHeaderGroups().map 渲染表头 + 第 96-111 行 table.getRowModel().rows.map 渲染所有行 + 第 102-108 行 row.getVisibleCells().map 渲染所有单元格
  • 额外问题:第 61-64 行 selectedIds useMemo 依赖 [table, getRowId]table 引用每次 useReactTable 调用都变化,导致 memo 失效
7.3.2 考试表 ExamDataTableP1
  • 位置src/modules/exams/components/exam-data-table.tsx:33-132
  • 问题:同样问题,无虚拟化
7.3.3 审计日志表P2
  • 位置src/modules/audit/components/audit-log-table.tsx:31-118
  • 问题:服务端分页(好),但每行渲染 <AuditLogDetailDialog>(第 101 行),即使未打开也实例化 Dialog 组件
  • 建议:改为点击行时 next/dynamic 加载 Dialog

[P2] 7.4 表单组件

7.4.2 备课编辑器标题输入直接改 store
  • 位置src/modules/lesson-preparation/components/lesson-plan-editor.tsx:329-333
  • 代码<input value={editor.title} onChange={(e) => editor.setTitle(e.target.value)} />
  • 影响:每次按键直接触发 store 更新 → 触发 LessonPlanEditor 全量重渲染(因第 62 行无 selector
  • 修复:使用 useDeferredValue 或本地 state + debounce
7.4.3 AttendanceSheet 多重 useState
  • 位置src/modules/attendance/components/attendance-sheet.tsx:115-127
  • 问题11 个 useStateclassId / date / period / statuses / reasons / searchQuery / focusedStudentIndex / isSubmitting / showSwitchConfirm / pendingClassIndexstatuses / reasonsRecord<string, ...> immutable 更新,每次按键都创建新对象 → 全表重渲染

第四部分Server Action / 数据层审计

4.1 N+1 查询审计

[P0] 反例:循环单条 INSERT无事务无批量化

  • 位置src/modules/course-plans/data-access.ts:347-406 copyCoursePlanToClasses
  • 问题:外层 for (classId of targetClassIds)await db.insert(coursePlans),嵌套 for (item of sourceItems) 内再 await db.insert(coursePlanItems)。10 班 × 20 周 = 200 次顺序 INSERT无事务保护无大小限制
  • 影响:复制 10 个班需 200+ 次往返;中途失败导致部分复制遗留脏数据;调用方传入 100+ classIds 时响应时间分钟级
  • 修复:改为 db.transaction(async tx => { await tx.insert(coursePlans).values([...]) ; await tx.insert(coursePlanItems).values([...]) }),并限制 targetClassIds.length <= 50

[P1] 反例:事务内循环 UPDATE

  • 位置 1src/modules/homework/data-access-write.ts:326-338 autoGradeSubmission 事务内
  • 位置 2src/modules/homework/data-access-write.ts:360-375 gradeHomeworkAnswers 事务内
  • 问题for (const ans of gradedAnswers) { await tx.update(homeworkAnswers) },每道题一次 UPDATE
  • 影响30 题作业需 30 次 UPDATE事务持锁时间线性增长并发批改时锁竞争加剧
  • 修复:改用 MySQL INSERT ... ON DUPLICATE KEY UPDATE 或构造 CASE WHEN id IN (...) THEN score ... END 单条 UPDATE

[P2] 反例:批量删除 fallback 路径循环

  • 位置src/modules/files/data-access.ts:174-182 deleteFileAttachments fallback
  • 问题:主路径已 inArray 批量,但 fallback 仍循环单条 delete

良好实践

  • src/modules/dashboard/data-access.ts:15-30 getAdminDashboardDataPromise.all 并行 6 个 stats
  • src/modules/homework/stats-service.ts:58-82 getTeacherGradeTrendsPromise.all 并行
  • src/modules/grades/data-access.ts:96-107 getGradeRecordsPromise.all 并行 count + rows

4.2 索引缺失审计

Schema 索引现状

  • 总表数: 50+ 张
  • 已建索引: 110+ 个(含复合、唯一、外键)
  • 整体索引覆盖良好,但存在以下缺口

[P1] 高频查询字段无索引

缺失索引 影响
exams (schema.ts:517-561) creatorId 无索引 getExamscreatorId 过滤("我的试卷"),全表扫描
exams status 无索引 getExams 按 status 过滤;getHomeworkDashboardStats 反复按 status 过滤
examQuestions (schema.ts:565-572) questionId 无索引 题目被引用检测、删除时反查 exam_questions 全表扫描
homeworkAssignmentQuestions (schema.ts:635-653) questionId 无索引 同上
submissionAnswers (schema.ts:589-602) questionId 无索引 题目级统计反查全表

[P2] 复合索引未覆盖单字段查询

现状 问题
examSubmissions (schema.ts:574-587) 仅有 (examId, studentId) 复合 单独按 studentId 查询(学生全部提交)走全表
homeworkAnswers (schema.ts:701-723) 仅有 (submissionId, questionId) 复合 单独按 questionId 反查(错题统计)走全表

修复建议

CREATE INDEX exams_creator_idx ON exams(creator_id);
CREATE INDEX exams_status_idx ON exams(status);
CREATE INDEX exam_questions_question_idx ON exam_questions(question_id);
CREATE INDEX hw_assignment_questions_question_idx ON homework_assignment_questions(question_id);
CREATE INDEX exam_submissions_student_idx ON exam_submissions(student_id);
CREATE INDEX submission_answers_question_idx ON submission_answers(question_id);
CREATE INDEX hw_answers_question_idx ON homework_answers(question_id);

良好实践

  • attendanceRecords 索引覆盖优秀:classDatePeriodIdx 复合索引覆盖按节次点名场景
  • gradeRecords 已建 classSubjectIdx 复合索引
  • messages 已建 receiverReadIdxreceiverStarredIdx 复合索引
  • auditLogs 已建 createdAtIdx 支持时间范围查询

4.3 并行数据获取审计

[P2] 串行 await 可优化为 Promise.all

文件:行号 反例 优化
src/modules/users/data-access.ts:27-37 getUserProfile db.query.users.findFirst,再 db.select roles —— 两次串行 await Promise.all([findUser, findRoles])
src/modules/homework/stats-service.ts:261-302 getStudentDashboardGrades 9 次串行 await 部分可并行(如 assignmentsgetAssignmentMaxScoreById 无依赖关系)
src/modules/dashboard/page.tsx:11-15 根 dashboard 页 await auth()await resolvePermissions(roles) 串行 JWT 已含 permissionsBitmap可直接 decodePermissionsBitmap(session.user.permissionsBitmap) 跳过 resolvePermissions DB 查询
src/modules/rbac/lib/data-scope-resolver.ts:71-79 parent scope getChildrengetGradeIdsForStudentIds 串行 可在 getChildren 一并返回 studentIds → gradeIds 联表查询,消除二次往返

良好实践

  • src/modules/dashboard/actions.ts:58-65 getTeacherDashboardAction 6 路并行
  • src/modules/dashboard/actions.ts:108-113 getStudentDashboardAction 4 路并行
  • src/modules/dashboard/data-access.ts:23-30 admin dashboard 6 路并行
  • src/modules/exams/data-access.ts:235-238 resolveSubjectGradeNames 2 路并行

4.4 缓存策略审计

[P0] 跨请求数据缓存完全缺失

现状 证据
全项目 0 处 unstable_cache 使用 Grep unstable_cache 返回 0 匹配
全项目 0 处 "use cache" 指令 Next.js 16 的 "use cache" 未启用
全项目 0 处 cache: "no-store" / "force-cache" 显式配置 Grep cache:\s*["'] 返回 0 匹配
全项目 0 处 next: { revalidate } 配置 同上
Data-access 层仅使用 cache() from React请求内去重 例如 src/modules/dashboard/data-access.ts:15 cache(async ...)

影响

每个 Server Action 调用都重新查 DB包括低频变更的高频读取数据

  • getSubjectOptions / getGradeOptions(学期内几乎不变)
  • getClassesDashboardStats(班级总数)
  • getSystemSettings(系统配置)
  • getTextbooks(教材列表)
  • getRoles(角色列表)
  • resolvePermissions(每次 JWT refresh 才查 DB但根 dashboard 仍重复查)

DataScope 每次 Server Action 调用都从 DB 重查1-3 次查询)

修复建议

// 1. 低变更频率数据用 unstable_cache + tag
import { unstable_cache } from "next/cache"

export const getSubjectOptions = unstable_cache(
  async () => db.select().from(subjects).orderBy(asc(subjects.name)),
  ["subject-options"],
  { tags: ["subjects"], revalidateTag: 3600 }
)

// 2. Dashboard stats 用 5 分钟短缓存
export const getClassesDashboardStats = unstable_cache(
  async () => { /* ... */ },
  ["classes-dashboard-stats"],
  { tags: ["dashboard", "classes"], revalidateTag: 300 }
)

// 3. DataScope 用 React cache() 请求级去重 + 短时 unstable_cache
// 注意DataScope 变更(教师新接班级)需触发 tag 失效

良好实践

  • revalidatePath 在 50+ actions 文件中正确使用,路径精确(如 /teacher/exams/all/student/error-book
  • src/auth.ts:55-86 JWT callback 在 token refresh 时刷新 roles/permissions

4.5 数据传输体积审计

[P1] 单次查询拉取过大

文件:行号 问题 影响
src/modules/search/data-access.ts:64-79 searchQuestions select({ content: questions.content, ... }) 把整个 JSON 题干内容(可能含图片 base64 / SlateJS 节点树)拉出,仅用于 truncate(text, 200) 摘要 单题 content 可达 10-50 KB搜索 10 条返回 500 KB+
src/modules/homework/stats-service.ts:162-172 getHomeworkAssignmentAnalytics db.query.homeworkSubmissions.findMany({ with: { answers: true, student: true } }) 一次拉取所有已批改提交 + 答案 + 学生信息,limit: 5000line 345 50 人班级 × 30 题 = 1500 行 answer + 50 行 submission + 50 行 user单次返回 MB 级
src/modules/users/data-access.ts:348-351 getAllUserIds db.select({ id: users.id }).from(users) 无 LIMIT 大型学校 5000+ 用户时返回 5000 行
src/modules/grades/data-access.ts:105 getGradeRecords .limit(params.limit ?? 1000) 默认 1000 行 默认值偏高,前端通常只展示 20 行

[P2] 中等过度传输

  • src/modules/homework/stats-service.ts:216 wrongAnswers.slice(0, 500) 每题返回最多 500 个错答样本
  • src/modules/textbooks/data-access.ts:43-91 getTextbooks 无显式 LIMIT

良好实践

  • users.password 字段在所有 select 中均未暴露
  • getExams 用 Drizzle with: { subject, gradeEntity } 关联查询,避免 N+1
  • getGradeRecords 批量 fetch 名称而非逐行 JOIN模式正确
  • getAdminUsers 显式 pageSize = Math.min(100, ...) 限制

4.6 权限校验性能审计

[P0] 每次 Server Action 调用必查 DB

调用链: actions.ts → requirePermission() → getAuthContext() → resolveDataScope() → DATA_SCOPE_RULES[].resolve(userId) → DB

角色 DB 查询次数 证据
admin 0直接返回 {type:"all"} data-scope-resolver.ts:30
grade_head / teaching_head 1 次(getGradesForStaff data-scope-resolver.ts:36
teacher 2 次并行classes + subjects data-scope-resolver.ts:48getTeacherScopeData 内部 Promise.all
student 1+ 次(getStudentScopeData data-scope-resolver.ts:60
parent 2 次(串行getChildrengetGradeIdsForStudentIds data-scope-resolver.ts:72-74

关键证据src/shared/lib/auth-guard.ts:44-47:

// Resolve data scope from DB (not cached in JWT since it can change)
const dataScope = await resolveDataScope(userId, roleNames)

注释明确说明"不缓存因可变",导致每次 Server Action 至少 1-2 次 DB 查询仅用于权限。

[P2] getSessionTeacherId 未用 React cache() 包装

  • 位置src/modules/classes/data-access.ts:17-31 getSessionTeacherId
  • 问题:每次 await auth() + DB 查询,无 cache() 包装。同一请求内多次调用(如 dashboard action + 子函数)会重复查询
  • 对比getUserProfileusers/data-access.ts:26)、getAdminDashboardDatadashboard/data-access.ts:15)、getStudentDashboardGradesstats-service.ts:257)均用 cache() 包装,请求内去重正确

修复建议

// 1. getAuthContext 用 React cache() 请求级去重
export const getAuthContext = cache(async (): Promise<AuthContext> => {
  const session = await getSession()
  // ... existing logic
})

// 2. DataScope 短时 unstable_cache30s变更时通过 tag 失效
const resolveDataScope = unstable_cache(
  async (userId, roleNames) => { /* ... */ },
  [`data-scope-${userId}`],
  { tags: [`user-scope-${userId}`], revalidateTag: 30 }
)

// 3. 教师新接班级 / 学生转班时调用 revalidateTag(`user-scope-${userId}`)

// 4. getSessionTeacherId 加 cache() 包装
export const getSessionTeacherId = cache(async () => { /* ... */ })

良好实践

  • src/auth.ts:47-83 JWT callback 在 token 创建 + refresh 时统一查询 roles + permissions
  • src/auth.ts:47-50 permissions 用 bitmap 压缩14 字符 vs 1.1 KB 数组),节省 cookie 体积

4.7 批量操作审计

[P0] 无事务、无批量、无大小限制

见 4.1 copyCoursePlanToClasses

良好实践

  • 全项目 41+ 处 db.transaction 包裹写入操作
  • src/modules/grades/data-access.ts:179-201 batchCreateGradeRecords 正确用事务 + 批量 insert
  • src/modules/users/data-access.ts:130 用户导入用事务
  • src/modules/elective/data-access-operations.ts 选课操作多处用事务

4.8 慢查询风险审计

[P0] 全表扫描 / 无 LIMIT 全量返回

文件:行号 问题
src/modules/search/data-access.ts:74-75 searchQuestions CAST(${questions.content} AS CHAR) LIKE ${kw} —— JSON 字段 CAST 后 LIKE无 FULLTEXT 索引,必然全表扫描
src/modules/lesson-preparation/data-access-knowledge.ts:98, 126 like(lessonPlans.content, \%${...}%`)` —— JSON content 列 LIKE
src/modules/users/data-access.ts:348-351 getAllUserIds db.select({ id }).from(users) 无 LIMIT无 WHERE
src/modules/announcements/data-access.ts:560-561 school 公告 fan-out 调 getAllUserIds()5000+ 用户全量加载到内存
src/modules/files/data-access.ts:128-139 getAllFileAttachments 全量返回文件表,无分页

[P1] LIKE 前缀通配 %xxx%(无法走索引)

文件:行号 字段
src/modules/textbooks/data-access.ts:50-53 title / subject / grade / publisher 4 列 %${q}%
src/modules/exams/data-access.ts:70 title / description %${q}%
src/modules/standards/data-access.ts:147 title %${keyword}%
src/modules/lesson-preparation/data-access.ts:238 title %${q}%
src/modules/audit/data-access.ts:46 action %${params.action}%
src/modules/users/data-access.ts:384 ilike(users.name, search) / ilike(users.email, search)
src/modules/rbac/data-access-assignments.ts:103 ilike(users.name, term) / ilike(users.email, term)
src/modules/messaging/data-access.ts:140 like(messages.subject, kw) / like(messages.content, kw)

例外:src/modules/files/data-access.ts:204 like(fileAttachments.mimeType, \${mimeType}%`)` 是前缀匹配,可走索引

[P2] 高 LIMIT 默认值

文件:行号 LIMIT
src/modules/grades/data-access.ts:105 getGradeRecords 默认 1000
src/modules/homework/stats-service.ts:276 getStudentDashboardGrades limit: 200
src/modules/homework/stats-service.ts:345 classGradedSubmissions limit: 5000
src/modules/homework/stats-service.ts:216 wrongAnswers.slice(0, 500)
src/modules/adaptive-practice/data-access-strategy.ts:293 .limit(1000)

修复建议

  • 搜索 API 改用 MySQL FULLTEXT 索引
    ALTER TABLE questions ADD FULLTEXT INDEX ft_content (content);
    ALTER TABLE announcements ADD FULLTEXT INDEX ft_title_content (title, content);
    -- 查询改用 MATCH ... AGAINST
    
  • getAllUserIds 改为游标分页 + fan-out 改为批量插入 message_notifications(每次 500 条)
  • LIKE %xxx% 改为前缀匹配 LIKE xxx% 或 ES/OpenSearch 外部搜索引擎
  • 高 LIMIT 改为基于业务实际值:classGradedSubmissions 50 人班级够用 1000

第五部分:综合性能预算基线表

5.1 Bundle 体积预算gzip 后)

维度 预算 强制等级
落地页 / 登录 First Load JS ≤ 180 KB 强制
学生端 Dashboard First Load JS ≤ 250 KB 强制
教师端 Dashboard First Load JS ≤ 280 KB 强制
列表/详情页 First Load JS ≤ 300 KB 强制
教材阅读页 First Load JS含 ReactFlow + tiptap + force-graph ≤ 600 KB 强制
试卷富文本编辑页 First Load JS ≤ 500 KB 强制
备课编辑页 First Load JS ≤ 550 KB 强制
单路由增量 chunk动态加载组件 ≤ 200 KB 推荐
共享框架 chunkreact/react-dom/next ≤ 130 KB
单文件体积上限 ≤ 1000 行 强制(已有 lint 规则)

5.2 Web Vitals 预算P95

指标 预算 强制等级
LCP ≤ 2.5s(备课编辑器允许 ≤ 3.0s 强制
INP ≤ 200ms仪表盘图表允许 ≤ 300ms 强制
CLS ≤ 0.05 强制
TTFB ≤ 800msdashboard 路由) 强制
FCP ≤ 1.8s 强制

5.3 数据层性能预算

维度 预算 强制等级
单次 Server Action 响应时间P50 ≤ 200 ms 强制
单次 Server Action 响应时间P99 ≤ 800 ms 强制
单次 DB 查询执行时间 ≤ 50 ms 强制
Server Action 内 DB 查询次数 ≤ 5 次 强制
单次查询返回行数 ≤ 200 行 强制
单次查询返回数据体积 ≤ 100 KB 强制
N+1 查询 严格禁止 强制CI 检查)
循环内 await db.insert/update/delete 严格禁止(除非有 fallback 注释) 强制
权限校验 DB 查询次数 ≤ 1 次/请求(用 React cache() 去重) 强制
DataScope 解析 DB 查询次数 ≤ 1 次/请求30s unstable_cache 强制
跨请求缓存命中率dashboard/统计) ≥ 80% 推荐
revalidatePath 路径精确度 ≤ 2 条/写操作 强制
批量操作事务包裹率 100% 强制
批量操作大小限制 ≤ 50 行/事务 强制
LIKE 前缀通配 %xxx% 出现次数 0除非有 FULLTEXT 索引) 强制
全表扫描查询(无 LIMIT + 无 WHERE 0 强制
默认分页 size ≤ 50 推荐
单条返回 JSON 字段体积 ≤ 50 KB 强制
Server → Client 传输 password/passwordHash/token 字段 0 强制

5.4 关键 KPI建议接入监控

  • Server Action P50/P99 响应时间(按 action 名分维度)
  • DB 查询次数 / Server Action 调用
  • 慢查询(>50ms次数与 SQL 摘要
  • DataScope 解析耗时
  • unstable_cache 命中率
  • N+1 自动检测(建议接入 drizzle-orm/dev 的 query log + CI 规则)
  • Web Vitals 上报LCP/CLS/INP/TTFB/FCP
  • Bundle 体积变化(每次 PR 与基线对比)

第六部分P0/P1 修复路线图

6.1 P0 修复路线图(按 ROI 排序)

# 项目 涉及章节 预期收益 单点改动 风险
1 启用 React Compiler §3.6 一次性解决 §3.1 大部分内联对象问题 低( Compiler 有 fallback
2 next.config.ts 完善optimizePackageImports + serverExternalPackages §1.5 全路由 -30~80 KB + 防止 2-3 MB 误打包
3 tiptap / ReactFlow 动态导入4 处文件) §1.3 编辑器路由 chunk 减少 ~300 KB
4 exams/editor/index.ts barrel 拆分 §1.6 修复已存在的构建错误 + 防止 server 误拉 tiptap 中(需测试 server 引用路径)
5 接入 Web Vitals 上报 §2.6 生产环境性能可见性
6 SidebarProvider value memoize + resize 防抖 §3.2 全站 dashboard 重渲染减少 50%+
7 lesson-plan-editor.tsx:62 拆分 selector §3.4, §3.7 备课编辑器按键响应速度提升 5-10x 中(需测试编辑器交互)
8 题库表启用虚拟化 §3.3, §3.7 1000+ 题时首屏可交互时间从秒级降到 100ms (需引入 @tanstack/react-virtual
9 copyCoursePlanToClasses 改为事务 + 批量 INSERT §4.1, §4.7 复制 10 班从分钟级降到秒级
10 unstable_cache 引入DataScope + 低变更数据) §4.4, §4.6 单次 Server Action DB 查询数减半 (需引入 tag 失效机制) 中(缓存一致性)
11 searchQuestions 改用 FULLTEXT 索引 §4.5, §4.8 搜索接口从全表扫描降到索引扫描 (需 DB 迁移) 中(需测试搜索召回率)
12 getAllUserIds 加 LIMIT + 公告 fan-out 改批量 §4.5, §4.8 防止 5000+ 用户场景内存爆炸

6.2 P1 修复路线图

# 项目 涉及章节 预期收益
1 serverExternalPackages 追加 exceljs §1.5 防止 250-300 KB 误打包
2 chart.tsx 改 barrel 为具名导入 §1.4 全图表路由 -20~40 KB
3 移除 status-badge.tsx 等过度标记的 "use client" §1.2 提升 SSR 覆盖率
4 4 个角色根路由新增 loading.tsx §2.1 LCP/CLS 改善
5 96 处 force-dynamic 评估改 revalidate §2.1 TTFB/LCP 静态缓存命中加速
6 Lighthouse CI 接入 §2.6 CI 流水线 Web Vitals 回归门槛
7 layout.tsx 串行 await 改 Promise.all §2.1 TTFB/FCP 改善
8 autoGradeSubmission / gradeHomeworkAnswers 改批量 UPDATE §4.1 30 题作业 UPDATE 次数从 30 降到 1
9 exams 表新增 5 个索引 §4.2 "我的试卷"等高频查询全表 → 索引扫描
10 recharts props 提升为模块级常量 §3.1 仪表盘图表重渲染减少
11 useOptimistic 用于消息星标 / 出勤 / 成绩 §3.6 替代手写 override stateUX 提升
12 考试表启用虚拟化 §3.3 数据量增长时避免卡顿
13 高频子组件 React.memoMessageCard、TreeNodeRow、InlineNode、AttendanceRow §3.1 阻断父级渲染扩散

6.3 P2 跟进项

  • useModal 修复(如计划采用)
  • AuditLogDetailDialog 懒加载
  • use() Hook 替代部分 Suspense 嵌套
  • 备课编辑器 title 输入 useDeferredValue
  • getSessionTeacherIdcache() 包装
  • parent DataScope 改为单次 JOIN
  • 默认 LIMIT 调低
  • metadata 补充 metadataBase / openGraph / robots
  • Playwright 性能断言
  • AiAssistantWidget 懒加载
  • scan-image-viewer <img> 加 aspect-ratio
  • 备课编辑器 textbook → chapters 改 Promise.all

6.4 建议执行顺序

Phase 1: 配置基线(无业务代码改动)
  ├── next.config.ts 完善
  ├── React Compiler 启用
  ├── Web Vitals 上报接入
  └── Lighthouse CI 接入

Phase 2: Bundle 瘦身(影响编辑器路由)
  ├── tiptap / ReactFlow 动态导入
  ├── exams/editor barrel 拆分
  └── 重新 next build 获取实际体积数据

Phase 3: 数据层重构(影响所有 Server Action
  ├── unstable_cache 引入
  ├── DataScope 缓存
  ├── copyCoursePlanToClasses 改造
  └── exams 索引补齐

Phase 4: 组件渲染优化
  ├── SidebarProvider memoize
  ├── lesson-plan-editor selector 拆分
  ├── 题库表虚拟化
  └── recharts props 常量化

Phase 5: 验收与基线校准
  ├── 实际 Web Vitals 数据采集
  ├── 实际 Bundle 体积对照基线表
  └── 预算阈值写入 CI 阻断规则

附录 A关键文件清单

配置文件

  • e:\Desktop\CICD\next.config.ts
  • e:\Desktop\CICD\package.json
  • e:\Desktop\CICD\playwright.config.ts
  • e:\Desktop\CICD\vitest.config.ts

入口与 Provider

  • e:\Desktop\CICD\src\app\layout.tsx
  • e:\Desktop\CICD\src\app\providers.tsx
  • e:\Desktop\CICD\src\app\(dashboard)\layout.tsx
  • e:\Desktop\CICD\src\app\manifest.ts
  • e:\Desktop\CICD\src\proxy.ts
  • e:\Desktop\CICD\src\instrumentation.ts
  • e:\Desktop\CICD\src\auth.ts

重型组件Bundle 风险点)

  • e:\Desktop\CICD\src\modules\textbooks\components\knowledge-graph.tsxReactFlow未 dynamic
  • e:\Desktop\CICD\src\modules\textbooks\components\force-graph.tsxreact-force-graph-2d已 dynamic
  • e:\Desktop\CICD\src\shared\components\ui\rich-text-editor.tsxtiptap
  • e:\Desktop\CICD\src\modules\exams\editor\exam-rich-editor.tsxtiptap
  • e:\Desktop\CICD\src\modules\lesson-preparation\components\blocks\rich-text-block.tsxtiptap

Barrel 文件(高风险)

  • e:\Desktop\CICD\src\modules\exams\editor\index.ts(已造成构建错误)

重渲染热点(组件性能风险点)

  • e:\Desktop\CICD\src\modules\layout\components\sidebar-provider.tsx
  • e:\Desktop\CICD\src\modules\lesson-preparation\components\lesson-plan-editor.tsx
  • e:\Desktop\CICD\src\modules\lesson-preparation\components\paper-editor\paper-editor.tsx
  • e:\Desktop\CICD\src\modules\lesson-preparation\components\structure-tree\structure-tree.tsx
  • e:\Desktop\CICD\src\modules\dashboard\components\admin-dashboard\user-growth-chart.tsx
  • e:\Desktop\CICD\src\modules\questions\components\question-data-table.tsx
  • e:\Desktop\CICD\src\modules\exams\components\exam-data-table.tsx

数据层风险点

  • e:\Desktop\CICD\src\modules\course-plans\data-access.tscopyCoursePlanToClasses P0
  • e:\Desktop\CICD\src\modules\homework\data-access-write.ts(循环 UPDATE P1
  • e:\Desktop\CICD\src\modules\search\data-access.tsLIKE 全表扫描 P0
  • e:\Desktop\CICD\src\modules\users\data-access.tsgetAllUserIds 无 LIMIT P0
  • e:\Desktop\CICD\src\shared\lib\auth-guard.tsDataScope 每次重查 P0
  • e:\Desktop\CICD\src\shared\db\schema.tsexams 索引缺失 P1
  • e:\Desktop\CICD\src\modules\homework\stats-service.ts5000 行 + with 关联 P1

缺失文件清单(建议新增)

  • src/app/(dashboard)/teacher/loading.tsx
  • src/app/(dashboard)/student/loading.tsx
  • src/app/(dashboard)/parent/loading.tsx
  • src/app/(dashboard)/management/loading.tsx
  • e:\Desktop\CICD\.gitea\workflows\lighthouse.yml(建议新增 Lighthouse CI
  • e:\Desktop\CICD\src\app\instrumentation-client.ts(建议实现 useReportWebVitals
  • e:\Desktop\CICD\src\app\api\web-vitals\route.ts(建议新增指标上报端点)

附录 B审计涉及的依赖清单

客户端大体积依赖(按估算体积排序)

依赖 估算体积min+gzip 用途 当前问题
@tiptap/*5 包 + tiptap-markdown ~180-220 KB 富文本编辑器3 处) 未动态导入P0
@xyflow/react ~120-140 KB ReactFlow 知识图谱 未动态导入P0
recharts ~95-110 KB 图表库13 处) chart.tsx barrel importP1
react-force-graph-2d ~80-100 KB 引力图谱 已动态导入
react-markdown + remark-* + rehype-* ~50-70 KB Markdown 渲染
openai ~60-80 KB AI SDK 服务端
@tanstack/react-query + react-table ~35-45 KB 数据层
@dnd-kit/*core+sortable+utilities ~30-40 KB 拖拽
radix-ui/*16 个包) 单包 ~3-8 KB UI 原语
lucide-react 单图标 ~1 KB全集 ~800 KB 图标库100+ 处) 缺 optimizePackageImportsP1

服务端大体积依赖(防误打包)

依赖 估算体积 用途 当前状态
tencentcloud-sdk-nodejs ~2-3 MB 腾讯云短信 已动态 import ,但未加 serverExternalPackagesP0
exceljs ~250-300 KB Excel 导入导出 静态 import未加 serverExternalPackagesP1
mysql2 MySQL 驱动 已加 serverExternalPackages

缺失依赖(建议新增)

依赖 用途 必要性
babel-plugin-react-compiler React Compiler 自动 memoization P0
eslint-plugin-react-compiler React Compiler ESLint 规则 P0
web-vitals Web Vitals 上报 P0
@tanstack/react-virtual 题库/考试表虚拟化 P0
@lh3/cli Lighthouse CI P1

审计结论

本次性能预算审计完成,共发现 15 个 P0 严重问题13 个 P1 高优先级问题22 个 P2 中等优先级问题14 个 P3 低优先级问题

核心建议:按第六部分"建议执行顺序"分 5 个 Phase 推进,Phase 1 配置基线4 项单点改动,低风险高收益)应作为首期落地,Phase 2 Bundle 瘦身Phase 3 数据层重构为中期重点,Phase 4 组件渲染优化为长期持续工作,Phase 5 验收与基线校准闭环反馈到预算阈值。

关键提醒:当前 next build 因 Turbopack 编译错误未成功出包,本报告所有量化指标为静态估算。修复 Phase 1 + Phase 2 后必须重新构建,获取实际 Bundle 体积与运行时 Web Vitals 数据,再对照第五部分基线表进行第二轮校准。

架构图同步建议:本审计为只读研究,未修改源码,不需要同步更新 docs/architecture/004_architecture_impact_map.md005_architecture_data.json。若后续落实任何修复(如新增 loading.tsx、修改 next.config.ts、新增 web-vitals 上报、引入 unstable_cache需按项目规则同步架构图。