Files
NextEdu/docs/troubleshooting/known-issues.md
SpecialX 5d9981fd7d
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
docs(architecture): update impact map, data, audit reports, superpowers docs
- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Add audit reports: data-access-audit-framework-v1, data-access-audit-v1-data.json,

  data-access-audit-v1, g1-g5 audit outputs

- Add superpowers plans and specs (logging-refactor, documentation-system-redesign)

- Update troubleshooting/known-issues.md
2026-07-07 16:23:35 +08:00

1683 lines
119 KiB
Markdown
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.
# 项目规则速查手册
> 一页式规则清单,指明正确做法。无需解释原因,遇到问题查表即可。
---
## 一、i18n 翻译文件规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| key 禁止包含 `.` | `"title": { "parent": "..." }` | `"title.parent": "..."` |
| 同字段多角色变体用嵌套对象 | `"title": { "default": "...", "parent": "...", "teacher": "..." }` | `"title": "..."`, `"title.parent": "..."` |
| 不同字段用驼峰命名 | `"createDesc": "..."` | `"create.desc": "..."` |
| 调用方 `t("title.parent")` 自动解析嵌套 | 无需改调用方代码 | — |
| 动态拼 key 需包含完整路径 | `t(\`type.${type}\`)`type 在 type 对象下) | `t(type)`(漏掉 `type.` 前缀) |
| 嵌套 key 调用需带父级路径 | `t(\`difficulty.${level}\`)` | `t(level)` |
### i18n 动态 key 调用规则
当 `t()` 参数为动态变量时,**必须包含完整嵌套路径**
```typescript
// JSON 结构: { "type": { "single_choice": "单选题", "multiple_choice": "多选题" } }
// 正确:带父级路径
const label = t(`type.${question.type}`) // t("type.single_choice")
// 错误:漏掉父级路径,触发 MISSING_MESSAGE
const label = t(question.type) // t("single_choice") → 找不到
```
| ICU 参数用单花括号 | `"level": "难度 {level}"` | `"level": "难度 {{level}}"` |
| ICU 多参数直接并列 | `"summary": "{count} 题 · {subject}"` | `"summary": "{{count}} 题 · {{subject}}"` |
---
## 二、"use server" 文件规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 只能导出 `async function` | `export async function foo() {}` | `export const foo = ...` |
| 常量/对象放 `types.ts` 或 `constants.ts` | `types.ts: export const CONFIG = {...}` | `actions.ts: export const CONFIG = {...}` |
| 同步函数放 `lib/` 目录 | `lib/track-event.ts: export function track() {}` | `actions.ts: export function track() {}` |
| 禁止值 re-export | 调用方直接从源文件 import | `export { foo } from "./bar"` |
| 类型 re-export 允许 | `export type { Foo } from "./bar"` | — |
---
## 三、Client Component 依赖隔离规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 常量定义放 `types.ts` 或 `constants.ts` | `types.ts: export const MAX = 30` | `retention.ts: export const MAX = 30`(含 `import { db }` |
| Client Component 不得 import server-only 文件 | `import { MAX } from "../types"` | `import { MAX } from "../retention"`(拉入 mysql2 |
| `import { db }` 或 `import "server-only"` 的文件不得被客户端 import | — | — |
| 纯类型文件不得 import db/mysql2 | `types.ts` 无 server-only import | `types.ts: import { db } from "@/shared/db"` |
---
## 四、可选依赖处理规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 可选依赖动态 import 加 `webpackIgnore` | `await import(/* webpackIgnore: true */ "@upstash/redis")` | `await import("@upstash/redis")` |
| 环境变量控制加载的模块用动态 import | `if (env.DRIVER === "redis") { const { Redis } = await import(...) }` | 顶层静态 import |
| 仅 redis 驱动加载的类用动态 import | `const { RedisRateLimiter } = await import("./redis-limiter")` | `import { RedisRateLimiter } from "./redis-limiter"` |
---
## 五、Next.js 配置规则
| 规则 | 配置 |
|------|------|
| Node.js 服务端驱动标记为外部包 | `serverExternalPackages: ["mysql2"]` |
| 输出模式 | `output: "standalone"` |
| Tailwind v4 排除非源码目录防止误扫描 | `@source not "../../docs"; @source not "../../scripts"; @source not "../../tests";` 置于 `globals.css` | 不配置docs 中的 `[length:var(--space-N)]` 字符串被扫描成类,生成 `length: var(--*)` 致 CSS 解析失败 `Unexpected token Delim('*')` |
---
## 六、架构分层规则
| 层级 | 允许依赖 | 禁止依赖 |
|------|---------|---------|
| `app/` | `modules/` 的 actions + data-access | 直接访问 DB |
| `modules/` | 其他 `modules/` 的 data-access + `shared/` | 直接查询对方 DB 表 |
| `shared/` | 无 | `@/auth`、`@/proxy`、`modules/*` |
---
## 七、模块文件职责规则
| 文件 | 职责 | 禁止 |
|------|------|------|
| `actions.ts` | Server Actionsasync function + 权限校验) | 导出常量、同步函数、值 re-export |
| `data-access.ts` | 数据访问(可拆分 `data-access-*.ts` | 业务逻辑、权限校验 |
| `types.ts` | 类型定义 + 常量 | `import { db }`、`import "server-only"` |
| `schema.ts` | Zod 验证 | — |
| `lib/` | 同步工具函数、业务逻辑 | "use server" 标记 |
---
## 八、验证命令速查
| 场景 | 命令 |
|------|------|
| 类型检查 | `npx tsc --noEmit` |
| Lint | `npm run lint` |
| 开发服务器 | `npm run dev` |
| 生产构建 | `npm run build` |
| 检查 i18n key 是否含 `.` | Grep pattern: `^\s*"[a-zA-Z_]+\.[a-zA-Z_\.]+"\s*:`glob: `**/messages/**/*.json` |
| 同步 schema 到数据库 | `npm run db:push` |
| 执行迁移文件 | `npm run db:migrate` |
| 检查表结构 | `SHOW COLUMNS FROM table_name` |
---
## 十、数据库 Schema 同步规则
| 规则 | 正确做法 | 错误做法 |
|------|---------|---------|
| 修改 schema.ts 后必须同步数据库 | `npm run db:push` 或 `npm run db:migrate` | 仅改 schema.ts 不同步数据库 |
| 报错 `Unknown column` 时先查表结构 | `SHOW COLUMNS FROM xxx` | 仅看代码 schema 假设数据库已同步 |
| 迁移文件按编号顺序执行 | `drizzle/00XX_*.sql` 按序号递增 | 跳过编号或乱序执行 |
| `Failed query` 错误先排查数据库结构 | 用 mysql 客户端执行相同 SQL 确认 | 仅看代码假设查询逻辑错误 |
| 新增字段必须生成迁移文件 | `npm run db:generate` 后 commit `.sql` + `_journal.json` | 仅 ALTER 数据库不留迁移文件 |
| 修改 schema 后验证一致性 | `node scripts/verify-modules-schema.mjs` 或 `npm run db:push` | 仅看 dev server 不报错即认为成功 |
### 数据库同步问题排查
当出现 `Unknown column 'xxx' in 'field list'` 或 `Failed query` 错误时:
1. 用 `SHOW COLUMNS FROM 表名` 对比数据库实际结构与 `schema.ts`
2. 若字段缺失,执行 `npm run db:push` 同步,或手动执行对应迁移 SQL
3. 验证:用相同 SQL 在数据库客户端执行,确认不再报错
---
## 十一、Drizzle 迁移记录管理规则
| 规则 | 正确做法 | 错误做法 |
|------|---------|---------|
| 迁移文件必须录入 `_journal.json` | `npm run db:generate` 自动生成 entry | 手动创建 `.sql` 不更新 journal |
| `__drizzle_migrations` 表必须记录已应用迁移 | `npm run db:migrate` 自动写入 hash | 手动 ALTER 后不补 hash 记录 |
| 迁移文件编号唯一不可复用 | `0016_invitation_codes.sql` 与 `0016_message_reports.sql` 应改 `0022+` | 同前缀冲突 |
| 手动补 DDL 后必须同步 journal + migrations 表 | 见下方"手动补迁移"流程 | 仅 ALTER 数据库,留 journal 缺失 |
| 检查迁移状态 | `SELECT * FROM __drizzle_migrations` | 仅看 `_journal.json` 文件 |
### 手动补迁移流程(数据库已 ALTER 但 journal/migrations 表缺失时)
1. 在 `drizzle/` 创建 `00XX_*.sql` 文件(即使 DDL 已应用,仍需归档)
2. 在 `drizzle/meta/_journal.json` 添加对应 entry`tag` = 文件名不含扩展名)
3. 计算文件 sha256 hash插入 `__drizzle_migrations` 表:
```sql
INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('<sha256>', <unix_ms>);
```
4. 验证:`npm run db:migrate` 应输出 "No new migrations to apply"
### Drizzle 字段映射规则
| 场景 | 正确写法 | 错误写法 |
|------|---------|---------|
| TS 属性名与 DB 列名不同 | `status: mysqlEnum("report_status", [...])` | 假设 DB 列名是 `status` |
| 验证 SQL 用 DB 列名 | `SELECT report_status FROM learning_diagnostic_reports` | `SELECT status FROM learning_diagnostic_reports` |
| ORM 查询用 TS 属性名 | `db.select({ s: table.status })` | `db.select({ s: table.report_status })` |
---
## 十二、V5 备课模块场景缺口修复规则
### Zustand store 拆 slice + history slice 模式
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| mutation 方法包装 history | `setTitle: (title) => { get().pushHistory(); set({ title, isDirty: true }); }` | `setTitle: (title) => set({ title, isDirty: true })` |
| 拖拽位置更新除外 | `updateNodePosition` 不推 history调用方 onDragStart 时推) | 拖拽过程每次 move 都 pushHistory栈瞬间爆满 |
| hydrate/replaceDoc 清空历史 | `hydrate: (...) => set({ ..., past: [], future: [] })` | 切换课案后旧 history 残留 |
| history 栈上限 | `past: [...s.past, snapshot].slice(-MAX_HISTORY)` | 无上限(内存爆炸) |
### 撤销/重做快捷键
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| Cmd/Ctrl 区分 | `const isMod = e.metaKey \|\| e.ctrlKey` | 仅 `e.ctrlKey`Mac 不工作) |
| Shift+Z 或 Y 重做 | `(key === "z" && e.shiftKey) \|\| key === "y"` | 仅 `key === "y"` |
| preventDefault | `e.preventDefault()` | 不阻止默认(浏览器原生撤销/重做冲突) |
### 自动保存失败 UI 兜底
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 保存失败显示 toast | `toast.error(t("status.saveFailed"), { description: ... })` | `console.error(e)`(用户无感知) |
| 断网时不触发保存 | `if (!editor.isOnline) return;` 在 debounce 内 | 网络请求堆积 |
| 监听 online/offline | `window.addEventListener("online", handleOnline)` | 不监听(永远显示离线) |
| saveError 显示重试按钮 | `{editor.saveError && <Button onClick={handleRetrySave}>...}` | 失败后无重试入口 |
### 发布前预览 3 步流程
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 步骤状态机 | `type Step = "select" \| "preview" \| "confirm"` | 单步直接发布 |
| 预览显示题目列表 | `<ol>{items.map(...)}` 含题干/选项/分值 | 仅显示题目数量 |
| 总分 useMemo | `const totalScore = useMemo(() => items.reduce(...), [items])` | 每次渲染 reduce |
| 确认页警告文案 | `{t("publish.confirmWarning")}` | 无确认步骤直接发布 |
### Tiptap Image 扩展集成
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 安装依赖 | `npm install @tiptap/extension-image` | 自建图片上传组件 |
| 配置 inline:false | `Image.configure({ inline: false, allowBase64: false })` | `allowBase64: true`XSS 风险) |
| 插入图片命令 | `editor.commands.setImage({ src, alt })` | 手动拼接 HTML `<img>` |
| HTMLAttributes 类名 | `class: "rich-text-image max-w-full h-auto rounded"` | 内联 style |
### 附件表 Date → ISO string 转换
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| DB 层 Date 转接口 string | `createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : a.createdAt` | 直接 `createdAt: a.createdAt`(类型不匹配) |
| data-access 与 service 接口分离 | data-access 返回 `LessonPlanAttachment`Dateservice 转换为 `LessonPlanAttachmentOption`string | data-access 直接返回 string破坏类型一致性 |
### 打印视图 print: 媒体查询
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 隐藏工具栏 | `<div className="... print:hidden">` | 工具栏也打印出来 |
| 内容区全屏 | `print:static print:w-full print:max-h-none print:rounded-none` | 保持 modal 样式 |
| 防止跨页 | `break-inside-avoid` | 段落被切断 |
| 页脚固定 | `print:fixed print:bottom-2` | 页脚丢失 |
### 素材库 picker 复用现有基础设施
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 复用 use-file-upload hook | `const { inputRef, handleFiles, tasks } = useFileUpload(...)` | 自建 FormData + XMLHttpRequest |
| 通过 service 调用 | `service.createLessonPlanAttachment(...)` | 组件直接 import createLessonPlanAttachmentAction |
| LessonPlanDataService 接口扩展 | 在接口声明 + default-data-service 实现 | 组件直接 import actions破坏解耦 |
| FileTargetType 扩展 | `files/types.ts` 新增 `'lesson_plan'` 类型 | 字符串硬编码 `"lesson_plan"` |
### V5 第二阶段 V5-6~V5-21 通用规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| Server Action form action 适配 | `duplicateLessonPlanFormAction(formData: FormData)` 内部调用 `duplicateLessonPlanAction(planId)` + `redirect()` | 直接把 `duplicateLessonPlanAction(planId: string)` 传给 `<form action>`(签名不匹配) |
| 按需加载知识点避免首屏负担 | 对话框 Tab 内 `useEffect` 调用 `getKnowledgePointsForAlignmentAction(textbookId)` | 编辑页直接 `Promise.all` 拉取整教材知识点(即使不打开 Tab |
| 复用已安装依赖 | V5-8 直接用 `@dagrejs/dagre`(已在 package.json | 重新装 dagre 或自实现 DAG 布局 |
| 轻量级可视化不引入新库 | V5-14 板书预览纯 CSS + 文本解析(缩进识别层级) | 引入 mermaid/markmap 库做板书预览 |
| AI 模块纯服务端函数 | `import "server-only"` + `createAiChatCompletion` + Zod 验证 + JSON 提取 | AI 函数混在 actions 里,没有 server-only 标记 |
| AI 不可用降级返回空结果 | `if (!text.trim()) return [];` `try { ... } catch { return []; }` | AI 失败抛错导致整个对话框崩溃 |
| 版本对比纯函数 | `diffDocuments(oldDoc, newDoc)` 输出 added/removed/modified/unchanged | 在组件内直接对比(无法复用 + 难测试) |
| 一致性校验纯函数 | `checkConsistency(doc)` 输出 ConsistencyResult | 在对话框 useEffect 内嵌套 if/else 校验逻辑 |
| 课标覆盖热力图纯函数 | `computeCurriculumCoverage(allKps, planLinks)` | 在组件内嵌套计算逻辑 |
| useMediaQuery 移动端切换 | `const isMobile = useMediaQuery("(max-width: 768px)")` 在 readonly-view | 服务端 `useState` 默认值与客户端不一致hydration mismatch |
| 编辑器工具栏按钮分组 | 一致性校验/AI 反馈/AI 差异化 三个并列 Button + 各自 state + ErrorBoundary 包裹 | 三个对话框共用一个 state无法独立打开 |
| FocusTrap + ESC 关闭对话框 | `useEffect` 监听 `Escape` 键 + FocusTrap 包裹 | 仅点击 X 关闭(键盘用户无法关闭) |
| 类型扩展不破坏旧数据 | `Block` 接口新增 `stage?` / `differentiation?` 可选字段 | 必填字段(旧数据迁移失败) |
| KnowledgePoint 类型导入路径 | `import type { KnowledgePoint } from "@/modules/textbooks/types"` | `from "@/modules/textbooks/data-access"`(仅 types 文件导出) |
| chapterId null 处理 | `chapterId: kp.chapterId ?? null` | 直接 `kp.chapterId``string \| undefined` 不能赋给 `string \| null` |
---
## 九、问题记录规则
| 规则 | 要求 |
|------|------|
| 构建报错修复后 | 追加到 `docs/troubleshooting/known-issues.md` |
| 运行时异常修复后 | 追加到 `docs/troubleshooting/known-issues.md` |
| 框架/库版本兼容问题 | 追加到 `docs/troubleshooting/known-issues.md` |
| 依赖配置问题 | 追加到 `docs/troubleshooting/known-issues.md` |
| 架构约束违规 | 追加到 `docs/troubleshooting/known-issues.md` |
| 记录格式 | 标题 → 错误现象 → 根因 → 解决方案 → 验证方法 → 受影响文件 |
| 去重 | 同类问题在原条目补充,不重复创建 |
---
## 十三、Drizzle 子查询字段 alias 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 子查询中 raw SQL 字段必须声明 alias | `count().as("errorCount")` | `count()` |
| `sql\`...\`` 字段也需 alias | `sql<number>\`sum(...)\`.as("masteredCount")` | `sql<number>\`sum(...)\`` |
| 普通列引用可不加 alias | `questionId: errorBookItems.questionId` | — |
| 外层引用子查询字段用 `.field` | `aggregatedSubquery.errorCount` | — |
### 错误现象
```
You tried to reference "errorCount" field from a subquery, which is a raw SQL field,
but it doesn't have an alias declared. Please add an alias to the field using ".as('alias')" method.
```
### 正确示例
```typescript
const aggregatedSubquery = db
.select({
questionId: errorBookItems.questionId,
errorCount: count().as("errorCount"),
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`.as("masteredCount"),
})
.from(errorBookItems)
.where(whereClause)
.groupBy(errorBookItems.questionId)
.as("aggregated")
```
---
## 十四、Server/Client Component 函数传递规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| Server Component 不传含函数的对象给 Client Component | `<Provider>`(不传 monitor prop | `<Provider monitor={noopMonitor}>`(含 track 函数) |
| Client Component prop 含函数时改为可选 | `monitor?: DiagnosticMonitor` | `monitor: DiagnosticMonitor`(必填) |
| 函数对象在 Client 端组装 | `useMemo(() => createService(monitor), [monitor])` | Server 端 `createService()` 后传给 Client |
| Server Component 仅传纯数据 | `reports={reports.reports}`(纯数据数组) | `service={createMonitoredService()}`(含函数) |
### 错误现象
```
Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
Attempted to call createMonitoredDiagnosticService() from the server but createMonitoredDiagnosticService is on the client.
```
### 修复模式
```typescript
// Provider (client component) - prop 改为可选,未传时用默认值
interface ProviderProps {
monitor?: DiagnosticMonitor // 可选Server Component 不传
children: ReactNode
}
export function Provider({ monitor, children }: ProviderProps) {
const value = monitor ?? noopDiagnosticMonitor
return <Context.Provider value={value}>{children}</Context.Provider>
}
// Server Component - 不传含函数的 prop
<DiagnosticMonitorProvider>
<DiagnosticServiceProvider>
<ReportList reports={reports.reports} />
</DiagnosticServiceProvider>
</DiagnosticMonitorProvider>
```
---
## 十五、Playwright 测试检测规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 不检查 content 中的 i18n JSON 文本 | `"发生未知错误" in content`(会匹配翻译 JSON | — |
| 检测错误用 HTTP 状态码 | `response.status != 200` | 仅检查页面文本 |
| 检测可见错误用 locator + is_visible | `page.locator('text="错误").is_visible()` | `"错误" in content`(匹配隐藏 JSON |
| pageerror 回调不能累积注册 | 循环外注册一次,循环内 `errors.clear()` | 每次循环 `page.on("pageerror", ...)` |
### 错误现象
i18n 翻译 JSON 内联在页面 HTML 中(如 `"errors":{"unexpected":"发生未知错误"}``"发生未知错误" in content` 会误判为错误边界触发。
### 正确检测方式
```python
# 1. HTTP 状态码
response = page.goto(url, ...)
http_status = response.status # 属性,不是方法
# 2. pageerror循环外注册一次
all_errors = []
page.on("pageerror", lambda err: all_errors.append(str(err)))
for module in modules:
all_errors.clear()
# ...
# 3. 可见错误元素(不检查 HTML content
loc = page.locator('text="发生未知错误"').first
if loc.count() > 0 and loc.is_visible():
# 真正的错误边界
```
---
## 十六、next-auth useSession SSR Hydration Mismatch 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| Root Layout SSR 期间预取 session | `const session = await auth()` 后传给 `<AuthSessionProvider session={session}>` | 依赖 client 端 `useSession()` 异步获取 |
| SessionProvider 接收 SSR session prop | `<SessionProvider session={session} refetchOnWindowFocus={false} refetchInterval={0}>` | 不传 sessionclient 端首次渲染为 `status:"loading"` |
| Hydration mismatch 修复不用 `mounted` workaround | root layout 提供 SSR session 后直接用 `useSession()` | `const [mounted] = useState(false); useEffect(()=>setMounted(true),[]); if(!mounted) return null` |
| 关闭 SessionProvider 自动 refetch | `refetchOnWindowFocus={false} refetchInterval={0}` | 默认 refetch 触发 SSR/CSR 不一致 |
### 错误现象
```
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
+ id="radix-_R_19ebn6lb_"
- id="radix-_R_55qbn6lb_"
```
### 根因
`useSession()` 在 SSR 期间返回 `status:"loading"`CSR hydration 后变成 `"authenticated"`,导致 `SiteHeader` 中 `displayName`/`avatarFallback` 渲染结果不同,触发 Radix UI 组件重新生成 `id` 属性。
### 修复模式
```typescript
// src/app/layout.tsx (Server Component)
import { auth } from "@/auth"
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await auth() // SSR 预取
return (
<AuthSessionProvider session={session}>
{children}
</AuthSessionProvider>
)
}
// src/shared/components/auth-session-provider.tsx ("use client")
export function AuthSessionProvider({ children, session }: { children: React.ReactNode; session?: Session | null }) {
return (
<SessionProvider session={session} refetchOnWindowFocus={false} refetchInterval={0}>
{children}
</SessionProvider>
)
}
```
---
## 十七、useState 初始值 SSR/CSR 一致性规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 初始值禁止 `typeof window` 分支 | `useState("unsupported")` 固定值 | `useState(typeof window !== "undefined" ? Notification.permission : "unsupported")` |
| 浏览器 API 检测放 `useEffect` | `useEffect(() => { if ("Notification" in window) setPermission(Notification.permission) }, [])` | `useState` 初始值中读取 `window.Notification` |
| 初始值禁止 `Math.random`/`Date.now()` | `useState(0)`,副作用在 `useEffect` 中更新 | `useState(Date.now())` |
| 初始值禁止读取 localStorage | `useState(null)``useEffect` 中读 localStorage | `useState(localStorage.getItem("key"))` |
### 错误现象
```
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
Hydration failed because the initial UI does not match what was rendered on the server.
```
### 修复模式
```typescript
// 错误SSR 返回 "unsupported"CSR 返回 "default"/"granted"
const [permission, setPermission] = useState(
typeof window !== "undefined" && "Notification" in window
? Notification.permission
: "unsupported"
)
// 正确:初始固定值 + useEffect 检测
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("unsupported")
useEffect(() => {
if (typeof window === "undefined" || !("Notification" in window)) return
setPermission(Notification.permission)
}, [])
```
---
## 十八、SectionErrorBoundary i18n Keys 完整性规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 使用 `SectionErrorBoundary namespace="X"` 时必须补全 3 个 key | `{namespace}.json` 中 `error.boundaryTitle` / `error.boundaryDescription` / `error.retry` 全部存在 | 仅添加 `error.retry` |
| 新增 namespace 用法时同步检查 i18n 文件 | `<SectionErrorBoundary namespace="notifications">` → 检查 `notifications.json` 是否有 `error.boundaryTitle/Description` | 仅检查 `error.retry` |
| 所有语言文件同步补全 | `zh-CN/X.json` 和 `en/X.json` 都要加 | 只加中文 |
| 缺 key 会触发 hydration 警告 | `MISSING_MESSAGE` 错误在 SSR 期间抛出client 渲染回退,触发 hydration mismatch | — |
### 错误现象
```
[error] IntlError: MISSING_MESSAGE: Could not resolve `notifications.error.boundaryTitle` in messages for locale `zh-CN`.
[error] IntlError: MISSING_MESSAGE: Could not resolve `notifications.error.boundaryDescription` in messages for locale `zh-CN`.
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
```
### 根因
`SectionErrorBoundary` 内部调用 `t("error.boundaryTitle")` 和 `t("error.boundaryDescription")`,若 namespace JSON 缺少这些 keynext-intl 在 SSR 期间抛 `IntlError`,导致 Server Component 渲染失败React 回退到 client 渲染,触发 hydration mismatch。
### 修复模式
```json
// src/shared/i18n/messages/zh-CN/notifications.json
{
"error": {
"loadFailed": "通知加载失败",
"loadFailedDesc": "...",
"boundaryTitle": "通知区块加载失败",
"boundaryDescription": "加载通知数据时发生错误,请重试。",
"retry": "重试"
}
}
```
---
## 十九、Server Component 传递纯数据替代函数规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 分页组件接收 `basePath` + `statusFilter` 纯数据 | `<Pagination basePath="/announcements" statusFilter="published" />` | `<Pagination buildPageHref={(p) => \`/announcements?page=${p}\`} />` |
| Client Component 内部构建 URL | `const buildHref = (p) => { const params = new URLSearchParams(); if (statusFilter) params.set("status", statusFilter); ... }` | Server 端定义函数传给 client |
| Server Component 仅传可序列化数据 | `pagination={{ page, pageSize, total, basePath, statusFilter }}` | `pagination={{ page, pageSize, total, buildPageHref: fn }}` |
### 错误现象
```
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
{page: 1, pageSize: 12, total: 2, buildPageHref: function buildPageHref}
```
### 修复模式
```typescript
// Server Component (announcements/page.tsx)
const statusFilter = sp.status === "published" ? sp.status : "all"
<AnnouncementList
pagination={{
page: currentPage,
pageSize,
total,
basePath: "/announcements", // 纯数据
statusFilter, // 纯数据
}}
/>
// Client Component (announcement-pagination.tsx)
export function AnnouncementPagination({ page, pageSize, total, basePath, statusFilter }: Props) {
const buildHref = (targetPage: number): string => {
const params = new URLSearchParams()
if (statusFilter && statusFilter !== "all") params.set("status", statusFilter)
if (targetPage > 1) params.set("page", String(targetPage))
const qs = params.toString()
return qs ? `${basePath}?${qs}` : basePath
}
// ...
}
```
---
## 备课模块审核问题lesson-preparation audit
### Server Action 权限常量引用规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 权限校验必须引用 `Permissions` 常量 | `await requirePermission(Permissions.LESSON_PLAN_READ)` | `await requirePermission("lesson_plan:read")` |
| Server Action 返回值统一用 `ActionState<T>` | `Promise<ActionState<null>>` / `Promise<ActionState<{ planId: string }>>` | `Promise<ActionState>` |
涉及文件:`actions-analytics.ts:38,61,76,91`、`actions.ts:142,248,263,282`
### TypeScript `as` 断言规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 字面量收窄用类型守卫 | `isLessonPlanStatus(v) ? v : "draft"` | `"published" as LessonPlanStatus` |
| DB JSON 字段转换用类型守卫 | `isLessonPlanDocument(content) ? content : null` | `content as unknown as LessonPlanDocument` |
| select onChange 值用类型守卫 | `isTeachingStage(v) && updateNode(id, { stage: v })` | `updateNode(id, { stage: v as TeachingStage })` |
| 判别联合字段提取用类型守卫 | `isInteractionBlockData(node.data) && ...` | `node.data as InteractionBlockData` |
| AI patch 合并到节点 data 用注册表守卫 | `mergeBlockDataPatch(node, patch)`(内部用 `BLOCK_DATA_GUARDS: Record<BlockType, BlockDataGuard>` 校验,失败回退原 data | `{ ...node.data, ...patch } as BlockData` |
| DB enum 字段安全窄化用 `toXxx` 辅助函数 | `toLessonPlanStatus(r.status)` / `toMessageReportReason(r.reason)`(守卫失败返回 fallback 默认值) | `r.status as LessonPlanStatus` / `r.reason as MessageReportReason` |
| 类型守卫参数类型放宽到 `unknown` | `function isRichTextBlockData(data: unknown): data is RichTextBlockData`(兼容 `BlockDataGuard = (data: unknown) => data is BlockData` 注册表签名) | `function isRichTextBlockData(data: BlockData): data is RichTextBlockData`(无法赋值给 `BlockDataGuard`,触发逆变错误) |
| AI 输出 patch 类型用 `Record<string, unknown>` | `interface NodeContentUpdate { data: Record<string, unknown> }`(诚实反映 Zod `z.record(z.string(), z.unknown())` 校验后的 untrusted 输出,强制调用方走 `mergeBlockDataPatch` | `interface NodeContentUpdate { data: Partial<BlockData> }`(伪装成可信类型,调用方直接 `as BlockData` 断言) |
| switch 分发用类型守卫而非 `as` | `case "objective": return isObjectiveBlockData(data) ? flattenObjective(data, t) : []` | `case "objective": return flattenObjective(data as ObjectiveBlockData, t)` |
| TextbookContentNode 分支用 `unknown` 中间变量 | `const merged: unknown = { ...n, ...patch }; return merged as TextbookContentNode`(结构类型不兼容 Block.data 联合,从 unknown 收窄需 `as` | `return { ...n, ...patch } as unknown as TextbookContentNode`(双重断言) |
| React Flow `node.data` 收窄用类型守卫 | `const nodeData = isGraphLayoutNodeData(node.data) ? node.data : undefined` | `node.data as GraphLayoutNodeData` |
| React Flow `EdgeProps.data` 双重断言用类型守卫 | `const edgeData = isGraphEdgeData(data) ? data : undefined` | `data as unknown as GraphEdgeData | undefined` |
| react-force-graph-2d `NodeObject` 收窄用类型守卫 | `if (!isKpGraphNode(node)) return; const kpNode = node` | `const kpNode = node as KpGraphNode` |
| react-force-graph-2d `LinkObject` 收窄用类型守卫 | `if (!isKpGraphLink(link)) return; const kpLink = link` | `const kpLink = link as KpGraphLink` |
| 力导向边 source/target 字符串排除后守卫 | `if (typeof source === "string") return; if (!isKpGraphNode(source)) return` | `kpLink.source as KpGraphNode` |
| next/dynamic 第三方库类型限制保留 `as` | `as unknown as ComponentType<React.ComponentProps<typeof ForceGraph2DType>>`next/dynamic 返回 `ComponentType` 与库默认导出类型不兼容,需 `as unknown as` 保留完整 props 类型) | 无(库类型限制无法用守卫替代) |
| DB JSON `unknown` 字段收窄用 `isRecord` | `if (!isRecord(value)) return value; const record = value` | `const record = value as Record<string, unknown>` |
| Tiptap `JSONContent` 收窄用守卫 | `isJSONContent(v) ? v : fallback` / `isJSONContentArray(arr) ? arr : []` | `slice.content.toJSON() as JSONContent[]` / `node as JSONContent` |
| dnd-kit `UniqueIdentifier` 转 string 用 `toSortableId` | `toSortableId(event.active.id)` / `toSortableId(over.id)` | `event.active.id as string` / `over.id as string` |
| Tiptap NodeView `node.attrs` 收窄用守卫 | `const attrs = isQuestionBlockAttrs(node.attrs) ? node.attrs : { questionId: "", type: "single_choice", score: 0 }` | `node.attrs as QuestionBlockAttrs` |
| AI 接口返回 `doc` 字段守卫失败显式报错 | `if (!isJSONContent(result.data.doc)) { toast.error(t("...")); return }` | `result.data.doc as EditorJSONContent` |
| next-intl `t()` params 类型放宽 | `type TranslationFn = (key: string, params?: Record<string, string \| number \| Date>) => string` | `t(key, params as Record<string, string \| number \| Date> \| undefined)` |
| react-hook-form + zodResolver 协变差异保留 `as unknown as` | `zodResolver(formSchema) as unknown as Resolver<ExamFormValues>`(库协变差异需 `as unknown as`,注释说明原因) | `zodResolver(formSchema) as Resolver<ExamFormValues>`(直接 `as` 违规) |
| 复杂 RichQuestionContent 结构守卫失败用 isRecord 兜底 | `if (!isRecord(parsed)) return fallback; const content = parsed as EditorQuestion["content"]`(结构校验由下游 Zod schema 保证,注释说明) | `parsed as EditorQuestion["content"]`(直接断言无守卫) |
### TypeScript 箭头函数语法规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 箭头函数带返回类型必须有 `=>` | `export const fn = async (x: string): Promise<void> => { ... }` | `export const fn = async (x: string): Promise<void> { ... }`TS1005: '=>' expected |
| `const fn = async ()` 与 `async function fn()` 区别 | `const fn = async (): Promise<T> => {` 用箭头;`async function fn(): Promise<T> {` 用声明 | 混用:`const fn = async (): Promise<T> {`(缺 `=>` |
> 2026-07-07 修复:`textbooks/data-access.ts` 的 `getKnowledgePointsByTextbookIdRaw` 缺失 `=>`pre-existing bugtsc 报 TS1005补全后通过。
严重违规双重断言:
- `structure-tree.tsx:72` `{ ...textbookNode, type: "textbook_content" } as unknown as Block`
- `version-diff-viewer.tsx:38` `selectedVersion.content as unknown as LessonPlanDocument`
**类型守卫专项重构2026-07-04涉及文件**
- `lesson-preparation/lib/type-guards.ts` — 11 个 `isXxxBlockData` 守卫参数放宽到 `unknown`,新增 `mergeBlockDataPatch` + `BLOCK_DATA_GUARDS` 注册表
- `lesson-preparation/lib/export.ts` — 14 处 `as` 替换为 `isXxxBlockData` 守卫
- `lesson-preparation/data-access-calendar.ts` — 新增 `toLessonPlanStatus`,移除 6 处 `as LessonPlanStatus`
- `lesson-preparation/data-access-review.ts` — 移除 5 处 `as`(含 `as LessonPlanStatus` / `as ReviewDecision`
- `lesson-preparation/hooks/editor-slice.ts` — `updateNode` 显式标注 `AnyLessonPlanNode` 返回类型 + `unknown` 中间变量
- `lesson-preparation/hooks/use-node-ai-assist.ts` — 4 处 `as BlockData` 替换为 `mergeBlockDataPatch`
- `lesson-preparation/lib/ai-node-assist.ts` — `NodeContentUpdate.data` 改为 `Record<string, unknown>`2 处 `as Partial<BlockData>` 移除
- `messaging/lib/type-guards.ts`(新建)— `isRecipientRole` / `isMessageReportReason` / `isMessageReportStatus` + `toMessageReportReason` / `toMessageReportStatus`
- `messaging/data-access.ts` — 5 处字面量 `as RecipientRole` 移除 + 2 处 `mapMessageReport` 改用 `toXxx` 辅助
- `messaging/components/message-report-block.tsx` — `setReason(v as MessageReportReason)` 替换为 `if (isMessageReportReason(v)) setReason(v)`
**教材模块类型守卫修复2026-07-07涉及文件**
- `textbooks/lib/type-guards.ts`(新建)— `isKpWithRelations` / `isGraphNodeData` / `isGraphLayoutNodeData` / `isGraphEdgeData` / `isKpGraphNode` / `isKpGraphLink` 6 个守卫
- `textbooks/types.ts` — `KpGraphNode` / `KpGraphLink` 接口从 force-graph.tsx 迁移至此(供守卫模块引用,避免组件文件被守卫模块反向依赖)
- `textbooks/components/graph-kp-node.tsx` — 2 处 `as``data.kp as KpWithRelations`、`data.graphData as GraphNodeData | undefined`)改用守卫;`extractNodeData` 返回 `| null`,校验失败组件渲染 null
- `textbooks/components/graph-prerequisite-edge.tsx` — `data as unknown as GraphEdgeData | undefined` 双重断言改用 `isGraphEdgeData` 守卫
- `textbooks/components/knowledge-graph-node.tsx` — `node.data as GraphLayoutNodeData` 改用 `isGraphLayoutNodeData` 守卫
- `textbooks/components/force-graph.tsx` — 6 处 `as``node as KpGraphNode` ×3、`link as KpGraphLink`、`kpLink.source as KpGraphNode`、`kpLink.target as KpGraphNode`)改用 `isKpGraphNode`/`isKpGraphLink` 守卫;保留 Line 80 `as unknown as ComponentType`next/dynamic 第三方库类型限制)
**exams 模块类型守卫修复2026-07-07涉及文件**
- `exams/lib/type-guards.ts`(新建)— `isRecord` / `isSimpleTextContent` / `isQuestionContentObj` / `isJSONContent` / `isJSONContentArray` / `toSortableId` / `isQuestionBlockAttrs` 共 7 个守卫/转换函数;`isJSONContent` 返回 `v is JSONContent`(依赖 `@tiptap/react` 类型)
- `exams/ai-pipeline/request.ts` — 2 处 `as Record<string, unknown>` 改用 `isRecord` 守卫;`as const` 字面量断言保留
- `exams/components/exam-preview-utils.ts` — `isPreviewBackgroundTask` 内 `as` 改用 `isRecord`
- `exams/components/exam-actions.tsx` — `isRawStructureNode` 守卫用 `isRecord` 替代 `typeof === "object"`
- `exams/components/assembly/structure-editor.tsx` — 9 处 `as` 修复(`as string` ×4 改 `toSortableId``as QuestionContent` ×2 改 `isQuestionContentObj``as Record` 改 `isRecord`
- `exams/components/assembly/question-bank-list.tsx` — `extractQuestionText` 用 `isRecord``parsedContent` 用 `isSimpleTextContent`
- `exams/components/assembly/exam-paper-preview.tsx` — `as QuestionContent` ×2 改用 `isQuestionContentObj` + 显式映射 ChoiceOption
- `exams/components/exam-columns.tsx` — `TranslationFn` 类型签名放宽 params 类型匹配 next-intl
- `exams/components/exam-data-table.tsx` — 移除 `t(key, params as ...)` 断言,类型由 TranslationFn 保证
- `exams/components/exam-form.tsx` — `zodResolver(formSchema) as Resolver` → `as unknown as Resolver`(合规:库协变差异,加注释)
- `exams/editor/selection-toolbar.tsx` — `as JSONContent[]` / `as JSONContent` 改用 `isJSONContentArray` + `.filter(isJSONContent)`
- `exams/editor/exam-nodes-to-editor-doc.ts` — `typeof raw === "object"` 改 `isRecord`;保留 `as EditorQuestion["content"]` 加注释(结构由下游 Zod 保证)
- `exams/editor/exam-rich-editor-inner.tsx` — 移除 `editor.getJSON() as EditorJSONContent` ×2`node.attrs.type as QuestionBlockType` 改用 `isRichQuestionType` 守卫
- `exams/editor/extensions/question-block.tsx` — `node.attrs as QuestionBlockAttrs` 改用 `isQuestionBlockAttrs` 守卫,失败回退默认 attrs
- `exams/components/exam-rich-form.tsx` — `result.data.doc as EditorJSONContent` 改用 `isJSONContent` 守卫,失败 toast 报错并 return
### 非空断言 `!.` 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 数组首元素先做存在性判断 | `const row = rows[0]; if (!row) return; row.resolved` | `rows[0]!.resolved` |
| Map.get 后做空值处理 | `const arr = chapterMap.get(chId); if (arr) arr.push(kp)` | `chapterMap.get(chId)!.push(kp)` |
涉及文件:`data-access-comments.ts:113`、`data-access-review.ts:51,83,217`、`data-access-substitutes.ts:131`、`lib/curriculum-coverage.ts:90`
### ESLint 零警告规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| effect 中读 localStorage 用 `useEffectEvent` 或加依赖 | `useEffect(() => { setRecentIds(readRecentTextbookIds()) }, [])` 改用 `useSyncExternalStore` 或初始化函数 | `useEffect(() => { setRecentIds(readRecentTextbookIds()) }, [])` 触发 set-state-in-effect |
| 挂载时从 localStorage 恢复 + 含表单副作用可豁免 | `// eslint-disable-next-line react-hooks/set-state-in-effect -- 挂载时恢复 localStorage` + `setXxx(data)` | 裸 `setXxx(data)` 触发 set-state-in-effect |
| 禁止 `eslint-disable-next-line`(仅限 exhaustive-deps | 修正依赖数组或用 `useCallback` 包裹 | `// eslint-disable-next-line react-hooks/exhaustive-deps` |
| ref 禁止渲染期访问,改用 `useState` 惰性初始化 | `const [q] = useState(() => new PQueue({...}))` | `const r = useRef(null); if (!r.current) r.current = new PQueue(...)` |
| useEffect 依赖数组须含闭包引用变量 | `useEffect(() => { f(t) }, [t])` | `useEffect(() => { f(t) }, [])` 漏 `t` |
| 闭包捕获的 state 恒为初始值,条件判断无意义需移除 | `if (active && usingSSE) { fetch() }`(移除 `count === 0` | `if (active && usingSSE && count === 0)` 在 `[]` effect 内恒真 |
涉及文件:`template-picker.tsx:72`error、`schedule-dialog.tsx:53`warning、`lesson-plan-editor.tsx:81`disable、`use-exam-preview-tasks.ts:28,36`refs+setState、`unread-message-badge.tsx:92`(闭包陷阱)、`practice-result-view.tsx:26`unused、`seed-grade5-chinese.ts`unused imports
### Tailwind 任意值规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 优先映射 Tailwind 默认阶梯 | `min-h-[40px]` → `min-h-10`、`min-w-[80px]` → `min-w-20` | 保留 `min-h-[40px]` 裸任意值 |
| 无法令牌化的固定尺寸加 `arbitrary-value:` 豁免注释 | `{/* arbitrary-value: dialog fixed width */}` 置于 JSX 元素上方 | 裸用 `w-[680px]` `min-h-[120px]` `text-[10px]` 无注释 |
| JSX 子元素上下文用 `{/* */}` 注释 | `<div>{/* arbitrary-value: ... */}\n<span .../>` | `//` 会渲染为文本 |
| `return (` / `&& (` 等 JS 表达式上下文用 `//` 注释 | `return (\n // arbitrary-value: ...\n <div/>)` | `{/* */}` 在 `()` 内触发语法错误 |
| Tiptap editorProps.attributes 等 JS 对象用 `//` 注释 | `attributes: {\n // arbitrary-value: tiptap editor fixed size\n class: "..."}` | `{/* */}` 在 JS 对象内非法 |
涉及 lesson-preparation 模块 23 文件Task 11 已清理Tier 1 替换 6 处min-h-10/min-h-20/min-w-20Tier 3 豁免 26 处dialog 宽度/textarea min-h/badge text-[10px]/tiptap 等)
### i18n 翻译文件对称性规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| zh-CN 与 en 必须 key 完全对称 | 同步增删 keyCI 校验 key 差集 | 单边新增 key导致 fallback |
| 缺失 key 必须补齐 | `analytics.publishedPlans` 等同步到 zh-CN | en 有 12 个 analytics.* key 但 zh-CN 缺失 |
涉及文件:`zh-CN/lesson-preparation.json`(缺 12 个 analytics.* key、`en/lesson-preparation.json`(缺 `analytics.templateUsage`
### i18n 硬编码中文规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 用户可见文本必须走 i18n | `t("v4.contextMenu.copySuffix")` | `${src.title}(副本)` 硬编码 |
| 导出/打印文本通过 i18n 注入 | `flattenObjective(data, t)` 接受翻译函数 | `dimensionLabel: { knowledge: "知识与技能" }` 硬编码 |
| 师生角色标签用 i18n | `t("v4.interaction.roleTeacher")` | `turn.role === "teacher" ? "师" : "生"` 硬编码 |
严重违规:
- [editor-slice.ts:181](file:///e:/Desktop/CICD/src/modules/lesson-preparation/hooks/editor-slice.ts#L181) `title: \`${src.title}(副本)\`` 硬编码"副本"
- [lib/export.ts:165-271](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L165) 5 个 label 映射表全硬编码中文dimensionLabel/importMethodLabels/homeworkTypeLabels/blackboardLayoutLabels/reflectionAspectLabels
- [lib/export.ts:246](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L246) `item.source === "inline" ? "课案内新建" : "题库"` 硬编码
- [lib/export.ts:271](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L271) `turn.role === "teacher" ? "师" : "生"` 硬编码
### AI Prompt 中文常量规则(允许)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| AI prompt 提取为模块常量 | `const AI_SUGGEST_PROMPT_TEMPLATE = \`...\`` | 散落在函数体内 |
| AI prompt 不强制 i18n | 开发期调优的固定 prompt可保持中文 | — |
合规文件:`ai-suggest.ts:31`、`lib/ai-differentiation.ts:80,92,103` 已提取为常量
---
## 二十、i18n Namespace 与文件名一致性规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| `useTranslations` namespace 必须与 `i18n/request.ts` 配置一致 | `useTranslations("errorBook")`(驼峰,与 `request.ts` 的 `errorBook: errorBook.default` 一致) | `useTranslations("error-book")`(连字符,与文件名 `error-book.json` 混淆) |
| namespace 跟 `request.ts` 的 messages key不跟文件名 | 文件名 `error-book.json` → messages key `errorBook` → `useTranslations("errorBook")` | 文件名 `error-book.json` → `useTranslations("error-book")` |
| 批量检查 namespace 一致性 | `grep -r "useTranslations(\"error-book\")"` 应返回 0 结果 | — |
| 新增 namespace 时同步更新 `request.ts` | `request.ts` 新增 `import` + `messages` key | 只创建 JSON 文件不注册 |
### 错误现象
```
IntlError: MISSING_MESSAGE: Could not resolve `error-book` in messages for locale `zh-CN`.
```
### 根因
`i18n/request.ts` 中配置的 messages key 是 `errorBook`(驼峰),但 17 个组件错误使用了 `useTranslations("error-book")`(连字符,与文件名 `error-book.json` 混淆。next-intl 找不到 `error-book` namespace所有 `t()` 调用抛出 `MISSING_MESSAGE` 错误。
### 修复模式
```typescript
// i18n/request.ts 配置
errorBook: errorBook.default, // key 是 "errorBook"
// 组件中正确使用
const t = useTranslations("errorBook") // ✅ 驼峰,与配置一致
// 组件中错误使用
const t = useTranslations("error-book") // ❌ 连字符,与文件名混淆
```
---
## 二十一、useSearchParams() 导致 Hydration Mismatch 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| Client Component 中避免 `useSearchParams()` | `window.location.search` 在 `handleSelect` 点击时读取 | `useSearchParams()` 在组件顶层读取 |
| `useSearchParams()` 需要 `<Suspense>` 包裹且会导致 SSR fallback | 仅在需要 SSR 流式渲染的场景使用 | 在普通交互组件中直接使用 |
| URL 参数在点击时构建 | `const params = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); params.set("subject", id); router.push(\`?${params}\`)` | `const searchParams = useSearchParams(); const params = new URLSearchParams(searchParams.toString())` |
| `useSearchParams()` 导致组件树 SSR/CSR 不同 | SSR 显示 Suspense fallbackCSR 显示真实内容,`useId()` 生成不同 id | — |
### 错误现象
```
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
+ aria-controls="radix-_R_3jabn6lb_"
- aria-controls="radix-_R_edabn6lb_"
+ data-chart="chart-_R_4matpesndubn6lb_"
- data-chart="chart-_R_ipbn5ritnqbn6lb_"
```
### 根因
`useSearchParams()` 在 SSR 期间会导致包裹它的 `<Suspense>` 显示 fallbackSkeleton而 CSR 期间直接显示真实内容。组件树结构在 SSR/CSR 间不同,导致 React `useId()` 生成的 id 不匹配,触发全局 hydration mismatch影响页面所有 Radix UI 和 ChartContainer 组件的 id
### 修复模式
```typescript
// 错误useSearchParams() 导致 SSR/CSR 组件树不同
export function SubjectTabs({ subjects, currentSubjectId }: Props) {
const router = useRouter()
const searchParams = useSearchParams() // ❌ SSR 显示 fallback
const handleSelect = (subjectId: string | null) => {
const params = new URLSearchParams(searchParams.toString())
// ...
}
}
// 正确:在点击时用 window.location.search 读取当前 URL 参数
export function SubjectTabs({ subjects, currentSubjectId }: Props) {
const router = useRouter()
const handleSelect = (subjectId: string | null) => {
// 仅在 client 端点击时执行,不影响 SSR
const params = new URLSearchParams(
typeof window !== "undefined" ? window.location.search : ""
)
if (subjectId === null) {
params.delete("subject")
} else {
params.set("subject", subjectId)
}
router.push(`?${params.toString()}`, { scroll: false })
}
}
```
## 二十二、React StrictMode 与 Server Action Hook 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| useEffect 中调用 Server Action 时禁止用 ref 防重复 | 仅用 useEffect 依赖数组控制执行频率 | `const lastKey = useRef(""); if (lastKey.current === key) return; lastKey.current = key;` |
| StrictMode 下 cleanup 会将第一次 action 结果标记为 cancelled | `let cancelled = false; action().then(r => { if (cancelled) return; ... }); return () => { cancelled = true }` 接受 StrictMode 下 action 执行两次 | 用 ref 阻止第二次 effect 执行 action导致第一次结果被 cancelled 丢弃,永久 loading |
| 客户端 dynamic import 第三方 Canvas/Window 依赖必须用 `import type` | `import type ForceGraph2DType from "react-force-graph-2d"` + `dynamic(...)` | `import ForceGraph2D from "react-force-graph-2d"` 值导入会触发顶层副作用在 SSR 执行 |
**问题现象**:知识点图谱页面一直显示"正在加载知识点",但 Server Action 实际已返回 `success: true` 和数据。
**根因 1**Next.js 默认启用 `reactStrictMode: true`dev 模式下 useEffect 执行两次mount → unmount → mount。若 Hook 内用 `lastRequestKey` ref 防重复,第一次 mount 发起 action 后 cleanup 设 `cancelled=true`,第二次 mount 因 key 相同跳过不发起 action第一次 action 结果被 cancelled 忽略state 永不更新。
**根因 2**`next/dynamic` 加载的第三方库若在顶层用值导入(`import X from "lib"`),即使 `dynamic({ ssr: false })`,模块顶层代码仍会在 SSR 求值时执行,导致 canvas/window 访问报错dynamic loading 永久卡住。必须用 `import type` 引入类型。
---
## V4 备课编辑器(纸感重构)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 正文节点必须是 Tiptap 编辑器 | `TextbookTiptapEditor`(含 AnchorMark 扩展) | 用 `whitespace-pre-wrap` 显示 Markdown 字符串 |
| 锚点用 Tiptap Mark 内嵌 | `editor.chain().toggleMark("anchor", { anchorId, nodeId }).run()` | 用字符串偏移 `start/end` + `injectPlaceholders` |
| 节点展开位置由 order 决定 | `nodes.sort((a,b) => a.order - b.order)` | 按 anchor 位置插入 |
| 节点类型色点用 `--lp-dot-*` | `var(--lp-dot-objective)` | `var(--lesson-node-objective)`(已删除) |
| 文档版本必须是 4 | `doc.version === 4` + `expandedNodeIds` 字段 | `doc.version === 3` |
| 师生交互节点用 QaEditor | `<QaEditor nodeId data onUpdate />` | 自定义对话编辑器 |
| 右键菜单触发对象区分 | inline-node 右键 = 节点菜单;正文右键 = 锚定菜单 | 任何右键都触发同一菜单 |
| v3 锚点迁移失效提示 | `<AnchorMigrationBanner />`(按 planId localStorage | toast 一闪即逝 |
### i18n 键缺失与中英文不同步
模块新增 t() 调用时,必须同步更新 zh-CN 和 en 两个语言文件,且键名必须完全一致(不能用 `week`/`weekView` 这种同义不同名的两套键)。
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 嵌套路径不存在时需创建子对象 | JSON 中 `"version": { "diff": { "title": "..." } }` 对应 `t("version.diff.title")` | JSON 中 `"version"` 和 `"diff"` 是平级节,却调用 `t("version.diff.title")` → MISSING_MESSAGE |
| Zod schema 错误键必须存在于 JSON `error` 节 | `z.string().min(1, "error.labelTooLong")` + JSON `error.labelTooLong` 存在 | schema 引用 `error.labelTooLong` 但 JSON 中无此键 → 校验失败时显示键路径 |
| zh-CN 与 en 键名必须完全一致 | zh-CN `calendar.weekView` + en `calendar.weekView` | zh-CN `calendar.weekView` + en `calendar.week` → en locale 下 MISSING_MESSAGE |
| 禁止 JSX 中硬编码中文 | `label={t("v4.detail.stageLabel")}` | `label="教学阶段"` → en 用户看到中文 |
| 未启用 useTranslations 的组件如需展示文本,必须先引入 | `import { useTranslations } from "next-intl"; const t = useTranslations("lessonPreparation");` | 组件内直接写中文字面量 |
### i18n dead 节清理规则
语言文件中未被任何 `t()` 调用引用的节dead keys应定期清理避免 zh-CN 与 en 各持不同 schema 导致维护混乱。清理前必须全代码库搜索(含 `src/app/`),确认无引用。
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 删除 dead 节前必须验证全代码库无引用 | `grep -r "t(\"review." src/` 无结果后删除 | 直接删除节,导致隐藏引用方 MISSING_MESSAGE |
| zh-CN 和 en 键集必须完全一致 | 两文件均保留 `analytics` 节且键名相同 | zh-CN 有 `analytics.totalPublished`en 只有 `analytics.publishedPlans` |
### i18n 动态键翻译守卫
Zod schema 中存储的 i18n 键(如 `"error.titleRequired"`)在服务端翻译时,必须先用 `t.has()` 守卫检查键是否存在,避免 schema 误写不存在的键时抛出 MISSING_MESSAGE。
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 动态键翻译前必须用 t.has() 守卫 | `if (t.has(msg)) { return t(msg); } return msg;` | `return t(msg);` — 键不存在时抛 MISSING_MESSAGE |
---
## 设计令牌替换规则chart/graph 组件 #hex 清理)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| chart 颜色配置用 `--chart-1~5` 令牌 | `color: "hsl(var(--chart-1))"` | `color: "#e11d48"` |
| graph 节点色用 `--graph-node-1~6` 令牌 | `"hsl(var(--graph-node-1))"` | `"#3b82f6"` |
| 节点默认/未评估色用 `--muted-foreground` | `"hsl(var(--muted-foreground))"` | `"#6b7280"` / `"#94a3b8"` |
| 超过 6 色的调色板按顺序循环复用 graph-node-1~6 | `["hsl(var(--graph-node-1))", ..., "hsl(var(--graph-node-6))", "hsl(var(--graph-node-1))", ...]` | 保留 8/12 个 #hex 不处理 |
| **recharts CSS 属性选择器中的 #hex 不可替换** | `[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50`#ccc/#fff 是 recharts 默认输出值,选择器需原样匹配) | `[stroke='hsl(var(--border))']`(选择器无法匹配 recharts 实际输出,功能失效) |
涉及文件:
- `src/shared/components/ui/chart.tsx:54` — 5 处 `#ccc`/`#fff` 保留CSS 属性选择器值,非颜色定义)
- `src/modules/textbooks/components/knowledge-graph.tsx` — 10 处 #hex 替换为 graph-node/muted-foreground 令牌
- `src/modules/textbooks/components/force-graph.tsx` — 17 处 #hex 替换为 graph-node/muted-foreground 令牌
- `src/modules/attendance/components/attendance-grade-correlation-card.tsx` — 3 处 #hex 替换为 chart 令牌
---
## 二十三、API 路由规范化规则2026-07-04 重构)
### 23.1 响应信封格式
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 所有 `app/api/**/route.ts` 统一响应形状 | `{ success, message?, errorCode?, data? }`(与 `ActionState<T>` 对齐) | 各路由自定义 `{ ok, error, payload }` 等不一致字段 |
| 成功响应用 `apiSuccess(data)` | `return apiSuccess({ file: result.data })` | `return NextResponse.json({ ok: true, file })` |
| 失败响应用 `apiError(message, status, errorCode?)` | `return apiError("Not found", 404, "not_found")` | `return NextResponse.json({ error: "..." }, { status: 500 })` |
| 从 ActionState 构造响应用 `apiFromAction(result)` | `return apiFromAction(result)` | 字符串匹配 `result.message?.includes("not found") ? 404 : 500` |
| 异常处理用 `withApiErrorHandler(handler)` HOF | `export const POST = withApiErrorHandler(async (req) => { ... })` | 每个 route 重复 try/catch + 自定义错误转换 |
### 23.2 错误 → HTTP 状态码映射
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| `PermissionDeniedError` → 403已认证但无权限 | `requirePermission()` 抛错由 `withApiErrorHandler` 自动转 403 | 手动 catch 后返回 401401 是未认证) |
| `NotFoundError` → 404 | `throw new NotFoundError("File")` 由 `handleApiError` 转 404 | 手动判断 `error.message.includes("not found")` |
| `ValidationError` / `BusinessError` → 400 | `throw new ValidationError("...")` 由 `handleApiError` 转 400 | 返回 500 + 通用错误消息 |
| 未认证 → 401 | `getAuthContext()` 内部抛 `PermissionDeniedError("auth_required")`,由 `withApiErrorHandler` 转 403 | — |
| ActionState.errorCode 驱动状态码 | `errorCode: "not_found"` → `apiFromAction` 自动转 404 | `result.message?.includes("not found")` 字符串匹配 |
| ActionState.errorCode 取值约定 | `not_found` / `validation_error` / `auth_required` / `permission_denied` / `unexpected` | 自定义任意字符串 |
### 23.3 SSE 路由规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 流建立前错误用 `createSseError(msg, status)` | `return createSseError("Unauthorized", 403)` | `return NextResponse.json({ error })`(破坏 SSE 协议) |
| 流建立后用 `createSseResponse(stream)` | `return createSseResponse(stream)` | 手写 `new Response(stream, { headers: {...} })` |
| 事件 payload 用 `formatSseEvent(data)` | `controller.enqueue(encoder.encode(formatSseEvent({ type: "token", content })))` | 手写 `data: ${JSON.stringify(...)}\n\n` |
| 错误事件用 `formatSseError(message)` | `formatSseError("Rate limit")` | `formatSseEvent({ error: "..." })`(不一致字段名) |
| 流结束用 `formatSseDone()` | `controller.enqueue(encoder.encode(formatSseDone()))` | `controller.enqueue(encoder.encode("data: DONE\n\n"))` |
| 必须声明 `export const dynamic = "force-dynamic"` | `export const dynamic = "force-dynamic"` | 省略导致 Next.js 静态化流端点 |
### 23.4 JSDoc 注释中禁止 `**/` 序列
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| JSDoc 注释中引用 glob 路径禁止 `**/` | `仅用于 app/api/.../route.ts` | `仅用于 app/api/**/route.ts``*/` 闭合注释ESLint 解析失败) |
| 描述通配符路径用 `...` 或 `<path>` | `统一 app/api/.../stream/route.ts` | `统一 app/api/**/stream/route.ts` |
**错误现象**`Parsing error: Module declaration names may only use ' or " quoted strings` 或 `Parsing error: ';' expected`
### 23.5 Permissions 类型 vs Permission 联合类型
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 类型注解用 `Permission`(联合类型,单数) | `Record<ExportType, Permission \| null>` | `Record<ExportType, Permissions \| null>`Permissions 是 const 对象类型) |
| 引用具体权限值用 `Permissions.XXX` | `attendance: Permissions.ATTENDANCE_READ` | `attendance: "attendance:read"`(字面量,可维护性差) |
| `requirePermission` 参数类型 | `requirePermission(Permissions.ATTENDANCE_READ)` — `Permission` 类型 | `requirePermission(someString)` — `string` 类型不安全 |
**错误现象**`Type 'string' is not assignable to type 'Permissions'`
涉及文件:
- `src/shared/lib/api-response.ts` — 统一响应工具
- `src/shared/lib/sse.ts` — SSE 共享工具
- `src/modules/search/{data-access,types}.ts` — 全文检索模块(从 app/api 下沉)
- `src/app/api/**/route.ts` — 11 个路由全部使用统一信封
- `src/modules/files/actions.ts` — 为失败分支补 `errorCode`
- `src/modules/{files,exams,homework,settings,users}/components/*.tsx` — 6 个客户端文件适配 `data.data.*` 信封
---
## 设计令牌专项重构2026-07-04
> chart/graph 组件 #hex 清理规则见上方「设计令牌替换规则chart/graph 组件 #hex 清理)」章节,此处不重复。
### 令牌引用规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 颜色必须用令牌 | `color: hsl(var(--foreground))` 或 `className="bg-background"` | `color: "#1c1917"` |
| 字体必须用令牌 | `fontFamily: "var(--font-family-sans)"` | `fontFamily: "'Inter', sans-serif"` |
| 字号必须用令牌 | `fontSize: "var(--font-size-3)"` | `fontSize: "13.5px"` |
| 间距优先 Tailwind 默认阶梯 | `className="w-7 p-2 gap-1.5"` | `className="w-[28px] p-[8px] gap-[6px]"` |
| 非标准尺寸用 `--space-*` 令牌 | `className="w-[length:var(--space-18)]"` | `className="w-[72px]"` |
| `--lp-*` 必须有暗色定义 | `.dark { --lp-paper: 240 6% 10%; }` | 仅 `:root` 定义,无 `.dark` |
| M3 Surface 令牌已删除 | `bg-background-elevated` 或 `bg-card` | `bg-surface` / `bg-surface-container-low`(已清理) |
| Tailwind v4 `@theme inline` 暴露 | `--color-lp-paper: hsl(var(--lp-paper));` 后用 `bg-lp-paper` | 直接 `style={{ background: "var(--lp-paper)" }}`(可用但非首选) |
### 任意值豁免注释规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 无法令牌化的固定尺寸需注释豁免 | `{/* arbitrary-value: dialog fixed width */}`<br>`<div className="w-[680px]" />` | `<div className="w-[680px]" />`(无注释) |
| JSX 子元素上下文用 `{/* */}` | `<div>{/* arbitrary-value: ... */}<span/></div>` | `//` 会渲染为文本 |
| `return (` / `&& (` 等 JS 表达式用 `//` | `return (\n // arbitrary-value: ...\n <div/>)` | `{/* */}` 在 `()` 内触发语法错误 |
| Tiptap editorProps 等 JS 对象用 `//` | `attributes: {\n // arbitrary-value: tiptap editor fixed size\n class: "..."}` | `{/* */}` 在 JS 对象内非法 |
### ESLint 强制约束规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| `#hex` 字面量被 `no-restricted-syntax` 禁止 | `hsl(var(--foreground))` 或 `bg-background` | `color: "#1c1917"` |
| 硬编码字体被 `design-tokens/no-hardcoded-fonts` 禁止 | `var(--font-family-sans)` | `'Inter'` / `'Fraunces'` / `'JetBrains Mono'` 字面量 |
| 白名单文件可豁免 #hex | `src/app/manifest.ts`、`src/modules/notifications/channels/email-channel.ts`、`src/app/styles/tokens/primitive.css` | 其它文件直接写 `#hex`ESLint 报错) |
| 白名单文件 #hex 需加 disable 注释 | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex` | `// arbitrary-value: ...`(非真正 disable 指令,规则仍触发) |
### ESLint disable 注释格式规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| disable 注释用 `--` 双连字符分隔描述 | `// eslint-disable-next-line no-restricted-syntax -- reason` | `// eslint-disable-next-line no-restricted-syntax - reason`(单连字符被解析为规则名的一部分) |
| disable 描述中禁止含逗号 | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex` | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex, not token`(逗号被解析为多个规则名) |
### ESLint 自定义规则加载规则Windows
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| Windows ESM 动态加载需 `pathToFileURL` 转换 | `await import(pathToFileURL(join(__dirname, "eslint-rules/xxx.js")).href)` | `await import(join(__dirname, "eslint-rules/xxx.js"))`Windows `e:\` 路径触发 `ERR_UNSUPPORTED_ESM_URL_SCHEME` |
### ESLint 自定义规则匹配规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 字体名匹配用单词边界正则 | `new RegExp(\`\\b${font}\\b\`)`(精确匹配 `Inter`,不影响 `Interval`/`Interactive`/`clearInterval` | `String.includes("Inter")`(误匹配 `calculateNewInterval`/`taskInterrupted`/`addInteractiveComponents` |
### 令牌文件分布规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 令牌文件统一在 `src/app/styles/tokens/` | `primitive.css` / `semantic-light.css` / `semantic-dark.css` / `lesson-preparation.css` / `tailwind-theme.css` / `index.css` 分层定义 | 在 `globals.css` 中内联定义所有令牌(文件膨胀难以维护) |
| `globals.css` 用 `@import` 引入 | `@import "./styles/tokens/index.css";` | 在 `globals.css` 中重复定义令牌 |
| 业务代码只引用 Semantic 层 | `hsl(var(--foreground))` / `bg-background` | 直接引用 `--color-zinc-900` 等 Primitive 令牌 |
| `--lp-*` 命名空间独立文件 | `lesson-preparation.css` 中定义 `--lp-*` 明暗双份 | 在 `semantic-light.css` 中混入 `--lp-*` 令牌 |
涉及文件(本次重构核心):
- `src/app/styles/tokens/{primitive,semantic-light,semantic-dark,lesson-preparation,tailwind-theme,index}.css` — 6 个令牌文件新建
- `src/app/globals.css` — 改为 `@import` 引入477→258 行
- `eslint-rules/no-hardcoded-design-tokens.js` — 自定义 ESLint 规则(单词边界正则)
- `eslint.config.mjs` — `no-restricted-syntax` + 自定义规则加载(`pathToFileURL`
- `.trae/rules/project_rules.md` — 「Tailwind 规范」+ 「设计令牌规范(强制)」章节
- `docs/architecture/004_architecture_impact_map.md` — 1.1.2 设计令牌体系章节
- `docs/architecture/005_architecture_data.json` — `modules.shared.exports.designTokens` 节点
## Phase 4.2 + Phase 4.5 状态管理优化规则2026-07-05
### Phase 4.2Zustand selector 细粒度拆分规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 单字段订阅用细粒度 selector | `const title = useStore((s) => s.title)` | `const { title } = useStore()` (整体订阅,任一字段变化都触发 re-render) |
| 派生 boolean 用 selector 调用 | `const canUndo = useStore((s) => s.canUndo())` | `const editor = useStore(); const canUndo = editor.canUndo()` (每次渲染都调用函数) |
| 多字段订阅用 useShallow | `const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))` | `const { a, b } = useStore((s) => ({ a: s.a, b: s.b }))` (每次渲染都创建新对象) |
| 函数引用天然稳定,无需 useShallow | `const setTitle = useStore((s) => s.setTitle)` | `const setTitle = useStore(useShallow((s) => s.setTitle))` (多余浅比较开销) |
| 选择最精细的字段订阅 | `const nodes = useStore((s) => s.doc.nodes)` | `const doc = useStore((s) => s.doc)` (如果只用到 nodes 字段,订阅整个 doc 会在 anchors 变化时也触发 re-render) |
### Phase 4.5useOptimistic + useTransition 乐观更新规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 单字段乐观切换用 useOptimistic + useTransition | `const [optimisticIsStarred, addOptimisticStarred] = useOptimistic(isStarred, (_, next) => next); const [, startTransition] = useTransition(); startTransition(async () => { addOptimisticStarred(next); await action(); router.refresh(); })` | `const [isStarred, setIsStarred] = useState(initial); setIsStarred(next); await action(); setIsStarred(serverValue)` (手动同步易出错) |
| useOptimistic 必须在 transition 或 action 内调用 | `startTransition(async () => { addOptimistic(value); await serverAction(); }); ` 或在 `<form action={fn}>` 的 fn 内调用 | 在普通事件处理函数外直接调用 `addOptimistic(value)` (无效) |
| 成功后调用 router.refresh() 同步数据源 | `await serverAction(); router.refresh();` (useOptimistic 自动回滚到最新服务端值) | 只调用 `await serverAction()` 不 refresh (useOptimistic 状态回滚到原值UI 不更新) |
| form action 模式下 useOptimistic 自动管理 | `<form action={async (fd) => { setOptimisticSubmitting(true); await action(fd); }}>` (action 完成后自动回滚) | 在 form action 内用 `useState` + `setIsSubmitting(false)` (多余的 finally 块) |
| form action 模式不要用 useTransition 包裹 | `<form action={handleSubmit}>` (useFormStatus 自动跟踪 pending) | `const [, startTransition] = useTransition(); <form action={(fd) => { startTransition(async () => { await handleSubmit(fd); }) }}>` (form action 立即返回useFormStatus 不可靠) |
| Map 形式乐观更新用 useOptimistic + reducer | `const [map, addOptimistic] = useOptimistic(new Map(), (state, { id, value }) => { const next = new Map(state); next.set(id, value); return next; })` | `const [override, setOverride] = useState({}); setOverride({ ...override, [id]: value })` (手动管理回滚复杂) |
| 不需要乐观更新的操作保留 useState | 撤回操作 `const [isRecalled, setIsRecalled] = useState(false)` (需要根据服务端返回判断是否成功) | 所有操作都用 useOptimistic (失败时乐观状态错误显示) |
### paper-context-menu.tsx duplicateNode 调用签名不匹配pre-existing tsc 错误)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| `duplicateNode` 函数签名只接受 1 个参数 | `const newId = duplicateNode(nodeId)` (后缀在函数内硬编码为 `(副本)`) | `const newId = duplicateNode(nodeId, t("v4.contextMenu.copySuffix"))` (TS2554: Expected 1 arguments, but got 2) |
> 此为 pre-existing bug`hooks/editor-slice.ts` 的 `duplicateNode(id: string) => string \| null` 签名只接受 1 参数,但 `paper-context-menu.tsx` 第 58 行传了 2 个参数。Phase 4.2 修改 selector 时未引入此错误,但需记录待修复。
---
## Phase 3.6 + Phase 3.7 + Phase 3.8 DB 性能优化规则2026-07-05
### 24.1 FULLTEXT 索引声明规则drizzle-kit 限制)
| 规则 | 正确做法 | 错误做法 |
|------|---------|---------|
| drizzle-kit 无法生成 FULLTEXT 索引声明 | 迁移文件中手动追加 `CREATE FULLTEXT INDEX \`idx_name\` ON \`table\` (\`col\`);` | 仅在 schema.ts 中声明索引期望 drizzle-kit 自动生成 |
| 手动追加后必须同步 snapshot 的 indexes 节点 | 在 `meta/00XX_snapshot.json` 对应表 indexes 中加 `"type": "fulltext"` | 仅改 .sql 不改 snapshot下次 drizzle-kit generate 会重复生成 |
| MySQL FULLTEXT 要求 InnoDB + utf8mb4 | 建表时 `engine=InnoDB` + `charset=utf8mb4`MySQL 5.7+ | 在 MyISAM 表或 utf8 字符集上创建 FULLTEXT |
| JSON 字段不能直接创建 FULLTEXT 索引 | 添加 STORED 生成列 `content_text` 用 `CAST(content AS CHAR)` 镜像为纯文本,对生成列创建 FULLTEXT | `CREATE FULLTEXT INDEX ON questions (content)`JSON 列不支持) |
| 生成列必须为 STORED 模式 | `text("content_text").generatedAlwaysAs(sql\`CAST(content AS CHAR)\`, { mode: "stored" })` | VIRTUAL 生成列不支持 FULLTEXT 索引 |
| schema.ts 顶部需导入 `sql` | `import { sql } from "drizzle-orm";` | 直接使用 `sql` 标识符ReferenceError |
**示例**`src/shared/db/schema.ts`
```typescript
contentText: text("content_text").generatedAlwaysAs(
sql`CAST(content AS CHAR)`,
{ mode: "stored" },
),
```
**示例**(迁移文件 `drizzle/0001_questions_fulltext_search.sql` 末尾手动追加):
```sql
-- P3-6: questions.content_text FULLTEXT 索引drizzle-kit 无法生成 FULLTEXT 声明,需手动追加)
-- 要求InnoDB 引擎 + utf8mb4 字符集MySQL 5.7+
CREATE FULLTEXT INDEX `questions_content_text_ft_idx` ON `questions` (`content_text`);
```
**示例**`drizzle/meta/0001_snapshot.json` 中 questions 表 indexes 节点):
```json
"questions_content_text_ft_idx": {
"name": "questions_content_text_ft_idx",
"columns": ["content_text"],
"isUnique": false,
"type": "fulltext"
}
```
### 24.2 MATCH AGAINST BOOLEAN MODE 查询转换规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 调用方传原始查询字符串 `q` | `searchQuestions(q, limit)`q 为用户输入原文) | `searchQuestions("%keyword%", limit)`LIKE 格式传给 MATCH |
| 工具函数转换 `q` 为 `+word1* +word2*` | `toBooleanModeQuery("数学 函数")` → `"+数学* +函数*"` | 直接传 `"数学 函数"` 给 AGAINST无前缀仅完整词匹配 |
| 转义 BOOLEAN MODE 特殊字符 | `word.replace(/[+\-<>()~*"']/g, " ").trim()` | 保留 `+`/`-`/`(` 等字符(被 MySQL 解释为操作符) |
| 单词后加 `*` 启用前缀匹配 | `+word*`(匹配 word 开头的所有词) | `+word`(仅匹配完全相同的词) |
| 空查询需短路返回空数组 | `if (words.length === 0) return []` | 空 `+*` 传给 AGAINSTMySQL 报语法错误) |
**示例**`src/modules/search/data-access.ts`
```typescript
function toBooleanModeQuery(q: string): string {
const words = q.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ""
const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*`
return words.map(escapeWord).filter((w) => w !== "+*").join(" ")
}
// 使用
const booleanQuery = toBooleanModeQuery(q)
const rows = await db
.select({ id: questions.id })
.from(questions)
.where(sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)
```
### 24.3 批量 INSERT 规则fan-out 场景)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| fan-out 批量通知用单次多行 INSERT | `db.insert(messageNotifications).values(rows)`rows 为数组) | `for (const p of payloads) { await insert(p) }`N 次 INSERT |
| 批量发短信/邮件用 `Promise.all(map(send))` | `await Promise.all(valid.map((item) => sendSms(item)))` | `for (const item of valid) { await sendSms(item) }`(串行等待) |
| 批量 INSERT 失败时整体回滚 | `try { const ids = await createNotifications(inputs); ... } catch { // 所有项标记失败 }` | 单条 try/catch 部分成功部分失败(数据一致性破坏) |
| in_app 渠道单次批量,其他渠道并行 | `inAppSender.sendBatch(inAppPayloads)` + `Promise.all(otherChannels.map(send))` | 所有渠道统一 `Promise.all(map(sendNotification))`in_app 实际是 N 次单条 INSERT |
| 批量入口接收 `items: Array<{...}>` 数组 | `async sendBatch(items: Array<{ payload; recipient }>): Promise<ChannelSendResult[]>` | 暴露单条 `send(payload, recipient)` 由上层 `Promise.all` 调用 |
| 预校验数据一致性后再批量 INSERT | `if (item.recipient.userId !== item.payload.userId) continue`(构造 inputs 前过滤) | INSERT 后才发现 userId 不一致(已写入脏数据) |
**示例**`src/modules/notifications/data-access.ts`
```typescript
export async function createNotifications(items: CreateNotificationInput[]): Promise<string[]> {
if (items.length === 0) return []
const rows = items.map((data) => ({
id: createId(),
userId: data.userId,
type: data.type,
title: data.title,
content: data.content ?? null,
link: data.link ?? null,
priority: data.priority ?? "normal",
}))
await db.insert(messageNotifications).values(rows)
return rows.map((r) => r.id)
}
```
### 24.4 getAllUserIds 分页规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 全量查询必须加 LIMIT 默认值 | `getAllUserIds(limit = 1000, offset = 0)` | `getAllUserIds()`(无 LIMIT超大学校 OOM |
| 暴露 offset 分页参数 | `getAllUserIds(limit, offset)` | 仅 LIMIT 无 offset无法翻页 |
| 调用方循环遍历全部数据 | `while (true) { const page = await getAllUserIds(PAGE_SIZE, offset); if (page.length === 0) break; allIds.push(...page); offset += PAGE_SIZE }` | 假设单次 `getAllUserIds()` 能返回全部用户 |
| 默认 PAGE_SIZE = 1000 | `const PAGE_SIZE = 1000`(与默认 LIMIT 一致) | `PAGE_SIZE = 10000`(单次查询过大) |
| 不足一页时退出循环 | `if (page.length < PAGE_SIZE) break` | 仅判断 `page.length === 0`(最后一页恰好等于 PAGE_SIZE 时多一次空查询) |
**示例**`src/modules/announcements/data-access.ts` school 公告 fan-out
```typescript
if (announcement.type === "school") {
const { getAllUserIds } = await import("@/modules/users/data-access")
const PAGE_SIZE = 1000
const allIds: string[] = []
let offset = 0
while (true) {
const page = await getAllUserIds(PAGE_SIZE, offset)
if (page.length === 0) break
allIds.push(...page)
if (page.length < PAGE_SIZE) break
offset += PAGE_SIZE
}
return allIds
}
```
### 24.5 默认 LIMIT 控制规则(非分页场景)
| 规则 | 正确做法 | 错误做法 |
|------|---------|---------|
| 非分页查询默认 LIMIT ≤ 100 | `limit(100)` 或 `limit: 100` | `limit: 5000` / `limit: 1000`(无业务理由的高默认值) |
| 分页场景保留显式分页参数 | `getQuestions({ page, pageSize = 50 })`(用户可控) | 内部硬编码 `limit: 5000`(用户无法翻页) |
| 跨模块辅助查询 LIMIT ≤ 100 | `getStudentAnsweredQuestionIds(studentId)` 内部 `.limit(100)`(仅查最近 100 条避免重复) | `.limit(1000)`(辅助查询不需要全量历史) |
| 全量聚合场景用 GROUP BY + 聚合函数而非拉全表 | `db.select({ count: count() }).from(table)` | `db.query.table.findMany({ limit: 5000 })` 后在内存 reduce |
**Phase 3.8 调整文件清单**
- `src/modules/attendance/data-access-correlation.ts:127``limit: 5000` → `limit: 100`(相关性分析辅助查询)
- `src/modules/adaptive-practice/data-access-strategy.ts:293``.limit(1000)` → `.limit(100)`(学生已答题目去重辅助查询)
- `src/modules/questions/data-access.ts:570``limit: 1000` → `limit: 100`(跨模块知识点查询辅助)
涉及文件Phase 3.6/3.7/3.8 全部改动):
- `src/shared/db/schema.ts` — questions 表新增 `contentText` STORED 生成列 + 5 个补齐索引
- `drizzle/0001_questions_fulltext_search.sql` — 新建迁移(含手动追加的 `CREATE FULLTEXT INDEX`
- `drizzle/meta/0001_snapshot.json` — questions 表 indexes 节点添加 fulltext 声明
- `drizzle/meta/_journal.json` — 添加 0001 迁移条目
- `src/modules/search/data-access.ts` — searchQuestions 改 MATCH AGAINST + `toBooleanModeQuery` 工具函数
- `src/app/api/search/route.ts` — 同步 searchQuestions 签名 + `toBooleanModeQuery` 辅助
- `src/modules/users/data-access.ts` — `getAllUserIds(limit=1000, offset=0)` 分页参数
- `src/modules/announcements/data-access.ts` — school 公告 fan-out 分页循环遍历
- `src/modules/notifications/data-access.ts` — 新增 `createNotifications(items)` 批量 INSERT
- `src/modules/notifications/index.ts` — 导出 `createNotifications`
- `src/modules/notifications/channels/in-app-channel.ts` — `sendBatch` 重写为单次批量 INSERT
- `src/modules/notifications/dispatcher.ts` — `sendBatchNotifications` 重构为 in_app 批量 + 其他渠道并行
## Phase 1 配置基线规则2026-07-07 补漏)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 启用 optimizePackageImports 优化 barrel 库 | `experimental: { optimizePackageImports: ["lucide-react", "recharts", "@xyflow/react", "@tiptap/*", "@radix-ui/*", "date-fns"] }` | 不配置(整个 barrel 被打入首屏 chunk |
| serverExternalPackages 覆盖所有 Node.js 原生模块 | `serverExternalPackages: ["mysql2", "tencentcloud-sdk-nodejs", "exceljs"]` | 仅 `["mysql2"]`(遗漏腾讯云 SDK 2-3 MB + Excel 250-300 KB |
| Web Vitals 上报组件必须注入到 Providers | `<WebVitalsReporter />` 在 Providers 内渲染(与 Toaster 同级) | 实现了组件但未注入(生产环境零 RUM 数据) |
| Lighthouse CI 门槛 LCP/CLS 为 error 级 | `assertions: { "largest-contentful-paint": ["error", { "maxNumericValue": 3000 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }] }` | 全部用 warn性能回归不阻断合并 |
**Phase 1 涉及文件**
- `next.config.ts` — 新增 experimental.optimizePackageImports + 扩展 serverExternalPackages
- `src/app/providers.tsx` — 注入 `<WebVitalsReporter />`
- `src/app/web-vitals-reporter.tsx` — useReportWebVitals + sendBeacon 上报
- `src/app/api/web-vitals/route.ts` — edge runtime 端点 + 预算基线对比
- `.gitea/workflows/lighthouse.yml` + `lighthouserc.json` — CI 性能门槛
## Phase 2 Bundle 预算优化规则2026-07-05 + 2026-07-07 补漏)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 重型客户端库用 next/dynamic ssr:false | `dynamic(() => import("./xxx-inner").then(m => m.XxxInner), { ssr: false, loading: () => <Skeleton/> })` | 顶层静态 import 整个 TipTap/ReactFlow |
| Provider 上下文与懒加载实现分离 | 同步壳保留 `<ReactFlowProvider>`inner 用 `useReactFlow()` 钩子 | inner 直接调用 `useReactFlow()` 但未在同层 Provider 内 |
| forwardRef 组件动态导入需保留 forwardRef 外壳 | `export const ExamRichEditor = forwardRef((props, ref) => <LazyInner {...props} ref={ref} />)` | 直接 `export const ExamRichEditor = dynamic(...)`ref 丢失) |
| 服务端消费方仅需类型时拆 types barrel | `import type { EditorDoc } from "@/modules/exams/editor/types"`(纯类型入口) | `import { EditorDoc } from "@/modules/exams/editor/exam-rich-editor-types"`(拉入 @tiptap/* 运行时) |
| recharts 改具名导入替代 barrel | `import { ResponsiveContainer, Tooltip, Legend } from "recharts"` | `import * as RechartsPrimitive from "recharts"`Turbopack 难以 tree-shake |
| 纯展示组件无需 "use client" | `function StatusBadge({ status }) { return <span>...</span> }` | 在文件首行加 `"use client"` 但未使用任何客户端 hook |
| 角色路由必须配 loading.tsx | `app/(dashboard)/teacher/loading.tsx` 提供 Skeleton fallback | 仅依赖默认 Next.js 加载状态导致页面切换白屏 |
**Phase 2 涉及文件**
- `src/shared/components/ui/rich-text-editor.tsx` + `rich-text-editor-inner.tsx` — TipTap 动态导入
- `src/modules/exams/editor/exam-rich-editor.tsx` + `exam-rich-editor-inner.tsx` — TipTap 动态导入forwardRef wrapper
- `src/modules/lesson-preparation/components/blocks/rich-text-block.tsx` + `rich-text-block-inner.tsx` — TipTap 动态导入
- `src/modules/ai/components/ai-assistant-widget.tsx` + `ai-assistant-widget-inner.tsx` — AI SDK 动态导入
- `src/modules/textbooks/components/knowledge-graph.tsx` + `knowledge-graph-inner.tsx` — ReactFlow 动态导入
- `src/shared/components/ui/chart.tsx` — recharts 具名导入替代 barrel
- `src/modules/exams/editor/types.ts` — 新建类型 barrel
- `src/shared/components/ui/status-badge.tsx` — 移除 "use client"
- `src/app/(dashboard)/teacher/loading.tsx`、`student/loading.tsx`、`parent/loading.tsx` — 新建 Skeleton fallback
## Phase 3.1-3.5 数据层优化规则2026-07-05
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 请求级去重用 React cache() | `export const getSession = cache(async () => { ... })`(同请求多次调用复用结果) | 在 Server Action 内用全局变量缓存(跨请求共享有用户隔离风险) |
| 跨请求缓存才用 unstable_cache | 仅对真正稳定数据(如权限配置)使用 `unstable_cache` + revalidateTags | 用 `unstable_cache` 缓存 session用户切换后无法即时刷新 |
| Auth 链路统一通过 getAuthContext | `const { userId, dataScope } = await getAuthContext()`(自动 cache 去重) | data-access 内直接 `auth()` + 重复 resolveDataScope每条记录都解析一次 |
| 批量 INSERT 用单次 values([...]) | `await tx.insert(table).values(items.map(...))`(单次 SQL | `for (const item of items) { await db.insert(table).values(item) }`N 次往返) |
| 批量 UPDATE 用 CASE WHEN | `UPDATE t SET score = CASE id WHEN 'a' THEN 1 WHEN 'b' THEN 2 ELSE score END WHERE id IN (...)` | 循环执行 `UPDATE t SET score = ? WHERE id = ?` |
| 事务批量操作用 db.transaction | `await db.transaction(async (tx) => { await tx.insert(...).values(plans); await tx.insert(...).values(items); })` | 多次独立 `db.insert`(无原子性保证) |
| MySQL FULLTEXT 不支持 JSON 列 | 添加 `content_text STORED` 生成列 `CAST(content AS CHAR)` 后创建索引 | 直接 `CREATE FULLTEXT INDEX ON questions (content)`JSON 列报错) |
| BOOLEAN MODE 特殊字符需转义 | `+word1* +word2*`(用 `toBooleanModeQuery` 工具函数处理 `+-<>()*~"` | 直接拼接用户输入到 `MATCH AGAINST`(特殊字符导致语法错误) |
| 高 LIMIT 默认值不超过 100 | `.limit(100)`(辅助查询足够) | `.limit(5000)`OOM 风险) |
| 分页参数显式暴露 | `getAllUserIds(limit=1000, offset=0)` | `getAllUserIds()`(无限制拉全表) |
**Phase 3.1-3.5 涉及文件**
- `src/shared/lib/session.ts` — `fetchSession` + `getSession = cache(fetchSession)`
- `src/shared/lib/auth-guard.ts` — `resolveDataScope = cache(...)` + `getAuthContext = cache(...)`
- `src/modules/classes/data-access.ts` — `getSessionTeacherId = cache(...)`
- `src/modules/course-plans/data-access.ts` — `copyCoursePlanToClasses` 单事务 + 2 次 batch INSERT
- `src/modules/homework/data-access-write.ts` — `gradeHomeworkAnswers` UPDATE CASE WHEN + `tx.execute(sql\`...\`)`
## Phase 4.1/4.3/4.4/4.6-4.9 组件渲染优化规则2026-07-05
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 高频列表项用 React.memo | `export const AssignmentCard = memo(function AssignmentCard(props) { ... })` | 内联匿名组件(每次父渲染都重建实例) |
| 大列表用虚拟化 | `const { virtualItems } = useVirtualizer({ count, estimateSize: () => 56, getScrollElement })` | `{items.map(item => <Row key={item.id} {...item} />)}`5000+ 行卡顿) |
| 高频事件用 debounce + useMemo | `const debounced = useMemo(() => debounce(handler, 200), [deps])` | 直接 `onChange={e => setWidth(e.target.value)}`(每像素触发 setState |
| recharts 配置 props 提取到模块级 | `const CHART_MARGIN = { top: 5, right: 5 } ` + `<BarChart margin={CHART_MARGIN}>` | `<BarChart margin={{ top: 5, right: 5 }}>`(每次渲染新对象引用,子组件 memo 失效) |
| 图片容器用 aspect-ratio | `<div className="aspect-[4/3]"><img className="h-full w-full object-cover"/></div>` | `<img style={{ height: 'auto', width: '100%' }}/>`(加载时布局抖动 CLS |
| next/web-vitals Metric 类型从签名推导 | `type M = Parameters<Parameters<typeof useReportWebVitals>[0]>[0]` | `import type { Metric } from 'next/web-vitals'`v15+ 已不导出) |
| PerformanceEntry 显式标注类型 | `metric.entries.map((entry: PerformanceEntry) => ({...}))` | `(entry) => ({...})`implicit any |
| sendBeacon 失败时回退 fetch keepalive | `if (!navigator.sendBeacon(url, blob)) { await fetch(url, { keepalive: true }) }` | 仅用 fetch页面卸载时可能被取消 |
| layout.tsx metadata 必须增强 SEO 字段 | `metadataBase: new URL(env.NEXTAUTH_URL ?? "http://localhost:3000"), openGraph: {...}, twitter: {...}, robots: {...}, authors: [...], creator: "..."` | 仅 `title` + `description`OG image 加载异常、SEO 缺失、build 时 warn |
| layout.tsx 串行 await 改 Promise.all | `const [locale, messages, session] = await Promise.all([getLocale(), getMessages(), auth()])` | 三次串行 `await`(增加 TTFB/FCP/LCP |
**Phase 4.1/4.3/4.4/4.6-4.9 涉及文件**
- `src/shared/components/ui/empty-state.tsx` — React.memo
- `src/shared/components/ui/status-badge.tsx` — React.memo
- `src/app/(dashboard)/student/learning/assignments/page.tsx` — AssignmentCard memo
- `src/modules/questions/components/question-data-table.tsx` — useVirtualizer
- `src/modules/exams/components/exam-data-table.tsx` — useVirtualizer
- `src/modules/layout/components/sidebar-provider.tsx` — resize debounce + useMemo
- `src/modules/homework/components/scan-image-viewer.tsx` — aspect-ratio
- 12 个 chart 组件parent/grades/dashboard/homework 模块下) — recharts margin 提取 CHART_MARGIN 模块级常量
- `src/app/web-vitals-reporter.tsx` — Metric 类型推导修复 + PerformanceEntry 注解
- `src/app/layout.tsx` — metadata 增强metadataBase/openGraph/twitter/robots/authors/creator+ Promise.all 并行获取 locale/messages/session
- `package.json` — 新增 `@tanstack/react-virtual`、`@tanstack/react-query-devtools`
## Phase 4.3 useOptimistic 乐观更新应用规则(补充)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 单字段乐观切换 | `const [starred, addStarred] = useOptimistic(isStarred, (_, v) => v)` | `const [starred, setStarred] = useState(isStarred); setStarred(v); await action(); setStarred(serverVal)` |
| Map 形式乐观更新 | `const [override, addOverride] = useOptimistic(new Map(), (s, { id, v }) => new Map(s).set(id, v))` | `const [override, setOverride] = useState({}); setOverride({ ...override, [id]: v })` |
| form action 模式 useOptimistic 自动回滚 | `<form action={async (fd) => { setOptimistic(true); await action(fd); }}>` | `useEffect(() => setIsSubmitting(false), [result])`(多余 effect |
| 不需要乐观的保留 useState | 撤回操作 `const [recalled, setRecalled] = useState(false)`(依赖服务端返回) | 强行用 useOptimistic失败时乐观状态错误 |
**Phase 4.3 涉及文件**
- `src/modules/messaging/components/message-detail.tsx` — 星标 useOptimistic + useTransition
- `src/modules/messaging/components/message-list.tsx` — 星标 Map useOptimistic
- `src/modules/grades/components/grade-record-list.tsx` — 编辑保存 useOptimistic + useTransition
- `src/modules/attendance/components/attendance-sheet.tsx` — 提交中 useOptimistic保留 form action
## Vitest + Next.js 16 缓存测试规则2026-07-05
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| `vi.mock` 工厂内引用变量必须用 `vi.hoisted` 提升 | `const { mockStore } = vi.hoisted(() => ({ mockStore: {...} }))` 后 `vi.mock("./x", () => ({ f: vi.fn().mockResolvedValue(mockStore) }))` | `const mockStore = {...}` 后 `vi.mock("./x", () => ({ f: vi.fn().mockResolvedValue(mockStore) }))`TDZCannot access 'mockStore' before initialization |
| `vi.mock` 工厂也可用箭头函数延迟绑定(不需 `vi.hoisted` | `vi.mock("./x", () => ({ f: () => mockFn() }))`(每次调用时才读取顶层 `mockFn` | `vi.mock("./x", () => ({ f: mockFn }))`(工厂执行时 `mockFn` 未初始化) |
| `vitest.unit.config.ts` 已设 `mockReset: true`,每个测试前 mock 返回值被清空 | `beforeEach(() => { vi.mocked(getCacheStore).mockResolvedValue(mockStore) })` | `beforeEach(() => { vi.clearAllMocks() })`mockResolvedValue 已被 reset调用时报 `Cannot read properties of undefined` |
| React 19 `cache()` 在 vitest jsdom 环境是 no-op不去重 | 测试用真正缓存的 mock 实现(`mockStore.getOrSet` 实现命中缓存逻辑)验证去重,不依赖 react.cache 行为 | 期望 `react.cache` 在 jsdom 中去重producer 仅调 1 次),实际不去重(调用 N 次) |
| cacheFn 双层缓存测试 mock store-factory 时需保持引用稳定 | 顶层 `const mockStore = { getOrSet: vi.fn() }` + `const getCacheStoreMock = vi.fn().mockResolvedValue(mockStore)` + `vi.mock("./store-factory", () => ({ getCacheStore: () => getCacheStoreMock() }))` | `vi.mock("./store-factory", () => ({ getCacheStore: vi.fn().mockResolvedValue(mockStore) }))`mockReset 后丢失实现) |
| Next.js 16 起 `revalidateTag` 类型签名要求第二参数 `profile`(必填) | `revalidateTag(tag, "default")` | `revalidateTag(tag)`tsc TS2554: Expected 2 arguments, but got 1 |
| `revalidatePath` 第二参数 `type` 仍可选 | `revalidatePath(path)` / `revalidatePath("/", "layout")` | — |
**涉及文件**`src/shared/lib/cache/invalidate.ts`、`src/shared/lib/cache/invalidate.test.ts`、`src/shared/lib/cache/cache-fn.ts`、`src/shared/lib/cache/cache-fn.test.ts`
---
## 二十四、缓存策略规则2026-07-05 缓存策略重构)
> 标杆模块:`classes`。后续模块按此模式扩展。底层基础设施见 `src/shared/lib/cache/`。
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 查询函数包装 | `export const fn = cacheFn(fnRaw, { tags, ttl, keyParts })` | `cache(fn)` 或不包装 |
| 双导出 raw 版本 | `export const fnRaw = ...; export const fn = cacheFn(fnRaw, ...)` | 仅导出缓存版本(单测无法 mock |
| Server Action 写后失效 | `await invalidateFor("module.action", { id })` | `revalidatePath("/path")` 直接调用 |
| 未知 actionId | 抛错(必须先登记 INVALIDATION_MAP | 静默忽略 |
| 客户端 useQuery queryKey | `queryKeys.classes.detail(id)` 工厂调用 | 手写字符串数组 `["classes", "detail", id]` |
| useActionQuery 传 queryKey | `useActionQuery(action, { queryKey: queryKeys.classes.detail(id) })` | 不传 queryKey旧模式不跨页共享 |
| useActionMutation 自动 invalidate | `useActionMutation({ mutationFn, actionId: "classes.update", params: { id } })` | onSuccess 中手动 invalidateQueries |
| Tag 命名 | `classes:detail:{id}` 模块:资源:ID | `class_detail` 或 `ClassesDetail` |
| TTL 选择 | 列表 300s / 学生 60s / 课表 600s / 统计 60s | 全局统一 TTL |
| CLIENT_INVALIDATION_MAP 与 INVALIDATION_MAP 同步 | 服务端 invalidation-map.ts 增项后,同步在 client-invalidation-map.ts 增 queryKeys 子项 | 只改服务端,客户端 useActionMutation 自动 invalidate 失效 |
| client-invalidation-map 仅含 queryKeys | `CLIENT_INVALIDATION_MAP["x.y"] = { queryKeys: [["x", "y"]] }` | 在客户端文件中引入 tags/paths拉入 server-only |
| raw 函数命名 | `getXxxRaw`(内部实现)+ `getXxx`cacheFn 包装后的对外 API | `getXxx` 与 `getXxxCached` 命名混用 |
| cacheFn options.tags 必填 | `{ tags: ["classes:detail:{id}"], ttl: 60, keyParts: [id] }` | `{ ttl: 60 }` 缺 tags无法失效 |
| invalidateFor 调用位置 | mutation Action 成功分支末尾 `await invalidateFor(...)` | 在 catch 分支调用(失败也失效缓存) |
| queryKey 工厂扩展 | 在 `query-keys.ts` 按模块命名空间扩展 `xxx: { all, lists, list, detail, ... }` | 在组件内就地定义 queryKey 字面量 |
### 缓存策略重构涉及文件清单
- `src/shared/lib/cache/{types,memory-store,redis-store,store-factory,cache-fn,invalidation-map,client-invalidation-map,invalidate,index}.ts` — 缓存基础设施9 个文件)
- `src/shared/lib/redis-client.ts` — 共享 Redis 客户端单例rate-limit + cache 共用)
- `src/shared/lib/query-keys.ts` — 客户端 queryKey 工厂(`queryKeys.classes.*`
- `src/shared/hooks/use-action-query.ts` — 新增 queryKey 入参走 QueryClient向后兼容旧模式
- `src/shared/hooks/use-action-mutation.ts` — 新增 actionId 自动 invalidate向后兼容 mutate(action)
- `src/env.mjs` — 新增 `CACHE_DRIVER: z.enum(["memory", "redis"]).default("memory")`
- `eslint.config.mjs` — 新增 `no-restricted-syntax` 规则禁止 Server Actions 中直接调用 revalidatePath/revalidateTag
### 全量迁移覆盖范围28 个模块)
**data-access 层迁移至 cacheFn**83 个文件250+ 查询函数):
| 组别 | 模块 | 备注 |
|------|------|------|
| 标杆 | classes | `data-access-{teacher,admin,students,stats,schedule}.ts` 5 文件 |
| A 组 | users / school / rbac / textbooks / questions / course-plans / files / dashboard / parent / proctoring | 77 个函数 |
| B 组 | exams / grades / homework / diagnostic / error-book / adaptive-practice | 含跨模块接口(如 exams/data-access-error-collection |
| C 组 | lesson-preparation / elective / announcements / messaging / notifications | 24 个函数 |
**actions 层迁移至 invalidateFor**43 个文件247 处 revalidatePath 替换):
| 组别 | 模块 | 替换数 |
|------|------|--------|
| 标杆 | classes5 文件) | 47 |
| A 组 | users / school / textbooks / questions / course-plans / standards / scheduling / audit / onboarding / invitation-codes / proctoring / i18n13 文件) | 70 |
| B 组 | exams / grades / homework / attendance / leave-requests / diagnostic / error-book / adaptive-practice9 文件) | 77 |
| C 组 | lesson-preparation / elective / announcements / messaging / notifications / settings / rbac8 文件) | 100 |
**INVALIDATION_MAP**:扩展至 **203 个 actionId**,覆盖全部 28 个模块。每个 actionId 声明 `{ tags, queryKeys, paths }` 三段副作用。`CLIENT_INVALIDATION_MAP` 同步保留客户端可见子集(仅 queryKeys
### 迁移过程中常见错误(速查)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| cacheFn 包装时移除原 `import { cache } from "react"` | 全部改用 `cacheFn` 双导出模式 | 保留 `cache(fn)` 调用但移除 importtsc 报 `Cannot find name 'cache'` |
| 跨模块接口函数(如 `getExamSubmissionDataForErrorCollection`)不包装 cacheFn | 仅包装本模块自有查询;跨模块接口由对方模块决定 | 给跨模块接口也加 cacheFn重复包装 |
| 多余的 `}` 或 `)` 字符 | 迁移后用 tsc 全量校验 | 手动编辑后未运行 tscTS1005/TS1128 语法错误) |
| questions/data-access.ts 等大文件分批迁移 | 完整迁移所有 `cache(...)` 调用至 cacheFn | 部分迁移导致 `import { cache }` 已删但调用仍存 |
---
## 25. 组件化重构专项2026-07-06
### 25.1 共享底座使用规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 错误边界必须用 preset | `<SectionErrorBoundary namespace="x">` | 自行实现类组件 |
| 统计卡片必须用 StatsGrid | `<StatsGrid items={[...]} />` | 手写 grid + StatCard 循环 |
| 骨架卡片必须用 SkeletonCard | `<SkeletonCard variant="table" />` | 手写 Card + Skeleton 布局 |
| 新增 `*-filters.tsx` 禁止 | 用 `<FilterBar>` + `<FilterSearchInput>` 组合 | 新建模块专属筛选器文件 |
| ErrorBoundary 基础类不直接使用 | 通过 SectionErrorBoundary/WidgetBoundary preset | 直接 `<ErrorBoundary>` |
| FilterBar 是 children-based 组合式 | `<FilterBar><FilterSearchInput/><Select/></FilterBar>` | 传入 fields 配置数组 |
### 25.2 巨型文件拆分模式
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 容器+子组件拆分保持外部 API 不变 | 容器 props 签名不变,子组件通过 props 接收数据 | 改变容器导出名或 props |
| 容器目标 ≤300 行,子组件各 ≤200 行 | 拆分后容器仅负责状态编排与 Server Action 调用 | 容器保留大量渲染逻辑 |
| 纯函数抽离为工具模块 | `utils/exam-structure-tree.ts` 含递归计算/收集/扁平化 | 在组件中写复杂纯函数 |
| 持久化逻辑抽为 Hook | `use-lesson-plan-persistence.ts` 封装 autoSave/effect | 容器内联 useEffect 处理持久化 |
| 流式响应状态保持稳定 | `useAiChatStream` hook 在容器中调用AbortController 用 useRef | 子组件持有流式状态 |
| Tiptap 编辑器 SSR 配置 | `immediatelyRender: false` 必须保留 | 拆分时遗漏 SSR 配置 |
| dashboard 4 角色仅抽象布局壳 | `<DashboardShell title stats actions>{children}</DashboardShell>` | 强制 4 角色使用相同内容区 |
### 25.3 重复组件迁移规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| filter 组件迁到 app 层就近位置 | `app/(dashboard)/.../xxx-filters.tsx` | 保留在 modules 层 |
| 删除旧组件不留 backwards-compat shim | 直接删除文件,调用方改 import | 保留 re-export 文件 |
| 同名冲突优先删除模块层版本 | textbooks 的 `section-error-boundary.tsx` 删除 | 保留两份同名文件 |
| 单数与复数重复时合并 | attendance 的 `attendance-stats-card.tsx`(单数)删除 | 保留两份 |
| StatsGrid 列数支持 2/3/4/5 | `<StatsGrid columns={5}>` 按需指定 | 固定 4 列 |
### 25.4 遗漏补全规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 批次划分需覆盖全部重复组件 | 审查 spec 附录清单与批次覆盖范围的一致性 | 仅覆盖有巨型文件的模块,遗漏无巨型文件模块 |
| i18n 键跨命名空间补齐 | school/settings/classes 等模块补 `error.boundaryTitle` 等 | 仅补部分模块,导致 next-intl SSR IntlError |
| adaptive-practice stats-cards 同样需迁移 | `practice-stats-cards` / `practice-overview-stats-cards` → StatsGrid | 仅迁移 grades/attendance 等批次内模块 |
### 25.5 验证规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 拆分后 tsc + lint 全量验证 | `npx tsc --noEmit` + `npm run lint` | 仅验证修改文件 |
| 旧组件名 grep 确认无残留 | 搜索 import 路径 + 组件名 | 仅搜索文件名 |
| i18n 键同步新增 | SectionErrorBoundary 的 namespace 需有 `error.boundaryTitle` 等键 | 新增 namespace 但不补 i18n 键 |
| 架构文档同步 004/005 | 每批闭环后更新 exports/lastUpdate | 全部完成后才更新 |
## 二十六、`as` 断言违规修复规则2026-07-07
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 禁止 `as` 断言(除 unknown 转换/测试) | 类型守卫 / `as unknown as T` + 注释 | `value as T` |
| JSON.parse 结果必须经 unknown 中转 | `const raw: unknown = JSON.parse(x); const d = raw as T` | `JSON.parse(x) as T` |
| localStorage 数据需字段校验 | `isRecord` + 逐字段 `typeof` 校验 | `JSON.parse(x) as MyType` |
| URL search param 需类型守卫 | `isTimeRange(v) ? v : "today"` | `v as TimeRange` |
| HTTP 响应需类型守卫 | `isFileUploadResult(raw) ? raw : fallback` | `(await res.json()) as Result` |
| DB enum 字段需守卫转换 | `toLeaveType(row.leaveType)` | `row.leaveType as LeaveType` |
| Select onValueChange 需守卫 | `isAttendancePeriod(v) ? v : "full_day"` | `v as AttendancePeriod` |
| 表单 event.currentTarget 需 instanceof | `if (e.currentTarget instanceof HTMLFormElement)` | `e.currentTarget as HTMLFormElement` |
| 空对象赋值需显式标注 | `const empty: Record<string, string> = {}` | `{} as Record<string, string>` |
| Set<X>.has(value) 需改 Set<string> | `const SET = new Set<string>([...]); SET.has(value)` | `SET.has(value as X)` |
| readonly X[] 转 readonly string[] | `const ARR: readonly string[] = [...]` | `ARR.includes(value as X)` |
| 联合类型收窄用 filter 守卫 | `.filter((n): n is 1\|2\|3 => n>=1 && n<=3)` | `.map(Number) as Array<1\|2\|3>` |
| Drizzle or() 返回 SQL\|undefined | `const r = or(...); return r ?? null` | `or(...) as SQL` |
| Zod schema + 类型守卫组合 | `parsed.data.filter(isPermission)` | `parsed.data as Permission[]` |
| 模块私有类型守卫放 lib/type-guards.ts | `lib/type-guards.ts` 导出 `isX` / `toX` | 在组件内联 `as` 断言 |
## 二十七、数据库访问层审计治理规则2026-07-07 v1 审计)
> 来源:[data-access-audit-v1.md](../architecture/audit/data-access-audit-v1.md)
### server-only 文件头规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 首行必须 `import "server-only"` | `import "server-only"\nimport { db } from ...` | `import { db } from ...`(首行缺失) |
### cacheFn 包装规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 读函数走 Raw + Wrapper 配对 | `getXxxRaw = async () => {...}; getXxx = cacheFn(getXxxRaw, {tags, ttl, keyParts})` | `getXxx = async () => { /* 直查 DB */ }` |
| 权限校验函数不缓存 | `verifyTeacherOwnsClass` 直查(避免缓存权限提升) | `verifyTeacherOwnsClass = cacheFn(...)` |
### N+1 循环 SQL 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 递归删除改批量收集 + inArray | 收集后代 ID 数组 → `inArray(ids)` 单次删除 | 递归内逐个 SELECT + DELETE |
| BFS 逐层 inArray 收集后代 | `while (queue.length) { const children = await tx.select().where(inArray(parentId, queue)); queue = nextLevel }` | `for (const child of children) { await recursive(child) }` |
| 循环 UPDATE 改 CASE WHEN | `UPDATE ... SET order = CASE id WHEN ... THEN ... END WHERE id IN (...)` | `for (item) await db.update(...).set(...).where(eq(id, item.id))` |
| 循环 UPDATE 在事务内并行 | `await db.transaction(async (tx) => { await Promise.all(items.map(i => tx.update(...).set(...).where(eq(id, i.id)))) })` | `for (item) await db.update(...).set(...).where(eq(id, item.id))`(串行 + 无事务) |
| 多步读写包装在 db.transaction | `await db.transaction(async (tx) => { /* reads + writes with tx */ })` | 多个独立 `db.select`/`db.update` 调用(无原子性,竞态风险) |
| 跨模块循环调用改批量接口 | `getActiveStudentIdsByClassIds(classIds)` 一次查询 | `Promise.all(classIds.map(id => getActiveStudentIdsByClassId(id)))` |
| 批量聚合改 GROUP BY + 应用层归并 | `db.select({...}).groupBy(studentId)` 一次查询 + `Map` 归并 | `Promise.all(classIds.map(id => db.select({...}).where(...)))`N 条聚合 SQL |
| 内存 filter 改 SQL WHERE | `inArray(field, ids)` 下推到 SQL | 拉全表后 `rows.filter(r => ids.includes(r.id))` |
### LIKE 全表扫描规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 前导通配符禁止 | `LIKE 'xxx%'`(可走索引)或 `MATCH AGAINST IN BOOLEAN MODE` | `LIKE '%xxx%'` |
| 前缀匹配必须转义用户输入通配符 | `escapeLikePattern(q)` 后再拼 `'%'`(转义 `%`/`_`/`\` | `like(col, \`${q}%\`)`(用户输入 `%` 破坏查询) |
| JSON 列禁止 LIKE | 关联表存储提取关系 + `inArray` 等值查询 | `LIKE('%id%', JSON列)` |
| LOWER+CAST+LIKE 三重杀手 | FULLTEXT 索引 + 生成列 `content_text` | `LOWER(CAST(content AS CHAR)) LIKE '%x%'` |
| 多字段搜索用 OR 前缀匹配 | `or(like(t.title, \`${esc(q)}%\`), like(t.subject, \`${esc(q)}%\`))` | `or(like(t.title, \`%${q}%\`), ...)` |
| JSON 内嵌 ID 子串匹配短期保留 | 注释 `// F-02: 子串匹配需 FULLTEXT 索引,短期保留` + 内存精确过滤 | 无注释的 `like(jsonCol, \`%${id}%\`)` |
### 跨模块 schema 引用规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 模块间走对方 data-access | `import { getClassNamesByIds } from "@/modules/classes/data-access"` | `import { classes } from "@/shared/db/schema"` + JOIN |
| 跨模块批量接口 | `getGradeNamesByIds(gradeIds): Promise<Map<string,string>>` | `Promise.all(gradeIds.map(id => getGradeNameById(id)))` |
| A-06 JOIN 改批量查询 + Map 合并 | 移除 `.leftJoin(textbooks, ...)`,改 `const map = await getTextbookTitlesByIds(ids); rows.map(r => ({...r, title: map.get(r.id) ?? null}))` | `.leftJoin(textbooks, eq(..., textbooks.id))` 直查对方表 |
| A-06 跨模块接口用 cacheFn Raw+Wrapper | `getXxxTitlesByIdsRaw = async (ids) => {...}; getXxxTitlesByIds = cacheFn(getXxxTitlesByIdsRaw, {tags, ttl, keyParts})` | 裸 `async (ids) => { return await db.select()... }` 无缓存 |
| A-06 别名导入避免命名冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling } from "@/modules/school/data-access"` | 本模块与导入方同名导出导致 duplicate identifier |
| A-06 单条查询用单 ID 接口 | `const name = (await getClassNameById(classId)) ?? ""` | `db.select().from(classes).leftJoin(...)` 仅查一条 |
| A-06 自有关联表可直查 + 跨模块名称走接口 | `db.select().from(questionsToKnowledgePoints)` + `await getKnowledgePointNamesByIds(kpIds)` | `.innerJoin(knowledgePoints, ...)` 直查对方知识点表 |
### data-access 职责边界规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 不含业务逻辑 | data-access 仅 CRUD + 映射 | 时间校验/状态机/抽签算法/角色判断 |
| 状态机移至 actions 或 lib | `actions.ts` 编排校验+状态迁移,调用 data-access 原子函数 | data-access 内 `if (status === 'pending') { ... } else if ...` |
| 纯计算函数移至 lib | `lib/schedule-conflict.ts` 导出 `isScheduleConflict` | data-access 内定义 `function isScheduleConflict(...)` |
| 跨模块编排移至 actions | actions 调用多个 data-access + 纯计算 | data-access 内调用 `getGradeRecords` + 归一化计算 |
### 事务规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 多步写必须包裹事务 | `await db.transaction(async (tx) => { ... })` | 循环内独立 `db.update(...)` 无事务 |
| 事务内用 tx 而非 db | `await tx.update(...)` | `await db.update(...)`(在 transaction 回调内) |
### 错误处理规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 层 throw 上抛 | `throw new Error("...")` 让 actions 处理 | `try { ... } catch { console.error(...); return null/[] }` |
| actions 层用 ActionState | `try { ... } catch (e) { return { success: false, message: handleActionError(e) } }` | actions 层 throw 不捕获 |
| 禁止 console.error 调试 | `throw new Error("...")` | `console.error("xxx failed:", error); return []` |
### 权限校验规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 每个模块必须有 actions.ts | `src/modules/xxx/actions.ts` 包装 Server Action | app/ 页面直接 import data-access |
| 破坏性操作用专属权限点 | `requirePermission(Permissions.AUDIT_LOG_PURGE)` | `requirePermission(Permissions.AUDIT_LOG_READ)` 执行删除 |
| 读权限不执行写操作 | 写操作用 `XXX_MANAGE`/`XXX_WRITE` 权限 | `XXX_READ` 权限执行 INSERT/UPDATE/DELETE |
### 文件结构规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 单文件 ≤ 800 行 | 超过时拆分为 `data-access-{职责}.ts` | 单文件 1000+ 行混合多类职责 |
| 单文件导出函数 ≤ 20 | 按职责拆分CRUD/跨模块/导入导出) | 单文件导出 40+ 函数 |
| 重复 helper 提取到 shared/lib | `import { toISODateString } from "@/shared/lib/date-utils"` | 每个模块自定义 `serializeDate`/`toIso` |
| export * 改显式 re-export | `export { getClassNamesByIds, getClassExists } from "./data-access-queries"` | `export * from "./data-access-queries"` |
### 非空断言规则G1-060~065
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 数组取值后判空再访问 | `const row = rows[0]; if (!row) throw new Error("..."); row.field` | `rows[0]!.field` |
| 数组取值后可选返回 | `const row = rows[0]; return row ? mapRow(row) : null` | `rows.length === 0 ? null : mapRow(rows[0]!)` |
| split 结果用 ?? 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` |
| WHERE 已过滤 NULL 的字段用 ?? | `templateId: r.templateId ?? ""` | `templateId: r.templateId!` |
### SELECT 列枚举规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 显式枚举所需列 | `db.select({ id: users.id, name: users.name }).from(users)` | `db.select().from(users)`SELECT * |
| 接口字段可空性须与 schema 一致 | `teacherName: string \| null`schema 中 `users.name` 无 `.notNull()` | `teacherName: string`tsc 报 `Type 'string \| null' is not assignable to type 'string'` |
| 仅枚举消费字段 | `db.select({ knowledgePointId, totalQuestions, correctQuestions })`(仅这三字段被 `existingByKp.set` 消费) | `db.select()` 拉全行后只用 3 字段 |
### 无 LIMIT 保护规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 列表查询加默认 LIMIT | `.limit(pageSize).offset(offset)` 或 `.limit(500)` | 无 LIMIT 的全表查询 |
| 统计走 SQL 聚合 | `SELECT COUNT(*), SUM(CASE WHEN ...) FROM ... WHERE ...` | 拉全表后内存循环统计 |
### Phase 0 P0 修复规则2026-07-07 落地)
> 来源:审计 v1 Phase 0 P0 修复6 项 P0 全部落地tsc + lint 0 errors
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| A-09 actions 禁止直查 DB | actions.ts 调用 data-access 函数(如 `getUserOnboardedAt()` / `markUserOnboarded()` | actions.ts 中 `db.select` / `db.update` / `db.transaction` + `import { db } from '@/shared/db'` |
| 权限点注册全链路同步 | `permissions.ts` 常量 → `permissions.ts` ROLE_PERMISSIONS_SEED → `permission-bitmap.ts` → `permission-catalog.ts` → i18n `rbac.json`zh-CN/en五处同步 | 仅在 `permissions.ts` 添加常量 |
| 权限提权修复 | 破坏性操作用独立权限点 `requirePermission(Permissions.AUDIT_LOG_PURGE)` | 用低权限 `AUDIT_LOG_READ` 覆盖清理操作(`purgeAuditLogsAction` |
| A-08 缺失 actions.ts | app/ 调用 `@/modules/parent/actions` 的 Server Action | app/ 直接 import `@/modules/parent/data-access` |
| S-01 文件超 1000 行拆分 | 拆分为 `data-access-{core,bulk,group,templates,reports}.ts` + barrel `export * from "./data-access-*"` | 单文件超过 1000 行(如 messaging/data-access.ts 1089 行) |
| S-04 DRY 日期序列化 | `import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"` | 各模块重复定义 `const toIso = (d: Date) => d.toISOString()` |
| P-01 server-only 强制 | data-access.ts 文件头 `import "server-only"` | data-access.ts 缺少 `import "server-only"`DB 逻辑泄露风险) |
### School 模块 G3 审计修复规则2026-07-07
> 来源school 模块 G3 审计G3-007 / G3-010 / G3-011 / G3-012 / S-03 / A-02 / A-10 / F-09
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| G3-007 / S-01 data-access 按职责拆分 | `data-access.ts`barrel→ `data-access-{departments,grades,subjects,classrooms,semesters,schools}.ts` + `lib/school-permissions.ts` | 单文件 938 行混合 6 类职责 |
| G3-010 / A-10 data-access 移除 try-catch 错误吞没 | `export const getXxxRaw = async () => { const rows = await db.select()...; return rows.map(...) }`(让错误向上抛出) | `try { ... } catch (error) { log.error({err: error}, "xxx failed"); return [] }`(吞没错误返回空数组) |
| G3-010 / A-10 data-access 禁止 log.error / console.error | `throw` 让 actions 层 catch 处理 | `log.error({ err: error }, "xxx failed"); return []` |
| G3-011 / A-02 角色判断迁出 data-access | `lib/school-permissions.ts` 导出 `getSchoolsForUser(userId)` / `getGradesForUser(userId)`data-access 只接受显式参数(如 `getGradesByIds(gradeIds)` | data-access 内 `if (roleNames.has("admin")) {...} else if (roleNames.has("teacher")) {...}` |
| G3-012 / F-09 promoteGrades 事务包裹 | `await db.transaction(async (tx) => { const rows = await tx.select()...; for (row of rows) await tx.update()... })` | `const rows = await db.select()...; for (row of rows) await db.update()...`(无事务,部分失败不一致) |
| S-03 fetchGradesWithHeads helper 抽取 | `async function fetchGradesWithHeads(where?: SQL) { const rows = await db.select()...; return mapGradesWithHeads(rows) }` 复用于 getGrades / getGradesForStaff / getGradesByIds | 三个函数各自重复"查询年级 + 批量查询主任用户 + Map 映射"逻辑 |
| barrel re-export 保持向后兼容 | 拆分后 barrel 文件 re-export 子文件导出,消费者无需改 import 路径 | 拆分后修改所有消费者的 import 路径 |
| G3-048 barrel export * 改显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` + `export type { TypeA } from "./sub"` | `export * from "./sub"`(子文件导出 < 15 时仍用 export * |
| G3-048 barrel export * 保留 | 子文件导出 >= 15 时保留 `export * from "./sub"` + 注释说明原因 | 子文件导出 >= 15 时强行显式列举(易遗漏符号) |
| 子文件首行 `import "server-only"` | 每个 `data-access-*.ts` 文件首行 `import "server-only"` | 仅 barrel 有 `import "server-only"`,子文件缺失 |
---
## 二十八、日志系统规则2026-07-07 重构)
### pino logger 使用
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 服务端日志统一用 pino | `import { createModuleLogger } from "@/shared/lib/logger"; const log = createModuleLogger("module-name"); log.info({...}, "msg")` | `console.log/error/warn/info(...)` |
| 模块级 logger 命名 | `createModuleLogger("audit")` / `createModuleLogger("exams")` / `createModuleLogger("client-error")` | `createModuleLogger("Audit")` / `createModuleLogger("a")` |
| 日志参数顺序 | `log.error({ err: error, userId }, "操作描述")` | `log.error("操作描述", error)` |
| 错误对象字段名 | `log.error({ err: error }, "msg")` | `log.error({ error }, "msg")` |
### Edge Runtime 限制
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| proxy.ts 不能导入 Node.js 模块 | `const requestId = crypto.randomUUID()` (Web Crypto API) | `import { randomUUID } from "node:crypto"` |
| Edge Runtime 路由不能导入 pino | `export const runtime = "nodejs"` + `import { createModuleLogger }` | `export const runtime = "edge"` + `import { createModuleLogger }` |
| 客户端组件不能导入服务端 logger | 客户端 track-event 改为纯 no-op | 客户端 .tsx 中 `import { createModuleLogger }` |
### Request ID 贯穿
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| Server Action 包装 | `export const myAction = withRequestContext(async (args) => {...})` | 直接在 Server Action 中 `requestContextStorage.run(...)` |
| Request ID 注入 | proxy.ts 通过 `NextResponse.next({ request: { headers } })` 注入 | proxy.ts 中 `requestContextStorage.run(...)` (Edge Runtime 不支持) |
| 读取 Request ID | `getRequestContext().requestId` (从 AsyncLocalStorage) | `headers().get("x-request-id")` (重复读取) |
### ESLint no-console 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 服务端 .ts 文件 | `import { createModuleLogger } from "@/shared/lib/logger"` | `console.error(...)` (ESLint error) |
| 客户端 .tsx 文件 | 暂时豁免(留待客户端错误上报机制处理) | - |
| scripts/ 和 tests/ | 允许 console脚本/测试场景) | - |
### 客户端错误上报
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| error.tsx 错误上报 | `useErrorReport(error)` (Hook 自动 sendBeacon) | `useEffect(() => { console.error(error) }, [error])` |
| 上报端点 | POST /api/client-error (Route Handler 用 pino 记录) | 自定义 console 输出 |
| 节流策略 | sessionStorage 记录 digest,1 分钟内不重复上报 | 每次渲染都上报 |
---
## 二十九、数据库访问层审计 Phase 1-4 修复规则速查2026-07-07
> Phase 0-4 全部落地230 条问题17 P0/48 P1/105 P2/60 P3tsc + lint 0 errors。
> 详细规则见第二十七节「数据库访问层审计治理规则」,本节为按规则 ID 的速查索引。
| 规则 ID | 正确写法 | 错误写法 |
|---------|---------|---------|
| F-01 N+1 递归删除 | BFS 逐层 `inArray` 收集后代 + 单次批量删除 | 递归内单独查询+删除 |
| F-01 N+1 批量统计 | 单条 `GROUP BY` 聚合 + 应用层 `Map` 归并 | 循环内每项单独查询 |
| F-01 N+1 多步写 | `db.transaction(async (tx) => { await Promise.all(items.map(...)) })` | 串行 `for (item) await db.update(...)` |
| F-02 LIKE 前缀匹配 | `like(field, escapeLikePattern(q) + "%")` | `like(field, "%" + q + "%")` |
| F-02 多字段搜索 | `or(like(t.title, esc(q)+"%"), like(t.subject, esc(q)+"%"))` | `or(like(t.title, "%"+q+"%"), ...)` |
| F-02 JSON 列搜索 | FULLTEXT 索引 + 生成列 `content_text` | `LIKE('%id%', JSON列)` |
| F-05 默认 LIMIT | `.limit(DEFAULT_LIMIT)` 无分页查询DEFAULT_LIMIT=1000 | 无 LIMIT 的全表查询 |
| F-09 事务包裹 | `db.transaction(async (tx) => { ... })` 多步写操作 | 多个独立 `db.update` 无事务 |
| F-09 事务内用 tx | `await tx.update(...)` | `await db.update(...)`(在 transaction 回调内) |
| A-02 业务逻辑下沉 | 算法/校验放 `lib/`CRUD 留 `data-access` | `data-access` 含状态机/抽签算法/时间冲突检测 |
| A-06 跨模块调用 | 调用对方 `data-access` 函数(`getTextbookTitlesByIds` 等) | `import { classes } from "@/shared/db/schema"` + JOIN |
| A-06 跨模块批量接口 | `getXxxTitlesByIds(ids): Map<id, title>` 一次查询 | `Promise.all(ids.map(id => getXxxTitleById(id)))` |
| A-06 别名导入避免冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling }` | 同名导出导致 duplicate identifier |
| A-10 错误处理 | `data-access` 层 `throw``actions` 层 `try-catch` 返回 `ActionState` | `data-access` 层 `console.error` + 吞错误返回 `[]` |
| P-03 cacheFn 配对 | `getXxxRaw` + `getXxx = cacheFn(getXxxRaw, {tags, ttl, keyParts})` | 读函数无缓存(直查 DB |
| P-03 权限校验不缓存 | `verifyTeacherOwnsClass` 直查(避免缓存权限提升) | `verifyTeacherOwnsClass = cacheFn(...)` |
| S-01 文件拆分 | 按职责拆为 `data-access-xxx.ts` + barrel | 单文件 > 1000 行(硬上限) |
| S-01 barrel re-export | barrel `export * from "./data-access-*"` 保持向后兼容 | 拆分后修改所有消费者 import 路径 |
| S-03 helper 去重 | 提取到 `lib/` 或 `shared/lib/`(如 `lib/date-utils.ts` | 多模块各自重复实现 `toIso`/`isClassSubject` |
| S-05 export * → 显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` | `export * from "./sub"`(导出 < 15 时) |
| S-05 export * 保留 | 子文件导出 >= 15 时保留 `export *` + 注释 | 导出 >= 15 强行显式列举(易遗漏) |
| S-06 JSDoc | 公开导出函数补 `@param` + `@returns` + `@throws` | 无 JSDoc |
| P-09 as 断言 | 类型守卫 / `satisfies` / 类型标注常量 | `value as Type` |
| P-09 非空断言 | `const row = rows[0]; if (!row) throw ...; row.field` | `rows[0]!.field` |
| P-09 split 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` |