项目规则速查手册
一页式规则清单,指明正确做法。无需解释原因,遇到问题查表即可。
一、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() 参数为动态变量时,必须包含完整嵌套路径:
| 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 |
六、架构分层规则
| 层级 |
允许依赖 |
禁止依赖 |
app/ |
modules/ 的 actions + data-access |
直接访问 DB |
modules/ |
其他 modules/ 的 data-access + shared/ |
直接查询对方 DB 表 |
shared/ |
无 |
@/auth、@/proxy、modules/* |
七、模块文件职责规则
| 文件 |
职责 |
禁止 |
actions.ts |
Server Actions(async 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 错误时:
- 用
SHOW COLUMNS FROM 表名 对比数据库实际结构与 schema.ts
- 若字段缺失,执行
npm run db:push 同步,或手动执行对应迁移 SQL
- 验证:用相同 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 表缺失时)
- 在
drizzle/ 创建 00XX_*.sql 文件(即使 DDL 已应用,仍需归档)
- 在
drizzle/meta/_journal.json 添加对应 entry(tag = 文件名不含扩展名)
- 计算文件 sha256 hash,插入
__drizzle_migrations 表:
- 验证:
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(Date),service 转换为 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 |
— |
错误现象
正确示例
十四、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()}(含函数) |
错误现象
修复模式
十五、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 会误判为错误边界触发。
正确检测方式
十六、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}> |
不传 session,client 端首次渲染为 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 不一致 |
错误现象
根因
useSession() 在 SSR 期间返回 status:"loading",CSR hydration 后变成 "authenticated",导致 SiteHeader 中 displayName/avatarFallback 渲染结果不同,触发 Radix UI 组件重新生成 id 属性。
修复模式
十七、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")) |
错误现象
修复模式
十八、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 |
— |
错误现象
根因
SectionErrorBoundary 内部调用 t("error.boundaryTitle") 和 t("error.boundaryDescription"),若 namespace JSON 缺少这些 key,next-intl 在 SSR 期间抛 IntlError,导致 Server Component 渲染失败,React 回退到 client 渲染,触发 hydration mismatch。
修复模式
十九、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 }} |
错误现象
修复模式
备课模块审核问题(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 |
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 bug,tsc 报 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-20),Tier 3 豁免 26 处(dialog 宽度/textarea min-h/badge text-[10px]/tiptap 等)
i18n 翻译文件对称性规则
| 规则 |
正确写法 |
错误写法 |
| zh-CN 与 en 必须 key 完全对称 |
同步增删 key,CI 校验 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" ? "师" : "生" 硬编码 |
严重违规:
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 文件不注册 |
错误现象
根因
i18n/request.ts 中配置的 messages key 是 errorBook(驼峰),但 17 个组件错误使用了 useTranslations("error-book")(连字符,与文件名 error-book.json 混淆)。next-intl 找不到 error-book namespace,所有 t() 调用抛出 MISSING_MESSAGE 错误。
修复模式
二十一、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 fallback,CSR 显示真实内容,useId() 生成不同 id |
— |
错误现象
根因
useSearchParams() 在 SSR 期间会导致包裹它的 <Suspense> 显示 fallback(Skeleton),而 CSR 期间直接显示真实内容。组件树结构在 SSR/CSR 间不同,导致 React useId() 生成的 id 不匹配,触发全局 hydration mismatch(影响页面所有 Radix UI 和 ChartContainer 组件的 id)。
修复模式
二十二、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 后返回 401(401 是未认证) |
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 */}
<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.2:Zustand 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.5:useOptimistic + 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 (失败时乐观状态错误显示) |
| 规则 |
正确写法 |
错误写法 |
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):
示例(迁移文件 drizzle/0001_questions_fulltext_search.sql 末尾手动追加):
示例(drizzle/meta/0001_snapshot.json 中 questions 表 indexes 节点):
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 [] |
空 +* 传给 AGAINST(MySQL 报语法错误) |
示例(src/modules/search/data-access.ts):
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):
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):
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) }))(TDZ:Cannot 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 替换):
| 组别 |
模块 |
替换数 |
| 标杆 |
classes(5 文件) |
47 |
| A 组 |
users / school / textbooks / questions / course-plans / standards / scheduling / audit / onboarding / invitation-codes / proctoring / i18n(13 文件) |
70 |
| B 组 |
exams / grades / homework / attendance / leave-requests / diagnostic / error-book / adaptive-practice(9 文件) |
77 |
| C 组 |
lesson-preparation / elective / announcements / messaging / notifications / settings / rbac(8 文件) |
100 |
INVALIDATION_MAP:扩展至 203 个 actionId,覆盖全部 28 个模块。每个 actionId 声明 { tags, queryKeys, paths } 三段副作用。CLIENT_INVALIDATION_MAP 同步保留客户端可见子集(仅 queryKeys)。
迁移过程中常见错误(速查)
| 规则 |
正确写法 |
错误写法 |
cacheFn 包装时移除原 import { cache } from "react" |
全部改用 cacheFn 双导出模式 |
保留 cache(fn) 调用但移除 import(tsc 报 Cannot find name 'cache') |
跨模块接口函数(如 getExamSubmissionDataForErrorCollection)不包装 cacheFn |
仅包装本模块自有查询;跨模块接口由对方模块决定 |
给跨模块接口也加 cacheFn(重复包装) |
多余的 } 或 ) 字符 |
迁移后用 tsc 全量校验 |
手动编辑后未运行 tsc(TS1005/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.has(value) 需改 Set |
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
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 P3),tsc + 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]! |