docs(cache): 同步 cacheFn 架构图与已知问题速查

This commit is contained in:
SpecialX
2026-07-05 18:07:18 +08:00
parent d6227d6e6c
commit 0f9d8825e7
3 changed files with 551 additions and 17 deletions

View File

@@ -135,6 +135,157 @@ src/app/
---
## 1.1.3 Phase 4.2 + Phase 4.5 状态管理优化2026-07-05 新增)
V5 状态管理统一的后续阶段,针对两类高频性能问题进行细粒度优化:**Zustand 整体订阅导致的级联 re-render** 与 **useState + 等待 Server Action 模式导致的 UI 不即时反馈**
### Phase 4.2lesson-plan-editor Zustand selector 拆分
`useLessonPlanEditor` 是组合了 5 个 slice 的 zustand store。原消费方使用 `const editor = useLessonPlanEditor()` 整体订阅,导致任一字段变化都触发整体 re-render。
| 文件 | 修改要点 |
|------|---------|
| `modules/lesson-preparation/components/lesson-plan-editor.tsx` | 拆分为 9 个细粒度 selector`isDirty`/`isOnline`/`doc`/`title`/`isSaving`/`saveError`/`lastSavedAt`/`setTitle` + `canUndo`/`canRedo` 派生 boolean移除 `editor.canUndo()` 重复调用,更新 `useEffect` 依赖与 `printablePlan` useMemo 使用拆分后的变量 |
| `modules/lesson-preparation/components/blocks/text-study-block.tsx` | `const { updateNode } = useLessonPlanEditor()``useLessonPlanEditor((s) => s.updateNode)` |
| `modules/lesson-preparation/components/blocks/exercise-block.tsx` | 拆为 `useLessonPlanEditor((s) => s.updateNode)` + `useLessonPlanEditor((s) => s.planId)` |
| `modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx` | 拆为 7 个细粒度 selector`toggleExpand`/`expandedNodeIds`/`updateNode`/`removeNode`/`addAnchor`/`duplicateNode`/`nodes`),特别地将 `doc` 改为 `s.doc.nodes` 更精细订阅(`addAnchor` 等仅修改 anchors 不会触发本组件 re-render |
**关键决策**
- `canUndo`/`canRedo` 改为 `useLessonPlanEditor((s) => s.canUndo())` 订阅派生 boolean避免每次渲染时调用函数
- 不使用 `useShallow` 包装多字段 selector因为单字段 selector 已经够细,且避免了 `useShallow` 自身的浅比较开销
- 函数引用(`setTitle`/`updateNode` 等)天然稳定,无需 `useShallow`
### Phase 4.5useOptimistic + useTransition 乐观更新
将 4 个组件从 `useState` + 等待 Server Action 模式迁移到 React 19 `useOptimistic` + `useTransition`,自动管理乐观状态与回滚。
| 文件 | 修改要点 |
|------|---------|
| `modules/messaging/components/message-detail.tsx` | 星标 `useState``useOptimistic<boolean>` + `useTransition``handleToggleStar``startStarTransition``addOptimisticStarred(nextStarred)` + `await toggleMessageStarAction` + `router.refresh()`。JSX 中所有 `isStarred` 引用改为 `optimisticIsStarred`Button variant/aria-pressed/Star className/星标徽章) |
| `modules/messaging/components/message-list.tsx` | 星标 `starredOverride` useState → `useOptimistic<Map<string, boolean>>`key=messageId, value=isStarred`getIsStarred` 改为 `optimisticStarredMap.get(m.id)`。撤回 `recalledOverride` 仍保留 useState不需要乐观更新 |
| `modules/grades/components/grade-record-list.tsx` | 编辑保存 `isSaving` useState → `useOptimistic<GradeRecordListItem[], GradeRecordListItem>` + `useTransition``handleEditSave` 构造 `optimisticRecord``startSaveTransition``addOptimisticRecord` + `await updateGradeRecordAction` + `router.refresh()`。两处 `records.map` 改为 `optimisticRecords.map`(桌面表格 + 移动端卡片视图)即时显示乐观记录 |
| `modules/attendance/components/attendance-sheet.tsx` | `isSubmitting` useState → `useOptimistic<boolean>`。保留 `<form action={handleSubmit}>` 模式 + `useFormStatus` SubmitButtonprogressive enhancement`setOptimisticSubmitting(true)` 在 form action 内调用action 完成后 `optimisticSubmitting` 自动恢复 false移除 `finally` 块 |
**关键决策**
- attendance-sheet 保留 form action 模式而非改用 `useTransition` 包裹,因为 `useFormStatus().pending` 依赖 form action 的 promise 状态,若用 `startTransition` 包裹会导致 form action 立即返回、`useFormStatus` 不可靠
- `useOptimistic``addOptimistic` 必须在 transition 或 action 内调用form action 也算 action
- 成功后调用 `router.refresh()` 同步数据源,让 `useOptimistic` 回滚到最新服务端值
- 不为撤回recall操作使用 useOptimistic因为撤回需要根据服务端返回判断是否成功2 分钟窗口校验),失败时不应乐观显示已撤回
### 验证结果
- `npx tsc --noEmit`零新增错误pre-existing 错误:`providers.tsx` 缺少 `@tanstack/react-query-devtools``web-vitals-reporter.tsx` Metric 导出、`homework/data-access-write.ts` MySqlTransaction.run、`paper-context-menu.tsx` 第 58 行 `duplicateNode` 调用签名不匹配——此为 pre-existing bug`duplicateNode(id: string)` 只接受 1 参数但调用方传 2 个、`print-view.tsx` Object.entries 类型推断)
- `npm run lint`零新增错误8 个 modified 文件均未出现在 lint 输出中pre-existing 3 errors + 13 warnings 均位于未修改文件:`scripts/check-db-state.mjs``scripts/seed-grade5-chinese.ts``practice-result-view.tsx``exam-rich-form.tsx``use-exam-preview-state.ts``use-exam-preview-tasks.ts``homework/data-access-write.ts``unread-message-badge.tsx`
---
## 1.1.4 Phase 3.6 + Phase 3.7 + Phase 3.8 DB 性能优化2026-07-05 新增)
针对数据库查询性能的三阶段优化:**FULLTEXT 全文检索**、**公告 fan-out 批量化**、**高 LIMIT 默认值调低**。
### Phase 3.6searchQuestions 改 FULLTEXT 索引
将 questions 表的模糊查询从 `LIKE '%xxx%'` 升级为 MySQL FULLTEXT 索引 + `MATCH AGAINST ... IN BOOLEAN MODE`,提升大表检索性能。
| 文件 | 修改要点 |
|------|---------|
| `src/shared/db/schema.ts` | 添加 `import { sql } from "drizzle-orm"`;在 `questions` 表添加 STORED 生成列 `contentText``text("content_text").generatedAlwaysAs(sql\`CAST(content AS CHAR)\`, { mode: "stored" })`);补齐 5 个先前未同步的索引typeDifficultyIdx/statusCreatedIdx/creatorIdx/examStatusIdx/submittedAtIdx |
| `drizzle/0001_questions_fulltext_search.sql` | drizzle-kit 生成的迁移CREATE TABLE lesson_plan_schedules + content_text 生成列 + 8 个索引;末尾手动追加 `CREATE FULLTEXT INDEX questions_content_text_ft_idx ON questions (content_text)` |
| `drizzle/meta/0001_snapshot.json` | questions 表 indexes 节点添加 `questions_content_text_ft_idx` 声明type: "fulltext" |
| `src/modules/search/data-access.ts` | `searchQuestions(kw, limit?)` → `searchQuestions(q, limit?)`;新增 `toBooleanModeQuery` 工具函数将原始查询转为 `+word1* +word2*` 格式;查询改用 `MATCH(questions.contentText) AGAINST(? IN BOOLEAN MODE)` |
| `src/app/api/search/route.ts` | 本地 `searchQuestions` 函数同步改用 MATCH AGAINST + `toBooleanModeQuery`;调用点从 `searchQuestions(kw, pageSize)` 改为 `searchQuestions(q, pageSize)` |
**关键决策**
- 使用 STORED 生成列 `content_text` 而非 VIRTUAL因为 FULLTEXT 索引在 MySQL 5.7+ 仅支持 STORED 生成列
- `CAST(content AS CHAR)` 将 JSON 字段序列化为纯文本,避免 MySQL FULLTEXT 无法直接索引 JSON 列的限制
- drizzle-kit 无法生成 FULLTEXT 索引声明,迁移文件中需手动追加 `CREATE FULLTEXT INDEX` 语句,并同步更新 snapshot 的 indexes 节点
- `toBooleanModeQuery` 转义 BOOLEAN MODE 特殊字符(+ - < > ( ) ~ * " ')避免用户输入破坏查询语法
- 保留 textbooks/exams/announcements 的 LIKE 模糊匹配FULLTEXT 索引需 InnoDB + utf8mb4且这些表查询量较小
### Phase 3.7getAllUserIds 加 LIMIT + 公告 fan-out 批量化
| 文件 | 修改要点 |
|------|---------|
| `src/modules/users/data-access.ts` | `getAllUserIds()` → `getAllUserIds(limit=1000, offset=0)`,添加默认 LIMIT 防止 OOM + 分页参数支持 |
| `src/modules/announcements/data-access.ts` | `resolveAnnouncementTargetUserIds` school 分支改为分页循环遍历 `getAllUserIds(PAGE_SIZE, offset)`PAGE_SIZE=1000聚合所有用户 ID |
| `src/modules/notifications/data-access.ts` | 新增 `createNotifications(items)` 批量 INSERT 函数(单次 `db.insert().values([...])`),返回生成的 ID 数组 |
| `src/modules/notifications/channels/in-app-channel.ts` | `InAppChannelSender.sendBatch` 从 `Promise.all(items.map(send))` 改为调用 `createNotifications(inputs)` 单次批量 INSERT预校验 recipient/userId 一致性;失败时整体回滚并返回失败结果 |
| `src/modules/notifications/dispatcher.ts` | `sendBatchNotifications` 重构:并行获取所有用户偏好+联系方式 → 收集 in_app 渠道 payload 单次 `sendBatch` → 其他渠道保持 per-payload 并行 → 按 payload 聚合日志 |
| `src/modules/notifications/index.ts` | 导出 `createNotifications` |
**关键决策**
- `getAllUserIds` 默认 LIMIT 1000 是平衡单次查询压力与 fan-out 完整性的折中值;调用方通过分页遍历获取全部用户
- `createNotifications` 使用 drizzle 的 `db.insert().values(array)` 一次 INSERT 多行,相比 N 次单行 INSERT 显著降低网络往返
- in_app 渠道批量化其他渠道sms/email/wechat保持原并行模式因为这些渠道的发送成本主要在网络 IO 而非 DB 写入
- 批量 INSERT 失败时整体回滚drizzle 默认事务行为),每条记录返回失败结果
### Phase 3.8:调低高 LIMIT 默认值
在 `src/modules/*/data-access*.ts` 中搜索 LIMIT > 100 的默认值并调低至 ≤100保留分页场景
| 文件 | 原 LIMIT | 新 LIMIT | 函数 |
|------|---------|---------|------|
| `src/modules/attendance/data-access-correlation.ts` | 5000 | 100 | `getAttendanceGradeCorrelation` 调用 `getGradeRecords` |
| `src/modules/adaptive-practice/data-access-strategy.ts` | 1000 | 100 | `getStudentAnsweredQuestionIds` |
| `src/modules/questions/data-access.ts` | 1000 | 100 | `exportQuestions` |
**未修改(不在 data-access*.ts 范围内)**
- `src/modules/homework/stats-service.ts:276`limit: 200和 `:345`limit: 5000不在 `data-access*.ts` glob 范围内,本次未修改
### 验证结果
- `npx tsc --noEmit`零新增错误pre-existing 错误:`providers.tsx`、`web-vitals-reporter.tsx`、`homework/data-access-write.ts`、`paper-context-menu.tsx`、`print-view.tsx` 均为未修改文件)
- `npm run lint`零新增错误16 个 pre-existing problems 均位于未修改文件:`scripts/check-db-state.mjs`、`scripts/seed-grade5-chinese.ts`、`practice-result-view.tsx`、`exam-rich-form.tsx`、`use-exam-preview-state.ts`、`use-exam-preview-tasks.ts`、`homework/data-access-write.ts`、`unread-message-badge.tsx`
---
## 1.1.5 性能预算重构专项2026-07-05 新增)
> 完整审计报告见 `docs/architecture/audit/performance-budget-audit-report.md`。
> 本章节记录本次重构对架构产生的关键变更,以便后续维护时定位。
### 影响范围概览
| Phase | 改造内容 | 关键文件 |
|-------|---------|---------|
| 2.1 | tiptap 动态导入(3 处) | `shared/components/ui/rich-text-editor.tsx` + `modules/exams/editor/exam-rich-editor.tsx` + `modules/lesson-preparation/components/blocks/rich-text-block.tsx` |
| 2.2 | ReactFlow 动态导入 | `modules/textbooks/components/knowledge-graph.tsx` 拆为 `knowledge-graph-inner.tsx` + lazy wrapper,ReactFlowProvider 留外层 |
| 2.3 | exams/editor barrel 拆分 | 新增 `modules/exams/editor/types.ts` 作为纯类型入口,供 server-only 消费方引用 |
| 2.4 | chart.tsx barrel + status-badge 移除 use client | chart.tsx 已是具名导出;`status-badge.tsx` 移除 `"use client"`(纯展示组件) |
| 2.5 | 4 个角色根路由 loading.tsx | 新增 `teacher/loading.tsx` + `student/loading.tsx` + `parent/loading.tsx`(admin 已有) |
| 3.1 | unstable_cache 评估 | 评估后决定不引入,与项目现有 React `cache()` 模式一致 |
| 3.2 | auth 函数 cache 包装 | `shared/lib/session.ts:getSession` + `shared/lib/auth-guard.ts:resolveDataScope` + `getAuthContext` + `modules/classes/data-access.ts:getSessionTeacherId` 全部用 React `cache()` 包装,`getSessionTeacherId` 改走 `getAuthContext()` |
| 3.3 | copyCoursePlanToClasses 批量化 | `modules/course-plans/data-access.ts:347` 改为单事务 + 2 次批量 INSERT,O(N*M) → O(1) |
| 3.4 | gradeHomeworkAnswers 批量 UPDATE | `modules/homework/data-access-write.ts:356` 改为单次 `UPDATE ... CASE WHEN` + `sql.join`,替代 N 次串行 UPDATE |
| 3.5 | exams 表索引补齐 | `shared/db/schema.ts` 新增 5 个索引,迁移 `drizzle/0001_questions_fulltext_search.sql` |
| 3.6 | searchQuestions FULLTEXT | `shared/db/schema.ts` 新增 `contentText` STORED 生成列 + `questions_content_text_ft_idx` FULLTEXT 索引;`search/data-access.ts` 改用 `MATCH(...) AGAINST(... IN BOOLEAN MODE)` |
| 3.7 | 公告 fan-out 批量化 | `getAllUserIds` 加分页参数;新增 `notifications/data-access.ts:createNotifications` 批量 INSERT;`dispatcher.sendBatchNotifications` 重构为 in_app 单次批量 + 其他渠道并行 |
| 3.8 | 高 LIMIT 调低 | attendance 5000→100、adaptive-practice 1000→100、questions 1000→100 |
| 4.1 | SidebarProvider memoize | `modules/layout/components/sidebar-provider.tsx` resize debounce 200ms + useMemo context + useCallback toggleSidebar |
| 4.2 | lesson-plan-editor selector 拆分 | `lesson-plan-editor.tsx` + 3 个子组件改为单字段 selector |
| 4.3 | 题库表 + 考试表虚拟化 | 新增 `@tanstack/react-virtual` 依赖;`question-data-table.tsx` + `exam-data-table.tsx` 引入 `useVirtualizer` |
| 4.4 | recharts props 模块级常量 | 14 个 chart 文件提取 `CHART_MARGIN`/`AXIS_TICK`/`TOOLTIP_CURSOR` 等模块级常量 |
| 4.5 | useOptimistic 改造 | `message-detail.tsx` + `message-list.tsx` + `grade-record-list.tsx` + `attendance-sheet.tsx` 改用 `useOptimistic` + `useTransition` |
| 4.6 | 高频子组件 React.memo | `AssignmentCard` + `StatusBadge` + `EmptyState` 添加 React.memo(ClassCard/GraphKpNode 已有) |
| 4.7 | AiAssistantWidget 懒加载 | 新增 `modules/ai/components/ai-assistant-widget-inner.tsx`,外层改为 lazy wrapper |
| 4.8 | scan-image-viewer aspect-ratio | `modules/homework/components/scan-image-viewer.tsx` `<img>` 加 `aspectRatio: "4 / 3"` + width/height 属性 |
| 4.9 | metadata 增强 | `app/layout.tsx` 补 `metadataBase`/`openGraph`/`robots`/`applicationName`/`authors`/`creator` |
### 关键架构决策
1. **动态导入模式标准化**:重 UI 库(tiptap / @xyflow/react / AI SDK)统一采用 `xxx-inner.tsx` + `xxx.tsx` lazy wrapper 模式。ReactFlowProvider 等 context provider 留在外层同步渲染,保证 lazy 子组件的 `useReactFlow()` 等 hook 可用。
2. **请求级去重优先于跨请求缓存**:auth 相关 4 个核心函数(getSession/resolveDataScope/getAuthContext/getSessionTeacherId)全部用 React `cache()` 包装。不引入 `unstable_cache`,因权限数据易变,跨请求缓存风险高于收益。
3. **批量 SQL 优于循环 SQL**:
- INSERT 场景:用 `db.insert().values([...])` 批量
- UPDATE 场景:用 `sql.join` + `CASE WHEN` 单次执行
- 复杂事务:用 `db.transaction` 包裹多个批量操作
4. **React 19 useOptimistic 替代手动 isPending**:消息星标/出勤/成绩录入统一改为 `useOptimistic` + `useTransition`,移除手动 `useState<boolean>` 跟踪 pending。
5. **细粒度 Zustand selector**:单字段 selector 优先于 `useShallow` 多字段 selector,因为函数引用天然稳定。
---
## 1.2 模块依赖关系图
下图展示模块间的实际依赖关系,**标注依赖类型与合规性**。
@@ -842,7 +993,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- Data-access`getQuestions`(✅ 审计升级:支持 `textbookId`/`chapterId` 级联筛选参数,通过 textbooks data-access 获取知识点 ID 集合)/ `createQuestionWithRelations` / `updateQuestionById` / `deleteQuestionByIdRecursive` / `getKnowledgePointOptions` / `getTextbookOptions`(✅ 审计新增)/ `getChapterOptions(textbookId)`(✅ 审计新增)/ `getKnowledgePointOptionsByChapter(chapterId)`(✅ 审计新增)/ `deleteQuestionsBatch`(✅ 审计新增:批量删除,事务原子性)/ `exportQuestions`(✅ 审计新增:导出,权限范围过滤)/ `importQuestions`(✅ 审计新增:批量导入,事务原子性)
- Types`Question` / `QuestionWithRelations` / `GetQuestionsParams`(✅ 审计升级:新增 `textbookId`/`chapterId` 字段)/ `CreateQuestionInput` / `KnowledgePointOption` / `TextbookOption`(✅ 审计新增)/ `ChapterOption`(✅ 审计新增)/ `QuestionContent`(✅ 审计新增:类型守卫接口)/ `QuestionOption`(✅ 审计新增)/ `QUESTION_TYPE_I18N_KEY`(✅ 审计新增:题型 i18n 键映射)/ `DIFFICULTY_I18N_KEY`(✅ 审计新增:难度 i18n 键映射)
- Utils`parseQuestionContent(raw)`(✅ 审计新增:类型守卫,替代 `as` 断言)/ `getQuestionPreview` / `getQuestionOptions` / `getQuestionText` / `trackQuestionEvent`(✅ 审计新增:埋点接口 no-op/ `trackQuestionCreated` / `trackQuestionUpdated` / `trackQuestionDeleted` / `trackQuestionSearched`
- Components`QuestionFilters`(✅ 审计重写:组合 QuestionCascadeFilter/ `QuestionCascadeFilter`(✅ 审计新增三级级联筛选nuqs 管理 URL 状态)/ `QuestionDataTable`(✅ 审计重写i18n + 集成 BatchOperations/ `useQuestionColumns`(✅ 审计改为 Hook因 useTranslations 是 Hook/ `CreateQuestionButton`(✅ 审计重写:权限感知)/ `QuestionActions`(✅ 审计重写:权限感知 + 类型守卫 + QuestionContentRenderer 结构化预览)/ `CreateQuestionDialog`(✅ 审计重写:拆分为 281 行)/ `KnowledgePointSelector`(✅ 审计新增:从 dialog 拆分)/ `OptionsEditor`(✅ 审计新增:从 dialog 拆分)/ `QuestionBankResultsClient`(✅ 审计新增RSC 与客户端 Hook 桥接)/ `QuestionContentRenderer`(✅ 审计新增:题目结构化渲染)/ `BatchOperations`(✅ 审计新增:批量删除工具栏)/ `ImportExportButtons`(✅ 审计新增JSON 导入导出)
- Components`QuestionFilters`(✅ 审计重写:组合 QuestionCascadeFilter/ `QuestionCascadeFilter`(✅ 审计新增三级级联筛选nuqs 管理 URL 状态)/ `QuestionDataTable`(✅ 审计重写i18n + 集成 BatchOperations;✅ Phase 4.3:引入 @tanstack/react-virtual 虚拟滚动替换分页/ `useQuestionColumns`(✅ 审计改为 Hook因 useTranslations 是 Hook/ `CreateQuestionButton`(✅ 审计重写:权限感知)/ `QuestionActions`(✅ 审计重写:权限感知 + 类型守卫 + QuestionContentRenderer 结构化预览)/ `CreateQuestionDialog`(✅ 审计重写:拆分为 281 行)/ `KnowledgePointSelector`(✅ 审计新增:从 dialog 拆分)/ `OptionsEditor`(✅ 审计新增:从 dialog 拆分)/ `QuestionBankResultsClient`(✅ 审计新增RSC 与客户端 Hook 桥接)/ `QuestionContentRenderer`(✅ 审计新增:题目结构化渲染)/ `BatchOperations`(✅ 审计新增:批量删除工具栏)/ `ImportExportButtons`(✅ 审计新增JSON 导入导出)
**依赖关系**
- 依赖:`shared/*`、`@/auth`、`textbooks`(✅ 审计升级:通过 textbooks data-access 的 `getTextbooks` / `getChaptersByTextbookId` / `getKnowledgePointsByChapterId` / `getKnowledgePointsByTextbookId` 实现级联筛选)
@@ -877,7 +1028,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `utils/track-event.ts` | 30+ | 埋点接口no-op 实现) |
| `components/question-cascade-filter.tsx` | 130+ | 三级级联筛选nuqs URL 状态) |
| `components/question-filters.tsx` | 40+ | 筛选栏(组合 QuestionCascadeFilter |
| `components/question-data-table.tsx` | 155+ | 数据表格i18n + 集成 BatchOperations |
| `components/question-data-table.tsx` | 180+ | 数据表格i18n + 集成 BatchOperations + ✅ Phase 4.3@tanstack/react-virtual 虚拟滚动,替换分页避免大数据渲染卡顿 |
| `components/question-columns.tsx` | 100+ | 列定义 HookuseQuestionColumns |
| `components/create-question-button.tsx` | 30+ | 创建按钮(权限感知) |
| `components/question-actions.tsx` | 200+ | 行操作(权限感知 + 类型守卫 + QuestionContentRenderer |
@@ -1142,6 +1293,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `components/knowledge-point-mastery-chart.tsx` | 232 | **P3-3 新增2026-06-26**:知识点掌握度柱状图组件,集成 diagnostic 模块的 getClassMasterySummary 数据。使用 SimpleBarChart + masteryBarColor 函数着色 + isMasteryTooltipPayload 类型守卫 |
| `hooks/use-draft-lock.ts` | 184 | **P3-6 新增2026-06-26**:协同录入锁管理 Hook。职责进入页面自动获取锁、3 分钟心跳续期TTL 5 分钟)、组件卸载/提交成功释放锁、锁冲突状态暴露。使用 ref 保存最新 token 避免闭包捕获旧值,防重复 acquire/release |
> 架构变更2026-07-05Phase 4.5 — grade-record-list.tsx 编辑保存改用 useOptimistic + useTransition
> - **grade-record-list.tsx**:编辑保存 `isSaving` useState → `useOptimistic<GradeRecordListItem[], GradeRecordListItem>` + `useTransition`。`handleEditSave` 构造 `optimisticRecord` 在 `startSaveTransition` 内 `addOptimisticRecord` + `await updateGradeRecordAction` + `router.refresh()`;两处 `records.map` 改为 `optimisticRecords.map`(桌面表格 + 移动端卡片视图)即时显示乐观记录
> - **关键决策**:成功后调用 `router.refresh()` 同步数据源,让 `useOptimistic` 回滚到最新服务端值;不为删除操作使用 useOptimistic因为删除需要服务端确认外键约束、关联记录检查等
---
## 2.7 classes班级模块— 耦合最严重
@@ -1441,6 +1596,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `data-access-correlation.ts` | ~130 | L-9 跨模块关联分析: 委托 classes/users/grades data-access |
| `components/attendance-grade-correlation-card.tsx` | ~220 | L-9 散点图 + 风险表格 (recharts ScatterChart) |
> 架构变更2026-07-05Phase 4.5 — attendance-sheet.tsx 提交状态改用 useOptimistic
> - **attendance-sheet.tsx**`isSubmitting` useState → `useOptimistic<boolean>`。保留 `<form action={handleSubmit}>` 模式 + `useFormStatus` SubmitButtonprogressive enhancement`setOptimisticSubmitting(true)` 在 form action 内调用action 完成后 `optimisticSubmitting` 自动恢复 false移除 `finally` 块
> - **关键决策**:保留 form action 模式而非改用 `useTransition` 包裹,因为 `useFormStatus().pending` 依赖 form action 的 promise 状态,若用 `startTransition` 包裹会导致 form action 立即返回、`useFormStatus` 不可靠;`useOptimistic` 的 `addOptimistic` 必须在 transition 或 action 内调用form action 也算 action
---
## 2.11 users用户模块
@@ -1449,7 +1608,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数**
- Actions`getUserProfileAction` / `updateUserProfileAction` / `importUsersAction` / `exportUsersAction` / `downloadUserTemplateAction` / `updateUserRoleAction` / `deleteUserAction`
- Data-access`getUserProfile` / `getCurrentStudentUser`(✅ P2-20 已修复:从 homework 模块迁移而来6 个 student 页面通过此函数获取学生身份,不再依赖 homework 模块)/ `getAdminUsers`(管理员用户列表分页查询,支持搜索+角色聚合)/ `getAdminUserRoles`(角色名列表,用于筛选下拉框)
- Data-access`getUserProfile` / `getCurrentStudentUser`(✅ P2-20 已修复:从 homework 模块迁移而来6 个 student 页面通过此函数获取学生身份,不再依赖 homework 模块)/ `getAdminUsers`(管理员用户列表分页查询,支持搜索+角色聚合)/ `getAdminUserRoles`(角色名列表,用于筛选下拉框)/ `getAllUserIds(limit=1000, offset=0)`(✅ P3-7全校用户 ID 查询,默认 LIMIT 1000 防止 OOM + 分页参数支持)/ `getUserIdsByGradeId` / `getUserNamesByIds`
- Import-export`generateUserImportTemplate` / `parseUserImportData` / `exportUsersToExcel`+ re-export `batchImportUsers` / `UserImportResult` 保持向后兼容)
- User-service`batchImportUsers`(用户创建 + 密码哈希 + 角色分配)
- Class-registration`registerStudentByInvitationCode`(委托 classes/data-access 完成班级注册)
@@ -1663,6 +1822,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- `message-detail.tsx`:✅ 审计 V1-P1-3 客户端调用 `getMessageThreadAction(messageId)` 加载回复链
- `message-compose.tsx`:✅ 审计 V1-P0-5 `submittedRef` 标志位阻止发送后自动保存 effect 触发新草稿
> 架构变更2026-07-05Phase 4.5 — 消息星标改用 useOptimistic + useTransition
> - **message-detail.tsx**:星标 `useState` → `useOptimistic<boolean>` + `useTransition`。`handleToggleStar` 在 `startStarTransition` 内 `addOptimisticStarred(nextStarred)` + `await toggleMessageStarAction` + `router.refresh()`。JSX 中所有 `isStarred` 引用改为 `optimisticIsStarred`Button variant/aria-pressed/Star className/星标徽章)
> - **message-list.tsx**:星标 `starredOverride` useState → `useOptimistic<Map<string, boolean>>`key=messageId, value=isStarred。`getIsStarred` 改为 `optimisticStarredMap.get(m.id)``handleToggleStar` 重写为 `startStarTransition` + `addOptimisticStar` + `await router.refresh()`。撤回 `recalledOverride` 仍保留 useState不需要乐观更新
> - **关键决策**:成功后调用 `router.refresh()` 同步数据源,让 `useOptimistic` 回滚到最新服务端值不为撤回recall操作使用 useOptimistic因为撤回需要根据服务端返回判断是否成功2 分钟窗口校验),失败时不应乐观显示已撤回
---
## 2.14 notifications通知分发模块
@@ -1671,8 +1835,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数**
- Actions`sendNotificationAction` / `sendClassNotificationAction` / `getNotificationsAction` / `getUnreadNotificationCountAction` / `markNotificationAsReadAction` / `markAllNotificationsAsReadAction` / `archiveNotificationAction`(✅ P1-4 新增:后 4 个通知 CRUD Action 从 messaging 模块迁移;✅ V2-P2-13b 新增archiveNotificationAction 归档 Action✅ V3-P1-3 新增:`getNotificationPreferencesAction` / `updateNotificationPreferencesAction` 从 messaging 模块迁移)
- Dispatcher`sendNotification(payload)` / `sendBatchNotifications(payloads)`
- Data-access`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `archiveNotification` / `unarchiveNotification` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移;✅ V2-P2-13b 新增archiveNotification / unarchiveNotification 归档函数)
- Dispatcher`sendNotification(payload)` / `sendBatchNotifications(payloads)`(✅ P3-7`sendBatchNotifications` 重构为 in_app 单次批量 INSERT其他渠道保持 per-payload 并行)
- Data-access`createNotification` / `createNotifications(items)`(✅ P3-7 新增:批量 INSERT 多行)/ `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `archiveNotification` / `unarchiveNotification` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移;✅ V2-P2-13b 新增archiveNotification / unarchiveNotification 归档函数)
- Preferences`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
- Channels`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender`
- Components`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13bNotificationList 支持优先级 Badge 显示和归档操作;✅ V2-P2-13cNotificationList 支持按类型筛选)
@@ -1705,8 +1869,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**文件清单**
| 文件 | 行数 | 职责 |
|------|------|------|
| `dispatcher.ts` | ~145 | 渠道选择 + 并行分发(✅ V3-P1-4移除 `sendBatchNotifications` 重复日志) |
| `data-access.ts` | ~250 | 站内通知 CRUD + 用户联系方式 + 发送日志持久化notification_logs 表P0-4 / P1-5 修复后新增通知 CRUD✅ V2-P2-13b新增归档函数 + 优先级/归档筛选;✅ V3-P2-8console 日志添加 TODO 统一日志服务标记) |
| `dispatcher.ts` | ~225 | 渠道选择 + 并行分发(✅ V3-P1-4移除 `sendBatchNotifications` 重复日志;✅ P3-7`sendBatchNotifications` 重构为 in_app 批量 INSERT + 其他渠道并行 |
| `data-access.ts` | ~290 | 站内通知 CRUD + 用户联系方式 + 发送日志持久化notification_logs 表P0-4 / P1-5 修复后新增通知 CRUD✅ V2-P2-13b新增归档函数 + 优先级/归档筛选;✅ V3-P2-8console 日志添加 TODO 统一日志服务标记;✅ P3-7新增 `createNotifications` 批量 INSERT 函数 |
| `preferences.ts` | 166 | 通知偏好 CRUDP0-4 / P1-5 修复后从 messaging 迁移) |
| `actions.ts` | ~380 | 9 个 Server Action✅ P1-4新增 4 个通知 CRUD Action✅ V2-P2-13b新增 archiveNotificationAction✅ V3-P1-3新增 getNotificationPreferencesAction / updateNotificationPreferencesAction 从 messaging 迁移) |
| `schema.ts` | 22 | ✅ V3-P1-3 新增:通知偏好校验 Schema从 messaging/schema.ts 迁移) |
@@ -1831,7 +1995,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数**
- Actions`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1 已修复actions 层不再直接访问 DB全部下沉到 data-access✅ 发布公告时触发通知模块 `sendBatchNotifications`/ `toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` / `getAnnouncementReadStatusAction`(✅ V2-P2-13d 新增:置顶切换、已读标记、批量已读状态查询;✅ Audit-P0-3`toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` 新增资源所有权/可见性二次校验;✅ Audit-P1-1所有 Action 返回 i18n 化 message✅ Audit-P1-7`handleActionError` 集成 console.error + trackEvent("announcement.action_error") + 区分 PermissionDeniedError / FORBIDDEN_ANNOUNCEMENT
- Data-access`getAnnouncements`(✅ Audit-P0-2`audience` 字段从单值升级为数组 `{ gradeIds, classIds }`,过滤逻辑改用 `inArray`/ `getAnnouncementById` / `getAnnouncementByIdForUser`(✅ Audit-P0-1 新增:用户端详情页专用,结合 status=published + 受众过滤 + 已读状态 + readCount 一次返回,防止越权)/ `isAnnouncementVisibleToAudience`(✅ Audit-P0-1 新增:纯函数,判断公告对受众是否可见,可独立单测)/ `resolveUserAudience`(✅ Audit-P0-2从原 `resolveAudience` 重命名,遍历所有 childrenIds/classIds/gradeIds 而非仅首个)/ `resolveAnnouncementTargetUserIds`(✅ Audit-P1-5从 actions.ts 下沉到 data-access/ `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `toggleAnnouncementPin`(✅ Audit-P1-6原子化 UPDATE ... SET isPinned = NOT isPinned/ `markAnnouncementAsRead`(✅ Audit-P1-6onDuplicateKeyUpdate 实现幂等 upsert/ `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`(✅ V2-P2-13d 新增:置顶切换 + 已读回执 CRUD/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数)/ `getUserAnnouncementsPageData`(✅ V3-P0-2 新增:用户端列表页编排函数)/ `getAdminAnnouncementDetailPageData`(✅ Audit-P1-2 新增:管理端详情页编排函数,并行 getAnnouncementById + getAnnouncementReadCount + classes.getClassOptions
- Data-access`getAnnouncements`(✅ Audit-P0-2`audience` 字段从单值升级为数组 `{ gradeIds, classIds }`,过滤逻辑改用 `inArray`/ `getAnnouncementById` / `getAnnouncementByIdForUser`(✅ Audit-P0-1 新增:用户端详情页专用,结合 status=published + 受众过滤 + 已读状态 + readCount 一次返回,防止越权)/ `isAnnouncementVisibleToAudience`(✅ Audit-P0-1 新增:纯函数,判断公告对受众是否可见,可独立单测)/ `resolveUserAudience`(✅ Audit-P0-2从原 `resolveAudience` 重命名,遍历所有 childrenIds/classIds/gradeIds 而非仅首个)/ `resolveAnnouncementTargetUserIds`(✅ Audit-P1-5从 actions.ts 下沉到 data-access;✅ P3-7school 分支改为分页循环遍历 `getAllUserIds(PAGE_SIZE, offset)`PAGE_SIZE=1000避免单次查询超大学校全量用户导致 OOM/ `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `toggleAnnouncementPin`(✅ Audit-P1-6原子化 UPDATE ... SET isPinned = NOT isPinned/ `markAnnouncementAsRead`(✅ Audit-P1-6onDuplicateKeyUpdate 实现幂等 upsert/ `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`(✅ V2-P2-13d 新增:置顶切换 + 已读回执 CRUD/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数)/ `getUserAnnouncementsPageData`(✅ V3-P0-2 新增:用户端列表页编排函数)/ `getAdminAnnouncementDetailPageData`(✅ Audit-P1-2 新增:管理端详情页编排函数,并行 getAnnouncementById + getAnnouncementReadCount + classes.getClassOptions
**依赖关系**
- 依赖:`shared/*`、`@/auth`、`school`(获取年级列表)、`classes`(获取班级列表 + 解析受众)、`users`(获取目标用户 ID 列表)、`notifications`(发布公告时发送通知)、`shared/i18n`(✅ Audit-P1-1Actions 通过 `getTranslations('announcements')` 返回 i18n 化 message
@@ -2894,6 +3058,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
> - **V2-2 节点级 AI 协助 4 项**`lib/ai-node-assist.ts` 新增 4 个服务函数(`generateNodeContent`/`optimizeNodeExpression`/`suggestNodeDifferentiation`/`generateLayeredQuestions`全部纯服务端、Zod 校验、失败返回 null`actions-ai.ts` 新增 4 个 Server Action每个 action 校验 `LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT` 三个权限点;`hooks/use-node-ai-assist.ts` 新增客户端 hook`components/paper-editor/paper-editor.tsx` 注入 `onAiAction` 回调;`components/detail-panel/detail-panel.tsx` 的 4 个 `AiButton` 接入真实 actioni18n 新增 `v4.contextMenu.aiRunning`/`aiSuccess`/`aiFailed`/`aiComingSoon` 键zh-CN + en
> - **V4-I18N-1 i18n 完整审查与修复**:对 lesson-preparation 模块 i18n 配置进行全量审查并修复——P0 修复运行时 MISSING_MESSAGE`version.diff.*` 3 键title/summary/noChanges+ `schema.ts` 引用的 8 个 `error.*` 键labelTooLong/versionNoInvalid/nameRequired/nameTooLong/queryTooLong/blockIdRequired/commentTooLong/reviewerRequired+ `v4.contextMenu` 6 键copied/copyFailed/aiComingSoon/aiRunning/aiSuccess/aiFailedzh-CN + en 同步P1 修复 en locale 下大面积报错en `calendar` 节键名对齐 zh-CNweekView/monthView/loadFailed + prev/next/eventMeta/weekDays 子节en `analytics` 节补齐 totalPublished/totalSubmitted/totalStandardsLinked/loadFailedP2 修复 7 处硬编码中文detail-props.tsx 的 stageLabel/differentiationLabel、inline-node.tsx 的 exerciseCount、inline-qa-dialog.tsx 的 expectedAnswer、qa-editor.tsx 的 turnContentPlaceholder、curriculum-map-view.tsx 的 legendTitle/noData并新增对应 6 个 i18n 键(`v4.detail.stageLabel`/`differentiationLabel`/`exerciseCount`/`turnContentPlaceholder`、`v4.heatmap.legendTitle`/`noData`);清理 7 个 dead 节review/comment/formative/substitute/evaluation/standards/gradeHeadanalytics 因被 `app/(dashboard)/admin/curriculum-map` 引用而保留);`lib/i18n-errors.ts` 新增 `t.has(msg)` 运行时守卫,避免 `as` 断言绕过类型检查(键不存在时返回原消息而非抛出 MISSING_MESSAGEzh-CN 与 en 顶层键集一致性验证通过43 = 43005_architecture_data.json 同步追加 `V4-I18N-1` auditFixes 条目known-issues.md 追加 3 个 i18n 规则表(键缺失与中英文不同步 / dead 节清理规则 / 动态键翻译守卫)
> 架构变更2026-07-05Phase 4.2 — Zustand selector 细粒度拆分,避免整体订阅级联 re-render
> - **lesson-plan-editor.tsx**`const editor = useLessonPlanEditor()` 整体订阅拆分为 9 个细粒度 selector`isDirty`/`isOnline`/`doc`/`title`/`isSaving`/`saveError`/`lastSavedAt`/`setTitle` + `canUndo`/`canRedo` 派生 boolean`canUndo`/`canRedo` 改为 `useLessonPlanEditor((s) => s.canUndo())` 订阅派生 boolean移除每次渲染时调用 `editor.canUndo()` 函数;`useEffect` 依赖与 `printablePlan` useMemo 同步更新使用拆分后的变量
> - **text-study-block.tsx**`const { updateNode } = useLessonPlanEditor()` → `useLessonPlanEditor((s) => s.updateNode)`
> - **exercise-block.tsx**:拆为 `useLessonPlanEditor((s) => s.updateNode)` + `useLessonPlanEditor((s) => s.planId)`
> - **paper-context-menu.tsx**:拆为 7 个细粒度 selector`toggleExpand`/`expandedNodeIds`/`updateNode`/`removeNode`/`addAnchor`/`duplicateNode`/`nodes`);特别地将 `doc` 改为 `s.doc.nodes` 更精细订阅(`addAnchor` 等仅修改 anchors 不会触发本组件 re-render
> - **关键决策**:不使用 `useShallow` 包装多字段 selector因为单字段 selector 已经够细,且避免了 `useShallow` 自身的浅比较开销;函数引用(`setTitle`/`updateNode` 等)天然稳定,无需 `useShallow`
> - **验证**tsc 零新增错误lint 零新增错误4 个 modified 文件均未出现在 lint 输出中)
---
## 2.27b standards课标模块2026-07-01 新增)
@@ -3065,7 +3237,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| **Hook** | `useDragPosition` | `modules/ai/hooks/use-drag-position.ts` | 拖拽位置 Hookpointer 事件 + 拖拽状态)— V3 新增 |
| **Context** | `createFullAiClientService` | `modules/ai/context/create-ai-client-service.ts` | 创建完整 AI 客户端服务工厂(含全部 9 个 Action— V3 新增 |
| **Context** | `createCoreAiClientService` | `modules/ai/context/create-ai-client-service.ts` | 创建核心 AI 客户端服务工厂(仅 6 个常用 Action— V3 新增 |
| **Component** | `AiAssistantWidget` | `modules/ai/components/ai-assistant-widget.tsx` | 全局 AI 助手悬浮按钮(上下文感知)— V2 新增 |
| **Component** | `AiAssistantWidget` | `modules/ai/components/ai-assistant-widget.tsx` | 全局 AI 助手悬浮按钮(上下文感知)— V2 新增 / Phase 4.7 改造为懒加载 wrapper`next/dynamic` ssr:false仅渲染 Skeleton 占位 + LazyInner |
| **Component** | `AiAssistantWidgetInner` | `modules/ai/components/ai-assistant-widget-inner.tsx` | 全局 AI 助手悬浮球内部实现(悬浮球 + 抽屉 + AiChatPanel + 上下文推断)— Phase 4.7 从 ai-assistant-widget.tsx 拆出,通过 dynamic 懒加载,使 AI SDK / Markdown 渲染等重型依赖打入独立 chunk |
| **Component** | `AiChatPanel` | `modules/ai/components/ai-chat-panel.tsx` | AI 对话面板(流式 + Markdown + 复制 + 停止 + 建议)— V2 增强 |
| **Component** | `AiMarkdownRenderer` | `modules/ai/components/ai-markdown-renderer.tsx` | Markdown 渲染器GFM + 复制按钮)— V2 新增 |
| **Component** | `AiGradingAssist` | `modules/ai/components/ai-grading-assist.tsx` | AI 批改辅助(主观题预评分 + 反馈建议) |
@@ -3143,7 +3316,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `modules/ai/services/usage-tracker.ts` | ~90 | AI 使用量埋点V3 新增 child_summary/study_path 能力) |
| `modules/ai/services/content-safety.ts` | ~290 | 内容安全过滤(输入/输出/每日限制/原子配额/苏格拉底校验)— V2 新增 / V3 加固 |
| `modules/ai/context/ai-client-provider.tsx` | ~62 | React Context Provider + Hooks |
| `modules/ai/components/ai-assistant-widget.tsx` | ~170 | 全局 AI 助手悬浮按钮 — V2 新增 / V4 i18n 修复 |
| `modules/ai/components/ai-assistant-widget.tsx` | ~25 | 懒加载 wrappernext/dynamic ssr:false + Skeleton 占位)— Phase 4.7 改造 / V2 新增 / V4 i18n 修复 |
| `modules/ai/components/ai-assistant-widget-inner.tsx` | ~330 | 全局 AI 助手悬浮球内部实现(悬浮球 + 抽屉 + AiChatPanel + 上下文推断)— Phase 4.7 新增(从 ai-assistant-widget.tsx 拆出) |
| `modules/ai/components/ai-chat-panel.tsx` | ~305 | AI 对话面板(流式 + Markdown— V2 增强 |
| `modules/ai/components/ai-markdown-renderer.tsx` | ~100 | Markdown 渲染器 — V2 新增 |
| `modules/ai/components/ai-child-summary.tsx` | ~170 | 家长学情摘要 — V2 新增 |
@@ -3544,7 +3718,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**:封装跨 4 张表questions / textbooks / exams / announcements的全文检索数据访问层供 `app/api/search/route.ts` 调用。本次 API 规范化重构从 `app/api/search/route.ts` 下沉而来,消除 `app/` 层直接查询 DB 的架构违规违反「app → modules → shared」三层架构单向依赖
**导出函数**`data-access.ts`server-only
- `searchQuestions(kw, limit?)` — 题库检索:`CAST(content AS CHAR) LIKE` 模糊匹配 JSON 字段,返回 `SearchResultItem[]`href 指向 `/admin/questions?id=`
- `searchQuestions(q, limit?)` — 题库检索:P3-6 改用 `MATCH(questions.contentText) AGAINST(? IN BOOLEAN MODE)` 全文检索(基于 `content_text` STORED 生成列的 FULLTEXT 索引);`toBooleanModeQuery` 工具函数将原始查询转为 `+word1* +word2*` 格式;返回 `SearchResultItem[]`href 指向 `/admin/questions?id=`
- `searchTextbooks(kw, limit?)` — 教材检索title/subject/publisher 三字段 OR 模糊匹配
- `searchExams(kw, limit?)` — 试卷检索title/description 模糊匹配
- `searchAnnouncements(kw, limit?)` — 公告检索:仅返回 `status=published` 的公告title/content 模糊匹配HTML 内容通过 `stripHtml` 提取纯文本摘要
@@ -3567,7 +3741,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- 被依赖:`app/api/search/route.ts`
**数据库表**(只读):
- `questions`content JSON 字段、type、createdAt
- `questions`content JSON 字段、P3-6 新增 `content_text` STORED 生成列 `CAST(content AS CHAR)` 用于 FULLTEXT 索引、type、createdAt
- `textbooks`title、subject、grade、publisher、createdAt
- `exams`title、description、status、createdAt
- `announcements`title、content、type、status、createdAt
@@ -3575,7 +3749,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**文件清单**
| 文件 | 行数 | 职责 |
|------|------|------|
| `data-access.ts` | 212 | 4 个 search 函数 + 3 个内部纯函数extractTextFromJson/stripHtml/truncate+ DEFAULT_SEARCH_PAGE_SIZE 常量 |
| `data-access.ts` | ~230 | 4 个 search 函数P3-6: searchQuestions 改 MATCH AGAINST+ 4 个内部纯函数extractTextFromJson/stripHtml/truncate/toBooleanModeQuery+ DEFAULT_SEARCH_PAGE_SIZE 常量 |
| `types.ts` | 31 | SearchType/SearchResultItem/SearchResponse 类型 + isSearchType 类型守卫 |
---
@@ -4191,6 +4365,20 @@ formatNumber(v: number | null | undefined, digits?: number): string
// shared/lib/search-params.ts (re-export from utils.ts)
getParam(params: SearchParams, key: string): string | undefined // = getSearchParam
// shared/lib/cache/ (服务端缓存基础设施P1 重构新增)
// store-factory.ts —— getCacheStore() 单例工厂:默认 MemoryCacheStoreCACHE_DRIVER=redis 时懒加载 RedisCacheStore
// memory-store.ts / redis-store.ts —— CacheStore 两种实现
// cache-fn.ts —— cacheFn 双层包装器:外层 react.cache 请求级 memoization内层 cacheStore.getOrSet 跨请求缓存(按 tag 失效TTL 可选)
// invalidation-map.ts —— INVALIDATION_MAP 集中式失效映射表 + fillTemplate 模板填充
// invalidate.ts —— invalidateFor 三步编排函数Server Action 写后调用)
getCacheStore(): Promise<CacheStore>
cacheFn<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => Promise<TResult>, options: CacheFnOptions): (...args: TArgs) => Promise<TResult>
fillTemplate(template: string, params: Record<string, string>): string
INVALIDATION_MAP // 集中式失效映射表常量
invalidateFor(actionId: string, params?: Record<string, string>): Promise<void>
// 三步编排1) CacheStore.invalidateTags 2) revalidateTag × N 3) revalidatePath × N
// 未知 actionId 抛错 "[cache] Unknown actionId: <id>. Update INVALIDATION_MAP."
```
### 业务模块核心 Actions

File diff suppressed because one or more lines are too long