chore: snapshot before P0 security phase (backup point)
This commit is contained in:
255
src/modules/adaptive-practice/README.md
Normal file
255
src/modules/adaptive-practice/README.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# adaptive-practice 自适应练习模块
|
||||
|
||||
> 按学生学情自适应出题(错题重做/知识点专项/薄弱章节/AI 推荐),支持答题记录、自动判分、会话状态机与多维度统计分析
|
||||
> 代码规模: 26 文件 · 178 符号 · 52,290 行(arch.db)
|
||||
> 模块路径: `src/modules/adaptive-practice/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Starter["practice-starter<br/>(330 行) 选择练习类型"]
|
||||
Session["practice-session-view<br/>(300 行) 答题界面"]
|
||||
QCard["question-card<br/>(150 行)"]
|
||||
AnsInput["answer-input<br/>(146 行)"]
|
||||
AnsResult["answer-result<br/>(77 行)"]
|
||||
Result["practice-result-view<br/>(110 行) 结果页"]
|
||||
History["practice-history<br/>(113 行)"]
|
||||
KpWeak["class-kp-weakness-chart<br/>(165 行)"]
|
||||
TypeBreak["practice-type-breakdown-chart<br/>(138 行)"]
|
||||
Rank["student-practice-ranking-table<br/>(113 行)"]
|
||||
Compare["class-practice-comparison-table<br/>(89 行)"]
|
||||
Inactive["inactive-students-alert<br/>(65 行)"]
|
||||
end
|
||||
|
||||
subgraph Service["服务抽象层"]
|
||||
SvcCtx["practice-service<br/>(161 行) Context + 接口"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(347 行, 7 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(553 行) 会话/答题 CRUD"]
|
||||
DAStrat["data-access-strategy.ts<br/>(344 行) 4 种出题策略"]
|
||||
DAAna["data-access-analytics.ts<br/>(748 行) 班级/学生统计"]
|
||||
Schema["schema.ts<br/>(130 行, Zod)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Grading["lib/grading.ts<br/>(143 行) autoGradeAnswer"]
|
||||
SourceMeta["lib/source-meta.ts<br/>(115 行) parseSourceMeta"]
|
||||
TypeGuards["lib/type-guards.ts<br/>(106 行) asPracticeXxx"]
|
||||
Errors["lib/errors.ts<br/>(59 行) practiceErrors"]
|
||||
AnsUtils["lib/answer-utils.ts<br/>(14 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Sessions[(practice_sessions)]
|
||||
Answers[(practiceAnswers)]
|
||||
Questions[(questions)]
|
||||
Mastery[(knowledgePointMastery)]
|
||||
end
|
||||
|
||||
Starter --> SvcCtx
|
||||
Session --> SvcCtx
|
||||
Session --> QCard
|
||||
QCard --> AnsInput
|
||||
QCard --> AnsResult
|
||||
Starter --> Result
|
||||
History --> SvcCtx
|
||||
KpWeak --> SvcCtx
|
||||
TypeBreak --> SvcCtx
|
||||
Rank --> SvcCtx
|
||||
Compare --> SvcCtx
|
||||
Inactive --> SvcCtx
|
||||
SvcCtx --> Actions
|
||||
|
||||
Actions --> DA
|
||||
DA --> DAStrat
|
||||
DA --> Grading
|
||||
DA --> TypeGuards
|
||||
DAStrat --> SourceMeta
|
||||
DAStrat --> Mastery
|
||||
DAStrat --> Questions
|
||||
DA --> Sessions
|
||||
DA --> Answers
|
||||
DA --> Questions
|
||||
DAAna --> Sessions
|
||||
DAAna --> Answers
|
||||
Actions --> Schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 创建练习会话(4 种类型分流)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 学生
|
||||
participant Starter as practice-starter
|
||||
participant Svc as practice-service
|
||||
participant Act as actions.ts
|
||||
participant Strat as data-access-strategy
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
S->>Starter: 选择练习类型(4 选 1)
|
||||
Starter->>Svc: createSession(formData)
|
||||
Svc->>Act: createPracticeSessionAction
|
||||
Act->>Act: requirePermission(ADAPTIVE_PRACTICE_WRITE)
|
||||
Act->>Act: resolveTargetStudentId(scope)
|
||||
Act->>Strat: selectQuestionsForPractice(type, sourceMeta)
|
||||
alt error_variant
|
||||
Strat->>DB: 查 sourceQuestionIds 题库存在题
|
||||
Strat->>Strat: 按难度升序(先易后难)
|
||||
else knowledge_point
|
||||
Strat->>DB: 按知识点 + 难度过滤
|
||||
else weak_chapter
|
||||
Strat->>DB: 查 mastery < 60 的薄弱 KP
|
||||
Strat->>DB: 按 KP 筛选题
|
||||
else ai_recommended
|
||||
Strat->>DB: 按 AI 推荐 KP 筛选
|
||||
end
|
||||
Strat-->>Act: {questionIds, variants}
|
||||
Act->>DA: createPracticeSession
|
||||
DA->>DB: INSERT practiceSessions + practiceAnswers
|
||||
DA-->>Act: sessionId
|
||||
Act-->>Svc: ActionState<{sessionId, selectedCount}>
|
||||
```
|
||||
|
||||
### 流程 2: 提交答案(自动判分)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 学生
|
||||
participant View as practice-session-view
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Grading as lib/grading.ts
|
||||
participant DB as MySQL
|
||||
|
||||
S->>View: 选择答案
|
||||
View->>Act: submitPracticeAnswerAction(sessionId, answer)
|
||||
Act->>Act: requirePermission + scope 校验
|
||||
Act->>DA: submitPracticeAnswer
|
||||
DA->>Grading: autoGradeAnswer(questionType, content, answer)
|
||||
alt single_choice / multiple_choice / judgment
|
||||
Grading-->>DA: true/false
|
||||
else text
|
||||
Grading-->>DA: null(留 AI 批改)
|
||||
end
|
||||
DA->>DB: UPDATE practiceAnswers SET isCorrect/score
|
||||
DA->>DB: UPDATE practiceSessions 累计 correctCount
|
||||
DA-->>Act: {isCorrect, score}
|
||||
Act-->>View: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 会话状态机
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[created 创建] --> B[in_progress 答题中]
|
||||
B -->|submitAnswer| B
|
||||
B -->|completeSession| C[completed 完成]
|
||||
B -->|abandonSession| D[abandoned 放弃]
|
||||
C --> E[结果页 practice-result-view]
|
||||
D --> F[历史记录 practice-history]
|
||||
Note over A,D: 类型守卫 asPracticeStatus<br/>保证状态合法转换
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | practiceSessions / practiceAnswers / questions / knowledgePointMastery 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 请求级去重(55 处,与 Drizzle 并列最高) |
|
||||
| Server Action | requirePermission | ADAPTIVE_PRACTICE_READ / WRITE 权限校验 |
|
||||
| 行级权限 | resolveTargetStudentId | 6 种 dataScope(all/owned/class_members/children/class_taught/grade_managed)归属校验 |
|
||||
| 图表 | recharts | 班级薄弱知识点/练习类型分布图表(18 处) |
|
||||
| 表单 | react-hook-form + Zod | schema.ts 4 个 input schema |
|
||||
| 服务抽象 | Context + Interface | practice-service 解耦组件与 actions,便于 mock 测试 |
|
||||
| 纯函数 | autoGradeAnswer | 客观题自动判分,便于单测 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/adaptive-practice/
|
||||
├─ actions.ts # Server Actions (347 行): 创建/答题/完成/放弃/查询/统计
|
||||
├─ data-access.ts # 主数据访问 (553 行): 会话 CRUD + 答题 + 状态机
|
||||
├─ data-access-strategy.ts # 出题策略 (344 行): 4 种类型 selectForXxx
|
||||
├─ data-access-analytics.ts # 统计分析 (748 行): 班级/学生/类型分布
|
||||
├─ schema.ts # Zod 校验 (130 行): 4 个 input schema
|
||||
├─ types.ts # 类型定义 (153 行): 4 种 SourceMeta + Session/Answer
|
||||
├─ services/
|
||||
│ └─ practice-service.tsx # 服务抽象 (161 行): Context + 接口 + 默认实现
|
||||
├─ lib/
|
||||
│ ├─ grading.ts # 自动判分 (143 行): single_choice/multiple_choice/judgment/text
|
||||
│ ├─ source-meta.ts # 来源元数据 (115 行): parseSourceMeta + 4 类型守卫
|
||||
│ ├─ type-guards.ts # 状态/类型守卫 (106 行): asPracticeStatus/Type/AnswerStatus
|
||||
│ ├─ errors.ts # 错误码 (59 行): practiceErrors
|
||||
│ └─ answer-utils.ts # 答案工具 (14 行)
|
||||
└─ components/ # 14 个组件
|
||||
├─ practice-starter.tsx # 选择练习类型 (330 行)
|
||||
├─ practice-session-view.tsx # 答题界面 (300 行)
|
||||
├─ class-knowledge-point-weakness-chart.tsx # 班级薄弱知识点 (165 行)
|
||||
├─ question-card.tsx # 题目卡片 (150 行)
|
||||
├─ answer-input.tsx # 答案输入 (146 行)
|
||||
├─ practice-type-breakdown-chart.tsx # 练习类型分布 (138 行)
|
||||
├─ practice-history.tsx # 练习历史 (113 行)
|
||||
├─ student-practice-ranking-table.tsx # 学生排名 (113 行)
|
||||
├─ practice-result-view.tsx # 结果页 (110 行)
|
||||
├─ class-practice-comparison-table.tsx # 班级对比 (89 行)
|
||||
├─ answer-result.tsx # 答案结果 (77 行)
|
||||
├─ question-content.tsx # 题目内容 (70 行)
|
||||
├─ inactive-students-alert.tsx # 不活跃提醒 (65 行)
|
||||
└─ practice-starter-with-nav.tsx # 带导航外壳 (46 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | getStudentActiveClassId / verifyTeacherOwnsClass / getClassesByGradeId 班级归属与列表 |
|
||||
| users | data-access-call + import | getUserNamesByIds / getUserIdsByGradeId 学生姓名与年级用户 |
|
||||
| shared | import | DB / cache / auth-guard / type-guards / action-utils |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
> arch.db 暂无记录。模块为终端业务模块,直接由 app/ 路由消费。
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **行级权限 resolveTargetStudentId**: 6 种 dataScope 类型分别校验 studentId 归属,防止越权访问他人练习数据
|
||||
- **教师出题前 verifyTeacherOwnsClass**: 教师创建班级维度练习时必须校验班级归属
|
||||
- **出题策略按类型分流**: 4 种 PracticeType 各有独立 selectForXxx 函数,新增类型仅需新增策略函数
|
||||
- **autoGradeAnswer 仅判客观题**: single_choice/multiple_choice/judgment 自动判分,text 题型返回 null 留 AI 批改接口
|
||||
- **WEAK_MASTERY_THRESHOLD = 60**: 薄弱章节策略以此为阈值识别低掌握度知识点
|
||||
- **data-access 按职责拆分 3 文件**: 主 CRUD(553)+ 策略(344)+ 分析(748),避免单文件超 1000 行硬上限
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| strategy 独立文件 | 出题算法可独立演进与单测,不污染主 data-access |
|
||||
| analytics 独立文件 | 统计查询复杂度高(748 行)且与主 CRUD 职责不同,分离降低单文件行数 |
|
||||
| practice-service Context 抽象 | 组件层通过接口消费,默认实现调真实 actions;测试可注入 mock,不同角色可注入不同实现 |
|
||||
| autoGradeAnswer 抽取为纯函数 | 判分逻辑无副作用,便于单测;主观题返回 null 留扩展接口给 AI 批改 |
|
||||
| 4 种 SourceMeta 用 discriminated union | 类型守卫(isXxxSourceMeta)精确分流,parseSourceMeta 统一入口 |
|
||||
| AI 推荐理由用枚举而非翻译文本 | 业务数据存数据库,UI 层 t(`reasons.${reason}`) 查 i18n,避免多语言脏数据 |
|
||||
273
src/modules/ai/README.md
Normal file
273
src/modules/ai/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# ai 人工智能模块
|
||||
|
||||
> AI 能力统一封装:聊天、相似题推荐、批改辅助、教案/题目变体生成、错因分析、学情/学习路径建议,支持 SSE 流式推送与多层内容安全过滤
|
||||
> 代码规模: 31 文件 · 203 符号 · 53,197 行(arch.db)
|
||||
> 模块路径: `src/modules/ai/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Widget["ai-assistant-widget<br/>(27 行, 懒加载外壳)"]
|
||||
Inner["ai-assistant-widget-inner<br/>(332 行, 浮球 + 面板)"]
|
||||
ChatPanel["ai-chat-panel<br/>(219 行)"]
|
||||
ChatMsg["ai-chat-messages<br/>(200 行)"]
|
||||
ChatInput["ai-chat-input<br/>(129 行)"]
|
||||
Renderer["ai-chart-renderer<br/>(367 行)"]
|
||||
MD["ai-markdown-renderer<br/>(163 行)"]
|
||||
Grading["ai-grading-assist<br/>(174 行)"]
|
||||
Lesson["ai-lesson-content-generator<br/>(181 行)"]
|
||||
Variant["ai-question-variant-generator<br/>(219 行)"]
|
||||
ErrBook["ai-error-book-analysis<br/>(247 行)"]
|
||||
Study["ai-study-path<br/>(201 行)"]
|
||||
Child["ai-child-summary<br/>(187 行)"]
|
||||
Usage["ai-usage-dashboard<br/>(221 行)"]
|
||||
Provider["ai-provider-selector<br/>(90 行)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseStream["use-ai-chat-stream<br/>(166 行, SSE 流)"]
|
||||
UseSuggest["use-ai-suggestion<br/>(73 行)"]
|
||||
UseDrag["use-drag-position<br/>(131 行)"]
|
||||
UseFloat["use-floating-ball<br/>(161 行)"]
|
||||
UsePersist["use-position-persistence<br/>(105 行)"]
|
||||
StreamUtils["stream-utils.ts<br/>(136 行)"]
|
||||
end
|
||||
|
||||
subgraph Context["Context"]
|
||||
AICP["ai-client-provider<br/>(63 行)"]
|
||||
CreateSvc["create-ai-client-service<br/>(55 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(416 行, 9 个 Server Actions)"]
|
||||
AISvc["services/ai-service.ts<br/>(483 行, createAiService + safeAiCall)"]
|
||||
Safety["services/content-safety.ts<br/>(292 行, 多层过滤)"]
|
||||
Prompts["services/prompt-templates.ts<br/>(301 行, 8 个系统 prompt)"]
|
||||
Tracker["services/usage-tracker.ts<br/>(101 行, withAiTracking)"]
|
||||
DA["data-access.ts<br/>(146 行, 内存统计)"]
|
||||
Schema["schema.ts<br/>(275 行, Zod)"]
|
||||
end
|
||||
|
||||
subgraph DB["存储"]
|
||||
Memory[(内存 usage 事件)]
|
||||
LocalStorage[(localStorage 历史记录)]
|
||||
end
|
||||
|
||||
Widget --> Inner
|
||||
Inner --> ChatPanel
|
||||
ChatPanel --> ChatMsg
|
||||
ChatPanel --> ChatInput
|
||||
ChatMsg --> MD
|
||||
ChatMsg --> Renderer
|
||||
Inner --> UseStream
|
||||
Inner --> UseFloat
|
||||
Inner --> UseDrag
|
||||
UseDrag --> UsePersist
|
||||
UseStream --> StreamUtils
|
||||
UseStream --> LocalStorage
|
||||
Grading --> Actions
|
||||
Lesson --> Actions
|
||||
Variant --> Actions
|
||||
ErrBook --> Actions
|
||||
Study --> Actions
|
||||
Child --> Actions
|
||||
Usage --> Actions
|
||||
Provider --> AICP
|
||||
AICP --> CreateSvc
|
||||
|
||||
Actions --> AISvc
|
||||
Actions --> Schema
|
||||
AISvc --> Prompts
|
||||
AISvc --> Safety
|
||||
AISvc --> Tracker
|
||||
Tracker --> DA
|
||||
DA --> Memory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: AI 业务调用(safeAiCall 包装 + 内容安全 + 埋点)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Comp as 业务组件(grading/lesson/variant...)
|
||||
participant Act as actions.ts
|
||||
participant Svc as ai-service.ts
|
||||
participant Safe as content-safety.ts
|
||||
participant Track as usage-tracker.ts
|
||||
participant LLM as AI Provider
|
||||
|
||||
U->>Comp: 触发 AI 能力
|
||||
Comp->>Act: xxxAction(input)
|
||||
Act->>Act: requirePermission(AI_XXX)
|
||||
Act->>Act: Zod schema 校验
|
||||
Act->>Svc: createAiService(userId)
|
||||
Act->>Svc: safeAiCall(() => service.xxx())
|
||||
Svc->>Safe: 输入过滤(blocked patterns)
|
||||
alt 输入违规
|
||||
Safe-->>Svc: blocked
|
||||
Svc-->>Act: {ok:false}
|
||||
else 通过
|
||||
Svc->>Track: withAiTracking 包装
|
||||
Track->>LLM: 调用 LLM(system prompt + input)
|
||||
LLM-->>Track: 文本响应
|
||||
Track->>Svc: 提取 JSON + Zod 二次校验
|
||||
Svc->>Safe: 输出过滤(含学生侧 patterns)
|
||||
Track->>Track: recordAiEvent + trackEvent
|
||||
Svc-->>Act: {ok:true, data}
|
||||
end
|
||||
Act-->>Comp: ActionState<T>
|
||||
```
|
||||
|
||||
### 流程 2: SSE 流式聊天(use-ai-chat-stream)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Input as ai-chat-input
|
||||
participant Hook as use-ai-chat-stream
|
||||
participant Utils as stream-utils.ts
|
||||
participant SSE as /api/ai/chat/stream
|
||||
participant LLM as AI Provider
|
||||
|
||||
U->>Input: 输入消息
|
||||
Input->>Hook: send(messages)
|
||||
Hook->>Hook: setStreaming(true)
|
||||
Hook->>SSE: fetch POST (AbortController)
|
||||
SSE->>LLM: 流式调用
|
||||
loop 逐 token
|
||||
LLM-->>SSE: data: {token}
|
||||
SSE-->>Hook: SSE chunk
|
||||
Hook->>Utils: consumeSseStream 解析
|
||||
Utils-->>Hook: token
|
||||
Hook->>Hook: appendTokenToLastAssistant
|
||||
Hook->>Input: 实时渲染
|
||||
end
|
||||
LLM-->>SSE: done
|
||||
Hook->>Hook: persistHistory(localStorage, max 20)
|
||||
Hook->>Input: setStreaming(false)
|
||||
Note over U,LLM: 用户可调用 stop()<br/>AbortController 中止
|
||||
```
|
||||
|
||||
### 流程 3: 浮球组件懒加载与位置持久化
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[页面首屏] --> B[ai-assistant-widget 占位]
|
||||
B --> C{用户交互/视口可见}
|
||||
C -->|是| D["next/dynamic ssr:false<br/>加载 inner"]
|
||||
D --> E[use-floating-ball 控制显隐]
|
||||
E --> F[use-drag-position 处理拖拽]
|
||||
F --> G[use-position-persistence<br/>localStorage 持久化坐标]
|
||||
G --> H[下次加载恢复位置]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| AI 集成 | @/shared/lib/ai | createAiChatCompletion 调用 OpenAI 兼容接口 |
|
||||
| 流式 | SSE (fetch + ReadableStream) | use-ai-chat-stream 逐 token 推送 |
|
||||
| Server Action | requirePermission | AI_CHAT / AI_GRADING / AI_LESSON 等权限校验 |
|
||||
| 表单 | react-hook-form + Zod | 输入校验(schema.ts 9 个 schema) |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(6 处) |
|
||||
| 国际化 | next-intl/server | getTranslations 渲染错误消息 |
|
||||
| 埋点 | @/shared/lib/track-event | withAiTracking 双写 usage + track-event |
|
||||
| 懒加载 | next/dynamic ssr:false | AiAssistantWidget 减小首屏 bundle |
|
||||
| Markdown | 自定义 ai-markdown-renderer | 渲染 AI 回复,代码高亮 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/ai/
|
||||
├─ actions.ts # Server Actions (416 行): 9 个能力(chat/similar/grading/lesson/variant/weakness/child/study/explain)
|
||||
├─ data-access.ts # 内存数据访问 (146 行): getAiUsageStats / recordAiEvent
|
||||
├─ schema.ts # Zod 校验 (275 行): 9 个 input/output schema
|
||||
├─ types.ts # 类型定义 (331 行): AiChatMessage / GradingInput / LessonContentInput...
|
||||
├─ services/
|
||||
│ ├─ ai-service.ts # AI 服务 (483 行): createAiService 工厂 + safeAiCall + JSON 提取
|
||||
│ ├─ content-safety.ts # 内容安全 (292 行): 输入/输出/学生侧 patterns + 每日限额
|
||||
│ ├─ prompt-templates.ts # 系统提示词 (301 行): 8 个业务场景 system prompt
|
||||
│ └─ usage-tracker.ts # 使用埋点 (101 行): withAiTracking + trackAiUsage
|
||||
├─ context/
|
||||
│ ├─ ai-client-provider.tsx # 客户端 Provider (63 行)
|
||||
│ └─ create-ai-client-service.ts # 客户端服务工厂 (55 行)
|
||||
├─ hooks/
|
||||
│ ├─ use-ai-chat-stream.ts # SSE 流式聊天 (166 行)
|
||||
│ ├─ use-ai-suggestion.ts # 一次性建议 (73 行)
|
||||
│ ├─ use-floating-ball.ts # 浮球显隐 (161 行)
|
||||
│ ├─ use-drag-position.ts # 拖拽位置 (131 行)
|
||||
│ ├─ use-position-persistence.ts # 位置持久化 (105 行)
|
||||
│ └─ stream-utils.ts # SSE 解析工具 (136 行)
|
||||
└─ components/ # 15 个客户端组件
|
||||
├─ ai-assistant-widget.tsx # 懒加载外壳 (27 行)
|
||||
├─ ai-assistant-widget-inner.tsx # 浮球 + 面板 (332 行)
|
||||
├─ ai-chart-renderer.tsx # 图表渲染 (367 行)
|
||||
├─ ai-error-book-analysis.tsx # 错题分析 (247 行)
|
||||
├─ ai-usage-dashboard.tsx # 使用统计仪表盘 (221 行)
|
||||
├─ ai-chat-panel.tsx # 聊天面板 (219 行)
|
||||
├─ ai-question-variant-generator.tsx # 题目变体生成 (219 行)
|
||||
├─ ai-study-path.tsx # 学习路径 (201 行)
|
||||
├─ ai-chat-messages.tsx # 聊天消息 (200 行)
|
||||
├─ ai-child-summary.tsx # 孩子学情总结 (187 行)
|
||||
├─ ai-lesson-content-generator.tsx # 教案内容生成 (181 行)
|
||||
├─ ai-grading-assist.tsx # 批改辅助 (174 行)
|
||||
├─ ai-markdown-renderer.tsx # Markdown 渲染 (163 行)
|
||||
├─ ai-chat-input.tsx # 聊天输入 (129 行)
|
||||
└─ ai-provider-selector.tsx # Provider 选择 (90 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| textbooks | data-access-call + import | getKnowledgePointsWithRelations / getStudentKpMastery 提供知识点上下文 |
|
||||
| shared | import | env / auth-guard / track-event / lib/ai / cache / types |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| exams | import | 考试模块调用 AI 批改/相似题推荐 |
|
||||
| homework | import | 作业模块调用 AI 批改辅助/错因分析 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **createAiService 工厂屏蔽 Provider 差异**: 通过 providerId 选择 OpenAI 兼容/Anthropic 等不同客户端,业务层不感知底层
|
||||
- **safeAiCall 统一异常**: AI 调用失败不抛裸异常,返回 `{ok:false, message}` 结构化错误,Server Action 转 ActionState
|
||||
- **AiAssistantWidget 必须 next/dynamic ssr:false 懒加载**: 浮球组件非首屏必需,避免拖慢首屏 bundle
|
||||
- **content-safety 多层过滤**: 输入 patterns + 输出 patterns + 学生侧 patterns(禁止直接给出答案) + 每日限额(防 TOCTOU 竞态)
|
||||
- **data-access.ts 当前为内存实现**: getAiUsageStats / recordAiEvent 仅内存,多实例部署必须替换为 Redis/DB
|
||||
- **AI 返回 JSON 必须经 Zod 二次校验**: ai-service 中 extractBalancedJsonSegment 提取后用 schema 校验,防止 LLM 返回脏数据
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| createAiService 工厂模式 | 屏蔽 Provider 差异(OpenAI 兼容/Anthropic),业务层统一 AiService 接口,新增 Provider 仅改工厂 |
|
||||
| safeAiCall 包装异常 | AI 调用失败率高(网络/限流/超时),统一返回结构化错误避免 try/catch 散落各 Action |
|
||||
| prompt 集中在 prompt-templates.ts | 8 个业务场景 system prompt 统一管理,便于版本控制与 A/B 测试 |
|
||||
| 内存 usage-tracker + track-event 双写 | 内存聚合供管理员仪表盘实时查询,track-event 落库供长期分析;生产替换 Redis 即可 |
|
||||
| AiAssistantWidget 懒加载 ssr:false | 浮球组件依赖浏览器 API(localStorage/drag),SSR 无意义且增大首屏 bundle |
|
||||
| withAiTracking 高阶函数包装 | 透明记录每次调用的 capability/success/duration,业务函数无需关心埋点 |
|
||||
| content-safety 学生侧额外 patterns | K12 场景学生不应直接得到作业答案,需引导思考而非给答案 |
|
||||
229
src/modules/announcements/README.md
Normal file
229
src/modules/announcements/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# announcements 公告模块
|
||||
|
||||
> 管理全校/年级/班级公告的发布、状态流转、受众可见性过滤、已读回执与 fan-out 推送
|
||||
> 代码规模: 15 文件 · 3,056 行(arch.db)
|
||||
> 模块路径: `src/modules/announcements/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
AdminView["admin-announcements-view<br/>(83 行, 管理端)"]
|
||||
Form["announcement-form<br/>(268 行, Zod 校验)"]
|
||||
List["announcement-list<br/>(166 行, 用户端)"]
|
||||
Card["announcement-card<br/>(157 行)"]
|
||||
Detail["announcement-detail<br/>(263 行, 详情)"]
|
||||
Page["announcement-pagination<br/>(131 行)"]
|
||||
Ctx["announcements-service-context<br/>(43 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(421 行, 9 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(603 行, server-only)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数 / Schema"]
|
||||
Schema["schema.ts<br/>(96 行, refineAudience)"]
|
||||
DefaultSvc["default-announcements-service<br/>(31 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Announcements[(announcements)]
|
||||
Reads[(announcement_reads)]
|
||||
end
|
||||
|
||||
AdminView --> Form
|
||||
AdminView --> List
|
||||
List --> Card
|
||||
Card --> Detail
|
||||
List --> Page
|
||||
Form --> Schema
|
||||
Form --> Actions
|
||||
List --> Actions
|
||||
Detail --> Actions
|
||||
Ctx --> DefaultSvc
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Notifications["notifications<br/>sendBatchNotifications"]
|
||||
DA --> Announcements
|
||||
DA --> Reads
|
||||
DA --> Users["users.getAllUserIds"]
|
||||
DA --> School["school"]
|
||||
DA --> Classes["classes"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 公告创建 + 受众校验 + 状态流转
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 管理员
|
||||
participant Form as announcement-form
|
||||
participant Schema as schema.ts
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写标题/内容/类型/受众
|
||||
Form->>Schema: CreateAnnouncementSchema.superRefine
|
||||
Schema->>Schema: refineAudience 校验
|
||||
alt type=school
|
||||
Schema->>Schema: targetGradeId/ClassId 必须空
|
||||
else type=grade
|
||||
Schema->>Schema: targetGradeId 必填
|
||||
else type=class
|
||||
Schema->>Schema: targetClassId 必填
|
||||
end
|
||||
Form->>Act: createAnnouncementAction
|
||||
Act->>Act: requirePermission(ANNOUNCEMENT_MANAGE)
|
||||
Act->>DA: insertAnnouncement
|
||||
DA->>DB: INSERT announcements(status=draft)
|
||||
Act->>Act: invalidateFor
|
||||
Act-->>Form: ActionState<{id}>
|
||||
```
|
||||
|
||||
### 流程 2: 发布 + fan-out 通知(school 维度分页遍历)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 管理员
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Notify as notifications
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Act: publishAnnouncementAction(id)
|
||||
Act->>Act: requirePermission(ANNOUNCEMENT_MANAGE)
|
||||
Act->>DA: publishAnnouncementById(id, now)
|
||||
DA->>DB: UPDATE announcements SET status=published
|
||||
Act->>DA: resolveAnnouncementTargetUserIds
|
||||
alt type=school
|
||||
DA->>DB: 分页遍历 getAllUserIds
|
||||
else type=grade
|
||||
DA->>DB: 查询年级下所有用户
|
||||
else type=class
|
||||
DA->>DB: 查询班级成员
|
||||
end
|
||||
DB-->>DA: targetUserIds[]
|
||||
DA-->>Act: string[]
|
||||
Act->>Notify: sendBatchNotifications(payloads)
|
||||
Note over Act,Notify: 通知失败不阻塞发布<br/>仅 log.error
|
||||
Act->>Act: invalidateFor
|
||||
Act-->>U: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 用户端可见性过滤 + 已读回执(P0-2 多受众修复)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[用户进入公告列表] --> B[getAnnouncementsAction<br/>权限: ANNOUNCEMENT_READ]
|
||||
B --> C[resolveUserAudience<br/>解析 gradeIds[] / classIds[]]
|
||||
C --> D{受众数组校验}
|
||||
D --> E[isAnnouncementVisibleToAudience<br/>纯函数过滤]
|
||||
E --> F{公告类型}
|
||||
F -->|school| G[全员可见]
|
||||
F -->|grade| H{targetGradeId ∈ audience.gradeIds}
|
||||
F -->|class| I{targetClassId ∈ audience.classIds}
|
||||
G --> J[返回可见公告列表]
|
||||
H --> J
|
||||
I --> J
|
||||
|
||||
J --> K[用户点击公告]
|
||||
K --> L[markAnnouncementAsReadAction]
|
||||
L --> M[INSERT announcement_reads]
|
||||
M --> N[返回 isReadByCurrentUser]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | announcements / announcement_reads 表 CRUD |
|
||||
| 缓存 | cacheFn | 列表/详情查询请求级去重 |
|
||||
| 缓存失效 | invalidateFor | 写操作后精确刷新列表与详情两条路径 |
|
||||
| 表单校验 | Zod + superRefine | schema.ts 的 refineAudience 联合校验类型与受众 |
|
||||
| Server Action | requirePermission | ANNOUNCEMENT_MANAGE(写) / ANNOUNCEMENT_READ(读) 两权限点 |
|
||||
| 通知推送 | notifications.sendBatchNotifications | 发布后 fan-out 批量推送 |
|
||||
| i18n | next-intl getTranslations | 错误消息与通知文案本地化 |
|
||||
| 事件追踪 | trackEvent | 操作错误上报监控告警 |
|
||||
| 日志 | createModuleLogger | 模块级结构化日志 |
|
||||
| 服务抽象 | service-context | 客户端服务注入,便于 Mock 测试 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/announcements/
|
||||
├─ actions.ts # Server Actions (421 行): 9 个 Action 编排
|
||||
├─ data-access.ts # 数据访问 (603 行, server-only): CRUD + fan-out + 可见性
|
||||
├─ schema.ts # Zod 校验 (96 行): refineAudience 联合校验
|
||||
├─ types.ts # 类型定义 (125 行): Announcement / UserAudience
|
||||
├─ is-announcement-visible.test.ts # 可见性纯函数单测 (154 行)
|
||||
├─ schema.test.ts # Schema 校验单测 (211 行)
|
||||
├─ components/ # 7 个组件
|
||||
│ ├─ admin-announcements-view.tsx # 管理端视图 (83 行)
|
||||
│ ├─ announcement-form.tsx # 公告表单 (268 行)
|
||||
│ ├─ announcement-list.tsx # 用户端列表 (166 行)
|
||||
│ ├─ announcement-card.tsx # 公告卡片 (157 行)
|
||||
│ ├─ announcement-detail.tsx # 公告详情 (263 行)
|
||||
│ ├─ announcement-pagination.tsx # 分页组件 (131 行)
|
||||
│ ├─ announcements-service-context.tsx # 服务上下文 (43 行)
|
||||
│ ├─ announcement-card.test.tsx # 卡片组件单测 (304 行)
|
||||
│ └─ default-announcements-service.ts # 默认服务实现 (31 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | DB / cache / auth-guard / logger / trackEvent |
|
||||
| notifications | import | 发布时 sendBatchNotifications 批量推送 |
|
||||
| users | data-access-call | resolveAnnouncementTargetUserIds 调用 getAllUserIds |
|
||||
| school | data-access-call | 年级公告解析目标年级用户 |
|
||||
| classes | data-access-call | 班级公告解析目标班级成员 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| search | data-access-call | 全文检索聚合 announcements 表 |
|
||||
| dashboard | data-access-call | 首页公告统计与最近公告展示 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **schema refineAudience 联合校验**: school 不指定受众、grade 必填 targetGradeId、class 必填 targetClassId,避免创建无受众公告
|
||||
- **状态机严格流转**: draft → published → archived,由独立 Action 处理(publishAnnouncementById / archiveAnnouncementById / toggleAnnouncementPin)
|
||||
- **P0-2 多受众修复**: audience 为 `gradeIds[]` / `classIds[]` 数组,解决家长多孩子/教师多班级/年级主任多年级只看到首个受众的截断 Bug
|
||||
- **通知失败不阻塞发布**: notifyAnnouncementPublished 内部 try/catch,失败仅 log.error,不回滚公告状态
|
||||
- **缓存失效双路径**: 写操作后 invalidateFor 必须覆盖列表与详情两条缓存路径
|
||||
- **data-access.ts 首行 server-only**: 禁止前端组件直接调用
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| school fan-out 分页遍历 getAllUserIds | 避免一次性加载全体用户 ID 导致内存峰值 |
|
||||
| announcementReads 独立表 | 与公告本体解耦,支持按用户聚合已读状态,避免公告表膨胀 |
|
||||
| audience 升级为数组(P0-2) | 单值无法表达家长多孩子/教师多班级场景,导致可见性截断 |
|
||||
| 可见性判断抽纯函数 isAnnouncementVisibleToAudience | 便于单测覆盖三类公告的可见性矩阵 |
|
||||
| 通知发送与发布解耦 | 通知基础设施故障不应阻塞业务公告发布 |
|
||||
| service-context 注入服务 | 客户端组件可注入 Mock 服务,便于组件单测 |
|
||||
| refineAudience 用 superRefine | 跨字段联合校验,Zod 原生支持,错误信息精准定位字段 |
|
||||
248
src/modules/attendance/README.md
Normal file
248
src/modules/attendance/README.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# attendance 考勤模块
|
||||
|
||||
> 学生考勤记录(节次考勤)、统计、趋势分析、缺勤预警、考勤-成绩关联分析与家长批量通知
|
||||
> 代码规模: 27 文件 · 168 符号 · 47,392 行(arch.db)
|
||||
> 模块路径: `src/modules/attendance/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Sheet["attendance-sheet<br/>(509 行) 点名表 form action"]
|
||||
RulesForm["attendance-rules-form<br/>(191 行) 规则配置"]
|
||||
RecordList["attendance-record-list<br/>(172 行) 记录列表"]
|
||||
CorrCard["attendance-grade-correlation-card<br/>(397 行) 关联分析"]
|
||||
Report["attendance-report-print<br/>(284 行) 打印报表"]
|
||||
Compare["class-comparison-card<br/>(165 行) 班级对比"]
|
||||
StudentView["student-attendance-view<br/>(153 行) 学生视图"]
|
||||
WarnCard["attendance-warnings-card<br/>(120 行) 预警卡片"]
|
||||
TrendChart["attendance-trend-chart<br/>(110 行) 趋势图"]
|
||||
Layout["attendance-page-layout<br/>(39 行)"]
|
||||
end
|
||||
|
||||
subgraph Service["服务抽象层"]
|
||||
Ctx["attendance-context<br/>(46 行) Provider"]
|
||||
DataSvc["attendance-data-service<br/>(161 行) 接口 + 默认实现"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(281 行, 5 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(525 行) CRUD + scope 过滤"]
|
||||
DAStats["data-access-stats.ts<br/>(498 行) 统计查询"]
|
||||
DACorr["data-access-correlation.ts<br/>(214 行) 关联数据"]
|
||||
Notif["notifications.ts<br/>(155 行) 缺勤通知"]
|
||||
Export["export.ts<br/>(92 行) 报表导出"]
|
||||
Schema["schema.ts<br/>(68 行, Zod)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
CorrCompute["correlation-compute.ts<br/>(211 行) Pearson + 风险等级"]
|
||||
WarnCompute["warning-compute.ts<br/>(153 行) 出勤率/连续缺勤预警"]
|
||||
TrendCompute["trend-compute.ts<br/>(86 行) 趋势计算"]
|
||||
TypeGuards["lib/type-guards.ts<br/>(18 行)"]
|
||||
Constants["constants.ts<br/>(20 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Records[(attendanceRecords)]
|
||||
Rules[(attendanceRules)]
|
||||
end
|
||||
|
||||
Sheet --> Actions
|
||||
RulesForm --> Actions
|
||||
RecordList --> DataSvc
|
||||
CorrCard --> DataSvc
|
||||
Report --> Export
|
||||
Compare --> DataSvc
|
||||
StudentView --> DataSvc
|
||||
WarnCard --> DataSvc
|
||||
TrendChart --> DataSvc
|
||||
DataSvc --> Ctx
|
||||
DataSvc --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Notif
|
||||
Actions --> Schema
|
||||
DA --> Records
|
||||
DA --> Rules
|
||||
DAStats --> Records
|
||||
DACorr --> Records
|
||||
Notif --> Records
|
||||
DAStats --> WarnCompute
|
||||
DAStats --> TrendCompute
|
||||
DACorr --> CorrCompute
|
||||
Export --> DAStats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 教师点名(form action + useOptimistic)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Sheet as attendance-sheet
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Notif as notifications.ts
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Sheet: 批量选择学生状态
|
||||
Sheet->>Sheet: useOptimistic 乐观更新 UI
|
||||
Sheet->>Act: batchRecordAttendanceAction(formData)
|
||||
Note over Sheet,Act: 必须用 form action<br/>useTransition 会破坏 useFormStatus
|
||||
Act->>Act: requirePermission(ATTENDANCE_MANAGE)
|
||||
Act->>Act: assertClassOwnership(classId, scope)
|
||||
Act->>DA: batchCreateAttendanceRecords
|
||||
DA->>DB: INSERT attendanceRecords(批量)
|
||||
Act->>Notif: notifyParentsOfAbsence(classId, records)
|
||||
Notif->>Notif: 过滤 NOTIFIABLE_STATUSES
|
||||
Notif->>Notif: 批量查学生→家长映射(防 N+1)
|
||||
Notif->>DB: sendBatchNotifications 多渠道
|
||||
Act-->>Sheet: ActionState
|
||||
Note over T,DB: 通知失败不阻断考勤保存
|
||||
```
|
||||
|
||||
### 流程 2: 缺勤预警计算
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[getAttendanceStats] --> B[data-access-stats 查询]
|
||||
B --> C[warning-compute.ts]
|
||||
C --> D{出勤率 < 阈值?}
|
||||
D -->|是| E[rateSeverity 划分等级<br/>high/medium/low]
|
||||
D -->|否| F[不预警]
|
||||
C --> G{连续缺勤 ≥ 阈值?}
|
||||
G -->|是| H[absenceSeverity 划分等级]
|
||||
G -->|否| F
|
||||
E --> I[AttendanceWarningSummary]
|
||||
H --> I
|
||||
I --> J[attendance-warnings-card 渲染]
|
||||
```
|
||||
|
||||
### 流程 3: 考勤-成绩关联分析
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Card as correlation-card
|
||||
participant Svc as data-service
|
||||
participant DA as data-access-correlation
|
||||
participant Compute as correlation-compute
|
||||
participant DB as MySQL
|
||||
|
||||
Card->>Svc: 查询关联分析
|
||||
Svc->>DA: getAttendanceGradeCorrelation
|
||||
DA->>DB: 查出勤率 + 成绩
|
||||
DA-->>Compute: 原始数据
|
||||
Compute->>Compute: Pearson 相关系数
|
||||
Compute->>Compute: 风险等级分类<br/>(ATTENDANCE 80/90, SCORE 60/75)
|
||||
Compute->>Compute: 相关系数解释<br/>(strong_negative...strong_positive)
|
||||
Compute-->>Card: 关联摘要 + 学生列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | attendanceRecords / attendanceRules 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(41 处) |
|
||||
| Server Action | requirePermission | ATTENDANCE_MANAGE / ATTENDANCE_READ 权限校验 |
|
||||
| 状态 | useOptimistic | attendance-sheet 点名乐观更新(8 处使用) |
|
||||
| 表单 | react-hook-form + Zod | attendance-rules-form 规则配置 |
|
||||
| 通知 | notifications/sendBatchNotifications | 缺勤批量推送家长,多渠道分发 |
|
||||
| 统计 | 自定义 compute 纯函数 | Pearson 相关系数 / 预警等级 / 趋势 |
|
||||
| 报表 | export.ts | 考勤报表导出与打印 |
|
||||
| scope 过滤 | buildScopeFilter | 6 种 dataScope 行级权限过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/attendance/
|
||||
├─ actions.ts # Server Actions (281 行): record/batch/update/delete/upsertRules
|
||||
├─ data-access.ts # 主数据访问 (525 行): CRUD + buildScopeFilter + buildPeriodFilter
|
||||
├─ data-access-stats.ts # 统计查询 (498 行): 出勤率/连续缺勤/班级对比
|
||||
├─ data-access-correlation.ts # 关联数据 (214 行): 考勤-成绩关联查询
|
||||
├─ schema.ts # Zod 校验 (68 行): Record/Batch/Update/Rule schema
|
||||
├─ types.ts # 类型定义 (269 行): 6 状态/5 节次/Stats/Warning/Correlation
|
||||
├─ constants.ts # 常量 (20 行)
|
||||
├─ notifications.ts # 缺勤通知 (155 行): notifyParentsOfAbsence
|
||||
├─ export.ts # 报表导出 (92 行)
|
||||
├─ correlation-compute.ts # 关联计算 (211 行): Pearson + 风险等级 + 解释
|
||||
├─ warning-compute.ts # 预警计算 (153 行): 出勤率/连续缺勤 severity
|
||||
├─ trend-compute.ts # 趋势计算 (86 行)
|
||||
├─ lib/type-guards.ts # 类型守卫 (18 行)
|
||||
├─ services/
|
||||
│ ├─ attendance-context.tsx # Provider (46 行)
|
||||
│ ├─ attendance-data-service.ts # 数据服务 (161 行): 接口 + 默认实现
|
||||
│ └─ types.ts # 服务类型 (158 行)
|
||||
└─ components/ # 11 个组件
|
||||
├─ attendance-sheet.tsx # 点名表 (509 行, form action + useOptimistic)
|
||||
├─ attendance-grade-correlation-card.tsx # 关联分析 (397 行)
|
||||
├─ attendance-report-print.tsx # 打印报表 (284 行)
|
||||
├─ attendance-rules-form.tsx # 规则配置 (191 行)
|
||||
├─ attendance-record-list.tsx # 记录列表 (172 行)
|
||||
├─ class-comparison-card.tsx # 班级对比 (165 行)
|
||||
├─ student-attendance-view.tsx # 学生视图 (153 行)
|
||||
├─ attendance-warnings-card.tsx # 预警卡片 (120 行)
|
||||
├─ attendance-trend-chart.tsx # 趋势图 (110 行)
|
||||
├─ attendance-page-layout.tsx # 页面布局 (39 行)
|
||||
└─ attendance-stats-class-selector.tsx # 班级选择 (28 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | verifyTeacherOwnsClass / getClassActiveStudentsWithInfo / getClassNameById |
|
||||
| users | data-access-call + import | getUserNamesByIds 获取记录人/学生姓名 |
|
||||
| grades | data-access-call + import | 关联分析查询学生成绩数据 |
|
||||
| school | data-access-call + import | 学年/学期维度过滤 |
|
||||
| parent | data-access-call + import | getParentStudentMapByStudentIds 通知映射 |
|
||||
| notifications | import | sendBatchNotifications 缺勤多渠道分发 |
|
||||
| shared | import | DB / cache / auth-guard / action-utils |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| grades | data-access-call | 成绩模块引用考勤数据做综合分析 |
|
||||
| leave-requests | data-access-call + import | 请假模块联动考勤记录(请假自动标记 excused) |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **attendance-sheet 必须保留 form action 模式**: useFormStatus 依赖 form action promise,useTransition 会破坏该契约;useOptimistic 配合 form action 实现乐观更新
|
||||
- **节次考勤 period 向后兼容**: null 视为 full_day,buildPeriodFilter 处理 `period IS NULL OR period = 'full_day'` 兼容旧数据
|
||||
- **scope 行级权限 buildScopeFilter**: 6 种 dataScope(all/class_taught/grade_managed/class_members/children/owned)分别构建 SQL 过滤
|
||||
- **通知失败不阻断考勤保存**: notifyParentsOfAbsence 仅记录日志,不影响主流程事务
|
||||
- **NOTIFIABLE_STATUSES 仅 4 种状态触发通知**: absent/late/early_leave/excused,present/school_activity 不通知
|
||||
- **计算逻辑拆分为独立 compute 文件**: data-access 只做读写,correlation/warning/trend 计算为纯函数便于单测
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| attendance-sheet 保留 form action + useOptimistic | useFormStatus 依赖 form action promise,useTransition 会破坏该契约;乐观更新提升点名体验 |
|
||||
| 计算逻辑拆分为 correlation/warning/trend compute | data-access 保持纯读写,计算无 IO 副作用可独立单测 |
|
||||
| 缺勤通知内建 notifications.ts 但调 notifications 模块 | 考勤事件与通知解耦(同模块维护业务规则,跨模块分发) |
|
||||
| 批量通知 sendBatchNotifications + 一次性查家长映射 | 避免 N+1 查询,一次 SQL 拿全部学生→家长映射 |
|
||||
| assertRecordOwnership / assertClassOwnership 双重校验 | 写操作前校验教师对班级/记录的归属,防止越权修改他人班级考勤 |
|
||||
| 风险等级用阈值常量而非魔法数字 | RISK_ATTENDANCE_HIGH/MEDIUM_THRESHOLD(80/90)、SCORE(60/75)集中定义,便于调优 |
|
||||
| 节次考勤向后兼容 null | L-6 引入 period 字段,旧数据 null 自动视为 full_day,无需数据迁移 |
|
||||
229
src/modules/audit/README.md
Normal file
229
src/modules/audit/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# audit 审计模块
|
||||
|
||||
> 提供审计日志、登录日志、数据变更日志的查询、Excel 导出与保留策略管理
|
||||
> 代码规模: 24 文件 · 3,451 行(arch.db)
|
||||
> 模块路径: `src/modules/audit/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
LogView["audit-log-view<br/>(145 行)"]
|
||||
LogTable["audit-log-table<br/>(120 行)"]
|
||||
LoginView["login-log-view<br/>(131 行)"]
|
||||
ChangeView["data-change-log-view<br/>(163 行)"]
|
||||
Retention["audit-retention-settings<br/>(218 行)"]
|
||||
Export["audit-log-export-button<br/>(87 行)"]
|
||||
Overview["audit-overview-view<br/>(114 行)"]
|
||||
Charts["趋势/分布图表"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UsePage["use-log-pagination<br/>(28 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(229 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(481 行, server-only)"]
|
||||
ExportLib["export.ts<br/>(145 行, Excel 导出)"]
|
||||
RetentionLib["retention.ts<br/>(187 行, 保留策略)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
AuditLogs[(audit_logs)]
|
||||
LoginLogs[(login_logs)]
|
||||
ChangeLogs[(data_change_logs)]
|
||||
SysSettings[(system_settings)]
|
||||
end
|
||||
|
||||
LogView --> LogTable
|
||||
LogTable --> UsePage
|
||||
LoginView --> UsePage
|
||||
ChangeView --> UsePage
|
||||
Export --> Actions
|
||||
Retention --> Actions
|
||||
Overview --> Charts
|
||||
UsePage --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> ExportLib
|
||||
Actions --> RetentionLib
|
||||
DA --> AuditLogs
|
||||
DA --> LoginLogs
|
||||
DA --> ChangeLogs
|
||||
RetentionLib --> SysSettings
|
||||
RetentionLib --> AuditLogs
|
||||
RetentionLib --> LoginLogs
|
||||
RetentionLib --> ChangeLogs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 日志查询 + 分页(nuqs URL 状态同步)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 管理员
|
||||
participant View as audit-log-view
|
||||
participant Hook as use-log-pagination
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>View: 打开审计日志页
|
||||
View->>Hook: 读取 nuqs 分页参数
|
||||
Hook->>Act: getDataChangeLogsAction(params)
|
||||
Act->>Act: requirePermission(AUDIT_LOG_READ)
|
||||
Act->>DA: Promise.all[getLogs, getTableOptions, getStats]
|
||||
DA->>DB: SELECT FROM audit_logs LIMIT/OFFSET
|
||||
DA->>DB: SELECT COUNT(*)
|
||||
DB-->>DA: rows + total
|
||||
DA-->>Act: PaginatedResult
|
||||
Act-->>Hook: ActionState
|
||||
Hook-->>View: 渲染表格
|
||||
View->>U: 翻页(URL 同步)
|
||||
```
|
||||
|
||||
### 流程 2: Excel 导出(含审计追踪)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 管理员
|
||||
participant Btn as audit-log-export-button
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Export as export.ts
|
||||
participant Track as trackEvent
|
||||
|
||||
U->>Btn: 点击导出
|
||||
Btn->>Act: exportAuditLogsAction(params)
|
||||
Act->>Act: requirePermission(AUDIT_LOG_READ)
|
||||
Act->>DA: getAuditLogsForExport(params)
|
||||
DA-->>Act: AuditLog[]
|
||||
Act->>Export: buildAuditLogExport(items)
|
||||
Export-->>Act: {buffer, filename}
|
||||
Act->>Track: trackEvent(audit.exported)
|
||||
Act-->>Btn: ActionState<{buffer, filename}>
|
||||
Btn->>U: 浏览器下载 Excel
|
||||
```
|
||||
|
||||
### 流程 3: 保留策略 + 清理(双权限点)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[管理员进入保留设置] --> B{操作类型}
|
||||
B -->|查看| C[getAuditRetentionConfigAction<br/>权限: AUDIT_LOG_READ]
|
||||
B -->|保存配置| D[saveAuditRetentionConfigAction<br/>权限: AUDIT_RETENTION_MANAGE]
|
||||
B -->|手动清理| E[purgeAuditLogsAction<br/>权限: AUDIT_LOG_PURGE]
|
||||
|
||||
D --> D1[retention.ts<br/>upsertSystemSetting]
|
||||
D --> D2[invalidateFor 缓存]
|
||||
|
||||
E --> E1[读取配置或入参]
|
||||
E1 --> E2[calculateRetentionThreshold<br/>计算截止日期]
|
||||
E2 --> E3[DELETE FROM audit_logs<br/>WHERE createdAt < threshold]
|
||||
E3 --> E4[DELETE FROM login_logs]
|
||||
E4 --> E5[trackEvent + invalidateFor]
|
||||
|
||||
C --> C1[getAuditRetentionConfig<br/>从 system_settings 读取]
|
||||
C1 --> C2[未配置返回默认值<br/>审计 180 天/登录 365 天]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | audit_logs / login_logs / data_change_logs / system_settings 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 查询函数请求级去重 |
|
||||
| URL 状态 | nuqs | 分页参数与筛选条件 URL 同步 |
|
||||
| Server Action | requirePermission | AUDIT_LOG_READ / AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE 三权限点 |
|
||||
| Excel 导出 | shared/lib/excel | export.ts 列定义 + buffer 生成 |
|
||||
| 日志 | createModuleLogger | 模块级结构化日志 |
|
||||
| 事件追踪 | trackEvent | 导出/保留/清理操作审计追踪 |
|
||||
| 缓存失效 | invalidateFor | 配置保存/清理后精确刷新 |
|
||||
| 纯函数 | calculateRetentionThreshold | 保留阈值日期计算,便于单测 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/audit/
|
||||
├─ actions.ts # Server Actions (229 行): 查询/导出/保留/清理编排
|
||||
├─ data-access.ts # 数据访问 (481 行, server-only): 三类日志分页查询
|
||||
├─ export.ts # Excel 导出 (145 行, server-only): 列定义 + 行映射
|
||||
├─ retention.ts # 保留策略 (187 行, server-only): 配置/阈值/清理
|
||||
├─ types.ts # 类型定义 (186 行): 含保留天数常量
|
||||
├─ export.test.ts # Excel 导出单测 (210 行)
|
||||
├─ retention.test.ts # 保留策略单测 (93 行)
|
||||
├─ components/ # 13 个组件
|
||||
│ ├─ audit-log-view.tsx # 审计日志视图 (145 行)
|
||||
│ ├─ audit-log-table.tsx # 审计日志表格 (120 行)
|
||||
│ ├─ audit-log-detail-dialog.tsx # 详情对话框 (144 行)
|
||||
│ ├─ audit-log-export-button.tsx # 导出按钮 (87 行)
|
||||
│ ├─ login-log-view.tsx # 登录日志视图 (131 行)
|
||||
│ ├─ login-log-table.tsx # 登录日志表格 (114 行)
|
||||
│ ├─ data-change-log-view.tsx # 数据变更视图 (163 行)
|
||||
│ ├─ data-change-log-table.tsx # 数据变更表格 (151 行)
|
||||
│ ├─ audit-overview-view.tsx # 概览视图 (114 行, 服务端组件)
|
||||
│ ├─ audit-overview-stats-bar.tsx # 概览统计条 (59 行)
|
||||
│ ├─ audit-activity-trend-chart.tsx # 活动趋势图 (68 行)
|
||||
│ ├─ data-change-distribution-chart.tsx # 变更分布图 (74 行)
|
||||
│ └─ audit-retention-settings.tsx # 保留设置 (218 行)
|
||||
├─ hooks/
|
||||
│ └─ use-log-pagination.ts # 分页 Hook (28 行)
|
||||
└─ services/ # 服务抽象层
|
||||
├─ audit-service.tsx # 审计服务 (140 行)
|
||||
├─ admin-audit-service.ts # 管理员服务 (88 行)
|
||||
└─ mock-audit-service.ts # Mock 服务 (76 行, 开发用)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | data-access + import | DB / cache / auth-guard / excel / logger / session |
|
||||
| settings | data-access-call | retention.ts 调用 getSystemSetting / upsertSystemSetting 读写配置 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| 全部业务模块 | 间接(经 shared/lib/audit-logger) | 通过 `logAudit` / `logDataChange` 写入审计日志,避免直接依赖 audit 模块造成循环 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **三个权限点严格区分**: `AUDIT_LOG_READ`(读)、`AUDIT_RETENTION_MANAGE`(写配置)、`AUDIT_LOG_PURGE`(物理删除),读权限不授予任何写能力(G4-003/G4-004 修复)
|
||||
- **保留天数常量定义在 types.ts**: 避免客户端组件经 retention.ts 间接拉入 mysql2 / server-only,MIN=30 / MAX=3650 防误设 0 全量删除
|
||||
- **分页硬上限**: `MAX_PAGE_SIZE = 100` 经 `clampPageSize` 强制限制,防大数据量拖垮 DB
|
||||
- **data-access.ts / retention.ts / export.ts 首行 `import "server-only"`**: 防止客户端组件误导入服务端代码
|
||||
- **审计写入下沉 shared/lib**: 业务模块通过 `logAudit` / `logDataChange` 间接写入,避免直接依赖 audit 模块导致循环依赖
|
||||
- **三类日志保留期不同**: 审计日志默认 180 天,登录日志默认 365 天(支持安全取证)
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 审计写入下沉到 shared/lib/audit-logger | 业务模块不直接依赖 audit 模块,避免循环依赖 |
|
||||
| retention 独立文件(非 data-access 内) | 保留策略与查询职责分离,便于定时任务调用与单测 |
|
||||
| 保留天数常量放 types.ts 而非 retention.ts | 客户端组件需读取默认值展示,放 retention.ts 会经 server-only 阻断 |
|
||||
| purge 使用独立 AUDIT_LOG_PURGE 权限 | 物理删除是破坏性操作,读权限不应授予删除能力 |
|
||||
| saveRetentionConfig 使用 AUDIT_RETENTION_MANAGE | 写配置属于管理操作,与读权限分离,符合最小权限原则 |
|
||||
| 导出操作记录 trackEvent | 导出敏感审计数据需留痕,形成"审计的审计" |
|
||||
| services/ 保留 service 抽象层 | 兼容 mock/开发环境切换,admin 与普通管理员能力隔离 |
|
||||
224
src/modules/auth/README.md
Normal file
224
src/modules/auth/README.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# auth 认证模块
|
||||
|
||||
> 注册、登录、2FA 预检/校验、密码策略与泄露检测、邀请码消费,基于 NextAuth 但登录前不使用 requirePermission
|
||||
> 代码规模: 9 文件 · 40 符号 · 7,817 行(arch.db)
|
||||
> 模块路径: `src/modules/auth/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Layout["auth-layout.tsx<br/>(51 行)"]
|
||||
Login["login-form.tsx<br/>(263 行)"]
|
||||
Register["register-form.tsx<br/>(285 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(378 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(128 行, createUser/isEmailAvailable)"]
|
||||
end
|
||||
|
||||
subgraph Services["服务层(services/)"]
|
||||
LoginSvc["login-service.ts<br/>(214 行, 7 类职责)"]
|
||||
TwoFA["two-factor-service.ts<br/>(121 行, TOTP/备份码)"]
|
||||
end
|
||||
|
||||
subgraph NextAuth["NextAuth 集成"]
|
||||
AuthCfg["auth.ts(authorize 回调)<br/>薄包装调用 authenticateUser"]
|
||||
end
|
||||
|
||||
subgraph Shared["shared/lib"]
|
||||
RateLimit["rate-limit"]
|
||||
PwdPolicy["password-policy"]
|
||||
BreachedPwd["breached-password"]
|
||||
LoginLogger["login-logger / track-event"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Users[(users)]
|
||||
Roles[(roles / users_to_roles)]
|
||||
PwdSec[(password_security)]
|
||||
end
|
||||
|
||||
Layout --> Login
|
||||
Layout --> Register
|
||||
Login --> Actions
|
||||
Register --> Actions
|
||||
Actions --> DA
|
||||
Actions --> Shared
|
||||
AuthCfg --> LoginSvc
|
||||
LoginSvc --> TwoFA
|
||||
LoginSvc --> Shared
|
||||
LoginSvc --> Users
|
||||
LoginSvc --> Roles
|
||||
LoginSvc --> PwdSec
|
||||
DA --> Users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 邀请码注册
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 注册用户
|
||||
participant Form as register-form
|
||||
participant Act as actions.ts
|
||||
participant InvDA as invitation-codes DA
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写注册信息 + 邀请码
|
||||
Form->>Act: registerAction
|
||||
Act->>Act: rateLimit(register:{ip})
|
||||
alt 超限
|
||||
Act-->>Form: RATE_LIMIT_EXCEEDED
|
||||
end
|
||||
Act->>Act: RegisterSchema.safeParse(含未成年人监护人校验)
|
||||
Act->>InvDA: validateInvitationCode(code, email)
|
||||
alt 无效/已用/过期
|
||||
Act-->>Form: INVITATION_CODE_INVALID
|
||||
end
|
||||
Act->>DA: isEmailAvailable(email)
|
||||
alt 邮箱已存在
|
||||
Act-->>Form: EMAIL_TAKEN
|
||||
end
|
||||
Act->>Shared: checkBreachedPassword
|
||||
alt 已泄露
|
||||
Act-->>Form: BREACHED_PASSWORD
|
||||
end
|
||||
Act->>InvDA: consumeInvitationCode(code, userId) 事务
|
||||
Act->>DA: createUser(bcrypt cost 12)
|
||||
DA->>DB: INSERT users + users_to_roles(默认 student)
|
||||
Act->>Shared: trackAuthEvent(signup)
|
||||
Act-->>Form: ActionState<RegisterResult>
|
||||
```
|
||||
|
||||
### 流程 2: 登录 + 2FA 预检
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant NextAuth as auth.ts
|
||||
participant LS as login-service
|
||||
participant TwoFA as two-factor-service
|
||||
participant DB as MySQL
|
||||
|
||||
U->>NextAuth: authorize(credentials)
|
||||
NextAuth->>LS: authenticateUser(input)
|
||||
LS->>LS: rateLimit(login:{ip} + login:{email})
|
||||
LS->>DB: SELECT users WHERE email
|
||||
LS->>LS: isAccountLocked(password_security)
|
||||
LS->>LS: bcrypt.compare + recordFailedLogin
|
||||
LS->>TwoFA: preflightTwoFactorByEmail(email)
|
||||
alt 启用 2FA
|
||||
TwoFA-->>LS: 需要 totpCode
|
||||
LS-->>NextAuth: 返回 2FA required 信号
|
||||
NextAuth-->>U: 二次输入 TOTP/备份码
|
||||
U->>NextAuth: totpCode
|
||||
NextAuth->>TwoFA: verifyTwoFactorForLogin
|
||||
end
|
||||
LS->>LS: resetFailedLogin + resetRateLimit
|
||||
LS->>Shared: logLoginEvent(success)
|
||||
LS-->>NextAuth: AuthenticatedUser
|
||||
NextAuth-->>U: session 建立
|
||||
```
|
||||
|
||||
### 流程 3: 注册错误归一化
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[原始错误] --> B{classifyRegisterError}
|
||||
B -->|DEFAULT_ROLE_NOT_FOUND| C[DEFAULT_ROLE_MISSING]
|
||||
B -->|duplicate/unique/er_dup_entry| D[EMAIL_TAKEN]
|
||||
B -->|其他| E[UNKNOWN]
|
||||
C --> F[messageForError i18n]
|
||||
D --> F
|
||||
E --> F
|
||||
F --> G[返回 ActionState]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 认证框架 | NextAuth.js | session/JWT 管理,authorize 回调薄包装 |
|
||||
| 密码哈希 | bcryptjs(cost 12) | 注册时哈希,登录时 compare |
|
||||
| 速率限制 | rateLimit + rateLimitKey | IP + email 双维度(register/login 独立规则) |
|
||||
| 密码策略 | password-policy | PASSWORD_RULES + isAccountLocked |
|
||||
| 泄露检测 | breached-password | checkBreachedPassword 防止常用弱密码 |
|
||||
| 2FA | TOTP + 备份码 | two-factor-service 处理二次验证 |
|
||||
| 校验 | Zod | RegisterSchema(含未成年人监护人条件) |
|
||||
| 国际化 | next-intl/server | 错误码 → i18n key 翻译 |
|
||||
| 审计 | trackAuthEvent + logLoginEvent | 8 类事件(signup/signin_failure/rate_limited/...) |
|
||||
| 服务端隔离 | import "server-only" | data-access 与 services 防止客户端引用 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/auth/
|
||||
├─ actions.ts # Server Actions (378 行): 注册/邮箱可用性
|
||||
├─ data-access.ts # 数据访问层 (128 行): createUser/isEmailAvailable
|
||||
├─ schema.ts # Zod 验证 (131 行, 含未成年人校验)
|
||||
├─ types.ts # 类型定义 (65 行): RegisterResult/REGISTER_ERROR_CODES
|
||||
├─ services/
|
||||
│ ├─ login-service.ts # 登录服务 (214 行, 7 类职责拆分)
|
||||
│ └─ two-factor-service.ts # 2FA 服务 (121 行, TOTP/备份码)
|
||||
├─ components/
|
||||
│ ├─ auth-layout.tsx # 认证布局 (51 行)
|
||||
│ ├─ login-form.tsx # 登录表单 (263 行)
|
||||
│ └─ register-form.tsx # 注册表单 (285 行)
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| invitation-codes | data-access-call(运行时) | validateInvitationCode + consumeInvitationCode |
|
||||
| classes | data-access-call | 注册时班级加入(预留) |
|
||||
| settings | import | 设置页相关 |
|
||||
| shared | import | DB / rate-limit / password-policy / breached-password / NextAuth |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| (无) | — | auth 是认证入口,仅 NextAuth 中间件与页面层调用 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **不使用 requirePermission**: 登录前无会话,注册/登录走 rateLimit + Zod 校验,2FA 走服务层逻辑
|
||||
- **bcrypt cost 固定 12**: 禁止降低,密码哈希强度不可妥协
|
||||
- **邮箱可用性只返回布尔**: `isEmailAvailable` 仅返回 `{ available: boolean }`,避免邮箱枚举攻击
|
||||
- **错误归一化 classifyRegisterError**: 不向客户端暴露 SQL 文案/表名/字段名,统一映射为 RegisterErrorCode
|
||||
- **速率限制双维度独立**: register:{ip} 与 login:{ip} + login:{email} 独立规则,避免互相影响
|
||||
- **services 层 import "server-only"**: login-service / two-factor-service 防止客户端引用,且通过动态 import 避免 edge runtime 冲突
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| service 层拆分(login-service/two-factor-service) | authorize 回调原 7 类职责混合,拆分后 actions.ts 仅编排,可独立单测 |
|
||||
| 邀请码消费下沉到 invitation-codes data-access | 跨模块事务消费,避免 auth 直接操作 invitation_codes 表 |
|
||||
| REGISTER_ERROR_CODES 定义在 types.ts | 客户端组件可直接导入,避免 "use server" 文件导出非 async 值(Next.js 16 规则) |
|
||||
| 错误归一化屏蔽 SQL 文案 | 防止 duplicate/unique/er_dup_entry 等原始信息泄露邮箱是否存在 |
|
||||
| 2FA 预检 preflightTwoFactorByEmail | 登录第一步决定是否需要 2FA,避免无谓的密码校验后再跳转 |
|
||||
289
src/modules/classes/README.md
Normal file
289
src/modules/classes/README.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# classes 班级模块
|
||||
|
||||
> 班级全生命周期管理:创建/学生名单/学科教师分配/邀请码注册/课表只读/班级统计(基础模块,25+ 模块依赖)
|
||||
> 代码规模: 42 文件 · 301 符号 · 110,970 行(arch.db)
|
||||
> 模块路径: `src/modules/classes/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件(多视角)"]
|
||||
direction TB
|
||||
MyGrid["my-classes-grid<br/>(419 行) 教师视角"]
|
||||
AdminView["admin-classes-view<br/>(159 行) 管理员视角"]
|
||||
GradeView["grade-classes-view<br/>(153 行) 年级组长视角"]
|
||||
Detail["class-detail/<br/>8 个 widget"]
|
||||
InvMgr["class-invitation-manager<br/>(327 行) 邀请码"]
|
||||
Schedule["schedule-view<br/>+ create/edit/delete-dialog"]
|
||||
end
|
||||
|
||||
subgraph DetailWidgets["班级详情 widgets"]
|
||||
Header["class-header"]
|
||||
Stats["class-overview-stats"]
|
||||
Students["class-students-widget"]
|
||||
SchedW["class-schedule-widget"]
|
||||
AssignW["class-assignments-widget"]
|
||||
Trends["class-trends-widget<br/>(417 行) recharts"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseData["use-class-data<br/>(115 行) TanStack Query"]
|
||||
UseFilters["use-class-filters<br/>(32 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端 Actions(按角色拆 6 文件)"]
|
||||
Actions["actions.ts<br/>(53 行) barrel"]
|
||||
ActTeacher["actions-teacher<br/>(162 行)"]
|
||||
ActAdmin["actions-admin<br/>(145 行)"]
|
||||
ActGrade["actions-grade<br/>(185 行)"]
|
||||
ActInv["actions-invitations<br/>(527 行) 邀请码"]
|
||||
ActSched["actions-schedule<br/>(168 行)"]
|
||||
ActShared["actions-shared<br/>(73 行) scope 判断"]
|
||||
end
|
||||
|
||||
subgraph DA["Data Access(按职责拆 7 文件)"]
|
||||
DA1["data-access.ts<br/>(643 行) barrel + 核心"]
|
||||
DATeacher["data-access-teacher<br/>(672 行)"]
|
||||
DAStats["data-access-stats<br/>(529 行) 统计+作业聚合"]
|
||||
DAInv["data-access-invitations<br/>(408 行)"]
|
||||
DAStudents["data-access-students<br/>(388 行)"]
|
||||
DAAdmin["data-access-admin<br/>(383 行)"]
|
||||
DASched["data-access-schedule<br/>(122 行) 只读"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Mappers["lib/admin-class-mappers<br/>(144 行)"]
|
||||
SchedUtils["schedule-utils<br/>(101 行)"]
|
||||
FormUtils["class-form-utils<br/>(36 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Classes[(classes)]
|
||||
Enroll[(classEnrollments)]
|
||||
SubjT[(classSubjectTeachers)]
|
||||
Sched[(classSchedule)]
|
||||
InvCode[(classInvitationCodes)]
|
||||
end
|
||||
|
||||
subgraph Cross["跨模块"]
|
||||
Homework["homework data-access<br/>作业聚合视图"]
|
||||
School["school data-access<br/>年级/学校"]
|
||||
Scheduling["scheduling 模块<br/>课表写入口"]
|
||||
end
|
||||
|
||||
MyGrid --> UseData
|
||||
AdminView --> UseFilters
|
||||
Detail --> DetailWidgets
|
||||
Trends --> recharts["recharts"]
|
||||
InvMgr --> ActInv
|
||||
Schedule --> ActSched
|
||||
|
||||
UseData --> ActTeacher
|
||||
ActTeacher --> DATeacher
|
||||
ActAdmin --> DAAdmin
|
||||
ActGrade --> DA1
|
||||
ActInv --> DAInv
|
||||
ActSched --> DASched
|
||||
|
||||
DA1 --> Classes
|
||||
DA1 --> Enroll
|
||||
DA1 --> SubjT
|
||||
DAInv --> InvCode
|
||||
DASched --> Sched
|
||||
DAStats --> Homework
|
||||
DA1 --> School
|
||||
DASched -.只读.-> Scheduling
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 教师创建班级 + 初始化
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant Form as class-form-dialog
|
||||
participant Act as actions-teacher
|
||||
participant DA as data-access-teacher
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写班级信息(名称/年级/学科教师)
|
||||
Form->>Act: createTeacherClassAction
|
||||
Act->>Act: requirePermission(CLASS_CREATE)
|
||||
Act->>Act: getTeacherIdForMutations(身份校验)
|
||||
Act->>DA: createTeacherClass
|
||||
DA->>DB: INSERT classes
|
||||
DA->>DB: INSERT classEnrollments(初始化空名单)
|
||||
DA->>DB: INSERT classSubjectTeachers(学科教师)
|
||||
DA->>DB: ensureClassInvitationCode(生成邀请码)
|
||||
DA-->>Act: classId
|
||||
Act->>Act: invalidateFor(classes)
|
||||
Act-->>Form: ActionState<{classId}>
|
||||
```
|
||||
|
||||
### 流程 2: 学生邀请码注册加入班级
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 学生
|
||||
participant Mgr as class-invitation-manager
|
||||
participant Act as actions-invitations
|
||||
participant Shared as actions-shared
|
||||
participant DA as data-access-invitations
|
||||
participant DB as MySQL
|
||||
|
||||
S->>Mgr: 输入邀请码
|
||||
Mgr->>Act: enrollStudentByInvitationCodeAction
|
||||
Act->>Act: requirePermission(CLASS_ENROLL)
|
||||
Act->>Shared: hasStudentScope(身份判断)
|
||||
Act->>DA: enrollStudentByInvitationCode
|
||||
DA->>DB: 校验邀请码有效性
|
||||
DA->>DB: INSERT classEnrollments(写入选课关系)
|
||||
DA-->>Act: 成功
|
||||
Act->>Act: invalidateFor(classes.students)
|
||||
Act-->>Mgr: ActionState
|
||||
|
||||
Note over S,DB: 教师也可通过 email 主动注册学生<br/>enrollStudentByEmail<br/>需先 assertClassOwnership 校验归属
|
||||
```
|
||||
|
||||
### 流程 3: 班级详情页多 widget 数据聚合
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[进入班级详情] --> B[use-class-data<br/>TanStack Query]
|
||||
B --> C[actions-teacher<br/>getClassDetail]
|
||||
C --> D[data-access-teacher<br/>672 行聚合查询]
|
||||
D --> E1[class-header<br/>基本信息]
|
||||
D --> E2[class-overview-stats<br/>学生数/作业数]
|
||||
D --> E3[class-students-widget<br/>学生名单]
|
||||
D --> E4[class-schedule-widget<br/>课表只读]
|
||||
D --> E5[class-assignments-widget<br/>作业列表]
|
||||
D --> E6[class-trends-widget<br/>recharts 趋势图]
|
||||
E5 --> F[data-access-stats<br/>529 行作业聚合视图]
|
||||
F --> G[homework data-access<br/>不直查 homework 表]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | classes / classEnrollments / classSubjectTeachers / classSchedule 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(146 处,高频基础模块) |
|
||||
| Server Action | requirePermission | CLASS_CREATE / CLASS_ENROLL / CLASS_MANAGE 等权限点 |
|
||||
| 客户端状态 | TanStack Query | use-class-data 班级数据缓存与重取 |
|
||||
| 数据可视化 | recharts | class-trends-widget 班级趋势图表(20 处使用) |
|
||||
| 表单验证 | Zod | schema.ts(158 行) 邀请码/班级表单校验 |
|
||||
| 国际化 | next-intl | getTranslations 错误消息 i18n |
|
||||
| 权限范围 | actions-shared | hasAdminScope/hasTeacherScope/hasStudentScope 角色判断 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/classes/
|
||||
├─ actions.ts # barrel 入口 (53 行)
|
||||
├─ actions-teacher.ts # 教师视角 Action (162 行)
|
||||
├─ actions-admin.ts # 管理员视角 Action (145 行)
|
||||
├─ actions-grade.ts # 年级组长视角 Action (185 行)
|
||||
├─ actions-invitations.ts # 邀请码 Action (527 行): 注册/生成/重置
|
||||
├─ actions-schedule.ts # 课表 Action (168 行)
|
||||
├─ actions-shared.ts # 共享 scope 判断 (73 行)
|
||||
├─ data-access.ts # barrel + 核心 CRUD (643 行)
|
||||
├─ data-access-teacher.ts # 教师视角数据 (672 行)
|
||||
├─ data-access-stats.ts # 统计 + 作业聚合视图 (529 行)
|
||||
├─ data-access-invitations.ts # 邀请码数据 (408 行)
|
||||
├─ data-access-students.ts # 学生名单 (388 行)
|
||||
├─ data-access-admin.ts # 管理员数据 (383 行)
|
||||
├─ data-access-schedule.ts # 课表只读 (122 行)
|
||||
├─ schema.ts # Zod 验证 (158 行)
|
||||
├─ types.ts # 类型定义 (196 行): TeacherClass/AdminClassListItem
|
||||
├─ lib/
|
||||
│ └─ admin-class-mappers.ts # 管理员数据映射 (144 行)
|
||||
├─ hooks/
|
||||
│ ├─ use-class-data.ts # TanStack Query 数据 Hook (115 行)
|
||||
│ └─ use-class-filters.ts # 筛选 Hook (32 行)
|
||||
└─ components/ # 23 个组件
|
||||
├─ class-detail/ # 班级详情 8 个 widget
|
||||
│ ├─ class-trends-widget.tsx # 趋势图 (417 行)
|
||||
│ ├─ class-schedule-widget.tsx # 课表 (126 行)
|
||||
│ ├─ class-header.tsx # 头部 (122 行)
|
||||
│ ├─ class-assignments-widget.tsx # 作业 (118 行)
|
||||
│ ├─ class-students-widget.tsx # 学生 (109 行)
|
||||
│ ├─ edit-class-dialog.tsx # 编辑 (146 行)
|
||||
│ └─ class-overview-stats / class-quick-actions
|
||||
├─ my-classes-grid.tsx # 教师班级网格 (419 行)
|
||||
├─ class-invitation-manager.tsx # 邀请码管理 (327 行)
|
||||
├─ class-form-dialog.tsx # 班级表单 (233 行)
|
||||
├─ schedule-edit-dialog.tsx # 课表编辑 (195 行)
|
||||
├─ schedule-view.tsx # 课表视图 (176 行)
|
||||
├─ students-table.tsx # 学生表格 (174 行)
|
||||
├─ schedule-create-dialog.tsx # 课表创建 (165 行)
|
||||
├─ admin-classes-view.tsx # 管理员视图 (159 行)
|
||||
├─ grade-classes-view.tsx # 年级组长视图 (153 行)
|
||||
└─ schedule-utils.ts / class-form-utils.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| school | data-access-call + import | 年级/学校信息过滤 |
|
||||
| homework | data-access-call + import | data-access-stats 作业聚合视图,不直查 homework 表 |
|
||||
| shared | import | DB / cache / auth-guard / 工具函数 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| textbooks | data-access-call + import | 按班级过滤教材 |
|
||||
| exams | data-access-call + import | 按班级过滤试卷/班级年级映射 |
|
||||
| grades | data-access-call + import | 成绩按班级录入/范围校验 |
|
||||
| lesson-preparation | data-access-call + import | 备课发布作业选班级 |
|
||||
| homework | data-access-call + import | 作业按班级下发 |
|
||||
| attendance | data-access-call + import | 考勤按班级 |
|
||||
| diagnostic | data-access-call + import | 诊断按班级 |
|
||||
| error-book | data-access-call + import | 错题按班级 |
|
||||
| parent | data-access-call + import | 家长查看孩子班级 |
|
||||
| dashboard | data-access-call + import | 首页班级统计 |
|
||||
| auth | data-access-call | 登录后加载班级 |
|
||||
| rbac | data-access-call | 权限按班级范围 |
|
||||
| scheduling | data-access-call | 课表写入口引用班级 |
|
||||
| onboarding / elective / messaging / notifications / leave-requests / announcements / course-plans / adaptive-practice / settings / student / users / school | data-access-call + import | 各业务模块按班级过滤 |
|
||||
|
||||
> 注: 本模块为基础设施模块,被 25+ 模块依赖,是系统中被依赖最多的模块。
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **基础模块稳定性**: 25+ 模块依赖本模块 data-access,接口变更影响面大,需保持向后兼容
|
||||
- **classSchedule 写入口收敛**: 课表写入口统一在 scheduling 模块,本模块 data-access-schedule 仅保留只读
|
||||
- **作业数据走 homework data-access**: data-access-stats 的作业聚合视图通过 homework 模块接口获取,不直查 homework 表
|
||||
- **data-access 按职责拆 7 文件**: 原 single 文件 974 行违反硬上限,拆分后 teacher(672)/stats(529)/invitations(408)/students(388)/admin(383)/schedule(122)+barrel
|
||||
- **actions 按角色拆 6 文件**: admin/teacher/grade/invitations/schedule/shared,权限边界清晰
|
||||
- **班级归属校验**: 教师操作前 `assertClassOwnership` 校验是否为该班班主任,admin scope 跳过
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 按角色拆分 actions(admin/teacher/grade) | 不同角色权限点与数据范围不同,拆分使权限边界清晰 |
|
||||
| data-access 按职责拆 7 文件 | 原单文件 974 行违反 1000 行硬上限,按职责拆分符合规范 |
|
||||
| classSchedule 写入口收敛到 scheduling | 避免双写入口导致归属校验不一致,本模块只读 |
|
||||
| 作业聚合视图类型放 classes 模块 | data-access-stats 产出 + classes 组件消费,是 classes 对 homework 的视图,不让 homework 承担 classes 视角类型 |
|
||||
| 邀请码注册机制 | 学生自助加入班级,减轻教师手动录入负担,527 行 Action 独立成文件 |
|
||||
| 多视角组件分离 | admin/grade/my-classes 三视角组件独立,避免条件分支臃肿 |
|
||||
249
src/modules/course-plans/README.md
Normal file
249
src/modules/course-plans/README.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# course-plans 课程计划模块
|
||||
|
||||
> 课程计划与周次计划项的创建、编辑、拖拽排序、批量复制到多班级、进度跟踪与日历视图
|
||||
> 代码规模: 15 文件 · 107 符号 · 3,548 行(arch.db)
|
||||
> 模块路径: `src/modules/course-plans/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
List["course-plan-list<br/>列表 (151 行)"]
|
||||
Detail["course-plan-detail<br/>详情 (393 行)"]
|
||||
Form["course-plan-form<br/>表单 (326 行)"]
|
||||
ItemEd["course-plan-item-editor<br/>周项编辑 (252 行)"]
|
||||
SortRow["sortable-week-row<br/>拖拽行 (177 行)"]
|
||||
TplDlg["template-picker-dialog<br/>模板选择 (221 行)"]
|
||||
Cal["course-plan-calendar<br/>日历 (220 行)"]
|
||||
Prog["course-plan-progress<br/>进度 (43 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(475 行, Server Actions + resolveScope)"]
|
||||
DA["data-access.ts<br/>(631 行, CRUD + 批量复制)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
CalU["lib/calendar-utils.ts<br/>(170 行, 日历事件映射)"]
|
||||
ExpU["lib/export-utils.ts<br/>(91 行, 导出)"]
|
||||
Track["lib/track-event.ts<br/>(22 行, 埋点)"]
|
||||
Schema["schema.ts<br/>(214 行, Zod)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Plans[(course_plans)]
|
||||
Items[(course_plan_items)]
|
||||
end
|
||||
|
||||
List --> Actions
|
||||
Detail --> Actions
|
||||
Form --> Actions
|
||||
ItemEd --> Actions
|
||||
SortRow --> Actions
|
||||
TplDlg --> Actions
|
||||
Cal --> CalU
|
||||
Prog --> Actions
|
||||
|
||||
Actions --> DA
|
||||
CalU --> Items
|
||||
DA --> Plans
|
||||
DA --> Items
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 创建课程计划 + 周次计划项
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Form as course-plan-form
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Form: 选择班级/学科/学期/周课时
|
||||
Form->>Act: createCoursePlanAction (FormData)
|
||||
Act->>Act: requirePermission(COURSE_PLAN_MANAGE)
|
||||
Act->>Act: CreateCoursePlanSchema.safeParse
|
||||
Act->>DA: createCoursePlan(input, userId)
|
||||
DA->>DB: INSERT course_plans (status=planning)
|
||||
DA-->>Act: plan.id
|
||||
Act->>Act: invalidateFor("course-plan.create")
|
||||
Act-->>Form: ActionState<string>
|
||||
Form-->>T: 跳转详情页编辑周项
|
||||
|
||||
T->>Form: 添加周次项(周次/主题/课时/章节)
|
||||
Form->>Act: createCoursePlanItemAction
|
||||
Act->>DA: createCoursePlanItem
|
||||
DA->>DB: INSERT course_plan_items
|
||||
Act-->>Form: ActionState
|
||||
```
|
||||
|
||||
### 流程 2: 拖拽排序周次项
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Sort as sortable-week-row
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Sort: 拖拽调整周次顺序
|
||||
Sort->>Act: reorderCoursePlanItemsAction (items[])
|
||||
Act->>Act: requirePermission(COURSE_PLAN_MANAGE)
|
||||
Act->>Act: ReorderItemsSchema.safeParse
|
||||
Act->>DA: reorderCoursePlanItems(planId, items[])
|
||||
DA->>DB: 批量 UPDATE course_plan_items SET week
|
||||
DA-->>Act: 完成
|
||||
Act->>Act: invalidateFor("course-plan.reorder")
|
||||
Act-->>Sort: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 批量复制计划到多班级(单事务)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[教师选择源计划] --> B[template-picker-dialog<br/>选择目标班级列表]
|
||||
B --> C[copyCoursePlanToClassesAction]
|
||||
C --> D[requirePermission<br/>COURSE_PLAN_MANAGE]
|
||||
D --> E[CopyPlanSchema.safeParse]
|
||||
E --> F[data-access.ts<br/>copyCoursePlanToClasses]
|
||||
F --> G[单事务 BEGIN]
|
||||
G --> H[批量 INSERT course_plans<br/>N 条目标计划]
|
||||
H --> I[查询源计划所有 items]
|
||||
I --> J[批量 INSERT course_plan_items<br/>N * M 条]
|
||||
J --> K[事务 COMMIT]
|
||||
K --> L[invalidateFor]
|
||||
L --> M[返回复制数量]
|
||||
|
||||
Note over F,K: 优化:单事务 + 2 次批量 INSERT<br/>复杂度 O(N*M) → O(1)<br/>避免循环单条插入
|
||||
```
|
||||
|
||||
### 流程 4: 日历视图渲染
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[进入日历页] --> B[course-plan-calendar]
|
||||
B --> C[actions.getCoursePlanById]
|
||||
C --> D[CoursePlanWithItems]
|
||||
D --> E[calendar-utils.ts<br/>planToCalendarEvents]
|
||||
E --> F{plan.startDate 存在?}
|
||||
F -- 是 --> G[第 N 周 → startDate + N-1*7]
|
||||
G --> H[生成 CalendarEvent[]]
|
||||
F -- 否 --> I[返回 null 无法映射]
|
||||
H --> J[日历组件渲染事件]
|
||||
```
|
||||
|
||||
### 流程 5: 年级进度仪表盘
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as 管理员
|
||||
participant Prog as course-plan-progress
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant SchoolDA as school.data-access
|
||||
participant DB as MySQL
|
||||
|
||||
Admin->>Prog: 查看年级进度
|
||||
Prog->>Act: getGradeCoursePlanProgressAction(gradeId)
|
||||
Act->>Act: requirePermission + resolveScope(isAdmin)
|
||||
Act->>SchoolDA: 按年级查班级列表
|
||||
SchoolDA-->>Act: classes[]
|
||||
Act->>DA: getGradeCoursePlanProgress(gradeId)
|
||||
DA->>DB: 聚合 course_plans GROUP BY classId, subjectId
|
||||
Note over DA,DB: 统计 totalHours / completedHours<br/>计算完成率
|
||||
DA-->>Act: GradeCoursePlanProgressItem[]
|
||||
Act-->>Prog: ActionState
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | course_plans / course_plan_items 表 CRUD + 批量操作 |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重 |
|
||||
| 表单 | Zod schema | CreateCoursePlan / UpdateItem / CopyPlan / Reorder 验证 |
|
||||
| 拖拽 | HTML5 Drag API | sortable-week-row 周次拖拽排序 |
|
||||
| 日历 | 自研 calendar-utils | 周计划项映射到日期范围(不依赖 date-fns) |
|
||||
| Server Action | requirePermission | COURSE_PLAN_MANAGE 权限校验 |
|
||||
| 缓存失效 | invalidateFor | 按 course-plan.{create/update/reorder/copy} 精确失效 |
|
||||
| 权限范围 | resolveScope | admin/teacher/parent/student 多角色查询范围解析 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/course-plans/
|
||||
├─ actions.ts # Server Actions (475 行): CRUD + 排序 + 复制 + 进度 + resolveScope
|
||||
├─ data-access.ts # 数据访问层 (631 行): CRUD + 单事务批量复制
|
||||
├─ schema.ts # Zod 验证 (214 行): 9 个 schema
|
||||
├─ types.ts # 类型定义 (162 行): Plan/Item/QueryScope/Progress
|
||||
├─ lib/ # 纯函数库
|
||||
│ ├─ calendar-utils.ts # 日历事件映射 (170 行): planToCalendarEvents
|
||||
│ ├─ export-utils.ts # 导出工具 (91 行)
|
||||
│ └─ track-event.ts # 埋点 (22 行)
|
||||
└─ components/ # 8 个客户端组件
|
||||
├─ course-plan-detail.tsx # 详情页 (393 行)
|
||||
├─ course-plan-form.tsx # 表单 (326 行)
|
||||
├─ course-plan-item-editor.tsx # 周项编辑 (252 行)
|
||||
├─ template-picker-dialog.tsx # 模板选择 (221 行)
|
||||
├─ course-plan-calendar.tsx # 日历视图 (220 行)
|
||||
├─ sortable-week-row.tsx # 拖拽行 (177 行)
|
||||
├─ course-plan-list.tsx # 列表 (151 行)
|
||||
└─ course-plan-progress.tsx # 进度条 (43 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call | 按班级查计划 / 班级元数据 |
|
||||
| school | data-access-call | 年级进度按年级查班级列表 |
|
||||
| users | data-access-call | 教师/学生姓名 join |
|
||||
| shared | import | DB / cache / auth-guard / action-utils |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| school | import | 学校模块引用课程计划进度 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **批量复制单事务**: `copyCoursePlanToClasses` 必须在单事务内执行 2 次批量 INSERT,避免循环单条插入,复杂度从 O(N*M) 降至 O(1)
|
||||
- **resolveScope 多角色范围解析**: admin 全局视图,teacher 按 teacherId+classIds 过滤,student/parent 按 classIds 过滤
|
||||
- **日历映射依赖 startDate**: 若 plan.startDate 缺失,周次项无法映射到日期,返回 null
|
||||
- **lib/ 拆分纯函数**: 日历/导出/埋点拆分到 lib/,data-access 保持纯数据访问职责单一
|
||||
- **仅依赖 shared + 3 模块 data-access**: 模块高度内聚,无横向耦合,不直接访问其他模块表
|
||||
- **所有 Server Action 调用 requirePermission**: COURSE_PLAN_MANAGE 权限校验
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| copyCoursePlanToClasses 单事务批量 INSERT | 避免循环单条插入,性能从 O(N*M) 降至 O(1),保证原子性 |
|
||||
| resolveScope 在 actions 层解析 | data-access 不感知 AuthContext,接收已解析的 QueryScope,便于复用与测试 |
|
||||
| 日历映射用自研 calendar-utils | 不依赖 date-fns,纯函数易测试,精确满足按周映射需求 |
|
||||
| lib/ 拆分日历/导出/埋点 | data-access 保持纯数据访问,非数据逻辑分离便于单测 |
|
||||
| schema 集中 9 个 Zod | Create/Update/Copy/Reorder/BulkToggle 等表单验证统一管理 |
|
||||
| 模板选择 template-picker-dialog | 支持从已有计划复制结构,减少重复录入 |
|
||||
267
src/modules/dashboard/README.md
Normal file
267
src/modules/dashboard/README.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# dashboard 仪表盘模块
|
||||
|
||||
> 仪表盘聚合层,按角色(admin/teacher/student/parent)输出汇总数据,通过 Promise.all 并行调用各模块 stats 函数,仅聚合不直查跨模块表
|
||||
> 代码规模: 34 文件 · 3,628 行(arch.db)
|
||||
> 模块路径: `src/modules/dashboard/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["按角色客户端组件"]
|
||||
direction TB
|
||||
Admin["admin-dashboard<br/>(220 行) + admin-sections<br/>(225 行)"]
|
||||
Teacher["teacher-dashboard-view<br/>(169 行) + 6 子卡片"]
|
||||
Student["student-dashboard-view<br/>(157 行) + 4 子卡片"]
|
||||
Parent["parent-dashboard<br/>(111 行)"]
|
||||
Shared["dashboard-section<br/>responsive-layout / shell"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UsePref["use-dashboard-preferences<br/>(91 行)"]
|
||||
UseRT["use-dashboard-realtime<br/>(83 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(167 行, 4 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(58 行, server-only)"]
|
||||
Streams["streams.ts<br/>(35 行, server-only)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数 / 配置"]
|
||||
Utils["lib/dashboard-utils.ts<br/>(280 行, 派生指标)"]
|
||||
WidgetCfg["config/widget-configs.ts<br/>(68 行)"]
|
||||
Svc["services/dashboard-service.tsx<br/>(119 行)"]
|
||||
end
|
||||
|
||||
Admin --> Actions
|
||||
Teacher --> Actions
|
||||
Student --> Actions
|
||||
Parent --> Actions
|
||||
Admin --> UsePref
|
||||
Admin --> UseRT
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Utils
|
||||
Actions --> Streams
|
||||
DA --> Users["users.getUsersDashboardStats"]
|
||||
DA --> Classes["classes.getClassesDashboardStats"]
|
||||
DA --> Textbooks["textbooks.getTextbooksDashboardStats"]
|
||||
DA --> Questions["questions.getQuestionsDashboardStats"]
|
||||
DA --> Exams["exams.getExamsDashboardStats"]
|
||||
DA --> Homework["homework.getHomeworkDashboardStats"]
|
||||
Actions --> Parent["parent.getParentDashboardData"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 管理员仪表盘 Promise.all 6 模块并行聚合
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as 管理员
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Users as users
|
||||
participant Classes as classes
|
||||
participant Textbooks as textbooks
|
||||
participant Questions as questions
|
||||
participant Exams as exams
|
||||
participant Homework as homework
|
||||
|
||||
A->>Act: getAdminDashboardAction()
|
||||
Act->>Act: requirePermission(DASHBOARD_ADMIN_READ)
|
||||
Act->>DA: getAdminDashboardData(ctx.dataScope)
|
||||
par Promise.all 6 路并行
|
||||
DA->>Users: getUsersDashboardStats
|
||||
DA->>Classes: getClassesDashboardStats
|
||||
DA->>Textbooks: getTextbooksDashboardStats
|
||||
DA->>Questions: getQuestionsDashboardStats
|
||||
DA->>Exams: getExamsDashboardStats(scope)
|
||||
DA->>Homework: getHomeworkDashboardStats(scope)
|
||||
end
|
||||
Users-->>DA: userCount/roleCounts/recentUsers
|
||||
Classes-->>DA: classCount
|
||||
Textbooks-->>DA: textbookCount/chapterCount
|
||||
Questions-->>DA: questionCount
|
||||
Exams-->>DA: examCount
|
||||
Homework-->>DA: assignment/submission counts
|
||||
DA->>DA: 聚合为 AdminDashboardData
|
||||
DA-->>Act: cacheFn(ttl=60s)
|
||||
Act-->>A: ActionState<AdminDashboardData>
|
||||
```
|
||||
|
||||
### 流程 2: 教师仪表盘 6 路并行 + 派生指标计算
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Act as actions.ts
|
||||
participant Utils as dashboard-utils
|
||||
participant Classes as classes
|
||||
participant HW as homework
|
||||
participant Users as users
|
||||
|
||||
T->>Act: getTeacherDashboardAction()
|
||||
Act->>Act: requirePermission(DASHBOARD_TEACHER_READ)
|
||||
Act->>Classes: getTeacherIdForMutations
|
||||
par Promise.all 6 路并行
|
||||
Act->>Classes: getTeacherClasses
|
||||
Act->>Classes: getClassSchedule
|
||||
Act->>HW: getHomeworkAssignments
|
||||
Act->>HW: getHomeworkSubmissions
|
||||
Act->>Users: getUserBasicInfo
|
||||
Act->>HW: getTeacherGradeTrends
|
||||
end
|
||||
Act->>Utils: computeTeacherMetrics(classes, schedule,<br/>assignments, submissions, gradeTrends, now)
|
||||
Utils->>Utils: 计算今日课表/待批改/趋势等
|
||||
Utils-->>Act: TeacherDashboardMetrics
|
||||
Act-->>T: ActionState<TeacherDashboardData & metrics>
|
||||
```
|
||||
|
||||
### 流程 3: 学生仪表盘 4 路并行 + 纯函数派生
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[学生进入仪表盘] --> B[getStudentDashboardAction<br/>权限: DASHBOARD_STUDENT_READ]
|
||||
B --> C[getCurrentStudentUser<br/>获取学生身份]
|
||||
C --> D{学生存在}
|
||||
D -->|否| E[返回 null]
|
||||
D -->|是| F[Promise.all 4 路并行]
|
||||
F --> F1[getStudentClasses]
|
||||
F --> F2[getStudentSchedule]
|
||||
F --> F3[getStudentHomeworkAssignments]
|
||||
F --> F4[getStudentDashboardGrades]
|
||||
|
||||
F1 --> G[dashboard-utils 纯函数派生]
|
||||
F2 --> G
|
||||
F3 --> G
|
||||
F4 --> G
|
||||
G --> G1[countStudentAssignments<br/>统计待交/逾期/已批]
|
||||
G --> G2[filterTodaySchedule<br/>今日课表过滤]
|
||||
G --> G3[sortUpcomingAssignments<br/>即将到期作业排序]
|
||||
G1 --> H[StudentDashboardProps]
|
||||
G2 --> H
|
||||
G3 --> H
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 并行聚合 | Promise.all(核心模式) | admin 6 路/teacher 6 路/student 4 路并行,降低总延迟 |
|
||||
| Server Action | requirePermission | DASHBOARD_ADMIN/TEACHER/STUDENT/PARENT_READ 四权限点 |
|
||||
| 缓存 | cacheFn(ttl=60s) | getAdminDashboardData 短缓存,平衡时效与 DB 压力 |
|
||||
| 纯函数 | dashboard-utils.ts(280 行) | computeTeacherMetrics / countStudentAssignments / filterTodaySchedule 等派生计算 |
|
||||
| 跨模块聚合 | data-access 调用 | 调用 8 模块 stats 函数,不直查跨模块表 |
|
||||
| 实时 | streams.ts + use-dashboard-realtime | 实时通知/数据推送 |
|
||||
| 偏好 | use-dashboard-preferences | 用户仪表盘偏好持久化 |
|
||||
| 服务抽象 | dashboard-service.tsx | 客户端服务注入,便于 Mock |
|
||||
| Widget 配置 | widget-configs.ts | Widget 元数据集中配置 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/dashboard/
|
||||
├─ actions.ts # Server Actions (167 行): 4 个角色入口
|
||||
├─ data-access.ts # 数据访问 (58 行, server-only): getAdminDashboardData
|
||||
├─ types.ts # 类型定义 (108 行): 4 角色仪表盘数据类型
|
||||
├─ streams.ts # 实时流 (35 行, server-only)
|
||||
├─ lib/
|
||||
│ └─ dashboard-utils.ts # 纯函数 (280 行): 派生指标计算
|
||||
├─ config/
|
||||
│ └─ widget-configs.ts # Widget 配置 (68 行)
|
||||
├─ hooks/
|
||||
│ ├─ use-dashboard-preferences.ts # 偏好 Hook (91 行)
|
||||
│ └─ use-dashboard-realtime.ts # 实时 Hook (83 行)
|
||||
├─ services/
|
||||
│ └─ dashboard-service.tsx # 服务抽象 (119 行, client)
|
||||
└─ components/ # 25 个组件
|
||||
├─ admin-dashboard/ # 管理员(3 个)
|
||||
│ ├─ admin-dashboard.tsx # 主容器 (220 行)
|
||||
│ ├─ admin-sections.tsx # 区块 (225 行)
|
||||
│ └─ user-growth-chart.tsx # 用户增长图 (69 行)
|
||||
├─ teacher-dashboard/ # 教师(8 个)
|
||||
│ ├─ teacher-dashboard-view.tsx # 主视图 (169 行)
|
||||
│ ├─ teacher-schedule.tsx # 课表 (126 行)
|
||||
│ ├─ recent-submissions.tsx # 最近提交 (122 行)
|
||||
│ ├─ teacher-homework-card.tsx # 作业卡片 (100 行)
|
||||
│ ├─ teacher-todo-card.tsx # 待办卡片 (90 行)
|
||||
│ ├─ teacher-grade-trends.tsx # 成绩趋势 (82 行)
|
||||
│ ├─ teacher-classes-card.tsx # 班级卡片 (73 行)
|
||||
│ └─ teacher-quick-actions.tsx # 快捷操作 (33 行)
|
||||
├─ student-dashboard/ # 学生(5 个)
|
||||
│ ├─ student-dashboard-view.tsx # 主视图 (157 行)
|
||||
│ ├─ student-grades-card.tsx # 成绩卡片 (120 行)
|
||||
│ ├─ student-upcoming-assignments-card.tsx # 即将作业 (107 行)
|
||||
│ ├─ student-today-schedule-card.tsx # 今日课表 (97 行)
|
||||
│ └─ student-stats-grid.tsx # 统计网格 (83 行)
|
||||
├─ parent-dashboard/
|
||||
│ └─ parent-dashboard.tsx # 家长仪表盘 (111 行)
|
||||
└─ shared/ # 共享(8 个)
|
||||
├─ dashboard-section.tsx # 区块容器 (157 行)
|
||||
├─ dashboard-notification-widget.tsx # 通知 (95 行)
|
||||
├─ dashboard-responsive-layout.tsx # 响应式 (92 行)
|
||||
├─ comparison-badge.tsx # 对比徽章 (83 行)
|
||||
├─ dashboard-time-range-filter.tsx # 时间范围 (69 行)
|
||||
├─ dashboard-section.test.tsx # 区块单测 (71 行)
|
||||
├─ dashboard-shell.tsx # 外壳 (37 行)
|
||||
└─ dashboard-error-fallback.tsx # 错误兜底 (31 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | cache / auth-guard / action-utils |
|
||||
| users | data-access-call + import | getUsersDashboardStats + getUserBasicInfo + getCurrentStudentUser |
|
||||
| classes | data-access-call + import | getClassesDashboardStats + getTeacherClasses/getStudentClasses/getClassSchedule |
|
||||
| textbooks | data-access-call + import | getTextbooksDashboardStats |
|
||||
| questions | data-access-call + import | getQuestionsDashboardStats |
|
||||
| exams | import | getExamsDashboardStats(scope) |
|
||||
| homework | data-access-call + import | getHomeworkDashboardStats + getHomeworkAssignments/Submissions + getStudentDashboardGrades/getTeacherGradeTrends |
|
||||
| parent | data-access-call + import | getParentDashboardData 家长仪表盘委托 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| settings | import | 用户偏好设置关联仪表盘配置 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **仅聚合不直查跨模块表**: dashboard 不持有任何业务表所有权,统计函数由归属模块提供(如 getUsersDashboardStats 属 users),dashboard 仅 Promise.all 聚合
|
||||
- **Promise.all 并行聚合(核心模式)**: admin 6 路/teacher 6 路/student 4 路必须并行,禁阻塞串行,总延迟≈最慢一路
|
||||
- **四角色权限点分离**: DASHBOARD_ADMIN/TEACHER/STUDENT/PARENT_READ 各自独立,按角色路由进入对应 Action
|
||||
- **派生指标用纯函数**: computeTeacherMetrics / countStudentAssignments / filterTodaySchedule 等集中在 dashboard-utils.ts(280 行),便于单测
|
||||
- **admin 数据短缓存(ttl=60s)**: getAdminDashboardData 经 cacheFn 缓存 60 秒,平衡时效性与 6 模块 stats 查询压力
|
||||
- **data-access.ts / streams.ts 首行 server-only**: 禁止客户端组件直接调用聚合查询
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 仅聚合不跨模块查表 | 遵循三层架构,统计逻辑归属数据方,dashboard 不持有业务表所有权 |
|
||||
| Promise.all 并行调用各模块 stats | 串行拉取 6+ 模块总延迟过高,并行后总延迟≈最慢一路 |
|
||||
| 按角色拆分 4 个 Action 入口 | 不同角色数据源差异大,分入口避免巨型函数,权限点独立 |
|
||||
| 派生指标抽至 dashboard-utils.ts 纯函数 | computeTeacherMetrics 等逻辑复杂,纯函数便于单测覆盖 |
|
||||
| admin 数据 cacheFn ttl=60s | admin 统计查询 6 模块开销大,短缓存显著降低 DB 压力,60s 平衡时效 |
|
||||
| 家长仪表盘委托 parent 模块 | parent 模块已实现 getParentDashboardData(含多孩子聚合),dashboard 仅转发避免重复 |
|
||||
| streams.ts + use-dashboard-realtime 实时 | 仪表盘需展示实时通知/数据更新,非纯静态聚合 |
|
||||
| Widget 配置集中 widget-configs.ts | Widget 元数据(标题/图标/顺序)集中管理,便于统一调整 |
|
||||
250
src/modules/diagnostic/README.md
Normal file
250
src/modules/diagnostic/README.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# diagnostic 学情诊断模块
|
||||
|
||||
> 学情诊断与知识点掌握度计算,生成个人/班级/年级诊断报告,提供 Service 接口 + Monitor 监控的客户端抽象层
|
||||
> 代码规模: 19 文件 · 130 符号 · 4,050 行(arch.db)
|
||||
> 模块路径: `src/modules/diagnostic/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
StuView["student-diagnostic-view<br/>学生视图 (309 行)"]
|
||||
ClsView["class-diagnostic-view<br/>班级视图 (468 行)"]
|
||||
RptList["report-list<br/>报告列表 (372 行)"]
|
||||
Radar["mastery-radar-chart<br/>掌握度雷达 (89 行)"]
|
||||
end
|
||||
|
||||
subgraph Ctx["服务注入层(Context)"]
|
||||
SvcCtx["diagnostic-service-context<br/>(62 行, Service Provider)"]
|
||||
MonCtx["diagnostic-monitor-context<br/>(73 行, Monitor Provider)"]
|
||||
end
|
||||
|
||||
subgraph Svc["服务层(接口 + 实现)"]
|
||||
DI["diagnostic-service.ts<br/>(59 行, DiagnosticService 接口)"]
|
||||
DefSvc["default-diagnostic-service.ts<br/>(85 行, 默认实现绑定 Actions)"]
|
||||
MonSvc["monitored-diagnostic-service.ts<br/>(157 行, 装饰器+监控)"]
|
||||
Mon["diagnostic-monitor.ts<br/>(92 行, 监控埋点)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(300 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(678 行, 掌握度查询)"]
|
||||
DARpt["data-access-reports.ts<br/>(345 行, 报告 CRUD)"]
|
||||
Stats["stats-service.ts<br/>(507 行, 统计计算)"]
|
||||
Export["export.ts<br/>(188 行, Excel 导出)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Mastery[(knowledge_point_mastery)]
|
||||
Reports[(diagnostic_reports)]
|
||||
end
|
||||
|
||||
StuView --> SvcCtx
|
||||
ClsView --> SvcCtx
|
||||
RptList --> SvcCtx
|
||||
Radar --> DA
|
||||
SvcCtx --> MonSvc
|
||||
MonSvc --> DI
|
||||
MonSvc --> Mon
|
||||
DI --> DefSvc
|
||||
DefSvc --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> DARpt
|
||||
Actions --> Export
|
||||
DARpt --> Stats
|
||||
DA --> Mastery
|
||||
DARpt --> Reports
|
||||
Stats --> Mastery
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 生成诊断报告(个人/班级/年级)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant View as class-diagnostic-view
|
||||
participant Svc as useDiagnosticService()
|
||||
participant Act as actions.ts
|
||||
participant DARpt as data-access-reports.ts
|
||||
participant Stats as stats-service.ts
|
||||
participant DB as MySQL
|
||||
|
||||
T->>View: 选择对象 + 周期
|
||||
View->>Svc: generateClassReport(classId, period)
|
||||
Svc->>Act: generateClassReportAction (FormData)
|
||||
Act->>Act: requirePermission(DIAGNOSTIC_MANAGE)
|
||||
Act->>Act: GenerateClassReportSchema.safeParse
|
||||
Act->>DARpt: generateClassDiagnosticReport
|
||||
DARpt->>Stats: 计算班级掌握度摘要
|
||||
Stats->>DB: 聚合 knowledge_point_mastery
|
||||
Stats-->>DARpt: ClassMasterySummary
|
||||
DARpt->>DB: INSERT diagnostic_reports (status=draft)
|
||||
DARpt-->>Act: report.id
|
||||
Act->>Act: invalidateFor("diagnostic.generateClassReport")
|
||||
Act-->>Svc: ActionState<string>
|
||||
Svc-->>View: 报告生成成功
|
||||
```
|
||||
|
||||
### 流程 2: Service 装饰器 + Monitor 监控
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[客户端组件] --> B[useDiagnosticService<br/>获取 Service]
|
||||
B --> C{Context 提供?}
|
||||
C -- 默认 --> D[monitored-diagnostic-service<br/>装饰器包装]
|
||||
D --> E[diagnostic-monitor<br/>记录调用耗时/错误]
|
||||
E --> F[default-diagnostic-service<br/>调用 Server Actions]
|
||||
F --> G[actions.ts<br/>requirePermission]
|
||||
C -- 测试 --> H[Mock Service 注入]
|
||||
H --> I[返回 mock 数据]
|
||||
G --> J[data-access<br/>DB 操作]
|
||||
```
|
||||
|
||||
### 流程 3: 报告发布 + 通知推送
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant List as report-list
|
||||
participant Svc as DiagnosticService
|
||||
participant Act as actions.ts
|
||||
participant DARpt as data-access-reports.ts
|
||||
participant Notif as notifications.data-access
|
||||
participant ClsDA as classes.data-access
|
||||
participant ParDA as parent.data-access
|
||||
participant DB as MySQL
|
||||
|
||||
T->>List: 点击"发布"
|
||||
List->>Svc: publishReport(id)
|
||||
Svc->>Act: publishReportAction
|
||||
Act->>Act: requirePermission(DIAGNOSTIC_MANAGE)
|
||||
Act->>DARpt: publishDiagnosticReport
|
||||
DARpt->>DB: UPDATE diagnostic_reports SET status=published
|
||||
Act->>ClsDA: getStudentIdsByClassId
|
||||
ClsDA-->>Act: studentIds[]
|
||||
Act->>ParDA: getParentIdsByStudentIds
|
||||
ParDA-->>Act: parentIds[]
|
||||
Act->>Notif: createNotification (学生 + 家长)
|
||||
Notif->>DB: INSERT notifications
|
||||
Act->>Act: invalidateFor("diagnostic.publish")
|
||||
Act-->>List: ActionState
|
||||
```
|
||||
|
||||
### 流程 4: 报告导出 Excel
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[点击导出] --> B[useDiagnosticService.exportReport]
|
||||
B --> C[actions.ts<br/>exportReportAction]
|
||||
C --> D[requirePermission]
|
||||
D --> E[data-access-reports<br/>getDiagnosticReportById]
|
||||
E --> F[export.ts<br/>exportDiagnosticReportToExcel]
|
||||
F --> G[buildDiagnosticReportFilename<br/>生成文件名]
|
||||
F --> H[构造 Excel buffer]
|
||||
H --> I[返回 base64 + filename]
|
||||
I --> J[客户端触发下载]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | knowledge_point_mastery / diagnostic_reports 表 CRUD + 聚合 |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重 |
|
||||
| 服务抽象 | TypeScript 接口 + 装饰器 | DiagnosticService 接口 + monitored 装饰器 |
|
||||
| 依赖注入 | React Context | DiagnosticService / DiagnosticMonitor 注入 |
|
||||
| 导出 | Excel 自研 | export.ts 生成 Excel buffer + 文件名 |
|
||||
| Server Action | requirePermission | DIAGNOSTIC_MANAGE 权限校验 |
|
||||
| 缓存失效 | invalidateFor | 按 diagnostic.{generate*/publish} 精确失效 |
|
||||
| 跨模块聚合 | 对方 data-access 调用 | classes/parent/notifications/questions/school/users |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/diagnostic/
|
||||
├─ actions.ts # Server Actions (300 行): 生成/发布/删除/导出报告
|
||||
├─ data-access.ts # 数据访问层 (678 行): 掌握度查询/知识点筛选
|
||||
├─ data-access-reports.ts # 报告数据访问 (345 行): 报告 CRUD + 生成
|
||||
├─ stats-service.ts # 统计服务 (507 行): 掌握度聚合计算
|
||||
├─ export.ts # Excel 导出 (188 行): exportDiagnosticReportToExcel
|
||||
├─ schema.ts # Zod 验证 (40 行)
|
||||
├─ types.ts # 类型定义 (127 行): Mastery/Report/Summary
|
||||
├─ role-config.ts # 角色配置 (41 行)
|
||||
├─ services/ # 服务层(接口 + 实现 + 监控)
|
||||
│ ├─ diagnostic-service.ts # DiagnosticService 接口 (59 行)
|
||||
│ ├─ default-diagnostic-service.ts # 默认实现绑定 Actions (85 行)
|
||||
│ ├─ monitored-diagnostic-service.ts # 装饰器+监控 (157 行)
|
||||
│ ├─ diagnostic-monitor.ts # 监控埋点 (92 行)
|
||||
│ ├─ diagnostic-service-context.tsx # Service Context (62 行)
|
||||
│ └─ diagnostic-monitor-context.tsx # Monitor Context (73 行)
|
||||
└─ components/ # 客户端组件
|
||||
├─ class-diagnostic-view.tsx # 班级诊断视图 (468 行)
|
||||
├─ report-list.tsx # 报告列表 (372 行)
|
||||
├─ student-diagnostic-view.tsx # 学生诊断视图 (309 行)
|
||||
├─ mastery-radar-chart.tsx # 掌握度雷达图 (89 行)
|
||||
└─ confidence-utils.ts # 置信度工具 (58 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | getStudentIdsByClassId 按班级查学生 |
|
||||
| parent | data-access-call + import | getParentIdsByStudentIds 推送家长通知 |
|
||||
| notifications | data-access-call + import | createNotification 报告发布通知 |
|
||||
| questions | data-access-call + import | 知识点关联题目元数据 |
|
||||
| school | data-access-call + import | 年级报告按年级聚合 |
|
||||
| users | data-access-call + import | 学生/教师姓名 join |
|
||||
| exams | import | 考试作答数据引用 |
|
||||
| grades | import | 成绩数据引用 |
|
||||
| homework | import | 作业作答数据引用 |
|
||||
| shared | import | DB / cache / auth-guard / action-utils |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| grades | data-access-call + import | 成绩模块引用诊断掌握度 |
|
||||
| homework | data-access-call + import | 作业模块触发掌握度更新 |
|
||||
| textbooks | data-access-call + import | 教材模块展示知识点掌握度 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **掌握度更新幂等**: `updateMasteryFromSubmission` 作为统一入口,重复触发不产生脏数据
|
||||
- **Service 接口抽象**: 客户端组件不直接 import actions,通过 `useDiagnosticService()` 获取实现,便于测试注入 mock
|
||||
- **Monitor 装饰器**: `monitored-diagnostic-service` 包装默认实现,记录调用耗时与错误,不影响业务逻辑
|
||||
- **跨模块数据经 data-access**: 诊断不持有考试/题目/班级/用户表所有权,统一走对方 data-access
|
||||
- **报告状态机**: draft → published → archived,发布后推送通知给学生 + 家长
|
||||
- **掌握度阈值消除盲区**: strengths >= 80,weaknesses < 80(消除 60-79 盲区)
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| Service 接口 + Context 注入 | 客户端依赖接口而非具体 Server Action,测试可注入 mock,多场景适配 |
|
||||
| Monitor 装饰器模式 | monitored-service 包装默认实现,监控与业务分离,可独立替换监控实现 |
|
||||
| data-access 拆分核心 + 报告 | 掌握度查询(678 行)与报告 CRUD(345 行)职责分离,避免超长 |
|
||||
| stats-service 独立统计层 | 掌握度聚合计算复杂(507 行),独立维护便于单测与重算 |
|
||||
| 报告三级类型(individual/class/grade) | K12 场景需多粒度诊断,共用 stats-service 统一计算 |
|
||||
| 导出独立 export.ts | Excel 生成逻辑与数据访问分离,纯函数易测试 |
|
||||
247
src/modules/elective/README.md
Normal file
247
src/modules/elective/README.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# elective 选修模块
|
||||
|
||||
> 管理选修课程、学生选课/退课、抽签分班、时间冲突检测与选课结果导出
|
||||
> 代码规模: 19 文件 · 3,364 行(arch.db)
|
||||
> 模块路径: `src/modules/elective/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
CourseList["elective-course-list<br/>(238 行)"]
|
||||
CourseForm["elective-course-form<br/>(321 行)"]
|
||||
CourseDetail["elective-course-detail<br/>(199 行)"]
|
||||
StudentView["student-selection-view<br/>(312 行, 学生选课)"]
|
||||
ParentView["parent-selection-view<br/>(86 行, 家长查看)"]
|
||||
Layout["elective-page-layout<br/>(31 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(390 行, 8 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(275 行, 课程 CRUD)"]
|
||||
DAOps["data-access-operations.ts<br/>(437 行, 选课/退课/抽签)"]
|
||||
DASel["data-access-selections.ts<br/>(165 行, 选课查询)"]
|
||||
DASet["data-access-settings.ts<br/>(108 行, 学分/阈值配置)"]
|
||||
DAStats["data-access-stats.ts<br/>(93 行, 统计)"]
|
||||
Export["export.ts<br/>(114 行, Excel 导出)"]
|
||||
Resolvers["resolvers.ts<br/>(84 行, 关联名解析)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Lottery["lib/lottery.ts<br/>(74 行, Fisher-Yates 洗牌)"]
|
||||
Conflict["lib/schedule-conflict.ts<br/>(102 行, 时间冲突检测)"]
|
||||
Schema["schema.ts<br/>(201 行, Zod 校验)"]
|
||||
Constants["constants.ts<br/>(56 行, 状态标签)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Courses[(elective_courses)]
|
||||
Selections[(course_selections)]
|
||||
end
|
||||
|
||||
CourseList --> Actions
|
||||
CourseForm --> Schema
|
||||
CourseForm --> Actions
|
||||
CourseDetail --> Actions
|
||||
StudentView --> Actions
|
||||
ParentView --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> DAOps
|
||||
Actions --> Export
|
||||
DA --> Courses
|
||||
DA --> Selections
|
||||
DAOps --> Selections
|
||||
DAOps --> Lottery
|
||||
DAOps --> Conflict
|
||||
DAOps --> DASet
|
||||
DAOps --> DASel
|
||||
DAOps --> Notifications["notifications<br/>sendNotification"]
|
||||
DASel --> Users["users.getStudentGradeId"]
|
||||
DA --> School["school"]
|
||||
DA --> Classes["classes"]
|
||||
Resolvers --> Users
|
||||
Resolvers --> School
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 学生选课(FCFS + 时间冲突 + 学分上限)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 学生
|
||||
participant View as student-selection-view
|
||||
participant Act as actions.ts
|
||||
participant Ops as data-access-operations
|
||||
participant Conflict as schedule-conflict
|
||||
participant DB as MySQL
|
||||
|
||||
S->>View: 点击选课
|
||||
View->>Act: selectCourseAction(courseId)
|
||||
Act->>Act: requirePermission(ELECTIVE_SELECT)
|
||||
Act->>Ops: selectCourse(studentId, courseId)
|
||||
Ops->>Ops: 校验课程状态(open)
|
||||
Ops->>DB: 查询学生已选课程 schedule
|
||||
Ops->>Conflict: hasAnyScheduleConflict(new, existing)
|
||||
Conflict->>Conflict: parseSchedule(中英文星期)
|
||||
alt 有冲突
|
||||
Conflict-->>Ops: true
|
||||
Ops-->>Act: ElectiveBusinessError(scheduleConflict)
|
||||
else 无冲突
|
||||
Conflict-->>Ops: false
|
||||
Ops->>Ops: 校验学分上限(getElectiveCreditLimit)
|
||||
Ops->>DB: INSERT course_selections(status=selected)
|
||||
Ops->>Notifications: sendNotification(选课成功)
|
||||
end
|
||||
Act-->>View: ActionState
|
||||
```
|
||||
|
||||
### 流程 2: 抽签模式批量录取(lottery)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Act as actions.ts
|
||||
participant Ops as data-access-operations
|
||||
participant Lottery as lib/lottery.ts
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Act: runLotteryAction(courseId)
|
||||
Act->>Act: requirePermission(ELECTIVE_MANAGE)
|
||||
Act->>Ops: runLottery(courseId)
|
||||
Ops->>DB: 查询所有 selected 记录 + course.capacity
|
||||
Ops->>Lottery: partitionLottery(selections, capacity)
|
||||
Lottery->>Lottery: fisherYatesShuffle(无偏洗牌)
|
||||
Lottery->>Lottery: 前 capacity 名 enrolled,其余 waitlist
|
||||
Lottery-->>Ops: {enrolledIds, waitlistIds}
|
||||
Ops->>Lottery: buildLotteryRankCase(ids, startRank)
|
||||
Lottery-->>Ops: SQL CASE 片段
|
||||
Ops->>DB: UPDATE course_selections SET status=enrolled/waitlist
|
||||
Ops->>DB: UPDATE lotteryRank = CASE...
|
||||
Ops-->>Act: 完成统计
|
||||
Act-->>T: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 课程管理 + 资源归属校验
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[教师/管理员进入课程管理] --> B[createElectiveCourseAction<br/>权限: ELECTIVE_MANAGE]
|
||||
B --> C[CreateElectiveCourseSchema 校验]
|
||||
C --> D[data-access.createElectiveCourse]
|
||||
|
||||
A --> E[updateElectiveCourseAction]
|
||||
E --> F{assertCourseOwnership}
|
||||
F -->|admin scope=all| G[放行]
|
||||
F -->|teacher scope≠all| H{course.teacherId === ctx.userId}
|
||||
H -->|是| G
|
||||
H -->|否| I[返回 noOwnership 错误]
|
||||
G --> J[updateElectiveCourse]
|
||||
|
||||
A --> K[openSelectionAction / closeSelectionAction<br/>控制选课窗口]
|
||||
K --> L[UPDATE status=open/closed]
|
||||
|
||||
A --> M[deleteElectiveCourseAction]
|
||||
M --> F
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | elective_courses / course_selections 表 CRUD + CASE SQL 批量更新 |
|
||||
| 缓存 | cacheFn | data-access 查询函数请求级去重 |
|
||||
| 缓存失效 | invalidateFor | 写操作后精确刷新课程列表/详情/选课状态 |
|
||||
| 表单校验 | Zod | schema.ts 校验课程信息与选课/退课/抽签入参 |
|
||||
| Server Action | requirePermission | ELECTIVE_MANAGE(管理) / ELECTIVE_SELECT(学生选课) 两权限点 |
|
||||
| 业务错误 | ElectiveBusinessError + i18n code | 错误码与文案分离,actions 层翻译 |
|
||||
| 抽签算法 | Fisher-Yates 洗牌(纯函数) | 无偏随机,避免 sort(Math.random) 分布偏差 |
|
||||
| 时间冲突检测 | 自定义 schedule-conflict 纯函数 | 支持中英文星期、多时段、多种分隔符 |
|
||||
| 通知 | notifications.sendNotification | 选课/抽签结果通知学生 |
|
||||
| Excel 导出 | shared/lib/excel | export.ts 选课结果导出 |
|
||||
| 事件追踪 | trackEvent | 选课/退课/抽签操作追踪 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/elective/
|
||||
├─ actions.ts # Server Actions (390 行): 8 个 Action 编排
|
||||
├─ data-access.ts # 数据访问 (275 行, server-only): 课程 CRUD
|
||||
├─ data-access-operations.ts # 选课操作 (437 行, server-only): 选课/退课/抽签
|
||||
├─ data-access-selections.ts # 选课查询 (165 行, server-only)
|
||||
├─ data-access-settings.ts # 配置查询 (108 行, server-only): 学分上限/通知阈值
|
||||
├─ data-access-stats.ts # 统计查询 (93 行, server-only)
|
||||
├─ schema.ts # Zod 校验 (201 行)
|
||||
├─ types.ts # 类型定义 (78 行)
|
||||
├─ constants.ts # 常量 (56 行): 状态标签 i18n keys
|
||||
├─ export.ts # Excel 导出 (114 行, server-only)
|
||||
├─ resolvers.ts # 关联名解析 (84 行, server-only): 教师/年级/学科名
|
||||
├─ lib/
|
||||
│ ├─ lottery.ts # 抽签纯函数 (74 行): shuffle/partition/CASE SQL
|
||||
│ └─ schedule-conflict.ts # 时间冲突纯函数 (102 行): parseSchedule/hasAnyConflict
|
||||
├─ components/ # 6 个组件
|
||||
│ ├─ elective-course-list.tsx # 课程列表 (238 行)
|
||||
│ ├─ elective-course-form.tsx # 课程表单 (321 行)
|
||||
│ ├─ elective-course-detail.tsx # 课程详情 (199 行)
|
||||
│ ├─ student-selection-view.tsx # 学生选课视图 (312 行)
|
||||
│ ├─ parent-selection-view.tsx # 家长查看视图 (86 行)
|
||||
│ └─ elective-page-layout.tsx # 页面布局 (31 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | DB / cache / auth-guard / action-utils / excel / logger |
|
||||
| users | data-access-call + import | getStudentGradeId 解析学生年级 |
|
||||
| school | data-access-call + import | 课程关联年级信息 |
|
||||
| classes | data-access-call + import | 课程关联班级信息 |
|
||||
| notifications | import | 选课/退课/抽签结果通知学生 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| 无直接被依赖 | - | 选课为相对独立业务域,其他模块按需通过本模块 data-access 查询 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **data-access 按职责拆分 5 文件**: 主 CRUD(275)/操作(437)/选课查询(165)/配置(108)/统计(93),避免单文件超 1000 行硬上限
|
||||
- **抽签算法纯函数化**: lottery.ts 无 DB 访问、无副作用,便于单测覆盖率,数据访问层负责 DB 写入
|
||||
- **时间冲突检测纯函数化**: schedule-conflict.ts 支持中英文星期与多时段,纯函数便于测试各种 schedule 字符串
|
||||
- **资源归属校验 assertCourseOwnership**: admin scope=all 放行,teacher 必须 course.teacherId === ctx.userId,防止越权改他人课程
|
||||
- **双权限点**: ELECTIVE_MANAGE(课程管理/抽签)与 ELECTIVE_SELECT(学生选课/退课)分离
|
||||
- **退课截止时间(P2-4)**: dropDeadline 字段控制退课窗口,过期返回 dropDeadlinePassed 错误
|
||||
- **ElectiveBusinessError + i18n code**: 业务错误码与文案分离,actions 层经 getTranslations 翻译,不向客户端泄露内部错误
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| data-access 拆分为 5 个文件 | 按职责(课程/选课操作/查询/配置/统计)分离,operations 437 行已接近上限,拆分后单文件均 < 500 行 |
|
||||
| 抽签算法抽至 lib/lottery.ts(A-02) | 纯函数便于单测洗牌与分桶逻辑,DB 写入留在 data-access 保持分层 |
|
||||
| 时间冲突检测抽至 lib/schedule-conflict.ts | schedule 字符串解析复杂(中英文/多时段),纯函数便于覆盖各种格式 |
|
||||
| Fisher-Yates 而非 sort(Math.random) | sort 洗牌有分布偏差,Fisher-Yates 保证均匀随机 |
|
||||
| buildLotteryRankCase 返回 SQL 片段 | 批量更新 lotteryRank 用 CASE 一次执行,避免 N 次 UPDATE |
|
||||
| ElectiveBusinessError 带 i18n code | 错误码与文案分离,actions 层翻译,支持多语言 |
|
||||
| assertCourseOwnership 资源归属校验 | 防止教师越权修改/删除他人课程,符合最小权限原则 |
|
||||
| 双选课模式 fcfs + lottery | fcfs 先到先得适合小规模,lottery 抽签保证公平,适用大规模选课 |
|
||||
272
src/modules/error-book/README.md
Normal file
272
src/modules/error-book/README.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# error-book 错题本模块
|
||||
|
||||
> 错题收录(手动/作业/考试自动采集)、SM-2 间隔重复复习调度、按学生/班级/知识点聚合统计
|
||||
> 代码规模: 21 文件 · 171 符号 · 4,455 行(arch.db)
|
||||
> 模块路径: `src/modules/error-book/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
List["error-book-list<br/>错题列表 (58 行)"]
|
||||
Card["error-book-item-card<br/>错题卡片 (117 行)"]
|
||||
Detail["error-book-detail-dialog<br/>详情对话框 (337 行)"]
|
||||
AddDlg["add-error-book-dialog<br/>手动添加 (170 行)"]
|
||||
Review["review-buttons<br/>复习按钮 (103 行)"]
|
||||
SubTabs["subject-tabs<br/>学科切换 (112 行)"]
|
||||
ClsFilter["class-filter<br/>班级过滤 (104 行)"]
|
||||
end
|
||||
|
||||
subgraph Charts["统计图表(recharts)"]
|
||||
ChWk["chapter-weakness-chart<br/>章节薄弱 (208 行)"]
|
||||
KpWk["knowledge-point-weakness-chart<br/>知识点薄弱 (203 行)"]
|
||||
ClsBar["class-error-bar-chart<br/>班级错题柱状 (165 行)"]
|
||||
SubDist["subject-distribution-chart<br/>学科分布 (154 行)"]
|
||||
TopWq["top-wrong-questions<br/>高频错题 (107 行)"]
|
||||
GrpTbl["grouped-student-error-table<br/>学生错题分组表 (214 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(340 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(497 行, 核心 CRUD + recordReview)"]
|
||||
DAColl["data-access-collection.ts<br/>(171 行, 作业/考试采集)"]
|
||||
DAAna["data-access-analytics.ts<br/>(616 行, 教师/班级统计)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
SM2["sm2-algorithm.ts<br/>(178 行, SM-2 间隔重复算法)"]
|
||||
Types["types.ts<br/>(246 行, 状态/Badge 映射)"]
|
||||
Schema["schema.ts<br/>(52 行, Zod 验证)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Items[(error_book_items)]
|
||||
Q[(questions)]
|
||||
KP[(knowledge_points)]
|
||||
Ch[(chapters)]
|
||||
Cls[(classes)]
|
||||
end
|
||||
|
||||
List --> Actions
|
||||
Card --> Detail
|
||||
AddDlg --> Actions
|
||||
Review --> Actions
|
||||
SubTabs --> List
|
||||
ClsFilter --> List
|
||||
ChWk --> DAAna
|
||||
KpWk --> DAAna
|
||||
ClsBar --> DAAna
|
||||
SubDist --> DAAna
|
||||
TopWq --> DAAna
|
||||
GrpTbl --> DAAna
|
||||
|
||||
Actions --> DA
|
||||
Actions --> DAColl
|
||||
DA --> SM2
|
||||
DA --> Items
|
||||
DAAna --> Items
|
||||
DAAna --> Q
|
||||
DAAna --> KP
|
||||
DAAna --> Ch
|
||||
DAAna --> Cls
|
||||
DAColl --> Items
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 手动添加错题
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 学生
|
||||
participant Dlg as add-error-book-dialog
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Dlg: 选择题目 + 填写笔记
|
||||
Dlg->>Act: createErrorBookItemAction (FormData/json)
|
||||
Act->>Act: requirePermission(ERROR_BOOK_MANAGE)
|
||||
Act->>Act: CreateErrorBookItemSchema.safeParse
|
||||
Act->>DA: createErrorBookItem(userId, input)
|
||||
DA->>DB: INSERT error_book_items (status=new, masteryLevel=0)
|
||||
DA-->>Act: 返回 itemId
|
||||
Act->>Act: invalidateFor("error-book.create")
|
||||
Act-->>Dlg: ActionState<string>
|
||||
Dlg-->>U: 提示"错题已添加"
|
||||
```
|
||||
|
||||
### 流程 2: 作业/考试错题自动采集
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Trig as 提交完成事件
|
||||
participant Act as actions.ts
|
||||
participant Coll as data-access-collection.ts
|
||||
participant Ex as exams.data-access
|
||||
participant Hw as homework.data-access
|
||||
participant Q as questions.data-access
|
||||
participant DB as MySQL
|
||||
|
||||
Trig->>Act: collectFromSubmissionAction (submissionId, sourceType)
|
||||
Act->>Act: requirePermission(ERROR_BOOK_MANAGE)
|
||||
Act->>Coll: collectFromExamSubmission / collectFromHomeworkSubmission
|
||||
alt sourceType = exam
|
||||
Coll->>Ex: getExamSubmissionDataForErrorCollection
|
||||
else sourceType = homework
|
||||
Coll->>Hw: getHomeworkSubmissionDataForErrorCollection
|
||||
end
|
||||
Ex-->>Coll: answers[] (questionId, score, maxScore)
|
||||
Coll->>Coll: 筛选 wrongAnswers (score < maxScore)
|
||||
Coll->>DB: 查询已存在错题(去重)
|
||||
par 并行查询
|
||||
Coll->>Q: getKnowledgePointsForQuestions
|
||||
Coll->>Q: getQuestionsContentForErrorCollection
|
||||
end
|
||||
Q-->>Coll: 知识点映射 + 正确答案
|
||||
Coll->>DB: INSERT error_book_items (批量)
|
||||
Coll-->>Act: 返回采集数量
|
||||
Act->>Act: invalidateFor("error-book.collect")
|
||||
Act-->>Trig: ActionState<number>
|
||||
```
|
||||
|
||||
### 流程 3: SM-2 复习调度
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[学生点击复习按钮] --> B[review-buttons<br/>选择 again/hard/good/easy]
|
||||
B --> C[reviewErrorBookItemAction]
|
||||
C --> D{requirePermission<br/>ERROR_BOOK_MANAGE}
|
||||
D -- 失败 --> E[返回 PermissionDenied]
|
||||
D -- 通过 --> F[recordReview itemId, result]
|
||||
F --> G[sm2-algorithm.ts<br/>calculateNewInterval]
|
||||
G --> H[calculateNewMastery<br/>掌握度 0-5 增减]
|
||||
H --> I[calculateNewCorrectStreak<br/>连续答对次数]
|
||||
I --> J{masteryLevel >= 5<br/>且 streak >= 3?}
|
||||
J -- 是 --> K[deriveStatus = mastered]
|
||||
J -- 否 --> L[deriveStatus = learning]
|
||||
K --> M[UPDATE error_book_items<br/>nextReviewAt = now + interval]
|
||||
L --> M
|
||||
M --> N[invalidateFor review]
|
||||
N --> O[刷新列表]
|
||||
```
|
||||
|
||||
### 流程 4: 教师班级错题分析
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Chart as class-error-bar-chart
|
||||
participant Act as actions.ts
|
||||
participant Ana as data-access-analytics.ts
|
||||
participant ClsDA as classes.data-access
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Chart: 选择班级
|
||||
Chart->>Act: getErrorBookStatsAction
|
||||
Act->>Act: requirePermission(ERROR_BOOK_READ)
|
||||
Act->>ClsDA: getStudentIdsByClassIds
|
||||
ClsDA-->>Act: studentIds[]
|
||||
Act->>Ana: getStudentErrorBookSummariesRaw
|
||||
Ana->>DB: SQL 条件聚合 GROUP BY studentId
|
||||
Note over Ana,DB: 按 status / nextReviewAt 聚合<br/>newCount / learningCount / masteredCount / dueReviewCount
|
||||
Ana-->>Act: StudentErrorBookSummary[]
|
||||
Act-->>Chart: ActionState<stats>
|
||||
Chart-->>T: 渲染柱状图
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | error_book_items 表 CRUD + SQL 条件聚合(GROUP BY + CASE WHEN) |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重 |
|
||||
| 复习算法 | SM-2(自研简化版) | 间隔重复调度,4 级评级 again/hard/good/easy |
|
||||
| 图表 | recharts | 章节薄弱/知识点薄弱/班级柱状/学科分布 4 类图表 |
|
||||
| 表单 | Zod schema | CreateErrorBookItem / Review / Collect 验证 |
|
||||
| Server Action | requirePermission | ERROR_BOOK_READ / ERROR_BOOK_MANAGE 权限校验 |
|
||||
| 缓存失效 | invalidateFor | 精确按 error-book.{create/update/review/collect} 失效 |
|
||||
| 跨模块采集 | 对方 data-access 调用 | exams/homework/questions 跨模块接口 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/error-book/
|
||||
├─ actions.ts # Server Actions (340 行): 列表/详情/统计/创建/笔记/复习/归档/采集
|
||||
├─ data-access.ts # 数据访问层 (497 行): 核心 CRUD + recordReview + deriveStatus
|
||||
├─ data-access-collection.ts # 采集层 (171 行): 从作业/考试提交自动采集错题
|
||||
├─ data-access-analytics.ts # 分析层 (616 行): 教师/班级 SQL 聚合统计
|
||||
├─ sm2-algorithm.ts # 纯函数 (178 行): SM-2 间隔重复算法
|
||||
├─ sm2-algorithm.test.ts # 单测 (303 行): 算法覆盖
|
||||
├─ schema.ts # Zod 验证 (52 行)
|
||||
├─ types.ts # 类型定义 (246 行): 状态/Badge/Label 映射
|
||||
├─ components/ # 13 个客户端组件
|
||||
│ ├─ error-book-list.tsx # 列表容器 (58 行)
|
||||
│ ├─ error-book-item-card.tsx # 错题卡片 (117 行)
|
||||
│ ├─ error-book-detail-dialog.tsx # 详情对话框 (337 行)
|
||||
│ ├─ add-error-book-dialog.tsx # 手动添加 (170 行)
|
||||
│ ├─ review-buttons.tsx # 复习按钮组 (103 行)
|
||||
│ ├─ subject-tabs.tsx # 学科切换 (112 行)
|
||||
│ ├─ class-filter.tsx # 班级过滤 (104 行)
|
||||
│ ├─ chapter-weakness-chart.tsx # 章节薄弱雷达 (208 行)
|
||||
│ ├─ knowledge-point-weakness-chart.tsx # 知识点薄弱 (203 行)
|
||||
│ ├─ class-error-bar-chart.tsx # 班级错题柱状 (165 行)
|
||||
│ ├─ subject-distribution-chart.tsx # 学科分布饼图 (154 行)
|
||||
│ ├─ top-wrong-questions.tsx # 高频错题 (107 行)
|
||||
│ └─ grouped-student-error-table.tsx # 学生错题分组表 (214 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | 按班级 ID 查学生 IDs(getStudentIdsByClassIds) |
|
||||
| exams | import | 跨模块采集考试错题(getExamSubmissionDataForErrorCollection) |
|
||||
| homework | import | 跨模块采集作业错题(getHomeworkSubmissionDataForErrorCollection) |
|
||||
| questions | data-access-call | 查题目知识点关联 + 题目内容/正确答案 |
|
||||
| shared | import | DB / cacheFn / auth-guard / 提取正确答案纯函数 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| grades | data-access-call + import | 成绩模块引用错题掌握度数据 |
|
||||
| homework | data-access-call + import | 作业批改完成后触发错题采集 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **dataScope 权限校验**: 学生只能看自己错题本,家长通过 `dataScope.type === "children"` 查看子女错题本,教师通过班级查学生错题,严禁越权
|
||||
- **SM-2 算法纯函数**: `sm2-algorithm.ts` 无副作用无 IO,所有函数可单测,`sm2-algorithm.test.ts` (303 行) 覆盖
|
||||
- **data-access 三层拆分**: 核心 CRUD / 采集 / 分析分离,避免单文件超过 1000 行硬上限(分析层已达 616 行)
|
||||
- **采集去重**: `collectErrorItemsFromAnswers` 先查已存在错题集合 `existingSet`,避免重复采集同一题目
|
||||
- **统计聚合用 SQL**: 教师视图 `getStudentErrorBookSummariesRaw` 使用 `GROUP BY + CASE WHEN` 在 DB 聚合,避免 N+1 查询
|
||||
- **所有 Server Action 调用 requirePermission**: ERROR_BOOK_READ(读)/ERROR_BOOK_MANAGE(写)双权限点
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| SM-2 算法独立文件 + 单测 | 复习调度是核心域逻辑,需可单测且可替换为 FSRS 等其他算法 |
|
||||
| collection 独立 data-access | 作业/考试错题采集涉及跨模块调用(exams/homework/questions),与本体查询职责分离 |
|
||||
| analytics 独立 data-access | 教师视图统计使用 SQL 聚合,与单学生 CRUD 查询模式不同,独立维护 |
|
||||
| 采集并行查询优化 | 知识点关联 + 题目内容 + 已存在错题三个查询 Promise.all 并行,降低延迟 |
|
||||
| 掌握度派生状态 | status(new/learning/mastered/archived) 由 masteryLevel + correctStreak 派生,不手动设置 |
|
||||
| 4 级评级简化 SM-2 | K12 场景不需要 0-5 评级,again/hard/good/easy 4 级够用且易理解 |
|
||||
297
src/modules/exams/README.md
Normal file
297
src/modules/exams/README.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# exams 试卷与考试模块
|
||||
|
||||
> 试卷全生命周期管理:手动组卷 / AI 出题 / 富文本编辑 / 预览发布 / 考试统计
|
||||
> 代码规模: 70 文件 · 373 符号 · 113,348 行(arch.db)
|
||||
> 模块路径: `src/modules/exams/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
RichForm["exam-rich-form<br/>(389 行) 富文本编辑入口"]
|
||||
StructEditor["structure-editor<br/>(771 行) 结构化组卷"]
|
||||
RichEditor["exam-rich-editor-inner<br/>(335 行) Tiptap 编辑器"]
|
||||
SelToolbar["selection-toolbar<br/>(369 行) 选区工具栏"]
|
||||
Preview["exam-preview<br/>+ exam-preview-dialog"]
|
||||
AIGen["exam-ai-generator<br/>AI 出题"]
|
||||
Actions["exam-actions<br/>(344 行) 操作面板"]
|
||||
Analytics["exam-analytics-dashboard<br/>统计"]
|
||||
end
|
||||
|
||||
subgraph Editor["编辑器扩展 + 转换"]
|
||||
Ext["extensions/<br/>question/group/section/<br/>blank/image/dotted"]
|
||||
E2S["editor-to-structure<br/>(251 行)"]
|
||||
S2E["structure-to-editor<br/>(87 行)"]
|
||||
Nodes2Doc["exam-nodes-to-editor-doc<br/>(118 行)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UsePreview["use-exam-preview<br/>+ state/tasks/rewrite"]
|
||||
end
|
||||
|
||||
subgraph Service["Service Port"]
|
||||
Port["exam-service-port<br/>(126 行) 接口抽象"]
|
||||
Ctx["exam-service-context<br/>Context 注入"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端 Actions"]
|
||||
Actions2["actions.ts<br/>(727 行) 核心 CRUD"]
|
||||
ActRich["actions-rich-editor<br/>(295 行) 富文本持久化"]
|
||||
ActHelp["actions-helpers<br/>(105 行) 工具"]
|
||||
end
|
||||
|
||||
subgraph DA["Data Access"]
|
||||
DA1["data-access.ts<br/>(615 行)"]
|
||||
DACross["data-access-cross-module<br/>(533 行) 对外接口"]
|
||||
DAErr["data-access-error-collection<br/>(102 行) 错误采集"]
|
||||
Stats["stats-service<br/>(167 行) 统计聚合"]
|
||||
end
|
||||
|
||||
subgraph AIPipe["AI Pipeline"]
|
||||
AIReq["request.ts<br/>(310 行) AI 请求"]
|
||||
AIParse["parse.ts<br/>(431 行) 解析"]
|
||||
AIStruct["structure.ts<br/>(214 行) 结构化"]
|
||||
AIMark["auto-mark.ts<br/>(400 行) 自动评分"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Exams[(exams)]
|
||||
EQ[(examQuestions)]
|
||||
Sub[(examSubmissions)]
|
||||
Ans[(submissionAnswers)]
|
||||
end
|
||||
|
||||
RichForm --> RichEditor
|
||||
RichEditor --> Ext
|
||||
RichEditor --> E2S
|
||||
StructEditor --> S2E
|
||||
E2S --> ActRich
|
||||
StructEditor --> Actions2
|
||||
SelToolbar --> RichEditor
|
||||
Preview --> UsePreview
|
||||
UsePreview --> Actions2
|
||||
AIGen --> Actions2
|
||||
Actions --> Actions2
|
||||
Analytics --> Stats
|
||||
|
||||
Actions2 --> DA1
|
||||
ActRich --> DA1
|
||||
DA1 --> Exams
|
||||
DA1 --> EQ
|
||||
DACross --> Sub
|
||||
DACross --> Ans
|
||||
DAErr --> Sub
|
||||
Stats --> Sub
|
||||
Actions2 --> AIPipe
|
||||
AIPipe --> Questions["questions data-access<br/>createQuestionWithRelations"]
|
||||
AIReq --> AIParse
|
||||
AIParse --> AIStruct
|
||||
AIStruct --> AIMark
|
||||
Ctx --> Port
|
||||
RichForm --> Ctx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 手动组卷(结构化编辑)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant Form as exam-rich-form
|
||||
participant SE as structure-editor
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Q as questions data-access
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写基础信息(标题/学科/年级)
|
||||
Form->>Act: persistExamDraftAction
|
||||
Act->>Act: requirePermission(EXAM_CREATE)
|
||||
Act->>DA: persistExamDraft
|
||||
DA->>DB: INSERT exams
|
||||
DA-->>Act: examId
|
||||
Act-->>Form: ActionState<{examId}>
|
||||
|
||||
U->>SE: 添加/排序题目
|
||||
SE->>Act: updateExamWithQuestionsAction
|
||||
Act->>DA: updateExamWithQuestions
|
||||
DA->>Q: getQuestionTypeMapByIds 校验题目
|
||||
DA->>DB: UPDATE examQuestions(批量)
|
||||
DA-->>Act: 成功
|
||||
Act-->>SE: ActionState
|
||||
```
|
||||
|
||||
### 流程 2: AI 出题 pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[exam-ai-generator<br/>输入题数/难度/原文] --> B[actions.ts<br/>generateAiPreview]
|
||||
B --> C[ai-pipeline/request<br/>调用 AI 模型]
|
||||
C --> D[ai-pipeline/parse<br/>解析 AI 响应]
|
||||
D --> E[ai-pipeline/structure<br/>组装试卷结构]
|
||||
E --> F[ai-pipeline/auto-mark<br/>生成参考答案+评分点]
|
||||
F --> G{用户预览}
|
||||
G -->|接受| H[persistAiGeneratedExamDraft]
|
||||
G -->|重生成单题| I[regenerateAiQuestionByInstruction]
|
||||
I --> C
|
||||
H --> J[questions.createQuestionWithRelations<br/>落库题目]
|
||||
J --> K[INSERT exams + examQuestions]
|
||||
```
|
||||
|
||||
### 流程 3: 富文本编辑器双向转换
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant RE as exam-rich-editor
|
||||
participant E2S as editor-to-structure
|
||||
participant S2E as structure-to-editor
|
||||
participant Act as actions-rich-editor
|
||||
participant DA as data-access
|
||||
participant DB as MySQL
|
||||
|
||||
U->>RE: Tiptap 编辑试卷
|
||||
RE->>E2S: 编辑器 doc → 试卷结构
|
||||
E2S-->>RE: structure 节点
|
||||
RE->>Act: saveRichEditorAction
|
||||
Act->>DA: updateExamFromRichEditor
|
||||
DA->>DB: UPDATE exams + examQuestions
|
||||
|
||||
Note over U,DB: 加载时反向转换<br/>S2E: structure → Tiptap doc<br/>保证编辑视图与存储一致
|
||||
DA->>S2E: structure → editor doc
|
||||
S2E-->>RE: Tiptap doc
|
||||
RE-->>U: 渲染
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | exams / examQuestions / examSubmissions / submissionAnswers 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(58 处) |
|
||||
| 富文本编辑 | Tiptap | exam-rich-editor 试卷富文本编辑,自定义节点扩展 |
|
||||
| 表单 | react-hook-form + Zod | exam-rich-form / exam-form 基础信息采集 |
|
||||
| Server Action | requirePermission | EXAM_CREATE / EXAM_UPDATE 等权限点 |
|
||||
| AI | 自定义 ai-pipeline | request→parse→structure→auto-mark 分阶段生成 |
|
||||
| 数据可视化 | recharts | exam-analytics-dashboard 考试统计图表 |
|
||||
| 服务抽象 | Service Port 模式 | exam-service-port 接口 + Context 注入,便于 mock |
|
||||
| 跨模块通信 | data-access-cross-module | 对外暴露查询接口,避免他模块直查 exams 表 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/exams/
|
||||
├─ actions.ts # 核心 Server Actions (727 行): 试卷 CRUD + AI 编排
|
||||
├─ actions-helpers.ts # Action 工具函数 (105 行)
|
||||
├─ actions-rich-editor.ts # 富文本编辑器持久化 Action (295 行)
|
||||
├─ data-access.ts # 核心 CRUD (615 行): exams/examQuestions
|
||||
├─ data-access-cross-module.ts # 跨模块对外接口 (533 行): 供 dashboard/grades 调用
|
||||
├─ data-access-error-collection.ts # 错误采集 (102 行): 供 error-book
|
||||
├─ stats-service.ts # 考试统计聚合 (167 行)
|
||||
├─ types.ts # 类型定义 (96 行): Exam/ExamSubmission/GradeExamItem
|
||||
├─ ai-pipeline/
|
||||
│ ├─ request.ts # AI 请求 (310 行)
|
||||
│ ├─ parse.ts # 响应解析 (431 行)
|
||||
│ ├─ structure.ts # 结构化 (214 行)
|
||||
│ ├─ auto-mark.ts # 自动评分 (400 行)
|
||||
│ └─ index.ts # pipeline 编排 (279 行)
|
||||
├─ editor/
|
||||
│ ├─ exam-rich-editor.tsx / exam-rich-editor-inner.tsx (335 行)
|
||||
│ ├─ selection-toolbar.tsx # 选区工具栏 (369 行)
|
||||
│ ├─ editor-to-structure.ts # doc→structure (251 行)
|
||||
│ ├─ structure-to-editor.ts # structure→doc (87 行)
|
||||
│ ├─ exam-nodes-to-editor-doc.ts # (118 行)
|
||||
│ └─ extensions/ # Tiptap 节点扩展
|
||||
│ ├─ question-block / group-block / section-block
|
||||
│ ├─ blank-node / image-node / dotted-mark
|
||||
│ └─ index.ts # 扩展注册入口
|
||||
├─ components/
|
||||
│ ├─ structure-editor.tsx # 结构化组卷 (771 行)
|
||||
│ ├─ exam-rich-form.tsx # 富文本编辑表单 (389 行)
|
||||
│ ├─ exam-form.tsx # 基础信息表单 (287 行)
|
||||
│ ├─ exam-actions.tsx # 操作面板 (344 行)
|
||||
│ ├─ exam-ai-generator.tsx # AI 出题 (172 行)
|
||||
│ ├─ exam-preview.tsx / exam-preview-dialog.tsx / exam-preview-utils.ts
|
||||
│ ├─ exam-analytics-dashboard.tsx # 统计仪表盘 (227 行)
|
||||
│ ├─ assembly/ # 组卷子组件(structure-editor/question-bank-list/selected-question-list)
|
||||
│ └─ question-options-editor.tsx / question-sub-questions-editor.tsx
|
||||
├─ hooks/
|
||||
│ ├─ use-exam-preview.ts # 预览主 Hook (69 行)
|
||||
│ ├─ use-exam-preview-state.ts # 预览状态 (68 行)
|
||||
│ ├─ use-exam-preview-tasks.ts # 预览任务 (81 行)
|
||||
│ └─ use-exam-preview-rewrite.ts # 重写 (80 行)
|
||||
├─ services/
|
||||
│ ├─ exam-service-port.ts # 服务接口抽象 (126 行)
|
||||
│ ├─ exam-service-context.tsx # Context 注入 (73 行)
|
||||
│ └─ index.ts
|
||||
├─ config/exam-widgets.ts # 试卷组件配置 (231 行)
|
||||
├─ lib/type-guards.ts # 类型守卫 (81 行)
|
||||
└─ utils/
|
||||
├─ exam-structure-tree.ts # 结构树工具 (97 行)
|
||||
└─ normalize-structure.ts # 结构规范化 (65 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| questions | data-access-call + import | AI 出题经 createQuestionWithRelations 落库题目 |
|
||||
| classes | data-access-call + import | 按班级过滤试卷/获取班级年级映射 |
|
||||
| school | data-access-call + import | 学科/年级选项解析 |
|
||||
| ai | import | AI 模型调用 |
|
||||
| homework | import | 试卷发布为作业 |
|
||||
| proctoring | import | 考试监考关联 |
|
||||
| settings | import | 考试设置 |
|
||||
| shared | import | DB / cache / auth-guard / 工具函数 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| homework | import | 作业关联试卷 |
|
||||
| grades | import | 成绩录入引用试卷题目 |
|
||||
| lesson-preparation | data-access-call + import | 备课发布作业时创建试卷 |
|
||||
| diagnostic | import | 诊断引用考试数据 |
|
||||
| error-book | import | 错题本采集考试错题 |
|
||||
| dashboard | import | 首页考试统计 |
|
||||
| school | import | 学校维度考试统计 |
|
||||
| shared | import | 共享类型 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **Server Action 全部 requirePermission + Zod + ActionState**: 权限点包括 EXAM_CREATE/EXAM_UPDATE/EXAM_DELETE
|
||||
- **跨模块数据走 data-access-cross-module.ts**: 他模块不直查 exams/examSubmissions 表,统一通过对外接口
|
||||
- **AI 生成题目经 questions 模块落库**: 不在 exams 模块直接 INSERT questions 表,通过 createQuestionWithRelations
|
||||
- **Tiptap 扩展注册制**: 新增节点扩展必须放入 editor/extensions/ 并注册到 extensions/index.ts
|
||||
- **富文本双向转换强一致**: editor-to-structure 与 structure-to-editor 必须可逆,保证编辑视图与存储结构一致
|
||||
- **data-access 三段拆分**: 主 CRUD + 跨模块接口 + 错误采集,避免单文件超 1000 行硬上限
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| AI 出题独立 pipeline 目录 | request/parse/structure/auto-mark 分阶段,便于单测、重试与单题重生成 |
|
||||
| 富文本编辑器双向转换 | Tiptap doc 与试卷 structure 互转,兼顾富文本体验与结构化存储 |
|
||||
| data-access-cross-module 集中对外 | 跨模块接口收口,避免循环依赖与他模块直查 exams 表 |
|
||||
| data-access-error-collection 独立 | 错题采集逻辑与主 CRUD 解耦,专为 error-book 模块服务 |
|
||||
| Service Port 模式 | exam-service-port 抽象服务接口 + Context 注入,客户端组件便于 mock 测试 |
|
||||
| 双编辑模式(结构化 + 富文本) | structure-editor 适合批量组卷,exam-rich-editor 适合排版美化,满足不同场景 |
|
||||
211
src/modules/files/README.md
Normal file
211
src/modules/files/README.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# files 文件模块
|
||||
|
||||
> 文件上传、存储路径生成、附件元数据管理与批量删除,通过 storageProvider 抽象屏蔽本地/对象存储差异
|
||||
> 代码规模: 12 文件 · 78 符号 · 15,486 行(arch.db)
|
||||
> 模块路径: `src/modules/files/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Upload["file-upload.tsx<br/>(139 行, 上传)"]
|
||||
Admin["admin-files-view.tsx<br/>(282 行, 管理后台)"]
|
||||
Preview["file-preview.tsx<br/>(256 行, 预览)"]
|
||||
PreviewDlg["file-preview-dialog.tsx<br/>(67 行)"]
|
||||
Icon["file-icon.tsx<br/>(87 行)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseUpload["use-file-upload.ts<br/>(182 行)"]
|
||||
UseBatch["use-file-batch-operations.ts<br/>(133 行)"]
|
||||
UsePreview["use-file-preview.ts<br/>(65 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(311 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(280 行, CRUD + 多条件查询)"]
|
||||
end
|
||||
|
||||
subgraph Lib["shared/lib"]
|
||||
Storage["storageProvider"]
|
||||
FileLib["file-storage<br/>generateStoragePath / isAllowedMimeType / MAX_FILE_SIZE"]
|
||||
Audit["audit-logger / track-event"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Files[(file_attachments)]
|
||||
end
|
||||
|
||||
Upload --> UseUpload
|
||||
Admin --> UseBatch
|
||||
Admin --> Actions
|
||||
Preview --> UsePreview
|
||||
UseUpload --> Actions
|
||||
UseBatch --> Actions
|
||||
UsePreview --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Storage
|
||||
Actions --> FileLib
|
||||
Actions --> Audit
|
||||
DA --> Files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 文件上传
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Up as file-upload
|
||||
participant Hook as use-file-upload
|
||||
participant Act as actions.ts
|
||||
participant Storage as storageProvider
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Up: 选择文件
|
||||
Up->>Hook: 收集文件 + metadata
|
||||
Hook->>Act: uploadFileAction(file, meta)
|
||||
Act->>Act: requirePermission(FILE_UPLOAD)
|
||||
Act->>Act: UploadMetadataSchema.parse
|
||||
Act->>Act: 校验 size/MIME
|
||||
Act->>FileLib: generateStoragePath
|
||||
Act->>Storage: save(bytes, path)
|
||||
Storage-->>Act: url
|
||||
Act->>DA: createFileAttachment
|
||||
DA->>DB: INSERT file_attachments
|
||||
Act->>Audit: trackEvent + logAudit
|
||||
Act-->>Hook: ActionState<FileUploadResult>
|
||||
Hook-->>Up: 刷新列表
|
||||
```
|
||||
|
||||
### 流程 2: 管理员文件管理(筛选 + 批量删除)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as 管理员
|
||||
participant View as admin-files-view
|
||||
participant Batch as use-file-batch-operations
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
A->>View: 选择筛选条件(mimeType/search)
|
||||
View->>Act: getFileAttachmentsAction
|
||||
Act->>Act: requirePermission(FILE_ADMIN)
|
||||
Act->>DA: getFileAttachmentsWithFilters
|
||||
DA->>DB: SELECT + WHERE + LIMIT/OFFSET
|
||||
DA-->>Act: FileAttachment[]
|
||||
Act-->>View: 列表渲染
|
||||
|
||||
A->>View: 勾选 + 批量删除
|
||||
View->>Batch: deleteSelected
|
||||
Batch->>Act: deleteFileAttachmentsAction
|
||||
Act->>DA: deleteFileAttachments(ids)
|
||||
DA->>DB: DELETE WHERE id IN (...)
|
||||
DA-->>Act: BatchDeleteResult
|
||||
Act->>Audit: logAudit(file.deleted)
|
||||
Act-->>Batch: 结果(含失败Ids)
|
||||
```
|
||||
|
||||
### 流程 3: 文件预览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[点击文件] --> B[use-file-preview<br/>获取附件]
|
||||
B --> C{mimeType?}
|
||||
C -->|image/*| D[图片预览]
|
||||
C -->|pdf| E[PDF 预览]
|
||||
C -->|其他| F[file-icon 占位]
|
||||
D --> G[file-preview-dialog]
|
||||
E --> G
|
||||
F --> G
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | file_attachments 表 CRUD + 多条件筛选 + 分页 |
|
||||
| 缓存 | cacheFn | getFileAttachment 请求级去重(TTL 300s) |
|
||||
| Server Action | requirePermission | FILE_UPLOAD / FILE_ADMIN / FILE_DELETE 权限校验 |
|
||||
| 校验 | Zod | UploadMetadataSchema / BatchDeleteSchema / FileListQuerySchema |
|
||||
| 存储 | storageProvider 抽象 | 屏蔽本地/对象存储,统一 save/delete 接口 |
|
||||
| 文件 | file-storage 工具 | generateStoragePath / isAllowedMimeType / MAX_FILE_SIZE |
|
||||
| 审计 | audit-logger + track-event | file.uploaded / file.deleted 事件记录 |
|
||||
| 服务端隔离 | import "server-only" | data-access.ts 防止客户端引用 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/files/
|
||||
├─ actions.ts # Server Actions (311 行): 上传/查询/批量删除/统计
|
||||
├─ data-access.ts # 数据访问层 (280 行): CRUD + 多条件筛选 + cacheFn
|
||||
├─ schema.ts # Zod 验证 schema (85 行)
|
||||
├─ types.ts # 类型定义 (75 行): FileTargetType / FileAttachment
|
||||
├─ components/
|
||||
│ ├─ file-upload.tsx # 上传组件 (139 行)
|
||||
│ ├─ admin-files-view.tsx # 管理后台 (282 行, 筛选 + 批量)
|
||||
│ ├─ file-preview.tsx # 预览组件 (256 行, 多类型)
|
||||
│ ├─ file-preview-dialog.tsx # 预览对话框 (67 行)
|
||||
│ └─ file-icon.tsx # 文件类型图标 (87 行)
|
||||
├─ hooks/
|
||||
│ ├─ use-file-upload.ts # 上传编排 (182 行)
|
||||
│ ├─ use-file-batch-operations.ts # 批量操作 (133 行)
|
||||
│ └─ use-file-preview.ts # 预览状态 (65 行)
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | DB / storageProvider / file-storage / audit-logger / auth-guard |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| homework | data-access-call | 作业附件关联 |
|
||||
| messaging | import + data-access-call | 私信附件上传与查询 |
|
||||
| lesson-preparation | import | 备课附件上传 |
|
||||
| settings | import + data-access-call | 用户头像上传 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **storageProvider 抽象**: actions.ts 通过 `@/shared/lib/storage-provider` 调用 save/delete,禁止在模块内直接访问文件系统
|
||||
- **存储路径统一**: 必须使用 `generateStoragePath` 生成,禁止前端拼路径,避免目录穿越
|
||||
- **多态关联**: `targetType + targetId` 一张表服务多种业务实体(exam/textbook/question/announcement/homework/user_avatar/message/lesson_plan),新增类型需同步 `types.ts` 的 `FileTargetType`
|
||||
- **MIME + 体积校验**: 上传强制 `isAllowedMimeType` + `MAX_FILE_SIZE(10MB)`,空文件直接拒绝
|
||||
- **data-access.ts 首行 `import "server-only"`**: 防止客户端组件误引用
|
||||
- **批量删除审计**: `deleteFileAttachments` 必须配合 `logAudit` 记录操作者与目标 ID
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| storageProvider 抽象而非直接 fs | 屏蔽本地/对象存储差异,部署环境切换零代码改动 |
|
||||
| targetType + targetId 多态关联 | 一张 file_attachments 表服务多业务实体,避免冗余表 |
|
||||
| cacheFn 缓存 getFileAttachment | 文件元数据高频读取(预览/列表),TTL 300s 降低 DB 压力 |
|
||||
| Hooks 按操作粒度拆分 | upload/batch/preview 各自独立,避免单 Hook 超 80 行建议上限 |
|
||||
| 上传校验三段式(权限→Zod→MIME/size) | 防御性编程,权限失败快速短路,避免无谓 IO |
|
||||
299
src/modules/grades/README.md
Normal file
299
src/modules/grades/README.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# grades 成绩模块
|
||||
|
||||
> 成绩全生命周期管理:录入(单条/批量/按试卷)/导入导出/分析统计/草稿锁定/申诉处理/成绩单生成
|
||||
> 代码规模: 60 文件 · 360 符号 · 120,932 行(arch.db)
|
||||
> 模块路径: `src/modules/grades/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
BatchEntry["batch-grade-entry<br/>(376 行) 批量录入"]
|
||||
ExcelDlg["excel-import-dialog<br/>(519 行) Excel 导入"]
|
||||
RecordList["grade-record-list<br/>(232 行) 成绩列表"]
|
||||
RecordForm["grade-record-form<br/>(196 行) 录入表单"]
|
||||
ReportCard["report-card-view<br/>(324 行) 成绩单"]
|
||||
Charts["分析图表<br/>distribution/comparison/<br/>trend/mastery/growth"]
|
||||
DraftBanner["draft-lock-banner<br/>草稿锁定横幅"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseUndo["use-batch-grade-entry-undo<br/>(101 行) 批量撤销"]
|
||||
UseLock["use-draft-lock<br/>(185 行) 锁定状态"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端 Actions(6 文件)"]
|
||||
Actions["actions.ts<br/>(745 行) 核心 CRUD"]
|
||||
ActAnalytics["actions-analytics<br/>(353 行) 分析"]
|
||||
ActImport["actions-import<br/>(295 行) 导入"]
|
||||
ActAppeal["actions-appeal<br/>(215 行) 申诉"]
|
||||
ActLock["actions-lock<br/>(209 行) 锁定"]
|
||||
ActDraft["actions-draft<br/>(123 行) 草稿"]
|
||||
end
|
||||
|
||||
subgraph DA["Data Access"]
|
||||
DA1["data-access.ts<br/>(635 行) 核心 CRUD"]
|
||||
DADraft["data-access-drafts<br/>(410 行) 草稿"]
|
||||
DAAppeal["data-access-appeals<br/>(273 行) 申诉"]
|
||||
DARank["data-access-ranking<br/>(199 行) 排名"]
|
||||
DAExam["data-access-exam-entry<br/>(190 行) 按试卷录入"]
|
||||
DAAnalytics["analytics 4 维度<br/>class(390)/overview(136)<br/>student(262)/trend(135)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Stats["stats-service.ts<br/>(696 行) 统计纯函数"]
|
||||
StatsTest["stats-service.test.ts<br/>(665 行) 单测"]
|
||||
Scope["lib/scope-check.ts<br/>assertClassInScope"]
|
||||
ScopeFilter["lib/scope-filter.ts"]
|
||||
Report["lib/report-card.ts<br/>(385 行) 成绩单生成"]
|
||||
Export["export.ts + import-export.ts<br/>导出/导入"]
|
||||
Notify["lib/notify.ts<br/>(188 行) 通知"]
|
||||
Guards["lib/type-guards.ts"]
|
||||
GradeUtils["lib/grade-utils.ts"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
GR[(grade_records)]
|
||||
GRD[(grade_record_drafts)]
|
||||
GA[(grade_appeals)]
|
||||
GL[(grade_locks)]
|
||||
end
|
||||
|
||||
subgraph Cross["跨模块联动"]
|
||||
Diagnostic["diagnostic<br/>updateMasteryFromExamScore"]
|
||||
Exams["exams<br/>getExamForGradeEntry"]
|
||||
ErrorBook["error-book<br/>collectFromExamSubmission"]
|
||||
end
|
||||
|
||||
BatchEntry --> UseUndo
|
||||
BatchEntry --> Actions
|
||||
ExcelDlg --> ActImport
|
||||
RecordList --> Actions
|
||||
RecordForm --> Actions
|
||||
ReportCard --> Report
|
||||
Charts --> ActAnalytics
|
||||
DraftBanner --> UseLock
|
||||
UseLock --> ActLock
|
||||
|
||||
Actions --> DA1
|
||||
ActAnalytics --> DAAnalytics
|
||||
ActAnalytics --> Stats
|
||||
ActImport --> Export
|
||||
ActAppeal --> DAAppeal
|
||||
ActLock --> GL
|
||||
ActDraft --> DADraft
|
||||
DAExam --> DAExam2["data-access-exam-entry"]
|
||||
DA1 --> GR
|
||||
DADraft --> GRD
|
||||
DAAppeal --> GA
|
||||
DA1 --> Scope
|
||||
DA1 --> Notify
|
||||
|
||||
Actions --> Diagnostic
|
||||
DAExam --> Exams
|
||||
Actions --> ErrorBook
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 按试卷批量录入成绩(跨模块联动)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant BE as batch-grade-entry
|
||||
participant Act as actions.ts
|
||||
participant DAExam as data-access-exam-entry
|
||||
participant Ex as exams data-access
|
||||
participant Diag as diagnostic
|
||||
participant EB as error-book
|
||||
participant DB as MySQL
|
||||
|
||||
U->>BE: 选择试卷 + 班级
|
||||
BE->>Act: batchCreateByExamAction
|
||||
Act->>Act: requirePermission(GRADE_RECORD_MANAGE)
|
||||
Act->>Act: assertClassInScope(班级范围校验)
|
||||
Act->>Ex: getExamForGradeEntry(题目分值)
|
||||
Act->>DAExam: batchCreateGradeRecordsByExam
|
||||
DAExam->>DB: INSERT grade_records(批量)
|
||||
DAExam-->>Act: 成功
|
||||
|
||||
Act->>Diag: updateMasteryFromExamScore<br/>更新知识点掌握度
|
||||
Act->>EB: collectFromExamSubmission<br/>采集错题
|
||||
Act->>Act: notifyGradeEntered + notifyScoreAnomaly
|
||||
Act-->>BE: ActionState
|
||||
BE->>U: 支持撤销(undoBatchCreate)
|
||||
```
|
||||
|
||||
### 流程 2: 成绩分析(纯函数统计)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[选择班级/学生/学期] --> B[actions-analytics]
|
||||
B --> C{分析维度}
|
||||
C -->|班级统计| D[data-access-analytics-class<br/>390 行]
|
||||
C -->|全校概览| E[data-access-analytics-overview<br/>136 行]
|
||||
C -->|学生汇总| F[data-access-analytics-student<br/>262 行]
|
||||
C -->|趋势分析| G[data-access-analytics-trend<br/>135 行]
|
||||
D --> H[stats-service.ts<br/>696 行纯函数]
|
||||
E --> H
|
||||
F --> H
|
||||
G --> H
|
||||
H --> I[computeGradeStats<br/>average/median/stdDev<br/>passRate/excellentRate]
|
||||
I --> J[charts 渲染<br/>distribution/comparison/trend]
|
||||
```
|
||||
|
||||
### 流程 3: 草稿保存与成绩锁定
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant BE as batch-grade-entry
|
||||
participant Lock as use-draft-lock
|
||||
participant ActD as actions-draft
|
||||
participant ActL as actions-lock
|
||||
participant DAD as data-access-drafts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>BE: 录入中(未提交)
|
||||
BE->>ActD: saveDraftAction
|
||||
ActD->>DAD: upsertDraft
|
||||
DAD->>DB: UPSERT grade_record_drafts
|
||||
Note over U,DB: 草稿不写入 grade_records<br/>支持断点续录
|
||||
|
||||
U->>BE: 提交成绩
|
||||
BE->>ActD: commitDraftAction
|
||||
ActD->>DAD: 读取草稿 → 批量插入 grade_records
|
||||
ActD->>DAD: 删除草稿
|
||||
ActD-->>BE: 成功
|
||||
|
||||
U->>Lock: 锁定成绩(防篡改)
|
||||
Lock->>ActL: lockGradesAction
|
||||
ActL->>ActL: requirePermission(GRADE_LOCK)
|
||||
ActL->>DB: INSERT grade_locks
|
||||
ActL-->>Lock: 锁定后成绩只读
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | grade_records / drafts / appeals / locks 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(86 处) |
|
||||
| Server Action | requirePermission | GRADE_RECORD_MANAGE / GRADE_LOCK 等权限点 |
|
||||
| URL 状态 | nuqs | 分析页筛选条件 URL 同步 |
|
||||
| 表单验证 | Zod | schema.ts(238 行) 输入校验 |
|
||||
| 数据可视化 | recharts | 分布/对比/趋势/掌握度/成长档案图表 |
|
||||
| Excel 导入导出 | 自定义 export/import-export | 成绩导出 + Excel 模板导入 |
|
||||
| 统计计算 | stats-service 纯函数 | average/median/stdDev/passRate/excellentRate |
|
||||
| 权限范围 | scope-check | assertClassInScope 防越权访问他班成绩 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/grades/
|
||||
├─ actions.ts # 核心 CRUD Action (745 行): 成绩录入/更新/删除/查询
|
||||
├─ actions-analytics.ts # 分析 Action (353 行)
|
||||
├─ actions-import.ts # 导入 Action (295 行)
|
||||
├─ actions-appeal.ts # 申诉 Action (215 行)
|
||||
├─ actions-lock.ts # 锁定 Action (209 行)
|
||||
├─ actions-draft.ts # 草稿 Action (123 行)
|
||||
├─ data-access.ts # 核心 CRUD (635 行): grade_records
|
||||
├─ data-access-drafts.ts # 草稿数据访问 (410 行)
|
||||
├─ data-access-appeals.ts # 申诉数据访问 (273 行)
|
||||
├─ data-access-ranking.ts # 排名查询 (199 行)
|
||||
├─ data-access-exam-entry.ts # 按试卷录入 (190 行)
|
||||
├─ data-access-analytics.ts # 分析入口 (58 行)
|
||||
├─ data-access-analytics-{class,overview,student,trend}.ts # 四维度 (390/136/262/135)
|
||||
├─ stats-service.ts # 统计纯函数 (696 行)
|
||||
├─ stats-service.test.ts # 统计单测 (665 行)
|
||||
├─ export.ts # 导出 (291 行)
|
||||
├─ import-export.ts # 导入 (278 行)
|
||||
├─ schema.ts # Zod 验证 (238 行)
|
||||
├─ types.ts # 类型定义 (387 行)
|
||||
├─ lib/
|
||||
│ ├─ report-card.ts # 成绩单生成 (385 行)
|
||||
│ ├─ notify.ts # 通知 (188 行)
|
||||
│ ├─ scope-check.ts # 班级范围校验 (37 行)
|
||||
│ ├─ scope-filter.ts # 范围过滤 (52 行)
|
||||
│ ├─ scope-check.test.ts # 范围校验单测 (74 行)
|
||||
│ ├─ grade-utils.ts # 工具函数 (21 行)
|
||||
│ └─ type-guards.ts # 类型守卫 (30 行)
|
||||
├─ hooks/
|
||||
│ ├─ use-draft-lock.ts # 草稿/锁定状态 (185 行)
|
||||
│ └─ use-batch-grade-entry-undo.ts # 批量撤销 (101 行)
|
||||
└─ components/ # 28 个组件
|
||||
├─ excel-import-dialog.tsx # Excel 导入 (519 行)
|
||||
├─ batch-grade-entry.tsx # 批量录入 (376 行)
|
||||
├─ report-card-view.tsx # 成绩单 (324 行)
|
||||
├─ grade-record-detail.tsx # 详情 (239 行)
|
||||
├─ grade-record-list.tsx # 列表 (232 行)
|
||||
├─ grade-record-row.tsx # 行 (200 行)
|
||||
├─ grade-record-form.tsx # 表单 (196 行)
|
||||
├─ knowledge-point-mastery-chart.tsx # 知识点掌握度 (237 行)
|
||||
├─ class-comparison-chart.tsx # 班级对比 (260 行)
|
||||
├─ grade-distribution-chart.tsx # 分布 (210 行)
|
||||
└─ growth-archive-chart.tsx / grade-trend-card.tsx / ranking-trend-card.tsx / ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| exams | import | getExamForGradeEntry 按试卷录入 |
|
||||
| diagnostic | data-access-call + import | updateMasteryFromExamScore 联动掌握度 |
|
||||
| error-book | data-access-call + import | collectFromExamSubmission 采集错题 |
|
||||
| classes | data-access-call + import | 班级信息 + 范围校验 |
|
||||
| school | data-access-call + import | 学科/年级/学年过滤 |
|
||||
| users | data-access-call + import | 学生/教师身份 |
|
||||
| notifications | data-access-call + import | 成绩录入/异常通知 |
|
||||
| parent | data-access-call + import | 成绩单推送家长 |
|
||||
| attendance | data-access-call | 考勤关联成绩分析 |
|
||||
| shared | import | DB / cache / auth-guard / 工具函数 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| attendance | data-access-call + import | 考勤模块引用成绩分析 |
|
||||
| diagnostic | import | 诊断引用成绩数据 |
|
||||
| parent | data-access-call + import | 家长端查看成绩 |
|
||||
| school | import | 学校维度成绩统计 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **actions 按职责拆 6 文件**: actions.ts(745 行)近上限,按 CRUD/分析/导入/申诉/锁定/草稿拆分,避免超 1000 行
|
||||
- **data-access 按职责 + 维度拆分**: 主 CRUD + 草稿/申诉/排名/试卷录入 + analytics 四维度(class/overview/student/trend)
|
||||
- **scope-check 统一范围校验**: `assertClassInScope` 确保班级在用户权限范围内,防止越权访问他班成绩
|
||||
- **type-guards 替代 as 断言**: 遵循禁止 as 断言规范,类型收窄通过类型守卫
|
||||
- **录入联动诊断与错题**: 成绩录入后调用 `updateMasteryFromExamScore` 更新掌握度 + `collectFromExamSubmission` 采集错题
|
||||
- **stats-service 纯函数 + 单测**: 696 行统计逻辑独立 + 665 行单测,保证计算正确且可独立测试
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| actions 按职责拆 6 文件 | 成绩场景多(CRUD/分析/导入/申诉/锁定/草稿),拆分避免单文件超 1000 行硬上限 |
|
||||
| stats-service 独立纯函数 | 统计逻辑与 DB I/O 解耦,696 行纯函数 + 665 行单测,保证计算正确可复用 |
|
||||
| analytics 按维度拆 4 文件 | 班级/概览/学生/趋势四维度独立,按场景加载,避免单文件臃肿 |
|
||||
| scope-check 集中范围校验 | 防越权访问他班成绩,权限校验收口单一入口,assertClassInScope 返回错误码 |
|
||||
| type-guards 替代 as 断言 | 遵循编码规范禁止 as,类型收窄通过类型守卫实现 |
|
||||
| 录入联动 diagnostic/error-book | 成绩是掌握度计算与错题采集的源头,录入时同步触发,保证数据一致 |
|
||||
289
src/modules/homework/README.md
Normal file
289
src/modules/homework/README.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# homework 作业模块
|
||||
|
||||
> 作业全生命周期:下发/学生作答/教师批改(含扫描图批改)/统计,串联考试与诊断闭环
|
||||
> 代码规模: 39 文件 · 229 符号 · 81,774 行(arch.db)
|
||||
> 模块路径: `src/modules/homework/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
TakeView["homework-take-view<br/>(331 行) 学生作答"]
|
||||
QRenderer["question-renderer<br/>(362 行) 题目渲染"]
|
||||
TakeSidebar["homework-take-sidebar<br/>(184 行)"]
|
||||
AssignForm["homework-assignment-form<br/>(241 行) 下发表单"]
|
||||
GradePanel["homework-grading-panel<br/>(258 行) 批改"]
|
||||
GradeView["homework-grading-view<br/>+ batch-grading-view"]
|
||||
ScanGrade["homework-scan-grading-view<br/>(275 行) 扫描批改"]
|
||||
ScanUploader["scan-uploader<br/>(269 行)"]
|
||||
ScanViewer["scan-image-viewer<br/>(211 行)"]
|
||||
Review["student-homework-review-view<br/>(223 行)"]
|
||||
Excellent["excellent-submissions<br/>(208 行)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseAutoSave["use-debounced-auto-save<br/>(208 行) 防抖保存"]
|
||||
UseCountdown["use-exam-countdown<br/>(123 行) 倒计时"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端 Actions"]
|
||||
Actions["actions.ts<br/>(723 行) 单文件编排"]
|
||||
end
|
||||
|
||||
subgraph DA["Data Access(按职责拆 8 文件)"]
|
||||
DA1["data-access.ts<br/>(757 行) barrel + 读"]
|
||||
DAWrite["data-access-write<br/>(526 行) 写入"]
|
||||
DAStudent["data-access-student<br/>(349 行)"]
|
||||
DAClasses["data-access-classes<br/>(341 行) 对外供 classes"]
|
||||
DAErr["data-access-error-collection<br/>(150 行)"]
|
||||
DAExamCross["data-access-exam-cross<br/>(146 行)"]
|
||||
DAScans["data-access-scans<br/>(57 行)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Stats["stats-service.ts<br/>(490 行) 统计聚合"]
|
||||
QUtils["lib/question-content-utils<br/>(361 行) 题目内容处理"]
|
||||
QUtilsTest["question-content-utils.test<br/>(434 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
HA[(homeworkAssignments)]
|
||||
HQ[(homeworkQuestions)]
|
||||
HT[(homeworkAssignmentTargets)]
|
||||
HS[(homeworkSubmissions)]
|
||||
HA2[(homeworkAnswers)]
|
||||
Scans[(homeworkScans)]
|
||||
end
|
||||
|
||||
subgraph Cross["跨模块闭环"]
|
||||
ErrorBook["error-book<br/>collectFromHomeworkSubmission"]
|
||||
Diagnostic["diagnostic<br/>updateMasteryFromHomeworkSubmission"]
|
||||
Exams["exams data-access<br/>getExamWithQuestionsForHomework"]
|
||||
Classes["classes data-access<br/>getStudentIdsByClassId"]
|
||||
end
|
||||
|
||||
TakeView --> QRenderer
|
||||
TakeView --> TakeSidebar
|
||||
TakeView --> UseAutoSave
|
||||
TakeView --> UseCountdown
|
||||
UseAutoSave --> Actions
|
||||
AssignForm --> Actions
|
||||
GradePanel --> Actions
|
||||
ScanGrade --> ScanUploader
|
||||
ScanGrade --> ScanViewer
|
||||
Review --> Actions
|
||||
Excellent --> Stats
|
||||
|
||||
Actions --> DAWrite
|
||||
Actions --> DA1
|
||||
Actions --> DAScans
|
||||
DAWrite --> HA
|
||||
DAWrite --> HQ
|
||||
DAWrite --> HT
|
||||
DAWrite --> HS
|
||||
DAWrite --> HA2
|
||||
DAScans --> Scans
|
||||
DAClasses --> HA
|
||||
Stats --> DA1
|
||||
|
||||
Actions --> ErrorBook
|
||||
Actions --> Diagnostic
|
||||
DAWrite --> Exams
|
||||
DAWrite --> Classes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 教师下发作业 + 学生作答(防抖自动保存)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant S as 学生
|
||||
participant AF as assignment-form
|
||||
participant TV as take-view
|
||||
participant AS as use-debounced-auto-save
|
||||
participant Act as actions.ts
|
||||
participant DAW as data-access-write
|
||||
participant DB as MySQL
|
||||
|
||||
T->>AF: 填写作业(关联试卷/班级/截止)
|
||||
AF->>Act: createHomeworkAssignmentAction
|
||||
Act->>Act: requirePermission(HOMEWORK_CREATE)
|
||||
Act->>DAW: getExamWithQuestionsForHomework(获取题目)
|
||||
Act->>DAW: createHomeworkAssignment
|
||||
DAW->>DB: INSERT homeworkAssignments/Questions/Targets
|
||||
Act-->>AF: ActionState<{assignmentId}>
|
||||
|
||||
S->>TV: 开始作答
|
||||
TV->>Act: startHomeworkSubmissionAction
|
||||
Act->>DAW: startHomeworkSubmission(INSERT submissions)
|
||||
TV->>AS: 输入答案(debounce)
|
||||
AS->>Act: saveHomeworkAnswerAction
|
||||
Act->>DAW: saveHomeworkAnswer(逐题 UPSERT)
|
||||
S->>TV: 提交
|
||||
TV->>Act: markHomeworkSubmittedAction
|
||||
Act->>DAW: markHomeworkSubmitted(更新状态)
|
||||
```
|
||||
|
||||
### 流程 2: 教师批改 + 闭环回流(错题/掌握度)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant GP as grading-panel
|
||||
participant Act as actions.ts
|
||||
participant DAW as data-access-write
|
||||
participant EB as error-book
|
||||
participant Diag as diagnostic
|
||||
participant DB as MySQL
|
||||
|
||||
T->>GP: 批改答案(逐题给分)
|
||||
GP->>Act: gradeHomeworkAnswersAction
|
||||
Act->>Act: requirePermission(HOMEWORK_GRADE)
|
||||
Act->>DAW: gradeHomeworkAnswers
|
||||
DAW->>DB: UPDATE homeworkAnswers SET score
|
||||
DAW->>DB: UPDATE submissions SET status=graded
|
||||
DAW-->>Act: 成功
|
||||
|
||||
par 并行闭环(Promise.allSettled)
|
||||
Act->>EB: collectFromHomeworkSubmission<br/>采集错题
|
||||
and
|
||||
Act->>Diag: updateMasteryFromHomeworkSubmission<br/>更新掌握度
|
||||
end
|
||||
Note over Act,Diag: 容错:一个失败不影响另一个<br/>错误记录但不抛出
|
||||
Act-->>GP: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 扫描批改(纸质作业数字化)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[scan-uploader<br/>上传扫描图] --> B[files 模块存储]
|
||||
B --> C[data-access-scans<br/>记录扫描关联]
|
||||
C --> D[homework-scan-grading-view<br/>扫描批改视图]
|
||||
D --> E[scan-image-viewer<br/>在图上标注得分]
|
||||
E --> F[grading-toolbar<br/>给分工具]
|
||||
F --> G[actions.ts<br/>gradeHomeworkAnswers]
|
||||
G --> H[data-access-write<br/>落库成绩]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | homeworkAssignments/Questions/Targets/Submissions/Answers/Scans 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(72 处) |
|
||||
| Server Action | requirePermission | HOMEWORK_CREATE / HOMEWORK_GRADE 等权限点 |
|
||||
| 表单验证 | Zod | schema.ts(69 行) 作业表单校验 |
|
||||
| 防抖保存 | 自定义 use-debounced-auto-save | 作答过程防抖自动保存,防数据丢失 |
|
||||
| 倒计时 | use-exam-countdown | 作业/考试倒计时 |
|
||||
| 数据可视化 | recharts | stats-service 统计图表 |
|
||||
| 跨模块编排 | Promise.allSettled | 批改后并行触发错题采集 + 掌握度更新 |
|
||||
| 国际化 | next-intl | getTranslations + translateZodErrors |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/homework/
|
||||
├─ actions.ts # Server Actions (723 行): 下发/作答/批改/统计编排
|
||||
├─ data-access.ts # barrel + 读 (757 行): 作业列表/详情/优秀作业
|
||||
├─ data-access-write.ts # 写入 (526 行): 创建/作答/提交/批改
|
||||
├─ data-access-student.ts # 学生视角 (349 行)
|
||||
├─ data-access-classes.ts # 班级视角对外接口 (341 行): 供 classes 模块调用
|
||||
├─ data-access-error-collection.ts # 错题采集 (150 行)
|
||||
├─ data-access-exam-cross.ts # exam 跨模块 (146 行)
|
||||
├─ data-access-scans.ts # 扫描记录 (57 行)
|
||||
├─ data-access-utils.ts # 工具 (10 行)
|
||||
├─ stats-service.ts # 统计聚合 (490 行)
|
||||
├─ schema.ts # Zod 验证 (69 行)
|
||||
├─ types.ts # 类型定义 (298 行): HomeworkAssignment/Submission 状态机
|
||||
├─ lib/
|
||||
│ ├─ question-content-utils.ts # 题目内容处理 (361 行)
|
||||
│ └─ question-content-utils.test.ts # 单测 (434 行)
|
||||
├─ hooks/
|
||||
│ ├─ use-debounced-auto-save.ts # 防抖自动保存 (208 行)
|
||||
│ └─ use-exam-countdown.ts # 倒计时 (123 行)
|
||||
└─ components/ # 23 个组件
|
||||
├─ question-renderer.tsx # 题目渲染 (362 行)
|
||||
├─ homework-take-view.tsx # 学生作答 (331 行)
|
||||
├─ homework-scan-grading-view.tsx # 扫描批改 (275 行)
|
||||
├─ scan-uploader.tsx # 扫描上传 (269 行)
|
||||
├─ homework-grading-panel.tsx # 批改面板 (258 行)
|
||||
├─ homework-assignment-form.tsx # 下发表单 (241 行)
|
||||
├─ homework-submission-result.tsx # 提交结果 (235 行)
|
||||
├─ student-homework-review-view.tsx # 学生回顾 (223 行)
|
||||
├─ homework-grading-toolbar.tsx # 批改工具栏 (216 行)
|
||||
├─ scan-image-viewer.tsx # 扫描查看 (211 行)
|
||||
├─ excellent-submissions.tsx # 优秀作业 (208 行)
|
||||
├─ homework-take-sidebar.tsx # 作答侧栏 (184 行)
|
||||
├─ homework-batch-grading-view.tsx # 批量批改 (178 行)
|
||||
└─ homework-grading-view.tsx # 批改视图 (171 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| exams | import | getExamWithQuestionsForHomework 获取试卷题目 |
|
||||
| classes | data-access-call + import | getStudentIdsByClassId 解析学生范围 |
|
||||
| school | data-access-call + import | 学科名称解析 |
|
||||
| users | data-access-call + import | 学生/教师身份 |
|
||||
| files | data-access-call | 扫描图存储 |
|
||||
| diagnostic | data-access-call + import | updateMasteryFromHomeworkSubmission 掌握度更新 |
|
||||
| error-book | data-access-call + import | collectFromHomeworkSubmission 错题采集 |
|
||||
| notifications | data-access-call | 作业下发/批改通知 |
|
||||
| ai | import | AI 辅助批改 |
|
||||
| shared | import | DB / cache / auth-guard / 工具函数 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | data-access-classes 作业聚合视图 |
|
||||
| lesson-preparation | data-access-call + import | 备课发布作业时创建 assignment |
|
||||
| dashboard | data-access-call + import | 首页作业统计 |
|
||||
| parent | data-access-call + import | 家长查看孩子作业 |
|
||||
| settings | data-access-call + import | 作业设置 |
|
||||
| diagnostic | import | 诊断引用作业数据 |
|
||||
| error-book | import | 错题本引用作业 |
|
||||
| exams | import | 试卷引用作业 |
|
||||
| shared | import | 共享类型 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **Server Action 全部 requirePermission**: 权限点包括 HOMEWORK_CREATE / HOMEWORK_GRADE / HOMEWORK_SUBMIT
|
||||
- **data-access 按职责拆 8 文件**: write(526)/student(349)/classes(341)/error-collection(150)/exam-cross(146)/scans(57)/utils(10) + barrel(757),避免单文件超限
|
||||
- **作业关联试卷走 exams data-access**: 不直查 exams 表,通过 getExamWithQuestionsForHomework
|
||||
- **学生范围走 classes data-access**: 通过 getStudentIdsByClassId 解析,不直查 classEnrollments
|
||||
- **批改后跨模块闭环**: Promise.allSettled 并行触发 error-book + diagnostic,一个失败不影响另一个
|
||||
- **data-access-classes 对外收口**: classes 模块的作业聚合视图通过此文件,保持三层架构不直查 homework 表
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| data-access 按写/读/领域拆 8 文件 | 原单文件超限,按 write/student/classes/scans/error-collection 等职责拆分 |
|
||||
| 作答防抖自动保存 | use-debounced-auto-save 防止学生作答因断网/刷新丢失 |
|
||||
| 批改后 Promise.allSettled 闭环 | 错题采集与掌握度更新并行,容错隔离,不阻塞批改返回 |
|
||||
| 扫描批改独立视图 | scan-uploader + scan-image-viewer 支持纸质作业数字化批改 |
|
||||
| data-access-classes 对外接口 | classes 模块作业聚合视图收口,保持三层架构,classes 不直查 homework 表 |
|
||||
| actions.ts 单文件 723 行 | 下发/作答/批改逻辑紧密耦合,未强行拆分,仍在 1000 行硬上限内 |
|
||||
188
src/modules/invitation-codes/README.md
Normal file
188
src/modules/invitation-codes/README.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# invitation-codes 邀请码模块
|
||||
|
||||
> 邀请码批量生成(安全字符集 + 唯一性保证)、校验、事务消费,支持注册时绑定角色/班级/邮箱限定
|
||||
> 代码规模: 8 文件 · 42 符号 · 8,199 行(arch.db)
|
||||
> 模块路径: `src/modules/invitation-codes/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
View["invitation-codes-view.tsx<br/>(306 行, 列表 + 操作)"]
|
||||
GenDlg["generate-invitation-codes-dialog.tsx<br/>(391 行, 生成对话框)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(158 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(261 行, CRUD + 校验/消费)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Gen["lib/code-generator.ts<br/>(67 行, 纯函数)"]
|
||||
Guards["lib/type-guards.ts<br/>(19 行)"]
|
||||
end
|
||||
|
||||
subgraph Consumer["消费方"]
|
||||
Auth["auth 模块注册流程"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Codes[(invitation_codes)]
|
||||
end
|
||||
|
||||
View --> GenDlg
|
||||
GenDlg --> Actions
|
||||
View --> Actions
|
||||
Actions --> DA
|
||||
DA --> Gen
|
||||
DA --> Guards
|
||||
DA --> Codes
|
||||
Auth -.->|validateInvitationCode + consumeInvitationCode| DA
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 批量生成邀请码
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as 管理员
|
||||
participant Dlg as generate-dialog
|
||||
participant Act as actions.ts
|
||||
participant Gen as code-generator
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
A->>Dlg: 填写 count/role/email?/classId?/expiresAt?
|
||||
Dlg->>Act: generateInvitationCodesAction
|
||||
Act->>Act: requirePermission(USER_MANAGE)
|
||||
Act->>Act: GenerateInvitationCodesSchema.safeParse
|
||||
Act->>Gen: generateUniqueInvitationCodes(count)
|
||||
Gen->>Gen: randomBytes + Set 去重(最多 3x 重试)
|
||||
Gen->>Gen: generateBatchId
|
||||
Gen-->>Act: codes[] + batchId
|
||||
Act->>DA: generateInvitationCodes(data, userId)
|
||||
DA->>DB: INSERT invitation_codes (含 batchId)
|
||||
DA-->>Act: records(含明文 code)
|
||||
Act->>Act: trackEvent + invalidateFor
|
||||
Act-->>Dlg: ActionState<Result>(明文仅此一次)
|
||||
```
|
||||
|
||||
### 流程 2: 注册时校验 + 消费(事务)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 注册用户
|
||||
participant Auth as auth 模块
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Auth: 输入邀请码注册
|
||||
Auth->>DA: validateInvitationCode(code, email)
|
||||
DA->>DB: SELECT invitation_codes WHERE code
|
||||
alt 已使用/过期/邮箱不匹配
|
||||
DA-->>Auth: { valid: false, reason }
|
||||
else 有效
|
||||
DA-->>Auth: { valid: true, code: 脱敏记录 }
|
||||
end
|
||||
Auth->>Auth: 创建用户 + 角色
|
||||
Auth->>DA: consumeInvitationCode(code, userId)
|
||||
DA->>DB: 事务: UPDATE invitation_codes<br/>SET usedBy/usedAt WHERE usedBy IS NULL
|
||||
alt 受影响行数=0
|
||||
DA-->>Auth: 并发消费失败
|
||||
else
|
||||
DA-->>Auth: 消费成功
|
||||
end
|
||||
```
|
||||
|
||||
### 流程 3: 邀请码生命周期
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[管理员生成] --> B[未使用 unused]
|
||||
B -->|注册校验通过| C{消费}
|
||||
C -->|成功| D[已使用 used]
|
||||
C -->|并发抢占失败| B
|
||||
B -->|expiresAt 到期| E[已过期 expired]
|
||||
B -->|管理员删除| F[已删除]
|
||||
D --> G[注册用户绑定 role/classId]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | invitation_codes 表 CRUD + 事务消费 |
|
||||
| 缓存 | cacheFn + invalidateFor | 邀请码列表请求级去重,生成后失效 |
|
||||
| Server Action | requirePermission | USER_MANAGE 权限校验 |
|
||||
| 校验 | Zod | GenerateInvitationCodesSchema |
|
||||
| 安全随机 | node:crypto.randomBytes | 邀请码生成随机源,避免 Math.random 伪随机 |
|
||||
| 字符集 | SAFE_ALPHABET(去 I/O/0/1) | 8 位大写字母+数字,去除易混淆字符 |
|
||||
| 唯一性 | Set 去重 + 3x 重试 | 批量生成保证唯一,极端冲突抛错 |
|
||||
| 服务端隔离 | import "server-only" | data-access.ts 防止客户端引用 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/invitation-codes/
|
||||
├─ actions.ts # Server Actions (158 行): 生成/列表/删除/校验
|
||||
├─ data-access.ts # 数据访问层 (261 行): CRUD + validate + consume 事务
|
||||
├─ schema.ts # Zod 验证 (47 行)
|
||||
├─ types.ts # 类型定义 (65 行): InvitationRole/Record/Result
|
||||
├─ lib/
|
||||
│ ├─ code-generator.ts # 纯函数 (67 行): generateInvitationCode/Unique/batchId
|
||||
│ └─ type-guards.ts # 类型守卫 (19 行): toInvitationRole
|
||||
├─ components/
|
||||
│ ├─ generate-invitation-codes-dialog.tsx # 生成对话框 (391 行)
|
||||
│ └─ invitation-codes-view.tsx # 列表视图 (306 行)
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | DB / auth-guard / cache / track-event |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| (无显式 arch.db 记录) | — | auth 模块注册流程通过运行时调用 validateInvitationCode/consumeInvitationCode |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **明文 code 仅生成时返回一次**: DB 不存明文 hash(仅唯一约束),生成接口返回明文,后续接口仅返回脱敏记录
|
||||
- **安全字符集**: `SAFE_ALPHABET` 去除 I/O/0/1 易混淆字符,8 位长度,避免人工录入歧义
|
||||
- **`generateUniqueInvitationCodes` 数量上限 1000**: 防止批量生成攻击,Set 去重 + 3x 重试保证唯一
|
||||
- **校验与消费分离**: `validateInvitationCode` 不写库(只读校验),`consumeInvitationCode` 事务消费(WHERE usedBy IS NULL),避免注册流程中途失败导致码丢失
|
||||
- **角色字段经 `toInvitationRole` 守卫**: 禁止裸字符串,必须 student/teacher/parent/admin 之一
|
||||
- **data-access.ts 首行 `import "server-only"`**: 防止客户端组件误引用消费逻辑
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| code-generator 独立纯函数 lib | 生成算法无副作用,可独立单测与替换(如换用 nanoid) |
|
||||
| 校验与消费分离 | validate 只读不锁,consume 用 WHERE usedBy IS NULL 乐观锁,避免注册事务长锁 |
|
||||
| 明文 code 仅返回一次 | 安全考虑,DB 不存明文,即使 DB 泄露也无法反推有效码 |
|
||||
| SAFE_ALPHABET 去易混淆字符 | 提升人工录入体验,避免 I/O/0/1 视觉混淆 |
|
||||
| batchId 标记批次 | 同批生成共享 batchId,便于审计追踪与批量撤销 |
|
||||
180
src/modules/layout/README.md
Normal file
180
src/modules/layout/README.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# layout 布局模块
|
||||
|
||||
> 应用级布局壳:侧边栏(展开/收起 Context)、顶栏、角色驱动导航配置,纯客户端组件 + 配置外置
|
||||
> 代码规模: 4 文件 · 13 符号 · 2,948 行(arch.db)
|
||||
> 模块路径: `src/modules/layout/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph AppShell["app 路由壳(消费方)"]
|
||||
Layout["app/(dashboard)/layout.tsx"]
|
||||
end
|
||||
|
||||
subgraph Components["客户端组件"]
|
||||
direction TB
|
||||
Provider["sidebar-provider.tsx<br/>(128 行, Context + resize)"]
|
||||
Sidebar["app-sidebar.tsx<br/>(188 行, 导航渲染)"]
|
||||
Header["site-header.tsx<br/>(176 行, 顶栏)"]
|
||||
end
|
||||
|
||||
subgraph Config["配置"]
|
||||
Nav["config/navigation.ts<br/>(484 行, NAV_CONFIG)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["shared/hooks"]
|
||||
UsePerm["usePermission<br/>(权限过滤)"]
|
||||
end
|
||||
|
||||
subgraph Ext["跨模块组件"]
|
||||
MsgBadge["messaging/unread-message-badge"]
|
||||
Notif["notifications 组件"]
|
||||
end
|
||||
|
||||
Layout --> Provider
|
||||
Provider --> Sidebar
|
||||
Provider --> Header
|
||||
Sidebar --> UsePerm
|
||||
Sidebar --> Nav
|
||||
Sidebar --> MsgBadge
|
||||
Header --> Notif
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 侧边栏状态管理(Context + 性能优化)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Provider as SidebarProvider
|
||||
participant Ctx as React Context
|
||||
participant Sidebar as app-sidebar
|
||||
participant Header as site-header
|
||||
|
||||
U->>Provider: 进入页面
|
||||
Provider->>Provider: useState(expanded=true)
|
||||
Provider->>Provider: useState(isMobile)
|
||||
Provider->>Provider: useEffect resize(debounce 200ms)
|
||||
Provider->>Ctx: useMemo value(expanded, isMobile)
|
||||
Ctx-->>Sidebar: useSidebar()
|
||||
Ctx-->>Header: useSidebar()
|
||||
U->>Sidebar: 点击 toggle
|
||||
Sidebar->>Ctx: toggleSidebar(useCallback)
|
||||
Ctx->>Provider: setExpanded
|
||||
Provider->>Ctx: 重新 useMemo
|
||||
Ctx-->>Sidebar: expanded 更新
|
||||
```
|
||||
|
||||
### 流程 2: 角色检测 + 导航项过滤
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[usePermission] --> B[detectAutoRole]
|
||||
B --> C{优先级判断}
|
||||
C -->|admin| D[role=admin]
|
||||
C -->|student| E[role=student]
|
||||
C -->|parent| F[role=parent]
|
||||
C -->|其他| G[role=teacher<br/>grade_head/teaching_head 归入]
|
||||
D --> H[NAV_CONFIG admin]
|
||||
E --> I[NAV_CONFIG student]
|
||||
F --> J[NAV_CONFIG parent]
|
||||
G --> K[NAV_CONFIG teacher]
|
||||
H --> L[filter by permission]
|
||||
I --> L
|
||||
J --> L
|
||||
K --> L
|
||||
L --> M[渲染 navItems]
|
||||
```
|
||||
|
||||
### 流程 3: 导航配置驱动渲染
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[NAV_CONFIG 配置] --> B[role → NavItem[] 映射]
|
||||
B --> C[COMMON_NAV_ITEMS 公共项]
|
||||
C --> D[角色专属项]
|
||||
D --> E[NavItem.title i18n key]
|
||||
E --> F[NavItem.permission 权限点]
|
||||
F --> G[usePermission 过滤]
|
||||
G --> H[Collapsible 折叠组]
|
||||
H --> I[Tooltip 收起态提示]
|
||||
I --> J[Link href 跳转]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 状态管理 | React Context + useState | 侧边栏 expanded/isMobile 状态 |
|
||||
| 性能优化 | useMemo + useCallback | 避免不必要子组件重渲染 |
|
||||
| 响应式 | resize 监听 + debounce 200ms | 计算 isMobile 断点 |
|
||||
| 国际化 | next-intl useTranslations | nav 命名空间翻译 |
|
||||
| 路由 | next/navigation usePathname | 当前路径高亮 |
|
||||
| 权限 | usePermission + permissions.includes | NavItem.permission 过滤 |
|
||||
| UI 组件 | shadcn/ui Collapsible/Tooltip/ScrollArea | 折叠组/提示/滚动区 |
|
||||
| 图标 | lucide-react | NavItem.icon |
|
||||
| 配置驱动 | NAV_CONFIG + COMMON_NAV_ITEMS | 导航项集中管理 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/layout/
|
||||
├─ components/
|
||||
│ ├─ sidebar-provider.tsx # Context Provider (128 行): expanded/isMobile/toggle
|
||||
│ ├─ app-sidebar.tsx # 侧边栏 (188 行): 角色检测 + 导航渲染
|
||||
│ └─ site-header.tsx # 顶栏 (176 行)
|
||||
├─ config/
|
||||
│ └─ navigation.ts # 导航配置 (484 行): NAV_CONFIG + COMMON_NAV_ITEMS
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| messaging | import | UnreadMessageBadge 未读消息徽章 |
|
||||
| notifications | import | 顶栏通知组件 |
|
||||
| shared | import | usePermission / shadcn ui / utils / Permissions 类型 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| app/(dashboard)/layout.tsx | import | 应用路由壳消费 SidebarProvider + AppSidebar + SiteHeader |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **纯客户端组件**: 3 个组件均 `"use client"`,无 actions/data-access,状态不持久化到服务端
|
||||
- **严禁 `role === "xxx"` 硬编码**: 角色检测用 `usePermission().hasRole()`,导航项过滤用 `permissions.includes(item.permission)`
|
||||
- **grade_head/teaching_head 归入 teacher**: detectAutoRole 优先级 admin > student > parent > teacher,班主任功能通过权限点(GRADE_MANAGE)动态显示
|
||||
- **Context value 必须 useMemo**: 依赖 expanded/isMobile,避免每次渲染创建新对象导致子组件全量重渲染
|
||||
- **toggleSidebar 必须 useCallback**: 避免每次渲染创建新函数引用
|
||||
- **导航配置外置**: NAV_CONFIG 集中管理,新增菜单项只改 config/navigation.ts,不改组件
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| Context + useMemo + useCallback | 侧边栏频繁 toggle,性能优化避免子组件全量重渲染 |
|
||||
| 导航配置外置 config/navigation.ts | 集中管理菜单项,便于权限点联动与 i18n 维护 |
|
||||
| COMMON_NAV_ITEMS 公共项复用 | 新增角色只需在 NAV_CONFIG 引用公共项,避免复制粘贴 |
|
||||
| detectAutoRole 优先级而非显式切换 | 自动检测当前主角色,grade_head 等通过权限点动态显示,无需手动切换 |
|
||||
| resize debounce 200ms | 避免频繁重算 isMobile,平衡响应性与性能 |
|
||||
208
src/modules/leave-requests/README.md
Normal file
208
src/modules/leave-requests/README.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# leave-requests 请假模块
|
||||
|
||||
> 学生请假申请、教师审批、审批通过后单向同步考勤豁免,支持家长代子女与学生本人两种提交路径
|
||||
> 代码规模: 9 文件 · 53 符号 · 10,105 行(arch.db)
|
||||
> 模块路径: `src/modules/leave-requests/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
ReqForm["leave-request-form.tsx<br/>(196 行, 申请表单)"]
|
||||
ReqList["leave-request-list.tsx<br/>(118 行, 我的请假)"]
|
||||
ReviewDlg["leave-review-dialog.tsx<br/>(162 行, 审批对话框)"]
|
||||
ReviewList["leave-review-list.tsx<br/>(119 行, 待审批列表)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(252 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(358 行, CRUD + 重叠校验)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Guards["lib/type-guards.ts<br/>(37 行, toLeaveType/toLeaveStatus)"]
|
||||
end
|
||||
|
||||
subgraph Ext["跨模块 data-access"]
|
||||
Parent["parent.verifyParentChildRelation"]
|
||||
Classes["classes.getStudentActiveClass"]
|
||||
Attendance["attendance.syncExcusedFromLeaveRequest"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Leaves[(leave_requests)]
|
||||
end
|
||||
|
||||
ReqForm --> Actions
|
||||
ReqList --> Actions
|
||||
ReviewDlg --> Actions
|
||||
ReviewList --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Parent
|
||||
Actions --> Classes
|
||||
Actions --> Attendance
|
||||
DA --> Leaves
|
||||
DA --> Guards
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 提交请假申请(含多重校验)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 家长/学生
|
||||
participant Form as leave-request-form
|
||||
participant Act as actions.ts
|
||||
participant Parent as parent DA
|
||||
participant Class as classes DA
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写请假表单
|
||||
Form->>Act: createLeaveRequestAction
|
||||
Act->>Act: requirePermission(LEAVE_REQUEST_CREATE)
|
||||
Act->>Act: CreateLeaveRequestSchema.safeParse
|
||||
Act->>Parent: verifyParentChildRelation(studentId, requesterId)
|
||||
Parent-->>Act: 关系校验结果
|
||||
alt 关系不通过
|
||||
Act-->>Form: 错误 noRelation
|
||||
end
|
||||
Act->>Class: getStudentActiveClass(studentId)
|
||||
Class-->>Act: activeClass
|
||||
alt classId 不匹配
|
||||
Act-->>Form: 错误 classMismatch
|
||||
end
|
||||
Act->>DA: hasOverlappingLeave(studentId, start, end)
|
||||
alt 时间重叠
|
||||
Act-->>Form: 错误 overlappingLeave
|
||||
end
|
||||
Act->>DA: createLeaveRequest
|
||||
DA->>DB: INSERT leave_requests
|
||||
Act->>Act: invalidateFor + trackEvent
|
||||
Act-->>Form: ActionState<id>
|
||||
```
|
||||
|
||||
### 流程 2: 教师审批 + 考勤同步
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Dlg as leave-review-dialog
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Att as attendance DA
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Dlg: 审批(approved/rejected)
|
||||
Dlg->>Act: reviewLeaveRequestAction
|
||||
Act->>Act: requirePermission(LEAVE_REVIEW)
|
||||
Act->>DA: reviewLeaveRequest(status, comment)
|
||||
DA->>DB: UPDATE leave_requests SET status
|
||||
alt 审批通过
|
||||
Act->>Att: syncExcusedFromLeaveRequest(leaveId)
|
||||
Att->>DB: INSERT attendance_records (status=excused)
|
||||
Att-->>Act: 同步完成
|
||||
Act->>DA: markAttendanceSynced(leaveId)
|
||||
DA->>DB: UPDATE leave_requests SET attendanceSynced=true
|
||||
end
|
||||
Act->>Act: invalidateFor + trackEvent
|
||||
Act-->>Dlg: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 请假状态机
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[pending 待审批] -->|approved| B[approved 已通过]
|
||||
A -->|rejected| C[rejected 已拒绝]
|
||||
A -->|requester 取消| D[cancelled 已取消]
|
||||
B -->|requester 取消| D
|
||||
B -->|审批通过| E[同步考勤 excused]
|
||||
E --> F[attendanceSynced=true]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | leave_requests 表 CRUD + 分页 + 重叠校验 |
|
||||
| 缓存 | cacheFn + invalidateFor | 请假查询请求级去重,写操作触发缓存失效 |
|
||||
| Server Action | requirePermission | LEAVE_REQUEST_CREATE / LEAVE_REVIEW 权限校验 |
|
||||
| 校验 | Zod | CreateLeaveRequestSchema / ReviewLeaveRequestSchema |
|
||||
| 国际化 | next-intl/server getTranslations | 错误消息本地化 |
|
||||
| 类型守卫 | lib/type-guards.ts | toLeaveType / toLeaveStatus 守卫 DB 枚举字段 |
|
||||
| 跨模块调用 | data-access-call | parent / classes / attendance 模块协作 |
|
||||
| 服务端隔离 | import "server-only" | data-access.ts 防止客户端引用 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/leave-requests/
|
||||
├─ actions.ts # Server Actions (252 行): 申请/审批/撤销
|
||||
├─ data-access.ts # 数据访问层 (358 行): CRUD + hasOverlappingLeave
|
||||
├─ schema.ts # Zod 验证 (36 行)
|
||||
├─ types.ts # 类型定义 (64 行): LeaveType/LeaveStatus/状态机
|
||||
├─ lib/
|
||||
│ └─ type-guards.ts # 类型守卫 (37 行): toLeaveType / toLeaveStatus
|
||||
├─ components/
|
||||
│ ├─ leave-request-form.tsx # 申请表单 (196 行)
|
||||
│ ├─ leave-request-list.tsx # 我的请假列表 (118 行)
|
||||
│ ├─ leave-review-dialog.tsx # 审批对话框 (162 行)
|
||||
│ └─ leave-review-list.tsx # 待审批列表 (119 行)
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| parent | import + data-access-call | verifyParentChildRelation 校验家长代提交归属 |
|
||||
| classes | import + data-access-call | getStudentActiveClass 校验学生当前班级 |
|
||||
| attendance | import + data-access-call | syncExcusedFromLeaveRequest 审批通过后同步豁免 |
|
||||
| users | import + data-access-call | 学生/审批人姓名解析 |
|
||||
| shared | import | DB / auth-guard / cache / track-event |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| (无) | — | leave-requests 是终端业务模块,仅页面层调用 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **家长代提交必须先 `verifyParentChildRelation`**: 学生本人路径由调用方保证 requesterId === studentId,跨模块归属校验不可省略
|
||||
- **classId 防伪校验**: 必须通过 `getStudentActiveClass` 验证提交的 classId 确为学生当前所在班级,防止伪造班级 ID
|
||||
- **时间重叠校验前置**: `hasOverlappingLeave` 防止同学生日期范围内重复请假,在 INSERT 之前执行
|
||||
- **类型字段统一走 type-guards**: `toLeaveType` / `toLeaveStatus` 守卫 DB 枚举值,禁止裸字符串比较
|
||||
- **请假→考勤单向同步**: 审批通过后调用 `syncExcusedFromLeaveRequest` 写入 attendance_records(status=excused),并通过 `markAttendanceSynced` 避免重复同步
|
||||
- **data-access.ts 首行 `import "server-only"`**: 防止客户端组件误引用
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 请假→考勤单向同步而非双向耦合 | 两模块解耦,attendance 不反向依赖 leave-requests,便于独立演进 |
|
||||
| type-guards 独立 lib | DB 枚举值与领域类型隔离,扩展请假类型只改守卫与 schema |
|
||||
| 多重校验在 actions 层串行 | 关系→班级→时间三段校验,任一失败快速短路,避免无效写入 |
|
||||
| 状态机 pending/approved 可撤销 | 允许申请人在审批前/通过后撤销,体现真实业务流程 |
|
||||
| attendanceSynced 标志位 | 防止审批通过后考勤同步失败或重复同步,保证最终一致性 |
|
||||
298
src/modules/lesson-preparation/README.md
Normal file
298
src/modules/lesson-preparation/README.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# lesson-preparation 备课模块
|
||||
|
||||
> 以 Tiptap 富文本 + 节点树组织教案,覆盖模板、版本、AI 辅助、审核工作流、发布作业与协同审阅
|
||||
> 代码规模: 113 文件 · 751 符号 · 202,804 行(arch.db)
|
||||
> 模块路径: `src/modules/lesson-preparation/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Editor["lesson-plan-editor<br/>三列布局容器"]
|
||||
STree["structure-tree<br/>节点树"]
|
||||
Paper["paper-editor<br/>Tiptap 富文本"]
|
||||
Detail["detail-panel<br/>节点详情/qa-editor"]
|
||||
LList["lesson-plan-list<br/>课案列表"]
|
||||
Cal["calendar-view<br/>日历视图"]
|
||||
VHD["version-history-drawer<br/>版本历史"]
|
||||
PHD["publish-homework-dialog<br/>发布作业"]
|
||||
Blocks["blocks/*<br/>13 种教学块"]
|
||||
end
|
||||
|
||||
subgraph Provider["Context Provider"]
|
||||
LPP["lesson-plan-provider<br/>(425 行, 数据服务注入)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks + Zustand Slices"]
|
||||
UseEditor["use-lesson-plan-editor<br/>zustand store"]
|
||||
UsePersist["use-lesson-plan-persistence<br/>(156 行, 自动保存)"]
|
||||
UseAI["use-node-ai-assist<br/>(110 行)"]
|
||||
Slices["editor/history/selection/<br/>expanded/version-slice"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端 Actions"]
|
||||
Actions["actions.ts<br/>(695 行, 核心 CRUD)"]
|
||||
ActPub["actions-publish<br/>发布作业"]
|
||||
ActReview["actions-review<br/>协同审阅"]
|
||||
ActAI["actions-ai / ai-suggest<br/>AI 辅助"]
|
||||
ActDomain["actions-calendar/comments/<br/>formative/kp/questions/<br/>schedules/substitutes/<br/>analytics/attachments"]
|
||||
end
|
||||
|
||||
subgraph DA["Data Access"]
|
||||
DA1["data-access.ts<br/>(695 行)"]
|
||||
DAVer["data-access-versions<br/>(270 行)"]
|
||||
DATpl["data-access-templates<br/>(164 行)"]
|
||||
DADomain["data-access-{knowledge,<br/>review,calendar,comments,<br/>formative,schedules,<br/>substitutes,analytics,<br/>attachments,ai-evaluation}"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Guards["lib/type-guards<br/>(381 行) isXxxBlockData"]
|
||||
Mig["lib/document-migration<br/>(400 行) 版本迁移"]
|
||||
Export["lib/export<br/>(300 行)"]
|
||||
Diff["lib/document-diff<br/>+ version-diff"]
|
||||
AISrv["lib/ai-node-assist/<br/>ai-differentiation/<br/>ai-feedback"]
|
||||
Chk["lib/consistency-check<br/>+ curriculum-coverage"]
|
||||
PubSvc["publish-service<br/>(245 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
LP[(lesson_plans)]
|
||||
LPT[(lesson_plan_templates)]
|
||||
LPV[(lesson_plan_versions)]
|
||||
LPA[(lesson_plan_attachments)]
|
||||
LPC[(lesson_plan_comments)]
|
||||
LPS[(lesson_plan_schedules)]
|
||||
end
|
||||
|
||||
Editor --> STree
|
||||
Editor --> Paper
|
||||
Editor --> Detail
|
||||
Editor --> Blocks
|
||||
Editor --> LPP
|
||||
LList --> Actions
|
||||
Cal --> Actions
|
||||
VHD --> Actions
|
||||
PHD --> ActPub
|
||||
Blocks --> UseEditor
|
||||
Detail --> UseAI
|
||||
UsePersist --> UseEditor
|
||||
UseEditor --> Slices
|
||||
UsePersist --> LPP
|
||||
UseAI --> ActAI
|
||||
|
||||
LPP --> Actions
|
||||
ActPub --> PubSvc
|
||||
ActReview --> Actions
|
||||
ActAI --> AISrv
|
||||
ActDomain --> DADomain
|
||||
|
||||
Actions --> DA1
|
||||
Actions --> DAVer
|
||||
Actions --> DATpl
|
||||
DA1 --> LP
|
||||
DA1 --> LPT
|
||||
DAVer --> LPV
|
||||
DADomain --> LPA
|
||||
DADomain --> LPC
|
||||
DADomain --> LPS
|
||||
DA1 --> Guards
|
||||
DA1 --> Mig
|
||||
PubSvc --> Homework["homework data-access"]
|
||||
PubSvc --> Exams["exams data-access"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 教案编辑 + 自动保存 + 版本快照
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant Ed as lesson-plan-editor
|
||||
participant Store as zustand store
|
||||
participant Persist as use-lesson-plan-persistence
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Ed: 编辑节点(paper-editor / structure-tree)
|
||||
Ed->>Store: dispatch(editor-slice / history-slice)
|
||||
Store->>Store: isDirty = true
|
||||
Store->>Persist: debounce 3s 触发自动保存
|
||||
Persist->>Act: updateLessonPlanContentAction
|
||||
Act->>Act: requirePermission(LESSON_PLAN_READ)
|
||||
Act->>DA: updateLessonPlanContent
|
||||
DA->>DA: normalizeDocument + type-guards 校验
|
||||
DA->>DB: UPDATE lesson_plans SET content
|
||||
DA-->>Act: 成功
|
||||
Act-->>Persist: ActionState
|
||||
Persist->>Store: setSaving(false) / isDirty=false
|
||||
|
||||
Note over Persist,DB: 定时版本快照(30min)<br/>调用 createLessonPlanVersion<br/>写入 lesson_plan_versions
|
||||
```
|
||||
|
||||
### 流程 2: 审核工作流状态机
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Draft[draft 草稿] -->|submit| Submitted[submitted 已提交]
|
||||
Submitted -->|approve 审核通过| Approved[approved]
|
||||
Submitted -->|reject 驳回| Rejected[rejected]
|
||||
Approved -->|publish| Published[published 已发布]
|
||||
Approved -->|退回| Draft
|
||||
Published -->|撤回| Draft
|
||||
Rejected -->|修改重提| Draft
|
||||
Rejected -->|归档| Archived[archived]
|
||||
Draft -->|归档| Archived
|
||||
Archived -->|恢复| Draft
|
||||
|
||||
Note1["isEditableStatus: draft / rejected 可编辑"]:::note
|
||||
Note2["isPublishableStatus: 仅 approved 可发布"]:::note
|
||||
classDef note fill:#fef3c7,stroke:#92400e
|
||||
```
|
||||
|
||||
### 流程 3: 备课节点发布为作业(跨模块编排)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant PHD as publish-homework-dialog
|
||||
participant Act as actions-publish
|
||||
participant Pub as publish-service
|
||||
participant HW as homework data-access
|
||||
participant Ex as exams data-access
|
||||
participant DB as MySQL
|
||||
|
||||
U->>PHD: 选择班级 + 起止时间
|
||||
PHD->>Act: publishLessonPlanHomeworkAction
|
||||
Act->>Act: requirePermission(LESSON_PLAN_PUBLISH)
|
||||
Act->>Act: requirePermission(HOMEWORK_CREATE)
|
||||
Act->>Pub: publishLessonPlanHomework
|
||||
Pub->>Ex: 创建试卷(从课案 block 提取题目)
|
||||
Ex->>DB: INSERT exams
|
||||
Pub->>HW: 创建作业关联试卷 + 班级
|
||||
HW->>DB: INSERT assignments
|
||||
Pub-->>Act: { examId, assignmentId }
|
||||
Act->>Act: invalidateFor(publishHomework)
|
||||
Act-->>PHD: ActionState<{examId, assignmentId}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | lesson_plans / templates / versions / attachments / comments / schedules 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(148 处使用) |
|
||||
| 编辑器 | Tiptap | paper-editor 富文本编辑,节点内嵌块 |
|
||||
| 状态管理 | Zustand + slices | editor/history/selection/expanded/version 五个 slice |
|
||||
| 表单 | react-hook-form + Zod | schema.ts 输入校验 |
|
||||
| 国际化 | next-intl | getTranslations + lib/i18n-errors 翻译校验错误 |
|
||||
| 数据服务 | React Context | lesson-plan-provider 注入 DataService,便于 mock 测试 |
|
||||
| Server Action | requirePermission | LESSON_PLAN_READ/CREATE/PUBLISH 等权限点 |
|
||||
| 文档迁移 | 自定义 document-migration | V1→V2 升级,保证旧教案向后兼容 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/lesson-preparation/
|
||||
├─ actions.ts # 核心 Server Actions (695 行): 课案 CRUD + 版本编排
|
||||
├─ actions-publish.ts # 发布作业 Action
|
||||
├─ actions-review.ts # 协同审阅 Action
|
||||
├─ actions-ai.ts / ai-suggest.ts # AI 辅助 Action
|
||||
├─ actions-{calendar,comments,formative,kp,questions,schedules,substitutes,analytics,attachments,ai-evaluation}.ts
|
||||
├─ data-access.ts # 核心 CRUD (695 行): 课案 + 模板
|
||||
├─ data-access-{versions,templates,knowledge,review,calendar,comments,formative,schedules,substitutes,analytics,attachments,ai-evaluation}.ts
|
||||
├─ publish-service.ts # 发布作业编排 (245 行): 跨 homework/exams
|
||||
├─ schema.ts # Zod 验证 (105 行)
|
||||
├─ types.ts # 类型定义 (475 行): 状态机 + 13 种 Block
|
||||
├─ constants.ts # 系统模板常量
|
||||
├─ providers/
|
||||
│ ├─ lesson-plan-provider.tsx # Context 数据服务注入 (425 行)
|
||||
│ └─ lesson-plan-provider-setup.tsx
|
||||
├─ hooks/
|
||||
│ ├─ use-lesson-plan-editor.ts # Zustand store 入口
|
||||
│ ├─ use-lesson-plan-persistence.ts # 自动保存 (156 行)
|
||||
│ ├─ use-node-ai-assist.ts # AI 辅助 (110 行)
|
||||
│ └─ {editor,history,selection,expanded,version}-slice.ts
|
||||
├─ services/
|
||||
│ ├─ default-data-service.ts # 默认数据服务实现 (204 行)
|
||||
│ └─ {default-question-service,external-questions-bridge}.ts
|
||||
├─ lib/
|
||||
│ ├─ type-guards.ts # isXxxBlockData 节点校验 (381 行)
|
||||
│ ├─ document-migration.ts # 文档版本迁移 (400 行)
|
||||
│ ├─ export.ts # 导出 (300 行)
|
||||
│ ├─ document-diff.ts / version-diff.ts # 差异对比
|
||||
│ ├─ ai-node-assist.ts / ai-differentiation.ts / ai-feedback.ts # AI 生成
|
||||
│ └─ {consistency-check,curriculum-coverage,anchor-mark,node-summary,scope-check,status-mappers,i18n-errors}.ts
|
||||
├─ config/
|
||||
│ └─ block-registry.tsx # 13 种块类型注册 (218 行)
|
||||
└─ components/ # 50+ 客户端组件
|
||||
├─ lesson-plan-editor.tsx # 三列布局容器 (274 行)
|
||||
├─ structure-tree/ # 节点树
|
||||
├─ paper-editor/ # Tiptap 富文本
|
||||
├─ detail-panel/ # 节点详情 + qa-editor (251 行)
|
||||
├─ blocks/ # 13 种教学块(objective/key_point/import/...)
|
||||
├─ lesson-plan-list.tsx # 课案列表
|
||||
├─ calendar-view.tsx # 日历视图 (420 行)
|
||||
└─ version-history-drawer(199) / publish-homework-dialog(325) / template-picker(446) / question-bank-picker(311)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| textbooks | data-access-call + import | 课案关联教材/章节,getTextbooks/getChaptersByTextbookId |
|
||||
| questions | data-access-call + import | 节点嵌入题目,question-bank-picker |
|
||||
| exams | data-access-call + import | 发布作业时创建试卷 |
|
||||
| homework | data-access-call + import | 发布作业时创建 assignment |
|
||||
| classes | data-access-call + import | 发布作业选择班级 |
|
||||
| standards | data-access-call | 课标覆盖度检查 |
|
||||
| school | import | 年级/学科过滤 |
|
||||
| files | import | 节点附件上传引用 |
|
||||
| shared | import | DB / cache / auth-guard / 工具函数 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| standards | import | 课标模块引用备课类型 |
|
||||
|
||||
> 本模块为顶层业务模块,主要作为消费方,被依赖较少。
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **状态切片化**: 编辑器状态用 5 个 Zustand slice 管理,组件禁止直接修改 store,必须通过 slice 暴露的 dispatch
|
||||
- **节点数据强校验**: 所有 block 数据写入前必须经 `lib/type-guards.ts` 的 `isXxxBlockData` 校验,防止畸形节点污染文档
|
||||
- **块类型注册制**: 新增块类型必须在 `config/block-registry.tsx` 注册,否则 structure-tree/paper-editor 无法渲染
|
||||
- **审核状态机**: `LESSON_PLAN_STATUS_TRANSITIONS` 强约束状态迁移,`isEditableStatus`/`isPublishableStatus` 控制操作权限
|
||||
- **data-access 按领域拆分**: 13 个 data-access 文件按职责切分,避免单文件超 1000 行硬上限
|
||||
- **Server Action 全部 requirePermission + ActionState 返回**: 权限点包括 LESSON_PLAN_READ/CREATE/PUBLISH 等
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| Zustand slice + history-slice | 编辑器状态切片化,history-slice 支持撤销/重做,避免单一 store 膨胀 |
|
||||
| Tiptap + 节点树双向同步 | paper-editor 富文本与 structure-tree 节点树互为表里,满足教案结构化组织需求 |
|
||||
| Actions / data-access 按领域拆分(各 13 文件) | 备课域复杂(calendar/comments/formative/kp/...),按职责拆分避免超长文件 |
|
||||
| Context Provider 注入 DataService | lesson-plan-provider 注入数据服务抽象,客户端组件可 mock,便于测试与 SSR |
|
||||
| 文档迁移机制 document-migration | 历史教案 V1→V2 升级,normalizeDocument 保证旧数据可读,向后兼容 |
|
||||
| publish-service 跨模块编排 | 发布作业需同时创建 exam + assignment,独立 service 编排,避免 Action 层臃肿 |
|
||||
252
src/modules/messaging/README.md
Normal file
252
src/modules/messaging/README.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# messaging 站内消息模块
|
||||
|
||||
> 站内私信收发、群发、撤回、星标、草稿、快捷模板、举报与屏蔽,通过 notifications 分发多通道通知
|
||||
> 代码规模: 26 文件 · 168 符号 · 59,066 行(arch.db)
|
||||
> 模块路径: `src/modules/messaging/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
List["message-list<br/>(397 行) 消息列表"]
|
||||
Detail["message-detail<br/>(362 行) 消息详情/撤回"]
|
||||
Compose["message-compose<br/>(305 行) 撰写表单"]
|
||||
GroupCompose["message-group-compose<br/>(202 行) 群发"]
|
||||
TplPicker["message-template-picker<br/>(298 行) 快捷模板"]
|
||||
Report["message-report-block<br/>(230 行) 举报/屏蔽"]
|
||||
Attach["message-attachments<br/>(167 行) 附件"]
|
||||
Badge["unread-message-badge<br/>(118 行) 未读徽章"]
|
||||
DraftList["message-draft-list<br/>(137 行) 草稿列表"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks / Services"]
|
||||
Search["use-message-search<br/>(107 行)"]
|
||||
ListSvcCtx["message-list-service-context<br/>服务上下文"]
|
||||
ListSvc["message-list-service<br/>接口 + 默认实现"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(973 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(72 行, barrel)"]
|
||||
DACore["data-access-core.ts<br/>(506 行) 核心 CRUD + 撤回 + 收件人解析"]
|
||||
DABulk["data-access-bulk.ts<br/>(82 行) 批量操作"]
|
||||
DAGroup["data-access-group.ts<br/>(69 行) 群发 fan-out"]
|
||||
DATpl["data-access-templates.ts<br/>(242 行) 草稿 + 模板"]
|
||||
DAReports["data-access-reports.ts<br/>(281 行) 举报 + 屏蔽 + 附件"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
TypeGuards["lib/type-guards.ts<br/>(69 行)"]
|
||||
ReplyHref["lib/build-reply-href.ts<br/>(35 行)"]
|
||||
Schema["schema.ts<br/>(89 行) Zod 校验"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Msgs[(messages)]
|
||||
Drafts[(message_drafts)]
|
||||
Tpls[(message_templates)]
|
||||
Reports[(message_reports)]
|
||||
Blocks[(user_blocks)]
|
||||
end
|
||||
|
||||
List --> ListSvcCtx
|
||||
List --> Search
|
||||
Detail --> Actions
|
||||
Compose --> Actions
|
||||
GroupCompose --> Actions
|
||||
TplPicker --> Actions
|
||||
Report --> Actions
|
||||
Badge --> Actions
|
||||
DraftList --> Actions
|
||||
ListSvcCtx --> ListSvc
|
||||
ListSvc --> Actions
|
||||
|
||||
Actions --> DA
|
||||
DA --> DACore
|
||||
DA --> DABulk
|
||||
DA --> DAGroup
|
||||
DA --> DATpl
|
||||
DA --> DAReports
|
||||
DACore --> Msgs
|
||||
DABulk --> Msgs
|
||||
DAGroup --> Msgs
|
||||
DATpl --> Drafts
|
||||
DATpl --> Tpls
|
||||
DAReports --> Reports
|
||||
DAReports --> Blocks
|
||||
Actions --> Schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 发送私信(含越权与屏蔽校验)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Form as message-compose
|
||||
participant Act as actions.ts
|
||||
participant DACore as data-access-core
|
||||
participant DARep as data-access-reports
|
||||
participant Notif as notifications/dispatcher
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写收件人 + 内容
|
||||
Form->>Act: sendMessageAction
|
||||
Act->>Act: requirePermission(MESSAGE_SEND)
|
||||
Act->>Act: SendMessageSchema 校验
|
||||
Act->>DACore: isReceiverAllowed(sender, receiver, dataScope)
|
||||
Note over Act,DACore: P0-1 二次校验<br/>防止绕过前端越权发送
|
||||
DACore-->>Act: true/false
|
||||
Act->>DARep: isEitherUserBlocked(sender, receiver)
|
||||
DARep-->>Act: false(未屏蔽)
|
||||
Act->>DACore: parentMessageId 归属校验(P0-6)
|
||||
Act->>DACore: createMessage
|
||||
DACore->>DB: INSERT messages
|
||||
Act->>Notif: sendNotification(尊重偏好)
|
||||
Act-->>Form: ActionState<{id}>
|
||||
```
|
||||
|
||||
### 流程 2: 消息撤回(2 分钟窗口,服务端校验)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 发送方
|
||||
participant Detail as message-detail
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access-core
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Detail: 点击撤回
|
||||
Detail->>Act: recallMessageAction(messageId)
|
||||
Act->>Act: requirePermission(MESSAGE_SEND)
|
||||
Act->>DA: recallMessage(id, userId)
|
||||
DA->>DB: SELECT createdAt, recalledAt
|
||||
alt 已撤回
|
||||
DA-->>Act: already_recalled
|
||||
else 超过 2 分钟
|
||||
DA-->>Act: expired
|
||||
else 在窗口内
|
||||
DA->>DB: UPDATE messages SET recalledAt=now
|
||||
DA-->>Act: ok
|
||||
end
|
||||
Act-->>Detail: ActionState
|
||||
Note over U,DB: 撤回不乐观更新<br/>避免超时撤回误成功
|
||||
```
|
||||
|
||||
### 流程 3: 群发消息(教师 → 全班家长,fan-out on write)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[教师选择班级] --> B[sendGroupMessageAction]
|
||||
B --> C{校验 classId 与权限}
|
||||
C -->|通过| D[data-access-group<br/>查询班级家长 ID 列表]
|
||||
D --> E[生成统一 groupMessageId]
|
||||
E --> F[批量 INSERT messages<br/>每条收件人一行]
|
||||
F --> G[notifications 分发]
|
||||
G --> H[各家长收到私信]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | messages / message_drafts / message_templates / message_reports / user_blocks 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(41 处使用) |
|
||||
| Server Action | requirePermission | MESSAGE_SEND / MESSAGE_MANAGE 等权限点校验 |
|
||||
| 状态 | useOptimistic | 消息星标切换乐观更新 |
|
||||
| 表单 | react-hook-form + Zod | schema.ts 校验输入 |
|
||||
| 通知 | notifications/dispatcher | sendNotification 多通道分发,尊重用户偏好 |
|
||||
| 附件 | files/data-access | getMessageAttachments 复用 fileAttachments 表 |
|
||||
| 国际化 | next-intl/server | getTranslations 渲染通知标题 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/messaging/
|
||||
├─ actions.ts # Server Actions (973 行): 发送/撤回/星标/草稿/模板/群发/举报/屏蔽
|
||||
├─ data-access.ts # barrel 重新导出 (72 行)
|
||||
├─ data-access-core.ts # 核心 CRUD + 撤回 + 收件人解析 (506 行)
|
||||
├─ data-access-bulk.ts # 批量标记已读/删除/星标 (82 行)
|
||||
├─ data-access-group.ts # 群发 fan-out on write (69 行)
|
||||
├─ data-access-templates.ts # 草稿 + 快捷回复模板 (242 行)
|
||||
├─ data-access-reports.ts # 举报 + 屏蔽 + 附件 (281 行)
|
||||
├─ schema.ts # Zod 校验 (89 行): SendMessage/Draft/Template/Group/Report/Block
|
||||
├─ types.ts # 类型定义 (214 行): Message / RecipientResolver / MessagingRoleConfig
|
||||
├─ schema.test.ts # Zod schema 单测 (243 行)
|
||||
├─ components/ # 10 个客户端组件
|
||||
│ ├─ message-list.tsx # 消息列表 (397 行)
|
||||
│ ├─ message-detail.tsx # 消息详情 + 撤回 (362 行)
|
||||
│ ├─ message-compose.tsx # 撰写表单 (305 行)
|
||||
│ ├─ message-template-picker.tsx # 快捷模板选择 (298 行)
|
||||
│ ├─ message-report-block.tsx # 举报/屏蔽 (230 行)
|
||||
│ ├─ message-group-compose.tsx # 群发 (202 行)
|
||||
│ ├─ message-attachments.tsx # 附件 (167 行)
|
||||
│ ├─ message-draft-list.tsx # 草稿列表 (137 行)
|
||||
│ ├─ unread-message-badge.tsx # 未读徽章 (118 行)
|
||||
│ └─ message-list-section.tsx # 列表区段 (24 行)
|
||||
├─ hooks/
|
||||
│ └─ use-message-search.ts # 消息搜索 (107 行)
|
||||
├─ services/
|
||||
│ ├─ message-list-service.ts # 列表服务接口 (50 行)
|
||||
│ ├─ default-message-list-service.ts # 默认实现 (29 行)
|
||||
│ └─ message-list-service-context.tsx # React Context (45 行)
|
||||
└─ lib/
|
||||
├─ type-guards.ts # isXxx 类型守卫 (69 行)
|
||||
└─ build-reply-href.ts # 构造回复链接 (35 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | 收件人解析按班级/年级/教师维度过滤 |
|
||||
| users | data-access-call + import | resolveUserNames 获取发件人/收件人姓名 |
|
||||
| parent | data-access-call + import | 群发场景查询班级家长列表 |
|
||||
| files | data-access-call + import | getMessageAttachments 复用 fileAttachments 表 |
|
||||
| notifications | import | sendNotification 多通道分发 |
|
||||
| shared | import | DB / cache / auth-guard / 日期工具 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| layout | import | 全局布局挂载 unread-message-badge 显示未读数 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **data-access 按职责拆分为 5 个子文件 + barrel**: 原 1089 行超过 1000 行硬上限,拆分后单一职责,barrel 保持向后兼容
|
||||
- **撤回不乐观更新**: 撤回必须服务端校验 `MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000` 时间窗口,乐观更新会导致超时撤回误成功
|
||||
- **星标使用 useOptimistic 乐观更新**: 简单布尔切换无需服务端窗口校验
|
||||
- **收件人二次校验(P0-1)**: Server Action 必须调用 `isReceiverAllowed` 校验 receiverId 在 sender 的 DataScope 内,防止绕过前端越权发送
|
||||
- **parentMessageId 归属校验(P0-6)**: 回复消息必须校验父消息属于当前用户会话,防止注入他人线程
|
||||
- **群发 fan-out on write**: 群发时每条收件人写入独立行,共享同一 `groupMessageId`,避免读时聚合
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| data-access 拆分 5 子文件 + barrel | 原 1089 行超 1000 行硬上限,按职责(核心/批量/群发/模板/举报)拆分,barrel 保持消费端 import 不变 |
|
||||
| 撤回不乐观更新而星标乐观更新 | 撤回涉及 2 分钟时间窗口服务端校验,星标仅布尔切换;两者风险/语义不同需区别对待 |
|
||||
| 收件人解析采用 RecipientResolver + MessagingRoleConfig | 开闭原则,新增角色仅需新增配置项,无需修改 getRecipients 主流程 |
|
||||
| 通知偏好迁移至 notifications 模块 | 统一通知配置入口,避免偏好散落多模块导致不一致 |
|
||||
| 举报防重复设计 | 同一用户对同一消息只能举报一次,服务端先 SELECT 再 INSERT,避免重复记录 |
|
||||
| 列表服务抽象为 Context | message-list-service 接口 + 默认实现 + Context,便于测试时替换 mock |
|
||||
265
src/modules/notifications/README.md
Normal file
265
src/modules/notifications/README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# notifications 通知模块
|
||||
|
||||
> 统一通知分发,支持 in-app / sms / email / wechat 多渠道,基于用户偏好选择性投递,提供 SSE 实时流与桌面通知
|
||||
> 代码规模: 22 文件 · 125 符号 · 3,651 行(arch.db)
|
||||
> 模块路径: `src/modules/notifications/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
Drop["notification-dropdown<br/>下拉通知 (179 行)"]
|
||||
NList["notification-list<br/>通知列表 (197 行)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
Stream["use-notification-stream<br/>SSE 实时流 (199 行)"]
|
||||
Desktop["use-desktop-notifications<br/>桌面通知 (160 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(384 行, Server Actions)"]
|
||||
Dispatcher["dispatcher.ts<br/>(225 行, 渠道分发)"]
|
||||
Prefs["preferences.ts<br/>(197 行, 通知偏好)"]
|
||||
DA["data-access.ts<br/>(301 行, CRUD + 联系方式)"]
|
||||
end
|
||||
|
||||
subgraph Channels["渠道层(实现 NotificationChannelSender 接口)"]
|
||||
InApp["in-app-channel.ts<br/>(170 行, 站内)"]
|
||||
Sms["sms-channel.ts<br/>(253 行, 短信)"]
|
||||
Email["email-channel.ts<br/>(205 行, 邮件)"]
|
||||
Wechat["wechat-channel.ts<br/>(220 行, 微信)"]
|
||||
ChTypes["channels/types.ts<br/>(39 行, 接口定义)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Notifs[(message_notifications)]
|
||||
NotifPrefs[(notification_preferences)]
|
||||
Users[(users)]
|
||||
end
|
||||
|
||||
Drop --> Actions
|
||||
Drop --> Stream
|
||||
Drop --> Desktop
|
||||
NList --> Actions
|
||||
|
||||
Actions --> Dispatcher
|
||||
Actions --> DA
|
||||
Actions --> Prefs
|
||||
Dispatcher --> DA
|
||||
Dispatcher --> Prefs
|
||||
Dispatcher --> InApp
|
||||
Dispatcher --> Sms
|
||||
Dispatcher --> Email
|
||||
Dispatcher --> Wechat
|
||||
|
||||
DA --> Notifs
|
||||
Prefs --> NotifPrefs
|
||||
DA --> Users
|
||||
InApp --> Notifs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 发送通知(单用户 + 多渠道分发)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller as 业务模块
|
||||
participant Act as actions.ts
|
||||
participant Disp as dispatcher.ts
|
||||
participant DA as data-access.ts
|
||||
participant Prefs as preferences.ts
|
||||
participant InApp as in-app-channel
|
||||
participant Sms as sms-channel
|
||||
participant Email as email-channel
|
||||
participant DB as MySQL
|
||||
|
||||
Caller->>Act: sendNotificationAction(payload)
|
||||
Act->>Act: requirePermission(MESSAGE_SEND)
|
||||
Act->>Act: SendNotificationSchema.safeParse
|
||||
Act->>Disp: sendNotification(payload)
|
||||
Disp->>Prefs: getNotificationPreferences(userId)
|
||||
Prefs-->>Disp: 偏好(smsEnabled/emailEnabled/pushEnabled)
|
||||
Disp->>DA: getUserContactInfo(userId)
|
||||
DA-->>Disp: ChannelRecipient(phone/email/wechatOpenId)
|
||||
Disp->>Disp: selectChannels(prefs, contact)
|
||||
par 并行发送
|
||||
Disp->>InApp: send (站内必发)
|
||||
InApp->>DB: INSERT message_notifications
|
||||
and
|
||||
Disp->>Sms: send (若 smsEnabled + phone)
|
||||
and
|
||||
Disp->>Email: send (若 emailEnabled + email)
|
||||
and
|
||||
Disp->>Wechat: send (若 pushEnabled + wechatOpenId)
|
||||
end
|
||||
Disp-->>Act: ChannelSendResult[]
|
||||
Act-->>Caller: ActionState<ChannelSendResult[]>
|
||||
```
|
||||
|
||||
### 流程 2: 班级批量通知(in_app 批量 + 其他渠道并行)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Act as actions.ts
|
||||
participant ClsDA as classes.data-access
|
||||
participant Disp as dispatcher.ts
|
||||
participant InApp as in-app-channel
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Act: sendClassNotificationAction(classId, payload)
|
||||
Act->>Act: requirePermission(MESSAGE_SEND)
|
||||
Act->>ClsDA: getStudentIdsByClassId(classId)
|
||||
ClsDA-->>Act: studentIds[]
|
||||
Act->>Disp: sendBatchNotifications(payloads[])
|
||||
Disp->>InApp: sendBatch (单次批量 INSERT)
|
||||
InApp->>DB: INSERT message_notifications (批量)
|
||||
par 其他渠道并行
|
||||
Disp->>Disp: sms/email/wechat 各渠道 sendBatch
|
||||
end
|
||||
Disp-->>Act: ChannelSendResult[][]
|
||||
Act-->>T: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: SSE 实时通知流 + 桌面通知
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[页面加载] --> B[use-notification-stream<br/>建立 SSE 连接]
|
||||
B --> C[/api/notifications/stream]
|
||||
C --> D[服务端推送未读通知]
|
||||
D --> E{新通知?}
|
||||
E -- 是 --> F[更新下拉通知计数]
|
||||
E -- 是 --> G[use-desktop-notifications<br/>触发桌面通知]
|
||||
G --> H{Notification.permission?}
|
||||
H -- granted --> I[显示桌面通知]
|
||||
H -- denied --> J[仅更新角标]
|
||||
E -- 否 --> K[保持连接]
|
||||
F --> L[notification-dropdown<br/>渲染角标]
|
||||
```
|
||||
|
||||
### 流程 4: 通知偏好管理
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Settings as settings 模块
|
||||
participant Act as actions.ts
|
||||
participant Prefs as preferences.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Settings: 通知偏好表单
|
||||
Settings->>Act: updateNotificationPreferencesAction
|
||||
Act->>Act: requirePermission(MESSAGE_READ)
|
||||
Act->>Act: UpdateNotificationPreferencesSchema.safeParse
|
||||
Act->>Prefs: upsertNotificationPreferences(userId, input)
|
||||
Prefs->>DB: UPSERT notification_preferences
|
||||
Note over Prefs,DB: 偏好字段:<br/>smsEnabled/emailEnabled/pushEnabled<br/>homeworkNotifications/gradeNotifications
|
||||
Prefs-->>Act: 完成
|
||||
Act-->>Settings: ActionState
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | message_notifications / notification_preferences 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重 |
|
||||
| 多渠道分发 | NotificationChannelSender 接口 | in-app/sms/email/wechat 统一接口,dispatcher 调度 |
|
||||
| 实时流 | SSE (Server-Sent Events) | use-notification-stream 实时推送未读通知 |
|
||||
| 桌面通知 | Notification API | use-desktop-notifications 浏览器桌面通知 |
|
||||
| 邮件 | 自研 HTML 渲染 | email-channel 渲染邮件 HTML(内联样式为令牌豁免场景) |
|
||||
| Server Action | requirePermission | 复用 MESSAGE_SEND / MESSAGE_READ(无独立通知权限点) |
|
||||
| 缓存失效 | invalidateFor | 按 notifications.{send/markRead/preferences} 精确失效 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/notifications/
|
||||
├─ actions.ts # Server Actions (384 行): 发送/班级通知/CRUD/偏好
|
||||
├─ dispatcher.ts # 渠道分发器 (225 行): selectChannels + 并行发送
|
||||
├─ dispatcher.test.ts # 分发器单测 (252 行)
|
||||
├─ data-access.ts # 数据访问层 (301 行): CRUD + getUserContactInfo
|
||||
├─ preferences.ts # 通知偏好 (197 行): get/upsert preferences
|
||||
├─ index.ts # Barrel 导出 (87 行)
|
||||
├─ schema.ts # Zod 验证 (26 行)
|
||||
├─ schema.test.ts # schema 单测 (143 行)
|
||||
├─ types.ts # 类型定义 (176 行): Payload/Channel/Result/Preferences
|
||||
├─ external-sdk.d.ts # 外部 SDK 类型声明 (48 行)
|
||||
├─ lib/
|
||||
│ └─ type-guards.ts # 类型守卫 (13 行)
|
||||
├─ channels/ # 渠道层(4 渠道 + 接口)
|
||||
│ ├─ types.ts # NotificationChannelSender 接口 (39 行)
|
||||
│ ├─ in-app-channel.ts # 站内渠道 (170 行)
|
||||
│ ├─ in-app-channel.test.ts # 站内渠道单测 (174 行)
|
||||
│ ├─ sms-channel.ts # 短信渠道 (253 行)
|
||||
│ ├─ email-channel.ts # 邮件渠道 (205 行)
|
||||
│ └─ wechat-channel.ts # 微信渠道 (220 行)
|
||||
├─ hooks/ # 客户端 Hooks
|
||||
│ ├─ use-notification-stream.ts # SSE 实时流 (199 行)
|
||||
│ └─ use-desktop-notifications.ts # 桌面通知 (160 行)
|
||||
└─ components/ # 客户端组件
|
||||
├─ notification-list.tsx # 通知列表 (197 行)
|
||||
├─ notification-dropdown.tsx # 下拉通知 (179 行)
|
||||
└─ index.ts # 组件 barrel (3 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | getStudentIdsByClassId 班级通知按班级查学生 / getClassExists 校验 |
|
||||
| shared | import | DB / cache / auth-guard / track-event |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| announcements | import | 公告发布触发通知 |
|
||||
| attendance | import | 考勤异常通知 |
|
||||
| diagnostic | data-access-call + import | 诊断报告发布通知学生+家长 |
|
||||
| elective | import | 选课结果通知 |
|
||||
| grades | data-access-call + import | 成绩发布通知 |
|
||||
| homework | data-access-call | 作业布置/批改完成通知 |
|
||||
| layout | import | 布局层引用通知下拉 |
|
||||
| messaging | import | 消息模块引用通知 |
|
||||
| settings | import | 通知偏好设置引用类型 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **createNotifications 必须批量 INSERT**: 禁止单条循环写入,in-app 渠道单次批量写入提升吞吐
|
||||
- **in_app 批量 + 其他渠道并行**: in_app 单次批量写入,sms/email/wechat 并行发送,兼顾吞吐与延迟
|
||||
- **渠道选择基于偏好**: `selectChannels` 根据用户 notification_preferences(smsEnabled/emailEnabled/pushEnabled) + 联系方式决定启用渠道
|
||||
- **邮件 HTML 内联样式为令牌豁免场景**: email-channel.ts 内联样式不受设计令牌约束(project_rules 豁免)
|
||||
- **复用 MESSAGE_SEND/MESSAGE_READ 权限**: 项目无独立 NOTIFICATION_SEND 权限点,班级通知按教师所教班级过滤
|
||||
- **渠道发送器单例**: `getSenders()` 缓存 senderRegistry,避免每次发送重新创建
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| in_app 批量 + 其他渠道并行 | 兼顾 in_app 写入吞吐与多渠道发送延迟 |
|
||||
| NotificationChannelSender 统一接口 | 新增渠道只需实现接口并注册到 dispatcher,开闭原则 |
|
||||
| 通知偏好纳入本模块 | 作为通知分发配置的唯一归属地,dispatcher 直接读取 |
|
||||
| SSE 实时流 + 桌面通知双通道 | SSE 保证实时性,桌面通知增强触达,权限降级优雅 |
|
||||
| getAllUserIds 分页遍历 | 避免大批量通知时一次性拉取全部用户 |
|
||||
| 复用 MESSAGE_SEND 权限 | 避免新增独立权限点增加管理成本,教师班级通知天然受班级归属约束 |
|
||||
204
src/modules/onboarding/README.md
Normal file
204
src/modules/onboarding/README.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# onboarding 引导模块
|
||||
|
||||
> 首次登录引导:配置驱动的步骤流程,收集 profile、按角色绑定班级/子女,完成标记并跳转角色默认首页
|
||||
> 代码规模: 7 文件 · 35 符号 · 6,749 行(arch.db)
|
||||
> 模块路径: `src/modules/onboarding/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Stepper["onboarding-stepper.tsx<br/>(259 行, 步骤容器)"]
|
||||
ParentForm["parent-children-form.tsx<br/>(123 行, 家长绑定子女)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseForm["use-onboarding-form.ts<br/>(221 行, 表单编排)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(327 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(178 行, profile/绑定/onboardedAt)"]
|
||||
end
|
||||
|
||||
subgraph Ext["跨模块 data-access"]
|
||||
Classes["classes.enrollStudent/Teacher"]
|
||||
end
|
||||
|
||||
subgraph Lib["shared/lib"]
|
||||
Route["route-resolver.resolveDefaultPath"]
|
||||
Audit["audit-logger"]
|
||||
RateLimit["rate-limit"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Users[(users)]
|
||||
ParentChild[(parent_student_relations)]
|
||||
end
|
||||
|
||||
Stepper --> UseForm
|
||||
Stepper --> ParentForm
|
||||
UseForm --> Actions
|
||||
Actions --> DA
|
||||
Actions --> Classes
|
||||
Actions --> Route
|
||||
Actions --> Audit
|
||||
Actions --> RateLimit
|
||||
DA --> Users
|
||||
DA --> ParentChild
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 引导状态判断 + 步骤路由
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Page as onboarding/page
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Page: 首次登录
|
||||
Page->>Act: getOnboardingStatusAction
|
||||
Act->>Act: requireAuth
|
||||
Act->>DA: getOnboardingStatus(userId)
|
||||
DA->>DB: SELECT users.onboardedAt + roles
|
||||
DA-->>Act: status
|
||||
alt onboardedAt 已存在
|
||||
Act-->>Page: 已完成,跳转默认首页
|
||||
else 未完成
|
||||
Act-->>Page: 返回角色 + 步骤
|
||||
Page->>Page: getOnboardingStepsForRole(role)
|
||||
end
|
||||
```
|
||||
|
||||
### 流程 2: 完成引导(按角色分支)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Stepper as onboarding-stepper
|
||||
participant Hook as use-onboarding-form
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Classes as classes DA
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Stepper: 填写步骤
|
||||
Stepper->>Hook: 收集表单
|
||||
Hook->>Act: completeOnboardingAction
|
||||
Act->>Act: requireAuth
|
||||
Act->>DA: getUserOnboardedAt(幂等检查)
|
||||
alt 已完成
|
||||
Act-->>Hook: 直接成功
|
||||
end
|
||||
Act->>Act: OnboardingSchema.safeParse
|
||||
Act->>DA: updateUserProfile(name/phone/address)
|
||||
alt role=teacher
|
||||
Act->>Classes: enrollTeacherByInvitationCode(循环多科目)
|
||||
else role=student
|
||||
Act->>Classes: enrollStudentByInvitationCode
|
||||
else role=parent
|
||||
Act->>DA: bindParentToChild(三因子验证)
|
||||
end
|
||||
Act->>DA: markUserOnboarded(onboardedAt=now)
|
||||
Act->>Act: invalidateFor + logAudit
|
||||
Act->>Route: resolveDefaultPath(roles)
|
||||
Act-->>Hook: ActionState<OnboardingCompleteData>
|
||||
```
|
||||
|
||||
### 流程 3: 步骤配置驱动
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[ONBOARDING_STEPS 配置] --> B[getOnboardingStepsForRole]
|
||||
B --> C{角色过滤}
|
||||
C -->|admin/grade_head/teaching_head| D[roleConfirm + basicInfo + complete]
|
||||
C -->|teacher/student| E[roleConfirm + basicInfo + roleInfo + complete]
|
||||
C -->|parent| F[roleConfirm + basicInfo + roleInfo 不可跳过 + complete]
|
||||
D --> G[stepper 渲染]
|
||||
E --> G
|
||||
F --> G
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | users / parent_student_relations 表更新 |
|
||||
| 缓存 | cacheFn + invalidateFor | 状态查询去重,完成后失效 |
|
||||
| Server Action | requireAuth(非 requirePermission) | 仅校验登录态,引导中无业务权限点 |
|
||||
| 校验 | Zod | OnboardingSchema(按角色条件校验) |
|
||||
| 配置驱动 | ONBOARDING_STEPS + getOnboardingStepsForRole | 步骤定义抽取为配置常量 |
|
||||
| 速率限制 | rateLimit + rateLimitKey | 防止引导接口爆破 |
|
||||
| 审计 | logAudit | onboarding 完成审计 |
|
||||
| 路由 | route-resolver.resolveDefaultPath | 引导完成后跳转角色默认首页 |
|
||||
| 服务端隔离 | import "server-only" | data-access.ts 防止客户端引用 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/onboarding/
|
||||
├─ actions.ts # Server Actions (327 行): 状态查询/完成引导
|
||||
├─ data-access.ts # 数据访问层 (178 行): profile/绑定/onboardedAt
|
||||
├─ schema.ts # Zod 验证 (86 行)
|
||||
├─ types.ts # 类型定义 (139 行): ONBOARDING_STEPS/ChildRow
|
||||
├─ hooks/
|
||||
│ └─ use-onboarding-form.ts # 表单编排 Hook (221 行)
|
||||
├─ components/
|
||||
│ ├─ onboarding-stepper.tsx # 步骤容器 (259 行)
|
||||
│ └─ parent-children-form.tsx # 家长绑定子女 (123 行)
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | import + data-access-call | enrollStudent/TeacherByInvitationCode + DEFAULT_CLASS_SUBJECTS |
|
||||
| shared | import | DB / auth-guard / cache / audit-logger / route-resolver |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| settings | data-access-call | 设置页查询 onboarding 状态 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **使用 requireAuth 而非 requirePermission**: 引导在角色生效前完成,无业务权限点,仅校验登录态
|
||||
- **角色只读**: 不直接写 `usersToRoles`,角色来源由管理员预分配或邀请码消费,引导只读取并展示
|
||||
- **服务端幂等**: `completeOnboardingAction` 开始时检查 `users.onboardedAt`,已完成直接返回成功,防止双击重复事务
|
||||
- **局部错误不回滚**: 班级码/子女绑定失败时不回滚整个事务,收集失败列表返回前端,成功项保留
|
||||
- **ONBOARDING_STEPS 配置驱动**: 步骤定义在 types.ts 的配置常量,新增/调整步骤不改 Hook 逻辑
|
||||
- **`DEFAULT_CLASS_SUBJECTS` 用 `as const` 元组**: `.includes()` 需 readonly 入参,显式标注 `CLASS_SUBJECTS_READONLY: readonly string[]` 避免 as 断言
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| onboardedAt 单字段标记 | 不引入独立状态表,users.onboardedAt 查询最简 |
|
||||
| 引导 enroll 复用 classes data-access | 避免本模块直接操作班级成员表,保持三层架构 |
|
||||
| ONBOARDING_STEPS 抽取为配置常量 | 步骤新增/调整只改配置,不改 Hook 逻辑,支持 canSkip 函数 |
|
||||
| 局部错误收集而非整体回滚 | 班级码/子女绑定部分失败时,成功项保留,用户体验更友好 |
|
||||
| resolveDefaultPath 跳转 | 引导完成后由 route-resolver 决定角色默认首页,避免硬编码路由 |
|
||||
216
src/modules/parent/README.md
Normal file
216
src/modules/parent/README.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# parent 家长模块
|
||||
|
||||
> 家长端聚合展示孩子的课表、作业、成绩、考试、考勤等信息,通过 parentId + studentId 双重校验防止跨家庭信息泄露
|
||||
> 代码规模: 19 文件 · 2,969 行(arch.db)
|
||||
> 模块路径: `src/modules/parent/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
DataPage["parent-children-data-page<br/>(92 行, 入口)"]
|
||||
DetailPanel["child-detail-panel<br/>(225 行, client)"]
|
||||
Cal["parent-attendance-calendar<br/>(282 行, client)"]
|
||||
Export["parent-export-button<br/>(86 行, client)"]
|
||||
ChildCard["child-card<br/>(149 行)"]
|
||||
Summaries["grade/homework/exam<br/>summary + detail"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(98 行, 6 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(339 行, server-only, 12 导出)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Relations[(parent_student_relations)]
|
||||
end
|
||||
|
||||
DataPage --> Actions
|
||||
DetailPanel --> Actions
|
||||
Cal --> Actions
|
||||
Export --> Actions
|
||||
ChildCard --> Actions
|
||||
Summaries --> Actions
|
||||
|
||||
Actions --> DA
|
||||
DA --> Relations
|
||||
DA --> Classes["classes<br/>getStudentClasses/Schedule"]
|
||||
DA --> Homework["homework<br/>getStudentHomeworkAssignments"]
|
||||
DA --> Grades["grades<br/>getStudentGradeSummary"]
|
||||
DA --> School["school<br/>getGradeNameById"]
|
||||
DA --> Users["users<br/>getUserNamesByIds"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: parentId + studentId 双重校验防信息泄露
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as 家长
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
P->>Act: getChildDashboardDataAction(studentId)
|
||||
Act->>Act: requireAuth() 获取 ctx.userId(parentId)
|
||||
Act->>DA: verifyParentChildRelation(studentId, ctx.userId)
|
||||
DA->>DB: SELECT FROM parent_student_relations<br/>WHERE studentId=X AND parentId=Y
|
||||
alt 关系不存在
|
||||
DB-->>DA: null
|
||||
DA-->>Act: null
|
||||
Act-->>P: 返回 null(拒绝访问)
|
||||
Note over Act,DB: 防止家长越权访问<br/>非自家孩子数据
|
||||
else 关系存在
|
||||
DB-->>DA: relation(如 father)
|
||||
DA-->>Act: relation
|
||||
Act->>DA: getChildDashboardData(studentId, relation)
|
||||
DA-->>Act: ChildDashboardData
|
||||
Act-->>P: 返回聚合数据
|
||||
end
|
||||
```
|
||||
|
||||
### 流程 2: 单个孩子仪表盘 Promise.all 6 路并行聚合
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Classes as classes
|
||||
participant HW as homework
|
||||
participant Grades as grades
|
||||
|
||||
Act->>DA: getChildDashboardData(studentId, relation)
|
||||
DA->>DA: getChildBasicInfo(Promise.all[年级名+班级])
|
||||
par 并行 6 路查询
|
||||
DA->>Classes: getStudentClasses
|
||||
DA->>Classes: getStudentSchedule
|
||||
DA->>HW: getStudentHomeworkAssignments
|
||||
DA->>HW: getStudentDashboardGrades
|
||||
DA->>Grades: getStudentGradeSummary
|
||||
DA->>HW: getStudentExamResults
|
||||
end
|
||||
Classes-->>DA: 班级列表 + 课表
|
||||
HW-->>DA: 作业 + 成绩趋势 + 考试
|
||||
Grades-->>DA: 成绩摘要
|
||||
DA->>DA: 聚合为 ChildDashboardData
|
||||
DA-->>Act: ChildDashboardData
|
||||
```
|
||||
|
||||
### 流程 3: 家长仪表盘多子女聚合
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[家长进入仪表盘] --> B[getParentDashboardDataAction<br/>权限: requireAuth]
|
||||
B --> C[getParentDashboardData(parentId)]
|
||||
C --> D[Promise.all<br/>getUserNamesByIds + getChildren]
|
||||
D --> E[获取家长姓名 + 子女关系列表]
|
||||
E --> F{子女数量}
|
||||
F -->|1 个| G[直接调用 getChildDashboardData]
|
||||
F -->|多个| H[Promise.all 并行<br/>map(getChildDashboardData)]
|
||||
G --> I[聚合 ParentDashboardData]
|
||||
H --> I
|
||||
I --> J[返回多子女概览<br/>含 attention banner 提醒]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | parent_student_relations 表查询(AND 双条件过滤) |
|
||||
| 缓存 | cacheFn(22 处) | getChildren / verifyParentChildRelation / getChildDashboardData 等查询去重 |
|
||||
| 安全 | requireAuth + verifyParentChildRelation | 双重校验:登录态 + 家长子女关系绑定 |
|
||||
| 并行聚合 | Promise.all | 单孩子 6 路并行 + 多孩子 map 并行,降低总延迟 |
|
||||
| Server Action | 6 个 Action | 统一入口,页面经 Action 调用,不直连 data-access |
|
||||
| Server Component | 13/16 服务端组件 | 家长端以只读展示为主,减少客户端 JS |
|
||||
| 跨模块聚合 | data-access 调用 | 聚合 classes/homework/grades/school/users 五模块数据 |
|
||||
| 缓存失效 | invalidateFor | 关系变更后精确刷新 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/parent/
|
||||
├─ actions.ts # Server Actions (98 行): 6 个 Action
|
||||
├─ data-access.ts # 数据访问 (339 行, server-only): 12 个导出 + Promise.all 聚合
|
||||
├─ types.ts # 类型定义 (135 行): ParentChildRelation / ChildDashboardData
|
||||
├─ components/ # 16 个组件(13 服务端 + 4 客户端)
|
||||
│ ├─ parent-children-data-page.tsx # 数据页入口 (92 行)
|
||||
│ ├─ child-detail-panel.tsx # 子女详情面板 (225 行, client)
|
||||
│ ├─ parent-attendance-calendar.tsx # 考勤日历 (282 行, client)
|
||||
│ ├─ child-card.tsx # 子女卡片 (149 行)
|
||||
│ ├─ child-detail-header.tsx # 详情头部 (78 行)
|
||||
│ ├─ child-exam-detail.tsx # 考试详情 (172 行)
|
||||
│ ├─ child-grade-detail.tsx # 成绩详情 (169 行)
|
||||
│ ├─ child-grade-summary.tsx # 成绩摘要 (162 行, client)
|
||||
│ ├─ child-homework-detail.tsx # 作业详情 (166 行)
|
||||
│ ├─ child-homework-summary.tsx # 作业摘要 (144 行)
|
||||
│ ├─ child-schedule-card.tsx # 课表卡片 (122 行)
|
||||
│ ├─ parent-attendance-rate-card.tsx # 考勤率卡片 (131 行)
|
||||
│ ├─ parent-attendance-warning.tsx # 考勤预警 (119 行)
|
||||
│ ├─ parent-attention-banner.tsx # 关注横幅 (136 行)
|
||||
│ ├─ parent-export-button.tsx # 导出按钮 (86 行, client)
|
||||
│ └─ parent-student-attendance-detail.tsx # 考勤详情 (164 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | DB / cache / auth-guard |
|
||||
| users | data-access-call + import | getUserBasicInfo / getUserNamesByIds 解析家长与子女姓名 |
|
||||
| classes | data-access-call + import | getStudentClasses / getStudentSchedule / getStudentActiveClass |
|
||||
| homework | data-access-call + import | getStudentHomeworkAssignments / getStudentDashboardGrades / getStudentExamResults |
|
||||
| grades | data-access-call + import | getStudentGradeSummary 成绩摘要 |
|
||||
| school | data-access-call + import | getGradeNameById 年级名称 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| dashboard | import + data-access-call | 家长仪表盘聚合展示 |
|
||||
| messaging | import + data-access-call | 家长消息推送,解析家长身份 |
|
||||
| attendance | import + data-access-call | 考勤异常通知家长 |
|
||||
| grades | import + data-access-call | 成绩通知家长 |
|
||||
| leave-requests | import + data-access-call | 请假审批通知家长 |
|
||||
| diagnostic | import + data-access-call | 诊断报告通知家长 |
|
||||
| rbac | import + data-access-call | 角色权限映射含 parent 角色 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **parentId + studentId 双重校验(核心安全)**: 所有涉及子女数据的 Action 必须先调用 `verifyParentChildRelation(studentId, ctx.userId)`,SQL 用 `AND(studentId=X, parentId=Y)` 双条件过滤,防止家长越权访问非自家孩子(G4-002 修复)
|
||||
- **统一经 Server Action 入口**: app 路由不直接 import data-access,经 6 个 Action 调用,确保 requireAuth + 关系校验不被绕过
|
||||
- **Promise.all 并行聚合**: 单孩子 6 路并行(班级/课表/作业/成绩趋势/成绩摘要/考试),多孩子 map 并行,禁阻塞串行
|
||||
- **Server Component 为主(13/16)**: 家长端以只读展示为主,仅日历/详情面板/导出按钮/成绩摘要 4 个组件启用客户端交互
|
||||
- **data-access.ts 首行 server-only**: 12 个导出函数禁止客户端组件直接调用
|
||||
- **cacheFn 广泛使用(22 处)**: 聚合查询高频,缓存(ttl=300s)显著降低跨模块调用压力
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| verifyParentChildRelation 双条件 AND 过滤 | 单按 studentId 查询会泄露任意学生给任意家长,必须同时校验 parentId 绑定关系 |
|
||||
| 统一经 Server Action 入口(G4-002) | 原 app 路由直连 data-access 绕过权限,现 6 个 Action 强制 requireAuth + 关系校验 |
|
||||
| Promise.all 6 路并行聚合 | 串行拉取 6 个子模块总延迟过高,并行后总延迟≈最慢一路 |
|
||||
| 多孩子 map 并行 getChildDashboardData | 多子女家庭需聚合所有孩子,串行会成倍增加延迟 |
|
||||
| Server Component 为主 | 家长端只读展示为主,减少客户端 JS bundle 体积 |
|
||||
| data-access 聚合多模块而非 app 路由聚合 | 聚合逻辑下沉到 data-access,便于缓存与复用,app 路由仅调 Action |
|
||||
| cacheFn 缓存聚合结果 | 跨模块聚合查询开销大,缓存命中可避免重复调用 5 个子模块 |
|
||||
| getParentIdsByStudentIds 批量反查 | 通知场景需按学生反查所有家长,批量查询避免 N+1 |
|
||||
204
src/modules/proctoring/README.md
Normal file
204
src/modules/proctoring/README.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# proctoring 监考模块
|
||||
|
||||
> 在线考试监考与防作弊,采集 8 类异常行为事件,提供教师监考面板与学生端反作弊监控
|
||||
> 代码规模: 6 文件 · 1,500 行(arch.db)
|
||||
> 模块路径: `src/modules/proctoring/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Dashboard["proctoring-dashboard<br/>(305 行, 教师监考面板)"]
|
||||
AntiCheat["anti-cheat-monitor<br/>(232 行, 学生端反作弊)"]
|
||||
ModeConfig["exam-mode-config<br/>(252 行, 考试模式配置)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(130 行, 2 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(444 行, server-only, 14 导出)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Events[(exam_proctoring_events)]
|
||||
end
|
||||
|
||||
AntiCheat -->|上报事件| Actions
|
||||
Dashboard -->|拉取面板| Actions
|
||||
ModeConfig --> Actions
|
||||
|
||||
Actions --> DA
|
||||
DA --> Events
|
||||
DA --> Exams["exams<br/>getExamForProctoringCrossModule<br/>getExamSubmissionForProctoringCrossModule"]
|
||||
DA --> Users["users<br/>getUserNamesByIds"]
|
||||
DA --> Shared["shared<br/>cacheFn / safeParseDate"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 学生端反作弊事件上报(submission 归属校验)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 学生
|
||||
participant Monitor as anti-cheat-monitor
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
S->>Monitor: 触发异常行为(切屏/复制/右键)
|
||||
Monitor->>Monitor: 检测事件类型
|
||||
Monitor->>Act: recordProctoringEventAction(formData)
|
||||
Act->>Act: requirePermission(EXAM_SUBMIT)
|
||||
Act->>Act: ProctoringEventSchema.safeParse
|
||||
Act->>DA: getExamSubmissionForProctoring(submissionId, ctx.userId)
|
||||
DA->>DB: SELECT FROM exam_submissions<br/>WHERE id=X AND studentId=Y
|
||||
alt submission 不属于当前学生
|
||||
DB-->>DA: null
|
||||
DA-->>Act: null
|
||||
Act-->>Monitor: 失败(拒绝上报)
|
||||
else 归属正确
|
||||
DB-->>DA: submission
|
||||
DA-->>Act: submission
|
||||
Act->>DA: recordProctoringEvent
|
||||
DA->>DB: INSERT exam_proctoring_events
|
||||
Act->>Act: invalidateFor(proctoring.recordEvent)
|
||||
Act-->>Monitor: ActionState<{id}>
|
||||
end
|
||||
```
|
||||
|
||||
### 流程 2: 教师监考面板(Promise.all 3 路并行)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Dash as proctoring-dashboard
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
|
||||
T->>Dash: 进入监考面板
|
||||
Dash->>Act: getProctoringDashboardAction(examId)
|
||||
Act->>Act: requirePermission(EXAM_PROCTOR)
|
||||
Act->>DA: getExamForProctoring(examId)
|
||||
DA-->>Act: exam(校验考试存在)
|
||||
par Promise.all 3 路并行
|
||||
Act->>DA: getExamProctoringSummary(examId)
|
||||
Act->>DA: getStudentProctoringStatuses(examId)
|
||||
Act->>DA: getRecentProctoringEvents(examId, 20)
|
||||
end
|
||||
DA-->>Act: summary + students + recentEvents
|
||||
Act-->>Dash: ProctoringDashboardData
|
||||
Dash->>T: 渲染统计/学生状态/事件流
|
||||
```
|
||||
|
||||
### 流程 3: 8 类异常事件 + 异常学生判定
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[学生考试中] --> B{行为检测}
|
||||
B -->|切换标签页| C1[tab_switch]
|
||||
B -->|窗口失焦| C2[window_blur]
|
||||
B -->|复制| C3[copy_attempt]
|
||||
B -->|粘贴| C4[paste_attempt]
|
||||
B -->|右键| C5[right_click]
|
||||
B -->|开发者工具| C6[devtools_open]
|
||||
B -->|退出全屏| C7[fullscreen_exit]
|
||||
B -->|空闲超时| C8[idle_timeout]
|
||||
|
||||
C1 --> D[recordProctoringEvent]
|
||||
C2 --> D
|
||||
C3 --> D
|
||||
C4 --> D
|
||||
C5 --> D
|
||||
C6 --> D
|
||||
C7 --> D
|
||||
C8 --> D
|
||||
|
||||
D --> E[exam_proctoring_events 表]
|
||||
E --> F[getExamProctoringSummary<br/>聚合 eventsByType]
|
||||
E --> G[getStudentProctoringStatuses<br/>按学生统计事件数]
|
||||
G --> H{事件数 ≥ ABNORMAL_EVENT_THRESHOLD=3}
|
||||
H -->|是| I[标记为异常学生]
|
||||
H -->|否| J[正常学生]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | exam_proctoring_events 表写入与聚合查询 |
|
||||
| 缓存 | cacheFn(20 处) | getProctoringEvents / getExamProctoringSummary 等查询去重 |
|
||||
| 表单校验 | Zod | ProctoringEventSchema 校验 8 类事件类型 |
|
||||
| Server Action | requirePermission | EXAM_SUBMIT(学生上报) / EXAM_PROCTOR(教师监考) 两权限点 |
|
||||
| 表单 | react-hook-form | exam-mode-config 考试模式配置表单 |
|
||||
| 跨模块数据 | data-access 调用 | exams 模块获取考试/提交信息,users 获取学生姓名 |
|
||||
| 并行查询 | Promise.all | 监考面板 3 路并行(summary + students + events) |
|
||||
| 缓存失效 | invalidateFor | 事件上报后精确刷新 examId 维度 |
|
||||
| 列表上限 | DEFAULT_LIMIT=1000 | F-05 防止无界查询拉爆内存 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/proctoring/
|
||||
├─ actions.ts # Server Actions (130 行): 2 个 Action
|
||||
├─ data-access.ts # 数据访问 (444 行, server-only): 14 个导出
|
||||
├─ types.ts # 类型定义 (137 行): 8 类事件 + ABNORMAL_EVENT_THRESHOLD=3
|
||||
├─ components/ # 3 个客户端组件
|
||||
│ ├─ proctoring-dashboard.tsx # 教师监考面板 (305 行)
|
||||
│ ├─ exam-mode-config.tsx # 考试模式配置 (252 行, react-hook-form)
|
||||
│ └─ anti-cheat-monitor.tsx # 学生端反作弊监控 (232 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | DB / cache / auth-guard / action-utils / cuid2 |
|
||||
| users | data-access-call + import | getUserNamesByIds 解析学生姓名 |
|
||||
| exams | data-access-call | getExamForProctoringCrossModule / getExamSubmissionForProctoringCrossModule / getExamSubmissionsForExam / getExamTitleById(源码可见,arch.db 未捕获) |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| exams | import | 考试模块关联监考配置与事件查询 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **submission 归属校验**: 学生上报事件前必须校验 `getExamSubmissionForProctoring(submissionId, ctx.userId)`,确保 submission 属于当前学生,防止伪造他人 submission 上报
|
||||
- **双权限点**: EXAM_SUBMIT(学生上报自己事件)与 EXAM_PROCTOR(教师查看监考面板)严格分离
|
||||
- **8 类事件类型枚举**: tab_switch/window_blur/copy_attempt/paste_attempt/right_click/devtools_open/fullscreen_exit/idle_timeout,Schema 用 z.enum 强校验
|
||||
- **ABNORMAL_EVENT_THRESHOLD=3**: 学生事件数 ≥3 标记为异常学生,阈值定义在 types.ts 便于调整
|
||||
- **DEFAULT_LIMIT=1000(F-05)**: 列表查询默认上限,防止无界查询拉爆内存
|
||||
- **data-access.ts 首行 server-only**: 14 个导出函数禁止客户端组件直接调用
|
||||
- **跨模块经 data-access**: 监考不持有 exam_submissions/exams 表所有权,经 exams data-access 获取
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 8 类事件类型枚举 + Zod 校验 | 防止客户端上报任意类型,事件类型固定便于聚合统计 |
|
||||
| submission 归属校验在 Action 层 | 防止学生伪造他人 submissionId 上报假事件,破坏监考数据可信度 |
|
||||
| ABNORMAL_EVENT_THRESHOLD 定义在 types.ts | 阈值常量便于统一调整,而非散落在多个查询中硬编码 |
|
||||
| Promise.all 3 路并行(面板) | summary + students + events 串行延迟高,并行后总延迟≈最慢一路 |
|
||||
| 监考不持有 exams 表所有权 | 遵循三层架构,考试数据归属 exams 模块,监考仅消费 |
|
||||
| 事件上报 invalidateFor 按 examId 维度 | 同一考试的面板数据需刷新,其他考试不受影响 |
|
||||
| react-hook-form 用于 exam-mode-config | 考试模式配置表单字段多,react-hook-form 管理状态更清晰 |
|
||||
| DEFAULT_LIMIT=1000 防无界查询 | 监考事件可能爆炸增长,列表查询必须有限制 |
|
||||
242
src/modules/questions/README.md
Normal file
242
src/modules/questions/README.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# questions 题库模块
|
||||
|
||||
> 题库 CRUD、MySQL FULLTEXT 全文检索、级联筛选(教材→章节→知识点)、批量导入导出、复合题递归子题与多对多知识点关联,为试卷/练习/诊断提供题目来源
|
||||
> 代码规模: 18 文件 · 116 符号 · 47,066 行(arch.db)
|
||||
> 模块路径: `src/modules/questions/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
DataTable["question-data-table<br/>(183 行) 题目表格"]
|
||||
Cascade["question-cascade-filter<br/>(155 行) 级联筛选"]
|
||||
CreateDlg["create-question-dialog<br/>(281 行) 创建对话框"]
|
||||
Options["options-editor<br/>(107 行) 选项编辑"]
|
||||
QActions["question-actions<br/>(205 行) 行操作"]
|
||||
Batch["batch-operations<br/>(122 行) 批量操作"]
|
||||
ImpExp["import-export-buttons<br/>(194 行) 导入导出"]
|
||||
KPSelector["knowledge-point-selector<br/>(108 行)"]
|
||||
Columns["question-columns.tsx<br/>(134 行)"]
|
||||
Renderer["question-content-renderer<br/>(117 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(375 行, 10+ Server Actions)"]
|
||||
DA["data-access.ts<br/>(828 行) CRUD + 检索 + 选项查询"]
|
||||
Schema["schema.ts<br/>(19 行, Zod 递归 schema)"]
|
||||
TrackEvt["utils/track-event.ts<br/>(95 行)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Parse["utils/parse-content.ts<br/>(129 行) 类型守卫"]
|
||||
BooleanQ["toBooleanModeQuery<br/>FULLTEXT 查询转换"]
|
||||
end
|
||||
|
||||
subgraph External["外部模块"]
|
||||
TextbooksDA["textbooks/data-access<br/>教材/章节/知识点查询"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Questions[(questions)]
|
||||
Q2KP[(questionsToKnowledgePoints)]
|
||||
Fulltext[(content_text 生成列<br/>FULLTEXT 索引)]
|
||||
end
|
||||
|
||||
DataTable --> Cascade
|
||||
DataTable --> QActions
|
||||
DataTable --> Batch
|
||||
CreateDlg --> Options
|
||||
CreateDlg --> KPSelector
|
||||
CreateDlg --> Actions
|
||||
Cascade --> Actions
|
||||
QActions --> Actions
|
||||
Batch --> Actions
|
||||
ImpExp --> Actions
|
||||
DataTable --> Renderer
|
||||
Renderer --> Parse
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Schema
|
||||
Actions --> TrackEvt
|
||||
DA --> TextbooksDA
|
||||
DA --> BooleanQ
|
||||
DA --> Questions
|
||||
DA --> Q2KP
|
||||
DA --> Fulltext
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 题目检索(级联筛选 + FULLTEXT)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Filter as question-cascade-filter
|
||||
participant Table as question-data-table
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant TDA as textbooks/data-access
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Filter: 选教材 → 章节 → 知识点
|
||||
Filter->>Act: getChapterOptionsAction / getKnowledgePointOptionsAction
|
||||
Act->>DA: getChapterOptions / getKnowledgePointOptions
|
||||
DA->>TDA: getChaptersByTextbookId / getKnowledgePointsByTextbookId
|
||||
TDA-->>DA: 章节列表 / 知识点列表
|
||||
DA-->>Filter: 选项数据
|
||||
|
||||
U->>Table: 输入关键词 + 触发检索
|
||||
Table->>Act: getQuestionsAction({q, knowledgePointId, ...})
|
||||
Act->>DA: getQuestions(params)
|
||||
DA->>DA: toBooleanModeQuery(q) → "+word* +word*"
|
||||
DA->>DB: MATCH(content_text) AGAINST(? IN BOOLEAN MODE)
|
||||
DA->>DB: JOIN questionsToKnowledgePoints 知识点过滤
|
||||
DB-->>DA: 题目列表 + meta
|
||||
DA-->>Table: {data, meta}
|
||||
```
|
||||
|
||||
### 流程 2: 创建题目(含复合题递归子题)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant Dlg as create-question-dialog
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Dlg: 填写题目(content/type/difficulty/KP)
|
||||
Dlg->>Dlg: CreateQuestionSchema 递归校验
|
||||
Note over Dlg: composite 类型可含 subQuestions<br/>z.lazy 递归 schema
|
||||
Dlg->>Act: createQuestionAction(JSON)
|
||||
Act->>Act: requirePermission(QUESTION_CREATE)
|
||||
Act->>Act: CreateQuestionSchema.safeParse
|
||||
Act->>DA: createQuestionWithRelations(input, userId)
|
||||
DA->>DB: BEGIN TRANSACTION
|
||||
DA->>DB: INSERT questions(主题)
|
||||
alt composite
|
||||
DA->>DB: INSERT questions(子题, parentId=主题)
|
||||
end
|
||||
DA->>DB: INSERT questionsToKnowledgePoints(多对多)
|
||||
DA->>DB: COMMIT
|
||||
DA-->>Act: questionId
|
||||
Act->>Act: trackQuestionCreated
|
||||
Act-->>Dlg: ActionState<{id}>
|
||||
```
|
||||
|
||||
### 流程 3: 批量导入导出
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[import-export-buttons] --> B{操作类型}
|
||||
B -->|导出| C[exportQuestionsAction]
|
||||
C --> D[data-access exportQuestions<br/>按筛选条件批量查询]
|
||||
D --> E[序列化为 QuestionExportItem[]]
|
||||
E --> F[下载文件]
|
||||
B -->|导入| G[importQuestionsAction]
|
||||
G --> H[解析上传文件]
|
||||
H --> I[逐条 CreateQuestionSchema 校验]
|
||||
I --> J{校验通过?}
|
||||
J -->|是| K[createQuestionWithRelations]
|
||||
J -->|否| L[收集错误行]
|
||||
K --> M[返回成功/失败计数]
|
||||
L --> M
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | questions / questionsToKnowledgePoints 表 CRUD + 事务 |
|
||||
| 全文检索 | MySQL FULLTEXT BOOLEAN MODE | MATCH(content_text) AGAINST 替代 LIKE,前缀匹配 |
|
||||
| 缓存 | cacheFn | getQuestions / exportQuestions 请求级去重(39 处) |
|
||||
| Server Action | requirePermission | QUESTION_CREATE / UPDATE / DELETE / READ 权限校验 |
|
||||
| 表单 | react-hook-form + Zod | create-question-dialog 题目表单 |
|
||||
| URL 状态 | nuqs | 题库列表筛选条件 URL 持久化 |
|
||||
| 递归 schema | z.lazy | 复合题(composite)subQuestions 递归校验 |
|
||||
| 事务 | db.transaction | createQuestionWithRelations 主题+子题+关联原子写入 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/questions/
|
||||
├─ actions.ts # Server Actions (375 行): create/update/delete/batchDelete/get/options/import/export
|
||||
├─ data-access.ts # 数据访问 (828 行): CRUD + FULLTEXT 检索 + 选项查询 + 递归删除
|
||||
├─ schema.ts # Zod 校验 (19 行): QuestionTypeEnum + 递归 CreateQuestionSchema
|
||||
├─ types.ts # 类型定义 (82 行): Question / QuestionType variant / 难度映射
|
||||
├─ utils/
|
||||
│ ├─ parse-content.ts # 内容类型守卫 (129 行): QuestionContent / QuestionOption
|
||||
│ └─ track-event.ts # 埋点工具 (95 行): trackQuestionCreated/Updated/Deleted
|
||||
└─ components/ # 12 个组件
|
||||
├─ create-question-dialog.tsx # 创建对话框 (281 行)
|
||||
├─ question-actions.tsx # 行操作 (205 行)
|
||||
├─ import-export-buttons.tsx # 导入导出 (194 行)
|
||||
├─ question-data-table.tsx # 题目表格 (183 行)
|
||||
├─ question-cascade-filter.tsx # 级联筛选 (155 行)
|
||||
├─ question-columns.tsx # 列定义 (134 行)
|
||||
├─ batch-operations.tsx # 批量操作 (122 行)
|
||||
├─ question-content-renderer.tsx # 内容渲染 (117 行)
|
||||
├─ knowledge-point-selector.tsx # 知识点选择 (108 行)
|
||||
├─ options-editor.tsx # 选项编辑 (107 行)
|
||||
├─ question-bank-results-client.tsx # 客户端结果容器 (21 行)
|
||||
└─ create-question-button.tsx # 创建按钮 (30 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| textbooks | data-access-call | 复用 getChaptersByTextbookId / getKnowledgePointsByTextbookId / getTextbooks / getKnowledgePointNamesByIds,不直查 textbooks 表 |
|
||||
| shared | import | DB / cache / auth-guard / action-utils / status-badge |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| textbooks | data-access-call + import | 知识点关联题目反查(双向依赖) |
|
||||
| exams | data-access-call + import | 试卷引用题库题目组卷 |
|
||||
| lesson-preparation | data-access-call + import | 备课引用题目 |
|
||||
| diagnostic | data-access-call + import | 诊断测试取题 |
|
||||
| error-book | data-access-call | 错题本关联原题 |
|
||||
| dashboard | data-access-call + import | 首页题目统计 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **全文检索必须用 toBooleanModeQuery**: 将用户输入转换为 `+word*` BOOLEAN MODE 格式,转义特殊字符,禁止直接拼接 SQL
|
||||
- **章节/知识点/教材选项必须经 textbooks data-access**: 不直查 textbooks/chapters/knowledgePoints 表,遵循模块间通过 data-access 通信规则
|
||||
- **所有 Server Action 必须经 requirePermission**: createQuestionAction 等全部调用,返回 ActionState
|
||||
- **题目 content 存为 JSON(unknown)**: 转具体类型必须用 utils/parse-content.ts 类型守卫,禁止 `as` 断言
|
||||
- **canEditAll 仅 admin 可用**: `ctx.dataScope.type === "all"` 才能编辑/删除他人题目,其他角色受限
|
||||
- **复合题(composite)支持递归子题**: CreateQuestionSchema 用 z.lazy 实现 subQuestions 递归,deleteQuestionByIdRecursive 递归删除子题
|
||||
- **data-access.ts 828 行已接近上限**: 新增查询应考虑拆分子文件
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| MySQL FULLTEXT BOOLEAN MODE 检索 | 前缀匹配兼顾性能与体验,无需引入 Elasticsearch 等外部搜索引擎,降低运维成本 |
|
||||
| 题目与知识点多对多 questionsToKnowledgePoints | 一题可关联多知识点,支持按知识点维度检索与统计 |
|
||||
| 跨模块复用 textbooks data-access | 避免重复实现教材/章节/知识点查询,遵循三层架构模块间通信规则 |
|
||||
| createQuestionWithRelations 用事务 | 主题+子题+关联表必须原子写入,部分失败回滚避免脏数据 |
|
||||
| 复合题 z.lazy 递归 schema | composite 类型子题数量不定,递归 schema 支持任意层级嵌套 |
|
||||
| deleteQuestionByIdRecursive 递归删除 | 复合题删除主题必须级联删除所有子题,避免孤儿数据 |
|
||||
| cacheFn 包装读函数 | getQuestions / exportQuestions 高频查询,请求级去重避免重复 DB 调用 |
|
||||
228
src/modules/rbac/README.md
Normal file
228
src/modules/rbac/README.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# rbac 角色权限模块
|
||||
|
||||
> 角色 CRUD、角色-权限映射、用户-角色分配、权限目录(PERMISSION_CATALOG)维护与数据范围(DataScope)解析
|
||||
> 代码规模: 18 文件 · 75 符号 · 13,396 行(arch.db)
|
||||
> 模块路径: `src/modules/rbac/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Mgmt["role-management-view.tsx<br/>(31 行, 入口)"]
|
||||
List["role-list.tsx<br/>(240 行, 角色列表)"]
|
||||
Matrix["role-permission-matrix.tsx<br/>(303 行, 权限矩阵)"]
|
||||
Catalog["permission-catalog-view.tsx<br/>(84 行)"]
|
||||
Form["role-form-dialog.tsx<br/>(139 行)"]
|
||||
Assign["user-role-assign-dialog.tsx<br/>(187 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(396 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(248 行, 角色 CRUD)"]
|
||||
DAAsg["data-access-assignments.ts<br/>(178 行, 用户分配)"]
|
||||
DAPerm["data-access-permissions.ts<br/>(42 行, 权限查询)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Catalog2["permission-catalog.ts<br/>(274 行, 单一来源)"]
|
||||
ScopeResolver["data-scope-resolver.ts<br/>(111 行, 规则链)"]
|
||||
ScopeCfg["data-scope-config.ts<br/>(27 行)"]
|
||||
Track["track.ts<br/>(45 行, trackPermissionChange)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Roles[(roles)]
|
||||
RolePerms[(role_permissions)]
|
||||
UserRoles[(user_roles)]
|
||||
end
|
||||
|
||||
Mgmt --> List
|
||||
Mgmt --> Matrix
|
||||
Mgmt --> Catalog
|
||||
List --> Form
|
||||
List --> Assign
|
||||
Form --> Actions
|
||||
Assign --> Actions
|
||||
Matrix --> Actions
|
||||
Catalog --> Catalog2
|
||||
|
||||
Actions --> DA
|
||||
Actions --> DAAsg
|
||||
Actions --> DAPerm
|
||||
Actions --> Track
|
||||
DA --> Roles
|
||||
DA --> RolePerms
|
||||
DAAsg --> UserRoles
|
||||
DAPerm --> RolePerms
|
||||
DAPerm --> Catalog2
|
||||
ScopeResolver --> ScopeCfg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 创建角色 + 设置权限
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as 管理员
|
||||
participant Form as role-form-dialog
|
||||
participant Matrix as role-permission-matrix
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
A->>Form: 填写名称/描述
|
||||
Form->>Act: createRoleAction
|
||||
Act->>Act: requirePermission(ROLE_CREATE)
|
||||
Act->>DA: createRole
|
||||
DA->>DB: INSERT roles
|
||||
Act->>Act: after(logAudit + logDataChange)
|
||||
Act->>Act: invalidateFor(rbac.role.create)
|
||||
Act-->>Form: ActionState<RoleRecord>
|
||||
|
||||
A->>Matrix: 勾选权限
|
||||
Matrix->>Act: setRolePermissionsAction(roleId, perm[])
|
||||
Act->>Act: requirePermission(ROLE_UPDATE)
|
||||
Act->>Act: 校验 name !== ADMIN_ROLE_NAME
|
||||
Act->>DA: setRolePermissions(替换 role_permissions)
|
||||
DA->>DB: DELETE + INSERT role_permissions
|
||||
Act->>Track: trackPermissionChange
|
||||
Act-->>Matrix: ActionState
|
||||
```
|
||||
|
||||
### 流程 2: 用户角色分配 + 缓存失效
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as 管理员
|
||||
participant Assign as user-role-assign-dialog
|
||||
participant Act as actions.ts
|
||||
participant DAAsg as data-access-assignments
|
||||
participant Track as track.ts
|
||||
participant Cache as invalidateFor
|
||||
participant DB as MySQL
|
||||
|
||||
A->>Assign: 选择用户 + 角色
|
||||
Assign->>Act: assignUserRolesAction
|
||||
Act->>Act: requirePermission(ROLE_ASSIGN)
|
||||
Act->>DAAsg: assignRolesToUser(userId, roleIds)
|
||||
DAAsg->>DB: DELETE + INSERT user_roles
|
||||
DAAsg-->>Act: 完成
|
||||
Act->>Track: trackPermissionChange
|
||||
Act->>Cache: invalidateFor(user.permission, userId)
|
||||
Cache-->>Act: 用户权限缓存失效
|
||||
Act-->>Assign: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 数据范围(DataScope)解析
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[用户请求资源] --> B[auth-guard 调用<br/>resolveDataScope]
|
||||
B --> C[遍历 DATA_SCOPE_RULES]
|
||||
C --> D{匹配角色?}
|
||||
D -->|admin| E[返回 type=all]
|
||||
D -->|grade_head/teaching_head| F[getGradesForStaff]
|
||||
D -->|teacher| G[getTeacherScopeData]
|
||||
D -->|student| H[getStudentScopeData]
|
||||
D -->|parent| I[getChildren]
|
||||
F --> J{managedGrades 非空?}
|
||||
J -->|是| K[返回 grade_managed]
|
||||
J -->|否| L[fall-through 下一条规则]
|
||||
L --> D
|
||||
G --> M[返回 class_taught]
|
||||
H --> N[返回 class_enrolled]
|
||||
I --> O[返回 children]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | roles / role_permissions / user_roles 表 CRUD |
|
||||
| 缓存 | cacheFn + invalidateFor | 角色与权限查询请求级去重,写操作触发缓存失效 |
|
||||
| Server Action | requirePermission | ROLE_CREATE/UPDATE/DELETE/ASSIGN 权限校验 |
|
||||
| 校验 | Zod | CreateRoleSchema / UpdateRoleSchema / SetRolePermissionsSchema / AssignUserRolesSchema |
|
||||
| 异步副作用 | next/server after | logAudit / logDataChange 异步执行不阻塞响应 |
|
||||
| 服务端隔离 | import "server-only" | data-access 与 lib 防止客户端引用 |
|
||||
| 权限元数据 | PERMISSION_CATALOG | 权限点单一来源,UI 矩阵与查询共用 |
|
||||
| 数据范围 | DATA_SCOPE_RULES | 配置驱动的角色-数据范围规则链 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/rbac/
|
||||
├─ actions.ts # Server Actions (396 行): 角色 CRUD/权限分配/用户分配
|
||||
├─ data-access.ts # 角色数据访问 (248 行): CRUD + cacheFn
|
||||
├─ data-access-assignments.ts # 用户分配 (178 行): assignRolesToUser
|
||||
├─ data-access-permissions.ts # 权限查询 (42 行): getRolePermissions
|
||||
├─ schema.ts # Zod 验证 (43 行)
|
||||
├─ types.ts # 类型定义 (58 行): RoleRecord/RoleDetail
|
||||
├─ lib/
|
||||
│ ├─ permission-catalog.ts # 权限目录 (274 行, 单一来源)
|
||||
│ ├─ data-scope-resolver.ts # 数据范围解析 (111 行, 规则链)
|
||||
│ ├─ data-scope-config.ts # 范围规则配置 (27 行)
|
||||
│ └─ track.ts # 权限变更追踪 (45 行)
|
||||
├─ components/
|
||||
│ ├─ role-management-view.tsx # 入口容器 (31 行)
|
||||
│ ├─ role-list.tsx # 角色列表 (240 行)
|
||||
│ ├─ role-permission-matrix.tsx # 权限矩阵 (303 行)
|
||||
│ ├─ permission-catalog-view.tsx # 目录视图 (84 行)
|
||||
│ ├─ role-form-dialog.tsx # 角色表单 (139 行)
|
||||
│ ├─ user-role-assign-dialog.tsx # 用户分配 (187 行)
|
||||
│ ├─ role-detail-edit-button.tsx # 编辑按钮 (33 行)
|
||||
│ └─ error-boundary.tsx # 错误边界 (28 行)
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call | 教师任教班级 / 学生就读班级范围解析 |
|
||||
| school | import + data-access-call | 年级组长管理的年级范围 |
|
||||
| parent | import + data-access-call | 家长关联子女范围 |
|
||||
| shared | import | DB / auth-guard / audit-logger / cache |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| (无) | — | rbac 是权限基础设施,仅通过 shared/lib/auth-guard 间接被全局使用 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **admin 角色全锁**: `ADMIN_ROLE_NAME = "admin"`,任何写操作必须先校验 `name !== ADMIN_ROLE_NAME`,禁止编辑/禁用/删除
|
||||
- **权限点注册五处同步**: 新增权限点必须同步 `Permissions` 常量、`PERMISSION_CATALOG`、角色-权限矩阵 UI、守卫调用点、文档,缺一会导致 UI 漏渲染或守卫漏校验
|
||||
- **PERMISSION_CATALOG 单一来源**: 权限元数据(分组/labelKey/descriptionKey)集中定义,UI 矩阵与 data-access-permissions 共用,禁止在组件内硬编码权限列表
|
||||
- **data-access 按职责拆分**: 角色(data-access.ts)/用户分配(data-access-assignments.ts)/权限查询(data-access-permissions.ts)分离,避免单文件超长
|
||||
- **DataScope 规则顺序敏感**: `DATA_SCOPE_RULES` 数组按顺序匹配,首个 resolve 返回非 null 即胜出,空资源返回 null 允许 fall-through
|
||||
- **写操作必须三段式副作用**: logAudit + logDataChange + invalidateFor,缺一则审计缺失或缓存不失效
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| PERMISSION_CATALOG 单一来源 | 权限元数据集中,UI 与查询共用,避免硬编码分散导致漏配 |
|
||||
| data-scope-config + data-scope-resolver | 数据范围(school/class/owner)解耦,角色不再硬编码 scope,新增角色只改规则数组 |
|
||||
| data-access 按职责三分 | 角色/分配/权限查询职责清晰,避免单文件超 1000 行硬上限 |
|
||||
| after() 异步审计 | logAudit/logDataChange 不阻塞响应,提升 Server Action 性能 |
|
||||
| getRolePermissions 只返回 catalog 中存在的权限 | 防止 DB 中残留已废弃权限点导致 UI 渲染异常 |
|
||||
251
src/modules/scheduling/README.md
Normal file
251
src/modules/scheduling/README.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# scheduling 排课模块
|
||||
|
||||
> 课表自动排课(贪心算法 + 约束规则)、人工调课申请与审批、冲突检测(教师/教室/班级时间重叠)
|
||||
> 代码规模: 14 文件 · 109 符号 · 2,785 行(arch.db)
|
||||
> 模块路径: `src/modules/scheduling/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
RulesForm["scheduling-rules-form<br/>规则配置 (236 行)"]
|
||||
AutoPanel["auto-schedule-panel<br/>自动排课 (121 行)"]
|
||||
AutoResult["auto-schedule-result<br/>排课结果 (95 行)"]
|
||||
GridView["schedule-grid-view<br/>课表网格 (177 行)"]
|
||||
ChgForm["schedule-change-form<br/>调课申请 (209 行)"]
|
||||
ChgList["schedule-change-list<br/>调课列表 (220 行)"]
|
||||
Conflict["schedule-conflicts-view<br/>冲突视图 (138 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(276 行, Server Actions)"]
|
||||
Scheduler["auto-scheduler.ts<br/>(312 行, 贪心排课算法)"]
|
||||
end
|
||||
|
||||
subgraph DA["Data Access"]
|
||||
DA["data-access.ts<br/>(488 行, 规则/调课/冲突)"]
|
||||
DACls["data-access-class-schedule.ts<br/>(127 行, classSchedule 写入口)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Valid["lib/schedule-validation.ts<br/>(159 行, 字段校验纯函数)"]
|
||||
Schema["schema.ts<br/>(82 行, Zod)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Rules[(scheduling_rules)]
|
||||
Changes[(schedule_changes)]
|
||||
ClsSch[(class_schedule)]
|
||||
end
|
||||
|
||||
RulesForm --> Actions
|
||||
AutoPanel --> Actions
|
||||
AutoResult --> AutoPanel
|
||||
GridView --> Actions
|
||||
ChgForm --> Actions
|
||||
ChgList --> Actions
|
||||
Conflict --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Scheduler
|
||||
Scheduler --> Valid
|
||||
DA --> DACls
|
||||
DA --> Rules
|
||||
DA --> Changes
|
||||
DACls --> ClsSch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 自动排课(贪心算法 + 约束)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师/管理员
|
||||
participant Panel as auto-schedule-panel
|
||||
participant Act as actions.ts
|
||||
participant Sch as auto-scheduler.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Panel: 选择班级 + 点击"自动排课"
|
||||
Panel->>Act: autoScheduleAction (classId)
|
||||
Act->>Act: requirePermission(SCHEDULE_AUTO)
|
||||
Act->>DA: getSchedulingRules(classId)
|
||||
DA-->>Act: rules(回退全局规则)
|
||||
Act->>DA: getClassSubjectsForScheduling(classId)
|
||||
DA-->>Act: 科目 + 教师分配
|
||||
Act->>DA: getClassroomsForScheduling
|
||||
DA-->>Act: 候选教室
|
||||
Act->>Sch: autoSchedule(params, rules)
|
||||
Sch->>Sch: buildDefaultTimeSlots(按规则生成时段)
|
||||
loop 每个科目课时
|
||||
Sch->>Sch: findOptimalSlot(贪心:首个满足约束的时段)
|
||||
Note over Sch: 约束: 午休/每日上限/避免连堂/学科均衡
|
||||
end
|
||||
Sch-->>Act: AutoScheduleResult {schedule, conflicts}
|
||||
Act->>DA: replaceClassSchedule(classId, schedule)
|
||||
DA->>DB: DELETE 旧 + INSERT 新 class_schedule
|
||||
Act->>Act: invalidateFor("scheduling.auto")
|
||||
Act-->>Panel: ActionState<AutoScheduleResult>
|
||||
```
|
||||
|
||||
### 流程 2: 人工调课申请 + 审批
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Form as schedule-change-form
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Admin as 管理员
|
||||
participant List as schedule-change-list
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Form: 填写调课申请(代课/改时间)
|
||||
Form->>Act: createScheduleChangeAction (FormData)
|
||||
Act->>Act: requirePermission(SCHEDULE_ADJUST)
|
||||
Act->>Act: ScheduleChangeSchema.safeParse
|
||||
Act->>DA: createScheduleChange(input)
|
||||
DA->>DB: INSERT schedule_changes (status=pending)
|
||||
Act->>Act: invalidateFor("scheduling.change.create")
|
||||
Act-->>Form: ActionState
|
||||
|
||||
Admin->>List: 查看待审批调课
|
||||
List->>Act: getScheduleChangesAction
|
||||
Act->>DA: getScheduleChanges(query)
|
||||
DA-->>Act: ScheduleChangeListItem[]
|
||||
Act-->>List: 列表
|
||||
|
||||
Admin->>List: 审批(通过/拒绝)
|
||||
List->>Act: updateScheduleChangeStatusAction(id, status)
|
||||
Act->>Act: requirePermission(SCHEDULE_ADJUST)
|
||||
Act->>DA: updateScheduleChangeStatus(id, approved/rejected)
|
||||
DA->>DB: UPDATE schedule_changes SET status, approvedBy
|
||||
Act-->>List: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 冲突检测(教师/教室/班级时间重叠)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[写入课表前] --> B[data-access.ts<br/>getClassConflicts]
|
||||
B --> C[lib/schedule-validation.ts<br/>isOverlap 时间重叠]
|
||||
C --> D{检测维度}
|
||||
D --> E[教师冲突<br/>同教师同时段多班]
|
||||
D --> F[教室冲突<br/>同教室同时段多课]
|
||||
D --> G[班级冲突<br/>同班级同时段多课]
|
||||
E --> H{有冲突?}
|
||||
F --> H
|
||||
G --> H
|
||||
H -- 是 --> I[返回 ScheduleConflict[]<br/>阻止写入]
|
||||
H -- 否 --> J[允许写入 class_schedule]
|
||||
```
|
||||
|
||||
### 流程 4: 排课规则配置
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[管理员配置规则] --> B[scheduling-rules-form]
|
||||
B --> C[saveSchedulingRulesAction]
|
||||
C --> D[requirePermission<br/>SCHEDULE_ADJUST]
|
||||
D --> E[SchedulingRuleSchema.safeParse]
|
||||
E --> F[upsertSchedulingRules]
|
||||
F --> G[INSERT/UPDATE scheduling_rules]
|
||||
G --> H[invalidateFor]
|
||||
|
||||
I[规则字段] --> J[每日最大课时<br/>maxDailyHours]
|
||||
I --> K[连堂上限<br/>maxContinuousHours]
|
||||
I --> L[午休时段<br/>lunchBreakStart/End]
|
||||
I --> M[每日窗口<br/>morningStart/afternoonEnd]
|
||||
I --> N[避免连堂<br/>avoidBackToBack]
|
||||
I --> O[学科均衡<br/>balancedSubjects]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | scheduling_rules / schedule_changes / class_schedule 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重 |
|
||||
| 排课算法 | 自研贪心算法 | auto-scheduler.ts findOptimalSlot 满足约束的首个时段 |
|
||||
| 校验 | 纯函数 schedule-validation | HH:MM 格式 / weekday 范围 / 时间区间校验 |
|
||||
| 表单 | Zod schema | SchedulingRule / ScheduleChange / AutoScheduleParams 验证 |
|
||||
| Server Action | requirePermission | SCHEDULE_ADJUST / SCHEDULE_AUTO 双权限点 |
|
||||
| 缓存失效 | invalidateFor | 按 scheduling.{saveRules/auto/change.*} 精确失效 |
|
||||
| 跨模块资源 | 对方 data-access 调用 | classes 校验班级归属 / school 获取教室科目 / users 教师姓名 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/scheduling/
|
||||
├─ actions.ts # Server Actions (276 行): 规则/排课/调课/冲突
|
||||
├─ data-access.ts # 数据访问层 (488 行): 规则/调课/冲突检测
|
||||
├─ data-access-class-schedule.ts # classSchedule 写入口 (127 行): replaceClassSchedule
|
||||
├─ auto-scheduler.ts # 贪心排课算法 (312 行): findOptimalSlot/autoSchedule
|
||||
├─ schema.ts # Zod 验证 (82 行)
|
||||
├─ types.ts # 类型定义 (145 行): Rule/Change/Conflict/TimeSlot
|
||||
├─ lib/
|
||||
│ └─ schedule-validation.ts # 纯校验函数 (159 行): isTimeHHMM/isOverlap/normalize
|
||||
└─ components/ # 7 个客户端组件
|
||||
├─ schedule-change-list.tsx # 调课列表 (220 行)
|
||||
├─ scheduling-rules-form.tsx # 规则配置 (236 行)
|
||||
├─ schedule-change-form.tsx # 调课申请 (209 行)
|
||||
├─ schedule-grid-view.tsx # 课表网格 (177 行)
|
||||
├─ schedule-conflicts-view.tsx # 冲突视图 (138 行)
|
||||
├─ auto-schedule-panel.tsx # 自动排课面板 (121 行)
|
||||
└─ auto-schedule-result.tsx # 排课结果 (95 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call | 校验班级归属 / 获取班级学生 |
|
||||
| school | data-access-call + import | 获取教室 getClassroomsForScheduling / 科目 |
|
||||
| users | data-access-call + import | getUserNamesByIds 教师/代课教师姓名 |
|
||||
| shared | import | DB / cache / auth-guard |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| (无) | - | 仅 app 路由层直接调用,无业务模块反向依赖 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **classSchedule 写入口唯一化**: `replaceClassSchedule` 是 class_schedule 表唯一写入口,其他模块不得直接写入,避免双写导致归属与冲突校验缺失
|
||||
- **排课算法独立成 auto-scheduler.ts**: 贪心算法与数据访问分离,可单独测试与替换为其他算法
|
||||
- **冲突检测在 data-access 层**: 保证写入口与冲突校验在同一事务边界,`getClassConflicts` 在写入前必检
|
||||
- **校验纯函数抽离**: `lib/schedule-validation.ts` 为纯函数无 DB 访问(A-02 修复),便于单测
|
||||
- **规则回退机制**: 班级无规则时回退到全局规则(classId=null),保证排课可执行
|
||||
- **双权限点**: SCHEDULE_AUTO(自动排课)/SCHEDULE_ADJUST(规则配置+调课)分离
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| classSchedule 写入口唯一化 | 避免 classes 模块双写导致归属与冲突校验缺失 |
|
||||
| 排课算法独立 auto-scheduler.ts | 算法逻辑可单独测试与替换,与数据访问解耦 |
|
||||
| 冲突检测在 data-access 层 | 保证写入口与校验在同一事务边界,防御性编程 |
|
||||
| 贪心算法而非约束求解器 | K12 排课规模小,贪心满足约束即可,避免引入 OR-Tools 等重依赖 |
|
||||
| 校验纯函数抽离到 lib | data-access 只做 DB 操作,字段校验纯函数易测试(A-02 修复) |
|
||||
| 规则回退到全局 | 班级未配规则时回退全局,保证排课流程不中断 |
|
||||
288
src/modules/school/README.md
Normal file
288
src/modules/school/README.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# school 学校管理模块
|
||||
|
||||
> 管理学校、学年学期、年级、部门、科目、教室等组织基础数据,提供角色感知查询与年级升级,是多数业务模块的基础依赖
|
||||
> 代码规模: 29 文件 · 138 符号 · 4,387 行(arch.db)
|
||||
> 模块路径: `src/modules/school/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
SchView["schools-view<br/>学校视图 (133 行)"]
|
||||
GrView["grades-view<br/>年级视图 (295 行)"]
|
||||
AcYrView["academic-year-view<br/>学年视图 (318 行)"]
|
||||
DepView["departments-view<br/>部门视图 (250 行)"]
|
||||
OrgTree["org-tree-nav<br/>组织树导航 (149 行)"]
|
||||
SchDlg["school-form-dialog<br/>(113 行)"]
|
||||
GrDlg["grade-form-dialog<br/>(329 行)"]
|
||||
end
|
||||
|
||||
subgraph Dashboard["年级仪表盘(多面板)"]
|
||||
GdDist["grade-distribution-panel<br/>(116 行)"]
|
||||
GdExam["grade-exams-panel<br/>(107 行)"]
|
||||
GdHw["grade-homework-panel<br/>(141 行)"]
|
||||
GdProg["grade-progress-panel<br/>(136 行)"]
|
||||
GdCards["grade-overview-cards<br/>(155 行)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseSch["use-school-data<br/>(43 行)"]
|
||||
UseGr["use-grade-data<br/>(43 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(457 行, CRUD + promoteGrades + audit)"]
|
||||
Perm["lib/school-permissions.ts<br/>(157 行, 角色感知查询)"]
|
||||
end
|
||||
|
||||
subgraph DA["Data Access(按实体拆分 7 文件 + barrel)"]
|
||||
DAMain["data-access.ts<br/>(80 行, barrel 重新导出)"]
|
||||
DASch["data-access-schools.ts<br/>(161 行)"]
|
||||
DAGr["data-access-grades.ts<br/>(454 行)"]
|
||||
DADep["data-access-departments.ts<br/>(65 行)"]
|
||||
DASub["data-access-subjects.ts<br/>(98 行)"]
|
||||
DACls["data-access-classrooms.ts<br/>(42 行)"]
|
||||
DASem["data-access-semesters.ts<br/>(86 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Schools[(schools)]
|
||||
Grades[(grades)]
|
||||
Deps[(departments)]
|
||||
Subj[(subjects)]
|
||||
AcYr[(academic_years)]
|
||||
end
|
||||
|
||||
SchView --> UseSch
|
||||
GrView --> UseGr
|
||||
AcYrView --> Actions
|
||||
DepView --> Actions
|
||||
OrgTree --> UseSch
|
||||
SchDlg --> Actions
|
||||
GrDlg --> Actions
|
||||
GdDist --> UseGr
|
||||
GdProg --> UseGr
|
||||
|
||||
UseSch --> Actions
|
||||
UseGr --> Actions
|
||||
Actions --> DAMain
|
||||
Actions --> Perm
|
||||
Perm --> DASch
|
||||
Perm --> DAGr
|
||||
DAMain --> DASch
|
||||
DAMain --> DAGr
|
||||
DAMain --> DADep
|
||||
DASch --> Schools
|
||||
DAGr --> Grades
|
||||
DADep --> Deps
|
||||
DASub --> Subj
|
||||
DASem --> AcYr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 组织基础数据 CRUD(含审计日志)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as 管理员
|
||||
participant Dlg as grade-form-dialog
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access-grades.ts
|
||||
participant Audit as shared/audit-logger
|
||||
participant DB as MySQL
|
||||
|
||||
Admin->>Dlg: 填写年级信息
|
||||
Dlg->>Act: createGradeAction (FormData)
|
||||
Act->>Act: requirePermission(SCHOOL_MANAGE)
|
||||
Act->>Act: UpsertGradeSchema.safeParse
|
||||
Act->>DA: createGrade(input)
|
||||
DA->>DB: INSERT grades
|
||||
DA-->>Act: 完成
|
||||
Act->>Audit: after(() => logAudit)
|
||||
Note over Act,Audit: 异步审计日志<br/>action=grade.create
|
||||
Act->>Act: invalidateFor("school.grade.create")
|
||||
Act-->>Dlg: ActionState<string>
|
||||
```
|
||||
|
||||
### 流程 2: 角色感知查询(getGradesForUser)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[页面加载] --> B[lib/school-permissions.ts<br/>getGradesForUser(userId)]
|
||||
B --> C[查用户角色 roles]
|
||||
C --> D{角色判断}
|
||||
D -- admin --> E[getGrades 返回全量]
|
||||
D -- grade_head/teaching_head --> F[getGradesForStaff<br/>按负责年级过滤]
|
||||
D -- teacher --> G[按任课班级所在年级过滤]
|
||||
D -- 其他 --> H[返回空数组]
|
||||
E --> I[GradeListItem[]]
|
||||
F --> I
|
||||
G --> I
|
||||
H --> I
|
||||
|
||||
Note over B,D: 审计 G3-011 修复:<br/>角色判断从 data-access 迁出至 lib<br/>data-access 只接受显式参数
|
||||
```
|
||||
|
||||
### 流程 3: 学年末年级升级(事务)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as 管理员
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access-grades.ts
|
||||
participant DB as MySQL
|
||||
|
||||
Admin->>Act: promoteGradesAction (academicYearId)
|
||||
Act->>Act: requirePermission(SCHOOL_MANAGE)
|
||||
Act->>Act: PromoteGradesSchema.safeParse
|
||||
Act->>DA: promoteGrades(academicYearId)
|
||||
DA->>DB: BEGIN TRANSACTION
|
||||
loop 遍历每个年级
|
||||
DA->>DB: UPDATE students SET gradeId = nextGrade
|
||||
end
|
||||
DA->>DB: COMMIT
|
||||
Note over DA,DB: db.transaction 包裹<br/>保证升级原子性<br/>避免部分升级导致数据不一致
|
||||
DA-->>Act: 升级学生数
|
||||
Act->>Act: invalidateFor("school.promote")
|
||||
Act-->>Admin: ActionState<number>
|
||||
```
|
||||
|
||||
### 流程 4: 年级仪表盘多面板聚合
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[管理员进入年级仪表盘] --> B[use-grade-data 加载]
|
||||
B --> C[getGradeDashboardAction(gradeId)]
|
||||
C --> D[requirePermission + getGradesForUser 校验]
|
||||
D --> E[并行查询多维度数据]
|
||||
E --> F[grade-overview-cards<br/>概览卡片]
|
||||
E --> G[grade-distribution-panel<br/>分布面板]
|
||||
E --> H[grade-exams-panel<br/>考试面板]
|
||||
E --> I[grade-homework-panel<br/>作业面板]
|
||||
E --> J[grade-progress-panel<br/>课程进度面板]
|
||||
F --> K[grades-view 渲染]
|
||||
G --> K
|
||||
H --> K
|
||||
I --> K
|
||||
J --> K
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | schools/grades/departments/subjects/academic_years 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重 |
|
||||
| 审计日志 | logAudit + after() | 异步记录组织变更操作(不阻塞响应) |
|
||||
| 状态 | nuqs | 年级/学校列表 URL 状态同步 |
|
||||
| Server Action | requirePermission | SCHOOL_MANAGE 权限校验 |
|
||||
| 缓存失效 | invalidateFor | 按 school.{entity.create/update/delete} 精确失效 |
|
||||
| 事务 | db.transaction | promoteGrades 年级升级原子性保证 |
|
||||
| Hooks | React Hooks | use-school-data / use-grade-data 数据加载 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/school/
|
||||
├─ actions.ts # Server Actions (457 行): CRUD + promoteGrades + 审计
|
||||
├─ data-access.ts # Barrel 重新导出 (80 行)
|
||||
├─ data-access-schools.ts # 学校数据访问 (161 行)
|
||||
├─ data-access-grades.ts # 年级数据访问 (454 行): 含 promoteGrades 事务
|
||||
├─ data-access-departments.ts # 部门数据访问 (65 行)
|
||||
├─ data-access-subjects.ts # 科目数据访问 (98 行)
|
||||
├─ data-access-classrooms.ts # 教室数据访问 (42 行)
|
||||
├─ data-access-semesters.ts # 学期数据访问 (86 行)
|
||||
├─ schema.ts # Zod 验证 (57 行): Upsert/Promote schema
|
||||
├─ types.ts # 类型定义 (104 行): ListItem/InsertData/UpdateData
|
||||
├─ lib/
|
||||
│ └─ school-permissions.ts # 角色感知查询 (157 行): getSchoolsForUser/getGradesForUser
|
||||
├─ hooks/
|
||||
│ ├─ use-school-data.ts # 学校数据 Hook (43 行)
|
||||
│ └─ use-grade-data.ts # 年级数据 Hook (43 行)
|
||||
└─ components/ # 16 个组件
|
||||
├─ grades-view.tsx # 年级视图 (295 行)
|
||||
├─ academic-year-view.tsx # 学年视图 (318 行)
|
||||
├─ grade-form-dialog.tsx # 年级表单 (329 行)
|
||||
├─ departments-view.tsx # 部门视图 (250 行)
|
||||
├─ grade-overview-cards.tsx # 概览卡片 (155 行)
|
||||
├─ org-tree-nav.tsx # 组织树导航 (149 行)
|
||||
├─ schools-view.tsx # 学校视图 (133 行)
|
||||
├─ grade-dashboard/ # 年级仪表盘(4 面板)
|
||||
│ ├─ grade-homework-panel.tsx # 作业面板 (141 行)
|
||||
│ ├─ grade-progress-panel.tsx # 进度面板 (136 行)
|
||||
│ ├─ grade-distribution-panel.tsx # 分布面板 (116 行)
|
||||
│ └─ grade-exams-panel.tsx # 考试面板 (107 行)
|
||||
├─ school-form-dialog.tsx # 学校表单 (113 行)
|
||||
├─ grade-list-toolbar.tsx # 年级工具栏 (112 行)
|
||||
├─ grade-delete-dialog.tsx # 删除确认 (72 行)
|
||||
├─ school-delete-dialog.tsx # 学校删除 (72 行)
|
||||
└─ school-list-toolbar.tsx # 学校工具栏 (42 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | 年级升级时关联班级学生 |
|
||||
| course-plans | import | 引用课程计划进度 |
|
||||
| exams | import | 年级仪表盘考试维度 |
|
||||
| grades | import | 年级仪表盘成绩维度 |
|
||||
| shared | import | DB / cache / auth-guard / audit-logger |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| announcements | data-access-call | 公告按学校范围发布 |
|
||||
| attendance | data-access-call + import | 考勤按学校/年级过滤 |
|
||||
| classes | data-access-call + import | 班级关联年级/学校 |
|
||||
| course-plans | data-access-call | 课程计划按年级查班级 |
|
||||
| diagnostic | data-access-call + import | 年级诊断报告聚合 |
|
||||
| elective | data-access-call + import | 选课按学校/年级 |
|
||||
| exams | data-access-call + import | 考试关联年级 |
|
||||
| grades | data-access-call + import | 成绩按年级聚合 |
|
||||
| homework | data-access-call + import | 作业按学校/年级 |
|
||||
| lesson-preparation | import | 备课引用学校数据 |
|
||||
| parent | data-access-call + import | 家长按学校/年级 |
|
||||
| rbac | data-access-call + import | 权限按学校范围 |
|
||||
| scheduling | data-access-call + import | 排课按学校/年级/科目 |
|
||||
| textbooks | data-access-call + import | 教材按年级/学科过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **角色判断外迁至 lib**: `lib/school-permissions.ts` 收敛角色判断逻辑,data-access 只接受显式参数(审计 G3-011 修复)
|
||||
- **data-access 按实体拆分 7 文件 + barrel**: 原 938 行拆分后单文件 < 500 行,`data-access.ts` 作为 barrel 保持调用方零改动
|
||||
- **promoteGrades 用 db.transaction 包裹**: 循环 UPDATE 在单事务内,保证升级原子性,避免部分升级数据不一致
|
||||
- **审计日志异步**: `after(() => logAudit())` 不阻塞响应,记录组织变更操作
|
||||
- **data-access 移除 try-catch 错误吞没**: 让错误向上抛出(审计 G3-010 修复),由 actions 层统一处理
|
||||
- **基础模块定位**: 14 个模块依赖本模块,变更需谨慎评估影响面
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 角色判断外迁至 lib/school-permissions.ts | data-access 保持纯数据访问,可测试性提升,职责单一 |
|
||||
| data-access 按实体拆分 + barrel 重新导出 | 原 938 行拆分后单文件 < 500 行,调用方零改动 |
|
||||
| promoteGrades 用 db.transaction 包裹 | 避免部分升级导致数据不一致,保证原子性 |
|
||||
| 审计日志用 after() 异步执行 | 不阻塞主响应,提升用户体验,日志失败不影响业务 |
|
||||
| 年级仪表盘多面板拆分 | 4 个维度(distribution/exams/homework/progress)独立面板,便于维护与按需加载 |
|
||||
| 组织树导航 org-tree-nav | 学校→年级→班级树形结构,统一组织数据导航入口 |
|
||||
166
src/modules/search/README.md
Normal file
166
src/modules/search/README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# search 全局搜索模块
|
||||
|
||||
> 跨 4 表(questions/textbooks/exams/announcements)全文检索聚合,供 API route 直接调用,questions 用 FULLTEXT 索引其他用 LIKE
|
||||
> 代码规模: 2 文件 · 18 符号 · 3,782 行(arch.db)
|
||||
> 模块路径: `src/modules/search/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph API["API 路由(调用方)"]
|
||||
Route["app/api/search/route.ts<br/>(聚合 + 角色过滤)"]
|
||||
end
|
||||
|
||||
subgraph Module["search 模块"]
|
||||
DA["data-access.ts<br/>(261 行, 4 个 Raw 查询)"]
|
||||
Types["types.ts<br/>(32 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表 + 索引"]
|
||||
Questions[(questions + FULLTEXT 索引<br/>content_text 生成列)]
|
||||
Textbooks[(textbooks + LIKE)]
|
||||
Exams[(exams + LIKE)]
|
||||
Announcements[(announcements + LIKE)]
|
||||
end
|
||||
|
||||
subgraph Shared["shared"]
|
||||
DBLib["db / schema"]
|
||||
Cache["cacheFn"]
|
||||
end
|
||||
|
||||
Route --> DA
|
||||
Route --> Types
|
||||
DA --> DBLib
|
||||
DA --> Cache
|
||||
DA -->|MATCH AGAINST<br/>BOOLEAN MODE| Questions
|
||||
DA -->|LIKE %q%| Textbooks
|
||||
DA -->|LIKE %q%| Exams
|
||||
DA -->|LIKE %q%| Announcements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 全局搜索请求(4 表并行 + 聚合)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Route as api/search/route
|
||||
participant Auth as getAuthContext
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Route: GET /api/search?q=xx&type=all
|
||||
Route->>Auth: getAuthContext 校验身份
|
||||
Auth-->>Route: ctx(含 roles)
|
||||
Route->>DA: searchQuestionsRaw(q, limit)
|
||||
DA->>DB: MATCH(content_text) AGAINST(+q* IN BOOLEAN MODE)
|
||||
DA-->>Route: SearchResultItem[]
|
||||
Route->>DA: searchTextbooksRaw(q, limit)
|
||||
DA->>DB: SELECT ... WHERE title LIKE %q%
|
||||
DA-->>Route: SearchResultItem[]
|
||||
Route->>DA: searchExamsRaw(q, limit)
|
||||
DA->>DB: SELECT ... WHERE title LIKE %q%
|
||||
DA-->>Route: SearchResultItem[]
|
||||
Route->>DA: searchAnnouncementsRaw(q, limit)
|
||||
DA->>DB: SELECT ... WHERE title/content LIKE %q%
|
||||
DA-->>Route: SearchResultItem[]
|
||||
Route->>Route: 角色过滤(学生移除 question/exam)
|
||||
Route->>Route: 聚合 + 排序 + 分页
|
||||
Route-->>U: SearchResponse JSON
|
||||
```
|
||||
|
||||
### 流程 2: FULLTEXT BOOLEAN MODE 查询转换
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[原始查询 'math question'] --> B[toBooleanModeQuery]
|
||||
B --> C[按空白拆分词]
|
||||
C --> D[转义特殊字符 + - > ~ * 等]
|
||||
D --> E[每个词加 + 前缀和 * 后缀]
|
||||
E --> F['+math* +question*']
|
||||
F --> G[MATCH AGAINST BOOLEAN MODE]
|
||||
G --> H[利用 FULLTEXT 索引]
|
||||
```
|
||||
|
||||
### 流程 3: 单表失败降级
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[4 表查询并行/串行] --> B{单表查询}
|
||||
B -->|成功| C[返回 SearchResultItem]
|
||||
B -->|异常| D[catch 返回空数组]
|
||||
C --> E[聚合]
|
||||
D --> E
|
||||
E --> F[其他表结果不受影响]
|
||||
F --> G[整体搜索可用]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | 4 表 SELECT + WHERE + LIMIT |
|
||||
| 全文索引 | MySQL FULLTEXT + MATCH AGAINST BOOLEAN MODE | questions.content_text 生成列高效检索 |
|
||||
| 模糊匹配 | LIKE %q% | textbooks/exams/announcements 兜底 |
|
||||
| 缓存 | cacheFn | Raw 查询请求级去重 |
|
||||
| 查询转换 | toBooleanModeQuery | 用户输入 → BOOLEAN MODE 语法(+前缀*后缀) |
|
||||
| HTML 清洗 | stripHtml | 公告内容去标签生成摘要 |
|
||||
| 文本截断 | truncate | 摘要长度限制 + 省略号 |
|
||||
| 服务端隔离 | import "server-only" | data-access.ts 防止客户端引用 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/search/
|
||||
├─ data-access.ts # 数据访问层 (261 行): 4 个 Raw 查询 + 工具函数
|
||||
└─ types.ts # 类型定义 (32 行): SearchResultItem/SearchResponse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | db / schema(questions/textbooks/exams/announcements) / cacheFn |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| app/api/search/route | data-access-call | API 路由聚合 4 表结果,角色过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **无 actions.ts**: 本模块仅 data-access + types,由 API route 直接调用,不走 Server Actions
|
||||
- **调用方必须 `getAuthContext()`**: 模块不校验身份,角色过滤(学生不能搜题目/考试)由路由层处理
|
||||
- **questions 用 FULLTEXT,其他用 LIKE**: questions.content_text 是 STORED 生成列 + FULLTEXT 索引,其他表用 LIKE 兼顾实现成本
|
||||
- **BOOLEAN MODE 查询转换**: `toBooleanModeQuery` 转义特殊字符(+ - < > ~ * " '),避免用户输入破坏查询语法
|
||||
- **单表失败降级**: 单个查询失败返回空数组,不拖垮整体搜索,其他表结果不受影响
|
||||
- **DEFAULT_PAGE_SIZE = 10**: 每表默认返回上限,路由层聚合后再统一分页
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| questions 用 FULLTEXT,其他用 LIKE | questions 表数据量大且检索频繁,FULLTEXT 索引性能优势明显;其他表 LIKE 兼顾实现成本 |
|
||||
| 单表失败降级而非整体失败 | 避免一张表慢查询或异常导致整体搜索不可用,提升健壮性 |
|
||||
| 无 actions 层直接由 API route 调用 | 搜索是只读聚合,无需 Server Action 的权限/缓存编排,API route 更轻量 |
|
||||
| 角色过滤在路由层 | 模块保持通用,过滤规则由调用方按 ctx.roles 决定,避免模块耦合 rbac |
|
||||
| toBooleanModeQuery 转义特殊字符 | 防止用户输入破坏 BOOLEAN MODE 语法,保证查询安全 |
|
||||
316
src/modules/settings/README.md
Normal file
316
src/modules/settings/README.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# settings 设置模块
|
||||
|
||||
> 系统级与用户级设置聚合: AI Provider 管理、2FA 安全中心、密码修改、品牌配置、头像上传、通知偏好、个人资料、系统设置
|
||||
> 代码规模: 48 文件 · 227 符号 · 6,986 行(arch.db)
|
||||
> 模块路径: `src/modules/settings/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
View["settings-view<br/>用户设置入口 (226 行)"]
|
||||
AdminView["admin-settings-view<br/>管理员视图 (195 行)"]
|
||||
AiCard["ai-provider-settings-card<br/>AI 服务商 (443 行)"]
|
||||
Sec2FA["security-two-factor-section<br/>2FA (474 行)"]
|
||||
PwdForm["password-change-form<br/>改密 (182 行)"]
|
||||
NotifForm["notification-preferences-form<br/>通知偏好 (336 行)"]
|
||||
ProfileForm["profile-settings-form<br/>资料 (157 行)"]
|
||||
Avatar["avatar-upload<br/>头像 (195 行)"]
|
||||
BrandCard["brand-config-card<br/>品牌 (161 行)"]
|
||||
end
|
||||
|
||||
subgraph Ctx["服务注入层"]
|
||||
SvcCtx["settings-service-context<br/>(40 行, React Context)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端 Actions(按域拆分 8 文件)"]
|
||||
ActMain["actions.ts<br/>(307 行, AI Provider)"]
|
||||
ActSec["actions-security.ts<br/>(492 行, 2FA + 登录日志)"]
|
||||
ActPwd["actions-password.ts<br/>(113 行)"]
|
||||
ActAvatar["actions-avatar.ts<br/>(106 行)"]
|
||||
ActBrand["actions-brand.ts<br/>(80 行)"]
|
||||
ActNotif["actions-notifications.ts<br/>(62 行)"]
|
||||
ActSvc["actions-service.ts<br/>(63 行)"]
|
||||
ActSys["actions-system-settings.ts<br/>(168 行)"]
|
||||
end
|
||||
|
||||
subgraph DA["Data Access(按域拆分 5 文件)"]
|
||||
DAMain["data-access.ts<br/>(316 行)"]
|
||||
DA2FA["data-access-two-factor.ts<br/>(130 行)"]
|
||||
DASys["data-access-system-settings.ts<br/>(144 行)"]
|
||||
DABrand["data-access-brand.ts<br/>(86 行)"]
|
||||
DAOv["data-access-profile-overview.ts<br/>(58 行)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
TOTP["lib/totp.ts<br/>(159 行, TOTP 实现)"]
|
||||
SecU["lib/security-utils.ts<br/>(46 行)"]
|
||||
OvData["lib/student-overview-data.ts<br/>(149 行)"]
|
||||
SysU["lib/system-settings-utils.ts<br/>(45 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Users[(users)]
|
||||
AiProv[(ai_providers)]
|
||||
LoginLogs[(login_logs)]
|
||||
SysSettings[(system_settings)]
|
||||
end
|
||||
|
||||
View --> SvcCtx
|
||||
AdminView --> ActSys
|
||||
AiCard --> ActMain
|
||||
Sec2FA --> ActSec
|
||||
PwdForm --> ActPwd
|
||||
NotifForm --> ActNotif
|
||||
ProfileForm --> SvcCtx
|
||||
Avatar --> ActAvatar
|
||||
BrandCard --> ActBrand
|
||||
|
||||
ActMain --> DAMain
|
||||
ActSec --> DA2FA
|
||||
ActSec --> TOTP
|
||||
ActSys --> DASys
|
||||
ActBrand --> DABrand
|
||||
DAOv --> OvData
|
||||
|
||||
DAMain --> AiProv
|
||||
DA2FA --> Users
|
||||
DA2FA --> SysSettings
|
||||
DASys --> SysSettings
|
||||
ActSec --> LoginLogs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: AI Provider 创建 + 探活
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户/管理员
|
||||
participant Card as ai-provider-settings-card
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant AI as shared/lib/ai
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Card: 填写 provider/model/apiKey
|
||||
Card->>Act: upsertAiProviderAction
|
||||
Act->>Act: requirePermission(AI_CHAT)
|
||||
Act->>Act: isAdmin = has(AI_CONFIGURE)
|
||||
Act->>Act: AiProviderFormSchema.safeParse
|
||||
Act->>AI: encryptAiApiKey(apiKey)
|
||||
AI-->>Act: encryptedKey + last4
|
||||
Act->>DA: createAiProvider / updateAiProvider
|
||||
DA->>DB: INSERT/UPDATE ai_providers (apiKeyEncrypted, apiKeyLast4)
|
||||
Act->>Act: invalidateFor("settings.ai-provider")
|
||||
Act-->>Card: ActionState
|
||||
|
||||
U->>Card: 点击"测试连接"
|
||||
Card->>Act: testAiProviderAction
|
||||
Act->>AI: testAiProviderConfig / testAiProviderById
|
||||
AI-->>Act: 成功/失败
|
||||
Act-->>Card: ActionState<{ok, message}>
|
||||
```
|
||||
|
||||
### 流程 2: 2FA 启用(TOTP)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Sec as security-two-factor-section
|
||||
participant Act as actions-security.ts
|
||||
participant DA as data-access-two-factor.ts
|
||||
participant TOTP as lib/totp.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Sec: 点击"启用 2FA"
|
||||
Sec->>Act: setupTwoFactorAction
|
||||
Act->>Act: requirePermission
|
||||
Act->>TOTP: generateTotpSecret
|
||||
Act->>TOTP: buildOtpAuthUrl + generateQrCodeDataUrl
|
||||
Act->>DA: setTotpSecret(userId, secret)
|
||||
DA->>DB: UPDATE users SET totpSecret
|
||||
Act-->>Sec: TwoFactorSetupData {qrCodeDataUrl, secret}
|
||||
|
||||
U->>Sec: 扫码后输入 6 位验证码
|
||||
Sec->>Act: verifyTwoFactorAction(code)
|
||||
Act->>TOTP: verifyTotpCode(secret, code)
|
||||
TOTP-->>Act: true/false
|
||||
alt 验证通过
|
||||
Act->>DA: setTwoFactorEnabled(true)
|
||||
Act->>TOTP: generateBackupCodes + hashBackupCodes
|
||||
Act->>DA: setBackupCodesHashed(hashed[])
|
||||
DA->>DB: UPDATE users SET twoFactorEnabled
|
||||
Act-->>Sec: 返回明文备份码(仅一次)
|
||||
else 验证失败
|
||||
Act-->>Sec: ActionState 失败
|
||||
end
|
||||
```
|
||||
|
||||
### 流程 3: 个人资料 + 通知偏好更新(服务注入)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[settings-view 加载] --> B[页面层注入 SettingsService]
|
||||
B --> C[SettingsServiceProvider<br/>包裹组件树]
|
||||
C --> D[profile-settings-form<br/>useSettingsService().profile]
|
||||
C --> E[notification-preferences-form<br/>useSettingsService().notifications]
|
||||
D --> F{更新资料}
|
||||
F --> G[profile.updateProfile input]
|
||||
G --> H[users.data-access<br/>updateUserProfile]
|
||||
H --> I[UPDATE users]
|
||||
E --> J{更新通知偏好}
|
||||
J --> K[notifications.updatePreferences input]
|
||||
K --> L[notifications.data-access]
|
||||
L --> M[UPDATE notification_preferences]
|
||||
```
|
||||
|
||||
### 流程 4: 教师/学生 Profile Overview 聚合
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Ov as profile-teacher-overview
|
||||
participant DAOv as data-access-profile-overview.ts
|
||||
participant OvData as lib/student-overview-data.ts
|
||||
participant ClsDA as classes.data-access
|
||||
participant HwDA as homework.data-access
|
||||
|
||||
U->>Ov: 查看个人概览
|
||||
Ov->>DAOv: getProfileOverview(userId, role)
|
||||
alt role = teacher
|
||||
DAOv->>ClsDA: 查教师班级数
|
||||
DAOv->>HwDA: 查布置作业数
|
||||
else role = student
|
||||
DAOv->>OvData: 聚合学生数据
|
||||
OvData->>ClsDA: 查班级
|
||||
OvData->>HwDA: 查作业完成情况
|
||||
end
|
||||
DAOv-->>Ov: ProfileOverview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | users/ai_providers/login_logs/system_settings 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重 |
|
||||
| 加密 | crypto(自研) | TOTP 生成/验证 + AI apiKey 加密 |
|
||||
| 表单 | react-hook-form + Zod | 改密/资料/AI Provider 表单验证 |
|
||||
| 服务注入 | React Context | SettingsService 接口解耦组件与 actions |
|
||||
| Server Action | requirePermission | AI_CHAT / AI_CONFIGURE / SECURITY_READ 等多权限点 |
|
||||
| 缓存失效 | invalidateFor | 按 settings.{ai-provider/two-factor/...} 精确失效 |
|
||||
| 跨模块聚合 | 对方 data-access 调用 | classes/homework/users/notifications/onboarding |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/settings/
|
||||
├─ actions.ts # AI Provider CRUD (307 行)
|
||||
├─ actions-security.ts # 2FA + 登录日志 (492 行)
|
||||
├─ actions-system-settings.ts # 系统设置 (168 行)
|
||||
├─ actions-password.ts # 改密 (113 行)
|
||||
├─ actions-avatar.ts # 头像 (106 行)
|
||||
├─ actions-brand.ts # 品牌 (80 行)
|
||||
├─ actions-service.ts # 服务注入 (63 行)
|
||||
├─ actions-notifications.ts # 通知偏好 (62 行)
|
||||
├─ data-access.ts # AI Provider 数据访问 (316 行)
|
||||
├─ data-access-system-settings.ts # 系统设置 (144 行)
|
||||
├─ data-access-two-factor.ts # 2FA 数据访问 (130 行)
|
||||
├─ data-access-brand.ts # 品牌数据访问 (86 行)
|
||||
├─ data-access-profile-overview.ts # Profile 概览聚合 (58 行)
|
||||
├─ types.ts # 类型定义 (76 行): AiProvider / SettingsService
|
||||
├─ brand-config.ts # 品牌配置 (28 行)
|
||||
├─ lib/ # 纯函数库 + 单测
|
||||
│ ├─ totp.ts # TOTP 实现 (159 行)
|
||||
│ ├─ totp.test.ts # TOTP 单测 (215 行)
|
||||
│ ├─ student-overview-data.ts # 学生概览聚合 (149 行)
|
||||
│ ├─ student-overview-data.test.ts # 单测 (181 行)
|
||||
│ ├─ system-settings-utils.ts # 系统设置工具 (45 行)
|
||||
│ ├─ system-settings-utils.test.ts # 单测 (142 行)
|
||||
│ ├─ security-utils.ts # 安全工具 (46 行)
|
||||
│ ├─ security-utils.test.ts # 单测 (109 行)
|
||||
│ └─ type-guards.ts # 类型守卫 (27 行)
|
||||
├─ config/
|
||||
│ ├─ profile-overview-config.ts # 概览配置 (27 行)
|
||||
│ └─ role-settings-config.tsx # 角色设置配置 (85 行)
|
||||
└─ components/ # 22 个组件
|
||||
├─ settings-view.tsx # 用户设置入口 (226 行)
|
||||
├─ admin-settings-view.tsx # 管理员视图 (195 行)
|
||||
├─ ai-provider-settings-card.tsx # AI Provider 卡片 (443 行)
|
||||
├─ security-two-factor-section.tsx # 2FA 区块 (474 行)
|
||||
├─ notification-preferences-form.tsx # 通知偏好 (336 行)
|
||||
├─ security-recent-logins-section.tsx # 登录日志 (173 行)
|
||||
├─ avatar-upload.tsx # 头像上传 (195 行)
|
||||
├─ password-change-form.tsx # 改密 (182 行)
|
||||
├─ admin-school-info-card.tsx # 学校信息 (114 行)
|
||||
├─ profile-teacher-overview.tsx # 教师概览 (115 行)
|
||||
├─ admin-security-policy-card.tsx # 安全策略 (111 行)
|
||||
├─ ai-provider-selector.tsx # Provider 选择器 (109 行)
|
||||
├─ security-center-card.tsx # 安全中心 (105 行)
|
||||
├─ profile-student-overview.tsx # 学生概览 (90 行)
|
||||
├─ admin-notification-config-card.tsx # 通知配置 (80 行)
|
||||
├─ theme-preferences-card.tsx # 主题偏好 (83 行)
|
||||
├─ ai-provider-delete-dialog.tsx # 删除对话框 (69 行)
|
||||
├─ admin-file-upload-card.tsx # 文件上传 (67 行)
|
||||
├─ quick-links-card.tsx # 快捷链接 (47 行)
|
||||
└─ settings-service-context.tsx # 服务 Context (40 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| users | data-access-call + import | getUserProfile / updateUserProfile / 密码校验 |
|
||||
| classes | data-access-call + import | 教师/学生概览聚合班级数据 |
|
||||
| homework | data-access-call + import | 学生概览聚合作业完成情况 |
|
||||
| files | data-access-call + import | 头像/品牌 logo 文件上传 |
|
||||
| notifications | import | 通知偏好类型 + 服务注入 |
|
||||
| onboarding | data-access-call | 引导状态查询 |
|
||||
| dashboard | import | 概览跳转 |
|
||||
| shared | import | DB / cache / auth-guard / ai 加密 / logger |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| auth | import | 登录流程引用 2FA 校验 |
|
||||
| exams | import | 考试模块引用设置 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **2FA 备份码必须哈希存储**: `hashBackupCodes` 后写入 DB,明文备份码仅在 `verifyTwoFactorAction` 成功时返回一次
|
||||
- **AI apiKey 仅存 last4 + 加密全文**: `encryptAiApiKey` 加密,`apiKeyLast4` 用于展示,禁止明文落库
|
||||
- **Actions 按域拆分 8 文件**: 避免单文件超过 800 行上限(security 已达 492 行)
|
||||
- **data-access 按域拆分 5 文件**: AI/2FA/系统设置/品牌/概览分离,避免单文件超长
|
||||
- **SettingsService 通过 Context 注入**: 组件层不直接 import 其他模块 actions,便于测试与多角色适配
|
||||
- **TOTP 自实现**: 基于 crypto,`lib/totp.ts` (159 行) + `totp.test.ts` (215 行) 单测覆盖,不引入 otplib
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| Actions 按域拆分 8 文件 | 单文件会超 800 行上限,按 security/brand/avatar/password/notifications 等垂直切分 |
|
||||
| TOTP 自实现而非 otplib | 避免引入额外依赖,基于 Node crypto 实现且可单测 |
|
||||
| SettingsService 接口 + Context 注入 | 解耦组件层与各业务模块 actions,支持多角色与测试场景注入不同实现 |
|
||||
| AI Provider visibility 双层(public/private) | 管理员发布全员可用 public,用户自管 private,AI_CONFIGURE 权限区分 |
|
||||
| Ollama 本地 Provider 特殊处理 | 本地部署无需 apiKey,使用占位符满足 NOT NULL 约束 + 默认 baseUrl |
|
||||
| data-access 按域拆分 5 文件 | AI/2FA/系统设置/品牌/概览职责不同,独立维护避免单文件超长 |
|
||||
176
src/modules/standards/README.md
Normal file
176
src/modules/standards/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# standards 课标模块
|
||||
|
||||
> 国家标准/课标/自定义三层级课标库 CRUD、树形结构查询、与备课多对多关联及覆盖度统计
|
||||
> 代码规模: 5 文件 · 47 符号 · 8,847 行(arch.db)
|
||||
> 模块路径: `src/modules/standards/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Pages["app/standards 页面<br/>(外部消费)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(223 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(294 行, CRUD + 树 + 关联)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Guards["lib/type-guards.ts<br/>(20 行)"]
|
||||
LPGuards["lesson-preparation/lib/type-guards<br/>toStandardLevel (复用)"]
|
||||
I18nErr["lesson-preparation/lib/i18n-errors<br/>safeParseWithI18n"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Standards[(standards)]
|
||||
PlanStd[(lesson_plan_standards)]
|
||||
end
|
||||
|
||||
Pages --> Actions
|
||||
Actions --> DA
|
||||
Actions --> I18nErr
|
||||
DA --> Standards
|
||||
DA --> PlanStd
|
||||
DA --> Guards
|
||||
Guards -.->|复用类型| LPGuards
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 课标树形查询
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant Page as 课标页面
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Page: 进入课标管理
|
||||
Page->>Act: getStandardsAction({ asTree: true })
|
||||
Act->>Act: requirePermission(STANDARD_READ)
|
||||
Act->>Act: getStandardsParamsSchema.safeParse
|
||||
Act->>DA: getStandardsTree(queryParams)
|
||||
DA->>DB: SELECT * FROM standards WHERE isActive
|
||||
DA->>DA: 递归构建 children 树
|
||||
DA-->>Act: StandardTreeNode[]
|
||||
Act-->>Page: ActionState<{ tree }>
|
||||
```
|
||||
|
||||
### 流程 2: 创建/更新课标
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as 管理员
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
A->>Act: createStandardAction(formData)
|
||||
Act->>Act: requirePermission(STANDARD_CREATE)
|
||||
Act->>Act: createStandardSchema.safeParse
|
||||
Act->>Act: safeParseWithI18n 翻译错误
|
||||
Act->>DA: createStandard(data)
|
||||
DA->>DB: INSERT standards
|
||||
DA-->>Act: Standard
|
||||
Act->>Act: invalidateFor(standards)
|
||||
Act-->>A: ActionState<Standard>
|
||||
|
||||
A->>Act: updateStandardAction(id, formData)
|
||||
Act->>Act: requirePermission(STANDARD_UPDATE)
|
||||
Act->>DA: updateStandard(id, data)
|
||||
DA->>DB: UPDATE standards SET ...
|
||||
Act->>Act: invalidateFor
|
||||
Act-->>A: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 备课↔课标关联 + 覆盖度
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[备课页面] --> B[linkPlanToStandardAction]
|
||||
B --> C[data-access.linkPlanToStandard]
|
||||
C --> D[(lesson_plan_standards<br/>INSERT)]
|
||||
D --> E[getStandardsByPlanId]
|
||||
E --> F[计算 coveragePercent<br/>primaryCount/totalStandards]
|
||||
F --> G[备课页面渲染覆盖度]
|
||||
H[unlinkPlanFromStandard] --> D
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | standards / lesson_plan_standards 表 CRUD + 树构建 |
|
||||
| 缓存 | cacheFn + invalidateFor | 课标查询请求级去重,写后失效 |
|
||||
| Server Action | requirePermission | STANDARD_READ / CREATE / UPDATE / DELETE 权限校验 |
|
||||
| 校验 | Zod | createStandardSchema / updateStandardSchema / linkPlanToStandardSchema |
|
||||
| 国际化 | next-intl + safeParseWithI18n | 字段错误本地化(复用 lesson-preparation) |
|
||||
| 类型守卫 | lib/type-guards.ts + lesson-preparation | toStandardLevel 守卫 StandardLevel 枚举 |
|
||||
| 跨模块协作 | data-access-call | lesson-preparation 反向调用关联管理 |
|
||||
| 服务端隔离 | import "server-only" | data-access.ts 防止客户端引用 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/standards/
|
||||
├─ actions.ts # Server Actions (223 行): CRUD/树查询/关联管理
|
||||
├─ data-access.ts # 数据访问层 (294 行): CRUD + getStandardsTree + 关联
|
||||
├─ schema.ts # Zod 验证 (69 行)
|
||||
├─ types.ts # 类型定义 (84 行): Standard/StandardTreeNode/Link
|
||||
├─ lib/
|
||||
│ └─ type-guards.ts # 类型守卫 (20 行)
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| lesson-preparation | import | 复用 StandardLevel 类型守卫与 i18n-errors |
|
||||
| shared | import | DB / auth-guard / cache / action-utils |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| lesson-preparation | data-access-call | 备课页面查询/关联课标,计算覆盖度 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **三层级课标库**: 国家标准/课标/自定义三层级,通过 `StandardLevel` 守卫区分,禁止裸字符串
|
||||
- **复用 lesson-preparation 的 StandardLevel**: 两模块共享同一套层级语义,避免重复定义导致不一致
|
||||
- **关联表多对多**: `lesson_plan_standards` 解耦课标与备课,支持 primary/related 两种关联类型
|
||||
- **树形查询缓存**: `getStandardsTree` 走 cacheFn,新增/更新/停用后必须 `invalidateFor` 失效
|
||||
- **deactivate 软删除**: 停用课标设置 `isActive=false`,不物理删除,保留历史备课关联
|
||||
- **data-access.ts 首行 `import "server-only"`**: 防止客户端组件误引用
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 复用 lesson-preparation 的 StandardLevel | 两模块共享同一套层级语义,避免重复定义与类型不一致 |
|
||||
| 关联表 lesson_plan_standards 多对多 | 解耦课标与备课,一个备课可对标多个课标,一个课标可被多个备课引用 |
|
||||
| asTree 查询参数切换扁平/树形 | 同一 API 服务列表与树形两种 UI,减少接口冗余 |
|
||||
| deactivate 软删除而非物理删除 | 保留历史备课关联,避免删除课标导致备课页关联断裂 |
|
||||
| 覆盖度在 data-access 层计算 | primaryCount/totalStandards 集中计算,UI 直接消费 |
|
||||
170
src/modules/student/README.md
Normal file
170
src/modules/student/README.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# student 学生视图模块
|
||||
|
||||
> 学生端聚合视图:已选课程卡片列表(含邀请码加入班级)与按周分组的课表展示,纯视图模块无数据层
|
||||
> 代码规模: 2 文件 · 5 符号 · 682 行(arch.db)
|
||||
> 模块路径: `src/modules/student/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Pages["app/student 页面(消费方)"]
|
||||
CoursesPage["student/courses/page.tsx"]
|
||||
SchedulePage["student/schedule/page.tsx"]
|
||||
end
|
||||
|
||||
subgraph Components["客户端组件"]
|
||||
direction TB
|
||||
CoursesView["student-courses-view.tsx<br/>(194 行, 课程卡片 + 邀请码加入)"]
|
||||
ScheduleView["student-schedule-view.tsx<br/>(98 行, 周课表)"]
|
||||
end
|
||||
|
||||
subgraph Ext["跨模块(actions/types)"]
|
||||
ClassesActions["classes.joinClassByInvitationCodeAction"]
|
||||
ClassesTypes["classes.StudentEnrolledClass / StudentScheduleItem"]
|
||||
end
|
||||
|
||||
subgraph Shared["shared"]
|
||||
UI["shadcn/ui Card/Badge/Button/Input"]
|
||||
ScheduleList["schedule/schedule-list"]
|
||||
Notify["notify"]
|
||||
end
|
||||
|
||||
CoursesPage --> CoursesView
|
||||
SchedulePage --> ScheduleView
|
||||
CoursesView --> ClassesActions
|
||||
CoursesView --> ClassesTypes
|
||||
CoursesView --> UI
|
||||
CoursesView --> Notify
|
||||
ScheduleView --> ClassesTypes
|
||||
ScheduleView --> UI
|
||||
ScheduleView --> ScheduleList
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 课程列表渲染 + 邀请码加入班级
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 学生
|
||||
participant Page as courses/page
|
||||
participant View as student-courses-view
|
||||
participant Act as classes.joinClassByInvitationCodeAction
|
||||
participant Router as useRouter
|
||||
|
||||
U->>Page: 进入课程页
|
||||
Page->>Page: 服务端取 StudentEnrolledClass[]
|
||||
Page->>View: 传入 classes
|
||||
View->>View: memo(ClassCard) 渲染卡片
|
||||
U->>View: 输入邀请码
|
||||
View->>Act: joinClassByInvitationCodeAction(code)
|
||||
Act-->>View: ActionState
|
||||
alt 成功
|
||||
View->>Router: refresh()
|
||||
View->>Notify: 成功提示
|
||||
else 失败
|
||||
View->>Notify: 错误提示
|
||||
end
|
||||
```
|
||||
|
||||
### 流程 2: 周课表渲染(按 weekday 分组 + 今日高亮)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[页面传入 StudentScheduleItem[]] --> B[getTodayWeekday]
|
||||
B --> C[itemsByDay Map 分组]
|
||||
C --> D[每日按 startTime 排序]
|
||||
D --> E[WEEKDAY_KEYS 周一~周日遍历]
|
||||
E --> F{isToday?}
|
||||
F -->|是| G[Card 高亮 ring-primary]
|
||||
F -->|否| H[普通 Card]
|
||||
G --> I[ScheduleList 渲染]
|
||||
H --> I
|
||||
I --> J[点击跳转 /courses/classId]
|
||||
```
|
||||
|
||||
### 流程 3: 数据来源(无本模块数据层)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[app/student 页面] --> B[服务端组件]
|
||||
B --> C[classes data-access<br/>getStudentEnrolledClasses]
|
||||
B --> D[classes data-access<br/>getStudentSchedule]
|
||||
C --> E[StudentEnrolledClass[]]
|
||||
D --> F[StudentScheduleItem[]]
|
||||
E --> G[student-courses-view]
|
||||
F --> H[student-schedule-view]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 渲染优化 | React memo | ClassCard 避免不必要重渲染 |
|
||||
| 交互反馈 | useTransition + useRouter.refresh | 加入班级后非阻塞刷新 |
|
||||
| 通知 | shared/lib/notify | 加入班级成功/失败提示 |
|
||||
| 国际化 | next-intl useTranslations | student 命名空间 + weekdays |
|
||||
| UI 组件 | shadcn/ui Card/Badge/Button/Input/EmptyState | 卡片/徽章/按钮/输入/空态 |
|
||||
| 复用组件 | shared/components/schedule/schedule-list | 课表列表统一渲染 |
|
||||
| 图标 | lucide-react | BookOpen/CalendarDays/User/Mail 等 |
|
||||
| 跨模块调用 | classes actions + types | 数据与操作来自 classes 模块 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/student/
|
||||
├─ components/
|
||||
│ ├─ student-courses-view.tsx # 课程视图 (194 行): 卡片 + 邀请码加入
|
||||
│ └─ student-schedule-view.tsx # 课表视图 (98 行): 周分组 + 今日高亮
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | import | joinClassByInvitationCodeAction + StudentEnrolledClass/StudentScheduleItem 类型 |
|
||||
| shared | import | shadcn/ui / schedule-list / notify / utils |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| app/student/courses | import | 课程页消费 student-courses-view |
|
||||
| app/student/schedule | import | 课表页消费 student-schedule-view |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **纯视图模块**: 无 actions.ts / data-access.ts / types.ts,仅 2 个客户端组件,所有数据来自页面服务端组件
|
||||
- **不直接查询 DB**: 所有数据经 classes 模块 data-access,本模块只负责展示
|
||||
- **ClassCard 必须 memo**: 课程列表频繁交互,memo 避免不必要重渲染
|
||||
- **路由权限点 CLASS_READ**: 学生端页面入口由 `Permissions.CLASS_READ` 控制,本模块组件不再校验
|
||||
- **WEEKDAY_KEYS 周一~周日**: 1=Mon ~ 7=Sun,与 getDay()(0=Sun)通过 WEEKDAY_MAP 转换
|
||||
- **空态用 EmptyState**: 课程/课表为空时统一用 EmptyState 组件,不内联空态 JSX
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 纯视图模块无数据层 | 学生聚合页只是 classes/dashboard 数据的展示层,不引入冗余数据层,保持三层架构清晰 |
|
||||
| 数据由页面服务端组件获取 | 充分利用 Next.js RSC,数据在服务端取好后传入,组件保持纯客户端交互 |
|
||||
| ClassCard memo 优化 | 课程卡片含多个 Link 与图标,memo 避免父组件 state 变化时全量重渲染 |
|
||||
| 复用 shared/schedule-list | 课表项渲染逻辑统一,避免与教师端重复实现 |
|
||||
| 邀请码加入走 classes actions | 加入班级是 classes 模块职责,本模块只触发并刷新,不直接操作 DB |
|
||||
250
src/modules/textbooks/README.md
Normal file
250
src/modules/textbooks/README.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# textbooks 教材模块
|
||||
|
||||
> 管理教材、章节树、知识点及前置依赖关系,提供知识图谱可视化与掌握度查询
|
||||
> 代码规模: 41 文件 · 234 符号 · 78,971 行(arch.db)
|
||||
> 模块路径: `src/modules/textbooks/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Reader["textbook-reader<br/>阅读器"]
|
||||
Editor["textbook-form-dialog<br/>教材表单"]
|
||||
ChapterList["chapter-sidebar-list<br/>章节侧栏"]
|
||||
KG["knowledge-graph<br/>知识图谱"]
|
||||
KPList["knowledge-point-list<br/>知识点列表"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseGraph["use-graph-data"]
|
||||
UseKpCrud["use-kp-crud<br/>+ create/update/delete"]
|
||||
UseDialog["use-kp-dialog-state"]
|
||||
UseSel["use-text-selection"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(545 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(907 行, 核心 CRUD)"]
|
||||
DAGraph["data-access-graph.ts<br/>(188 行, 图谱查询)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Layout["graph-layout.ts<br/>自定义布局算法"]
|
||||
Utils["utils.ts<br/>buildChapterTree / 环检测"]
|
||||
Guards["lib/type-guards.ts"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Textbooks[(textbooks)]
|
||||
Chapters[(chapters)]
|
||||
KPs[(knowledge_points)]
|
||||
Prereq[(knowledge_prerequisites)]
|
||||
end
|
||||
|
||||
Reader --> ChapterList
|
||||
Reader --> UseSel
|
||||
Editor --> Actions
|
||||
ChapterList --> Actions
|
||||
KG --> UseGraph
|
||||
KG --> Layout
|
||||
KPList --> UseKpCrud
|
||||
UseKpCrud --> Actions
|
||||
UseGraph --> DAGraph
|
||||
UseDialog --> KPList
|
||||
|
||||
Actions --> DA
|
||||
DA --> Textbooks
|
||||
DA --> Chapters
|
||||
DA --> KPs
|
||||
DA --> Prereq
|
||||
DAGraph --> KPs
|
||||
DAGraph --> Prereq
|
||||
DA --> Utils
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 教材 + 章节树管理
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant Form as textbook-form-dialog
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写教材信息(年级/学科)
|
||||
Form->>Act: createTextbookAction
|
||||
Act->>Act: requirePermission(TEXTBOOK_MANAGE)
|
||||
Act->>DA: createTextbook
|
||||
DA->>DB: INSERT textbooks
|
||||
DA-->>Act: 返回 textbook.id
|
||||
Act-->>Form: ActionState<{id}>
|
||||
Form->>U: 跳转章节管理
|
||||
|
||||
U->>Form: 添加章节(拖拽排序)
|
||||
Form->>Act: reorderChaptersAction
|
||||
Act->>DA: reorderChapters
|
||||
DA->>DB: UPDATE chapters SET sort_order
|
||||
DA->>DB: 使用 buildChapterTree 重建树
|
||||
DA-->>Act: 返回新树
|
||||
Act-->>Form: ActionState<Chapter[]>
|
||||
```
|
||||
|
||||
### 流程 2: 知识点 + 前置依赖(含环检测)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 教师
|
||||
participant List as knowledge-point-list
|
||||
participant Hook as use-kp-crud
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Utils as utils.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>List: 创建知识点 KP-A
|
||||
List->>Hook: createKp
|
||||
Hook->>Act: createKnowledgePointAction
|
||||
Act->>Act: requirePermission(TEXTBOOK_MANAGE)
|
||||
Act->>DA: createKnowledgePoint
|
||||
DA->>DB: INSERT knowledge_points
|
||||
DA-->>Act: KP-A.id
|
||||
Act-->>Hook: ActionState
|
||||
|
||||
U->>List: 为 KP-B 添加前置依赖 KP-A
|
||||
List->>Hook: updateKp(添加 prerequisite)
|
||||
Hook->>Act: updateKnowledgePointAction
|
||||
Act->>DA: hasCycleAfterAddingEdge(KP-B, KP-A)
|
||||
DA->>Utils: DFS 环检测
|
||||
Utils-->>DA: false(无环)
|
||||
DA->>DB: INSERT knowledge_prerequisites
|
||||
DA-->>Act: 成功
|
||||
Act-->>Hook: ActionState
|
||||
Hook-->>List: 刷新列表
|
||||
|
||||
Note over U,DB: 若环检测返回 true<br/>则 Action 返回错误<br/>不写入 DB
|
||||
```
|
||||
|
||||
### 流程 3: 知识图谱渲染
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[进入图谱页] --> B[use-graph-data 加载]
|
||||
B --> C[data-access-graph.ts<br/>查询知识点 + 依赖]
|
||||
C --> D[graph-layout.ts<br/>自定义布局算法]
|
||||
D --> E[计算节点坐标<br/>分层布局]
|
||||
E --> F[force-graph.tsx<br/>力导向渲染]
|
||||
F --> G[graph-prerequisite-edge<br/>绘制前置边]
|
||||
G --> H[graph-kp-node<br/>绘制知识点节点]
|
||||
H --> I[graph-node-detail-panel<br/>点击查看详情]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | textbooks/chapters/knowledge_points/knowledge_prerequisites 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重 |
|
||||
| 表单 | react-hook-form + Zod | textbook-form-dialog 教材表单 |
|
||||
| 状态 | React Hooks | use-graph-data / use-kp-crud / use-kp-dialog-state |
|
||||
| 可视化 | 自定义 force-graph | 知识图谱力导向渲染(非第三方 ReactFlow) |
|
||||
| 布局算法 | 自定义 graph-layout.ts | 层级布局,满足 K12 前置依赖展示需求 |
|
||||
| 测试 | Vitest | graph-layout.test.ts / utils.test.ts |
|
||||
| Server Action | requirePermission | TEXTBOOK_MANAGE 权限点校验 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/textbooks/
|
||||
├─ actions.ts # Server Actions (545 行): 教材/章节/知识点 CRUD 编排
|
||||
├─ data-access.ts # 数据访问层 (907 行): 核心 CRUD + 环检测
|
||||
├─ data-access-graph.ts # 图谱查询 (188 行): 掌握度 + 关联关系
|
||||
├─ schema.ts # Zod 验证 schema
|
||||
├─ types.ts # 类型定义 (134 行)
|
||||
├─ constants.ts # 常量
|
||||
├─ utils.ts # 纯函数 (250 行): buildChapterTree / findChapterById / sortChapters
|
||||
├─ graph-layout.ts # 自定义图谱布局算法 (127 行)
|
||||
├─ analytics.tsx # 教材分析页
|
||||
├─ lib/
|
||||
│ └─ type-guards.ts # isXxx 类型守卫
|
||||
├─ components/ # 20 个客户端组件
|
||||
│ ├─ textbook-reader.tsx # 阅读器主容器 (319 行)
|
||||
│ ├─ textbook-form-dialog.tsx # 教材表单
|
||||
│ ├─ chapter-sidebar-list.tsx # 章节侧栏 (351 行, 拖拽排序)
|
||||
│ ├─ knowledge-graph.tsx # 图谱入口
|
||||
│ ├─ knowledge-graph-inner.tsx # 图谱渲染 (296 行)
|
||||
│ ├─ force-graph.tsx # 力导向图 (443 行)
|
||||
│ ├─ graph-kp-node.tsx # 知识点节点
|
||||
│ ├─ graph-prerequisite-edge.tsx # 前置依赖边
|
||||
│ └─ ...
|
||||
├─ hooks/ # 8 个自定义 Hook
|
||||
│ ├─ use-graph-data.ts # 图谱数据加载
|
||||
│ ├─ use-kp-crud.ts # 知识点 CRUD 编排
|
||||
│ ├─ use-kp-create.ts # 创建
|
||||
│ ├─ use-kp-update.ts # 更新
|
||||
│ ├─ use-kp-delete.ts # 删除
|
||||
│ ├─ use-kp-dialog-state.ts # 对话框状态
|
||||
│ ├─ use-knowledge-point-actions.ts # 操作聚合
|
||||
│ └─ use-text-selection.ts # 文本选择
|
||||
├─ graph-layout.test.ts # 布局算法单测
|
||||
└─ utils.test.ts # 工具函数单测
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | 按班级过滤教材 |
|
||||
| school | data-access-call + import | 按年级/学科过滤 |
|
||||
| users | data-access-call + import | 教师身份校验 |
|
||||
| questions | data-access-call + import | 知识点关联题目 |
|
||||
| diagnostic | data-access-call + import | 知识点掌握度诊断数据 |
|
||||
| shared | import | DB / 工具函数 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| ai | data-access-call + import | AI 基于教材生成内容 |
|
||||
| dashboard | data-access-call + import | 首页教材统计 |
|
||||
| lesson-preparation | data-access-call + import | 备课引用教材章节 |
|
||||
| questions | data-access-call | 题目关联知识点 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **标杆模块**: 无跨模块 DB 访问,只通过对方 data-access 通信
|
||||
- **data-access 按职责拆分**: 主数据访问与图谱查询分离,避免单文件超过 1000 行硬上限
|
||||
- **环检测前置**: 创建 prerequisite 前必须调用 `hasCycleAfterAddingEdge`,防止循环依赖导致图谱渲染死循环
|
||||
- **章节树工具集中在 utils.ts**: `buildChapterTree` / `findChapterById` / `sortChapters` 作为纯函数复用,便于单测
|
||||
- **自定义图谱布局 graph-layout.ts**: 替代第三方库,精确满足 K12 知识图谱前置依赖的层级展示需求
|
||||
- **Server Action 全部使用 requirePermission 校验**,返回 ActionState
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| data-access 拆分为核心 + 图谱 | 主 CRUD(907 行)与图谱查询(188 行)职责分离,避免超长文件 |
|
||||
| 自定义 force-graph 而非 ReactFlow | 知识图谱需要力导向 + 前置依赖边,ReactFlow 不适合 |
|
||||
| 自定义 graph-layout 而非 dagre | K12 知识图谱需要按章节分层 + 前置依赖流向,通用算法不满足 |
|
||||
| 环检测在 data-access 层 | 防御性编程,即使前端漏检,后端也阻止循环依赖写入 |
|
||||
| Hooks 按操作粒度拆分 | create/update/delete 各自独立,便于单测与复用 |
|
||||
220
src/modules/users/README.md
Normal file
220
src/modules/users/README.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# users 用户模块
|
||||
|
||||
> 用户身份校验核心,提供用户 CRUD、批量导入、角色解析、按年级/班级查询与仪表盘统计,被几乎所有业务模块依赖
|
||||
> 代码规模: 7 文件 · 1,693 行(arch.db)
|
||||
> 模块路径: `src/modules/users/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
AdminView["admin-users-view<br/>(333 行, 管理端)"]
|
||||
ImportDialog["user-import-dialog<br/>(301 行, 批量导入)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(192 行, 5 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(542 行, server-only, 23 导出)"]
|
||||
UserService["user-service.ts<br/>(119 行, 批量导入事务)"]
|
||||
ImportExport["import-export.ts<br/>(178 行, Excel 模板/解析)"]
|
||||
ClassReg["class-registration.ts<br/>(28 行, 邀请码注册)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Users[(users)]
|
||||
Roles[(roles)]
|
||||
UsersToRoles[(users_to_roles)]
|
||||
LoginLogs[(login_logs)]
|
||||
end
|
||||
|
||||
AdminView --> Actions
|
||||
ImportDialog --> Actions
|
||||
Actions --> DA
|
||||
Actions --> ImportExport
|
||||
Actions --> UserService
|
||||
UserService --> ClassReg
|
||||
UserService --> DA
|
||||
|
||||
DA --> Users
|
||||
DA --> Roles
|
||||
DA --> UsersToRoles
|
||||
DA --> LoginLogs
|
||||
DA --> Classes["classes<br/>getClassesByGradeId"]
|
||||
DA --> Shared["shared<br/>resolvePrimaryRole / bcrypt / cacheFn"]
|
||||
|
||||
Dependents["16 个业务模块<br/>经 data-access 查询用户身份"] -.-> DA
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 批量导入用户(事务 + 随机临时密码 P0-12)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as 管理员
|
||||
participant Dlg as user-import-dialog
|
||||
participant Act as actions.ts
|
||||
participant IE as import-export.ts
|
||||
participant Svc as user-service.ts
|
||||
participant DB as MySQL
|
||||
|
||||
A->>Dlg: 上传 Excel
|
||||
Dlg->>Act: importUsersAction(formData)
|
||||
Act->>Act: requirePermission(USER_MANAGE)
|
||||
Act->>IE: parseUserImportData(buffer)
|
||||
IE->>IE: 校验邮箱/角色合法性
|
||||
IE-->>Act: {valid, invalid}
|
||||
Act->>Svc: batchImportUsers(valid)
|
||||
Svc->>DB: 预加载 roles + 查询已存在 emails
|
||||
loop 每条记录
|
||||
Svc->>Svc: generateTempPassword(randomBytes)
|
||||
Svc->>DB: 事务内 INSERT users + users_to_roles
|
||||
alt student 且有邀请码
|
||||
Svc->>Svc: registerStudentByInvitationCode
|
||||
end
|
||||
end
|
||||
Svc-->>Act: UserImportResult{success, failed, errors}
|
||||
Act-->>Dlg: ActionState
|
||||
Dlg->>A: 展示导入结果
|
||||
```
|
||||
|
||||
### 流程 2: 用户身份解析(被其他模块调用)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Mod as 业务模块
|
||||
participant DA as data-access.ts
|
||||
participant Cache as cacheFn
|
||||
participant DB as MySQL
|
||||
|
||||
Mod->>DA: getUserProfile(userId)
|
||||
DA->>Cache: cacheFn(getUserProfileRaw)
|
||||
alt 缓存命中
|
||||
Cache-->>DA: UserProfile
|
||||
else 缓存未命中
|
||||
DA->>DB: SELECT FROM users WHERE id
|
||||
DA->>DB: SELECT roles JOIN users_to_roles
|
||||
DA->>DA: resolvePrimaryRole(roleNames)
|
||||
DA-->>Cache: 写入缓存(ttl=300s)
|
||||
end
|
||||
DA-->>Mod: UserProfile | null
|
||||
|
||||
Note over Mod,DA: 其他常用查询:<br/>getUserWithRole / getCurrentStudentUser<br/>getUserNamesByIds / getTeachersByIds<br/>getUserIdsByGradeId / getAllUserIds(分页)
|
||||
```
|
||||
|
||||
### 流程 3: 仪表盘统计 + 全量用户 ID 遍历
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[dashboard 模块] --> B[getUsersDashboardStats<br/>cacheFn 缓存]
|
||||
B --> C[聚合 users/roles/login_logs]
|
||||
C --> D[返回统计指标]
|
||||
|
||||
E[公告/通知 fan-out] --> F[getAllUserIds<br/>分页 limit=1000 offset]
|
||||
F --> G{是否还有下一页}
|
||||
G -->|是| F
|
||||
G -->|否| H[全量 userId 列表]
|
||||
|
||||
I[年级相关查询] --> J[getUserIdsByGradeId<br/>按年级过滤用户]
|
||||
J --> K[classes data-access<br/>解析年级下班级成员]
|
||||
|
||||
L[自助修改资料] --> M[updateUserProfile<br/>权限: USER_PROFILE_UPDATE]
|
||||
M --> N[updateUserProfileById<br/>仅允许 name/phone/address/gender/age]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | users / roles / users_to_roles / login_logs 表 CRUD |
|
||||
| 缓存 | cacheFn(31 处) | getUserProfile / getUsersDashboardStats / getUserWithRole 等查询请求级去重 |
|
||||
| 密码加密 | bcryptjs + normalizeBcryptHash | 用户密码哈希与验证 |
|
||||
| 临时密码 | crypto.randomBytes | 批量导入生成随机临时密码(P0-12 修复,替代硬编码 123456) |
|
||||
| Excel | shared/lib/excel parseExcel/exportToExcel/generateTemplate | 用户导入模板与导出 |
|
||||
| Server Action | requirePermission | USER_PROFILE_UPDATE(自助) / USER_MANAGE(管理) 两权限点 |
|
||||
| 角色解析 | shared/lib/role-utils resolvePrimaryRole | 多角色用户解析主角色 |
|
||||
| 事务 | Drizzle transaction | batchImportUsers 整体事务保证一致性 |
|
||||
| 缓存失效 | invalidateFor | 用户更新/删除后精确刷新 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/users/
|
||||
├─ actions.ts # Server Actions (192 行): 5 个 Action
|
||||
├─ data-access.ts # 数据访问 (542 行, server-only): 23 个导出函数
|
||||
├─ user-service.ts # 用户服务 (119 行, server-only): batchImportUsers 事务
|
||||
├─ import-export.ts # Excel 导入导出 (178 行, server-only)
|
||||
├─ class-registration.ts # 班级注册 (28 行, server-only): registerStudentByInvitationCode
|
||||
├─ components/
|
||||
│ ├─ admin-users-view.tsx # 管理端视图 (333 行)
|
||||
│ └─ user-import-dialog.tsx # 批量导入对话框 (301 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | DB / cache / auth-guard / bcrypt-utils / role-utils / excel |
|
||||
| classes | data-access-call + import | getUserIdsByGradeId 解析年级下班级成员 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| dashboard | import + data-access-call | getUsersDashboardStats 仪表盘统计 |
|
||||
| parent | import + data-access-call | 家长关联学生身份解析 |
|
||||
| scheduling | import + data-access-call | 排课按教师/学生过滤 |
|
||||
| textbooks | import + data-access-call | 教材按教师身份过滤 |
|
||||
| proctoring | import + data-access-call | 监考按教师/学生身份过滤 |
|
||||
| settings | import + data-access-call | 用户偏好设置关联 |
|
||||
| announcements | data-access-call | 公告 fan-out 调用 getAllUserIds |
|
||||
| attendance | import + data-access-call | 考勤按学生身份查询 |
|
||||
| grades | import + data-access-call | 成绩按学生/教师过滤 |
|
||||
| homework | import + data-access-call | 作业按学生/教师过滤 |
|
||||
| diagnostic | import + data-access-call | 诊断按学生身份 |
|
||||
| elective | import + data-access-call | 选课按学生年级过滤 |
|
||||
| messaging | import + data-access-call | 消息按用户身份 |
|
||||
| leave-requests | import + data-access-call | 请假按学生/教师 |
|
||||
| adaptive-practice | import + data-access-call | 自适应练习按学生 |
|
||||
| course-plans | data-access-call | 课程计划按教师 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **跨模块访问经 data-access**: 16 个业务模块通过本模块 data-access 函数查询用户身份,禁止直查 users 表(三层架构)
|
||||
- **getAllUserIds 必须分页**: limit=1000 + offset,避免一次性加载全量用户 ID 造成内存峰值(公告/通知 fan-out 场景)
|
||||
- **批量导入事务 + 随机临时密码(P0-12)**: batchImportUsers 包裹事务,每用户 randomBytes(8) 生成独立临时密码,替代硬编码 "123456"
|
||||
- **自助与管理权限分离**: USER_PROFILE_UPDATE 仅允许改 name/phone/address/gender/age 五个自助字段;USER_MANAGE 才能删除/导入/导出
|
||||
- **仪表盘统计下沉至数据归属方**: getUsersDashboardStats 由 users 模块提供,dashboard 仅做 Promise.all 并行聚合
|
||||
- **data-access.ts 首行 server-only**: 所有查询函数禁止客户端组件直接调用
|
||||
- **角色解析用 resolvePrimaryRole**: 多角色用户需解析主角色,而非取首个
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 仪表盘统计函数下沉至本模块 | 由数据归属方提供 stats,dashboard 仅做并行聚合,避免 dashboard 直查 users 表 |
|
||||
| getAllUserIds 分页遍历 | fan-out 场景(公告/通知)需全量 userId,分页避免内存峰值 |
|
||||
| 批量导入用事务 | 部分失败时保证数据一致性,避免脏数据残留 |
|
||||
| 随机临时密码替代硬编码(P0-12) | 硬编码 "123456" 是严重安全漏洞,randomBytes 保证每用户独立密码 |
|
||||
| 自助与管理权限分离 | 用户可改自己资料但不能改他人/角色,符合最小权限原则 |
|
||||
| cacheFn 广泛使用(31 处) | 用户身份信息被高频查询,缓存(ttl=300s)显著降低 DB 压力 |
|
||||
| 角色解析放 shared/lib/role-utils | 多模块需解析主角色,放 shared 避免重复实现 |
|
||||
| class-registration 独立文件 | 邀请码注册是 student 专属流程,与主用户管理解耦 |
|
||||
Reference in New Issue
Block a user