diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 399d7d8..1430ba1 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -1212,6 +1212,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ 审计 P2 已实施:~~无题目结构化预览~~ 新增 QuestionContentRenderer 组件(题干/选项/答案/解析,单选圆点/多选方框,正确答案高亮) - ✅ 审计 P2 已实施:~~无批量操作~~ 新增 deleteQuestionsBatch data-access + deleteQuestionsBatchAction + BatchOperations 组件(事务原子性,权限感知) - ✅ 审计 P2 已实施:~~无导入导出~~ 新增 exportQuestions/importQuestions data-access + exportQuestionsAction/importQuestionsAction + ImportExportButtons 组件(JSON 格式,权限范围过滤,导入预览确认) +- ✅ 2026-07-07 S-06:`data-access.ts` 7 个公开导出函数(getQuestionsRaw/getQuestionsDashboardStatsRaw/createQuestionWithRelations/updateQuestionById/deleteQuestionByIdRecursive/getKnowledgePointOptions/getKnowledgePointsForQuestionsRaw)补全 JSDoc(@param + @returns + @throws) **文件清单**: | 文件 | 行数 | 职责 | @@ -1305,6 +1306,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ v6 审计修复(2026-07-07):`graph-prerequisite-edge.tsx` `data as unknown as GraphEdgeData | undefined` 双重断言改用 `lib/type-guards.isGraphEdgeData` 守卫 - ✅ v6 审计修复(2026-07-07):`knowledge-graph-node.tsx` `node.data as GraphLayoutNodeData` 断言改用 `lib/type-guards.isGraphLayoutNodeData` 守卫 - ✅ v6 审计修复(2026-07-07):`force-graph.tsx` 6 处 `as` 断言(`node as KpGraphNode` ×3、`link as KpGraphLink`、`kpLink.source as KpGraphNode`、`kpLink.target as KpGraphNode`)改用 `lib/type-guards.isKpGraphNode`/`isKpGraphLink` 守卫;`KpGraphNode`/`KpGraphLink` 接口从 force-graph.tsx 迁移至 types.ts 供守卫模块引用;保留 Line 80 `as unknown as ComponentType` 第三方库(next/dynamic)类型限制并加注释 +- ✅ 2026-07-07 S-06:`data-access.ts` 全部公开导出函数(getTextbooksRaw/getTextbookByIdRaw/getChaptersByTextbookIdRaw/createTextbook/updateTextbook/deleteTextbook/createChapter/updateChapterContent/deleteChapter/getKnowledgePointsByChapterIdRaw/getKnowledgePointsByTextbookIdRaw/createKnowledgePoint/updateKnowledgePoint/deleteKnowledgePoint/reorderChapters/getTextbooksDashboardStatsRaw/createPrerequisite/deletePrerequisite/getTextbookTitlesByIdsRaw/getChapterTitlesByIdsRaw/getKnowledgePointNamesByIdsRaw 共 22 个)补全 JSDoc(@param + @returns + @throws) +- ✅ 2026-07-07 修复:`getKnowledgePointsByTextbookIdRaw` 箭头函数缺失 `=>`(pre-existing 语法错误,tsc 在添加后续 JSDoc 时暴露),补全后 tsc 通过 - ⚠️ i18n 覆盖率约 99%(v3 新增 `notFound`/`noClassMasteryPermission`/`close`/`prerequisiteAddFailed`/`prerequisiteRemoveFailed` 等键,data-access 异常文案已错误码化) - ⚠️ P2 图谱方向键导航未实现 - ⚠️ P2 data-access 函数未导出非缓存版本,单测难以 mock @@ -1452,7 +1455,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `data-access.ts` | 536 | 成绩 CRUD + 统计(含 v2-P2-9 修复:recorderName 批量查询;P3 修复:PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名;v3-P3-2 新增:bulkDeleteGradeRecords 使用 inArray 批量删除;**P1-2 重构(2026-06-25)**:草稿 CRUD(saveGradeDraft/getGradeDraft/deleteGradeDraft)迁移至 data-access-drafts.ts;按试卷录入(batchCreateGradeRecordsByExam)迁移至 data-access-exam-entry.ts;**P2-3**:updateGradeRecord 类型强化为 Partial;**P2-4**:新增 getStudentRankInClass 解耦 getStudentGradeSummary 与 getClassRanking) | | `data-access-drafts.ts` | 405 | **P1-2 新增(2026-06-25)**:成绩录入草稿 CRUD(saveGradeDraft/getGradeDraft/deleteGradeDraft)+ GradeDraftData 接口,从 data-access.ts 拆分。使用 cache() memoize getGradeDraft,isGradeDraftData 类型守卫安全解析 DB JSON 列,24 小时过期。**P3-6 扩展(2026-06-26)**:新增协同录入锁机制 5 个函数(getDraftLockStatus/acquireDraftLock/renewDraftLock/releaseDraftLock/cleanupExpiredDraftLocks)+ DraftLockStatus/AcquireLockResult 接口,LOCK_TTL_MS=5 分钟,悲观锁语义+乐观 TTL+令牌续期,锁以 grade_drafts 行为载体(lockedBy/lockedAt/lockToken 列) | | `data-access-exam-entry.ts` | 167 | **P1-2 新增(2026-06-25)**:按试卷批量录入成绩(batchCreateGradeRecordsByExam)+ BatchGradeRecordResult 接口,从 data-access.ts 拆分。单事务写入 grade_records + grade_record_answers + 投影到 exam_submissions(status=graded) + submission_answers(answerContent=null),返回 BatchGradeRecordResult[]({gradeRecordId, studentId, submissionId})供调用方触发错题采集等后置钩子 | -| `data-access-analytics.ts` | 417 | 趋势/对比分析(P3 修复:getClassComparison 应用 buildScopeClassFilter;v3-P2 新增:getExamOptionsForGrades/getSchoolWideGradeSummary;getGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) | +| `data-access-analytics.ts` | 23 | **G2-005 / S-01 治理(2026-07-07)**:barrel 重新导出,原 831 行超过 800 行建议上限,拆分为 4 个按分析维度划分的子文件(trend/class/student/overview),保持向后兼容,所有消费者无需修改 import 路径 | +| `data-access-analytics-trend.ts` | 135 | **G2-005 / S-01 新增(2026-07-07)**:成绩趋势分析(getGradeTrendRaw/getGradeTrend + getExamOptionsForGradesRaw/getExamOptionsForGrades + GradeTrendParams 接口),从 data-access-analytics.ts 拆分 | +| `data-access-analytics-class.ts` | 340 | **G2-005 / S-01 新增(2026-07-07)**:班级/科目/年级维度分析(getClassComparison/getClassComparisonWithSignificance + getSubjectComparison + getGradeDistribution + getGradeDistributionByGradeId + 4 个 Params 接口),从 data-access-analytics.ts 拆分 | +| `data-access-analytics-student.ts` | 225 | **G2-005 / S-01 新增(2026-07-07)**:学生维度分析(getStudentPositionInClassDistribution + getStudentGrowthArchive),从 data-access-analytics.ts 拆分 | +| `data-access-analytics-overview.ts` | 135 | **G2-005 / S-01 新增(2026-07-07)**:全校汇总分析(getSchoolWideGradeSummary,管理员视图,按年级聚合),从 data-access-analytics.ts 拆分 | | `data-access-ranking.ts` | 166 | 排名查询(P3 修复:getRankingTrend 接受 scope 参数 + class_taught 校验) | | `stats-service.ts` | 285 | 统计计算纯函数(P1-1 新增:8 个纯函数 + 2 个常量 + 2 个接口;P3-10:createDefaultBuckets 改为内部函数;P3-24:buildGradeTrendPoints 使用 isGradeTrendType 类型守卫替代 as 断言) | | `export.ts` | 290+ | Excel 导出(v2-P1-5 修复:传递 currentUserId 到 data-access;P3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope;P3-6:复用 stats-service.computeAverageScore 替代局部 avg;P3-7:硬编码中文改用 next-intl getTranslations;v4-P1-12 新增:exportStudentGradeRecordsToExcel 家长视角单学生导出) | @@ -1549,6 +1556,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - **新增权限点使用**:`student/learning/page.tsx` / `student/learning/courses/page.tsx` / `student/learning/courses/[classId]/page.tsx` / `student/schedule/page.tsx` 接入 `requirePermission(CLASS_READ)` - **generateMetadata 接入**(✅ P2 修复新增):8 个 page.tsx 新增 `generateMetadata` 导出(通过 next-intl `getTranslations` 生成页面标题):`teacher/classes/my/page.tsx`、`teacher/classes/my/[id]/page.tsx`、`teacher/classes/schedule/page.tsx`、`teacher/classes/students/page.tsx`、`student/learning/page.tsx`、`student/learning/courses/page.tsx`、`student/learning/courses/[classId]/page.tsx`、`student/schedule/page.tsx` +> 架构变更(2026-07-07,S-03 重复 Helper 提取): +> - **G3-020 admin 班级列表去重**:新增 `lib/admin-class-mappers.ts`(115 行),抽出 `buildSubjectsByClassIdMap`(subjectTeachers 行 → Map>)+ `composeAdminClassList`(classRows + subjectsByClassId → AdminClassListItem[],含 compareClassLike 排序);消除 `data-access-admin.ts` 中 145+124 行重复的 subjectsByClassId 构建与 list 排序逻辑,data-access-admin.ts 从 406 行降至 353 行 +> - **G3-036 isClassSubject/toClassSubject 去重**:从 `data-access-admin.ts`/`data-access-teacher.ts`/`actions-shared.ts` 移除本地 `isClassSubject`/`toClassSubject`,统一从 `lib/admin-class-mappers.ts` 导入;`actions-shared.ts` 通过 `export { isClassSubject } from "./lib/admin-class-mappers"` re-export 保持向后兼容 +> - **lib/admin-class-mappers.ts 接口**:`isClassSubject`/`toClassSubject`/`buildSubjectsByClassIdMap`/`composeAdminClassList` 4 个函数 + `SubjectTeacherRow`/`AdminClassRow` 2 个接口(供 data-access-admin 类型标注) + **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| @@ -1556,7 +1568,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `data-access-stats.ts` | 513 | 作业统计查询(班级/年级作业洞察,通过 homework/data-access-classes 获取数据) | | `data-access-schedule.ts` | 93 | 课表查询(学生/班级课表只读,P0-5 已修复:写函数已迁移至 scheduling 模块) | | `data-access-students.ts` | 253 | 学生相关查询(科目成绩、学生名单、学生班级,通过 homework/data-access-classes 获取数据) | -| `data-access-admin.ts` | 406 | 管理员班级管理(管理员班级 CRUD、年级管理班级查询) | +| `data-access-admin.ts` | 353 | 管理员班级管理(管理员班级 CRUD、年级管理班级查询;2026-07-07 S-03:移除 145+124 行重复的 subjectsByClassId 构建与 list 排序,改调 `lib/admin-class-mappers`) | +| `lib/admin-class-mappers.ts` | 115 | **admin 班级列表共享辅助(2026-07-07 S-03 新增)**:`isClassSubject`/`toClassSubject`/`buildSubjectsByClassIdMap`/`composeAdminClassList` 4 个函数 + `SubjectTeacherRow`/`AdminClassRow` 2 个接口;消除 data-access-admin/data-access-teacher/actions-shared 三处重复定义 | | `actions.ts` | 50 | Barrel re-export(P0-3 修复:从 974 行拆分为 6 个文件) | | `actions-teacher.ts` | 100 | 教师班级 CRUD(3 个 Action) | | `actions-admin.ts` | 120 | 管理员班级 CRUD(3 个 Action) | @@ -1617,8 +1630,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **导出函数**: - Actions:`createSchoolAction` / `updateSchoolAction` / `deleteSchoolAction` / `createAcademicYearAction` / `updateAcademicYearAction` / `deleteAcademicYearAction` / `createDepartmentAction` / `updateDepartmentAction` / `deleteDepartmentAction` / `createGradeAction` / `updateGradeAction` / `deleteGradeAction` / `promoteGradesAction`(编排层:权限校验 + Zod 校验 + 调用 data-access + revalidatePath + after(logAudit);`promoteGradesAction` 年级升级,审计日志 `grade.promote`) -- Data-access:只读查询(`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff` / `getOrgTree` / `getSubjectOptions` / `getGradeOptions` / `getSubjectNameMapByIds`(P1-1 新增:批量科目名称映射,供 homework/data-access-classes 调用)/ `getGradeOverviewStats`(✅ v4-P2-6 新增:年级概览统计,返回 `GradeOverviewStats[]`,每个年级的 classCount/studentCount/teacherCount,供年级管理卡片视图使用,动态导入 classes/data-access 避免循环依赖))+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`)+ `promoteGrades(schoolId)` 年级升级(order +1 + 名称升级,辅助函数 `promoteGradeName`) -- Types(✅ v4-P2-6 新增):`GradeOverviewStats`(年级概览统计接口:{ gradeId, classCount, studentCount, teacherCount },位于 data-access.ts) +- Data-access:只读查询(`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff` / `getGradesByIds`(✅ G3-007 / S-03 新增:按 ID 列表批量获取年级,供 lib/school-permissions teacher 分支使用)/ `getOrgTree` / `getSubjectOptions` / `getGradeOptions` / `getSubjectNameMapByIds`(P1-1 新增:批量科目名称映射,供 homework/data-access-classes 调用)/ `getGradeOverviewStats`(✅ v4-P2-6 新增:年级概览统计,返回 `GradeOverviewStats[]`,每个年级的 classCount/studentCount/teacherCount,供年级管理卡片视图使用,动态导入 classes/data-access 避免循环依赖))+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`)+ `promoteGrades(schoolId)` 年级升级(order +1 + 名称升级,辅助函数 `promoteGradeName`;✅ G3-012 / F-09:使用 db.transaction 包裹循环 UPDATE) +- Data-access 文件结构(✅ G3-007 / S-01 修复:从 938 行单文件拆分为 barrel + 6 个子文件):`data-access.ts`(barrel)→ `data-access-departments.ts` / `data-access-grades.ts` / `data-access-subjects.ts` / `data-access-classrooms.ts` / `data-access-semesters.ts` / `data-access-schools.ts` +- Lib(✅ G3-011 / A-02 新增):`lib/school-permissions.ts` 包含 `getSchoolsForUser(userId)` / `getGradesForUser(userId)` 角色感知查询(admin/grade_head/teaching_head/teacher 分支),从 data-access 层迁出 +- Types(✅ v4-P2-6 新增):`GradeOverviewStats`(年级概览统计接口:{ gradeId, classCount, studentCount, teacherCount },位于 data-access-grades.ts) - Components:`SchoolsClient` / `SchoolFormDialog` / `SchoolDeleteDialog` / `SchoolListToolbar` / `SchoolErrorBoundary` / `SchoolListSkeleton` / `SchoolCardSkeleton` / `OrgTreeNav` / `GradesClient`(✅ v4-P2-6 更新:新增 `gradeStats: GradeOverviewStats[]` prop,渲染年级概览卡片视图——每个年级卡片展示班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口)/ `GradeInsightsFilters`(✅ v4-P2-6 新增:年级洞察筛选器,使用 ChipNav 替代原生 form get,点击 chip 即时通过 URL 参数切换,无整页刷新)/ `GradeDistributionPanel` / `GradeHomeworkPanel` / `GradeExamsPanel` / `GradeProgressPanel`(✅ v4-P2-7 新增:年级仪表盘 4 个维度面板组件,位于 `components/grade-dashboard/` 子目录,服务端组件直接渲染数据,无客户端交互) **依赖关系**: @@ -1628,7 +1643,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **已知问题**: - ✅ P0-8 已修复:`actions.ts` 不再直接导入 `db` 和 schema,所有 DB 写操作下沉到 `data-access.ts`,符合三层架构 - ✅ P2 已修复:`logAudit()` 通过 Next.js `after()` 异步非阻塞执行 -- ✅ P2 已修复:`data-access.ts` 中 8 处 catch 块添加 `console.error` 输出错误上下文(getDepartments/getAcademicYears/getSchools/getGrades/getStaffOptions/getGradesForStaff/getSubjectOptions/getGradeOptions) +- ✅ G3-010 / A-10 修复(2026-07-07):~~`data-access.ts` 中 8 处 catch 块添加 `console.error` 输出错误上下文~~ 改为移除所有 try-catch 错误吞没 + log.error,让错误向上抛出(data-access 层不应吞没错误) +- ✅ G3-007 / S-01 修复(2026-07-07):~~`data-access.ts` 938 行超过 800 行上限~~ 拆分为 6 个按职责划分的子文件 + 1 个 lib(school-permissions),单文件均 < 460 行 +- ✅ G3-011 / A-02 修复(2026-07-07):~~角色判断逻辑(getSchoolsForUser / getGradesForUser)混在 data-access 层~~ 迁出至 `lib/school-permissions.ts`,data-access 层只接受显式参数 +- ✅ G3-012 / F-09 修复(2026-07-07):~~`promoteGrades` 循环 UPDATE 无事务~~ 改用 `db.transaction(async (tx) => {...})` 包裹整个循环,保证原子性 +- ✅ S-03 修复(2026-07-07):抽取 `fetchGradesWithHeads` helper,消除 getGrades / getGradesForStaff / getGradesForUser 三处重复的"查询年级+主任用户+映射"代码 - ⚠️ P2:审计日志不一致(仅 school 实体记录,department/academicYear/grade 未记录) - ⚠️ P2:`getStaffOptions`/`getGrades` 直查 users/roles(展示用,可接受) - ✅ P0-2 修复(2026-06-22):~~年级 CRUD 逻辑与 `grade-management` 模块重复定义~~ `grade-management` 死模块已删除,年级 CRUD 统一由 school 模块负责 @@ -1649,7 +1668,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | 文件 | 行数 | 职责 | |------|------|------| | `actions.ts` | 457 | 13 个 Server Action(编排层,无 DB 直访;含 `promoteGradesAction` 年级升级) | -| `data-access.ts` | 782 | 只读查询 + 12 个写操作 + `promoteGrades` 年级升级 + `getOrgTree` 组织架构树 + 跨模块查询接口 + 权限感知函数(getSchoolsForUser/getGradesForUser) | +| `data-access.ts` | 33 | barrel 重新导出(G3-007 / S-01 修复:从 938 行拆分为 6 个子文件 + 1 个 lib) | +| `data-access-departments.ts` | 65 | 部门 CRUD(G3-007 / S-01 拆分;G3-010 / A-10 移除 try-catch 错误吞没) | +| `data-access-grades.ts` | 452 | 年级 CRUD + promoteGrades(G3-012 / F-09 事务包裹)+ 跨模块年级接口 + 年级概览统计 + fetchGradesWithHeads helper(S-03 抽取)+ getGradesByIds(新增供 school-permissions 使用) | +| `data-access-subjects.ts` | 98 | 科目只读接口(跨模块:getSubjectOptions / getSubjectNameById / getSubjectNameMapByIds) | +| `data-access-classrooms.ts` | 42 | 教室只读接口(跨模块:getClassroomsForScheduling 供排课使用) | +| `data-access-semesters.ts` | 86 | 学年 CRUD(createAcademicYear / updateAcademicYear 已用 db.transaction) | +| `data-access-schools.ts` | 161 | 学校 CRUD + getStaffOptions 员工选项 + getOrgTree 组织架构树 | +| `lib/school-permissions.ts` | 145 | 角色感知查询(G3-011 / A-02 迁出:getSchoolsForUser / getGradesForUser,根据用户角色返回可见数据范围) | | `schema.ts` | 56 | Zod 校验(含 `PromoteGradesSchema`) | | `types.ts` | 103 | 类型定义(含 Insert/Update 入参类型 + `OrgTreeNode`) | | components/schools-view.tsx | 132 | 学校列表容器(组合模式,P1-5 修复) | @@ -1706,7 +1732,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `auto-scheduler.ts` | 310 | 排课算法(纯函数,标杆) | | `actions.ts` | 302 | 8 个 Server Action | | `data-access.ts` | 398 | 排课辅助查询 + 规则/变更 CRUD + classSchedule 低级写入(insert/update/delete/replace) | -| `data-access-class-schedule.ts` | 165 | classSchedule 业务写入(P0-5 新增,从 classes 模块迁移,含教师归属校验) | +| `data-access-class-schedule.ts` | 165 | classSchedule 业务写入(P0-5 新增,从 classes 模块迁移,含教师归属校验;A-02 修复:字段校验已抽离至 lib/schedule-validation.ts,仅保留 DB 归属校验 + CRUD) | +| `lib/schedule-validation.ts` | 173 | 课表项纯校验函数(A-02 新增:isTimeHHMM/isValidWeekday/isTimeRangeValid/normalizeLocation/normalizeAndValidateCreateInput/buildValidatedScheduleUpdate) | | `schema.ts` | - | Zod 校验 | | `types.ts` | - | 类型定义(含 P0-5 迁移的 CreateClassScheduleItemInput / UpdateClassScheduleItemInput) | @@ -2309,6 +2336,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P1 已修复:~~`FileTargetType` 缺 `"user_avatar"` 枚举值导致 settings 头像上传 targetType 未注册~~ 已加入枚举 - ✅ P2 已修复:~~`file-list.tsx` 125 行死代码~~ 已删除 - ✅ P2 已修复:~~所有函数 try-catch 吞错误返回空数组/null~~ 所有 catch 块已添加 `console.error` 输出错误上下文 +- ✅ 审计治理已修复(G5-005/G5-006/G5-007/P-05/A-10/F-03):~~data-access.ts 12 处 `log.error` + try-catch 吞错误返回 null/[]/false + `db.select()` SELECT \*~~ 移除全部 try-catch 与 `log.error`,错误自然抛出由 actions 层捕获返回 `ActionState`;`db.select()` 改为显式列枚举 `db.select(fileAttachmentColumns)` - ✅ P2 已修复:`getFileAttachmentsWithFilters` 中 `or(...)!` 非空断言清理为安全守卫 - ✅ P2 已修复:~~`getFileAttachmentsWithFilters` 中 `conditions` 隐式 `any[]`~~ 改为显式 `SQL[]` 类型标注 - ✅ P2 已修复:~~`admin-files-view.tsx` 中 `t(`filter.${key}` as never)` 类型断言~~ 改为 `renderTypeLabel` 辅助函数 + 字面量联合类型 + switch @@ -2320,7 +2348,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `data-access.ts` | 305 | 文件 CRUD + 批量删除 + 统计(11 个函数 + mapRow) | +| `data-access.ts` | 280 | 文件 CRUD + 批量删除 + 统计(11 个函数 + mapRow,审计治理后移除 try-catch/log.error + 显式列枚举) | | `actions.ts` | 330 | 6 个 Server Action(编排层,权限+Zod+storageProvider+trackEvent+logAudit) | | `schema.ts` | - | Zod 校验(UploadMetadata/BatchDelete/FileListQuery/FileTargetType) | | `types.ts` | 70 | 类型定义(FileTargetType 含 user_avatar) | @@ -2542,6 +2570,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P2-9 已修复:`selectCourse` 新增时间冲突检测(`parseSchedule` + `isScheduleConflict` 纯函数 + `checkScheduleConflict` 异步查询) - ✅ P2-10 已修复:`selectCourse` 新增学分上限校验(`checkCreditLimit`,MAX_CREDIT_PER_TERM=10) - ✅ P2-11 已修复:新增 `export.ts`,课程列表/选课名单可导出 Excel +- ✅ A-02 已修复:业务逻辑从 data-access 抽离至 lib/——`lib/schedule-conflict.ts`(normalizeDay/parseSchedule/isScheduleConflict/hasAnyScheduleConflict 纯函数)、`lib/lottery.ts`(buildLotteryRankCase/fisherYatesShuffle/partitionLottery 纯函数);data-access-operations.ts 仅保留 DB CRUD + 调用 lib 函数,并通过 re-export 保持向后兼容 - ✅ P0-1 已修复(2026-06-25):教师页面跳转 admin 路由修复,新增 `teacher/elective/create` 与 `teacher/elective/[id]/edit` 路由(含权限校验、教师越权防护) - ✅ P0-2 已修复(2026-06-25):新增 `parent/elective/page.tsx`(含 loading.tsx + error.tsx),使用 `ParentChildrenDataPage` 模式 + `parentId+studentId` 双重校验 - ✅ P0-3 已修复(2026-06-25):`admin/elective/page.tsx` 与 `student/elective/page.tsx` 入口添加 `requirePermission(Permissions.ELECTIVE_READ)` 纵深防御 @@ -2573,7 +2602,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" |------|------|------| | `actions.ts` | 348 | 11 个 Server Action(P0-4:translateBusinessError 翻译 ElectiveBusinessError) | | `data-access.ts` | 258 | 课程 CRUD + scope 过滤 + 共享映射函数(P1-10:移除静默吞错) | -| `data-access-operations.ts` | 374 | 选课操作(P0-4:ElectiveBusinessError + i18n code;P1-11:parseSchedule 支持完整星期+多时段;P1-12:抽签可重跑) | +| `data-access-operations.ts` | 374 | 选课操作(P0-4:ElectiveBusinessError + i18n code;P1-11:parseSchedule 支持完整星期+多时段;P1-12:抽签可重跑;A-02:纯函数抽离至 lib/,仅保留 DB CRUD + re-export 向后兼容) | +| `lib/schedule-conflict.ts` | 108 | 时间冲突检测纯函数(A-02 新增:normalizeDay/parseSchedule/isScheduleConflict/hasAnyScheduleConflict) | +| `lib/lottery.ts` | 72 | 抽签算法纯函数(A-02 新增:buildLotteryRankCase/fisherYatesShuffle/partitionLottery) | | `data-access-selections.ts` | 147 | 选课记录查询 + 学生可选课程 | | `data-access-stats.ts` | 88 | 管理员概览统计(P1-13 新增:getElectiveOverviewStats) | | `resolvers.ts` | 83 | 跨模块依赖接口抽象 | @@ -3122,13 +3153,21 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" > - **P2-6 Zustand store 拆 slice**:`hooks/use-lesson-plan-editor.ts` 从 303 行单体 store 拆分为 3 个独立 slice——`hooks/editor-slice.ts`(文档结构 + 文档操作)/`hooks/selection-slice.ts`(选中状态)/`hooks/version-slice.ts`(版本与保存状态);主文件重写为 25 行薄层,组合 3 个 slice 并导出 `EditorState` 类型 > - **type-guards 导出补全**:`lib/type-guards.ts` 的 `VALID_QUESTION_TYPES` 常量从模块私有改为 `export`(供 `inline-question-editor.tsx` 复用,消除重复定义) +> 架构变更(2026-07-07,S-03 重复 Helper 提取 + S-06 JSDoc 补全): +> - **G1-026 状态映射去重**:新增 `lib/status-mappers.ts`(20 行),集中 `toLessonPlanStatus` / `toReviewDecision`;从 `data-access-review.ts` 与 `data-access-calendar.ts` 移除本地重复实现 +> - **G1-027 isStringArray 去重**:从 `data-access-knowledge.ts` 移除本地 `isStringArray`,改从 `lib/type-guards.ts` 导入(守卫已存在) +> - **G3-036 修复 buggy 守卫**:`data-access.ts` 移除本地 `isLessonPlanStatus`(仅含 3/6 状态:draft/published/archived),统一使用 `lib/type-guards.ts` 完整 6 状态守卫(含 submitted/approved/rejected);同时移除本地 `isTemplateType`/`isTemplateScope`,统一从 `lib/type-guards.ts` 导入 +> - **S-06 JSDoc 补全**:`data-access-versions.ts`(5 函数)/ `data-access-templates.ts`(3 函数)所有公开导出函数补全 JSDoc(@param + @returns + @throws) +> - **修复 textbooks/data-access.ts 语法错误**:`getKnowledgePointsByTextbookIdRaw` 箭头函数缺失 `=>`(pre-existing bug),补全后通过 tsc + **文件清单**: | 文件 | 职责 | |------|------| | `types.ts` | 类型定义(含 v1/v2/v3 文档类型、TextbookContentNode、LessonPlanNode、NodeAnchor、AnchorEdge、FlowEdge、11 种 BlockData 接口) | | `constants.ts` | 常量定义 | | `schema.ts` | Zod 验证(V3:错误消息改为 i18n 键,如 `error.titleRequired`) | -| `lib/type-guards.ts` | **集中类型守卫(V3 新增)**:11 种 BlockData 类型守卫(isRichTextBlockData/isTextStudyBlockData/isExerciseBlockData/isObjectiveBlockData/isKeyPointBlockData/isImportBlockData/isNewTeachingBlockData/isSummaryBlockData/isHomeworkBlockData/isBlackboardBlockData/isReflectionBlockData)+ 节点类型守卫(isTextbookContentNode/isLessonPlanNode)+ 题目类型守卫(isValidQuestionType + **V4 导出 `VALID_QUESTION_TYPES` 常量 + `ValidQuestionType` 类型**)+ 基础类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope/isBlockType/isReviewDecision)+ **Block 字段值类型守卫(V3 续新增)**:isBlackboardLayout/isImportMethod/isExercisePurpose/isObjectiveDimension/isKeyPointType/isHomeworkType/isReflectionAspect(用于 select onChange 替代 `as` 断言)+ **normalizeTemplateBlocks 规范化函数(V3 续审计新增)**:从 DB `unknown` 安全转换为 `TemplateBlockSkeleton[]`,替代 `as LessonPlanTemplate["blocks"]` 断言 + **类型守卫专项重构(2026-07-04)**:所有 `isXxxBlockData` 守卫参数从 `BlockData` 拓宽为 `unknown`(守卫内部已用 `isObject` 检查,安全;使守卫可从 `unknown` 直接收窄,支持 `AnyLessonPlanNode.data` 联合类型);新增 `mergeBlockDataPatch(node, patch)` 辅助函数 + `BLOCK_DATA_GUARDS` 注册表(BlockType → 守卫映射),替代 AI patch 合并中的 `{ ...node.data, ...patch } as BlockData` 断言,校验失败回退原 data 保证编辑器完整性 | +| `lib/type-guards.ts` | **集中类型守卫(V3 新增)**:11 种 BlockData 类型守卫(isRichTextBlockData/isTextStudyBlockData/isExerciseBlockData/isObjectiveBlockData/isKeyPointBlockData/isImportBlockData/isNewTeachingBlockData/isSummaryBlockData/isHomeworkBlockData/isBlackboardBlockData/isReflectionBlockData)+ 节点类型守卫(isTextbookContentNode/isLessonPlanNode)+ 题目类型守卫(isValidQuestionType + **V4 导出 `VALID_QUESTION_TYPES` 常量 + `ValidQuestionType` 类型**)+ 基础类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope/isBlockType/isReviewDecision)+ **Block 字段值类型守卫(V3 续新增)**:isBlackboardLayout/isImportMethod/isExercisePurpose/isObjectiveDimension/isKeyPointType/isHomeworkType/isReflectionAspect(用于 select onChange 替代 `as` 断言)+ **normalizeTemplateBlocks 规范化函数(V3 续审计新增)**:从 DB `unknown` 安全转换为 `TemplateBlockSkeleton[]`,替代 `as LessonPlanTemplate["blocks"]` 断言 + **类型守卫专项重构(2026-07-04)**:所有 `isXxxBlockData` 守卫参数从 `BlockData` 拓宽为 `unknown`(守卫内部已用 `isObject` 检查,安全;使守卫可从 `unknown` 直接收窄,支持 `AnyLessonPlanNode.data` 联合类型);新增 `mergeBlockDataPatch(node, patch)` 辅助函数 + `BLOCK_DATA_GUARDS` 注册表(BlockType → 守卫映射),替代 AI patch 合并中的 `{ ...node.data, ...patch } as BlockData` 断言,校验失败回退原 data 保证编辑器完整性 + **isStringArray 字符串数组守卫(2026-07-07 S-03 去重)**:从 `data-access-knowledge.ts` 移入,消除局部重复定义;同时 `data-access.ts` 移除本地 buggy 的 `isLessonPlanStatus`(仅含 3/6 状态),统一使用本文件的 6 状态守卫 | +| `lib/status-mappers.ts` | **状态映射辅助(2026-07-07 S-03 去重新增)**:集中 `toLessonPlanStatus(value: string): LessonPlanStatus`(非法值回退 "draft")+ `toReviewDecision(value: string): ReviewDecision`(非法值回退 "rejected");消除 `data-access-review.ts` 与 `data-access-calendar.ts` 中重复的本地实现(G1-026) | | `lib/i18n-errors.ts` | **Zod 错误 i18n 翻译辅助(V3 新增)**:`translateFieldErrors`(将 Zod fieldErrors 中的 i18n 键翻译为实际消息)/ `safeParseWithI18n`(安全解析 Zod 结果并返回带翻译的 ActionState 错误格式) | | `lib/document-migration.ts` | **纯函数**:v1→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(normalizeDocument,兼容 v1/v2/v3/v4)/ 初始内容(buildInitialContent)/ 默认骨架(buildDefaultSkeleton)/ defaultDataForType / 工具函数(isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges) | | `lib/anchor-mark.ts` | **V4 新增**:Tiptap `AnchorMark` 扩展(区间锚)+ `AnchorPoint` 扩展(点锚),替代 v3 字符串偏移锚点;存储在正文富文本 doc 中 | @@ -3139,9 +3178,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `providers/lesson-plan-provider-setup.tsx` | **页面层 Provider 设置包装组件(V3 续新增)**:LessonPlanProviderSetup 自动注入默认数据服务(createDefaultDataService)和角色配置(TEACHER_ROLE_CONFIG),3 个 teacher 页面用此组件包裹 | | `services/default-data-service.ts` | **默认数据服务实现**:createDefaultDataService() 包装 Server Actions 为 LessonPlanDataService 实现(V3:实现扩展的 5 个方法;**V3 续扩展 7 个方法 createLessonPlan/getTextbooksForPicker/getChaptersForPicker/getLessonPlanTemplates/getKnowledgePointOptions/publishLessonPlanHomework/getQuestions**),测试可替换为 mock | | `data-access.ts` | 课案 CRUD + 模板查询(V3:新增 `getLessonPlanStats()` 统计函数 + `LessonPlanStats` 接口;`duplicateLessonPlan` 接受 `duplicateSuffix` 参数消除硬编码;V3 续审计:使用 `normalizeTemplateBlocks` 替代 `as unknown as LessonPlanTemplate["blocks"]` 断言;从 `textbooks/data-access` 导入 `findChapterById` 消除本地重复实现) | -| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理) | -| `data-access-templates.ts` | 个人模板 CRUD(V3 续审计:使用 `normalizeTemplateBlocks` 替代 `as LessonPlanTemplate["blocks"]` 断言) | -| `data-access-knowledge.ts` | 按知识点/题目反查课案(V3 续审计:移除两处 `as unknown as` 断言,改用 `getKnowledgePointIds`/`getQuestionIds` 安全字段提取辅助函数) | +| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理)(2026-07-07 S-06:5 个导出函数补全 JSDoc) | +| `data-access-templates.ts` | 个人模板 CRUD(V3 续审计:使用 `normalizeTemplateBlocks` 替代 `as LessonPlanTemplate["blocks"]` 断言)(2026-07-07 S-06:3 个导出函数补全 JSDoc) | +| `data-access-knowledge.ts` | 按知识点/题目反查课案(V3 续审计:移除两处 `as unknown as` 断言,改用 `getKnowledgePointIds`/`getQuestionIds` 安全字段提取辅助函数)(2026-07-07 S-03:移除本地 `isStringArray`,改从 `lib/type-guards` 导入) | | `actions.ts` | 课案 CRUD/版本/模板 Server Actions(V3:所有 action 使用 `translateFieldErrors` 翻译 Zod 错误;`duplicateLessonPlanAction` 传入 i18n 翻译的副本后缀;`getLessonPlanByIdAction` 返回类型改为 `ActionState<{ plan: LessonPlan }>`) | | `actions-publish.ts` | 发布作业 Server Action(V3:actions 层注入 i18n 翻译的作业标题/描述/日期标签;新增 `INVALID_QUESTION_TYPE` 错误码映射) | | `actions-ai.ts` | AI 知识点建议 Server Action(V3 续审计:显式 `unknown[]` 类型标注替代隐式 any;统一使用 `handleActionError` + `translateFieldErrors` 处理错误) | @@ -3204,8 +3243,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `components/blocks/reflection-block.tsx` | 反思 Block(V3 续:select onChange 使用 `isReflectionAspect` 类型守卫替代 `as` 断言) | | `components/calendar-view.tsx` | **M9 日历视图组件(2026-07-01 新增)**:客户端组件,支持周历/月历切换,6 种事件类型颜色映射 | | `components/curriculum-map-view.tsx` | **M4 课程地图热力图组件(2026-07-01 新增)**:学科×年级矩阵表格,颜色编码覆盖率 | -| `data-access-calendar.ts` | **M9 日历事件数据访问层(2026-07-01 新增)** | -| `data-access-review.ts` | **M3 审核工作流数据访问层(2026-07-01 新增)** | +| `data-access-calendar.ts` | **M9 日历事件数据访问层(2026-07-01 新增)**(2026-07-07 S-03:移除本地 `toLessonPlanStatus`/`isLessonPlanStatus`,改从 `lib/status-mappers` + `lib/type-guards` 导入) | +| `data-access-review.ts` | **M3 审核工作流数据访问层(2026-07-01 新增)**(2026-07-07 S-03:移除本地 `toLessonPlanStatus`/`toReviewDecision`,改从 `lib/status-mappers` 导入) | | `data-access-comments.ts` | **M2 协同备课评论数据访问层(2026-07-01 新增)** | | `data-access-formative.ts` | **M5 形成性评价数据访问层(2026-07-01 新增)** | | `data-access-attachments.ts` | **M6 资源附件库数据访问层(2026-07-01 新增)** | @@ -4434,16 +4473,18 @@ type ApiResponse = { ### 3.8.2 高风险模块(P0+P1 ≥ 3) -| 模块 | P0 | P1 | 主要问题 | -|---|---|---|---| -| messaging | 1 | 4 | 单文件 1089 行超硬限 + 业务逻辑嵌入 | -| classes | 1 | 4 | cacheFn 全缺失 + 邀请码状态机 + N+1 | -| school | 0 | 5 | 938 行 + 30+ 导出 + 12 console.error + 角色判断 | -| lesson-preparation | 1 | 3 | LIKE 全表扫描 + N+1 + 无 LIMIT | -| scheduling | 1 | 2 | 跨模块 schema 直查 + 业务逻辑嵌入 | -| adaptive-practice | 1 | 2 | 2N+1 循环查询 | -| elective | 0 | 3 | 抽签算法 + 冲突检测 + i18n 通知嵌入 data-access | -| textbooks | 2 | 1 | 跨模块 schema 直查 + N+1 | +> ✅ Phase 1-4 全部修复完成(2026-07-07),tsc + lint 0 errors。 + +| 模块 | P0 | P1 | 主要问题 | 修复状态 | +|---|---|---|---|---| +| messaging | 1 | 4 | 单文件 1089 行超硬限 + 业务逻辑嵌入 | ✅ Phase 0 P0-5:拆分为 5 子文件 + barrel | +| classes | 1 | 4 | cacheFn 全缺失 + 邀请码状态机 + N+1 | ✅ Phase 1 F-01 + Phase 3 P-03:N+1 修复 + 21 函数 cacheFn 包装 + lib/admin-class-mappers 去重 | +| school | 0 | 5 | 938 行 + 30+ 导出 + 12 console.error + 角色判断 | ✅ Phase 2 S-01/A-10/A-02/F-09:拆 6 子文件 + lib/school-permissions + 移除 console.error + promoteGrades 事务 | +| lesson-preparation | 1 | 3 | LIKE 全表扫描 + N+1 + 无 LIMIT | ✅ Phase 1 F-01/F-05 + Phase 2 F-02:BFS 批量删除 + DEFAULT_LIMIT + 前缀匹配 + lib/status-mappers 去重 | +| scheduling | 1 | 2 | 跨模块 schema 直查 + 业务逻辑嵌入 | ✅ Phase 1 A-06/A-02:跨模块改 data-access 接口 + 校验逻辑抽离至 lib/schedule-validation.ts | +| adaptive-practice | 1 | 2 | 2N+1 循环查询 | ✅ Phase 1 F-01:getTeacherClassPracticeOverviewsRaw 批量学生 ID + GROUP BY 单条聚合 | +| elective | 0 | 3 | 抽签算法 + 冲突检测 + i18n 通知嵌入 data-access | ✅ Phase 1 A-02:抽签算法抽离至 lib/lottery.ts、冲突检测抽离至 lib/schedule-conflict.ts | +| textbooks | 2 | 1 | 跨模块 schema 直查 + N+1 | ✅ Phase 1 A-06/F-01:graph 改调 questions/diagnostic 接口 + reorderChapters 事务并行 UPDATE | ### 3.8.3 P0 关键问题(17 条) @@ -4453,28 +4494,57 @@ type ApiResponse = { - `exams/data-access.ts` 与 `onboarding/data-access.ts` 缺 `import "server-only"`(DB 逻辑泄露风险) #### 架构硬违规(5 条) -- `textbooks/data-access-graph.ts` 直查 questions + diagnostic 模块表 -- `scheduling/data-access.ts` 直查 classes/users/subjects 三模块表 -- `scheduling/data-access-class-schedule.ts` 含校验+状态机业务逻辑 +- `textbooks/data-access-graph.ts` 直查 questions + diagnostic 模块表(✅ A-06 已修复:改调 questions/diagnostic data-access 批量接口) +- `scheduling/data-access.ts` 直查 classes/users/subjects 三模块表(✅ A-06 已修复:改调 classes/users/school data-access 接口) +- `scheduling/data-access-class-schedule.ts` 含校验+状态机业务逻辑(✅ A-02 已修复:校验逻辑抽离至 lib/schedule-validation.ts) - `onboarding/actions.ts` 直查 DB(违反三层架构) - `messaging/data-access.ts` 1089 行超 1000 硬限 #### 必定性能问题(8 条) -- 4 处递归/循环 N+1:questions(deleteQuestionRecursive/Batch)、lesson-preparation(deleteComment)、textbooks(reorderChapters) +- 4 处递归/循环 N+1:questions(deleteQuestionRecursive/Batch)、lesson-preparation(deleteComment)、textbooks(reorderChapters)— ✅ F-01 已修复:BFS 批量收集 + inArray 批量删除/并行 UPDATE - classes 24+ 读函数未走 cacheFn -- classes getTeacherClassesRaw 2N+1 -- course-plans reorderCoursePlanItems N+1 + 未包裹事务 -- adaptive-practice getTeacherClassPracticeOverviewsRaw 2N+1 +- classes getTeacherClassesRaw 2N+1 — ✅ F-01 已修复:课表批量查询 + Map 分组 +- course-plans reorderCoursePlanItems N+1 + 未包裹事务 — ✅ F-01/F-09 已修复:db.transaction 包裹并行 UPDATE +- adaptive-practice getTeacherClassPracticeOverviewsRaw 2N+1 — ✅ F-01 已修复:批量学生 ID + GROUP BY studentId 单条聚合 ### 3.8.4 治理路线图 -| Phase | 范围 | 工作量 | 完成标准 | -|---|---|---|---| -| Phase 0 | P0 安全漏洞 | XS-S | server-only 补齐 + parent actions 新建 + audit 权限点新增 | -| Phase 1 | P0 架构+性能 | M-L | 跨模块 schema 治理 + messaging 拆分 + N+1 修复 + 业务逻辑下移 | -| Phase 2 | P1 性能+结构 | M-L | LIKE 治理 + 超长文件拆分 + school 重构 + 事务修复 | -| Phase 3 | P2 模式标准化 | S-M | cacheFn 补齐 + SELECT * 改显式 + helper 提取 + JSDoc | -| Phase 4 | P3 风格优化 | XS | as 断言 + 非空断言 + export * + 死代码 | +> ✅ Phase 0-4 全部完成(2026-07-07),tsc + lint 0 errors。 + +| Phase | 范围 | 工作量 | 完成标准 | 状态 | +|---|---|---|---|---| +| Phase 0 | P0 安全漏洞 | XS-S | server-only 补齐 + parent actions 新建 + audit 权限点新增 | ✅ 已完成 | +| Phase 1 | P0 架构+性能 | M-L | 跨模块 schema 治理 + messaging 拆分 + N+1 修复 + 业务逻辑下移 | ✅ 已完成 | +| Phase 2 | P1 性能+结构 | M-L | LIKE 治理 + 超长文件拆分 + school 重构 + 事务修复 | ✅ 已完成 | +| Phase 3 | P2 模式标准化 | S-M | cacheFn 补齐 + SELECT * 改显式 + helper 提取 + JSDoc | ✅ 已完成 | +| Phase 4 | P3 风格优化 | XS | as 断言 + 非空断言 + export * + 死代码 | ✅ 已完成 | + +#### Phase 1-4 修复明细 + +**Phase 1(P0 架构+性能)**: +- A-06 跨模块 schema 直查修复:5 文件(textbooks/data-access-graph、scheduling/data-access、lesson-preparation/data-access、lesson-preparation/data-access-schedules、questions/data-access)改调对方 data-access 批量接口;新增 10 个跨模块接口(详见 §3.8.6) +- F-01 N+1 热点修复:8 处(questions 递归删除 BFS + inArray 批量、lesson-preparation deleteComment BFS、textbooks reorderChapters 事务并行 UPDATE、course-plans reorderCoursePlanItems 事务并行、classes getTeacherClassesRaw 批量+Map、classes enrollTeacherByInvitationCode 事务、adaptive-practice getTeacherClassPracticeOverviewsRaw GROUP BY);新增 getActiveStudentIdsByClassIdsBatch +- A-02 业务逻辑下沉:scheduling/lib/schedule-validation.ts(6 纯函数)、elective/lib/lottery.ts(3 函数)+ lib/schedule-conflict.ts(4 函数) + +**Phase 2(P1 性能+结构)**: +- F-02 LIKE 全表扫描:6 处改前缀匹配 + escapeLikePattern 转义;questions 改 FULLTEXT MATCH AGAINST +- school 模块重构(S-01/A-10/A-02/F-09):938 行拆为 6 子文件 + barrel + lib/school-permissions.ts;移除 12 console.error;promoteGrades 事务包裹;fetchGradesWithHeads helper 去重 +- files 模块错误处理(P-05/A-10/F-03):移除 12 console.error + try-catch 吞错;SELECT * 改显式列 +- F-09 事务修复:auth createUser 事务、school promoteGrades 事务 +- F-05 LIMIT 保护:lesson-preparation(5 文件)+ textbooks + classes + proctoring 无分页查询加 DEFAULT_LIMIT=1000 +- grades-analytics 拆分(S-01):831 行 → 4 子文件(trend/class/student/overview)+ barrel + +**Phase 3(P2 模式标准化)**: +- P-03 cacheFn 覆盖:34 读函数包装(classes 21 + questions 5 + textbooks 3 + lesson-preparation 5),均采用 Raw + Wrapper 配对 +- F-03 SELECT * 改显式列:diagnostic 3 处;textbooks pre-existing 语法错误修复 +- S-03 重复 helper 去重:lesson-preparation/lib/status-mappers.ts(toLessonPlanStatus/toReviewDecision)、classes/lib/admin-class-mappers.ts(buildSubjectsByClassIdMap/composeAdminClassList/isClassSubject/toClassSubject)、lesson-preparation isStringArray 提取到 lib/type-guards.ts +- S-06 JSDoc 补全:lesson-preparation 版本/模板(8 函数)+ questions(7 函数)+ textbooks(22 函数) + +**Phase 4(P3 风格优化)**: +- P-09 as 断言:classes/lib/admin-class-mappers.ts + onboarding/actions.ts 改类型标注常量 +- P-09 非空断言:lesson-preparation data-access 6 处改 null 检查 +- S-05 export * → 显式 re-export:14 个 barrel 子文件改具名导出;5 个保留 export *(>15 导出) +- 死代码:未发现确认死代码 ### 3.8.5 架构图待补记项 @@ -4486,6 +4556,50 @@ type ApiResponse = { 4. ✅ 新增权限点 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE —— 已补记至 004 §2.15 与 005 permissions + rolePermissions.admin 5. ✅ 新增 shared/lib/date-utils.ts —— 已补记至 004 §2.1 导出函数与文件清单、005 modules.shared.exports.functions +✅ Phase 1-4 修复已完成(2026-07-07),以下待补记项已全部同步至 004/005 架构文档: + +1. ✅ scheduling/lib/schedule-validation.ts —— 6 个纯函数已补记至 004 §2.9 文件清单与 005 modules.scheduling.exports.lib +2. ✅ elective/lib/lottery.ts + lib/schedule-conflict.ts —— 7 个纯函数已补记至 004 §2.20 文件清单与 005 modules.elective.exports.lib +3. ✅ school 模块拆分(938 行 → 6 子文件 + barrel + lib/school-permissions.ts)—— 已补记至 004 §2.8 文件清单与 005 modules.school.exports(dataAccess 函数 file 字段更新 + lib 节点新增) +4. ✅ grades/data-access-analytics.ts 拆分(831 行 → 4 子文件 + barrel)—— 已补记至 004 §2.6 文件清单与 005 modules.grades.exports.dataAccess(7 函数 file 字段更新 + 3 函数补图) +5. ✅ classes/lib/admin-class-mappers.ts —— 4 函数 + 2 接口已补记至 004 §2.7 文件清单与 005 modules.classes.files +6. ✅ lesson-preparation/lib/status-mappers.ts —— toLessonPlanStatus/toReviewDecision 已补记至 004 §2.27 文件清单与 005 modules.lesson-preparation.exports +7. ✅ 10 个跨模块接口(getTextbookTitlesByIds/getChapterTitlesByIds/getKnowledgePointNamesByIds/getQuestionCountByKpIds/getStudentMasteryByKpIds/getClassMasteryByKpIds/getClassesForScheduling/getClassSubjectAssignmentsByClassId/getDistinctTeacherIdsFromAssignments/getClassroomsForScheduling)—— 已补记至 004 §3.8.6 与 005 对应模块 exports.dataAccess +8. ✅ getActiveStudentIdsByClassIdsBatch —— 已补记至 005 modules.classes.exports.dataAccess +9. ✅ getGradesByIds(school 模块新增供 school-permissions 使用)—— 已补记至 005 modules.school.exports.dataAccess +10. ✅ cacheFn 覆盖 34 读函数(classes 21 + questions 5 + textbooks 3 + lesson-preparation 5)—— Raw + Wrapper 配对已补记至 005 对应模块 exports.dataAccess(file 字段更新) + +### 3.8.6 A-06 跨模块 schema 直查修复(2026-07-07) + +> Phase 1 A-06 规则修复:5 处 data-access 文件直接 import 并查询其他模块 DB schema 表,改为调用对方 data-access 跨模块接口。 + +**新增跨模块批量接口(cacheFn Raw+Wrapper 模式):** + +| 模块 | 新增接口 | 用途 | +|------|---------|------| +| textbooks | `getTextbookTitlesByIds(ids): Map` | 供 lesson-preparation 批量解析教材标题 | +| textbooks | `getChapterTitlesByIds(ids): Map` | 供 lesson-preparation 批量解析章节标题 | +| textbooks | `getKnowledgePointNamesByIds(ids): Map` | 供 questions 批量解析知识点名称 | +| questions | `getQuestionCountByKpIds(ids): Map` | 供 textbooks/graph 批量查关联题目数 | +| diagnostic | `getStudentMasteryByKpIds(sid, ids): Map` | 供 textbooks/graph 查学生掌握度 | +| diagnostic | `getClassMasteryByKpIds(sids, ids): Map` | 供 textbooks/graph 聚合班级掌握度 | +| classes | `getClassesForScheduling(): ClassSchedulingOption[]` | 供 scheduling 获取班级选择器 | +| classes | `getClassSubjectAssignmentsByClassId(cid): ClassSubjectAssignment[]` | 供 scheduling 获取班级科目分配 | +| classes | `getDistinctTeacherIdsFromAssignments(): string[]` | 供 scheduling 获取有任课的教师 ID | +| school | `getClassroomsForScheduling(): ClassroomOption[]` | 供 scheduling 获取教室选择器 | + +**修复的 5 个文件:** + +| 文件 | 原违规 | 修复方式 | +|------|--------|---------| +| `textbooks/data-access-graph.ts` | 直查 `questionsToKnowledgePoints` + `knowledgePointMastery` | 改调 questions/diagnostic data-access 批量接口 | +| `scheduling/data-access.ts` | 直查 `classes/classSubjectTeachers/classrooms/subjects/users` | 改调 classes/users/school data-access 接口 | +| `lesson-preparation/data-access.ts` | 直查 `textbooks` + `chapters` 表 JOIN | 移除 JOIN,改调 textbooks 批量标题接口 | +| `lesson-preparation/data-access-schedules.ts` | 直查 `classes` 表 JOIN | 移除 JOIN,改调 classes 批量名称接口 | +| `questions/data-access.ts` | 直查 `knowledgePoints` 表 JOIN | 移除 JOIN,改调 textbooks 批量名称接口 | + +✅ `npx tsc --noEmit` 零错误,`npm run lint` 零错误 + --- # 附录 A:模块间依赖矩阵 @@ -4748,6 +4862,7 @@ getTeacherClasses(teacherId: string): Promise getStudentClasses(studentId: string): Promise getClassStudents(classId: string): Promise getClassHomeworkInsights(classId: string): Promise // ✅ P0-7 已修复:通过 homework/data-access-classes 获取数据 +getActiveStudentIdsByClassIdsBatch(classIds: string[]): Promise> // 批量获取多班级活跃学生 ID,消除 N+1(F-01) // grades/data-access.ts getGradeRecords(params: GetGradeRecordsParams & { scope: DataScope; currentUserId?: string; limit?: number; offset?: number }): Promise diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index feda1d9..db606ee 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -5,7 +5,7 @@ "generatedAt": "2026-06-17", "formatVersion": "1.1", "rule": "每次文件修改后须同步更新本文件", - "lastUpdate": "性能预算重构专项 Phase 1-4 完成(2026-07-05 审计 + 2026-07-07 补漏):Phase 1 配置基线:(1.1) next.config.ts 新增 experimental.optimizePackageImports 覆盖 lucide-react/recharts/@xyflow/react/@tiptap/* /@radix-ui/*/date-fns;(1.2) serverExternalPackages 追加 tencentcloud-sdk-nodejs + exceljs;(1.3) providers.tsx 注入 WebVitalsReporter 闭环 RUM 上报链路;(1.4) lighthouse.yml + lighthouserc.json CI 性能门槛(LCP<=3000ms/CLS<=0.1 error 级)。Phase 2 Bundle 预算优化:(2.1) TipTap 三实例(shared/ui/rich-text-editor + exams/editor/exam-rich-editor + lesson-preparation/blocks/rich-text-block)全部改 next/dynamic ssr:false + xxx-inner.tsx 拆分模式;(2.2) KnowledgeGraph 改 ReactFlowProvider 同步壳 + next/dynamic lazy inner;(2.3) exams/editor/types.ts barrel 减少服务端消费方拉入 @tiptap/* 运行时;(2.4) chart.tsx 改 recharts 具名导入(ResponsiveContainer/Tooltip/Legend)替代 import * as RechartsPrimitive barrel;(2.5) status-badge 移除多余 use client;(2.6) teacher/student/parent 角色路由补齐 loading.tsx。Phase 3 数据层优化:(3.1) getSession/getAuthContext/resolveDataScope/getSessionTeacherId 全部 React cache() 包裹实现请求级去重;(3.2) copyCoursePlanToClasses 改单事务 + 2 次 batch INSERT;(3.3) gradeHomeworkAnswers 改 UPDATE CASE WHEN 单次批量更新;(3.4) searchQuestions 改 FULLTEXT INDEX + MATCH AGAINST BOOLEAN MODE + toBooleanModeQuery 工具;(3.5) getAllUserIds 加默认 LIMIT 1000 + 分页参数;(3.6) announcements/notifications fan-out 改 createNotifications 批量 INSERT + sendBatchNotifications 并行收集;(3.7) 高 LIMIT 默认值调低(attendance/adaptive-practice/questions data-access 1000-5000→100)。Phase 4 组件渲染优化:(4.1) AssignmentCard/EmptyState/StatusBadge React.memo;(4.2) lesson-plan-editor/text-study-block/exercise-block/paper-context-menu 拆分 Zustand 细粒度 selector(避免整体订阅);(4.3) message-detail/message-list/grade-record-list/attendance-sheet 改 useOptimistic + useTransition 乐观更新;(4.4) question-data-table/exam-data-table 接入 @tanstack/react-virtual 列表虚拟化;(4.5) sidebar-provider resize 改 debounce + useMemo;(4.6) 12 个 recharts 图表组件 margin props 提取到模块级 CHART_MARGIN 常量避免 inline 重建(parent/grades/dashboard/homework 12 文件);(4.7) scan-image-viewer aspect-ratio;(4.8) AiAssistantWidget inner 拆分 + 动态 import;(4.9) layout.tsx metadata 增强(metadataBase/openGraph/twitter/robots/authors/creator)+ Promise.all 并行获取 locale/messages/session。架构决策:(1) 动态 import 标准模式 xxx-inner.tsx + lazy wrapper;(2) 请求级去重 > 跨请求 cache(React cache() not unstable_cache);(3) 批量 SQL > 循环 SQL(INSERT batch + UPDATE CASE WHEN);(4) React 19 useOptimistic 替代手动 isPending;(5) 细粒度 Zustand selector > useShallow 多字段 selector;(6) recharts 具名导入 + optimizePackageImports 替代 barrel 导入。未完成项:React Compiler(babel-plugin-react-compiler RC 状态暂不引入)、force-dynamic 96 处评估(待后续专项)、Playwright 性能断言(待 e2e 基础设施完善)。验证:npx tsc --noEmit 零新增错误;npm run lint 零新增错误。| V5 状态管理统一专项 Phase 4 完成(2026-07-07 补漏重执行批量替换):Phase 4-2 sonner→notify 批量替换重新执行:(1) 修复 PowerShell 脚本语法错误(单引号数组字面量转义问题);(2) 执行 scripts/replace-sonner-v2.ps1 替换 131 个业务文件(含 import 语句 + toast.X(...) → notify.X(...) 方法调用),覆盖 exams/grades/lesson-preparation/settings/classes/scheduling/textbooks/ai/announcements/attendance/audit/auth/classes/course-plans/diagnostic/elective/error-book/files/homework/invitation-codes/leave-requests/messaging/notifications/onboarding/parent/questions/school/student/users 共 28 个模块 + shared/hooks + app/(dashboard) 路由;(3) paper-context-menu.tsx 手动重构:移除 `const { toast } = await import('sonner')` 和 `void import('sonner').then(...)` 动态 import 模式,改为静态 `import { notify } from '@/shared/lib/notify'` + `notify.success/error/info(...)`;(4) action-utils.ts 和 resolve-action-error.ts 注释示例同步更新为 notify。验证:仅 notify.ts(实现层)+ sonner.tsx(Toaster 组件)保留 `from 'sonner'` import;tsc --noEmit 0 errors;npm run lint 0 errors 0 warnings。| 数据库访问层审计专项 v1 完成(2026-07-07):纯深读审计 86 个 data-access*.ts + 15 个 actions.ts,输出 230 条问题(17 P0/48 P1/105 P2/60 P3)覆盖 4 维度 38 条规则(P/F/A/S 系列)。报告:docs/architecture/audit/data-access-audit-v1.md + data-access-audit-v1-data.json + 5 个分组 JSON(g1-g5)。5 阶段治理路线图:Phase 0 紧急安全(6 项 P0)→ 1 P0 治理 → 2 P1 性能+结构 → 3 P2 模式标准化 → 4 P3 风格优化。本次同步 005 文档:(1) permissions 补记 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE 两个审计建议新增权限点;(2) parent 模块添加 auditNotes 标注缺失 actions.ts 异常;(3) onboarding dependencyMatrix 添加 auditNote 标注 actions.ts 直查 DB 违规(A-09);(4) messaging 模块添加 auditNotes 标注 data-access.ts 976 行接近 1000 硬限,待治理阶段拆分;(5) shared 模块清单添加 auditNote 标注 date-utils.ts 待治理阶段新增。所有标注均为审计发现的事实记录,治理阶段实施后需更新对应 exports/permissions/dependencyMatrix。| 审计 v1 Phase 0 P0 修复完成(2026-07-07):6 项 P0 全部落地,tsc + lint 0 errors。本次同步 005 文档:(1) rolePermissions.admin 追加 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE;(2) _permissionsAuditNote 改为「已实施」状态;(3) modules.audit.exports.actions 中 purgeAuditLogsAction 权限改为 AUDIT_LOG_PURGE、saveAuditRetentionConfigAction 改为 AUDIT_RETENTION_MANAGE;(4) modules.onboarding.exports.dataAccess 新增 getUserOnboardedAtRaw / getUserOnboardedAt(cacheFn 包装) / markUserOnboarded 三个函数;onboarding dependencyMatrix.auditNote 改为「已实施」状态;(5) modules.parent.exports 新增 actions 数组(6 个 Server Action:getChildrenAction / getChildBasicInfoAction / getChildDashboardDataAction / getParentDashboardDataAction / getChildNameListAction / verifyParentChildRelationAction);parent.auditNotes 改为「已实施」状态;(6) modules.messaging.auditNotes 改为「已实施」状态,记录 5 个子文件拆分详情;(7) modules.shared.exports.functions 新增 serializeDate / serializeDateRequired(lib/date-utils.ts,S-04 DRY 修复,10+ data-access 文件迁移至此共享 util)。", + "lastUpdate": "性能预算重构专项 Phase 1-4 完成(2026-07-05 审计 + 2026-07-07 补漏):Phase 1 配置基线:(1.1) next.config.ts 新增 experimental.optimizePackageImports 覆盖 lucide-react/recharts/@xyflow/react/@tiptap/* /@radix-ui/*/date-fns;(1.2) serverExternalPackages 追加 tencentcloud-sdk-nodejs + exceljs;(1.3) providers.tsx 注入 WebVitalsReporter 闭环 RUM 上报链路;(1.4) lighthouse.yml + lighthouserc.json CI 性能门槛(LCP<=3000ms/CLS<=0.1 error 级)。Phase 2 Bundle 预算优化:(2.1) TipTap 三实例(shared/ui/rich-text-editor + exams/editor/exam-rich-editor + lesson-preparation/blocks/rich-text-block)全部改 next/dynamic ssr:false + xxx-inner.tsx 拆分模式;(2.2) KnowledgeGraph 改 ReactFlowProvider 同步壳 + next/dynamic lazy inner;(2.3) exams/editor/types.ts barrel 减少服务端消费方拉入 @tiptap/* 运行时;(2.4) chart.tsx 改 recharts 具名导入(ResponsiveContainer/Tooltip/Legend)替代 import * as RechartsPrimitive barrel;(2.5) status-badge 移除多余 use client;(2.6) teacher/student/parent 角色路由补齐 loading.tsx。Phase 3 数据层优化:(3.1) getSession/getAuthContext/resolveDataScope/getSessionTeacherId 全部 React cache() 包裹实现请求级去重;(3.2) copyCoursePlanToClasses 改单事务 + 2 次 batch INSERT;(3.3) gradeHomeworkAnswers 改 UPDATE CASE WHEN 单次批量更新;(3.4) searchQuestions 改 FULLTEXT INDEX + MATCH AGAINST BOOLEAN MODE + toBooleanModeQuery 工具;(3.5) getAllUserIds 加默认 LIMIT 1000 + 分页参数;(3.6) announcements/notifications fan-out 改 createNotifications 批量 INSERT + sendBatchNotifications 并行收集;(3.7) 高 LIMIT 默认值调低(attendance/adaptive-practice/questions data-access 1000-5000→100)。Phase 4 组件渲染优化:(4.1) AssignmentCard/EmptyState/StatusBadge React.memo;(4.2) lesson-plan-editor/text-study-block/exercise-block/paper-context-menu 拆分 Zustand 细粒度 selector(避免整体订阅);(4.3) message-detail/message-list/grade-record-list/attendance-sheet 改 useOptimistic + useTransition 乐观更新;(4.4) question-data-table/exam-data-table 接入 @tanstack/react-virtual 列表虚拟化;(4.5) sidebar-provider resize 改 debounce + useMemo;(4.6) 12 个 recharts 图表组件 margin props 提取到模块级 CHART_MARGIN 常量避免 inline 重建(parent/grades/dashboard/homework 12 文件);(4.7) scan-image-viewer aspect-ratio;(4.8) AiAssistantWidget inner 拆分 + 动态 import;(4.9) layout.tsx metadata 增强(metadataBase/openGraph/twitter/robots/authors/creator)+ Promise.all 并行获取 locale/messages/session。架构决策:(1) 动态 import 标准模式 xxx-inner.tsx + lazy wrapper;(2) 请求级去重 > 跨请求 cache(React cache() not unstable_cache);(3) 批量 SQL > 循环 SQL(INSERT batch + UPDATE CASE WHEN);(4) React 19 useOptimistic 替代手动 isPending;(5) 细粒度 Zustand selector > useShallow 多字段 selector;(6) recharts 具名导入 + optimizePackageImports 替代 barrel 导入。未完成项:React Compiler(babel-plugin-react-compiler RC 状态暂不引入)、force-dynamic 96 处评估(待后续专项)、Playwright 性能断言(待 e2e 基础设施完善)。验证:npx tsc --noEmit 零新增错误;npm run lint 零新增错误。| V5 状态管理统一专项 Phase 4 完成(2026-07-07 补漏重执行批量替换):Phase 4-2 sonner→notify 批量替换重新执行:(1) 修复 PowerShell 脚本语法错误(单引号数组字面量转义问题);(2) 执行 scripts/replace-sonner-v2.ps1 替换 131 个业务文件(含 import 语句 + toast.X(...) → notify.X(...) 方法调用),覆盖 exams/grades/lesson-preparation/settings/classes/scheduling/textbooks/ai/announcements/attendance/audit/auth/classes/course-plans/diagnostic/elective/error-book/files/homework/invitation-codes/leave-requests/messaging/notifications/onboarding/parent/questions/school/student/users 共 28 个模块 + shared/hooks + app/(dashboard) 路由;(3) paper-context-menu.tsx 手动重构:移除 `const { toast } = await import('sonner')` 和 `void import('sonner').then(...)` 动态 import 模式,改为静态 `import { notify } from '@/shared/lib/notify'` + `notify.success/error/info(...)`;(4) action-utils.ts 和 resolve-action-error.ts 注释示例同步更新为 notify。验证:仅 notify.ts(实现层)+ sonner.tsx(Toaster 组件)保留 `from 'sonner'` import;tsc --noEmit 0 errors;npm run lint 0 errors 0 warnings。| 数据库访问层审计专项 v1 完成(2026-07-07):纯深读审计 86 个 data-access*.ts + 15 个 actions.ts,输出 230 条问题(17 P0/48 P1/105 P2/60 P3)覆盖 4 维度 38 条规则(P/F/A/S 系列)。报告:docs/architecture/audit/data-access-audit-v1.md + data-access-audit-v1-data.json + 5 个分组 JSON(g1-g5)。5 阶段治理路线图:Phase 0 紧急安全(6 项 P0)→ 1 P0 治理 → 2 P1 性能+结构 → 3 P2 模式标准化 → 4 P3 风格优化。本次同步 005 文档:(1) permissions 补记 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE 两个审计建议新增权限点;(2) parent 模块添加 auditNotes 标注缺失 actions.ts 异常;(3) onboarding dependencyMatrix 添加 auditNote 标注 actions.ts 直查 DB 违规(A-09);(4) messaging 模块添加 auditNotes 标注 data-access.ts 976 行接近 1000 硬限,待治理阶段拆分;(5) shared 模块清单添加 auditNote 标注 date-utils.ts 待治理阶段新增。所有标注均为审计发现的事实记录,治理阶段实施后需更新对应 exports/permissions/dependencyMatrix。| 审计 v1 Phase 0 P0 修复完成(2026-07-07):6 项 P0 全部落地,tsc + lint 0 errors。本次同步 005 文档:(1) rolePermissions.admin 追加 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE;(2) _permissionsAuditNote 改为「已实施」状态;(3) modules.audit.exports.actions 中 purgeAuditLogsAction 权限改为 AUDIT_LOG_PURGE、saveAuditRetentionConfigAction 改为 AUDIT_RETENTION_MANAGE;(4) modules.onboarding.exports.dataAccess 新增 getUserOnboardedAtRaw / getUserOnboardedAt(cacheFn 包装) / markUserOnboarded 三个函数;onboarding dependencyMatrix.auditNote 改为「已实施」状态;(5) modules.parent.exports 新增 actions 数组(6 个 Server Action:getChildrenAction / getChildBasicInfoAction / getChildDashboardDataAction / getParentDashboardDataAction / getChildNameListAction / verifyParentChildRelationAction);parent.auditNotes 改为「已实施」状态;(6) modules.messaging.auditNotes 改为「已实施」状态,记录 5 个子文件拆分详情;(7) modules.shared.exports.functions 新增 serializeDate / serializeDateRequired(lib/date-utils.ts,S-04 DRY 修复,10+ data-access 文件迁移至此共享 util)。| 审计 v1 G2-005 / S-01 治理(2026-07-07):grades/data-access-analytics.ts 831 行超 800 行建议上限,按分析维度拆分为 4 个子文件 + barrel 重新导出:(1) data-access-analytics-trend.ts(135 行,getGradeTrend/getGradeTrendRaw + getExamOptionsForGrades/getExamOptionsForGradesRaw + GradeTrendParams 接口);(2) data-access-analytics-class.ts(340 行,getClassComparison/getClassComparisonWithSignificance + getSubjectComparison + getGradeDistribution + getGradeDistributionByGradeId + 4 个 Params 接口);(3) data-access-analytics-student.ts(225 行,getStudentPositionInClassDistribution + getStudentGrowthArchive);(4) data-access-analytics-overview.ts(135 行,getSchoolWideGradeSummary);(5) data-access-analytics.ts 改为 23 行 barrel 重新导出,保持向后兼容,5 个消费者(grades/actions-analytics.ts + lesson-preparation/actions-analytics.ts + 4 个 page.tsx)无需修改 import 路径。本次同步 005 文档:(a) modules.grades.exports.dataAccess 中 7 个函数 file 字段更新为新子文件路径(getGradeTrend→trend、getClassComparison→class、getSubjectComparison→class、getGradeDistribution→class、getGradeDistributionByGradeId→class、getExamOptionsForGrades→trend、getSchoolWideGradeSummary→overview);(b) 补图 3 个原缺失函数:getClassComparisonWithSignificance(class 子文件)、getStudentPositionInClassDistribution(student 子文件)、getStudentGrowthArchive(student 子文件),含完整 signature/purpose/deps/usedBy。验证:npx tsc --noEmit 0 errors;npm run lint 0 errors(1 pre-existing warning in classes/data-access.ts 无关)。| 审计 v1 G5-005/G5-006/G5-007/P-05/A-10/F-03 治理(2026-07-07):files/data-access.ts 错误处理与查询规范化:(1) 移除全部 12 处 `log.error`(createModuleLogger)调用与 `createModuleLogger` import;(2) 移除全部 try-catch 块(11 个函数均改为错误自然抛出,由 actions 层 try-catch 捕获返回 ActionState);(3) `deleteFileAttachments` 移除批量失败回退逐条删除逻辑,改为单次 batch delete 失败即抛出;(4) 7 处 `db.select()`(SELECT *)改为显式列枚举 `db.select(fileAttachmentColumns)`(新增模块级 fileAttachmentColumns 常量复用);(5) 函数签名与返回类型不变(仍 Promise / Promise / Promise / Promise),仅错误路径从返回 null/[]/false 改为 throw。004 文档同步:files 模块已知问题新增审计治理条目 + data-access.ts 行数 305→280。验证:npx tsc --noEmit 0 errors;npm run lint 0 errors。| 数据库访问层审计 v1 Phase 1-4 治理全部完成(2026-07-07):tsc + lint 0 errors。Phase 1(P0 架构+性能):(1) A-06 跨模块 schema 直查修复 5 文件——textbooks/data-access-graph、scheduling/data-access、lesson-preparation/data-access、lesson-preparation/data-access-schedules、questions/data-access 改调对方 data-access 批量接口;新增 10 个跨模块接口(textbooks: getTextbookTitlesByIds/getChapterTitlesByIds/getKnowledgePointNamesByIds;questions: getQuestionCountByKpIds;diagnostic: getStudentMasteryByKpIds/getClassMasteryByKpIds;classes: getClassesForScheduling/getClassSubjectAssignmentsByClassId/getDistinctTeacherIdsFromAssignments;school: getClassroomsForScheduling);新增 classes: getActiveStudentIdsByClassIdsBatch;新增 school: getGradesByIds 供 school-permissions 使用。(2) F-01 N+1 热点修复 8 处——questions deleteQuestionRecursive/deleteQuestionsBatch BFS+inArray 批量删除;lesson-preparation deleteComment BFS 批量删除;textbooks reorderChapters 事务+Promise.all 并行 UPDATE;course-plans reorderCoursePlanItems 事务+Promise.all;classes getTeacherClassesRaw 批量查询+Map 组装、enrollTeacherByInvitationCode 事务包裹;adaptive-practice getTeacherClassPracticeOverviewsRaw 批量学生 ID+GROUP BY。(3) A-02 业务逻辑下沉——scheduling/lib/schedule-validation.ts(6 纯函数:isTimeHHMM/isValidWeekday/isTimeRangeValid/normalizeLocation/normalizeAndValidateCreateInput/buildValidatedScheduleUpdate);elective/lib/lottery.ts(3 函数:buildLotteryRankCase/fisherYatesShuffle/partitionLottery)+ lib/schedule-conflict.ts(4 函数:normalizeDay/parseSchedule/isScheduleConflict/hasAnyScheduleConflict)。Phase 2(P1 性能+结构):(1) F-02 LIKE 全表扫描 6 处改前缀匹配+escapeLikePattern 转义;questions 改 FULLTEXT MATCH AGAINST;(2) school 模块重构(S-01/A-10/A-02/F-09)938 行拆 6 子文件(data-access-{departments,grades,subjects,classrooms,semesters,schools})+ barrel + lib/school-permissions.ts;移除 12 console.error;promoteGrades 事务包裹;fetchGradesWithHeads helper 去重;(3) files 模块(P-05/A-10/F-03)移除 12 console.error+try-catch 吞错;SELECT * 改显式列;(4) F-09 事务修复:auth createUser + school promoteGrades;(5) F-05 LIMIT 保护:lesson-preparation(5 文件)+ textbooks + classes + proctoring 无分页查询加 DEFAULT_LIMIT=1000;(6) grades-analytics 拆分(S-01)831 行 → 4 子文件(trend/class/student/overview)+ barrel。Phase 3(P2 模式标准化):(1) P-03 cacheFn 覆盖 34 读函数包装(classes 21+questions 5+textbooks 3+lesson-preparation 5)均 Raw+Wrapper 配对;(2) F-03 SELECT * 改显式列:diagnostic 3 处+textbooks pre-existing 语法错误修复;(3) S-03 重复 helper 去重:lesson-preparation/lib/status-mappers.ts(toLessonPlanStatus/toReviewDecision)、classes/lib/admin-class-mappers.ts(buildSubjectsByClassIdMap/composeAdminClassList/isClassSubject/toClassSubject)、lesson-preparation isStringArray 提取到 lib/type-guards.ts;(4) S-06 JSDoc 补全:lesson-preparation 版本/模板(8 函数)+ questions(7 函数)+ textbooks(22 函数)。Phase 4(P3 风格优化):(1) P-09 as 断言:classes/lib/admin-class-mappers.ts+onboarding/actions.ts 改类型标注常量;(2) P-09 非空断言:lesson-preparation data-access 6 处改 null 检查;(3) S-05 export * → 显式 re-export:14 个 barrel 子文件改具名导出;5 个保留 export *(>15 导出);(4) 死代码:未发现确认死代码。004 文档同步:§3.8.2 高风险模块表全部标记已修复+§3.8.4 治理路线图 Phase 0-4 标记完成+§3.8.5 架构图待补记项新增 Phase 1-4 闭环条目。005 文档同步:模块 exports.dataAccess 函数 file 字段更新为新子文件路径+lib 节点新增 schedule-validation/lottery/schedule-conflict/school-permissions/admin-class-mappers/status-mappers/type-guards 函数条目。", "lastUpdated": "2026-07-07" }, "_loggingRefactorSync": { @@ -6537,6 +6537,14 @@ "usedBy": [ "importQuestionsAction" ] + }, + { + "name": "getQuestionCountByKpIds", + "signature": "(knowledgePointIds: string[]) => Promise>", + "purpose": "A-06 新增:跨模块批量查询知识点关联题目数,供 textbooks/graph 避免直接查询 questionsToKnowledgePoints 表", + "usedBy": [ + "textbooks/data-access-graph.getKnowledgePointsWithRelationsRaw" + ] } ], "components": [ @@ -6903,6 +6911,30 @@ "textbooks/components/textbook-filters.tsx", "textbooks/components/textbook-card.tsx" ] + }, + { + "name": "getTextbookTitlesByIds", + "signature": "(textbookIds: string[]) => Promise>", + "purpose": "A-06 新增:跨模块批量标题解析,供 lesson-preparation 等模块避免直接查询 textbooks 表", + "usedBy": [ + "lesson-preparation/data-access.getLessonPlansRaw" + ] + }, + { + "name": "getChapterTitlesByIds", + "signature": "(chapterIds: string[]) => Promise>", + "purpose": "A-06 新增:跨模块批量章节标题解析,供 lesson-preparation 等模块避免直接查询 chapters 表", + "usedBy": [ + "lesson-preparation/data-access.getLessonPlansRaw" + ] + }, + { + "name": "getKnowledgePointNamesByIds", + "signature": "(knowledgePointIds: string[]) => Promise>", + "purpose": "A-06 新增:跨模块批量知识点名称解析,供 questions 模块避免直接查询 knowledgePoints 表", + "usedBy": [ + "questions/data-access.getKnowledgePointsForQuestionsRaw" + ] } ], "hooks": [ @@ -7605,6 +7637,14 @@ "dashboard" ] }, + { + "name": "getActiveStudentIdsByClassIdsBatch", + "signature": "(classIds: string[]) => Promise>", + "purpose": "批量获取多个班级的活跃学生 ID(Map),消除 N+1 查询", + "usedBy": [ + "adaptive-practice/data-access-analytics" + ] + }, { "name": "getAdminClasses", "signature": "() => Promise", @@ -7966,6 +8006,30 @@ "classes/actions-schedule.updateClassScheduleItemAction", "classes/actions-schedule.deleteClassScheduleItemAction" ] + }, + { + "name": "getClassesForScheduling", + "signature": "() => Promise", + "purpose": "A-06 新增:跨模块接口,供 scheduling 模块获取班级选择器,避免直接查询 classes 表", + "usedBy": [ + "scheduling/data-access.getAdminClassesForSchedulingRaw" + ] + }, + { + "name": "getClassSubjectAssignmentsByClassId", + "signature": "(classId: string) => Promise", + "purpose": "A-06 新增:跨模块接口,供 scheduling 模块获取班级科目分配,避免直接查询 classSubjectTeachers 表", + "usedBy": [ + "scheduling/data-access.getClassSubjectsForSchedulingRaw" + ] + }, + { + "name": "getDistinctTeacherIdsFromAssignments", + "signature": "() => Promise", + "purpose": "A-06 新增:跨模块接口,供 scheduling 模块获取有任课的教师 ID,避免直接查询 classSubjectTeachers 表", + "usedBy": [ + "scheduling/data-access.getTeachersForSchedulingRaw" + ] } ], "schema": [ @@ -8525,8 +8589,8 @@ }, { "path": "data-access-admin.ts", - "lines": 406, - "description": "管理员班级管理(管理员班级 CRUD、年级管理班级查询)", + "lines": 353, + "description": "管理员班级管理(管理员班级 CRUD、年级管理班级查询;2026-07-07 S-03:移除 145+124 行重复的 subjectsByClassId 构建与 list 排序,改调 lib/admin-class-mappers)", "exports": [ "getAdminClasses", "getGradeManagedClasses", @@ -8536,6 +8600,19 @@ "deleteAdminClass" ] }, + { + "path": "lib/admin-class-mappers.ts", + "lines": 115, + "description": "admin 班级列表共享辅助(2026-07-07 S-03 新增):isClassSubject/toClassSubject/buildSubjectsByClassIdMap/composeAdminClassList 4 个函数 + SubjectTeacherRow/AdminClassRow 2 个接口;消除 data-access-admin/data-access-teacher/actions-shared 三处重复定义", + "exports": [ + "isClassSubject", + "toClassSubject", + "buildSubjectsByClassIdMap", + "composeAdminClassList", + "SubjectTeacherRow", + "AdminClassRow" + ] + }, { "path": "actions.ts", "lines": 50, @@ -8809,9 +8886,18 @@ "grade_head视图" ] }, + { + "name": "getGradesByIds", + "signature": "(gradeIds: string[]) => Promise", + "purpose": "G3-007 / S-03 新增:按 ID 列表批量获取年级(含学校+主任信息),供 lib/school-permissions teacher 分支使用,复用 fetchGradesWithHeads helper 消除重复", + "usedBy": [ + "lib/school-permissions.getGradesForUserRaw" + ] + }, { "name": "getSchoolsForUser", "signature": "(userId) => Promise", + "purpose": "G3-011 / A-02 修复:实现已迁至 lib/school-permissions.ts,通过 barrel 重新导出保持向后兼容。根据用户角色返回可见学校列表", "usedBy": [ "非admin角色学校视图" ] @@ -8819,6 +8905,7 @@ { "name": "getGradesForUser", "signature": "(userId) => Promise", + "purpose": "G3-011 / A-02 修复:实现已迁至 lib/school-permissions.ts,通过 barrel 重新导出保持向后兼容。根据用户角色返回可见年级列表", "usedBy": [ "非admin角色年级视图" ] @@ -8962,7 +9049,8 @@ "purpose": "按 ID 批量获取科目名称映射(跨模块接口,P1-1 新增供 homework/data-access-classes 调用,替代直查 subjects 表)", "usedBy": [ "homework/data-access-classes.getHomeworkAssignmentsWithSubject", - "homework/data-access-classes.getPublishedHomeworkAssignmentsWithSubject" + "homework/data-access-classes.getPublishedHomeworkAssignmentsWithSubject", + "scheduling/data-access.getClassSubjectsForSchedulingRaw" ] }, { @@ -8973,6 +9061,14 @@ "admin/school/grades/page.tsx", "school/components/grades-view.tsx" ] + }, + { + "name": "getClassroomsForScheduling", + "signature": "() => Promise", + "purpose": "A-06 新增:跨模块接口,供 scheduling 模块获取教室选择器,避免直接查询 classrooms 表", + "usedBy": [ + "scheduling/data-access.getClassroomsForSchedulingRaw" + ] } ], "schema": [ @@ -9136,11 +9232,11 @@ { "name": "GradeOverviewStats", "type": "interface", - "file": "data-access.ts", + "file": "data-access-grades.ts", "definition": "{ gradeId: string; classCount: number; studentCount: number; teacherCount: number }", - "purpose": "v4-P2-6 新增:年级概览统计接口,每个年级的班级数/学生数/教师数", + "purpose": "v4-P2-6 新增:年级概览统计接口,每个年级的班级数/学生数/教师数。G3-007 / S-01 修复:从 data-access.ts 拆分至 data-access-grades.ts", "usedBy": [ - "data-access.getGradeOverviewStats", + "data-access-grades.getGradeOverviewStats", "school/components/grades-view.tsx", "school/components/grade-overview-cards.tsx" ] @@ -9257,6 +9353,22 @@ "school/components/grades-view.tsx" ] } + ], + "lib": [ + { + "name": "getSchoolsForUser", + "file": "lib/school-permissions.ts", + "signature": "(userId: string) => Promise", + "purpose": "G3-011 / A-02 修复:从 data-access.ts 迁出的角色感知查询。根据用户角色返回可见学校列表(admin=全量,grade_head/teaching_head=负责年级所在学校,teacher=任课班级所在学校,其他=空)", + "usedBy": [] + }, + { + "name": "getGradesForUser", + "file": "lib/school-permissions.ts", + "signature": "(userId: string) => Promise", + "purpose": "G3-011 / A-02 修复:从 data-access.ts 迁出的角色感知查询。根据用户角色返回可见年级列表(admin=全量,grade_head/teaching_head=负责年级,teacher=任课班级所在年级,其他=空)。teacher 分支复用 data-access-grades.getGradesByIds(S-03 修复)", + "usedBy": [] + } ] } }, @@ -13554,7 +13666,8 @@ { "name": "getGradeTrend", "signature": "(params: { studentId; subjectId?; semester?; examId?; scope: DataScope }) => Promise", - "file": "data-access-analytics.ts", + "file": "data-access-analytics-trend.ts", + "purpose": "G2-005 / S-01 治理(2026-07-07):从 data-access-analytics.ts 拆分至 trend 子文件", "deps": [ "shared.db", "shared.db.schema.gradeRecords", @@ -13568,7 +13681,8 @@ { "name": "getClassComparison", "signature": "(params: { gradeId; subjectId; examId?; semester?; scope: DataScope }) => Promise", - "file": "data-access-analytics.ts", + "file": "data-access-analytics-class.ts", + "purpose": "G2-005 / S-01 治理(2026-07-07):从 data-access-analytics.ts 拆分至 class 子文件", "deps": [ "shared.db", "shared.db.schema.gradeRecords", @@ -13579,10 +13693,30 @@ "teacher/grades/analytics" ] }, + { + "name": "getClassComparisonWithSignificance", + "signature": "(params: ClassComparisonParams) => Promise", + "file": "data-access-analytics-class.ts", + "purpose": "P3-5 新增 + G2-005 / S-01 治理(2026-07-07 补图):班级对比 + Cohen's d 显著性检验。复用 getClassComparison cache,重新查询 top/bottom 班级原始分数调用 computeSignificance", + "deps": [ + "shared.db", + "shared.db.schema.gradeRecords", + "grades/lib/grade-utils.normalize", + "grades/lib/grade-utils.toNumber", + "grades/lib/scope-filter.buildScopeClassFilter", + "grades/stats-service.computeSignificance", + "grades/data-access-analytics-class.getClassComparison" + ], + "usedBy": [ + "grades/actions-analytics.getClassComparisonWithSignificanceAction", + "teacher/grades/analytics" + ] + }, { "name": "getSubjectComparison", "signature": "(params: { classId; examId?; semester?; scope: DataScope }) => Promise", - "file": "data-access-analytics.ts", + "file": "data-access-analytics-class.ts", + "purpose": "G2-005 / S-01 治理(2026-07-07):从 data-access-analytics.ts 拆分至 class 子文件", "deps": [ "shared.db", "shared.db.schema.gradeRecords", @@ -13596,7 +13730,8 @@ { "name": "getGradeDistribution", "signature": "(params: { classId; subjectId?; examId?; semester?; scope: DataScope }) => Promise", - "file": "data-access-analytics.ts", + "file": "data-access-analytics-class.ts", + "purpose": "G2-005 / S-01 治理(2026-07-07):从 data-access-analytics.ts 拆分至 class 子文件", "deps": [ "shared.db", "shared.db.schema.gradeRecords" @@ -13609,8 +13744,8 @@ { "name": "getGradeDistributionByGradeId", "signature": "(params: { gradeId; subjectId?; examId?; semester?; scope: DataScope }) => Promise", - "file": "data-access-analytics.ts", - "purpose": "v4-P2-7 新增:年级仪表盘维度1,通过 getClassesByGradeId 获取年级下所有班级,inArray 查询成绩记录,复用 computeGradeDistribution/computeGradeStats,返回整体分布 + 按班级拆分", + "file": "data-access-analytics-class.ts", + "purpose": "v4-P2-7 新增 + G2-005 / S-01 治理(2026-07-07 迁移至 class 子文件):年级仪表盘维度1,通过 getClassesByGradeId 获取年级下所有班级,inArray 查询成绩记录,复用 computeGradeDistribution/computeGradeStats,返回整体分布 + 按班级拆分", "deps": [ "shared.db", "shared.db.schema.gradeRecords", @@ -13621,6 +13756,47 @@ "management/grade/dashboard" ] }, + { + "name": "getStudentPositionInClassDistribution", + "signature": "(studentId: string, scope: DataScope) => Promise", + "file": "data-access-analytics-student.ts", + "purpose": "P3-9 新增 + G2-005 / S-01 治理(2026-07-07 补图 + 迁移至 student 子文件):学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图)。仅 class_members scope 可调用,通过 getStudentActiveClassId 查询班级,findBucketIndex 计算学生所在桶索引", + "deps": [ + "shared.db", + "shared.db.schema.gradeRecords", + "classes/data-access.getStudentActiveClassId", + "classes/data-access.getClassNameById", + "grades/lib/scope-filter.buildScopeClassFilter", + "grades/lib/grade-utils.normalize", + "grades/lib/grade-utils.toNumber", + "grades/stats-service.computeGradeDistribution", + "grades/stats-service.findBucketIndex" + ], + "usedBy": [ + "grades/actions-analytics.getStudentPositionInClassDistributionAction" + ] + }, + { + "name": "getStudentGrowthArchive", + "signature": "(studentId: string, scope: DataScope) => Promise", + "file": "data-access-analytics-student.ts", + "purpose": "P3-4 新增 + G2-005 / S-01 治理(2026-07-07 补图 + 迁移至 student 子文件):学生纵向成长档案(跨学年/学期聚合)。class_taught scope 在 data-access 层校验学生归属,构建成长点序列 + 总览统计(overallAverage/totalRecords/totalSubjects/totalAcademicYears/growthDelta)", + "deps": [ + "shared.db", + "shared.db.schema.gradeRecords", + "classes/data-access.getStudentActiveClassId", + "school/data-access.getAcademicYears", + "users/data-access.getUserNamesByIds", + "grades/lib/scope-filter.buildScopeClassFilter", + "grades/lib/grade-utils.normalize", + "grades/lib/grade-utils.toNumber", + "grades/stats-service.buildGrowthArchivePoints", + "grades/stats-service.computeAverageScore" + ], + "usedBy": [ + "grades/actions-analytics.getStudentGrowthArchiveAction" + ] + }, { "name": "getRankingTrend", "signature": "(studentId: string, subjectId?, semester?, scope?: DataScope) => Promise", @@ -13771,8 +13947,8 @@ { "name": "getExamOptionsForGrades", "signature": "(params: { classId?: string; subjectId?: string }) => Promise<{ id: string; title: string }[]>", - "file": "data-access-analytics.ts", - "purpose": "v3-P2 新增:获取指定班级/科目下有成绩记录的考试列表(供分析页考试筛选)", + "file": "data-access-analytics-trend.ts", + "purpose": "v3-P2 新增 + G2-005 / S-01 治理(2026-07-07 迁移至 trend 子文件):获取指定班级/科目下有成绩记录的考试列表(供分析页考试筛选)", "deps": [ "shared.db", "shared.db.schema.gradeRecords" @@ -13784,8 +13960,8 @@ { "name": "getSchoolWideGradeSummary", "signature": "() => Promise", - "file": "data-access-analytics.ts", - "purpose": "v3-P2 新增:获取全校各年级成绩汇总(管理员视图,按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)", + "file": "data-access-analytics-overview.ts", + "purpose": "v3-P2 新增 + G2-005 / S-01 治理(2026-07-07 迁移至 overview 子文件):获取全校各年级成绩汇总(管理员视图,按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)", "deps": [ "shared.db", "shared.db.schema.gradeRecords", @@ -19307,8 +19483,7 @@ "signature": "() => Promise>", "file": "data-access.ts", "deps": [ - "shared.db", - "shared.db.schema.classes" + "classes/data-access.getClassesForScheduling" ], "usedBy": [ "admin/scheduling/rules/page.tsx", @@ -19322,9 +19497,8 @@ "signature": "() => Promise>", "file": "data-access.ts", "deps": [ - "shared.db", - "shared.db.schema.users", - "shared.db.schema.classSubjectTeachers" + "classes/data-access.getDistinctTeacherIdsFromAssignments", + "users/data-access.getTeachersByIds" ], "usedBy": [ "teacher/schedule-changes/page.tsx" @@ -19335,8 +19509,7 @@ "signature": "() => Promise>", "file": "data-access.ts", "deps": [ - "shared.db", - "shared.db.schema.classrooms" + "school/data-access.getClassroomsForScheduling" ], "usedBy": [ "autoScheduleAction" @@ -19347,9 +19520,8 @@ "signature": "(classId: string) => Promise>", "file": "data-access.ts", "deps": [ - "shared.db", - "shared.db.schema.classSubjectTeachers", - "shared.db.schema.subjects" + "classes/data-access.getClassSubjectAssignmentsByClassId", + "school/data-access.getSubjectNameMapByIds" ], "usedBy": [ "autoScheduleAction" @@ -19359,10 +19531,11 @@ "name": "createClassScheduleItem", "signature": "(data: CreateClassScheduleItemInput) => Promise", "file": "data-access-class-schedule.ts", - "purpose": "创建课表项(P0-5 从 classes 模块迁移,含教师班级归属校验)", + "purpose": "创建课表项(P0-5 从 classes 模块迁移,含教师班级归属校验;A-02 修复:字段校验已抽离至 lib/schedule-validation.ts)", "deps": [ "classes/data-access.getTeacherIdForMutations", "classes/data-access.verifyTeacherOwnsClass", + "lib/schedule-validation.normalizeAndValidateCreateInput", "data-access.insertClassScheduleItem" ], "usedBy": [ @@ -19373,10 +19546,11 @@ "name": "updateClassScheduleItem", "signature": "(scheduleId: string, data: UpdateClassScheduleItemInput) => Promise", "file": "data-access-class-schedule.ts", - "purpose": "更新课表项(P0-5 从 classes 模块迁移,含教师班级归属校验)", + "purpose": "更新课表项(P0-5 从 classes 模块迁移,含教师班级归属校验;A-02 修复:字段校验与 update 对象构建已抽离至 lib/schedule-validation.ts)", "deps": [ "classes/data-access.getTeacherIdForMutations", "classes/data-access.verifyTeacherOwnsClass", + "lib/schedule-validation.buildValidatedScheduleUpdate", "data-access.updateClassScheduleItemById" ], "usedBy": [ @@ -19440,6 +19614,78 @@ ] } ], + "lib": [ + { + "name": "isTimeHHMM", + "signature": "(v: string) => boolean", + "file": "lib/schedule-validation.ts", + "purpose": "纯函数:HH:MM 时间格式校验(A-02 修复:从 data-access-class-schedule.ts 抽离)", + "usedBy": [ + "lib/schedule-validation.normalizeAndValidateCreateInput", + "lib/schedule-validation.buildValidatedScheduleUpdate" + ] + }, + { + "name": "isValidWeekday", + "signature": "(weekday: number) => boolean", + "file": "lib/schedule-validation.ts", + "purpose": "纯函数:星期 1-7 范围校验(A-02 修复)", + "usedBy": [ + "lib/schedule-validation.normalizeAndValidateCreateInput", + "lib/schedule-validation.buildValidatedScheduleUpdate" + ] + }, + { + "name": "isTimeRangeValid", + "signature": "(startTime: string, endTime: string) => boolean", + "file": "lib/schedule-validation.ts", + "purpose": "纯函数:时间区间校验,start 必须早于 end(A-02 修复)", + "usedBy": [ + "lib/schedule-validation.normalizeAndValidateCreateInput", + "lib/schedule-validation.buildValidatedScheduleUpdate" + ] + }, + { + "name": "normalizeLocation", + "signature": "(value: string | null | undefined) => string | null", + "file": "lib/schedule-validation.ts", + "purpose": "纯函数:location 归一化,trim 后空字符串转为 null(A-02 修复)", + "usedBy": [ + "lib/schedule-validation.normalizeAndValidateCreateInput", + "lib/schedule-validation.buildValidatedScheduleUpdate" + ] + }, + { + "name": "normalizeAndValidateCreateInput", + "signature": "(data: CreateClassScheduleItemInput) => NormalizedScheduleCreateInput", + "file": "lib/schedule-validation.ts", + "purpose": "纯函数:归一化并校验创建课表项输入(trim + 时间格式/星期范围/时间区间校验),返回可直接写入 DB 的字段(A-02 修复)", + "deps": [ + "lib/schedule-validation.isTimeHHMM", + "lib/schedule-validation.isValidWeekday", + "lib/schedule-validation.isTimeRangeValid", + "lib/schedule-validation.normalizeLocation" + ], + "usedBy": [ + "data-access-class-schedule.createClassScheduleItem" + ] + }, + { + "name": "buildValidatedScheduleUpdate", + "signature": "(existing: ScheduleItemExisting, data: UpdateClassScheduleItemInput) => { update: Partial<...>, nextClassId?: string }", + "file": "lib/schedule-validation.ts", + "purpose": "纯函数:根据 update 输入与已存在记录构建校验通过的 update 字段对象(含字段级 trim/格式校验 + 合并后时间区间校验);classId 归属校验为 DB 相关,通过 nextClassId 传出由 data-access 完成(A-02 修复)", + "deps": [ + "lib/schedule-validation.isTimeHHMM", + "lib/schedule-validation.isValidWeekday", + "lib/schedule-validation.isTimeRangeValid", + "lib/schedule-validation.normalizeLocation" + ], + "usedBy": [ + "data-access-class-schedule.updateClassScheduleItem" + ] + } + ], "schemas": [ { "name": "SchedulingRuleSchema", @@ -20053,6 +20299,32 @@ "usedBy": [ "actions.deleteReportAction" ] + }, + { + "name": "getStudentMasteryByKpIds", + "signature": "(studentId: string, knowledgePointIds: string[]) => Promise>", + "file": "data-access.ts", + "purpose": "A-06 新增:跨模块批量查询学生知识点掌握度,供 textbooks/graph 避免直接查询 knowledgePointMastery 表", + "deps": [ + "shared.db", + "shared.db.schema.knowledgePointMastery" + ], + "usedBy": [ + "textbooks/data-access-graph.getStudentKpMasteryRaw" + ] + }, + { + "name": "getClassMasteryByKpIds", + "signature": "(studentIds: string[], knowledgePointIds: string[]) => Promise>", + "file": "data-access.ts", + "purpose": "A-06 新增:跨模块批量聚合班级知识点掌握度(AVG/SUM/MAX),供 textbooks/graph 避免直接查询 knowledgePointMastery 表", + "deps": [ + "shared.db", + "shared.db.schema.knowledgePointMastery" + ], + "usedBy": [ + "textbooks/data-access-graph.getClassKpMasteryRaw" + ] } ], "actions": [ @@ -20792,7 +21064,11 @@ "name": "runLottery", "file": "data-access-operations.ts", "signature": "(courseId: string) => Promise<{enrolled: number, waitlist: number}>", - "purpose": "抽签录取(Fisher-Yates 无偏洗牌 selected 记录,前 capacity 名 enrolled,其余 waitlist;P1-12 重写:不再自动将课程置为 closed,保留 status=open 支持管理员重抽;v3 修复:替换 sort(Math.random) 有偏洗牌)", + "purpose": "抽签录取(Fisher-Yates 无偏洗牌 selected 记录,前 capacity 名 enrolled,其余 waitlist;P1-12 重写:不再自动将课程置为 closed,保留 status=open 支持管理员重抽;v3 修复:替换 sort(Math.random) 有偏洗牌。A-02 修复:洗牌+分桶算法已抽离至 lib/lottery.ts partitionLottery,本函数仅保留 DB 读取(course/selections)+ DB 事务写入)", + "deps": [ + "lib/lottery.partitionLottery", + "lib/lottery.buildLotteryRankCase" + ], "usedBy": [ "actions.runLotteryAction" ] @@ -20824,12 +21100,13 @@ "name": "checkScheduleConflict", "file": "data-access-operations.ts", "signature": "(tx: Tx, studentId: string, courseId: string) => Promise", - "purpose": "查询学生已选课程时间段,与新课程时间段逐一调用 isScheduleConflict 判断冲突(P2-9 新增:选课时间冲突检测,selectCourse 调用)", + "purpose": "查询学生已选课程时间段,与新课程时间段逐一调用 isScheduleConflict 判断冲突(P2-9 新增:选课时间冲突检测,selectCourse 调用。A-02 修复:时间段解析与冲突判定纯函数已抽离至 lib/schedule-conflict.ts,本函数仅保留 DB 查询)", "deps": [ "shared.db", "shared.db.schema.courseSelections", "shared.db.schema.electiveCourses", - "lib.isScheduleConflict" + "lib/schedule-conflict.parseSchedule", + "lib/schedule-conflict.hasAnyScheduleConflict" ], "usedBy": [ "data-access-operations.selectCourse" @@ -21192,24 +21469,74 @@ } ], "lib": [ + { + "name": "normalizeDay", + "signature": "(day: string) => string", + "file": "lib/schedule-conflict.ts", + "purpose": "纯函数:星期字符串归一化(中英文全称/缩写统一映射为 1-7 数字字符串)。A-02 修复:从 data-access-operations.ts 抽离。", + "usedBy": [ + "lib/schedule-conflict.isScheduleConflict" + ] + }, { "name": "parseSchedule", "signature": "(schedule: string | null) => Array<{ day: string; start: string; end: string }>", - "file": "data-access-operations.ts", - "purpose": "纯函数:解析时间段字符串(P1-11 重写:支持中文 周一/星期一 + 英文 Mon/Monday 全称/缩写;支持多时段,按逗号/分号/中文分号拆分;返回数组,空数组表示无法解析)。配合 normalizeDay 将星期统一为数字字符串。", + "file": "lib/schedule-conflict.ts", + "purpose": "纯函数:解析时间段字符串(P1-11 重写:支持中文 周一/星期一 + 英文 Mon/Monday 全称/缩写;支持多时段,按逗号/分号/中文分号拆分;返回数组,空数组表示无法解析)。A-02 修复:从 data-access-operations.ts 抽离至 lib/schedule-conflict.ts。", "usedBy": [ - "data-access-operations.isScheduleConflict", "data-access-operations.checkScheduleConflict" ] }, { "name": "isScheduleConflict", - "signature": "(a: string, b: string) => boolean", - "file": "data-access-operations.ts", - "purpose": "纯函数:判断两个时间段是否冲突(同一天且时间区间重叠);任一无法解析返回 false(P2-9 新增,可独立单测)", + "signature": "(a: { day, start, end }, b: { day, start, end }) => boolean", + "file": "lib/schedule-conflict.ts", + "purpose": "纯函数:判断两个时间段是否冲突(同一天且时间区间重叠)。A-02 修复:从 data-access-operations.ts 抽离至 lib/schedule-conflict.ts。", + "usedBy": [ + "lib/schedule-conflict.hasAnyScheduleConflict" + ] + }, + { + "name": "hasAnyScheduleConflict", + "signature": "(newSlots: Array<{day,start,end}>, existingSlots: Array<{day,start,end}>) => boolean", + "file": "lib/schedule-conflict.ts", + "purpose": "纯函数:检测新课程时间段与任意已有时间段是否存在冲突,任一配对冲突即返回 true(A-02 修复新增,替代 data-access-operations.checkScheduleConflict 中的双层循环)", + "deps": [ + "lib/schedule-conflict.isScheduleConflict" + ], "usedBy": [ "data-access-operations.checkScheduleConflict" ] + }, + { + "name": "buildLotteryRankCase", + "signature": "(ids: string[], startRank: number) => SQL", + "file": "lib/lottery.ts", + "purpose": "纯函数:构建 lotteryRank 的 CASE SQL 表达式(仅构建 SQL 片段,不执行查询)。A-02 修复:从 data-access-operations.ts 抽离至 lib/lottery.ts。", + "usedBy": [ + "data-access-operations.runLottery" + ] + }, + { + "name": "fisherYatesShuffle", + "signature": "(items: readonly T[]) => T[]", + "file": "lib/lottery.ts", + "purpose": "纯函数:Fisher-Yates 无偏洗牌,返回新数组不修改原数组(A-02 修复新增,替代 runLottery 内联洗牌)", + "usedBy": [ + "lib/lottery.partitionLottery" + ] + }, + { + "name": "partitionLottery", + "signature": "(selections: readonly T[], capacity: number) => { enrolledIds: string[], waitlistIds: string[] }", + "file": "lib/lottery.ts", + "purpose": "纯函数:抽签分桶,洗牌后将前 capacity 名分为 enrolled、其余分为 waitlist,返回两组 id 列表(A-02 修复新增,替代 runLottery 内联洗牌+分桶逻辑)", + "deps": [ + "lib/lottery.fisherYatesShuffle" + ], + "usedBy": [ + "data-access-operations.runLottery" + ] } ], "importExport": [ @@ -21821,6 +22148,7 @@ "constants.ts", "schema.ts", "lib/type-guards.ts", + "lib/status-mappers.ts", "lib/i18n-errors.ts", "lib/document-migration.ts", "lib/node-summary.ts", diff --git a/docs/architecture/audit/data-access-audit-framework-v1.md b/docs/architecture/audit/data-access-audit-framework-v1.md new file mode 100644 index 0000000..e258671 --- /dev/null +++ b/docs/architecture/audit/data-access-audit-framework-v1.md @@ -0,0 +1,206 @@ +# 数据库访问层重构专项 - 审计框架 v1 + +> 创建日期:2026-07-07 +> 目标:对全项目 86 个 `data-access*.ts` + ~30 个 `actions.ts` 进行深度审计,输出可执行的分级治理路线图 +> 推进路径:先审计后治理(用户已确认) +> 执行方案:纯深读(用户已确认方案 B) + +--- + +## 一、审计范围 + +### 1.1 文件范围 + +| 类型 | 路径模式 | 文件数(约) | 备注 | +|---|---|---|---| +| 数据访问层 | `src/modules/**/data-access*.ts` | 86 | 主审计对象 | +| Server Actions | `src/modules/**/actions.ts` | ~30 | 辅查(权限校验、业务逻辑归属) | +| 辅助文件 | `src/modules/**/schema.ts`、`types.ts` | 按需 | 仅当 data-access 引用时查看 | + +### 1.2 排除范围 + +- `src/app/**`:仅在 A-05 规则(app 直访 DB)触发时反向查看 +- `src/shared/**`:仅在 A-07 规则(shared 反向依赖)触发时查看 +- 已有的模块级 audit 报告(`docs/architecture/audit/*-audit-report.md`):作为参考但不直接复用,因本次为横切关注点 + +### 1.3 模块分组(并行执行单元) + +| 组 | 模块 | data-access 文件数 | sub-agent | +|---|---|---|---| +| **G1 核心教学 A** | lesson-preparation(12)+ questions + textbooks | ~16 | agent-1 | +| **G2 核心教学 B** | exams + homework(7)+ grades(6)+ diagnostic + adaptive-practice(3) | ~21 | agent-2 | +| **G3 教学管理** | classes(6)+ school + scheduling + attendance(3)+ course-plans + proctoring | ~16 | agent-3 | +| **G4 用户与沟通** | users + messaging + notifications + parent + audit + auth + rbac(3) | ~12 | agent-4 | +| **G5 扩展与设置** | elective(5)+ settings(5)+ dashboard + files + search + onboarding + ai + announcements + error-book(3) | ~21 | agent-5 | + +--- + +## 二、审计维度与检查规则 + +### 2.1 维度 1:模式标准化(Pattern Standardization) + +| 规则 ID | 检查项 | 期望状态 | 检测方式 | +|---|---|---|---| +| P-01 | `import "server-only"` 文件头 | 每个文件首行 | 静态 | +| P-02 | 类型导入使用 `import type` | 类型导入与值导入分离 | 静态 | +| P-03 | 读函数是否走 `cacheFn` 包装(Raw + Wrapper 配对) | 全部覆盖 | 深读 | +| P-04 | 函数返回类型显式标注 `Promise` | 无隐式推断 | 深读 | +| P-05 | 错误处理一致 | data-access 层用 throw,actions 层用 ActionState | 深读 | +| P-06 | 分页参数命名统一 | `page`/`pageSize` 或 `limit`/`offset` 全局统一 | 深读 | +| P-07 | 日期序列化走 helper | `serializeDate`/`toISODateString` | 深读 | +| P-08 | 列表项映射走 `mapListItem` 模式 | 避免 inline mapping 重复 | 深读 | +| P-09 | `as` 断言出现次数 | 0(除 unknown 收窄) | 静态 | +| P-10 | `any` 出现次数 | 0 | 静态 | + +### 2.2 维度 2:性能与查询优化(Performance) + +| 规则 ID | 检查项 | 期望状态 | 检测方式 | +|---|---|---|---| +| F-01 | 循环内 SQL 调用(N+1) | 改批量查询 + Map 解析 | 深读 | +| F-02 | `LIKE '%xxx%'` 全表扫描 | 改 FULLTEXT 或前缀匹配 | 深读 | +| F-03 | SELECT * 未指定列 | 显式列枚举 | 深读 | +| F-04 | JOIN 表数量 > 3 | 评估拆分或冗余字段 | 深读 | +| F-05 | 大表查询无 LIMIT | 添加默认 LIMIT | 深读 | +| F-06 | 重复查询同表/同条件 | 走 cacheFn 或合并查询 | 深读 | +| F-07 | 缺失索引(高频 WHERE 字段) | 提示加索引 | 深读 | +| F-08 | 跨模块多次调用 `getXxxNamesByIds` | 批量化 | 深读 | +| F-09 | 事务范围过大(含网络调用) | 收紧事务 | 深读 | +| F-10 | `count()` 全表统计无过滤 | 添加过滤条件 | 深读 | + +### 2.3 维度 3:架构违规治理(Architecture) + +| 规则 ID | 检查项 | 期望状态 | 检测方式 | +|---|---|---|---| +| A-01 | data-access 含 `requirePermission` 调用 | 移至 actions | 静态 | +| A-02 | data-access 含业务逻辑(条件分支、状态机) | 移至 actions 或 lib | 深读 | +| A-03 | data-access 含 `"use server"` 标记 | 移至 actions | 静态 | +| A-04 | data-access 含 `revalidatePath` 调用 | 移至 actions | 静态 | +| A-05 | app/ 直接 import `@/shared/db` | 违规,改走 data-access | 静态 | +| A-06 | modules 间直接 import 对方 `@/shared/db/schema` 表 | 改走对方 data-access | 静态 | +| A-07 | shared/ 反向 import `@/auth`/`@/proxy`/`modules/*` | 违规 | 静态 | +| A-08 | actions.ts 漏调 `requirePermission` | 补齐 | 深读 | +| A-09 | actions.ts 含直接 DB 查询 | 移至 data-access | 深读 | +| A-10 | data-access 含 `console.log` 调试代码 | 删除 | 静态 | + +### 2.4 维度 4:结构与可维护性(Structure) + +| 规则 ID | 检查项 | 期望状态 | 检测方式 | +|---|---|---|---| +| S-01 | 文件行数 > 800 行警告,> 1000 行必须拆分 | 拆分 | 静态 | +| S-02 | 单文件导出函数数 > 20 | 警告,考虑拆分 | 静态 | +| S-03 | 重复 helper(多模块各自实现 serializeDate/buildScopeFilter 等) | 提取到 shared/lib | 深读 | +| S-04 | 过细拆分(同模块 ≥ 5 个子文件且单文件 < 100 行) | 评估合并 | 静态 | +| S-05 | 未使用导出(dead code) | 删除 | 深读 | +| S-06 | 公共导出函数缺 JSDoc | 补齐 | 深读 | +| S-07 | 跨模块重复查询逻辑 | 提取共享 data-access | 深读 | +| S-08 | 模块内 data-access 与 actions 职责混淆 | 重新分层 | 深读 | + +--- + +## 三、严重性分级 + +| 级别 | 含义 | 示例 | 治理窗口 | +|---|---|---|---| +| **P0 Critical** | 架构硬违规、安全漏洞、必定性能问题 | app 直访 DB、跨模块 schema 直查、actions 漏权限、N+1 循环 SQL | 立即 | +| **P1 High** | 显著性能/可维护性问题 | 超长文件(>1000 行)、缺 cacheFn 的热路径读函数、LIKE 全表扫描 | Phase 1 | +| **P2 Medium** | 模式偏差、可优化 | 错误处理不一致、缺 JSDoc、重复 helper、分页命名不统一 | Phase 2 | +| **P3 Low** | 风格问题、可选优化 | 单行格式、import 顺序、注释措辞 | Phase 3 | + +--- + +## 四、执行流程 + +### 4.1 阶段 A:sub-agent 分组深读(并行) + +每个 sub-agent 接收: +- 该组所有 `data-access*.ts` + 同模块 `actions.ts` 文件清单 +- 完整规则表(4 维度 × 38 条规则) +- 统一输出格式(见 4.3) + +每个 sub-agent 执行: +1. 完整读取每个文件(不使用 limit/offset) +2. 按规则表逐条检测 +3. 命中即记录到问题清单 +4. 对每个问题给出修复建议与预估工作量 + +### 4.2 阶段 B:主 agent 汇总 + +- 收集 5 个 sub-agent 的结构化输出 +- 去重(同一问题被多 agent 命中时合并) +- 跨模块统计(如重复 helper 在多少模块出现) +- 生成优先级矩阵 +- 编写治理路线图 + +### 4.3 sub-agent 输出格式 + +每个 sub-agent 产出 JSON 数组,每条问题: + +```json +{ + "id": "G1-001", + "file": "src/modules/lesson-preparation/data-access.ts", + "lines": "L123-L145", + "ruleId": "F-01", + "severity": "P0", + "dimension": "performance", + "title": "循环内调用 getClassNamesByIds", + "description": "在 for 循环内对每个 classId 单独查询 className,应改为批量查询后用 Map 解析", + "recommendation": "提取 classIds 数组,一次调用 getClassNamesByIds(classIds),循环内改为 map.get(classId)", + "effort": "S (≤30 分钟)" +} +``` + +工作量分级: +- **XS**:≤ 15 分钟(如删除 console.log、补 import type) +- **S**:≤ 30 分钟(如替换 as 断言为类型守卫) +- **M**:≤ 2 小时(如 N+1 改批量、提取 helper) +- **L**:≤ 1 天(如拆分超长文件、跨模块重构) +- **XL**:> 1 天(如架构层重构) + +--- + +## 五、报告输出 + +### 5.1 主报告 + +文件:`docs/architecture/audit/data-access-audit-v1.md` + +结构: +1. **执行摘要**:总文件数、问题总数、P0/P1/P2/P3 分布、模块热度图 +2. **量化指标仪表盘**:cacheFn 覆盖率、平均行数、`as` 断言数、违规 import 数等 +3. **按维度分组的问题清单**:每条含 文件:行号、规则 ID、严重性、现状描述、修复建议、预估工作量 +4. **按模块分组的问题清单**:每个模块的累计问题数与 Top 问题 +5. **P0-P3 优先级矩阵**:四象限图(影响 × 紧迫度) +6. **分阶段治理路线图**:Phase 1 (P0) → Phase 2 (P1) → Phase 3 (P2) → Phase 4 (P3) +7. **附录**:完整规则表、sub-agent 原始输出索引 + +### 5.2 结构化数据 + +文件:`docs/architecture/audit/data-access-audit-v1-data.json` + +字段:`issues[]`、`metrics{}`、`moduleSummary{}`、`roadmap{}` + +### 5.3 速查手册同步 + +发现的新模式问题需追加到 `docs/troubleshooting/known-issues.md`(速查手册格式)。 + +--- + +## 六、质量约束 + +- **零误报**:每条问题必须给出文件:行号 + 代码证据,避免臆测 +- **零遗漏**:86 个 data-access 文件必须全部深读,不得抽样 +- **可执行**:每条修复建议必须具体到代码示例或操作步骤 +- **不修改代码**:审计阶段只产出报告,不做任何源码修改 +- **架构同步**:审计过程中发现的架构图遗漏(004/005 文档)记录到报告附录,治理阶段统一补图 + +--- + +## 七、后续衔接 + +审计报告 v1 完成后: + +1. **用户审查报告**:确认问题清单与优先级 +2. **制定治理路线图**:基于 P0-P3 分级,输出 `data-access-refactor-roadmap-v1.md` +3. **分阶段执行治理**:每阶段完成后运行 `npm run lint` + `npx tsc --noEmit` 验证 +4. **同步架构文档**:每阶段完成后同步 004/005 文档与 known-issues.md diff --git a/docs/architecture/audit/data-access-audit-v1-data.json b/docs/architecture/audit/data-access-audit-v1-data.json new file mode 100644 index 0000000..27155ef --- /dev/null +++ b/docs/architecture/audit/data-access-audit-v1-data.json @@ -0,0 +1,171 @@ +{ + "version": "v1", + "createdAt": "2026-07-07", + "scope": { + "dataAccessFiles": 86, + "actionsFilesAudited": 15, + "totalFilesAudited": 101 + }, + "summary": { + "totalIssues": 230, + "bySeverity": { + "P0": 17, + "P1": 48, + "P2": 105, + "P3": 60 + }, + "byDimension": { + "pattern": 54, + "performance": 71, + "architecture": 58, + "structure": 47 + } + }, + "metrics": { + "serverOnlyMissing": 2, + "cacheFnMissingEstimated": 60, + "filesOver800Lines": 3, + "filesOver1000Lines": 1, + "filesOver20Exports": 5, + "asAssertionsNonExempt": 2, + "anyUsage": 0, + "consoleErrorCount": 25, + "nPlusOnePatterns": 11, + "likeFullScanPatterns": 7, + "selectStarCount": 35, + "noLimitQueries": 18, + "crossModuleSchemaAccess": 7, + "businessLogicInDataAccess": 18, + "unprotectedTransactions": 4, + "actionsPermissionIssues": 4, + "actionsDirectDB": 1 + }, + "moduleHeatmap": [ + { "module": "messaging", "p0": 1, "p1": 4, "total": 5, "risk": "critical" }, + { "module": "classes", "p0": 1, "p1": 4, "total": 14, "risk": "critical" }, + { "module": "school", "p0": 0, "p1": 5, "total": 8, "risk": "critical" }, + { "module": "lesson-preparation", "p0": 1, "p1": 3, "total": 28, "risk": "high" }, + { "module": "scheduling", "p0": 1, "p1": 2, "total": 6, "risk": "high" }, + { "module": "adaptive-practice", "p0": 1, "p1": 2, "total": 4, "risk": "high" }, + { "module": "elective", "p0": 0, "p1": 3, "total": 7, "risk": "high" }, + { "module": "textbooks", "p0": 2, "p1": 1, "total": 11, "risk": "high" }, + { "module": "onboarding", "p0": 2, "p1": 0, "total": 2, "risk": "medium" }, + { "module": "grades", "p0": 0, "p1": 2, "total": 4, "risk": "medium" }, + { "module": "questions", "p0": 1, "p1": 1, "total": 11, "risk": "medium" }, + { "module": "audit", "p0": 1, "p1": 1, "total": 3, "risk": "medium" }, + { "module": "parent", "p0": 1, "p1": 0, "total": 1, "risk": "medium" }, + { "module": "attendance", "p0": 0, "p1": 1, "total": 9, "risk": "medium" }, + { "module": "files", "p0": 0, "p1": 4, "total": 13, "risk": "medium" }, + { "module": "exams", "p0": 1, "p1": 0, "total": 1, "risk": "low" }, + { "module": "course-plans", "p0": 1, "p1": 0, "total": 5, "risk": "low" }, + { "module": "homework", "p0": 0, "p1": 1, "total": 2, "risk": "low" }, + { "module": "diagnostic", "p0": 0, "p1": 1, "total": 2, "risk": "low" } + ], + "p0Issues": [ + { "id": "G4-003", "file": "src/modules/audit/actions.ts", "lines": "L192-225", "ruleId": "A-08", "title": "purgeAuditLogsAction 用读权限执行物理删除", "category": "security" }, + { "id": "G4-002", "file": "src/modules/parent/", "lines": "—", "ruleId": "A-08", "title": "parent 模块缺失 actions.ts,3 页面直访 data-access", "category": "security" }, + { "id": "G2-001", "file": "src/modules/exams/data-access.ts", "lines": "L1", "ruleId": "P-01", "title": "缺 import server-only", "category": "security" }, + { "id": "G5-001", "file": "src/modules/onboarding/data-access.ts", "lines": "L1", "ruleId": "P-01", "title": "缺 import server-only", "category": "security" }, + { "id": "G1-001", "file": "src/modules/textbooks/data-access-graph.ts", "lines": "L7-121", "ruleId": "A-06", "title": "直查 questions + diagnostic 模块表", "category": "architecture" }, + { "id": "G3-002", "file": "src/modules/scheduling/data-access.ts", "lines": "L8-17", "ruleId": "A-06", "title": "直查 classes/users/subjects 三模块表", "category": "architecture" }, + { "id": "G3-003", "file": "src/modules/scheduling/data-access-class-schedule.ts", "lines": "L28-158", "ruleId": "A-02", "title": "data-access 含校验+状态机业务逻辑", "category": "architecture" }, + { "id": "G5-002", "file": "src/modules/onboarding/actions.ts", "lines": "L15-76", "ruleId": "A-09", "title": "actions 直查 DB", "category": "architecture" }, + { "id": "G4-001", "file": "src/modules/messaging/data-access.ts", "lines": "L1-1089", "ruleId": "S-01", "title": "1089 行超 1000 硬限", "category": "structure" }, + { "id": "G1-002", "file": "src/modules/questions/data-access.ts", "lines": "L294-315", "ruleId": "F-01", "title": "deleteQuestionRecursive 递归 N+1", "category": "performance" }, + { "id": "G1-003", "file": "src/modules/questions/data-access.ts", "lines": "L350-378", "ruleId": "F-01", "title": "deleteQuestionsBatch 循环 N+1", "category": "performance" }, + { "id": "G1-004", "file": "src/modules/lesson-preparation/data-access-comments.ts", "lines": "L128-140", "ruleId": "F-01", "title": "deleteComment 递归 N+1", "category": "performance" }, + { "id": "G1-005", "file": "src/modules/textbooks/data-access.ts", "lines": "L426-458", "ruleId": "F-01", "title": "reorderChapters 循环 UPDATE", "category": "performance" }, + { "id": "G3-001", "file": "src/modules/classes/data-access.ts", "lines": "L17-313", "ruleId": "P-03", "title": "24+ 读函数未走 cacheFn", "category": "performance" }, + { "id": "G3-004", "file": "src/modules/classes/data-access-teacher.ts", "lines": "L92-116", "ruleId": "F-01", "title": "getTeacherClassesRaw 2N+1", "category": "performance" }, + { "id": "G3-005", "file": "src/modules/course-plans/data-access.ts", "lines": "L324-331", "ruleId": "F-01", "title": "reorderCoursePlanItems N+1 + 未包裹事务", "category": "performance" }, + { "id": "G2-003", "file": "src/modules/adaptive-practice/data-access-analytics.ts", "lines": "L311-384", "ruleId": "F-01", "title": "getTeacherClassPracticeOverviewsRaw 2N+1", "category": "performance" } + ], + "roadmap": { + "phase0": { + "name": "紧急安全修复", + "priority": "immediate", + "tasks": [ + { "id": "G2-001", "effort": "XS", "action": "添加 import server-only 到 exams/data-access.ts" }, + { "id": "G5-001", "effort": "XS", "action": "添加 import server-only 到 onboarding/data-access.ts" }, + { "id": "G4-002", "effort": "M", "action": "新建 parent/actions.ts,3 页面改调 Action" }, + { "id": "G4-003", "effort": "S", "action": "audit purge 权限点新增 + 替换" }, + { "id": "G4-004", "effort": "S", "action": "audit retention 权限点替换" }, + { "id": "G5-002", "effort": "S", "action": "onboarding/actions.ts 移除直查 DB" } + ] + }, + "phase1": { + "name": "P0 架构与性能修复", + "priority": "high", + "tasks": [ + { "batch": "1.1", "ids": ["G1-001", "G3-002", "G1-031", "G1-032", "G1-033"], "effort": "L", "action": "跨模块 schema 直查治理" }, + { "batch": "1.2", "ids": ["G4-001", "G4-005", "G4-006", "G4-007", "G4-008"], "effort": "L", "action": "messaging 拆分" }, + { "batch": "1.3", "ids": ["G1-002", "G1-003", "G1-004", "G1-005", "G3-001", "G3-004", "G3-005", "G2-003"], "effort": "L", "action": "N+1 热路径修复" }, + { "batch": "1.4", "ids": ["G3-003", "G3-024", "G3-025"], "effort": "M", "action": "scheduling 业务逻辑下移" }, + { "batch": "1.5", "ids": ["G5-003", "G5-004"], "effort": "L", "action": "elective 业务逻辑拆分" } + ] + }, + "phase2": { + "name": "P1 性能与结构优化", + "priority": "medium", + "tasks": [ + { "batch": "2.1", "ids": ["G1-006", "G1-007", "G1-008", "G1-009", "G3-017", "G4-010"], "effort": "L", "action": "LIKE 全表扫描治理" }, + { "batch": "2.2", "ids": ["G3-007", "G2-005"], "effort": "M", "action": "超长文件拆分" }, + { "batch": "2.3", "ids": ["G3-007", "G3-008", "G3-009", "G3-010", "G3-011"], "effort": "L", "action": "school 模块重构" }, + { "batch": "2.4", "ids": ["G5-005", "G5-006", "G5-007"], "effort": "M", "action": "files 模块错误处理重构" }, + { "batch": "2.5", "ids": ["G3-006", "G3-012", "G3-025", "G4-009"], "effort": "S", "action": "事务包裹修复" }, + { "batch": "2.6", "ids": ["G1-011", "G1-012", "G1-013", "G1-050", "G1-051", "G1-052", "G1-053", "G3-031", "G3-032", "G3-041", "G3-044"], "effort": "M", "action": "无 LIMIT 查询保护" } + ] + }, + "phase3": { + "name": "P2 模式标准化", + "priority": "low", + "tasks": [ + { "batch": "3.1", "ids": ["G1-021", "G1-022", "G1-023", "G1-024", "G3-001"], "effort": "M", "action": "cacheFn 全量补齐" }, + { "batch": "3.2", "ids": ["G1-039-049", "G3-009", "G3-026-028", "G5-007"], "effort": "M", "action": "SELECT * 改显式列" }, + { "batch": "3.3", "ids": ["G3-021", "G3-047", "G1-025"], "effort": "S", "action": "日期 helper 提取" }, + { "batch": "3.4", "ids": ["G1-026", "G1-027", "G3-020", "G3-036"], "effort": "M", "action": "重复 helper 提取" }, + { "batch": "3.5", "ids": ["G1-034-036", "G1-066", "G1-067", "G3-046"], "effort": "M", "action": "JSDoc 补齐" } + ] + }, + "phase4": { + "name": "P3 风格优化", + "priority": "optional", + "tasks": [ + { "ids": ["G3-029", "G3-030"], "effort": "XS", "action": "as widening 断言改类型标注" }, + { "ids": ["G1-060-065"], "effort": "XS", "action": "非空断言 ! 改类型守卫" }, + { "ids": ["G3-048"], "effort": "S", "action": "export * 改显式 re-export" }, + { "ids": ["G3-018"], "effort": "XS", "action": "死代码删除" } + ] + } + }, + "crossModuleRecommendations": { + "newSharedHelpers": [ + { "name": "toISODateString", "path": "src/shared/lib/date-utils.ts", "replaces": ["attendance/serializeDate", "scheduling/serializeDate", "school/toIso", "course-plans/toIso"] }, + { "name": "buildScopeFilter", "path": "src/shared/lib/scope-filter.ts", "replaces": ["attendance/buildScopeFilter", "grades/buildScopeFilter"] } + ], + "newCrossModuleInterfaces": [ + { "name": "getActiveStudentIdsByClassIds", "module": "classes", "callers": ["adaptive-practice", "attendance"] }, + { "name": "getGradeNamesByIds", "module": "school", "callers": ["textbooks", "lesson-preparation"] }, + { "name": "getQuestionCountByKpIds", "module": "questions", "callers": ["textbooks"] }, + { "name": "getKpMasteryByTextbookId", "module": "diagnostic", "callers": ["textbooks"] } + ], + "newPermissions": [ + { "name": "AUDIT_LOG_PURGE", "description": "审计日志物理删除", "roles": ["admin"] }, + { "name": "AUDIT_RETENTION_MANAGE", "description": "审计保留策略配置", "roles": ["admin"] } + ] + }, + "architectureDocGaps": [ + "parent 模块缺失 actions.ts - 004 文档模块清单未标注", + "onboarding/actions.ts 直查 DB - 005 文档 dependencyMatrix 需修正", + "messaging/data-access.ts 拆分后 - 005 文档 modules.messaging.exports 需更新", + "新增权限点 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE - 005 文档 permissions 需补记", + "新增 shared/lib/date-utils.ts - 004/005 shared 模块清单需补记" + ], + "sourceOutputs": [ + "docs/architecture/audit/g1-audit-output.json", + "docs/architecture/audit/g2-data-access-audit.json", + "docs/architecture/audit/g3-audit-output.json", + "docs/architecture/audit/g4-audit-output.json", + "docs/architecture/audit/g5-audit-output.json" + ] +} diff --git a/docs/architecture/audit/data-access-audit-v1.md b/docs/architecture/audit/data-access-audit-v1.md new file mode 100644 index 0000000..e8a184e --- /dev/null +++ b/docs/architecture/audit/data-access-audit-v1.md @@ -0,0 +1,525 @@ +# 数据库访问层审计报告 v1 + +> 创建日期:2026-07-07 +> 审计范围:86 个 `data-access*.ts` + ~30 个 `actions.ts` +> 审计方案:纯深读(5 个并行 sub-agent 全量扫描) +> 框架依据:[data-access-audit-framework-v1.md](./data-access-audit-framework-v1.md) +> 原始输出:[g1-audit-output.json](./g1-audit-output.json) · [g2-data-access-audit.json](./g2-data-access-audit.json) · [g3-audit-output.json](./g3-audit-output.json) · [g4-audit-output.json](./g4-audit-output.json) · [g5-audit-output.json](./g5-audit-output.json) + +--- + +## 一、执行摘要 + +| 指标 | 数值 | +|---|---| +| 审计文件总数 | 101(86 data-access + 15 actions 辅查) | +| 发现问题总数 | 230 | +| P0 Critical | 17(7.4%) | +| P1 High | 48(20.9%) | +| P2 Medium | 105(45.6%) | +| P3 Low | 60(26.1%) | + +### 1.1 模块热度图(按 P0+P1 数量降序) + +| 模块 | P0 | P1 | P0+P1 | 总计 | 风险等级 | +|---|---|---|---|---|---| +| messaging | 1 | 4 | 5 | 5 | 🔴 极高 | +| classes | 1 | 4 | 5 | 14 | 🔴 极高 | +| school | 0 | 5 | 5 | 8 | 🔴 极高 | +| lesson-preparation | 1 | 3 | 4 | 28 | 🟠 高 | +| scheduling | 1 | 2 | 3 | 6 | 🟠 高 | +| adaptive-practice | 1 | 2 | 3 | 4 | 🟠 高 | +| elective | 0 | 3 | 3 | 7 | 🟠 高 | +| textbooks | 2 | 1 | 3 | 11 | 🟠 高 | +| onboarding | 2 | 0 | 2 | 2 | 🟡 中 | +| grades | 0 | 2 | 2 | 4 | 🟡 中 | +| questions | 1 | 1 | 2 | 11 | 🟡 中 | +| audit | 1 | 1 | 2 | 3 | 🟡 中 | +| parent | 1 | 0 | 1 | 1 | 🟡 中 | +| attendance | 0 | 1 | 1 | 9 | 🟡 中 | +| files | 0 | 4 | 4 | 13 | 🟡 中 | +| exams | 1 | 0 | 1 | 1 | 🟢 低 | +| course-plans | 1 | 0 | 1 | 5 | 🟢 低 | +| homework | 0 | 1 | 1 | 2 | 🟢 低 | +| diagnostic | 0 | 1 | 1 | 2 | 🟢 低 | +| 其他 (auth/rbac/notifications/dashboard/search/ai/announcements/error-book/proctoring/settings) | 0 | 0 | 0 | 0-3 | 🟢 低 | + +### 1.2 维度分布 + +| 维度 | 问题数 | 占比 | P0 | P1 | +|---|---|---|---|---| +| 架构违规(A-*) | 58 | 25.2% | 6 | 18 | +| 性能优化(F-*) | 71 | 30.9% | 7 | 14 | +| 结构可维护性(S-*) | 47 | 20.4% | 2 | 11 | +| 模式标准化(P-*) | 54 | 23.5% | 2 | 5 | + +--- + +## 二、量化指标仪表盘 + +| 指标 | 数值 | 备注 | +|---|---|---| +| `import "server-only"` 缺失文件 | 2 | exams/data-access.ts、onboarding/data-access.ts | +| cacheFn 未覆盖读函数(估算) | 60+ | 集中在 classes(24+)、questions(5)、textbooks(3)、lesson-preparation(4) | +| 超长文件(>800 行) | 3 | messaging(1089,超硬限)、school(938)、grades-analytics(831) | +| 单文件导出函数 > 20 | 4 | messaging(42+)、classes/data-access.ts(25+)、school(30+)、questions(28)、textbooks(35) | +| `as` 断言(非豁免) | 2 | classes/data-access-admin.ts、classes/data-access-teacher.ts(DEFAULT_CLASS_SUBJECTS widening) | +| `any` 使用 | 0 | 全部合规 | +| `console.error` 调试代码 | 25+ | school(12)、files(12)、classes(3)、course-plans(2)、audit(9) | +| N+1 循环 SQL(F-01) | 11 | 跨 4 组 | +| `LIKE '%xxx%'` 全表扫描 | 7 | lesson-preparation(4)、questions(1)、textbooks(1)、classes(1)、messaging(1) | +| SELECT * 未指定列 | 35+ | 跨 G1(16)、G3(11)、G5(8) | +| 无 LIMIT 大表查询 | 18 | 集中在 lesson-preparation | +| 跨模块直查 schema 表(A-06) | 7 | textbooks-graph(2)、lesson-preparation(2)、questions(1)、scheduling(1)、announcements(1) | +| data-access 含业务逻辑(A-02) | 18 | 集中在 scheduling、messaging、elective、classes | +| 未包裹事务的多步写(F-09) | 4 | course-plans、classes、auth、school | +| actions 漏/错权限校验(A-08) | 4 | parent(缺失全部)、audit(purge 用读权限)、audit(retention 用读权限) | +| actions 直查 DB(A-09) | 1 | onboarding | + +--- + +## 三、P0 Critical 问题清单(17 条,必须立即治理) + +### 3.1 安全漏洞类(4 条) + +| ID | 文件 | 问题 | 修复 | +|---|---|---|---| +| G4-003 | audit/actions.ts L192-225 | `purgeAuditLogsAction` 用 `AUDIT_LOG_READ`(读权限)执行物理删除,权限提权漏洞 | 新增 `AUDIT_LOG_PURGE` 权限点 | +| G4-002 | parent/ | 模块缺失 actions.ts,3 个 app 页面直接 import data-access,完全绕过 `requirePermission` | 新建 parent/actions.ts,3 个页面改调 Action | +| G2-001 | exams/data-access.ts L1 | 缺 `import "server-only"`,DB 逻辑可能泄露到客户端 bundle | 首行添加 `import "server-only"` | +| G5-001 | onboarding/data-access.ts L1 | 缺 `import "server-only"` | 首行添加 `import "server-only"` | + +### 3.2 架构硬违规类(5 条) + +| ID | 文件 | 问题 | 修复 | +|---|---|---|---| +| G1-001 | textbooks/data-access-graph.ts L7-121 | 直查 questions 模块 `questionsToKnowledgePoints` 表 + diagnostic 模块 `knowledgePointMastery` 表 | 改调对方 data-access 跨模块接口 | +| G3-002 | scheduling/data-access.ts L8-17 | 直查 classes/users/subjects 三模块的 schema 表 | 改调 `getClassNamesByIds`/`getUserNamesByIds` 等 | +| G3-003 | scheduling/data-access-class-schedule.ts | data-access 含时间校验、归属校验、状态机判断 | 校验逻辑移至 actions | +| G5-002 | onboarding/actions.ts L15-76 | actions.ts 直接 `import { db }` 并查 `users` 表,违反三层架构 | data-access 新增 `getUserOnboardedAt`,actions 改调 | +| G4-001 | messaging/data-access.ts L1-1089 | 单文件 1089 行超 1000 硬限,8 类职责混合 | 拆分为 7 个 data-access-*.ts | + +### 3.3 必定性能问题类(8 条) + +| ID | 文件 | 问题 | 修复 | +|---|---|---|---| +| G1-002 | questions/data-access.ts L294-315 | `deleteQuestionRecursive` 递归 N+1,每子题单独查询+删除 | 收集后代 ID + `inArray` 批量删除 | +| G1-003 | questions/data-access.ts L350-378 | `deleteQuestionsBatch` 循环调用 `deleteQuestionRecursive` 产生 N×深度 查询 | 一次性收集所有后代 + 单次 `inArray` 删除 | +| G1-004 | lesson-preparation/data-access-comments.ts L128-140 | `deleteComment` 递归 N+1 | 单次查询构建 parent→children Map + 批量删除 | +| G1-005 | textbooks/data-access.ts L426-458 | `reorderChapters` 循环内逐条 UPDATE | `CASE WHEN` 批量更新 | +| G3-001 | classes/data-access.ts L17-313 | 24+ 读函数全部未走 cacheFn,跨模块高频调用直连 DB | 补齐 Raw + Wrapper 配对 | +| G3-004 | classes/data-access-teacher.ts L92-116 | `getTeacherClassesRaw` 循环内对每班发起 2 次子查询(2N+1) | 新增批量接口 | +| G3-005 | course-plans/data-access.ts L324-331 | `reorderCoursePlanItems` 循环内 N 次 UPDATE 且未包裹事务 | 事务 + `CASE WHEN` 批量更新 | +| G2-003 | adaptive-practice/data-access-analytics.ts L311-384 | `getTeacherClassPracticeOverviewsRaw` 对每班发起 2 条 SQL(2N+1) | 批量查询 + groupBy | + +--- + +## 四、P1 High 问题清单(48 条,Phase 1 治理) + +### 4.1 性能类(14 条) + +| ID | 文件 | 规则 | 概要 | +|---|---|---|---| +| G1-006~009 | lesson-preparation/questions/textbooks | F-02 | 4 处 `LIKE '%xxx%'` 全表扫描(课案标题、JSON content、题目 content、教材 4 字段) | +| G1-010 | lesson-preparation/data-access.ts L247-277 | F-04 | `getLessonPlansRaw` 5 表 LEFT JOIN | +| G1-011~013 | lesson-preparation (3 处) | F-05 | 列表查询无 LIMIT(getLessonPlansRaw、getPendingReviewPlansRaw、getCalendarEventsRaw) | +| G1-014~015 | lesson-preparation (2 处) | F-10 | 全表拉取后内存聚合统计 | +| G1-016 | lesson-preparation/data-access-analytics.ts L168-184 | F-06 | 5 次串行 COUNT 查询同表 | +| G1-017~018 | lesson-preparation (2 处) | F-01 | 拉全表后内存 filter | +| G1-019 | textbooks/actions.ts L396-398 | F-08 | 循环调用 `getGradeNameById`(N 次 DB) | +| G2-002 | grades/data-access-appeals.ts L122-151 | F-01 | `getPendingAppealsForReviewRaw` JS 层 filter 班级范围(潜在数据泄露) | +| G2-004 | adaptive-practice/data-access-analytics.ts L320-325 | F-08 | 循环内跨模块调用 `getActiveStudentIdsByClassId` | +| G3-006 | course-plans/data-access.ts L309-332 | F-09 | `reorderCoursePlanItems` 多次 UPDATE 未包裹事务 | +| G3-012 | school/data-access.ts L803-822 | F-09 | `promoteGrades` 循环 UPDATE 未包裹事务 | +| G3-017 | classes/data-access-students.ts L281-285 | F-02 | `LIKE '%xxx%'` 全表扫描 users.name/email | +| G3-022 | attendance/data-access-correlation.ts L46-193 | F-01/A-02 | 148 行业务编排逻辑(含跨模块调用) | + +### 4.2 架构类(11 条) + +| ID | 文件 | 规则 | 概要 | +|---|---|---|---| +| G4-004 | audit/actions.ts L163-190 | A-08 | `saveAuditRetentionConfigAction` 用读权限执行写操作 | +| G4-006~008 | messaging/data-access.ts (3 处) | A-02 | 状态机/防重复业务逻辑嵌入 data-access | +| G3-007~008 | school/data-access.ts | S-01/S-02 | 938 行 + 30+ 导出函数 | +| G3-010 | school/data-access.ts | A-10 | 12 处 `console.error` 吞异常 | +| G3-011 | school/data-access.ts L246-408 | A-02 | 角色判断业务逻辑嵌入 data-access | +| G3-024~025 | classes/data-access-teacher.ts L284-439 | A-02/F-09 | `enrollTeacherByInvitationCode` 155 行状态机 + 未包裹事务 | +| G5-003 | elective/data-access-operations.ts | A-02 | 业务逻辑混淆(抽签算法、冲突检测、i18n 通知) | + +### 4.3 结构类(5 条) + +| ID | 文件 | 规则 | 概要 | +|---|---|---|---| +| G2-005 | grades/data-access-analytics.ts | S-01 | 831 行超 800 警告线 | +| G5-004 | elective/data-access-operations.ts L222-304 | S-08 | DB 写入与抽签算法混淆 | +| G5-005 | files/data-access.ts | A-10 | 12 处 `console.error` | +| G5-006 | files/data-access.ts | P-05 | try-catch 吞错误返回 null/[]/false | +| G4-009 | auth/data-access.ts | F-09 | `createUser` 两次 INSERT 无事务包裹 | + +(完整 P1 清单详见各 sub-agent JSON 输出) + +--- + +## 五、按维度分组的问题清单 + +### 5.1 模式标准化(P-*,54 条) + +#### P-01 `import "server-only"` 缺失(2 条 P0) + +| ID | 文件 | 修复 | +|---|---|---| +| G2-001 | exams/data-access.ts L1 | 首行添加 `import "server-only"` | +| G5-001 | onboarding/data-access.ts L1 | 首行添加 `import "server-only"` | + +#### P-03 cacheFn 未覆盖(30+ 条,P2) + +集中模块: +- **classes/data-access.ts**(24+ 读函数,G3-001 P0) +- **lesson-preparation**(4 个,G1-021) +- **questions**(5 个,G1-023) +- **textbooks**(3 个,G1-024) +- **lesson-preparation-substitutes**(1 个,G1-022) + +修复模式: +```ts +// Before +export const getClassNamesByIds = async (classIds: string[]) => { /* SQL */ } + +// After +export const getClassNamesByIdsRaw = async (classIds: string[]) => { /* SQL */ } +export const getClassNamesByIds = cacheFn(getClassNamesByIdsRaw, { + tags: ["classes:names"], + ttl: 300, + keyParts: ["classes", "getClassNamesByIds"], +}) +``` + +#### P-05 错误处理不一致(13 条,P1-P2) + +集中模块:files(9 处 try-catch 吞错误)、school(12 处 console.error + 吞异常) + +#### P-07 日期序列化 helper 重复(5 处,P2-P3) + +- attendance/data-access.ts `serializeDate` +- attendance/data-access-stats.ts `serializeDate` +- scheduling/data-access.ts `serializeDate` +- school/data-access.ts `toIso` +- course-plans/data-access.ts `toIso`/`toIsoRequired` + +修复:提取到 `src/shared/lib/date-utils.ts` + +#### P-09 `as` 断言(2 条 P3,非豁免) + +- classes/data-access-admin.ts L36 `DEFAULT_CLASS_SUBJECTS as readonly string[]` +- classes/data-access-teacher.ts L41 同上 + +#### P-10 `any` 使用 + +零违规,全部合规。 + +### 5.2 性能优化(F-*,71 条) + +#### F-01 N+1 循环 SQL(11 条,跨 P0/P1/P2) + +| ID | 文件 | 模式 | +|---|---|---| +| G1-002 | questions deleteQuestionRecursive | 递归内单独查询+删除 | +| G1-003 | questions deleteQuestionsBatch | 循环调用递归删除 | +| G1-004 | lesson-preparation deleteComment | 递归内单独查询+删除 | +| G1-005 | textbooks reorderChapters | 循环内逐条 UPDATE | +| G1-017 | lesson-preparation getSchedulesByDateRangeRaw | 拉全表后内存 filter | +| G1-018 | lesson-preparation getResponsesByStudentIdRaw | 拉全量后内存 filter | +| G2-002 | grades getPendingAppealsForReviewRaw | JS 层 filter 班级范围 | +| G2-003 | adaptive-practice getTeacherClassPracticeOverviewsRaw | Promise.all 内 2N+1 | +| G3-004 | classes getTeacherClassesRaw | 循环内 2 次子查询 | +| G3-005 | course-plans reorderCoursePlanItems | 循环内 N 次 UPDATE | +| G3-042 | classes generateUniqueInvitationCode | 循环内重试查询 | + +#### F-02 `LIKE '%xxx%'` 全表扫描(7 条 P1) + +| ID | 文件 | 字段 | +|---|---|---| +| G1-006 | lesson-preparation | lessonPlans.title | +| G1-007 | lesson-preparation-knowledge | lessonPlans.content (JSON) | +| G1-008 | questions | questions.content (JSON, +LOWER+CAST) | +| G1-009 | textbooks | title/subject/grade/publisher 4 字段 | +| G3-017 | classes-students | users.name/email | +| G4-010 | messaging | messages.subject/content | + +修复策略: +- 短期:前缀匹配 `LIKE 'xxx%'`(可走索引) +- 中期:FULLTEXT 索引 + `MATCH AGAINST IN BOOLEAN MODE`(questions 表已实施,参见架构图 1.1.4) +- 长期:关联表存储提取后的关系(如 lesson_plan_knowledge_point_refs) + +#### F-03 SELECT * 未指定列(35+ 条 P2-P3) + +集中模块:lesson-preparation(16 处)、school(4 处)、scheduling(3 处)、attendance(2 处)、course-plans(7 处)、files(8 处) + +#### F-05 无 LIMIT 大表查询(18 条 P1-P2) + +集中模块:lesson-preparation(7 处)、textbooks(2 处)、classes(3 处)、proctoring(1 处) + +#### F-09 事务范围问题(4 条 P1) + +| ID | 文件 | 问题 | +|---|---|---| +| G3-006 | course-plans reorderCoursePlanItems | 多次 UPDATE 未包裹事务 | +| G3-012 | school promoteGrades | 循环 UPDATE 未包裹事务 | +| G3-025 | classes enrollTeacherByInvitationCode | 多次写操作未包裹事务 | +| G4-009 | auth createUser | 两次 INSERT 无事务包裹 | + +#### F-10 全表 COUNT 无过滤(4 条 P2) + +集中模块:textbooks、questions、lesson-preparation、classes + +### 5.3 架构违规(A-*,58 条) + +#### A-02 data-access 含业务逻辑(18 条 P0-P2) + +| 模块 | 文件 | 业务逻辑类型 | +|---|---|---| +| scheduling | data-access-class-schedule.ts | 时间校验 + 归属校验 + 状态机 | +| scheduling | data-access.ts | — | +| messaging | data-access.ts | 撤回状态机 + 防重复 + 页面编排 | +| elective | data-access-operations.ts | 抽签算法 + 冲突检测 + i18n 通知 | +| classes | data-access-teacher.ts | 邀请码状态机 + 角色校验 | +| classes | data-access-invitations.ts | 懒清理状态迁移 | +| school | data-access.ts | 角色判断 + 权限感知查询 | +| attendance | data-access-correlation.ts | 跨模块编排 + 成绩归一化 | +| attendance | data-access-stats.ts | 纯计算函数导出 | +| lesson-preparation | data-access-review.ts | 状态机迁移 | +| lesson-preparation | data-access-ai-evaluation.ts | 评分算法纯函数 | +| textbooks | data-access.ts | 重排序算法 | +| diagnostic | data-access.ts | 掌握度累积计算 | +| homework | data-access.ts | computeOverdueCount 闭包 | + +#### A-06 跨模块直查 schema 表(7 条 P0-P2) + +| ID | 文件 | 被查模块 | +|---|---|---| +| G1-001 | textbooks/data-access-graph.ts | questions + diagnostic | +| G1-031 | lesson-preparation/data-access.ts | textbooks (textbooks/chapters) | +| G1-032 | lesson-preparation/data-access-schedules.ts | classes | +| G1-033 | questions/data-access.ts | textbooks (knowledgePoints) | +| G3-002 | scheduling/data-access.ts | classes + users + subjects | + +#### A-08 actions 权限校验问题(4 条 P0-P1) + +| ID | 文件 | 问题 | +|---|---|---| +| G4-002 | parent/ | 模块缺失 actions.ts,3 页面直访 data-access | +| G4-003 | audit/actions.ts | purge 用读权限 | +| G4-004 | audit/actions.ts | retention 配置用读权限 | +| G4-047 | rbac/data-access-assignments.ts | 内存 post-fetch 过滤导致 total 错误(伴随 A-02) | + +#### A-09 actions 直查 DB(1 条 P0) + +| ID | 文件 | 问题 | +|---|---|---| +| G5-002 | onboarding/actions.ts L15-76 | 直接 `import { db }` 并查 `users` 表 | + +#### A-10 `console.error` 调试代码(25+ 条 P1-P2) + +| 模块 | 文件 | 数量 | +|---|---|---| +| school | data-access.ts | 12 | +| files | data-access.ts | 12 | +| classes | data-access-teacher/students/admin | 3 | +| course-plans | data-access.ts | 2 | +| audit | data-access.ts | 9 | + +### 5.4 结构与可维护性(S-*,47 条) + +#### S-01 超长文件(3 条 P0-P1) + +| ID | 文件 | 行数 | 状态 | +|---|---|---|---| +| G4-001 | messaging/data-access.ts | 1089 | 超 1000 硬限,必须拆分 | +| G3-007 | school/data-access.ts | 938 | 超 800 警告,接近硬限 | +| G2-005 | grades/data-access-analytics.ts | 831 | 超 800 警告 | + +#### S-02 单文件导出函数过多(4 条 P2) + +| 文件 | 导出数 | +|---|---| +| messaging/data-access.ts | 42+ | +| school/data-access.ts | 30+ | +| textbooks/data-access.ts | 35 | +| questions/data-access.ts | 28 | +| classes/data-access.ts | 25+ | + +#### S-03 重复 helper(8 条 P2) + +| helper | 出现模块 | +|---|---| +| serializeDate/toIso | attendance、scheduling、school、course-plans | +| toLessonPlanStatus | lesson-preparation(2 文件) | +| isStringArray | lesson-preparation(2 文件) | +| fetchClassesWithSubjects | classes(2 函数 145+124 行重复) | +| fetchGradesWithHeads | school(3 函数重复) | + +#### S-06 缺 JSDoc(15+ 条 P2-P3) + +集中模块:lesson-preparation(versions/templates)、questions、textbooks + +--- + +## 六、P0-P3 优先级矩阵 + +``` +高影响 + │ + │ P0 立即治理 P1 Phase 1 + │ ───────────────── ───────────────── + │ • parent 权限漏洞 • N+1 循环 SQL(非热路径) + │ • audit 权限提权 • LIKE 全表扫描 + │ • server-only 缺失 • 超长文件(school/grades) + │ • 跨模块 schema 直查 • 业务逻辑嵌入 data-access + │ • N+1 循环 SQL(热路径) • 事务未包裹 + │ • messaging 超硬限 • console.error 吞异常 + │ + ├────────────────────────────────────────────── + │ + │ P2 Phase 2 P3 Phase 3 + │ ───────────────── ───────────────── + │ • cacheFn 未覆盖 • as 断言(widening) + │ • SELECT * 未指定列 • 非空断言 ! + │ • 无 LIMIT 大表查询 • JSDoc 补齐 + │ • 重复 helper • 动态 import 注释 + │ • 单文件导出过多 • export * 改显式 + │ +低影响 + 高紧迫 ─────────────────── 低紧迫 +``` + +--- + +## 七、分阶段治理路线图 + +### Phase 0:紧急安全修复(XS-S,立即执行) + +| 任务 | ID | 工作量 | 验证 | +|---|---|---|---| +| 添加 `import "server-only"` 到 exams/data-access.ts | G2-001 | XS | tsc + lint | +| 添加 `import "server-only"` 到 onboarding/data-access.ts | G5-001 | XS | tsc + lint | +| 新建 parent/actions.ts,3 页面改调 Action | G4-002 | M | 手动测试 3 页面 | +| audit purge 权限点新增 + 替换 | G4-003 | S | 权限矩阵测试 | +| audit retention 权限点替换 | G4-004 | S | 权限矩阵测试 | +| onboarding/actions.ts 移除直查 DB | G5-002 | S | tsc + lint | + +**Phase 0 完成标准**:所有 P0 安全漏洞修复,`npm run lint` + `npx tsc --noEmit` 零错误。 + +### Phase 1:P0 架构与性能修复(M-L,1-2 周) + +| 任务批次 | 涉及 ID | 工作量 | 依赖 | +|---|---|---|---| +| **1.1 跨模块 schema 直查治理** | G1-001, G3-002, G1-031~033 | L | 需在 questions/diagnostic/textbooks/classes 模块新增跨模块接口 | +| **1.2 messaging 拆分** | G4-001, G4-005~008 | L | 拆分为 7 个子文件 + 业务逻辑移至 actions | +| **1.3 N+1 热路径修复** | G1-002~005, G3-001, G3-004, G3-005, G2-003 | L | classes 补齐 cacheFn 是基础 | +| **1.4 scheduling 业务逻辑下移** | G3-003, G3-024, G3-025 | M | data-access-class-schedule.ts 重写 | +| **1.5 elective 业务逻辑拆分** | G5-003, G5-004 | L | 提取 lib/lottery.ts + lib/schedule-conflict.ts | + +**Phase 1 完成标准**:所有 P0 修复,关键路径性能提升,架构分层清晰。 + +### Phase 2:P1 性能与结构优化(M-L,2-3 周) + +| 任务批次 | 涉及 ID | 工作量 | +|---|---|---| +| **2.1 LIKE 全表扫描治理** | G1-006~009, G3-017, G4-010 | L(FULLTEXT 索引 + 查询重写) | +| **2.2 超长文件拆分** | G3-007, G2-005 | M(school 按职责拆 8 文件、grades-analytics 按维度拆) | +| **2.3 school 模块重构** | G3-007~011 | L(拆分 + 角色判断移至 actions + 删除 console.error) | +| **2.4 files 模块错误处理重构** | G5-005, G5-006, G5-007 | M(删除 try-catch + console.error) | +| **2.5 事务包裹修复** | G3-006, G3-012, G3-025, G4-009 | S | +| **2.6 无 LIMIT 查询保护** | G1-011~013, G1-050~053, G3-031~032, G3-041, G3-044 | M | + +**Phase 2 完成标准**:所有 P1 修复,无超长文件,无 LIKE 全表扫描,无未包裹事务。 + +### Phase 3:P2 模式标准化(S-M,1-2 周) + +| 任务批次 | 涉及 ID | 工作量 | +|---|---|---| +| **3.1 cacheFn 全量补齐** | G1-021~024, G3-001(剩余) | M | +| **3.2 SELECT * 改显式列** | G1-039~049, G3-009, G3-026~028, G5-007 | M(机械替换) | +| **3.3 日期 helper 提取** | G3-021, G3-047, G1-025 | S(提取 shared/lib/date-utils.ts) | +| **3.4 重复 helper 提取** | G1-026~027, G3-020, G3-036 | M | +| **3.5 JSDoc 补齐** | G1-034~036, G1-066~067, G3-046 | M | + +**Phase 3 完成标准**:所有 P2 修复,模式统一,helper 集中到 shared/lib。 + +### Phase 4:P3 风格优化(XS,按需) + +| 任务 | 涉及 ID | 工作量 | +|---|---|---| +| `as` widening 断言改类型标注 | G3-029~030 | XS | +| 非空断言 `!` 改类型守卫 | G1-060~065 | XS | +| `export *` 改显式 re-export | G3-048 | S | +| 死代码删除 | G3-018 | XS | + +**Phase 4 完成标准**:零 `as`(非豁免)、零 `!`、零死代码。 + +--- + +## 八、跨模块治理建议 + +### 8.1 新增 shared/lib 公共 helper + +| helper | 路径 | 用途 | 替代模块 | +|---|---|---|---| +| `toISODateString` | shared/lib/date-utils.ts | 日期序列化 | attendance/scheduling/school/course-plans | +| `buildScopeFilter` | shared/lib/scope-filter.ts | DataScope → SQL 过滤 | attendance/grades/homework 等重复实现 | +| `serializeDate` | (合并到 date-utils.ts) | 同 toISODateString | — | + +### 8.2 新增跨模块批量接口 + +| 接口 | 模块 | 用途 | 调用方 | +|---|---|---|---| +| `getActiveStudentIdsByClassIds(classIds)` | classes | 批量获取多班学生 ID | adaptive-practice、attendance | +| `getGradeNamesByIds(gradeIds)` | school | 批量获取年级名称 | textbooks、lesson-preparation | +| `getQuestionCountByKpIds(kpIds)` | questions | 知识点关联题目数 | textbooks | +| `getKpMasteryByTextbookId(textbookId)` | diagnostic | 教材下知识点掌握度 | textbooks | + +### 8.3 新增权限点 + +| 权限点 | 用途 | 角色映射 | +|---|---|---| +| `AUDIT_LOG_PURGE` | 审计日志物理删除 | admin 专属 | +| `AUDIT_RETENTION_MANAGE` | 审计保留策略配置 | admin 专属 | + +--- + +## 九、附录 + +### 9.1 完整规则表 + +见 [data-access-audit-framework-v1.md](./data-access-audit-framework-v1.md) 第二节。 + +### 9.2 sub-agent 原始输出索引 + +| 组 | 文件 | 问题数 | +|---|---|---| +| G1 | [g1-audit-output.json](./g1-audit-output.json) | 67 | +| G2 | [g2-data-access-audit.json](./g2-data-access-audit.json) | 11 | +| G3 | [g3-audit-output.json](./g3-audit-output.json) | 50 | +| G4 | [g4-audit-output.json](./g4-audit-output.json) | 61 | +| G5 | [g5-audit-output.json](./g5-audit-output.json) | 41 | + +### 9.3 架构图遗漏记录 + +审计过程中发现的架构图(004/005)需补记项(治理阶段统一补图): + +1. **parent 模块缺失 actions.ts** —— 004 文档模块清单未标注此异常 +2. **onboarding/actions.ts 直查 DB** —— 005 文档 dependencyMatrix 需修正 +3. **messaging/data-access.ts 拆分后** —— 005 文档 modules.messaging.exports 需更新 +4. **新增权限点 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE** —— 005 文档 permissions 节点需补记 +5. **新增 shared/lib/date-utils.ts** —— 004/005 shared 模块清单需补记 + +### 9.4 治理验证检查清单 + +每个 Phase 完成后必须通过: + +- [ ] `npm run lint` 零错误 +- [ ] `npx tsc --noEmit` 零错误 +- [ ] 架构文档 004/005 同步更新 +- [ ] `docs/troubleshooting/known-issues.md` 追加新模式 +- [ ] 受影响模块的功能测试通过 +- [ ] P0/P1 问题在 issues JSON 中标记为 resolved diff --git a/docs/architecture/audit/g1-audit-output.json b/docs/architecture/audit/g1-audit-output.json new file mode 100644 index 0000000..c1e0234 --- /dev/null +++ b/docs/architecture/audit/g1-audit-output.json @@ -0,0 +1,806 @@ +[ + { + "id": "G1-001", + "file": "src/modules/textbooks/data-access-graph.ts", + "lines": "L7-L13, L46-L53, L107-L121", + "ruleId": "A-06", + "severity": "P0", + "dimension": "architecture", + "title": "textbooks 模块直接查询 questions/diagnostic 模块的表", + "description": "data-access-graph.ts 从 @/shared/db/schema 导入 questionsToKnowledgePoints(属 questions 模块)和 knowledgePointMastery(属 diagnostic 模块),并直接执行 SELECT FROM 查询(L46-53 查 questionsToKnowledgePoints,L107-121 查 knowledgePointMastery)。这违反了三层架构'模块间通过对方 data-access 通信,不直接查询对方 DB 表'的规则。", + "recommendation": "1) questionsToKnowledgePoints 的关联题目数查询应改为调用 questions 模块 data-access 暴露的跨模块接口(如 getQuestionCountByKpIds);2) knowledgePointMastery 查询应改为调用 diagnostic 模块 data-access 暴露的接口(如 getKpMasteryByTextbookId)。", + "effort": "M (≤2h)" + }, + { + "id": "G1-002", + "file": "src/modules/questions/data-access.ts", + "lines": "L294-L315", + "ruleId": "F-01", + "severity": "P0", + "dimension": "performance", + "title": "deleteQuestionRecursive 递归 N+1:每个子题单独查询+删除", + "description": "deleteQuestionRecursive 在递归中对每个子题先 SELECT 子题列表(L305-308),再 for 循环递归调用自身(L310-312),最后 DELETE 当前题(L314)。对于有 N 层子题的复合题,会产生 2N 次数据库往返。", + "recommendation": "改为先递归收集所有后代 ID 到一个数组(单次查询children即可),然后用 inArray 批量 DELETE:`await tx.delete(questions).where(inArray(questions.id, allDescendantIds))`。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-003", + "file": "src/modules/questions/data-access.ts", + "lines": "L350-L378", + "ruleId": "F-01", + "severity": "P0", + "dimension": "performance", + "title": "deleteQuestionsBatch 循环调用 deleteQuestionRecursive 产生 N+1", + "description": "deleteQuestionsBatch 在 L372-374 对 targetIds 数组 for 循环,每个 id 单独调用 deleteQuestionRecursive,每次调用内部又递归查询子题。批量删除 M 个题目时产生 M × (递归深度) 次查询。", + "recommendation": "先将所有 targetIds 的后代 ID 一次性收集(用 inArray 批量查询 parentId in targetIds,递归用 Map 解析),再单次 inArray 批量删除所有后代+自身。", + "effort": "M (≤2h)" + }, + { + "id": "G1-004", + "file": "src/modules/lesson-preparation/data-access-comments.ts", + "lines": "L128-L140", + "ruleId": "F-01", + "severity": "P0", + "dimension": "performance", + "title": "deleteComment 递归 N+1:每个子回复单独查询+删除", + "description": "deleteComment 先 SELECT 子回复列表(L130-133),再 for 循环递归调用 deleteComment(L134-136),最后 DELETE 当前评论(L137-139)。嵌套回复深时产生大量 DB 往返。", + "recommendation": "改为先用单次查询获取该 plan 下所有评论,在内存中构建 parent→children Map,收集所有后代 ID 后用 inArray 批量删除。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-005", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L426-L458", + "ruleId": "F-01", + "severity": "P0", + "dimension": "performance", + "title": "reorderChapters 循环内逐条 UPDATE(N+1)", + "description": "reorderChapters 在事务内 for 循环遍历所有兄弟章节(L445-457),每个章节单独执行 tx.update(L448-454)。重排 N 个章节产生 N 次 UPDATE 语句。", + "recommendation": "使用 CASE WHEN 批量更新:`UPDATE chapters SET order = CASE id WHEN ... THEN ... END, parentId = CASE id WHEN ... THEN ... END WHERE id IN (...)`,或用 sql`VALUES(...)` 构造批量更新。", + "effort": "M (≤2h)" + }, + { + "id": "G1-006", + "file": "src/modules/lesson-preparation/data-access.ts", + "lines": "L237", + "ruleId": "F-02", + "severity": "P1", + "dimension": "performance", + "title": "LIKE '%query%' 全表扫描查询课案标题", + "description": "getLessonPlansRaw 在 L237 使用 `like(lessonPlans.title, \\`%${escapeLikePattern(params.query)}%\\`)`,前导通配符 % 导致无法使用索引,全表扫描。课案表数据量大时严重影响性能。", + "recommendation": "对 lessonPlans.title 建立全文索引(MySQL FULLTEXT INDEX),改用 `sql\\`MATCH(title) AGAINST(${query} IN BOOLEAN MODE)\\``;或至少对高频查询场景使用前缀匹配 `like(title, query + '%')`。", + "effort": "M (≤2h)" + }, + { + "id": "G1-007", + "file": "src/modules/lesson-preparation/data-access-knowledge.ts", + "lines": "L99, L129", + "ruleId": "F-02", + "severity": "P1", + "dimension": "performance", + "title": "LIKE '%id%' 全表扫描 JSON content 字段", + "description": "getLessonPlansByKnowledgePointRaw(L99)和 getLessonPlansByQuestionRaw(L129)对 lessonPlans.content(JSON 列)使用 `like(content, \\`%${kpId}%\\`)` 做粗筛。JSON 列上的 LIKE 全表扫描代价极高,且无法走索引。", + "recommendation": "建立关联表 lesson_plan_knowledge_point_refs(plan_id, knowledge_point_id) 和 lesson_plan_question_refs(plan_id, question_id) 存储提取后的关联关系,改用 inArray 等值查询。短期可加 LIMIT 并在 actions 层缓存结果。", + "effort": "L (≤1d)" + }, + { + "id": "G1-008", + "file": "src/modules/questions/data-access.ts", + "lines": "L60-L65", + "ruleId": "F-02", + "severity": "P1", + "dimension": "performance", + "title": "LOWER(CAST(content AS CHAR)) LIKE '%q%' 全表扫描", + "description": "getQuestionsRaw 在 L61-64 使用 `sql\\`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}\\`` 对 JSON content 列做 LIKE 模糊搜索,包含 LOWER + CAST + 前导 % 三重性能杀手,无法走索引。", + "recommendation": "对 questions 表增加 searchable_text 列(存储从 content 提取的纯文本),建立 FULLTEXT 索引;或引入 Meilisearch/TypeSense 等外部搜索引擎处理题目全文检索。", + "effort": "L (≤1d)" + }, + { + "id": "G1-009", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L48-L54, L545-L551", + "ruleId": "F-02", + "severity": "P1", + "dimension": "performance", + "title": "LIKE '%q%' 全表扫描 4 个字段", + "description": "getTextbooksRaw(L48-54)和 getTextbooksWithScopeRaw(L545-551)对 title/subject/grade/publisher 四个字段做 `like(field, \\`%${q}%\\`)` OR 查询,4 个前导通配符 LIKE 全表扫描。", + "recommendation": "对 title 建立全文索引;或将 subject/grade/publisher 改为等值过滤(下拉选择),仅 title 做前缀匹配。", + "effort": "M (≤2h)" + }, + { + "id": "G1-010", + "file": "src/modules/lesson-preparation/data-access.ts", + "lines": "L247-L277", + "ruleId": "F-04", + "severity": "P1", + "dimension": "performance", + "title": "getLessonPlansRaw 5 表 LEFT JOIN", + "description": "getLessonPlansRaw 在 L270-275 对 lessonPlans LEFT JOIN textbooks/chapters/subjects/grades/users 共 5 个表。JOIN 表数量 > 3,查询计划复杂度高,且无 LIMIT。", + "recommendation": "拆分为两步:1) 先查 lessonPlans 主表(带 scope + 过滤条件 + LIMIT + ORDER BY);2) 用 collect 的 textbookId/chapterId/subjectId/gradeId/creatorId 批量查 textbooks/chapters/subjects/grades/users 名称,在内存中 Map 关联。", + "effort": "L (≤1d)" + }, + { + "id": "G1-011", + "file": "src/modules/lesson-preparation/data-access.ts", + "lines": "L247-L277", + "ruleId": "F-05", + "severity": "P1", + "dimension": "performance", + "title": "getLessonPlansRaw 列表查询无 LIMIT", + "description": "getLessonPlansRaw 查询课案列表时无 LIMIT,当课案数量增长时会一次性拉取全表数据到内存做分组聚合(L283-316),可能导致 OOM。", + "recommendation": "添加默认分页 `.limit(pageSize).offset(offset)`,或至少 `.limit(500)` 保护;版本聚合逻辑应改为分页后处理。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-012", + "file": "src/modules/lesson-preparation/data-access-review.ts", + "lines": "L168-L218, L255-L294", + "ruleId": "F-05", + "severity": "P1", + "dimension": "performance", + "title": "getPendingReviewPlansRaw / getPlansByStatusesRaw 无 LIMIT", + "description": "getPendingReviewPlansRaw(L184-196)和 getPlansByStatusesRaw(L275-285)均无 LIMIT,且后者还在内存中做 filter(L199-208)而非 SQL 过滤。待审核/按状态查询的课案可能很多。", + "recommendation": "添加分页参数 page/pageSize,SQL 层用 inArray 过滤 gradeId/subjectId 而非内存 filter;加 `.limit(pageSize).offset(offset)`。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-013", + "file": "src/modules/lesson-preparation/data-access-calendar.ts", + "lines": "L43-L60, L102-L120, L136-L153", + "ruleId": "F-05", + "severity": "P1", + "dimension": "performance", + "title": "getCalendarEventsRaw 三段查询均无 LIMIT", + "description": "getCalendarEventsRaw 对 lessonPlans(L43)、lessonPlanVersions(L102)、lessonPlanReviewRecords(L136)三段查询均无 LIMIT。日历范围跨度大时可能拉取大量记录。", + "recommendation": "每段查询添加 `.limit(500)` 上限保护,或在 actions 层强制限制日期范围跨度(如最多 90 天)。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-014", + "file": "src/modules/lesson-preparation/data-access-formative.ts", + "lines": "L212-L239", + "ruleId": "F-10", + "severity": "P1", + "dimension": "performance", + "title": "getFormativeItemStatsRaw 全表拉取后内存聚合统计", + "description": "getFormativeItemStatsRaw 在 L215-218 SELECT 所有作答记录(无 LIMIT),然后在 L220-232 内存循环统计 total/correct/incorrect/avgDuration。一个互动组件可能有上千条作答。", + "recommendation": "改用 SQL 聚合:`SELECT COUNT(*) as total, SUM(isCorrect=1) as correct, SUM(isCorrect=0) as incorrect, AVG(durationSec) as avgDuration FROM ... WHERE itemId=?`,单次查询完成。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-015", + "file": "src/modules/lesson-preparation/data-access-comments.ts", + "lines": "L145-L157", + "ruleId": "F-10", + "severity": "P1", + "dimension": "performance", + "title": "countUnresolvedCommentsRaw SELECT 全部 ID 后取 length 计数", + "description": "countUnresolvedCommentsRaw 在 L146-155 SELECT 所有匹配的 id 字段,然后 L156 `return rows.length` 计数。应直接用 SQL COUNT 聚合,避免拉取全部行数据。", + "recommendation": "改为 `.select({ count: count() }).from(...).where(...)`,返回 `Number(rows[0]?.count ?? 0)`。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-016", + "file": "src/modules/lesson-preparation/data-access-analytics.ts", + "lines": "L168-L184", + "ruleId": "F-06", + "severity": "P1", + "dimension": "performance", + "title": "getGlobalLessonPlanStatsRaw 5 次串行查询同表", + "description": "getGlobalLessonPlanStatsRaw 对 lessonPlans/lessonPlanStandards 表执行 5 次 SELECT COUNT 查询(L168-184),且是串行 await。仪表盘每次加载产生 5 次 DB 往返。", + "recommendation": "合并为单次 GROUP BY 查询:`SELECT status, COUNT(*) FROM lessonPlans GROUP BY status`,或用 Promise.all 并行执行;lessonPlanStandards 计数可合并到同一查询。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-017", + "file": "src/modules/lesson-preparation/data-access-schedules.ts", + "lines": "L77-L123", + "ruleId": "F-01", + "severity": "P1", + "dimension": "performance", + "title": "getSchedulesByDateRangeRaw 拉全表后内存 filter", + "description": "getSchedulesByDateRangeRaw 仅按日期范围查询(L99-104),然后用 `rows.filter((r) => teacherPlanIds.includes(r.planId))`(L108-109)在内存过滤教师课案。注释 L101 自述'简化:仅按日期范围过滤'。当全校课案绑定量大时拉取大量无关数据。", + "recommendation": "将 planId 过滤下推到 SQL:`inArray(lessonPlanSchedules.planId, teacherPlanIds)`,配合日期范围条件,避免拉取无关行。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-018", + "file": "src/modules/lesson-preparation/data-access-formative.ts", + "lines": "L182-L205", + "ruleId": "F-01", + "severity": "P1", + "dimension": "performance", + "title": "getResponsesByStudentIdRaw 拉全量作答后内存 filter", + "description": "getResponsesByStudentIdRaw 当传入 planId 时(L187-198):先查该 plan 的 formative items ID(L188-191),再 SELECT 该学生的全部 responses(L194-197 无 itemId 过滤),最后内存 filter `itemIds.includes(r.itemId)`(L198)。应直接用 inArray 在 SQL 过滤。", + "recommendation": "在 L196 的 WHERE 中增加 `inArray(lessonPlanFormativeResponses.itemId, itemIds)` 条件,移除内存 filter。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-019", + "file": "src/modules/textbooks/actions.ts", + "lines": "L396-L398", + "ruleId": "F-08", + "severity": "P1", + "dimension": "performance", + "title": "getKnowledgeGraphDataAction 循环调用 getGradeNameById(N+1)", + "description": "getKnowledgeGraphDataAction 在 L396-398 用 `Promise.all(allowedGradeIds.map((gid) => getGradeNameById(gid)))` 逐个查询年级名称。虽然 Promise.all 并行了请求,但仍是 N 次 DB 查询。", + "recommendation": "school 模块应提供批量接口 `getGradeNamesByIds(gradeIds): Promise>`,单次 inArray 查询返回映射。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-020", + "file": "src/modules/questions/data-access.ts", + "lines": "L39-L49", + "ruleId": "P-04", + "severity": "P1", + "dimension": "pattern", + "title": "getQuestionsRaw 缺少显式返回类型标注", + "description": "getQuestionsRaw(L39)使用 `=> {` 箭头函数,未显式标注返回类型 `Promise`,依赖 TypeScript 推断。违反 P-04 规则'函数返回值必须显式标注,特别是 Promise'。", + "recommendation": "定义返回类型并显式标注:`export const getQuestionsRaw = async (params: GetQuestionsParams = {}): Promise => { ... }`,将返回结构提取为命名类型。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-021", + "file": "src/modules/lesson-preparation/data-access.ts", + "lines": "L574-L590, L416-L426, L429-L444, L593-L617", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "4 个读函数未走 cacheFn 包装", + "description": "getLessonPlanStats(L574)、getTextbooksForPicker(L416)、getChaptersForPicker(L429)、getTemplateById(L593)均为纯读函数但未用 cacheFn 包装。其中 getTemplateById 在 createLessonPlan 热路径中被调用(L366),缺少缓存影响创建性能。", + "recommendation": "为每个读函数添加 Raw + cacheFn 配对:`export const getTemplateById = cacheFn(getTemplateByIdRaw, { tags: [...], ttl: 300, keyParts: [...] })`。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-022", + "file": "src/modules/lesson-preparation/data-access-substitutes.ts", + "lines": "L125-L141", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "canTeacherAccessPlan 读函数未走 cacheFn", + "description": "canTeacherAccessPlan(L125)是读函数(查询 plan + 查询 substitutes),但未用 cacheFn 包装。该函数可能在权限校验热路径被频繁调用。", + "recommendation": "拆为 canTeacherAccessPlanRaw + cacheFn 包装,注意 TTL 应较短(60s)因权限相关。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-023", + "file": "src/modules/questions/data-access.ts", + "lines": "L380-L384, L391-L399, L406-L426, L433-L436, L577-L622", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "5 个读函数未走 cacheFn 包装", + "description": "getKnowledgePointOptions(L380)、getTextbookOptions(L391)、getChapterOptions(L406)、getKnowledgePointOptionsByChapter(L433)、exportQuestions(L577)均为读函数但未用 cacheFn。前四个是级联筛选下拉数据,频繁调用。", + "recommendation": "为 getKnowledgePointOptions/getTextbookOptions/getChapterOptions/getKnowledgePointOptionsByChapter 添加 cacheFn(ttl 可较长 600s)。exportQuestions 因可能导出大结果集,可不缓存或短 TTL。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-024", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L492-L504, L511-L524, L689-L703", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "3 个读函数未走 cacheFn 包装", + "description": "verifyChapterBelongsToTextbook(L492)、verifyKnowledgePointBelongsToTextbook(L511)、getPrerequisiteEdgesForTextbook(L689)均为读函数但未用 cacheFn。verify* 函数在 actions 层归属校验热路径中被频繁调用(actions.ts 中多处调用)。", + "recommendation": "添加 cacheFn 包装,TTL 较短(60-120s)。getPrerequisiteEdgesForTextbook 用于循环检测,可缓存 300s。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-025", + "file": "src/modules/lesson-preparation/data-access-schedules.ts", + "lines": "L29-L34", + "ruleId": "P-07", + "severity": "P2", + "dimension": "pattern", + "title": "toDateStr 本地实现日期序列化,未用 shared helper", + "description": "toDateStr(L29-34)手动拼接 YYYY-MM-DD 字符串,未使用项目统一的 serializeDate/toISODateString helper。其他模块(如 data-access.ts 的 mapRowToLessonPlan)使用 `.toISOString()` 序列化。", + "recommendation": "统一使用 shared/lib 中的日期序列化 helper,或将 toDateStr 提取到 shared/lib/date-utils.ts 供所有模块复用。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-026", + "file": "src/modules/lesson-preparation/data-access-knowledge.ts", + "lines": "L16-L18", + "ruleId": "S-03", + "severity": "P2", + "dimension": "structure", + "title": "isStringArray 与 lib/type-guards 重复实现", + "description": "data-access-knowledge.ts 在 L16-18 本地定义 isStringArray,而 data-access-ai-evaluation.ts L14 已从 './lib/type-guards' 导入同名函数。同一模块内重复实现 helper。", + "recommendation": "删除 data-access-knowledge.ts L16-18 的本地实现,改为 `import { isStringArray } from './lib/type-guards'`。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-027", + "file": "src/modules/lesson-preparation/data-access-review.ts", + "lines": "L16-L23", + "ruleId": "S-03", + "severity": "P2", + "dimension": "structure", + "title": "toLessonPlanStatus/toReviewDecision 在多个文件重复定义", + "description": "data-access-review.ts(L16-18)和 data-access-calendar.ts(L16-18)各自定义了 toLessonPlanStatus 函数,逻辑完全相同(isLessonPlanStatus 守卫失败回退 'draft')。toReviewDecision(L21-23)也仅在本文件定义但可共享。", + "recommendation": "将 toLessonPlanStatus 提取到 lib/type-guards.ts 或 lib/serialize.ts,两个 data-access 文件统一导入。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-028", + "file": "src/modules/lesson-preparation/data-access-ai-evaluation.ts", + "lines": "L141-L192", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "evaluateDocument 业务逻辑(评分算法)放在 data-access 层", + "description": "evaluateDocument(L141-192)是纯业务逻辑函数(基于规则计算 5 维度评分 + 生成建议),不涉及任何 DB 操作,却导出在 data-access 文件中。违反 A-02'data-access 不含业务逻辑'规则。", + "recommendation": "将 evaluateDocument 移至 lib/ai-evaluation.ts(纯函数模块),data-access-ai-evaluation.ts 仅保留 DB CRUD。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-029", + "file": "src/modules/lesson-preparation/data-access-review.ts", + "lines": "L40-L45, L50-L76, L82-L137, L225-L250", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "状态机逻辑(isValidTransition + 状态迁移)放在 data-access 层", + "description": "isValidTransition(L40-45)是状态机校验纯函数;submitForReview(L50-76)、reviewPlan(L82-137)、withdrawSubmission(L225-250)内部包含状态迁移判断逻辑(L66-68、L100-107、L240-242),属于业务编排而非纯数据访问。", + "recommendation": "将 isValidTransition 和状态迁移判断逻辑移至 actions-review.ts 或 lib/status-machine.ts;data-access 仅暴露 updateStatus(planId, newStatus) 和 insertReviewRecord() 等纯数据操作。", + "effort": "M (≤2h)" + }, + { + "id": "G1-030", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L426-L458", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "reorderChapters 重排序业务逻辑放在 data-access 层", + "description": "reorderChapters(L426-458)包含排序算法(splice 插入 L442)、parentId 变更判断(L447)等业务逻辑,且在事务内循环更新。这些编排逻辑应属于 actions 层。", + "recommendation": "将排序算法和变更判断移至 actions.ts,data-access 仅暴露 updateChapterOrder(tx, id, order, parentId) 单条更新接口,由 actions 在事务内调用。", + "effort": "M (≤2h)" + }, + { + "id": "G1-031", + "file": "src/modules/lesson-preparation/data-access.ts", + "lines": "L10-L15", + "ruleId": "A-06", + "severity": "P2", + "dimension": "architecture", + "title": "导入 textbooks/chapters 表(属 textbooks 模块)用于 JOIN", + "description": "data-access.ts L10-15 从 @/shared/db/schema 导入 textbooks、chapters 表(属 textbooks 模块)用于 L271-272 的 LEFT JOIN。虽然 L27 也通过 textbooks data-access 导入查询函数,但 JOIN 仍直接引用对方表。", + "recommendation": "短期:保留 JOIN 引用但添加注释说明;长期:重构为两步查询(先查 lessonPlans,再用 ID 批量查 textbooks/chapters 名称),彻底消除跨模块 schema 引用。", + "effort": "L (≤1d)" + }, + { + "id": "G1-032", + "file": "src/modules/lesson-preparation/data-access-schedules.ts", + "lines": "L9", + "ruleId": "A-06", + "severity": "P2", + "dimension": "architecture", + "title": "导入 classes 表(属 classes 模块)用于 JOIN", + "description": "data-access-schedules.ts L9 从 @/shared/db/schema 导入 classes 表(属 classes 模块),在 L55、L98、L164 的 LEFT JOIN 中获取 className。应通过 classes 模块 data-access 获取。", + "recommendation": "改为两步:1) 查 lessonPlanSchedules(不含 JOIN);2) 收集 classId 后调用 classes 模块的 getClassNamesByIds(classIds) 批量获取名称,内存 Map 关联。", + "effort": "M (≤2h)" + }, + { + "id": "G1-033", + "file": "src/modules/questions/data-access.ts", + "lines": "L4", + "ruleId": "A-06", + "severity": "P2", + "dimension": "architecture", + "title": "导入 knowledgePoints 表(属 textbooks 模块)用于 JOIN", + "description": "data-access.ts L4 从 @/shared/db/schema 导入 knowledgePoints 表(属 textbooks 模块),在 L463 的 INNER JOIN 中获取知识点名称。虽然 L8-14 已通过 textbooks data-access 导入查询函数,此处 JOIN 仍直接引用对方表。", + "recommendation": "getKnowledgePointsForQueries 改为两步:1) 查 questionsToKnowledgePoints(本模块表)获取 questionId→knowledgePointId 映射;2) 调用 textbooks data-access 批量获取知识点名称,内存关联。", + "effort": "M (≤2h)" + }, + { + "id": "G1-034", + "file": "src/modules/lesson-preparation/data-access-versions.ts", + "lines": "L35-L55, L59-L95, L97-L126, L128-L165, L167-L205", + "ruleId": "S-06", + "severity": "P2", + "dimension": "structure", + "title": "5 个公共导出函数缺少 JSDoc 注释", + "description": "getLessonPlansRaw(L35)、createLessonPlanVersion(L59)、getVersionContentRaw(L97)、revertToVersion(L128)、pruneAutoVersions(L167)均无 JSDoc。仅 L132/L140 有内联注释。公共导出函数应补齐 JSDoc 说明用途、参数、返回值。", + "recommendation": "为每个导出函数添加 JSDoc,如 `/** 创建课案版本,在事务内 max(versionNo)+1 防止并发重复 */`。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-035", + "file": "src/modules/lesson-preparation/data-access-templates.ts", + "lines": "L45-L72, L76-L112, L114-L125", + "ruleId": "S-06", + "severity": "P2", + "dimension": "structure", + "title": "3 个公共导出函数缺少 JSDoc 注释", + "description": "getLessonPlansRaw(L45)、saveAsTemplate(L76)、deletePersonalTemplate(L114)均无 JSDoc。saveAsTemplate 的 sourcePlanId→skeleton 提取逻辑(L94-100)需要文档说明。", + "recommendation": "添加 JSDoc 说明函数用途、参数含义、返回值。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-036", + "file": "src/modules/questions/data-access.ts", + "lines": "L39-L193, L214-L247, L258-L292", + "ruleId": "S-06", + "severity": "P2", + "dimension": "structure", + "title": "核心函数 getQuestionsRaw/insertQuestionWithRelations/updateQuestionById 缺少 JSDoc", + "description": "getQuestionsRaw(L39)、insertQuestionWithRelations(L214)、updateQuestionById(L258)等核心函数无 JSDoc。getQuestionsRaw 的级联筛选逻辑(L75-122)较复杂,需要文档说明筛选优先级。", + "recommendation": "为这些函数添加 JSDoc,特别是 getQuestionsRaw 的 knowledgePointId > chapterId > textbookId 级联筛选优先级。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-037", + "file": "src/modules/questions/data-access.ts", + "lines": "L1-L662", + "ruleId": "S-02", + "severity": "P2", + "dimension": "structure", + "title": "单文件导出函数数约 28 个,超过 20 警告阈值", + "description": "data-access.ts 导出约 28 个符号(含类型、函数、接口),包括 getQuestions/getQuestionsDashboardStats/createQuestionWithRelations/updateQuestionById/deleteQuestionByIdRecursive/deleteQuestionsBatch/getKnowledgePointOptions/getTextbookOptions/getChapterOptions/getKnowledgePointOptionsByChapter/getKnowledgePointsForQuestions/getQuestionsContentForErrorCollection/getQuestionTypeMapByIds/exportQuestions/importQuestions 等。职责混合了 CRUD + 跨模块接口 + 导入导出。", + "recommendation": "按职责拆分为 data-access.ts(核心 CRUD)、data-access-cross-module.ts(跨模块只读接口)、data-access-import-export.ts(导入导出)。", + "effort": "L (≤1d)" + }, + { + "id": "G1-038", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L1-L703", + "ruleId": "S-02", + "severity": "P2", + "dimension": "structure", + "title": "单文件导出函数数约 35 个,超过 20 警告阈值", + "description": "data-access.ts 导出约 35 个符号,涵盖教材 CRUD、章节 CRUD、知识点 CRUD、排序、统计、归属校验、scope 查询、跨模块接口、前置依赖 CRUD。职责过重。", + "recommendation": "拆分为 data-access.ts(教材+章节)、data-access-knowledge-points.ts(知识点+前置依赖)、data-access-cross-module.ts(跨模块只读接口)。", + "effort": "L (≤1d)" + }, + { + "id": "G1-039", + "file": "src/modules/lesson-preparation/data-access-ai-evaluation.ts", + "lines": "L58, L77", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(getEvaluationsByPlanIdRaw / getLatestEvaluationRaw)", + "description": "getEvaluationsByPlanIdRaw(L58)和 getLatestEvaluationRaw(L77)使用 `.select()` 无参数,SELECT 所有列。表字段可能后续增加,且传输不需要的列浪费带宽。", + "recommendation": "改为显式列枚举 `.select({ id: ..., planId: ..., ... })`,仅查询 mapRowToEvaluation 实际使用的字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-040", + "file": "src/modules/lesson-preparation/data-access-analytics.ts", + "lines": "L63, L210", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(getTeacherInvestmentRaw / upsertDailyAnalytics)", + "description": "getTeacherInvestmentRaw(L63)和 upsertDailyAnalytics 内的查询(L210)使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-041", + "file": "src/modules/lesson-preparation/data-access-review.ts", + "lines": "L146", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(getReviewRecordsByPlanIdRaw)", + "description": "getReviewRecordsByPlanIdRaw(L146)使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-042", + "file": "src/modules/lesson-preparation/data-access-substitutes.ts", + "lines": "L34, L52", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(getSubstitutesByPlanIdRaw / getActiveSubstitutesByTeacherIdRaw)", + "description": "两个读函数 L34、L52 均使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-043", + "file": "src/modules/lesson-preparation/data-access-versions.ts", + "lines": "L50", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(getLessonPlanVersionsRaw)", + "description": "getLessonPlanVersionsRaw(L50)使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-044", + "file": "src/modules/lesson-preparation/data-access-templates.ts", + "lines": "L61", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(getLessonPlansRaw)", + "description": "getLessonPlansRaw(L61)使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-045", + "file": "src/modules/lesson-preparation/data-access-knowledge.ts", + "lines": "L93, L123", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(getLessonPlansByKnowledgePointRaw / getLessonPlansByQuestionRaw)", + "description": "两个函数 L93、L123 均使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段,仅查询 mapRowToListItemWithoutJoin 实际使用的列。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-046", + "file": "src/modules/lesson-preparation/data-access-formative.ts", + "lines": "L51, L68, L169, L195, L201, L216", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "6 处 SELECT * 未指定列", + "description": "getFormativeItemsByPlanIdRaw(L51)、getFormativeItemByIdRaw(L68)、getResponsesByItemIdRaw(L169)、getResponsesByStudentIdRaw(L195、L201)、getFormativeItemStatsRaw(L216)均使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段。getFormativeItemStatsRaw 尤其应仅查聚合字段。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-047", + "file": "src/modules/lesson-preparation/data-access-comments.ts", + "lines": "L33, L51", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(getCommentsByPlanIdRaw / getCommentsByBlockIdRaw)", + "description": "两个读函数 L33、L51 均使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-048", + "file": "src/modules/lesson-preparation/data-access-attachments.ts", + "lines": "L31, L49, L123", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "3 处 SELECT * 未指定列", + "description": "getAttachmentsByPlanIdRaw(L31)、getAttachmentsByBlockIdRaw(L49)、getAttachmentByIdRaw(L123)均使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-049", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L427, L431", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "SELECT * 未指定列(reorderChapters 内查询)", + "description": "reorderChapters 中 L427 `db.select().from(chapters)` 和 L431 `db.select().from(chapters)` 使用 `.select()` 无参数。", + "recommendation": "显式枚举所需字段(id, textbookId, parentId, order, title)。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-050", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L43-L91, L627-L660", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getTextbooksRaw / getKnowledgePointOptionsRaw 无 LIMIT", + "description": "getTextbooksRaw(L64-79)和 getKnowledgePointOptionsRaw(L628-648)无 LIMIT。getKnowledgePointOptionsRaw 拉取全量知识点+章节+教材 JOIN,数据量大时风险高。", + "recommendation": "getTextbooksRaw 添加分页或 `.limit(200)`;getKnowledgePointOptionsRaw 应改为按 textbookId/subject 参数过滤,或前端懒加载。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-051", + "file": "src/modules/lesson-preparation/data-access-formative.ts", + "lines": "L165-L175, L182-L205", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getResponsesByItemIdRaw / getResponsesByStudentIdRaw 无 LIMIT", + "description": "两个函数查询学生作答记录均无 LIMIT。一个互动组件可能有上千条作答,一个学生可能有大量作答历史。", + "recommendation": "添加分页参数或 `.limit(500)` 上限保护。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-052", + "file": "src/modules/lesson-preparation/data-access-comments.ts", + "lines": "L29-L39, L46-L62", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getCommentsByPlanIdRaw / getCommentsByBlockIdRaw 无 LIMIT", + "description": "两个函数查询评论均无 LIMIT。热门课案评论数可能很多。", + "recommendation": "添加分页参数或 `.limit(200)` 上限保护。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-053", + "file": "src/modules/lesson-preparation/data-access-knowledge.ts", + "lines": "L92-L101, L122-L131", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getLessonPlansByKnowledgePointRaw / getLessonPlansByQuestionRaw 无 LIMIT", + "description": "两个函数对 lessonPlans 全表 LIKE 扫描后无 LIMIT,且无分页。匹配数量不可控。", + "recommendation": "添加 `.limit(100)` 上限保护,或改为分页查询。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-054", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L465-L474", + "ruleId": "F-10", + "severity": "P2", + "dimension": "performance", + "title": "getTextbooksDashboardStatsRaw 全表 COUNT 无过滤", + "description": "getTextbooksDashboardStatsRaw(L465-474)对 textbooks 和 chapters 表各执行 `count()` 无 WHERE 过滤,统计全量数据。仪表盘统计应至少按可见范围过滤。", + "recommendation": "如需按权限范围统计,传入 scope 参数添加 WHERE 条件;若确为管理员全局统计,可保留但加缓存(已有 cacheFn)。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-055", + "file": "src/modules/questions/data-access.ts", + "lines": "L204-L207", + "ruleId": "F-10", + "severity": "P2", + "dimension": "performance", + "title": "getQuestionsDashboardStatsRaw 全表 COUNT 无过滤", + "description": "getQuestionsDashboardStatsRaw(L204-207)对 questions 表执行 `count()` 无 WHERE 过滤。仪表盘应按用户可见范围统计。", + "recommendation": "传入 scope/authorId 参数添加 WHERE 条件,或确认是否为管理员全局统计。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-056", + "file": "src/modules/lesson-preparation/data-access.ts", + "lines": "L574-L590", + "ruleId": "F-10", + "severity": "P2", + "dimension": "performance", + "title": "getLessonPlanStats 全表 GROUP BY 无过滤", + "description": "getLessonPlanStats(L574-590)对 lessonPlans 全表 GROUP BY status 统计,无 WHERE 过滤。管理员看板统计应限定范围(如本学期/本学年)。", + "recommendation": "添加时间范围 WHERE 条件(如 createdAt >= 学期开始日期),避免统计历史归档数据。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-057", + "file": "src/modules/lesson-preparation/data-access-substitutes.ts", + "lines": "L125-L141", + "ruleId": "A-02", + "severity": "P3", + "dimension": "architecture", + "title": "canTeacherAccessPlan 含权限判断业务逻辑", + "description": "canTeacherAccessPlan(L125-141)包含'原教师→true / 代课教师→true'的权限判断逻辑,属于业务编排。虽然查询了 DB,但'是否可访问'的判断应属于 actions 或权限层。", + "recommendation": "将 canTeacherAccessPlan 的判断逻辑移至 actions 层,data-access 仅暴露 getPlanCreatorId 和 getActiveSubstitutesByTeacherId 两个纯读接口。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G1-058", + "file": "src/modules/lesson-preparation/data-access-analytics.ts", + "lines": "L201-L249", + "ruleId": "A-02", + "severity": "P3", + "dimension": "architecture", + "title": "upsertDailyAnalytics 含 read-then-write 业务逻辑", + "description": "upsertDailyAnalytics(L201-249)先 SELECT 判断是否存在(L209-218),存在则 UPDATE 累加(L222-234),不存在则 INSERT(L236-247)。该 upsert 编排逻辑可下放到 actions 或用 SQL `INSERT ... ON DUPLICATE KEY UPDATE` 替代。", + "recommendation": "改用 MySQL `INSERT ... ON DUPLICATE KEY UPDATE` 单语句完成 upsert,或在 actions 层编排 read-then-write。", + "effort": "M (≤2h)" + }, + { + "id": "G1-059", + "file": "src/modules/lesson-preparation/data-access.ts", + "lines": "L336, L535, L612", + "ruleId": "F-03", + "severity": "P3", + "dimension": "performance", + "title": "3 处 SELECT * 未指定列", + "description": "getLessonPlanByIdRaw(L336)、duplicateLessonPlan(L535)、getTemplateById(L612)使用 `.select()` 无参数。其中 getLessonPlanByIdRaw 查询后用 mapRowToLessonPlan 映射,所需字段已知。", + "recommendation": "显式枚举 mapRowToLessonPlan 所需的 14 个字段。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-060", + "file": "src/modules/lesson-preparation/data-access-substitutes.ts", + "lines": "L136", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "plan[0]!.creatorId 非空断言", + "description": "L136 `if (plan[0]!.creatorId === teacherId) return true;` 在已检查 `plan.length === 0`(L135)后使用 `!` 非空断言。虽逻辑正确,但可改为更安全的 `const row = plan[0]; if (row && row.creatorId === teacherId) ...`。", + "recommendation": "用 `const row = plan[0]; if (!row) return false; if (row.creatorId === teacherId) return true;` 替代非空断言。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-061", + "file": "src/modules/lesson-preparation/data-access-calendar.ts", + "lines": "L181", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "split('T')[0]! 非空断言", + "description": "L181 `e.occurredAt.toISOString().split('T')[0]!` 对数组取值使用 `!`。虽然 toISOString() 必定含 'T',但 `!` 属非空断言。", + "recommendation": "改为 `e.occurredAt.toISOString().split('T')[0] ?? ''` 或用专门的 toISODateString helper。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-062", + "file": "src/modules/lesson-preparation/data-access-formative.ts", + "lines": "L72", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "rows[0]! 非空断言", + "description": "L72 `return rows.length === 0 ? null : mapRowToItem(rows[0]!);` 使用 `!`。虽逻辑正确,但可避免。", + "recommendation": "改为 `const row = rows[0]; return row ? mapRowToItem(row) : null;`。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-063", + "file": "src/modules/lesson-preparation/data-access-comments.ts", + "lines": "L118", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "rows[0]!.resolved 非空断言", + "description": "L118 `const newResolved = !rows[0]!.resolved;` 使用 `!`。已检查 `rows.length === 0`(L117)但风格上可改进。", + "recommendation": "改为 `const row = rows[0]; if (!row) return; const newResolved = !row.resolved;`。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-064", + "file": "src/modules/lesson-preparation/data-access-schedules.ts", + "lines": "L167", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "created[0]! 非空断言", + "description": "L167 `const r = created[0]!;` 在 createSchedule 中查询刚插入的记录后使用 `!`。INSERT 后立即查询,理论上必定有值,但 `!` 不够安全。", + "recommendation": "改为 `const r = created[0]; if (!r) throw new Error('SCHEDULE_CREATE_FAILED');`。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-065", + "file": "src/modules/lesson-preparation/data-access-analytics.ts", + "lines": "L98", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "r.templateId! 非空断言", + "description": "L98 `templateId: r.templateId!,` 在 WHERE 已过滤 `templateId IS NOT NULL`(L93)后使用 `!`。", + "recommendation": "改为 `templateId: r.templateId ?? ''`,或用类型守卫收窄。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-066", + "file": "src/modules/questions/data-access.ts", + "lines": "L1-L662", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "deleteQuestionRecursive/insertQuestionWithRelations 缺少 JSDoc", + "description": "deleteQuestionRecursive(L294)、insertQuestionWithRelations(L214)等内部函数无 JSDoc。环检测逻辑(L299-303)需要文档说明。", + "recommendation": "补充 JSDoc 说明环检测目的和 visited Set 的作用。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G1-067", + "file": "src/modules/textbooks/data-access.ts", + "lines": "L170-L206, L208-L210, L212-L240, L242-L273, L275-L333", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "createTextbook/updateTextbook/deleteTextbook/createChapter 等多个函数缺少 JSDoc", + "description": "createTextbook(L170)、updateTextbook(L192)、deleteTextbook(L208)、createChapter(L212)、updateChapterContent(L242)、deleteChapter(L275)、createKnowledgePoint(L398)、updateKnowledgePoint(L411)、deleteKnowledgePoint(L422)、reorderChapters(L426)均无 JSDoc。deleteChapter 的级联删除逻辑(L310-332)较复杂,需要文档。", + "recommendation": "为这些函数添加 JSDoc,特别是 deleteChapter 需说明级联删除知识点+前置依赖的行为。", + "effort": "S (≤30 分钟)" + } +] diff --git a/docs/architecture/audit/g2-data-access-audit.json b/docs/architecture/audit/g2-data-access-audit.json new file mode 100644 index 0000000..bbf248c --- /dev/null +++ b/docs/architecture/audit/g2-data-access-audit.json @@ -0,0 +1,134 @@ +[ + { + "id": "G2-001", + "file": "src/modules/exams/data-access.ts", + "lines": "L1", + "ruleId": "P-01", + "severity": "P0", + "dimension": "pattern", + "title": "文件首行缺少 import \"server-only\" 标记", + "description": "data-access.ts 首行为 `import { db } from \"@/shared/db\"`,未在文件头声明 `import \"server-only\"`。该文件包含直接 DB 访问(exams/examQuestions 表的 CRUD),若被客户端组件意外引入,会将数据库连接与查询逻辑泄露到客户端 bundle,造成安全漏洞。同模块的 data-access-error-collection.ts(L1)与 data-access-cross-module.ts(L1)均已正确声明,唯独主文件遗漏。", + "recommendation": "在文件第一行(所有 import 之前)添加 `import \"server-only\"`。注意:必须位于首行,否则 next.js 的 server-only 边界检测可能不生效。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G2-002", + "file": "src/modules/grades/data-access-appeals.ts", + "lines": "L122-L151", + "ruleId": "F-01", + "severity": "P1", + "dimension": "performance", + "title": "getPendingAppealsForReviewRaw 在 JS 层过滤班级范围而非 SQL WHERE", + "description": "函数 WHERE 子句仅过滤 `gradeAppeals.status = 'pending'`(L134),未对 classIds 加任何过滤,导致 SQL 返回全库所有 pending 申诉(含 gradeRecord 全字段 innerJoin),随后在 L141 用 `rows.filter((r) => classIds.includes(r.gradeRecord.classId))` 在 JS 层过滤。代码注释写明「在 JS 层过滤班级范围(避免复杂 SQL join)」,但 innerJoin gradeRecords 已存在,加 `inArray(gradeRecords.classId, classIds)` 并不复杂。当 pending 申诉总量增长时(全校维度),单次查询会拉取大量无关行,造成内存与网络压力;同时若 JS filter 被误删将引发跨班级数据泄露。", + "recommendation": "在 L132-L137 的 `and()` 内追加 `inArray(gradeRecords.classId, classIds)` 条件(classIds 为空时已在 L123 提前返回),删除 L140-L141 的 JS 层 filter,直接返回 rows.map(...)。这样既收窄 SQL 结果集,又消除数据泄露风险。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G2-003", + "file": "src/modules/adaptive-practice/data-access-analytics.ts", + "lines": "L311-L384", + "ruleId": "F-01", + "severity": "P0", + "dimension": "performance", + "title": "getTeacherClassPracticeOverviewsRaw 在 Promise.all 内对每个班级循环发起 2 条 SQL(2N+1 模式)", + "description": "函数对 classIds 数组执行两次 Promise.all 循环:(1) L320-L325 对每个 classId 调用 `getActiveStudentIdsByClassId(classId)`(每班 1 条 SQL,共 N 条);(2) L328-L365 对每个班级再发起 1 条 `db.select().from(practiceSessions).where(inArray(studentId, ...))` 聚合查询(共 N 条)。加上 L317 的 getClassNamesByIds(1 条),总计 2N+1 条 SQL。当教师所教班级数 N 较大(如年级主任辖 10+ 班级)时,单次请求产生 20+ 条 SQL,且 Promise.all 仅并发 IO 不减少 DB 负载。", + "recommendation": "改为批量查询:(1) 一次性获取所有班级的学生 ID 映射(可用单条 SQL `SELECT classId, studentId FROM class_members WHERE classId IN (...) AND status='active'` 后在 JS 层 groupBy);(2) 用单条聚合 SQL `SELECT classId, count(...), SUM(...), COUNT(DISTINCT studentId) FROM practiceSessions WHERE studentId IN (全部学生) GROUP BY studentId` 后在 JS 层按班级归并;或直接 JOIN class_members 按 classId 分组。目标:将 2N+1 降至 2-3 条 SQL。", + "effort": "M (≤2 小时)" + }, + { + "id": "G2-004", + "file": "src/modules/adaptive-practice/data-access-analytics.ts", + "lines": "L320-L325", + "ruleId": "F-08", + "severity": "P1", + "dimension": "performance", + "title": "跨模块在循环内多次调用 getActiveStudentIdsByClassId(classes 模块)", + "description": "在 Promise.all 内对每个 classId 单独调用 `@/modules/classes/data-access` 的 `getActiveStudentIdsByClassId`,属于 F-08 跨模块多次调用 getXxxByIds 模式。该函数内部本身可能已 cacheFn 包装,但首次填充缓存时仍会产生 N 条 SQL。应改用批量接口 `getActiveStudentIdsByClassIds(classIds)`(如不存在则需在 classes 模块新增)。", + "recommendation": "在 classes/data-access 新增 `getActiveStudentIdsByClassIds(classIds: string[]): Promise>` 批量接口(单条 SQL `WHERE classId IN (...)` 后 groupBy),本函数改为一次调用获取全量映射。与 G2-003 的修复可合并执行。", + "effort": "M (≤2 小时)" + }, + { + "id": "G2-005", + "file": "src/modules/grades/data-access-analytics.ts", + "lines": "L1-L831", + "ruleId": "S-01", + "severity": "P1", + "dimension": "structure", + "title": "文件 831 行超过 800 行警告阈值", + "description": "文件总计 831 行,超过 S-01 规则的 800 行警告线(虽未达 1000 行硬性上限)。文件内含多个独立分析维度:年级分布(getGradeDistribution*)、班级统计(getClassGradeStats*)、学生摘要(getStudentGradeSummary*)、排名(getClassRanking*)等。职责虽同属 grades 分析,但可按分析维度进一步拆分以提升可维护性。", + "recommendation": "按分析维度拆分为 data-access-analytics-grade-distribution.ts / data-access-analytics-class-stats.ts / data-access-analytics-student-summary.ts 等,每个子文件 ≤ 300 行。或暂不拆分但监控增长,一旦逼近 1000 行必须拆分。", + "effort": "L (≤1 天)" + }, + { + "id": "G2-006", + "file": "src/modules/homework/data-access.ts", + "lines": "L207-L212", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "data-access 内联 computeOverdueCount 业务计算闭包", + "description": "在 getHomeworkAssignmentsRaw 的数据组装段内定义了 `computeOverdueCount` 闭包,包含条件分支 `if (!dueAt || dueAt > now) return 0` 及逾期人数推导逻辑 `Math.max(0, targetCount - submittedCount)`。虽为纯计算(非状态机),但「逾期」的业务定义(dueAt 已过且未提交)属于业务规则,下沉到 data-access 后未来若规则变更(如加宽限期、按作业类型区分)需改 data-access 而非 actions/lib。属 A-02 边界情形。", + "recommendation": "将 computeOverdueCount 提取到 homework/lib/overdue.ts 作为纯函数 `computeOverdueCount(dueAt, targetCount, submittedCount, now)`,data-access 仅负责数据获取与组装,业务规则集中到 lib。优先级较低,可在重构窗口处理。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G2-007", + "file": "src/modules/grades/data-access-drafts.ts", + "lines": "L367-L382", + "ruleId": "P-04", + "severity": "P2", + "dimension": "pattern", + "title": "releaseDraftLock 返回值依赖隐式类型推断的元组解构", + "description": "L367-L378 执行 `db.update(gradeDrafts).set(...).where(...)` 后,L381 用 `const [header] = result` 解构,L382 返回 `(header?.affectedRows ?? 0) > 0`。drizzle MySQL 的 update 返回类型为 `MySqlRawQueryResult`(即 `[ResultSetHeader, FieldPacket[]]`),header 类型由推断得到。代码逻辑正确,但依赖 drizzle 内部类型推断而非显式标注,未来 drizzle 版本变更返回类型时可能静默失效。函数签名已显式标注 `Promise`(L364),属轻微模式偏差。", + "recommendation": "可在解构处补充类型注释 `const [header] = result as [ResultSetHeader, unknown]`(此处 as 属从 unknown/drizzle 内部类型收窄,符合豁免);或保持现状但增加单元测试覆盖锁释放场景。优先级低。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G2-008", + "file": "src/modules/diagnostic/data-access.ts", + "lines": "L1-L553", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "诊断掌握度累积计算函数(updateMasteryFrom*)含业务规则分支", + "description": "文件含 3 个掌握度累积函数:updateMasteryFromSubmission / updateMasteryFromHomeworkSubmission / updateMasteryFromExamScore。这些函数内部包含掌握度合并算法(加权平均/最大值取值等业务规则)与 DB 写入混合。掌握度计算属于诊断业务规则,理想分层应将算法提取到 diagnostic/lib/mastery-calculator.ts,data-access 仅负责读写 knowledgePointMastery 表。当前实现可行但职责混合,属 A-02 边界。", + "recommendation": "提取纯函数 `computeMasteryAfterSubmission(current: MasteryState, submission: SubmissionInput): MasteryState` 到 diagnostic/lib/,data-access 函数改为:读取当前掌握度 → 调用纯函数计算新值 → 写回 DB。优先级中等,可在掌握度算法需调整时一并重构。", + "effort": "M (≤2 小时)" + }, + { + "id": "G2-009", + "file": "src/modules/adaptive-practice/data-access.ts", + "lines": "L307", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "sourceMeta as unknown 用于 JSON 序列化字段写入(属豁免范畴)", + "description": "L307 `sourceMeta: sourceMeta as unknown` 将类型化对象转为 unknown 以写入 JSON 列。此处的 as 属于「向 unknown 转换」的合规用法(框架 P-09 豁免:从 unknown 收窄或反向序列化)。仅作记录,非违规。", + "recommendation": "无需修改。若追求严谨,可改用 `JSON.parse(JSON.stringify(sourceMeta))` 显式序列化,但当前写法已合规。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G2-010", + "file": "src/modules/exams/data-access.ts", + "lines": "L316", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "new Map(generated.map((q) => [q.id, q] as const)) 使用 as const 构造 Map(属豁免)", + "description": "L316 `[q.id, q] as const` 用于向 Map 构造器提供 readonly tuple 类型。as const 属于 TypeScript 类型工具的合规用法(P-09 豁免),非类型断言违规。仅作记录。", + "recommendation": "无需修改。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G2-011", + "file": "src/modules/homework/data-access-write.ts", + "lines": "L16,L20", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "import 语句中的 as 为模块别名(非类型断言)", + "description": "L16 `getClassTeacherById as getClassTeacherIdFromClass` 与 L20 `getExamWithQuestionsForHomework as getExamWithQuestionsFromExams` 为 ES module import 别名,用于避免跨模块同名函数冲突。非 P-09 规则所约束的类型断言。仅作记录,零违规。", + "recommendation": "无需修改。", + "effort": "XS (≤15 分钟)" + } +] diff --git a/docs/architecture/audit/g3-audit-output.json b/docs/architecture/audit/g3-audit-output.json new file mode 100644 index 0000000..22ed7de --- /dev/null +++ b/docs/architecture/audit/g3-audit-output.json @@ -0,0 +1,602 @@ +[ + { + "id": "G3-001", + "file": "src/modules/classes/data-access.ts", + "lines": "L17-L313", + "ruleId": "P-03", + "severity": "P0", + "dimension": "pattern", + "title": "classes/data-access.ts 中 24+ 个读函数未走 cacheFn 包装", + "description": "文件中导出的读函数(getClassSubjects、getAccessibleClassIdsForTeacher、getClassGradeIdsByClassIds、getTeacherSubjectIdsForClass、getClassTeacherById、getStudentIdsByClassId、getStudentIdsByClassIds、getActiveStudentIdsByClassId、getClassActiveStudentsWithInfo、getTeacherSubjectIdsByClass、getTeacherIdsByClassIds、getStudentActiveClassId、getStudentActiveClass、getStudentActiveGradeId、getClassExists、getClassNameById、getClassGradeId、getGradeIdsByClassIds、getClassNamesByIds、getClassesByGradeId、getClassIdsByGradeIds 等)全部直接执行 DB 查询,未使用项目标准的 `cacheFn(raw, { tags, ttl, keyParts })` 模式。这些函数被跨模块高频调用(attendance、scheduling、course-plans、proctoring 等模块都依赖),每次调用都直接命中 DB,导致重复查询与缓存失效。", + "recommendation": "为每个公开读函数添加 Raw + Wrapper 配对模式。例如:\n```ts\nexport const getClassNamesByIdsRaw = async (classIds: string[]): Promise> => { /* 原 SQL 逻辑 */ }\nexport const getClassNamesByIds = cacheFn(getClassNamesByIdsRaw, {\n tags: [\"classes:names\"],\n ttl: 300,\n keyParts: [\"classes\", \"getClassNamesByIds\"],\n})\n```\n注意:getSessionTeacherId、getTeacherIdForMutations、verifyTeacherOwnsClass 等用于权限校验的函数可不缓存(避免缓存权限提升风险)。", + "effort": "L (≤1d)" + }, + { + "id": "G3-002", + "file": "src/modules/scheduling/data-access.ts", + "lines": "L8-L17", + "ruleId": "A-06", + "severity": "P0", + "dimension": "architecture", + "title": "scheduling 模块直接 import classes/users/subjects 等其他模块的 schema 表", + "description": "文件头部 `import { classes, classSchedule, classSubjectTeachers, classrooms, scheduleChanges, schedulingRules, subjects, users } from \"@/shared/db/schema\"` 中,`classes`、`classSubjectTeachers` 属于 classes 模块,`subjects` 属于 school 模块,`users` 属于 users 模块。scheduling 模块直接查询这些表违反了架构规则 A-06:modules 之间应通过对方 data-access 通信,不直接查询对方 DB 表。\n\n证据:\n- L122-L124 `getScheduleChangesRaw` 直接 INNER JOIN `classes` 表查询班级名称\n- L128-L146 直接查询 `users` 表解析 substituteTeacher/approver 姓名\n- L295-L302 `getTeachersForSchedulingRaw` 直接查询 `users` 表\n- L323-L335 `getClassSubjectsForSchedulingRaw` 直接 JOIN `subjects` 与 `classSubjectTeachers`", + "recommendation": "改为通过对方 data-access 调用:\n```ts\nimport { getClassNamesByIds } from \"@/modules/classes/data-access\"\nimport { getUserNamesByIds } from \"@/modules/users/data-access\"\nimport { getSubjectNameMapByIds } from \"@/modules/school/data-access\"\n\n// 替代直接 JOIN classes:\nconst classNameMap = await getClassNamesByIds(classIds)\n// 替代直接查询 users:\nconst userMap = await getUserNamesByIds(userIds)\n```\n对于 `classSubjectTeachers` 的查询,应在 classes 模块新增 `getSubjectTeachersForScheduling(classId)` 暴露给 scheduling 调用。", + "effort": "M (≤2h)" + }, + { + "id": "G3-003", + "file": "src/modules/scheduling/data-access-class-schedule.ts", + "lines": "L28-L57, L64-L136, L142-L158", + "ruleId": "A-02", + "severity": "P0", + "dimension": "architecture", + "title": "data-access-class-schedule.ts 包含大量业务逻辑(校验、归属校验、状态机)", + "description": "createClassScheduleItem、updateClassScheduleItem、deleteClassScheduleItem 三个函数包含:\n- 时间格式校验 `isTimeHHMM`(L42)\n- 业务规则校验 `startTime >= endTime`(L43)、`weekday < 1 || weekday > 7`(L44)\n- 归属校验 `verifyTeacherOwnsClass`(L46、L85、L94、L155)\n- 字段合并与冲突检测(L121-L127)\n- 通过 `getTeacherIdForMutations()` 获取当前教师 ID(L31、L68、L143)\n\n这些业务逻辑应位于 actions 层(编排层),data-access 层应只负责 DB 读写。当前实现导致职责混淆(S-08),且这些函数既不是 \"use server\" 也不是纯 data-access,处于灰色地带。", + "recommendation": "将校验与归属校验逻辑移至 actions-schedule.ts:\n```ts\n// actions-schedule.ts\n\"use server\"\nexport async function createClassScheduleItemAction(prevState, formData) {\n const ctx = await requirePermission(Permissions.SCHEDULE_ADJUST)\n // 校验输入\n if (!isTimeHHMM(startTime)) return { success: false, message: \"Invalid time\" }\n // 归属校验\n const owned = await verifyTeacherOwnsClass(classId, ctx.userId)\n if (!owned) return { success: false, message: \"Class not found\" }\n // 调用 data-access\n const id = await insertClassScheduleItem({ classId, weekday, ... })\n await invalidateFor(\"scheduling.create\")\n return { success: true, data: id }\n}\n```\ndata-access-class-schedule.ts 仅保留 `insertClassScheduleItem`、`updateClassScheduleItemById`、`deleteClassScheduleItemById` 等纯 DB 操作(这些已在 data-access.ts 中定义,本文件可考虑删除)。", + "effort": "M (≤2h)" + }, + { + "id": "G3-004", + "file": "src/modules/classes/data-access-teacher.ts", + "lines": "L92-L116", + "ruleId": "F-01", + "severity": "P0", + "dimension": "performance", + "title": "getTeacherClassesRaw 循环内对每个班级发起 2 次子查询(N+1)", + "description": "`getTeacherClassesRaw` 在获取班级列表后,使用 `Promise.all(list.map(async (c) => { ... }))` 对每个班级并行调用 `getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 })` 和 `getClassSchedule({ classId: c.id, teacherId })`。虽然使用了 Promise.all 并行化,但如果教师有 N 个班级,将产生 2N 次子查询(每次 getClassHomeworkInsights 内部还有多轮 DB 查询:accessibleIds、classRow、enrollments、assignments、submissions 等),总查询数可能达到 10N+。对于任教 10+ 班级的教师,单次列表加载可能触发 100+ DB 查询。", + "recommendation": "改为批量查询:\n1. 一次性获取所有班级的 homework insights:在 data-access-stats.ts 新增 `getBatchClassHomeworkInsights(classIds: string[], teacherId: string)` 批量函数\n2. 一次性获取所有班级的 schedule:新增 `getBatchClassSchedule(classIds: string[])`\n3. 在 getTeacherClassesRaw 中并行调用这两个批量函数,然后用 Map 在内存中关联到班级\n\n```ts\nconst [insightsMap, scheduleMap] = await Promise.all([\n getBatchClassHomeworkInsights(list.map(c => c.id), teacherId),\n getBatchClassSchedule(list.map(c => c.id)),\n])\nconst listWithTrends = list.map(c => {\n const insights = insightsMap.get(c.id)\n const schedule = scheduleMap.get(c.id) ?? []\n return { ...c, recentAssignments: ..., schedule }\n})\n```", + "effort": "L (≤1d)" + }, + { + "id": "G3-005", + "file": "src/modules/course-plans/data-access.ts", + "lines": "L324-L331", + "ruleId": "F-01", + "severity": "P0", + "dimension": "performance", + "title": "reorderCoursePlanItems 循环内发起 N 次 UPDATE 查询(N+1)", + "description": "`reorderCoursePlanItems` 使用 `Promise.all(items.map((item) => db.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))))` 对每个 item 发起独立的 UPDATE 查询。如果一次排序涉及 20 个条目,将产生 20 次 DB 往返。此外,这些更新没有包裹在事务中(F-09),若中间某个更新失败,会导致部分条目排序已变更、部分未变更的不一致状态。", + "recommendation": "改为单次事务 + 批量更新(使用 CASE WHEN 或单事务内顺序更新):\n```ts\nexport async function reorderCoursePlanItems(planId: string, items: ReorderCoursePlanItemInput[]): Promise {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n // 方案1:使用 CASE WHEN 单次 UPDATE\n const caseExpr = sql`CASE ${items.map((item, i) => sql`WHEN id = ${item.id} THEN ${item.week}`).join(' ')} END`\n await tx.update(coursePlanItems).set({ week: caseExpr }).where(eq(coursePlanItems.planId, planId))\n // 方案2:事务内顺序更新(简单但仍是 N 次查询,至少保证原子性)\n // for (const item of items) {\n // await tx.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))\n // }\n })\n}\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-006", + "file": "src/modules/course-plans/data-access.ts", + "lines": "L309-L332", + "ruleId": "F-09", + "severity": "P1", + "dimension": "performance", + "title": "reorderCoursePlanItems 多次 UPDATE 未包裹事务", + "description": "`reorderCoursePlanItems` 对多条 coursePlanItems 执行 UPDATE,未使用 `db.transaction` 包裹。若中间某次更新失败,已成功的更新无法回滚,导致周次排序部分变更的不一致状态。同样问题存在于 `bulkUpdateItemCompleted`(L337-L349)使用单次 inArray UPDATE,虽然单语句本身原子,但若业务上需要级联校验则缺少事务边界。", + "recommendation": "```ts\nexport async function reorderCoursePlanItems(planId: string, items: ReorderCoursePlanItemInput[]): Promise {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n for (const item of items) {\n await tx.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))\n }\n })\n}\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-007", + "file": "src/modules/school/data-access.ts", + "lines": "L1-L938", + "ruleId": "S-01", + "severity": "P1", + "dimension": "structure", + "title": "school/data-access.ts 938 行,超过 800 行警告阈值,接近 1000 行硬上限", + "description": "文件总行数 938 行,已超过项目规范的 800 行警告阈值(Server Actions / Data Access 模块建议 ≤ 800 行),接近 1000 行硬上限。文件同时包含:5 类读函数(departments/academicYears/schools/grades/staffOptions)、3 类权限感知查询(getSchoolsForUser/getGradesForUser/getOrgTree)、12 个 mutation 函数(create/update/delete × department/school/grade/academicYear)、6 个跨模块查询接口(getSubjectOptions/getGradeOptions/getGradeNameById/getSubjectNameById/getSubjectNameMapByIds/isGradeHead/isGradeManager/findGradeIdByHeadAndName)、2 个统计函数(getGradeOverviewStats/promoteGrades)。", + "recommendation": "按职责拆分为多个文件:\n```\nsrc/modules/school/\n├─ data-access.ts # 主入口(re-export)\n├─ data-access-departments.ts # 部门 CRUD\n├─ data-access-schools.ts # 学校 CRUD + getSchoolsForUser\n├─ data-access-grades.ts # 年级 CRUD + getGradesForUser + promoteGrades\n├─ data-access-academic-years.ts # 学年 CRUD\n├─ data-access-staff.ts # getStaffOptions + getGradesForStaff\n├─ data-access-options.ts # getSubjectOptions + getGradeOptions + getXxxNameById\n├─ data-access-permissions.ts # isGradeHead + isGradeManager + findGradeIdByHeadAndName\n└─ data-access-org-tree.ts # getOrgTree + getGradeOverviewStats\n```", + "effort": "M (≤2h)" + }, + { + "id": "G3-008", + "file": "src/modules/school/data-access.ts", + "lines": "L1-L938", + "ruleId": "S-02", + "severity": "P1", + "dimension": "structure", + "title": "school/data-access.ts 导出 30+ 函数,远超 20 个警告阈值", + "description": "文件导出函数清单(30+ 个):getDepartments、getAcademicYears、getSchools、getGrades、getStaffOptions、getGradesForStaff、getSchoolsForUser、getGradesForUser、createDepartment、updateDepartment、deleteDepartment、createSchool、updateSchool、deleteSchool、createGrade、updateGrade、deleteGrade、createAcademicYear、updateAcademicYear、deleteAcademicYear、getSubjectOptions、getGradeOptions、getGradeNameById、getSubjectNameById、getSubjectNameMapByIds、isGradeHead、isGradeManager、findGradeIdByHeadAndName、promoteGrades、getOrgTree、getGradeOverviewStats(含 Raw 版本则达 50+ 个)。导出函数过多导致文件职责不单一,维护困难。", + "recommendation": "按职责拆分(见 G3-007 建议),每个拆分文件导出函数数控制在 5-10 个以内。", + "effort": "M (≤2h)" + }, + { + "id": "G3-009", + "file": "src/modules/school/data-access.ts", + "lines": "L29, L50, L73, L294", + "ruleId": "F-03", + "severity": "P1", + "dimension": "performance", + "title": "school/data-access.ts 多处使用 db.select() 未指定列(SELECT *)", + "description": "以下查询使用 `db.select().from(table)` 返回所有列,违反 F-03 规则:\n- L29 `db.select().from(departments)` (getDepartmentsRaw)\n- L50 `db.select().from(academicYears)` (getAcademicYearsRaw)\n- L73 `db.select().from(schools)` (getSchoolsRaw)\n- L294 `db.select().from(schools)` (getSchoolsForUserRaw 内部)\n\n虽然这些表列数较少,但 SELECT * 会返回不需要的列(如 updatedAt、内部审计字段),增加网络传输与内存开销,且在 schema 变更时可能意外暴露新字段。", + "recommendation": "显式枚举所需列:\n```ts\nconst rows = await db\n .select({\n id: departments.id,\n name: departments.name,\n description: departments.description,\n createdAt: departments.createdAt,\n updatedAt: departments.updatedAt,\n })\n .from(departments)\n .orderBy(asc(departments.name))\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-010", + "file": "src/modules/school/data-access.ts", + "lines": "L38, L61, L82, L141, L168, L229, L307, L405, L573, L605, L877, L930", + "ruleId": "A-10", + "severity": "P1", + "dimension": "architecture", + "title": "school/data-access.ts 包含 12 处 console.error 调试代码", + "description": "文件中 12 个读函数内都有 `console.error(\"xxx failed:\", error)` 后返回空数组的模式(如 L38、L61、L82、L141、L168、L229、L307、L405、L573、L605、L877、L930)。这违反 A-10 规则(data-access 含 console.log 调试代码)。更重要的是,这种模式吞掉异常并返回空数组,导致调用方无法区分"无数据"和"查询失败",是错误的错误处理模式(P-05 也要求 data-access 层用 throw)。", + "recommendation": "删除所有 console.error,改为 throw 让 actions 层处理:\n```ts\nexport const getDepartmentsRaw = async (): Promise => {\n const rows = await db.select({...}).from(departments).orderBy(asc(departments.name))\n return rows.map(...)\n // 移除 try/catch,让异常向上传播\n}\n```\n若需保留容错,应在 actions 层用 try/catch 包裹并返回 ActionState。", + "effort": "M (≤2h)" + }, + { + "id": "G3-011", + "file": "src/modules/school/data-access.ts", + "lines": "L246-L310, L324-L408", + "ruleId": "A-02", + "severity": "P1", + "dimension": "architecture", + "title": "getSchoolsForUserRaw / getGradesForUserRaw 包含角色判断业务逻辑", + "description": "`getSchoolsForUserRaw`(L246-L310)和 `getGradesForUserRaw`(L324-L408)内部包含:\n- 查询用户角色 `db.select({ name: roles.name }).from(roles)...`\n- 基于角色分支:`if (roleNames.has(\"admin\"))` / `if (roleNames.has(\"grade_head\"))` / `if (roleNames.has(\"teacher\"))`\n- 动态导入 classes data-access 并调用 `getAccessibleClassIdsForTeacher`、`getGradeIdsByClassIds`\n\n这是典型的权限感知业务编排逻辑,应位于 actions 层或 lib 层,而非 data-access 层。data-access 层应只提供原子查询能力,由 actions 层根据用户角色选择调用哪个查询。", + "recommendation": "将角色判断逻辑移至 actions.ts 或新建 lib/school-scope-resolver.ts:\n```ts\n// actions.ts\nexport async function getSchoolsForUserAction(userId: string): Promise> {\n const ctx = await requirePermission(Permissions.SCHOOL_READ)\n // 基于 ctx.dataScope 与 roles 决定调用哪个 data-access 函数\n if (ctx.dataScope.type === \"all\") {\n return { success: true, data: await getSchools() }\n }\n // ... 其他分支\n}\n```\ndata-access 层保留 getSchools()、getSchoolsByIds(ids) 等原子函数。", + "effort": "L (≤1d)" + }, + { + "id": "G3-012", + "file": "src/modules/school/data-access.ts", + "lines": "L803-L822", + "ruleId": "F-09", + "severity": "P1", + "dimension": "performance", + "title": "promoteGrades 循环内多次 UPDATE 未包裹事务", + "description": "`promoteGrades` 查询所有年级后,在 `for (const row of rows)` 循环中对每个年级执行独立的 `db.update(grades).set(...)`,未使用事务。若中间某次更新失败(如唯一约束冲突、连接断开),已升级的年级无法回滚,导致年级数据部分升级、部分未升级的不一致状态。注释虽提到"从高到低升级,避免唯一约束冲突",但这只是降低风险,不能替代事务。", + "recommendation": "```ts\nexport async function promoteGrades(schoolId: string): Promise<{ promoted: number }> {\n const rows = await db.select(...).from(grades).where(eq(grades.schoolId, schoolId)).orderBy(desc(grades.order))\n let promoted = 0\n await db.transaction(async (tx) => {\n for (const row of rows) {\n const newOrder = (row.order ?? 0) + 1\n const newName = promoteGradeName(row.name)\n await tx.update(grades).set({ order: newOrder, name: newName }).where(eq(grades.id, row.id))\n promoted += 1\n }\n })\n return { promoted }\n}\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-013", + "file": "src/modules/classes/data-access-teacher.ts", + "lines": "L73", + "ruleId": "A-10", + "severity": "P2", + "dimension": "architecture", + "title": "classes/data-access-teacher.ts 包含 console.error 调试代码", + "description": "L73 `console.error(\"getTeacherClasses query failed:\", error)` 后 `throw new Error(\"Failed to load teacher classes\")`。虽然这里重新抛出了错误(比 school 模块的吞异常好),但 console.error 仍违反 A-10 规则。生产环境应使用结构化日志(如 logAudit 或 trackEvent),而非 console.error。", + "recommendation": "删除 console.error,直接 throw:\n```ts\n} catch (error) {\n throw new Error(\"Failed to load teacher classes\")\n}\n```\n若需记录错误上下文,使用项目统一的日志工具。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-014", + "file": "src/modules/classes/data-access-students.ts", + "lines": "L170", + "ruleId": "A-10", + "severity": "P2", + "dimension": "architecture", + "title": "classes/data-access-students.ts 包含 console.error 调试代码", + "description": "L170 `console.error(\"getStudentClasses primary query failed, falling back:\", error)` 后执行 fallback 查询。这种模式将异常吞掉并降级,调用方无法感知主查询失败。console.error 违反 A-10,且 fallback 逻辑(使用 `sql\\`NULL\\`` 替代 schoolName)隐藏了潜在 schema 问题。", + "recommendation": "删除 console.error 与 fallback,让异常向上传播由 actions 层处理。若确实需要 fallback(如兼容旧 schema),应使用结构化日志并添加监控埋点。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-015", + "file": "src/modules/classes/data-access-admin.ts", + "lines": "L91, L252", + "ruleId": "A-10", + "severity": "P2", + "dimension": "architecture", + "title": "classes/data-access-admin.ts 包含 console.error 调试代码", + "description": "L91 `console.error(\"getAdminClasses primary query failed, falling back:\", error)` 和 L252 `console.error(\"getGradeManagedClasses primary query failed:\", error)`。与 G3-014 类似,主查询失败后执行 fallback 并吞掉异常。", + "recommendation": "同 G3-014,删除 console.error,移除 fallback 或改用结构化日志。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-016", + "file": "src/modules/course-plans/data-access.ts", + "lines": "L167, L202", + "ruleId": "A-10", + "severity": "P2", + "dimension": "architecture", + "title": "course-plans/data-access.ts 包含 console.error 调试代码", + "description": "L167 `console.error(\"getCoursePlans failed:\", error)` 返回空数组;L202 `console.error(\"getCoursePlanById failed:\", error)` 返回 null。两处都吞掉异常,调用方无法区分"无数据"与"查询失败"。", + "recommendation": "删除 try/catch 与 console.error,让异常向上传播。actions 层已有 handleActionError 统一处理。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-017", + "file": "src/modules/classes/data-access-students.ts", + "lines": "L281-L285", + "ruleId": "F-02", + "severity": "P1", + "dimension": "performance", + "title": "getClassStudentsRaw 使用 LIKE '%xxx%' 全表扫描", + "description": "L282-L285:\n```ts\nconst needle = `%${q}%`\nconditions.push(\n sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})`\n)\n```\n`%xxx%` 前缀通配符 LIKE 无法使用 B-Tree 索引,会导致 users 表全表扫描。当 users 表数据量增长(如 10 万学生),此查询性能会急剧下降。同时 LOWER() 函数包裹列也会阻止索引使用。", + "recommendation": "1. 短期:改为前缀匹配 `LIKE ${q}%`(可使用索引),或限制搜索字段为 email(唯一索引)\n2. 中期:为 users.name 与 users.email 添加 FULLTEXT 索引(MySQL)或 pg_trgm 索引(PostgreSQL)\n3. 使用生成的列索引:`ALTER TABLE users ADD COLUMN name_lower VARCHAR(255) GENERATED ALWAYS AS (LOWER(name)) STORED, ADD INDEX idx_name_lower (name_lower)`\n\n```ts\n// 前缀匹配方案(可走索引)\nconst needle = `${q}%`\nconditions.push(\n or(\n like(users.name, needle),\n like(users.email, needle)\n )\n)\n```", + "effort": "M (≤2h)" + }, + { + "id": "G3-018", + "file": "src/modules/scheduling/data-access.ts", + "lines": "L462-L464", + "ruleId": "S-05", + "severity": "P2", + "dimension": "structure", + "title": "getScheduleEntriesForAdminRaw 为死代码(永远返回空数组)", + "description": "L462-L464:\n```ts\nexport async function getScheduleEntriesForAdminRaw(): Promise {\n return []\n}\n```\n函数体只有 `return []`,注释说明"simplified implementation returns an empty array; a real implementation should join classSchedule with classes/users..."。这是未实现的桩函数,但仍被 `cacheFn` 包装并导出,属于 dead code。调用方若依赖此函数将永远拿到空数据,可能导致前端显示异常而无报错。", + "recommendation": "要么完整实现该函数(JOIN classSchedule + classes + users 填充 teacherName/className/subject/room),要么删除该函数及其 cacheFn 包装。若暂不实现,应抛出 `throw new Error(\"Not implemented\")` 而非静默返回空数组。", + "effort": "XS (≤15 分钟) 删除 / M (≤2h) 实现" + }, + { + "id": "G3-019", + "file": "src/modules/classes/data-access-stats.ts", + "lines": "L520-L523", + "ruleId": "F-10", + "severity": "P2", + "dimension": "performance", + "title": "getClassesDashboardStatsRaw 使用 count() 无过滤条件全表统计", + "description": "L521:`db.select({ value: count() }).from(classes)` 没有 WHERE 子句,对 classes 表执行全表 COUNT(*)。虽然 COUNT(*) 在 InnoDB 上仍有性能开销(尤其大表),且此处无任何业务过滤(如按学校、学年、状态过滤),统计的是历史所有班级总数,可能不符合业务预期(如已删除的班级是否应计入?)。", + "recommendation": "添加业务过滤条件:\n```ts\nexport const getClassesDashboardStatsRaw = async (): Promise => {\n const [row] = await db\n .select({ value: count() })\n .from(classes)\n .where(eq(classes.deletedAt, null)) // 若有软删除字段\n // 或按学年过滤:.where(eq(classes.academicYearId, currentAcademicYearId))\n return { classCount: Number(row?.value ?? 0) }\n}\n```\n若确实需要全表统计,考虑使用缓存或物化视图。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-020", + "file": "src/modules/classes/data-access-admin.ts", + "lines": "L42-L186, L193-L316", + "ruleId": "S-03", + "severity": "P2", + "dimension": "structure", + "title": "getAdminClassesRaw 与 getGradeManagedClassesRaw 大量代码重复", + "description": "`getAdminClassesRaw`(L42-L186,145 行)与 `getGradeManagedClassesRaw`(L193-L316,124 行)有大量重复代码:\n- 相同的 select 字段列表(id/schoolName/schoolId/name/grade/gradeId/...)\n- 相同的 groupBy 子句\n- 相同的 orderBy 子句\n- 相同的 try/catch + fallback 逻辑\n- 相同的 subjectsByClassId Map 构建逻辑\n- 相同的 list.map + compareClassLike 排序逻辑\n\n唯一差异:getGradeManagedClasses 多了 `where(inArray(classes.gradeId, gradeIds))` 过滤条件。", + "recommendation": "提取共享 helper:\n```ts\nasync function fetchClassesWithSubjects(\n whereClause?: SQL\n): Promise {\n const [rows, subjectRows] = await Promise.all([\n db.select({...}).from(classes).innerJoin(users, ...).leftJoin(classEnrollments, ...)\n .where(whereClause)\n .groupBy(...).orderBy(...),\n db.select({...}).from(classSubjectTeachers)...\n ])\n // 共享的 Map 构建与排序逻辑\n return list\n}\n\nexport const getAdminClassesRaw = async () => fetchClassesWithSubjects()\nexport const getGradeManagedClassesRaw = async (userId: string) => {\n const gradeIds = await getManagedGradeIds(userId)\n return fetchClassesWithSubjects(inArray(classes.gradeId, gradeIds))\n}\n```", + "effort": "M (≤2h)" + }, + { + "id": "G3-021", + "file": "src/modules/attendance/data-access.ts", + "lines": "L50-L51", + "ruleId": "S-03", + "severity": "P2", + "dimension": "structure", + "title": "serializeDate helper 在 attendance/scheduling 多个文件中重复定义", + "description": "`serializeDate` 函数在以下文件中重复定义,且实现略有差异(返回 \"\" vs null):\n- attendance/data-access.ts L50: `(d: Date | string | null): string => d ? new Date(d).toISOString().slice(0, 10) : \"\"`\n- attendance/data-access-stats.ts L97: 同上(返回 \"\")\n- scheduling/data-access.ts L27: `(d: Date | string | null): string | null => d ? new Date(d).toISOString().slice(0, 10) : null`\n- school/data-access.ts L25: `const toIso = (d: Date): string => d.toISOString()`\n- course-plans/data-access.ts L28-L31: `toIso` + `toIsoRequired` 两个函数\n\nP-07 规则要求日期序列化走 helper,但目前每个模块自定义 helper,违反 S-03(重复 helper 应提取到 shared/lib)。", + "recommendation": "在 `src/shared/lib/date-utils.ts` 统一导出:\n```ts\nexport const toISODateString = (d: Date | string | null): string | null =>\n d ? new Date(d).toISOString().slice(0, 10) : null\n\nexport const toISODateStringOrEmpty = (d: Date | string | null): string =>\n d ? new Date(d).toISOString().slice(0, 10) : \"\"\n\nexport const toISODateTimeString = (d: Date | string | null): string | null =>\n d ? new Date(d).toISOString() : null\n```\n各模块改为 `import { toISODateString } from \"@/shared/lib/date-utils\"`。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-022", + "file": "src/modules/attendance/data-access-correlation.ts", + "lines": "L46-L193", + "ruleId": "A-02", + "severity": "P1", + "dimension": "architecture", + "title": "getAttendanceGradeCorrelationRaw 包含大量业务编排逻辑", + "description": "`getAttendanceGradeCorrelationRaw`(L46-L193,148 行)包含:\n- scope 权限校验(L57-L62):`if (scope && scope.type === \"class_taught\")` / `if (scope && scope.type === \"owned\") return null`\n- 时间范围默认值计算(L70-L77):`DEFAULT_RANGE_DAYS = 90` 天回溯\n- 跨模块数据编排:调用 `getClassNameById`、`getClassActiveStudentsWithInfo`、`getGradeRecords`\n- 成绩归一化计算(L139-L157):`normalized = (r.score / r.fullScore) * 100`、加权平均\n- 考勤率计算(L167-L169)\n- 调用纯函数 `computeCorrelationSummary`(L186-L192)\n\n这是典型的业务编排逻辑,应位于 actions 层或 lib 层,data-access 层应只提供原子查询(如 `getAttendanceStatsByStudent`、`getGradeRecordsByClass`)。", + "recommendation": "拆分职责:\n1. data-access 层:保留 `getAttendanceAggByStudent(classId, startDate, endDate)` 原子查询\n2. lib 层:新建 `correlation-compute.ts`(已存在)存放纯计算逻辑\n3. actions 层:新建 `getAttendanceGradeCorrelationAction`,负责 scope 校验、时间范围计算、跨模块编排、调用纯计算\n\n```ts\n// actions.ts\nexport async function getAttendanceGradeCorrelationAction(classId: string, ...) {\n const ctx = await requirePermission(Permissions.ATTENDANCE_READ)\n // scope 校验\n if (ctx.dataScope.type === \"owned\") return { success: false, message: \"...\" }\n // 编排\n const className = await getClassNameById(classId)\n const students = await getClassActiveStudentsWithInfo(classId)\n const attendanceAgg = await getAttendanceAggByStudent(classId, ...)\n const gradeRecords = await getGradeRecords({ classId, ... })\n // 计算纯函数\n const summary = computeCorrelationSummary(...)\n return { success: true, data: summary }\n}\n```", + "effort": "L (≤1d)" + }, + { + "id": "G3-023", + "file": "src/modules/attendance/data-access-correlation.ts", + "lines": "L145", + "ruleId": "F-01", + "severity": "P2", + "dimension": "performance", + "title": "correlation 模块使用 Array.includes 进行 O(n*m) 查找", + "description": "L145:`if (!studentIds.includes(r.studentId)) continue` 在 `for (const r of filteredGradeRecords)` 循环内。若 studentIds 有 N 个学生,filteredGradeRecords 有 M 条成绩记录,则此处为 O(N*M) 复杂度。虽然 N 通常较小(< 100),但 M 可能较大(多年成绩记录),应使用 Set 优化。", + "recommendation": "```ts\nconst studentIdSet = new Set(studentIds)\nfor (const r of filteredGradeRecords) {\n if (!studentIdSet.has(r.studentId)) continue\n // ...\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-024", + "file": "src/modules/classes/data-access-teacher.ts", + "lines": "L284-L439", + "ruleId": "A-02", + "severity": "P1", + "dimension": "architecture", + "title": "enrollTeacherByInvitationCode 包含复杂业务状态机逻辑", + "description": "`enrollTeacherByInvitationCode`(L284-L439,155 行)包含:\n- 教师身份校验(L320-L328)\n- 邀请码校验(L331-L335)\n- 班级归属校验(L337-L343)\n- 科目查找与分配逻辑(L346-L431):\n - 已分配科目冲突检测(L365 `throw new Error(\"Subject already assigned\")`)\n - 自动选择首选科目(L401 `DEFAULT_CLASS_SUBJECTS.find`)\n - 多次 SELECT + INSERT + UPDATE 实现教师-科目绑定状态机\n- 邀请码消耗(L433-L436)\n\n这是典型的业务状态机,应位于 actions 层。data-access 层应只提供 `insertClassSubjectTeacher`、`updateClassSubjectTeacher`、`getClassSubjectTeacher` 等原子操作。", + "recommendation": "将 enrollTeacherByInvitationCode 拆分:\n1. data-access 层:提供 `getTeacherExistingAssignment(classId, teacherId)`、`assignTeacherToSubject(classId, subjectId, teacherId)`、`findUnassignedSubject(classId)` 等原子函数\n2. actions 层:`enrollTeacherByInvitationCodeAction` 编排校验、状态机、调用原子函数、包裹事务\n3. 整个流程应用 `db.transaction` 包裹,确保邀请码消耗与教师分配原子性", + "effort": "L (≤1d)" + }, + { + "id": "G3-025", + "file": "src/modules/classes/data-access-teacher.ts", + "lines": "L284-L439", + "ruleId": "F-09", + "severity": "P1", + "dimension": "performance", + "title": "enrollTeacherByInvitationCode 多次写操作未包裹事务", + "description": "`enrollTeacherByInvitationCode` 内部执行多次写操作:\n- L368-L372 `db.insert(classSubjectTeachers).values(...).onDuplicateKeyUpdate(...)`\n- L382-L385 `db.update(classSubjectTeachers).set({ teacherId: tid })...`\n- L407-L416 `db.update(classSubjectTeachers).set({ teacherId: tid })...`\n- L304 `consumeInvitationCode(code)`(内部 UPDATE)\n\n这些写操作未包裹在事务中。若中间失败(如 consumeInvitationCode 失败),教师已被分配到科目但邀请码未消耗,导致数据不一致(邀请码可被重复使用)。", + "recommendation": "```ts\nexport async function enrollTeacherByInvitationCode(...): Promise {\n // 校验逻辑...\n return await db.transaction(async (tx) => {\n // 所有写操作使用 tx\n await tx.insert(classSubjectTeachers).values(...)\n await tx.update(classSubjectTeachers).set(...)\n if (result.codeId) {\n await tx.update(classInvitationCodes).set({ usedCount: sql`${classInvitationCodes.usedCount} + 1` })...\n }\n return cls.id\n })\n}\n```", + "effort": "M (≤2h)" + }, + { + "id": "G3-026", + "file": "src/modules/scheduling/data-access.ts", + "lines": "L53, L69, L294", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "scheduling/data-access.ts 多处使用 db.select() 未指定列", + "description": "以下查询使用 `db.select().from(table)` 返回所有列:\n- L53 `db.select().from(schedulingRules)` (getSchedulingRulesRaw)\n- L69 `db.select().from(schedulingRules)` (upsertSchedulingRules 内部查询)\n- L294 `db.select({ id: classes.id, name: classes.name, ... })` - 此处已指定列 ✓\n\nschedulingRules 表可能包含较多字段(classId、maxDailyHours、maxContinuousHours、lunchBreakStart、lunchBreakEnd、morningStart、afternoonEnd、avoidBackToBack、balancedSubjects、createdAt、updatedAt),SELECT * 会返回所有字段。", + "recommendation": "显式指定所需列:\n```ts\nconst rows = await db\n .select({\n id: schedulingRules.id,\n classId: schedulingRules.classId,\n maxDailyHours: schedulingRules.maxDailyHours,\n // ... 其他所需字段\n })\n .from(schedulingRules)\n .where(...)\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-027", + "file": "src/modules/attendance/data-access.ts", + "lines": "L314, L341", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "attendance/data-access.ts 多处使用 db.select() 未指定列", + "description": "L314 `db.select().from(attendanceRules)` (getAttendanceRulesRaw) 和 L341 `db.select().from(attendanceRules)` (upsertAttendanceRules 内部) 使用 SELECT *。attendanceRules 表字段较多(classId、lateThresholdMinutes、earlyLeaveThresholdMinutes、enableAutoMark、attendanceRateThreshold、consecutiveAbsenceThreshold、createdAt、updatedAt),返回全部字段会增加开销。", + "recommendation": "显式指定所需列,同 G3-026 建议。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-028", + "file": "src/modules/course-plans/data-access.ts", + "lines": "L159, L183, L192, L366, L374, L450, L461", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "course-plans/data-access.ts 多处使用 db.select() 未指定列", + "description": "以下 7 处查询使用 `db.select().from(table)` 返回所有列:\n- L159 `db.select().from(coursePlans)` (getCoursePlansRaw)\n- L183 `db.select().from(coursePlans)` (getCoursePlanByIdRaw)\n- L192 `db.select().from(coursePlanItems)` (getCoursePlanByIdRaw 内部)\n- L366 `db.select().from(coursePlans)` (copyCoursePlanToClasses 内部)\n- L374 `db.select().from(coursePlanItems)` (copyCoursePlanToClasses 内部)\n- L450 `db.select().from(coursePlans)` (getGradeCoursePlanProgressRaw)\n- L461 `db.select().from(coursePlanItems)` (getGradeCoursePlanProgressRaw)\n\ncoursePlans 表字段较多(id、classId、subjectId、teacherId、academicYearId、semester、totalHours、completedHours、weeklyHours、startDate、endDate、syllabus、objectives、status、createdBy、createdAt、updatedAt),全量返回会增加网络与内存开销。", + "recommendation": "显式指定所需列。对于 copyCoursePlanToClasses 等需要全字段的场景,可保留 SELECT * 但添加注释说明。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-029", + "file": "src/modules/classes/data-access-admin.ts", + "lines": "L36-L40", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "使用 as 断言将 DEFAULT_CLASS_SUBJECTS 转为 readonly string[]", + "description": "L36-L40:\n```ts\nconst isClassSubject = (v: unknown): v is ClassSubject =>\n typeof v === \"string\" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)\n```\n`DEFAULT_CLASS_SUBJECTS as readonly string[]` 是类型断言(从具体元组类型 widening 为 readonly string[])。虽然这是 widening 断言(比 narrowing 安全),但仍违反 P-09 规则(禁止 as 断言)。`.includes(v)` 需要 `readonly string[]` 类型参数,而 DEFAULT_CLASS_SUBJECTS 可能是 `readonly [\"语文\", \"数学\", ...]` 元组类型。", + "recommendation": "改用类型安全的方式:\n```ts\nconst CLASS_SUBJECTS_READONLY: readonly string[] = DEFAULT_CLASS_SUBJECTS\nconst isClassSubject = (v: unknown): v is ClassSubject =>\n typeof v === \"string\" && CLASS_SUBJECTS_READONLY.includes(v)\n```\n或在 types.ts 中将 DEFAULT_CLASS_SUBJECTS 类型显式标注为 `readonly string[]`。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-030", + "file": "src/modules/classes/data-access-teacher.ts", + "lines": "L41", + "ruleId": "P-09", + "severity": "P3", + "dimension": "pattern", + "title": "使用 as 断言将 DEFAULT_CLASS_SUBJECTS 转为 readonly string[]", + "description": "L41:`typeof v === \"string\" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)`,与 G3-029 相同的 as 断言模式。", + "recommendation": "同 G3-029。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-031", + "file": "src/modules/classes/data-access.ts", + "lines": "L83-L92", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getAccessibleClassIdsForTeacher 等多个查询无 LIMIT 保护", + "description": "`getAccessibleClassIdsForTeacher`(L83-L92)查询教师所有可访问班级 ID,无 LIMIT。其他无 LIMIT 的查询:\n- getStudentIdsByClassId(L147-L153)\n- getStudentIdsByClassIds(L159-L166)\n- getTeacherIdsByClassIds(L208-L228)\n- getClassesByGradeId(L352-L359)\n- getClassIdsByGradeIds(L365-L373)\n- getClassNamesByIds(L334-L346)\n\n虽然班级数量通常有限(< 100),但若数据异常增长(如测试数据、迁移错误),可能导致一次查询返回大量数据。", + "recommendation": "为可能返回大量数据的查询添加默认 LIMIT:\n```ts\nexport const getStudentIdsByClassIds = async (classIds: string[]): Promise => {\n if (classIds.length === 0) return []\n const rows = await db\n .select({ studentId: classEnrollments.studentId })\n .from(classEnrollments)\n .where(inArray(classEnrollments.classId, classIds))\n .limit(10000) // 安全上限\n return Array.from(new Set(rows.map((r) => r.studentId)))\n}\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-032", + "file": "src/modules/classes/data-access-admin.ts", + "lines": "L42-L186", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getAdminClassesRaw 无 LIMIT,可能返回全量班级数据", + "description": "`getAdminClassesRaw` 查询所有班级(无 WHERE、无 LIMIT),并 LEFT JOIN classEnrollments 计算学生数。若系统有 1000+ 班级,此查询会返回 1000+ 行,每行还包含聚合计算,性能压力大。同样问题存在于 `getGradeManagedClassesRaw`(L193-L316)和 `getTeacherClassesRaw`(classes/data-access-teacher.ts L46-L119)。", + "recommendation": "添加分页参数或默认 LIMIT:\n```ts\nexport const getAdminClassesRaw = async (params?: { limit?: number; offset?: number }): Promise => {\n const limit = Math.min(params?.limit ?? 200, 500)\n const offset = params?.offset ?? 0\n // 查询添加 .limit(limit).offset(offset)\n}\n```\n前端列表应实现分页或虚拟滚动。", + "effort": "M (≤2h)" + }, + { + "id": "G3-033", + "file": "src/modules/scheduling/data-access-class-schedule.ts", + "lines": "L72-L81, L147-L151", + "ruleId": "A-09", + "severity": "P2", + "dimension": "architecture", + "title": "data-access-class-schedule.ts 直接查询 classSchedule 表(应走 scheduling/data-access.ts 统一入口)", + "description": "L72-L81 `updateClassScheduleItem` 内部直接查询 `db.select({...}).from(classSchedule).where(eq(classSchedule.id, id))`,L147-L151 `deleteClassScheduleItem` 内部同样直接查询 classSchedule 表。虽然 classSchedule 是 scheduling 模块的表,但 scheduling/data-access.ts 已提供了 `insertClassScheduleItem`、`updateClassScheduleItemById`、`deleteClassScheduleItemById` 统一写入入口(L343-L410)。当前文件绕过这些入口直接查询,导致查询逻辑分散在两个文件中,维护困难。", + "recommendation": "将 L72-L81 的查询逻辑移至 scheduling/data-access.ts,新增 `getClassScheduleItemById(id)` 函数:\n```ts\n// scheduling/data-access.ts\nexport async function getClassScheduleItemById(id: string) {\n const [row] = await db.select({...}).from(classSchedule).where(eq(classSchedule.id, id)).limit(1)\n return row ?? null\n}\n```\ndata-access-class-schedule.ts 调用此函数,或直接删除该文件将逻辑合并到 actions-schedule.ts(见 G3-003)。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-034", + "file": "src/modules/scheduling/data-access.ts", + "lines": "L108-L172", + "ruleId": "F-06", + "severity": "P2", + "dimension": "performance", + "title": "getScheduleChangesRaw 内部二次查询 users 表(可与主查询合并)", + "description": "L140-L146:在主查询(JOIN classes + LEFT JOIN users)后,又对 users 表执行第二次查询 `db.select({ id, name }).from(users).where(inArray(users.id, userIds))` 来解析 substituteTeacher/approver/requester 姓名。虽然这是为了避免 JOIN 歧义,但若 scheduleChanges 数据量大(如 100 条变更),userIds 可能只有 5-10 个,二次查询开销可控。然而,此模式可通过 cacheFn 缓存 getUserNamesByIds 来优化。", + "recommendation": "改为调用 users 模块 data-access:\n```ts\nimport { getUserNamesByIds } from \"@/modules/users/data-access\"\n// 替代直接查询 users 表\nconst userMap = await getUserNamesByIds(userIds)\n```\n这样既符合架构规则 A-06,又能利用 users data-access 的 cacheFn 缓存。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-035", + "file": "src/modules/attendance/data-access-stats.ts", + "lines": "L53-L67", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "computeStats 纯计算函数导出在 data-access 文件中", + "description": "L53-L67 `export const computeStats = (rows: { status: string }[]): AttendanceStats => {...}` 是纯计算函数(无 DB 访问、无 IO),但定义并导出自 data-access-stats.ts。这违反职责分层:纯计算函数应位于 lib/ 或 compute/ 目录。同文件还有 `statsFromAggregate`(L72-L95)也是纯函数但未导出(private)。", + "recommendation": "将 computeStats 移至 `src/modules/attendance/lib/stats-compute.ts` 或 `src/modules/attendance/stats-compute.ts`(与现有 `correlation-compute.ts`、`trend-compute.ts`、`warning-compute.ts` 同级):\n```ts\n// attendance/stats-compute.ts\nexport const computeStats = (rows: { status: string }[]): AttendanceStats => {...}\nexport const statsFromAggregate = (row: {...}): AttendanceStats => {...}\n```\ndata-access-stats.ts 改为 import 调用。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-036", + "file": "src/modules/school/data-access.ts", + "lines": "L92-L149, L178-L232, L324-L408", + "ruleId": "S-03", + "severity": "P2", + "dimension": "structure", + "title": "getGradesRaw / getGradesForStaffRaw / getGradesForUserRaw(teacher 分支) 三处重复查询逻辑", + "description": "三个函数都执行类似的 grades INNER JOIN schools 查询,并解析 gradeHead/teachingHead 姓名:\n- getGradesRaw(L92-L149):全量查询\n- getGradesForStaffRaw(L178-L232):按 staffId 过滤\n- getGradesForUserRaw 的 teacher 分支(L356-L400):按 gradeIds 过滤\n\n三处的 select 字段列表、headIds 收集、headById Map 构建、rows.map 返回逻辑几乎完全相同(每处约 30 行重复)。", + "recommendation": "提取共享 helper:\n```ts\nasync function fetchGradesWithHeads(whereClause?: SQL): Promise {\n const rows = await db.select({...}).from(grades).innerJoin(schools, ...).where(whereClause).orderBy(...)\n const headIds = Array.from(new Set(rows.flatMap(r => [r.gradeHeadId, r.teachingHeadId]).filter(...)))\n const heads = headIds.length ? await db.select({...}).from(users).where(inArray(users.id, headIds)) : []\n const headById = new Map(heads.map(u => [u.id, {...}]))\n return rows.map(r => ({...}))\n}\n\nexport const getGradesRaw = async () => fetchGradesWithHeads()\nexport const getGradesForStaffRaw = async (staffId: string) =>\n fetchGradesWithHeads(or(eq(grades.gradeHeadId, staffId), eq(grades.teachingHeadId, staffId)))\n```", + "effort": "M (≤2h)" + }, + { + "id": "G3-037", + "file": "src/modules/classes/data-access.ts", + "lines": "L18-L20", + "ruleId": "P-07", + "severity": "P3", + "dimension": "pattern", + "title": "getSessionTeacherId 内部使用动态 import 加载 auth 模块", + "description": "L18 `const { auth } = await import(\"@/auth\")` 使用动态 import 加载 auth 模块。虽然这可能是为了避免循环依赖,但动态 import 在 TypeScript 类型推断与打包分析上不如静态 import。此外,auth 模块导入应位于文件顶部,除非有明确的循环依赖问题。", + "recommendation": "若不存在循环依赖,改为静态 import:\n```ts\nimport { auth } from \"@/auth\"\n```\n若存在循环依赖,保留动态 import 但添加注释说明原因:\n```ts\n// 动态 import 避免 classes ↔ auth 循环依赖\nconst { auth } = await import(\"@/auth\")\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-038", + "file": "src/modules/classes/data-access-invitations.ts", + "lines": "L254-L307", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "validateInvitationCode 包含懒清理业务逻辑", + "description": "`validateInvitationCode`(L254-L307)除了校验邀请码有效性外,还包含"懒清理"业务逻辑:\n- L266-L272:发现邀请码已过期时,主动 UPDATE status 为 'expired'\n- L274-L284:发现邀请码已用尽时,主动 UPDATE status 为 'exhausted'\n- L294-L304:fallback 到旧格式 6 位数字码(classes.invitationCode)\n\n这是业务状态机逻辑(状态迁移:active → expired/exhausted),应位于 actions 层或独立的清理逻辑中,而非 data-access 层的校验函数内。校验函数应只读,状态迁移应显式调用。", + "recommendation": "拆分职责:\n1. `validateInvitationCode` 只做校验,返回 `{ valid, classId, codeId, status, needsCleanup: true }`\n2. 调用方(actions)根据 needsCleanup 决定是否调用 `markInvitationCodeExpired(codeId)` 或 `markInvitationCodeExhausted(codeId)`\n3. 懒清理逻辑可作为独立函数 `cleanupExpiredCodes()` 由定时任务调用\n\n或保留当前实现但在 JSDoc 中明确标注"此函数有副作用:会更新过期/用尽的邀请码状态"。", + "effort": "M (≤2h)" + }, + { + "id": "G3-039", + "file": "src/modules/classes/data-access.ts", + "lines": "L1-L406", + "ruleId": "S-02", + "severity": "P3", + "dimension": "structure", + "title": "classes/data-access.ts 导出 25+ 函数,超过 20 个警告阈值", + "description": "文件导出函数清单:getSessionTeacherId、getTeacherIdForMutations、getClassSubjects、compareClassLike、getAccessibleClassIdsForTeacher、verifyTeacherOwnsClass、getClassGradeIdsByClassIds、getTeacherSubjectIdsForClass、getClassTeacherById、getStudentIdsByClassId、getStudentIdsByClassIds、getActiveStudentIdsByClassId、getClassActiveStudentsWithInfo、getTeacherSubjectIdsByClass、getTeacherIdsByClassIds、getStudentActiveClassId、getStudentActiveClass、getStudentActiveGradeId、getClassExists、getClassNameById、getClassGradeId、getGradeIdsByClassIds、getClassNamesByIds、getClassesByGradeId、getClassIdsByGradeIds、getClassIdsByGradeIdsSubquery(26 个)。此外还有 `export * from \"./data-access-stats\"` 等 6 个 re-export,实际导出函数总数达 50+。", + "recommendation": "按职责拆分为多个文件:\n- data-access.ts(主入口,re-export)\n- data-access-teacher-scope.ts(getSessionTeacherId、getAccessibleClassIdsForTeacher、verifyTeacherOwnsClass、getTeacherScopeData)\n- data-access-class-queries.ts(getClassExists、getClassNameById、getClassGradeId、getClassNamesByIds、getClassesByGradeId 等)\n- data-access-student-queries.ts(getStudentIdsByClassId、getStudentActiveClass、getStudentActiveGradeId 等)\n- data-access-helpers.ts(compareClassLike、normalizeSortText 等纯函数)", + "effort": "M (≤2h)" + }, + { + "id": "G3-040", + "file": "src/modules/attendance/data-access.ts", + "lines": "L106-L168", + "ruleId": "F-06", + "severity": "P2", + "dimension": "performance", + "title": "getAttendanceRecordsRaw 每次分页查询都重复调用 getUserNamesByIds/getClassNamesByIds", + "description": "`getAttendanceRecordsRaw` 在每次分页查询时(L148-L152)都调用 `getUserNamesByIds(studentIds)`、`getClassNamesByIds(classIds)`、`resolveRecorderNames(rows)` 解析姓名。虽然这些函数内部可能有 cacheFn 缓存,但每页的 studentIds/classIds 可能高度重叠(如同一班级的不同页记录),缓存命中率取决于 TTL 与 keyParts。对于高频分页场景(如教师翻页查看考勤记录),这可能产生重复查询。", + "recommendation": "1. 确保 getUserNamesByIds 与 getClassNamesByIds 已使用 cacheFn 包装(若未包装,参见 G3-001)\n2. 考虑在前端缓存姓名映射,避免每次翻页都重新解析\n3. 对于 recorderName,可在 INSERT 时冗余存储 recordedByName 字段,避免每次查询都 JOIN(反范式优化)", + "effort": "M (≤2h)" + }, + { + "id": "G3-041", + "file": "src/modules/proctoring/data-access.ts", + "lines": "L113-L171", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getProctoringEventsRaw 查询无 LIMIT,可能返回大量事件", + "description": "`getProctoringEventsRaw`(L113-L171)查询某场考试的所有监考事件,无 LIMIT。若考试持续 2 小时,30 个学生每个产生 50+ 事件,总事件数可能达 1500+。一次性返回所有事件会导致内存压力与网络延迟。虽然有 `getRecentProctoringEvents` 函数(L398-L429)提供 LIMIT 版本,但 getProctoringEvents 本身无保护。", + "recommendation": "添加默认 LIMIT 或分页参数:\n```ts\nexport const getProctoringEventsRaw = async (\n examId: string,\n filters?: GetProctoringEventsFilters & { limit?: number; offset?: number },\n): Promise => {\n const limit = Math.min(filters?.limit ?? 500, 1000)\n const offset = filters?.offset ?? 0\n // 查询添加 .limit(limit).offset(offset)\n}\n```\n前端面板应优先使用 getRecentProctoringEvents(默认 20 条),完整列表走分页。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-042", + "file": "src/modules/classes/data-access-invitations.ts", + "lines": "L127-L138, L146-L157", + "ruleId": "F-01", + "severity": "P2", + "dimension": "performance", + "title": "generateUniqueInvitationCode / generateUniqueCode 循环内查询 DB(N+1 重试模式)", + "description": "`generateUniqueInvitationCode`(L127-L138)和 `generateUniqueCode`(L146-L157)都使用 for 循环最多 40 次重试,每次循环内执行 `db.select(...).where(eq(classes.invitationCode, code)).limit(1)` 查询 DB 检查码是否已存在。虽然正常情况下 1-2 次就能成功(碰撞概率低),但最坏情况下 40 次 DB 查询。这种模式无法批量化(每次生成的码随机),但可通过 INSERT 失败捕获唯一约束错误来优化。", + "recommendation": "改为"先生成再 INSERT,捕获唯一约束错误"模式:\n```ts\nexport async function generateUniqueInvitationCode(): Promise {\n for (let attempt = 0; attempt < 40; attempt += 1) {\n const code = generateInvitationCode()\n try {\n // 直接尝试 INSERT 一个临时记录或使用 SELECT FOR UPDATE 检查\n // 更优:直接在调用方 INSERT 时捕获 duplicate 错误\n return code\n } catch (err) {\n if (isDuplicateInvitationCodeError(err)) continue\n throw err\n }\n }\n throw new Error(\"Failed to generate invitation code\")\n}\n```\n或保留当前模式但将 40 次重试降为 5 次(碰撞概率极低,5 次足够)。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-043", + "file": "src/modules/scheduling/data-access.ts", + "lines": "L213-L244", + "ruleId": "F-01", + "severity": "P2", + "dimension": "performance", + "title": "getClassConflictsRaw 使用 O(n²) 双重循环比较课表项", + "description": "`getClassConflictsRaw`(L213-L244)查询班级所有课表项后,使用双重循环 `for (let i = 0; i < rows.length; i++) { for (let j = i + 1; j < rows.length; j++) {...} }` 检测时间冲突。若班级有 N 个课表项,比较次数为 N*(N-1)/2。虽然 N 通常较小(< 50),但可优化为 O(N) 的扫描线算法。", + "recommendation": "优化为按 weekday 分组 + 排序后单次扫描:\n```ts\nexport async function getClassConflictsRaw(classId: string): Promise {\n const rows = await db.select({...}).from(classSchedule).where(eq(classSchedule.classId, classId)).orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))\n const conflicts: ScheduleConflict[] = []\n // 按 weekday 分组\n const byWeekday = new Map()\n for (const r of rows) {\n const list = byWeekday.get(r.weekday) ?? []\n list.push(r)\n byWeekday.set(r.weekday, list)\n }\n // 每个 weekday 内已按 startTime 排序,只需比较相邻项\n for (const [weekday, items] of byWeekday) {\n for (let i = 0; i < items.length - 1; i++) {\n const a = items[i]\n const b = items[i + 1]\n if (a && b && a.startTime < b.endTime && b.startTime < a.endTime) {\n conflicts.push({...})\n }\n }\n }\n return conflicts\n}\n```\n注意:相邻比较只能检测相邻冲突,若需检测所有重叠仍需 O(n²),但可先用排序+早退优化。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-044", + "file": "src/modules/attendance/data-access-stats.ts", + "lines": "L299-L307", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getClassAttendanceWarningsRaw 查询无 LIMIT,可能返回大量考勤记录", + "description": "`getClassAttendanceWarningsRaw`(L299-L307)查询班级在时间范围内的所有考勤记录(select studentId, date, status),无 LIMIT。若时间范围跨 1 学期(约 100 天),30 学生每天 1 条记录,总记录数达 3000+。全部加载到内存按 studentId 聚合,内存压力大。", + "recommendation": "改为 SQL 聚合查询(GROUP BY studentId),避免拉全量记录:\n```ts\nconst rows = await db\n .select({\n studentId: attendanceRecords.studentId,\n total: count(),\n present: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,\n // ... 其他状态统计\n })\n .from(attendanceRecords)\n .where(where)\n .groupBy(attendanceRecords.studentId)\n```\n连续缺勤检测若需要日期序列,可单独查询有 absent 记录的日期,而非全量加载。", + "effort": "M (≤2h)" + }, + { + "id": "G3-045", + "file": "src/modules/classes/data-access-teacher.ts", + "lines": "L1-L631", + "ruleId": "S-01", + "severity": "P2", + "dimension": "structure", + "title": "classes/data-access-teacher.ts 631 行,接近 800 行警告阈值", + "description": "文件 631 行,已超过 500 行组件建议上限(虽 data-access 建议 ≤ 800 行,但仍偏高)。文件包含:教师班级查询、教师选项查询、教师科目查询、班级 CRUD(createTeacherClass、updateTeacherClass、deleteTeacherClass)、邀请码管理(ensureClassInvitationCode、regenerateClassInvitationCode)、学生注册(enrollStudentByInvitationCode、enrollTeacherByInvitationCode、enrollStudentByEmail)、科目教师分配(setClassSubjectTeachers)、DataScope 辅助(getTeacherScopeData)。职责过多。", + "recommendation": "进一步拆分:\n- data-access-teacher-queries.ts(getTeacherClasses、getTeacherOptions、getTeacherTeachingSubjects、getTeacherScopeData)\n- data-access-teacher-mutations.ts(createTeacherClass、updateTeacherClass、deleteTeacherClass、setClassSubjectTeachers)\n- data-access-teacher-enrollment.ts(enrollStudentByInvitationCode、enrollTeacherByInvitationCode、enrollStudentByEmail、setStudentEnrollmentStatus)\n- data-access-teacher-invitations.ts(ensureClassInvitationCode、regenerateClassInvitationCode)", + "effort": "M (≤2h)" + }, + { + "id": "G3-046", + "file": "src/modules/classes/data-access-stats.ts", + "lines": "L126-L277", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "getClassHomeworkInsightsRaw 151 行,缺少详细 JSDoc 说明返回结构与分支逻辑", + "description": "`getClassHomeworkInsightsRaw`(L126-L277)是复杂的聚合函数,包含:教师归属判断(homeroom vs subject teacher)、活跃学生筛选、作业查询、提交解析、统计计算。函数仅有简短 JSDoc `cacheFn(getClassHomeworkInsightsRaw, {...})`,未说明:\n- 返回的 ClassHomeworkInsights 结构字段含义\n- isHomeroomTeacher 分支与 subjectIdFilter 分支的区别\n- 当 subjectIdFilter 为空且非 homeroom teacher 时的早返回逻辑\n- latest/overallScores 的计算方式\n\n同样问题存在于 `getGradeHomeworkInsightsRaw`(L290-L509,219 行)。", + "recommendation": "补充详细 JSDoc:\n```ts\n/**\n * 获取班级作业洞察汇总。\n *\n * 权限分支:\n * - 班主任(homeroom teacher):返回所有科目的作业统计\n * - 任课教师(subject teacher):仅返回其所教科目的作业统计\n *\n * 返回结构:\n * - class: 班级基本信息\n * - studentCounts: 活跃/非活跃学生数\n * - assignments: 各作业的提交/批改/分数统计\n * - latest: 最近一次作业统计\n * - overallScores: 所有作业分数汇总\n *\n * @param params.classId 班级 ID\n * @param params.teacherId 教师 ID(默认从 session 获取)\n * @param params.limit 作业数量上限(默认 50)\n */\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-047", + "file": "src/modules/course-plans/data-access.ts", + "lines": "L1-L530", + "ruleId": "P-07", + "severity": "P3", + "dimension": "pattern", + "title": "course-plans/data-access.ts 自定义 toIso/toIsoRequired 而非使用 shared helper", + "description": "L28-L31 定义了 `toIso` 和 `toIsoRequired` 两个日期序列化函数,与 attendance/scheduling 模块的 `serializeDate` 功能重叠。P-07 规则要求日期序列化走 helper,但每个模块自定义导致行为不一致(返回 null vs \"\" vs undefined)。", + "recommendation": "参见 G3-021,统一使用 `@/shared/lib/date-utils` 中的 helper。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G3-048", + "file": "src/modules/classes/data-access.ts", + "lines": "L384-L389", + "ruleId": "S-08", + "severity": "P3", + "dimension": "structure", + "title": "data-access.ts 通过 export * re-export 6 个子文件,职责边界模糊", + "description": "L384-L389:\n```ts\nexport * from \"./data-access-stats\"\nexport * from \"./data-access-schedule\"\nexport * from \"./data-access-students\"\nexport * from \"./data-access-admin\"\nexport * from \"./data-access-invitations\"\nexport * from \"./data-access-teacher\"\n```\n主文件通过 `export *` 聚合 6 个子文件的导出,导致:\n1. 单一导入路径 `@/modules/classes/data-access` 暴露 50+ 函数,职责边界模糊\n2. 无法 tree-shake(即使只用了 getClassNamesByIds,也会加载所有子模块)\n3. 命名冲突风险(若两个子文件导出同名函数,ES 模块语义下后者覆盖前者,且无警告)\n4. 文件头部注释(L391-L406)提到曾有 `getTeacherScopeData` 重复定义问题,正是 export * 的风险体现", + "recommendation": "改为显式 re-export:\n```ts\nexport { getClassHomeworkInsights, getGradeHomeworkInsights, getClassesDashboardStats } from \"./data-access-stats\"\nexport { getStudentSchedule, getClassSchedule, getClassIdByScheduleId } from \"./data-access-schedule\"\nexport { getStudentClasses, getClassStudents, getStudentScopeData } from \"./data-access-students\"\n// ... 其他子文件\n```\n这样可避免命名冲突,且便于 IDE 跳转追踪。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G3-049", + "file": "src/modules/attendance/data-access-correlation.ts", + "lines": "L121-L137", + "ruleId": "F-08", + "severity": "P2", + "dimension": "performance", + "title": "correlation 模块跨模块调用 getGradeRecords 后内存过滤时间范围(非最优)", + "description": "L125-L129 调用 `getGradeRecords({ classId, scope, limit: 100 })` 获取成绩记录,注释说明"getGradeRecords 不直接支持 createdAt 范围筛选,因此这里传入 classId + scope,后续在内存中按 createdAt 过滤时间范围"。这意味着:\n1. DB 返回 100 条记录(可能大部分不在时间范围内)\n2. 内存中再过滤,效率低\n3. LIMIT 100 可能截断有效记录(若 100 条都是旧记录,时间范围内可能 0 条)\n\n这是跨模块 data-access 接口能力不足导致的性能问题。", + "recommendation": "在 grades 模块 data-access 中扩展 `getGradeRecords` 支持时间范围筛选:\n```ts\n// grades/data-access.ts\nexport async function getGradeRecords(params: {\n classId?: string\n scope: DataScope\n limit?: number\n startDate?: string // 新增\n endDate?: string // 新增\n}) {\n // WHERE 条件添加 createdAt 范围过滤\n}\n```\n这样 attendance 模块可直接调用并让 DB 过滤,避免内存过滤。", + "effort": "M (≤2h)" + }, + { + "id": "G3-050", + "file": "src/modules/classes/data-access-teacher.ts", + "lines": "L538-L570", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "enrollStudentByEmail 包含身份校验与角色验证业务逻辑", + "description": "`enrollStudentByEmail`(L538-L570)包含:\n- 教师归属校验(L543-L549)\n- 学生邮箱查询(L551-L555)\n- 学生角色校验(L558-L564):查询 usersToRoles JOIN roles 确认用户是学生\n- 注册写入(L566-L569)\n\n角色校验是业务逻辑,应位于 actions 层。data-access 层应提供 `getUserByEmail`、`getUserRole` 等原子查询,由 actions 编排。", + "recommendation": "将角色校验移至 actions 层:\n```ts\n// actions-invitations.ts\nexport async function enrollStudentByEmailAction(classId, email) {\n const ctx = await requirePermission(Permissions.CLASS_ENROLL)\n // 归属校验\n const owns = await verifyTeacherOwnsClass(classId, ctx.userId)\n if (!owns) return { success: false, message: \"...\" }\n // 查询学生\n const student = await getUserByEmail(email)\n if (!student) return { success: false, message: \"Student not found\" }\n // 角色校验\n const isStudent = await hasRole(student.id, ROLE_NAMES.STUDENT)\n if (!isStudent) return { success: false, message: \"User is not a student\" }\n // 注册\n await enrollStudent(classId, student.id)\n return { success: true }\n}\n```", + "effort": "M (≤2h)" + } +] diff --git a/docs/architecture/audit/g4-audit-output.json b/docs/architecture/audit/g4-audit-output.json new file mode 100644 index 0000000..75a8405 --- /dev/null +++ b/docs/architecture/audit/g4-audit-output.json @@ -0,0 +1,734 @@ +[ + { + "id": "G4-001", + "file": "src/modules/messaging/data-access.ts", + "lines": "L1-L1089", + "ruleId": "S-01", + "severity": "P0", + "dimension": "structure", + "title": "messaging/data-access.ts 超 1000 行硬性上限", + "description": "文件总长 1089 行,违反项目硬性规则「任何文件不超过 1000 行,超过必须拆分」。文件混合了消息 CRUD、群发、撤回、举报、屏蔽、草稿、模板、附件 8 类职责。", + "recommendation": "按职责拆分为:(1) data-access-messages.ts(消息 CRUD + 线程);(2) data-access-group.ts(群发 sendGroupMessage);(3) data-access-recall.ts(撤回 + 批量操作);(4) data-access-reports.ts(举报 + 屏蔽);(5) data-access-drafts.ts(草稿 CRUD);(6) data-access-templates.ts(模板 CRUD);(7) data-access-recipients.ts(收件人解析器)。原 data-access.ts 仅作 barrel re-export。", + "effort": "L (≤1d)" + }, + { + "id": "G4-002", + "file": "src/modules/parent/", + "lines": "—", + "ruleId": "A-08", + "severity": "P0", + "dimension": "architecture", + "title": "parent 模块缺失 actions.ts,data-access 被 app/ 直接引用", + "description": "parent 模块目录下只有 data-access.ts 与 types.ts,无 actions.ts。Grep 证实 src/app/(dashboard)/parent/children/[studentId]/page.tsx、parent/leave/page.tsx、parent/elective/page.tsx 三处页面直接 import @/modules/parent/data-access,绕过 Server Action 层与 requirePermission 校验。getParentDashboardData / getChildDashboardData / getChildren 等敏感数据查询无任何权限校验。", + "recommendation": "新建 src/modules/parent/actions.ts,为每个对外暴露的读函数包装 Server Action:\n```ts\n\"use server\"\nimport { requirePermission } from \"@/shared/lib/auth-guard\"\nimport { Permissions } from \"@/shared/types/permissions\"\nimport { getParentDashboardData } from \"./data-access\"\nexport async function getParentDashboardDataAction() {\n const ctx = await requirePermission(Permissions.PARENT_VIEW)\n return getParentDashboardData(ctx.userId)\n}\n```\n3 个页面改为调用 Action。", + "effort": "M (≤2h)" + }, + { + "id": "G4-003", + "file": "src/modules/audit/actions.ts", + "lines": "L192-L225", + "ruleId": "A-08", + "severity": "P0", + "dimension": "architecture", + "title": "purgeAuditLogsAction 使用 AUDIT_LOG_READ 权限执行破坏性清理", + "description": "purgeAuditLogsAction 在 L197 调用 `await requirePermission(Permissions.AUDIT_LOG_READ)`,但该 Action 调用 purgeExpiredAuditLogs 会物理删除审计日志。读权限用于删除操作是严重权限提权漏洞——任何能查看审计日志的用户都能清空审计痕迹。", + "recommendation": "新增专用权限点 Permissions.AUDIT_LOG_PURGE(admin 专属),改为:\n```ts\nawait requirePermission(Permissions.AUDIT_LOG_PURGE)\n```\n同步更新 src/shared/types/permissions.ts 与角色-权限映射。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-004", + "file": "src/modules/audit/actions.ts", + "lines": "L163-L190", + "ruleId": "A-08", + "severity": "P1", + "dimension": "architecture", + "title": "saveAuditRetentionConfigAction 用读权限执行写操作", + "description": "saveAuditRetentionConfigAction 在 L167 调用 `requirePermission(Permissions.AUDIT_LOG_READ)`,但该 Action 调用 saveAuditRetentionConfig 写入保留策略配置。读权限不应授予配置写入能力。", + "recommendation": "新增 Permissions.AUDIT_RETENTION_MANAGE 或复用 Permissions.AUDIT_LOG_EXPORT,将 requirePermission 改为该写权限点。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-005", + "file": "src/modules/messaging/data-access.ts", + "lines": "L1-L1089", + "ruleId": "S-02", + "severity": "P1", + "dimension": "structure", + "title": "messaging/data-access.ts 导出 42 个函数 + 4 个常量/接口,远超 20 上限", + "description": "Grep 统计 `^export (async )?(function|const)` 共 44 个导出(含 Raw+Wrapper 配对),加上 SendGroupMessageInput/SendGroupResult 2 个 interface 共 46 个公共导出。职责混杂导致单文件难以维护。", + "recommendation": "与 G4-001 拆分方案同步执行,拆分后每个 data-access-*.ts 导出数控制在 8-12 个。", + "effort": "L (≤1d)" + }, + { + "id": "G4-006", + "file": "src/modules/messaging/data-access.ts", + "lines": "L449-L476", + "ruleId": "A-02", + "severity": "P1", + "dimension": "architecture", + "title": "recallMessage 在 data-access 层嵌入状态机业务逻辑", + "description": "recallMessage 函数内部实现 4 态状态机(\"ok\"/\"not_found\"/\"expired\"/\"already_recalled\"),包含时间窗口校验(MESSAGE_RECALL_WINDOW_MS = 2 分钟)、已撤回判断、elapsed 时间计算。这些是业务规则,不应放在 data-access 层。data-access 应只做 DB 读写。", + "recommendation": "将状态机移至 actions.ts:\n```ts\n// data-access 只保留纯 DB 操作\nexport async function markMessageRecalled(id: string): Promise {\n await db.update(messages).set({ recalledAt: new Date() }).where(eq(messages.id, id))\n}\nexport async function getMessageForRecallCheck(id: string, userId: string) {\n return db.select({id, senderId, recalledAt, createdAt}).from(messages)\n .where(and(eq(messages.id, id), eq(messages.senderId, userId))).limit(1)\n}\n// actions.ts 中 recallMessageAction 实现状态机\n```", + "effort": "M (≤2h)" + }, + { + "id": "G4-007", + "file": "src/modules/messaging/data-access.ts", + "lines": "L695-L718, L641-L668", + "ruleId": "A-02", + "severity": "P1", + "dimension": "architecture", + "title": "blockUser / reportMessage 在 data-access 层实现防重复业务规则", + "description": "blockUser 实现 self_block/already_blocked 双业务校验(L699, L712);reportMessage 实现 already_reported 防重复校验(L644-L656)。这些是业务规则,应在 actions 层通过 Zod + 状态判断完成,data-access 仅提供 unique 索引写入与查询原语。", + "recommendation": "data-access 层只暴露纯 insert/block 查询;actions 层负责状态判断与错误码映射。可利用 DB unique 索引(userBlocks(blockerId, blockedId))直接 insert + catch 冲突判定 already_blocked,省去一次 SELECT。", + "effort": "M (≤2h)" + }, + { + "id": "G4-008", + "file": "src/modules/messaging/data-access.ts", + "lines": "L617-L630", + "ruleId": "A-02", + "severity": "P1", + "dimension": "architecture", + "title": "getMessageDetailPageData 是页面编排函数,不应在 data-access 层", + "description": "getMessageDetailPageData 内部组合 getMessageById + 条件性 markMessageAsRead,是典型的页面层编排逻辑(orchestration)。文件头注释 L20 明确说「getMessagesPageData 已迁出至 messages/page.tsx」,但本函数仍保留在 data-access,违反同层职责一致性。", + "recommendation": "删除该函数,将其逻辑移至 app/(dashboard)/messages/[id]/page.tsx 或包装为 getMessageDetailAction Server Action。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-009", + "file": "src/modules/auth/data-access.ts", + "lines": "L48-L84", + "ruleId": "F-09", + "severity": "P1", + "dimension": "performance", + "title": "createUser 两次 INSERT 无事务,存在数据不一致风险", + "description": "createUser 先 db.insert(users)(L55),再 db.query.roles.findFirst 查角色(L71),最后 db.insert(usersToRoles)(L78)。三次操作无事务包裹。若第二步 roleRow 未找到抛错,用户已写入但无角色;若第三步失败,用户存在但无角色关联,导致下次登录 resolvePermissions 失败。", + "recommendation": "用 db.transaction 包裹:\n```ts\nawait db.transaction(async (tx) => {\n await tx.insert(users).values({...})\n const roleRow = await tx.query.roles.findFirst({ where: eq(roles.name, roleName) })\n if (!roleRow) throw new Error('DEFAULT_ROLE_NOT_FOUND')\n await tx.insert(usersToRoles).values({ userId, roleId: roleRow.id })\n})\n```\n注意:抛错会回滚用户记录,避免孤儿用户。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-010", + "file": "src/modules/messaging/data-access.ts", + "lines": "L137-L141", + "ruleId": "F-02", + "severity": "P1", + "dimension": "performance", + "title": "getMessages 对 messages.subject/content 使用 LIKE '%kw%' 全表扫描", + "description": "L138-139 `like(messages.subject, kw), like(messages.content, kw)` 中 kw = `%${params.keyword.trim()}%`。前导通配符使 B-tree 索引失效,messages 表增长后查询退化。同时 getMessages 还要 count() 同条件总数,单次列表请求触发 2 次全表扫描。", + "recommendation": "(1) 短期:对短关键词改前缀匹配 `kw%` 可用索引;(2) 长期:在 messages 表加 FULLTEXT 索引 `ALTER TABLE messages ADD FULLTEXT idx_subject_content(subject, content)`,改用 `match(messages.subject, messages.content).against(kw)`;(3) count 也可考虑用估算值或缓存。", + "effort": "L (≤1d)" + }, + { + "id": "G4-011", + "file": "src/modules/messaging/data-access.ts", + "lines": "L506,L517,L531,L542,L555,L805,L807", + "ruleId": "P-09", + "severity": "P2", + "dimension": "pattern", + "title": "messaging/data-access.ts 出现 7 处 `as` 类型断言", + "description": "Grep 证实 7 处 `as` 断言:(1) L506/517/531/542/555 `role: \"admin\" as RecipientRole` 等 5 处把 string literal 断言为联合类型 RecipientRole;(2) L805 `r.reason as MessageReportReason`;(3) L807 `r.status as MessageReport[\"status\"]`。规则 P-09 要求 `as` 出现次数为 0(除 unknown 收窄)。", + "recommendation": "(1) RecipientRole 字面量断言:改用类型守卫函数 `function toRecipientRole(v: string): RecipientRole { return RECIPIENT_ROLES.includes(v) ? (v as RecipientRole) : 'admin' }` 或在 map 回调显式标注返回类型让 TS 推断;(2) reason/status 断言:仿照 notifications/data-access.ts L39-49 的 isNotificationType 类型守卫模式。", + "effort": "M (≤2h)" + }, + { + "id": "G4-012", + "file": "src/modules/messaging/actions.ts", + "lines": "L812", + "ruleId": "P-09", + "severity": "P2", + "dimension": "pattern", + "title": "messaging/actions.ts L812 `as` 联合类型断言", + "description": "L812 `reason: input.reason as \"spam\" | \"harassment\" | \"inappropriate\" | \"other\"` 直接把 Zod 解析后的 string 断言为联合类型。ReportMessageSchema 应在 Zod 层用 z.enum() 收窄类型,避免后续 `as`。", + "recommendation": "修改 schema.ts 的 ReportMessageSchema:\n```ts\nreason: z.enum(['spam', 'harassment', 'inappropriate', 'other'])\n```\n然后删除 `as` 断言,TS 会从 Zod 推断正确类型。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-013", + "file": "src/modules/messaging/data-access.ts", + "lines": "L86-L94", + "ruleId": "S-07", + "severity": "P2", + "dimension": "structure", + "title": "resolveUserNames 与 users/data-access.getUserNamesByIds 逻辑重复", + "description": "messaging/data-access.ts L86-94 自定义 resolveUserNames 函数,查询 users 表返回 Map。但同模块 L41 已 import getUserNamesByIds from users/data-access,且后者返回 Map(含 id/name/email)。功能高度重复,违反 S-07 跨模块重复查询逻辑。", + "recommendation": "删除 resolveUserNames,统一使用 getUserNamesByIds:\n```ts\nconst nameMap = await getUserNamesByIds(userIds)\n// 取值改为 nameMap.get(id)?.name ?? null\n```\n可获得 cacheFn 缓存收益。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-014", + "file": "src/modules/messaging/data-access.ts", + "lines": "L815-L825, L843-L850, L1001-L1012, L673-L688, L740-L755, L761-L776, L781-L787", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "多个读函数未走 cacheFn Raw+Wrapper 配对", + "description": "以下读函数均直接 export async function 而未提供 Raw + cacheFn Wrapper 配对:getMessageReports(L815)、getUserBlocks(L843)、getMessageDraftById(L1001)、hasUserReportedMessage(L673)、isUserBlocked(L740)、isEitherUserBlocked(L761)、getBlockedUserIds(L781)、getMessageAttachments(L864)。违反 P-03「读函数是否走 cacheFn 包装 - 全部覆盖」。", + "recommendation": "为每个读函数补齐 Raw + Wrapper 配对:\n```ts\nexport const getMessageReportsRaw = async (...) => {...}\nexport const getMessageReports = cacheFn(getMessageReportsRaw, { tags: ['messaging'], ttl: 60, keyParts: ['messaging', 'getMessageReports'] })\n```\n注意 hasUserReportedMessage 等布尔回传函数 ttl 可设短(30s)。", + "effort": "M (≤2h)" + }, + { + "id": "G4-015", + "file": "src/modules/users/data-access.ts", + "lines": "L429-L498", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "getAdminUsers / getAdminUserRoles 读函数未走 cacheFn", + "description": "getAdminUsers(L429) 与 getAdminUserRoles(L495) 是 admin 后台读函数,均未提供 Raw + Wrapper 配对,直接 export async function。getAdminUsers 内部还有 2 次 SQL(用户列表 + count)+ 1 次批量查角色,无缓存导致每次后台访问都全量打 DB。", + "recommendation": "补齐 cacheFn 包装:\n```ts\nexport const getAdminUsersRaw = async (params): Promise => {...}\nexport const getAdminUsers = cacheFn(getAdminUsersRaw, { tags: ['users'], ttl: 60, keyParts: ['users', 'getAdminUsers'] })\n```\ngetAdminUserRoles 同理。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-016", + "file": "src/modules/rbac/data-access-assignments.ts", + "lines": "L95-L177", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "getUserRoleAssignments 读函数未走 cacheFn", + "description": "getUserRoleAssignments 是分页读函数,未提供 Raw + Wrapper 配对。该函数被 rbac/actions.ts 的角色分配页面调用,无缓存导致每次列表访问都触发 2 次 SQL + 1 次批量查角色。", + "recommendation": "拆为 getUserRoleAssignmentsRaw + getUserRoleAssignments = cacheFn(...) 配对,ttl 设 60s。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-017", + "file": "src/modules/audit/data-access.ts", + "lines": "L88, L146, L165, L225, L248, L267, L380, L445, L470", + "ruleId": "A-10", + "severity": "P2", + "dimension": "architecture", + "title": "audit/data-access.ts 9 处 console.error 调试代码", + "description": "Grep 证实 9 处 `console.error(...)`:L88 getAuditLogs、L146 getLoginLogs、L165 getAuditModuleOptions、L225 getDataChangeLogs、L248 getDataChangeStats、L267 getDataChangeTableOptions、L380 getAuditOverviewStats、L445 getAuditTrend、L470 getDataChangeActionStats。规则 A-10 明确禁止 data-access 含 console.log 调试代码。", + "recommendation": "接入统一日志服务(shared/lib/logger,需先创建)。过渡期可改为:\n```ts\nimport { logger } from '@/shared/lib/logger'\ncatch (error) { logger.error('getAuditLogs failed', { error }); throw error }\n```\n或直接删除 try-catch 让上层处理(data-access 应 throw,不应吞错)。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-018", + "file": "src/modules/notifications/data-access.ts", + "lines": "L261, L282", + "ruleId": "A-10", + "severity": "P2", + "dimension": "architecture", + "title": "notifications/data-access.ts 含 console.info / console.error", + "description": "L261 `console.info('[NotificationLog] OK/FAIL ...')`、L282 `console.error('[NotificationLog] Failed to persist log:', dbError)`。代码已标注 TODO V3-P2-8 接入统一日志服务但未实施。违反 A-10。", + "recommendation": "创建 shared/lib/logger 后替换;过渡期可用 trackEvent 写入 audit_logs。console.info 至少应改为可关闭的 debug 级别。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-019", + "file": "src/modules/auth/actions.ts", + "lines": "L248-L250", + "ruleId": "A-10", + "severity": "P3", + "dimension": "architecture", + "title": "auth/actions.ts 含 console.warn 调试代码", + "description": "L248-250 `console.warn('[register] Invitation code ... was already consumed ...')` 在 actions 层打印邀请码与邮箱到日志,可能泄露用户隐私信息到日志文件。", + "recommendation": "改用 trackEvent 上报埋点(不含 email 明文),或改为 logger.warn 并脱敏 email。删除 console.warn。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-020", + "file": "src/modules/audit/data-access.ts", + "lines": "L14-L22, L27-L35", + "ruleId": "S-03", + "severity": "P2", + "dimension": "structure", + "title": "clampPageSize / clampPage 在 audit 与 rbac 重复定义", + "description": "audit/data-access.ts L24-35 定义 DEFAULT_PAGE_SIZE / MAX_PAGE_SIZE / clampPageSize / clampPage;rbac/data-access-assignments.ts L11-22 完全相同地重复定义这 4 个常量与函数。违反 S-03 重复 helper 应提取到 shared/lib。", + "recommendation": "提取到 shared/lib/pagination.ts:\n```ts\nexport const DEFAULT_PAGE_SIZE = 20\nexport const MAX_PAGE_SIZE = 100\nexport function clampPageSize(size?: number): number {...}\nexport function clampPage(page?: number): number {...}\nexport function computeOffset(page: number, pageSize: number): number { return (page - 1) * pageSize }\n```\n两处 import 替换。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-021", + "file": "src/modules/notifications/data-access.ts", + "lines": "L37, L67 (messaging), L37 (notifications)", + "ruleId": "S-03", + "severity": "P2", + "dimension": "structure", + "title": "toIso / toIsoRequired 在多个模块重复定义", + "description": "notifications/data-access.ts L37 `const toIsoRequired = (d: Date): string => d.toISOString()`;messaging/data-access.ts L67-69 `toIso` + `toIsoRequired`;audit/data-access.ts L22 `toIso`;parent/data-access.ts L63 直接调用 `r.createdAt.toISOString()`。多处重复实现日期序列化 helper,违反 S-03 与 P-07(日期序列化走 helper)。", + "recommendation": "在 shared/lib/datetime.ts 统一导出:\n```ts\nexport const toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null\nexport const toIsoRequired = (d: Date): string => d.toISOString()\nexport const toIsoDateString = (d: Date): string => d.toISOString().slice(0, 10)\n```\n各模块 import 替换本地实现。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-022", + "file": "src/modules/messaging/data-access.ts", + "lines": "L960-L993", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "updateMessageDraft 在 data-access 实现乐观锁版本冲突业务逻辑", + "description": "updateMessageDraft 内部实现乐观锁:查询 existing.version → 比对 expectedVersion → 返回 \"ok\"/\"not_found\"/\"conflict\" 三态。这是业务状态机,应在 actions 层处理。data-access 应只暴露 getVersion + update 两个原语。", + "recommendation": "拆分:data-access 提供 getMessageDraftVersion(id, userId) + updateMessageDraftRaw(id, userId, data, expectedVersion)(用 WHERE version = expectedVersion 实现原子检查);actions 层根据 affectedRows 判定冲突。", + "effort": "M (≤2h)" + }, + { + "id": "G4-023", + "file": "src/modules/messaging/data-access.ts", + "lines": "L348-L362, L400-L426", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "toggleMessageStar / bulkToggleMessagesStar 在 data-access 层做状态分支", + "description": "toggleMessageStar 先 SELECT 当前 isStarred,再 UPDATE 为相反值;bulkToggleMessagesStar 更复杂——SELECT 后按 toStar/toUnstar 分组分别 UPDATE。这是条件分支业务逻辑,应在 actions 层完成。", + "recommendation": "data-access 暴露 setMessageStarred(ids, userId, starred: boolean) 原语;actions 层先查询当前状态、决定目标值、调用原语。或更优:用 SQL `SET isStarred = NOT isStarred WHERE id IN (...)` 单语句完成翻转。", + "effort": "M (≤2h)" + }, + { + "id": "G4-024", + "file": "src/modules/parent/data-access.ts", + "lines": "L214-L237, L245-L268", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "parent/data-access 含 dashboard 编排逻辑", + "description": "getChildDashboardDataRaw(L214) 内部 Promise.all 调用 6 个跨模块 data-access 函数(getStudentClasses、getStudentSchedule、getStudentHomeworkAssignments、getStudentDashboardGrades、getStudentGradeSummary、getStudentExamResults),是典型的 dashboard 编排。getParentDashboardDataRaw(L245) 同理。data-access 层应只负责本模块表查询,跨模块编排应在 actions 或 services 层。", + "recommendation": "新建 src/modules/parent/services/parent-dashboard-service.ts 容纳编排逻辑;data-access 只保留 getChildren / verifyParentChildRelation / getParentIdsByStudentIds 等本模块表查询。", + "effort": "M (≤2h)" + }, + { + "id": "G4-025", + "file": "src/modules/parent/data-access.ts", + "lines": "L260-L262", + "ruleId": "F-01", + "severity": "P2", + "dimension": "performance", + "title": "getParentDashboardData 并行 N 次 getChildDashboardData,每次内部 6 次跨模块查询", + "description": "L260-262 `Promise.all(relations.map((r) => getChildDashboardData(r.studentId, r.relation)))`。若家长有 N 个孩子,触发 N × 6 = 6N 次跨模块 data-access 调用,每调用可能再触发 DB 查询。多子女家长场景下性能差。", + "recommendation": "重构为批量查询:getStudentClasses(studentIds[]) / getStudentSchedule(studentIds[]) 等批量接口,一次拉取所有孩子数据,再在内存按 studentId 分组组装。需 classes/homework/grades 模块提供批量查询函数。", + "effort": "L (≤1d)" + }, + { + "id": "G4-026", + "file": "src/modules/audit/data-access.ts", + "lines": "L281-L294, L299-L312, L317-L330", + "ruleId": "F-01", + "severity": "P2", + "dimension": "performance", + "title": "三个 ForExport 函数用 while 循环分页拉取全表,N 次往返", + "description": "getAuditLogsForExport / getLoginLogsForExport / getDataChangeLogsForExport 均 `while (hasMore) { result = await getXxxLogs({page, pageSize: 100}); ... }`。导出大表时每 100 条一次 DB 往返,10 万条审计日志 = 1000 次查询。且每次都走 cacheFn 包装层,无意义缓存。", + "recommendation": "新增不带分页的导出专用查询(流式或单次大查询):\n```ts\nexport async function getAuditLogsForExportRaw(params): Promise {\n return db.select().from(auditLogs).where(where).orderBy(desc(auditLogs.createdAt)).limit(100000)\n}\n```\n或用 cursor-based 流式导出。导出函数不应走 cacheFn(数据量大、不复用)。", + "effort": "M (≤2h)" + }, + { + "id": "G4-027", + "file": "src/modules/notifications/data-access.ts", + "lines": "L290-L294", + "ruleId": "F-01", + "severity": "P2", + "dimension": "performance", + "title": "logNotificationSendBatch 用 Promise.all 串行 N 次 INSERT", + "description": "L294 `Promise.all(results.map((result) => logNotificationSend(result, payload)))`。每个 logNotificationSend 内部 L268-278 一次 db.insert(notificationLogs)。N 条日志 = N 次 INSERT 往返,应批量插入。注意 Promise.all 是并发但 DB 连接池有限,仍 N 次查询。", + "recommendation": "改批量 INSERT:\n```ts\nexport async function logNotificationSendBatch(results, payload) {\n const rows = results.map(r => ({ id: createId(), userId: payload.userId, title: payload.title, channel: r.channel, status: r.success ? 'success' : 'failure', messageId: r.messageId ?? null, error: r.error ?? null, sentAt: r.sentAt }))\n await db.insert(notificationLogs).values(rows)\n}\n```\n一次 INSERT 完成所有日志写入。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-028", + "file": "src/modules/messaging/data-access.ts", + "lines": "L176, L197-198, L226, L237, L502, L820, L844, L913, L1002, L1041", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "messaging/data-access 多处 db.select().from(table) 未显式枚举列", + "description": "多处使用 `db.select().from(messages)` / `db.select().from(users)` / `db.select().from(messageDrafts)` / `db.select().from(messageTemplates)` / `db.select().from(messageReports)` / `db.select().from(userBlocks)` 全列查询。其中 L502 `db.select({ id, name, email }).from(users)` 是好的反例。messages 表含 content 长文本字段,列表查询全列拉取浪费带宽。", + "recommendation": "列表查询显式枚举所需列:\n```ts\ndb.select({ id: messages.id, senderId: messages.senderId, receiverId: messages.receiverId, subject: messages.subject, content: messages.content, isRead: messages.isRead, isStarred: messages.isStarred, recalledAt: messages.recalledAt, readAt: messages.readAt, parentMessageId: messages.parentMessageId, groupMessageId: messages.groupMessageId, createdAt: messages.createdAt }).from(messages)\n```\n列表场景若不需 content,可省略该列。", + "effort": "M (≤2h)" + }, + { + "id": "G4-029", + "file": "src/modules/audit/data-access.ts", + "lines": "L56, L117, L194", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "audit/data-access 三个分页查询用 db.select() 全列", + "description": "getAuditLogsRaw L56、getLoginLogsRaw L117、getDataChangeLogsRaw L194 均 `db.select().from(auditLogs/loginLogs/dataChangeLogs)`。audit_logs 表含 detail (JSON)、userAgent (长字符串) 等大字段,分页列表全列拉取浪费。dataChangeLogs.oldValue/newValue 是大 JSON。", + "recommendation": "列表查询显式枚举列,详情字段(detail / oldValue / newValue / userAgent)按需在详情页查询时拉取。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-030", + "file": "src/modules/rbac/data-access.ts", + "lines": "L39", + "ruleId": "F-03", + "severity": "P3", + "dimension": "performance", + "title": "getRolesRaw 用 db.select().from(roles) 全列查询", + "description": "L39 `db.select().from(roles).orderBy(roles.name)` 查询所有角色。roles 表通常很小(< 20 行),影响有限,但仍应显式枚举列以避免 schema 变更后意外暴露字段。", + "recommendation": "改为 `db.select({ id, name, description, isSystem, isEnabled, createdAt, updatedAt }).from(roles)`。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-031", + "file": "src/modules/users/data-access.ts", + "lines": "L449-L457", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "getAdminUsers 用 db.select() 全列查询 users 表", + "description": "L451-452 `db.select().from(users).where(where).orderBy(...)`。users 表含 password (bcrypt hash)、image、address 等敏感或大字段,全列拉取既浪费又可能泄露 password hash 到内存对象。", + "recommendation": "显式枚举所需列:`db.select({ id, name, email, phone, createdAt }).from(users)`。绝不能 select password 列。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-032", + "file": "src/modules/users/data-access.ts", + "lines": "L441-L444", + "ruleId": "F-02", + "severity": "P2", + "dimension": "performance", + "title": "getAdminUsers 对 name/email 使用 ilike '%search%' 全表扫描", + "description": "L443 `or(ilike(users.name, search), ilike(users.email, search))`,search = `%${params.search}%`。前导通配符使索引失效,users 表增长后搜索退化。", + "recommendation": "(1) 短期:email 改前缀匹配 `search%`(用户邮箱通常前缀输入);(2) 长期:加 FULLTEXT 索引或用 Elasticsearch。", + "effort": "M (≤2h)" + }, + { + "id": "G4-033", + "file": "src/modules/rbac/data-access-assignments.ts", + "lines": "L107-L108", + "ruleId": "F-02", + "severity": "P2", + "dimension": "performance", + "title": "getUserRoleAssignments 对 name/email 使用 ilike '%term%' 全表扫描", + "description": "L108 `or(ilike(users.name, term), ilike(users.email, term))`,term = `%${params.search}%`。同 G4-032 问题。", + "recommendation": "同 G4-032:email 前缀匹配,或加 FULLTEXT 索引。", + "effort": "M (≤2h)" + }, + { + "id": "G4-034", + "file": "src/modules/audit/data-access.ts", + "lines": "L47", + "ruleId": "F-02", + "severity": "P3", + "dimension": "performance", + "title": "getAuditLogsRaw 对 action 字段使用 like '%action%' 模糊匹配", + "description": "L47 `like(auditLogs.action, \\`%${params.action}%\\`)`。action 字段通常是固定枚举值(如 'user.login'),用 `%xxx%` 匹配既慢又可能误匹配('user.login' 会匹配 'admin.user.login')。应改 eq 精确匹配。", + "recommendation": "改为 `eq(auditLogs.action, params.action)`;若需多值匹配,用 inArray([actions])。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-035", + "file": "src/modules/messaging/data-access.ts", + "lines": "L502", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "resolveAdminRecipients 全量查询 users 表无 LIMIT", + "description": "L502 `db.select({ id, name, email }).from(users)` 无 limit。admin 角色收件人解析时全量拉取所有用户,超大学校(万级用户)会 OOM。注释虽在 users/data-access.ts getAllUserIds 提到 P3-7 加 LIMIT 1000,但此处未应用。", + "recommendation": "加分页或 LIMIT:\n```ts\ndb.select({ id, name, email }).from(users).limit(1000)\n```\n或改用 getTeachersByIds / 按角色筛选避免全量。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-036", + "file": "src/modules/messaging/data-access.ts", + "lines": "L520-L532", + "ruleId": "F-08", + "severity": "P2", + "dimension": "performance", + "title": "resolveGradeManagedRecipients 循环 N 次调用 getClassesByGradeId", + "description": "L525 `await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g)))`。年级主任管理的年级通常 1-3 个,但模式上仍是 N 次跨模块调用。每个 getClassesByGradeId 内部一次 DB 查询,N 个年级 = N 次查询。classes 模块缺少 getClassesByGradeIds(批量) 接口。", + "recommendation": "在 classes/data-access 新增 `getClassesByGradeIds(gradeIds: string[])` 批量查询接口,本处改为单次调用。", + "effort": "M (≤2h)" + }, + { + "id": "G4-037", + "file": "src/modules/messaging/data-access.ts", + "lines": "L549", + "ruleId": "F-08", + "severity": "P2", + "dimension": "performance", + "title": "resolveChildrenRecipients 循环 N 次调用 getStudentActiveClassId", + "description": "L549 `await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id)))`。家长有 N 个孩子则 N 次调用。多子女家长场景下性能差。", + "recommendation": "在 classes/data-access 新增 `getStudentActiveClassIds(studentIds: string[])` 批量查询接口。", + "effort": "M (≤2h)" + }, + { + "id": "G4-038", + "file": "src/modules/audit/data-access.ts", + "lines": "L370", + "ruleId": "F-10", + "severity": "P3", + "dimension": "performance", + "title": "getAuditOverviewStatsRaw 含 count() 全表统计", + "description": "L370 `db.select({ value: count() }).from(auditLogs)` 无 WHERE 过滤,统计审计日志总数。audit_logs 表会持续增长(保留期 180 天),全表 count 在大表上慢(MyISAM 快但 InnoDB 慢)。", + "recommendation": "(1) 用元数据表缓存总数,定时刷新;(2) 或用 `SELECT table_rows FROM information_schema.tables WHERE table_name='audit_logs'`(近似值);(3) 或限定统计近 30 天。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-039", + "file": "src/modules/notifications/data-access.ts", + "lines": "L98", + "ruleId": "F-03", + "severity": "P3", + "dimension": "performance", + "title": "getNotificationsRaw 用 db.select() 全列查询", + "description": "L98 `db.select().from(messageNotifications).where(where).orderBy(...)`。messageNotifications.content 可能为长文本,列表查询全列拉取浪费。", + "recommendation": "显式枚举列,content 字段按需拉取。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-040", + "file": "src/modules/users/data-access.ts", + "lines": "L196", + "ruleId": "P-07", + "severity": "P3", + "dimension": "pattern", + "title": "getUsersDashboardStatsRaw 直接调用 toISOString 而非 helper", + "description": "L196 `createdAt: u.createdAt.toISOString()` 直接调用,未使用项目统一日期序列化 helper(serializeDate / toISODateString)。其他模块(messaging/notifications/audit)均使用 toIso/toIsoRequired helper。", + "recommendation": "import { toIsoRequired } from '@/shared/lib/datetime'(需先创建,见 G4-021),替换为 `createdAt: toIsoRequired(u.createdAt)`。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-041", + "file": "src/modules/parent/data-access.ts", + "lines": "L63", + "ruleId": "P-07", + "severity": "P3", + "dimension": "pattern", + "title": "getChildrenRaw 直接调用 toISOString 而非 helper", + "description": "L63 `createdAt: r.createdAt.toISOString()` 直接调用,与 G4-040 同类问题。", + "recommendation": "同 G4-021 / G4-040:使用统一 helper。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-042", + "file": "src/modules/rbac/data-access.ts", + "lines": "L28-L31", + "ruleId": "P-07", + "severity": "P3", + "dimension": "pattern", + "title": "toRoleRecord 返回 Date 对象而非 ISO 字符串", + "description": "L28-31 `createdAt: row.createdAt, updatedAt: row.updatedAt` 直接返回 Date 对象。其他模块(notifications/audit/messaging)均返回 ISO 字符串。RoleRecord 类型定义可能是 Date,但跨层传递 Date 在 Server Action 序列化时会丢失时区信息,应统一为 ISO 字符串。", + "recommendation": "改为 `createdAt: toIsoRequired(row.createdAt), updatedAt: toIsoRequired(row.updatedAt)`;同步更新 RoleRecord 类型为 string。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-043", + "file": "src/modules/rbac/data-access-assignments.ts", + "lines": "L160", + "ruleId": "P-07", + "severity": "P3", + "dimension": "pattern", + "title": "getUserRoleAssignments 返回 Date 对象而非 ISO 字符串", + "description": "L160 `createdAt: u.createdAt` 直接返回 Date。同 G4-042 问题。", + "recommendation": "改为 `createdAt: toIsoRequired(u.createdAt)`;同步更新 UserRoleAssignment 类型。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-044", + "file": "src/modules/users/data-access.ts", + "lines": "L510-L538", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "deleteUserById 在 data-access 层嵌入 last-admin 保护业务逻辑", + "description": "deleteUserById L510-538 实现「最后管理员保护」:查询 admin 角色 → count admin 数量 → 若 ≤1 再检查目标是否 admin → 抛错。这是业务安全规则,应在 actions 层校验,data-access 只做 delete 原语。", + "recommendation": "actions.ts deleteUserAction 在调用 deleteUserById 前先调用 isLastAdmin(userId) 校验:\n```ts\n// data-access 暴露 isLastAdmin\nexport async function isLastAdmin(userId: string): Promise {...}\n// actions.ts\nif (await isLastAdmin(userId)) return { success: false, message: 'Cannot delete last admin' }\nawait deleteUserById(userId)\n```\ndata-access.deleteUserById 只保留 `db.delete(users).where(eq(users.id, userId))`。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-045", + "file": "src/modules/rbac/data-access.ts", + "lines": "L146-L201", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "rbac/data-access 多处嵌入 admin 角色保护业务逻辑", + "description": "updateRole(L151-153 admin name 锁)、deleteRole(L177-179 system role 锁)、setRoleEnabled(L192-194 admin disable 锁)、setRolePermissions(L233-235 admin perm 锁) 均在 data-access 层嵌入角色保护业务规则。这些是 RBAC 安全策略,应在 actions 层统一校验。", + "recommendation": "data-access 层提供纯 CRUD 原语;actions.ts 在调用前校验 isAdminRole / isSystemRole 并抛错。可复用 rbac/actions.ts 已有的 isAdminRole 函数。", + "effort": "M (≤2h)" + }, + { + "id": "G4-046", + "file": "src/modules/rbac/data-access-assignments.ts", + "lines": "L52-L89", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "assignRolesToUser 在 data-access 层做角色存在性/disabled 校验", + "description": "assignRolesToUser L57-58 校验 user 存在、L71-75 校验 role 名全部存在、L78 过滤 disabled 角色。这些是业务校验,应在 actions 层完成。data-access 应只做 transactional insert/delete。", + "recommendation": "actions.ts assignUserRolesAction 在调用前用 Zod + 业务校验:检查 user 存在、role 名有效、无 disabled。data-access 只暴露 replaceUserRoles(userId, roleIds) 原语。", + "effort": "M (≤2h)" + }, + { + "id": "G4-047", + "file": "src/modules/rbac/data-access-assignments.ts", + "lines": "L164-L166", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "getUserRoleAssignments 在 data-access 层 post-fetch 过滤角色", + "description": "L164-166 `const filtered = params?.role ? items.filter((i) => i.roleNames.includes(params.role ?? '')) : items`。这是在内存中做角色过滤,但 total 仍是未过滤前的总数(L168),导致分页 totalPages 错误。这是业务逻辑 + bug。", + "recommendation": "把 role 过滤下推到 SQL:用 EXISTS 子查询或 JOIN usersToRoles。或至少在 SQL 层用 `inArray(users.id, (db.select({userId}).from(usersToRoles).innerJoin(roles...).where(eq(roles.name, role))))` 子查询过滤。同时修正 total 计算。", + "effort": "M (≤2h)" + }, + { + "id": "G4-048", + "file": "src/modules/messaging/actions.ts", + "lines": "L1-L972", + "ruleId": "S-01", + "severity": "P2", + "dimension": "structure", + "title": "messaging/actions.ts 972 行,接近上限", + "description": "文件 972 行,已超过 React 组件 / actions 建议 800 行上限(虽然 actions 硬上限是 1000)。文件包含 25+ 个 Server Action,覆盖消息 CRUD、群发、撤回、批量、草稿、模板、举报、屏蔽、附件 9 类功能。", + "recommendation": "按功能拆分为 actions-messages.ts / actions-group.ts / actions-drafts.ts / actions-templates.ts / actions-reports.ts / actions-blocks.ts / actions-attachments.ts。原 actions.ts 作 barrel re-export。", + "effort": "M (≤2h)" + }, + { + "id": "G4-049", + "file": "src/modules/users/data-access.ts", + "lines": "L26, L429, L495", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "getUserProfileRaw / getAdminUsers / getAdminUserRoles 缺 JSDoc", + "description": "getUserProfileRaw(L26) 无 JSDoc;getAdminUsers(L429) 无 JSDoc;getAdminUserRoles(L495) 无 JSDoc。其他函数(updateUserProfileById、updateUserAvatar、deleteUserById)均有 JSDoc。规则 S-06 要求公共导出函数补齐 JSDoc。", + "recommendation": "为这 3 个函数补 JSDoc,说明用途、参数、返回值、副作用。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-050", + "file": "src/modules/messaging/data-access.ts", + "lines": "L325, L348, L369, L384, L400", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "messaging/data-access 多个公共函数仅有单行注释缺 JSDoc", + "description": "markMessageAsRead(L325)、toggleMessageStar(L348)、bulkMarkMessagesAsRead(L369)、bulkDeleteMessages(L384)、bulkToggleMessagesStar(L400) 等函数仅有 `/** P2-1: ... */` 单行注释,缺标准 JSDoc(@param / @returns / @throws)。", + "recommendation": "补全 JSDoc,至少说明参数语义、返回值含义、异常情况。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-051", + "file": "src/modules/notifications/data-access.ts", + "lines": "L156, L163, L170, L177", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "notifications/data-access 部分 CRUD 函数缺 JSDoc", + "description": "markNotificationAsRead(L156)、markAllNotificationsAsRead(L163)、archiveNotification(L170)、unarchiveNotification(L177) 4 个公共函数均无 JSDoc。createNotification / createNotifications / getUserContactInfoRaw 等有 JSDoc。", + "recommendation": "为这 4 个函数补 JSDoc。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-052", + "file": "src/modules/parent/data-access.ts", + "lines": "L42, L97", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "parent/data-access getChildrenRaw / getChildBasicInfoRaw 缺 JSDoc", + "description": "getChildrenRaw(L42) 与 getChildBasicInfoRaw(L97) 是模块核心读函数,均无 JSDoc。其他函数(verifyParentChildRelationRaw、getParentIdsByStudentIdsRaw)有 JSDoc。", + "recommendation": "补全 JSDoc,说明入参与返回结构。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-053", + "file": "src/modules/audit/data-access.ts", + "lines": "L272-L279", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "getDataChangeTableOptions JSDoc 错位", + "description": "L272-274 的 JSDoc 注释 `Export-ready: fetch all audit logs matching params` 是给 getAuditLogsForExport 用的,但实际位于 getDataChangeTableOptionsRaw 上方,且内容描述与函数名不符(注释说 audit logs,函数查 dataChangeLogs.tableName 选项)。", + "recommendation": "修正 JSDoc:getDataChangeTableOptionsRaw 的注释应为「获取数据变更日志中所有出现过的表名选项」;getAuditLogsForExport 的注释应放在 L281 上方。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-054", + "file": "src/modules/rbac/data-access.ts", + "lines": "L22-L32", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "toRoleRecord 内部 helper 无 JSDoc", + "description": "toRoleRecord(L22) 是 row→record 映射 helper,无 JSDoc。虽是内部函数,但项目规则建议 helper 也补简短说明。", + "recommendation": "补单行 JSDoc:`/** 将 roles 表行映射为 RoleRecord 类型 */`", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-055", + "file": "src/modules/rbac/actions.ts", + "lines": "L393-L394", + "ruleId": "S-08", + "severity": "P3", + "dimension": "structure", + "title": "isAdminRole 作为 Server Action 但不返回 ActionState", + "description": "L393-394 `export async function isAdminRole(roleName: string): Promise` 直接返回 boolean,未包装 ActionState。其他所有 Action 均返回 ActionState。不一致,且 client 调用方无法区分「权限拒绝」与「不是 admin」。", + "recommendation": "改为标准 ActionState:\n```ts\nexport async function isAdminRoleAction(roleName: string): Promise> {\n try { await requirePermission(Permissions.ROLE_READ); return { success: true, data: roleName === ADMIN_ROLE_NAME } }\n catch (e) { return { success: false, message: '...' } }\n}\n```\n或下沉为纯 data-access 函数(非 Action)。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-056", + "file": "src/modules/auth/data-access.ts", + "lines": "L25-L33", + "ruleId": "P-03", + "severity": "P3", + "dimension": "pattern", + "title": "isEmailAvailable 读函数未走 cacheFn Raw+Wrapper 配对", + "description": "isEmailAvailable(L25) 是读函数,直接 export async function,未提供 Raw + Wrapper 配对。虽是注册前可用性检查(缓存可能引入脏读),但模式不一致。其他模块读函数均配对。", + "recommendation": "若担心缓存影响可用性判断,可设 ttl: 5s 短缓存,至少模式一致。或显式标注 `// 不缓存:注册可用性检查需实时` 并在 lint 规则中加豁免。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-057", + "file": "src/modules/notifications/data-access.ts", + "lines": "L252-L285", + "ruleId": "A-02", + "severity": "P3", + "dimension": "architecture", + "title": "logNotificationSend 含 try-catch + console 降级,偏业务编排", + "description": "logNotificationSend(L252-285) 内部 try-catch DB 写入失败时降级为 console.error,是日志写入的容错策略,属业务编排而非纯数据访问。data-access 应只做 DB 写入,失败应 throw 由上层决定降级。", + "recommendation": "data-access 层只做 `db.insert(notificationLogs).values(...)`,失败 throw;上层(channels/dispatcher)catch 后决定是否降级。console 部分按 G4-018 处理。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G4-058", + "file": "src/modules/messaging/data-access.ts", + "lines": "L617-L630", + "ruleId": "S-05", + "severity": "P2", + "dimension": "structure", + "title": "getMessageDetailPageData 疑似 dead code", + "description": "文件头注释 L18-20 明确说「getMessagesPageData 已迁出至 messages/page.tsx 页面层,保持模块独立性」。getMessageDetailPageData 是同类编排函数,仍保留在 data-access。需确认是否被调用,若无调用方则为 dead code。", + "recommendation": "Grep 调用方:`getMessageDetailPageData`。若无 app/ 或 actions 调用,删除;若有调用,按 G4-008 迁移至页面层。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-059", + "file": "src/modules/messaging/data-access.ts", + "lines": "L67-L69", + "ruleId": "P-08", + "severity": "P3", + "dimension": "pattern", + "title": "messaging/data-access 用本地 toIso/toIsoRequired 而非 mapListItem 模式", + "description": "L67-69 定义 toIso/toIsoRequired 本地 helper,每个 map* 函数(mapMessage、mapDraft、mapTemplate、mapUserBlock、mapMessageReport)内联调用。规则 P-08 建议列表项映射走 mapListItem 模式统一。当前 5 个 mapper 各自实现,虽结构相似但无统一抽象。", + "recommendation": "提取 shared/lib/map-helpers.ts 通用 `mapListItem(row, mapper)` 工具,或至少在模块内统一 mapper 签名风格。优先级低,当前实现可读性尚可。", + "effort": "M (≤2h)" + }, + { + "id": "G4-060", + "file": "src/modules/users/data-access.ts", + "lines": "L476-L484", + "ruleId": "P-08", + "severity": "P3", + "dimension": "pattern", + "title": "getAdminUsers 列表项 inline map 而非 mapListItem 模式", + "description": "L476-483 `items: userRows.map((u) => ({ id, name, email, roles, phone, createdAt }))` 内联 map。其他模块(messaging/notifications)均有专用 mapper 函数(mapMessage/mapNotification)。", + "recommendation": "提取 `mapAdminUserListItem(row, rolesByUserId)` mapper 函数,与 mapMessage 等保持一致风格。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G4-061", + "file": "src/modules/audit/data-access.ts", + "lines": "L67-L81, L128-L139, L206-L218", + "ruleId": "P-08", + "severity": "P3", + "dimension": "pattern", + "title": "audit/data-access 三个分页查询 inline map 列表项", + "description": "getAuditLogsRaw L67-81、getLoginLogsRaw L128-139、getDataChangeLogsRaw L206-218 均用 `rows.map((r) => ({...}))` 内联映射,无独立 mapper 函数。与 messaging/notifications 模式不一致。", + "recommendation": "提取 mapAuditLog / mapLoginLog / mapDataChangeLog mapper 函数,便于复用与单测。", + "effort": "S (≤30 分钟)" + } +] diff --git a/docs/architecture/audit/g5-audit-output.json b/docs/architecture/audit/g5-audit-output.json new file mode 100644 index 0000000..3a021cf --- /dev/null +++ b/docs/architecture/audit/g5-audit-output.json @@ -0,0 +1,494 @@ +[ + { + "id": "G5-001", + "file": "src/modules/onboarding/data-access.ts", + "lines": "L1-L5", + "ruleId": "P-01", + "severity": "P0", + "dimension": "pattern", + "title": "data-access 文件缺少 `import \"server-only\"` 文件头", + "description": "文件首行直接为 `import { eq, and } from \"drizzle-orm\"`,未声明 `import \"server-only\"`,导致此 data-access 模块可能被客户端代码意外引入,存在将 DB schema 与查询逻辑泄露到客户端 bundle 的安全风险。同模块其他 data-access(如 elective/data-access.ts L1、settings/data-access.ts L1)均规范声明了 `import \"server-only\"`。", + "recommendation": "在文件第一行添加 `import \"server-only\"`,与项目其他 data-access 文件保持一致:\n```ts\nimport \"server-only\"\n\nimport { eq, and } from \"drizzle-orm\"\n// ...\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-002", + "file": "src/modules/onboarding/actions.ts", + "lines": "L15-L16, L72-L76", + "ruleId": "A-09", + "severity": "P0", + "dimension": "architecture", + "title": "actions.ts 含直接 DB 查询,违反三层架构", + "description": "actions.ts 在 L15-L16 直接 `import { db } from \"@/shared/db\"` 与 `import { users } from \"@/shared/db/schema\"`,并在 completeOnboardingAction 内部 L72-L76 直接执行 `db.select({ onboardedAt: users.onboardedAt }).from(users).where(eq(users.id, userId)).limit(1)`。按架构规则 `app/ → modules/ → shared/` 单向依赖,actions 层是编排层,应通过 data-access 访问 DB,不得直查 schema 表。直查 DB 还会绕过 cacheFn 缓存层。", + "recommendation": "在 onboarding/data-access.ts 中新增 `getUserOnboardedAt(userId): Promise` 读函数(含 cacheFn 包装),actions.ts 改为调用该函数:\n```ts\n// data-access.ts\nexport const getUserOnboardedAtRaw = async (userId: string): Promise => {\n const [row] = await db.select({ onboardedAt: users.onboardedAt })\n .from(users).where(eq(users.id, userId)).limit(1)\n return row?.onboardedAt ?? null\n}\nexport const getUserOnboardedAt = cacheFn(getUserOnboardedAtRaw, {\n tags: [\"onboarding\"], ttl: 300, keyParts: [\"onboarding\", \"onboarded-at\"],\n})\n\n// actions.ts\nimport { getUserOnboardedAt } from \"./data-access\"\nconst existingOnboardedAt = await getUserOnboardedAt(userId)\nif (existingOnboardedAt) { /* ... */ }\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G5-003", + "file": "src/modules/elective/data-access-operations.ts", + "lines": "L19-L46, L90-L174, L222-L304, L306-L396", + "ruleId": "A-02", + "severity": "P1", + "dimension": "architecture", + "title": "data-access 含大量业务逻辑(状态机、冲突检测、抽签算法、i18n 通知)", + "description": "文件混入了大量本应位于 actions 或 lib 的业务逻辑:\n1. L19-L46 自定义 `ElectiveBusinessError` 业务错误类(含 i18n code 映射)\n2. L62-L78 `DAY_NORMALIZE_MAP` 星期归一化映射\n3. L90-L119 `parseSchedule` 时间段解析(含正则)\n4. L125-L131 `isScheduleConflict` 时间冲突判定\n5. L137-L174 `checkScheduleConflict` 业务校验\n6. L185-L220 `checkCreditLimit` 学分上限业务校验\n7. L222-L304 `runLottery` Fisher-Yates 抽签算法\n8. L410-L446 `notifyCapacityThresholdIfNeeded` 调用 `getTranslations` + `sendNotification` 跨模块通知\ndata-access 层应只做 CRUD 与简单映射,业务规则、状态机、跨模块编排应下沉到 lib 或 actions。", + "recommendation": "拆分为三层:\n1. `elective/lib/schedule-conflict.ts` —— 纯函数 `parseSchedule` / `isScheduleConflict` / `normalizeDay`\n2. `elective/lib/lottery.ts` —— `runLotteryShuffle` 纯函数\n3. `elective/lib/business-rules.ts` —— `checkScheduleConflict` / `checkCreditLimit` / `ElectiveBusinessError`(接受 tx 与必要 data-access 函数作为参数)\n4. `elective/actions.ts` —— `notifyCapacityThresholdIfNeeded` 移至 actions(含 i18n + sendNotification)\ndata-access-operations.ts 只保留 `selectCourse` / `dropCourse` / `runLottery` 的 DB 写入部分。", + "effort": "L (≤1 天)" + }, + { + "id": "G5-004", + "file": "src/modules/elective/data-access-operations.ts", + "lines": "L222-L304", + "ruleId": "S-08", + "severity": "P1", + "dimension": "structure", + "title": "runLottery 中 DB 写入与抽签算法混淆,职责不清", + "description": "`runLottery` 函数同时承担:1) 查询课程与选课记录(DB 访问);2) Fisher-Yates shuffle 抽签(业务算法);3) 事务内批量更新状态(DB 写入)。函数 83 行,复杂度高,难以单元测试抽签逻辑(需 mock DB)。", + "recommendation": "拆分为:\n```ts\n// lib/lottery.ts\nexport function runLotteryShuffle(selections: CourseSelection[], capacity: number): {\n enrolledIds: string[]; waitlistIds: string[]\n} { /* 纯函数 Fisher-Yates */ }\n\n// data-access-operations.ts\nexport async function persistLotteryResult(\n courseId: string, enrolledIds: string[], waitlistIds: string[], capacity: number\n): Promise { /* 仅 DB 写入 */ }\n\n// actions.ts\nexport async function runLotteryAction(...) {\n const [course, selections] = await Promise.all([...])\n const { enrolledIds, waitlistIds } = runLotteryShuffle(selections, course.capacity)\n await persistLotteryResult(courseId, enrolledIds, waitlistIds, course.capacity)\n}\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-005", + "file": "src/modules/files/data-access.ts", + "lines": "L55, L73, L101, L126, L150, L169, L186, L195, L244, L281, L305, L328", + "ruleId": "A-10", + "severity": "P1", + "dimension": "architecture", + "title": "data-access 含 12 处 console.error 调试代码", + "description": "文件中几乎所有函数都用 `console.error(...)` 记录错误:L55 `createFileAttachment failed`、L73 `getFileAttachment failed`、L101 `getFileAttachmentsByTarget failed`、L126 `getFileAttachmentsByUploader failed`、L150 `getAllFileAttachments failed`、L169 `deleteFileAttachment failed`、L186/L195 `deleteFileAttachments batch/single failed`、L244 `getFileAttachmentsWithFilters failed`、L281 `getFileStats failed`、L305 `getFileByUrl failed`、L328 `getFileAttachmentsByIds failed`。data-access 层应通过 throw 上抛错误由 actions 层统一处理,不应自行 console 输出,污染生产日志且违反 A-10 规则。", + "recommendation": "删除所有 `console.error`,改为 throw 上抛:\n```ts\nexport async function createFileAttachment(data: CreateFileAttachmentInput): Promise {\n await db.insert(fileAttachments).values({...})\n return getFileAttachment(data.id)\n}\n// actions 层已有 handleActionError 统一处理\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G5-006", + "file": "src/modules/files/data-access.ts", + "lines": "L35-L58, L63-L76, L87-L105, L116-L129, L140-L153, L212-L248, L259-L284, L295-L308, L319-L331", + "ruleId": "P-05", + "severity": "P1", + "dimension": "pattern", + "title": "data-access 层用 try-catch 返回 null/false/空数组,违反 throw 上抛约定", + "description": "所有函数均用 `try { ... } catch (error) { console.error(...); return null/[]/false }` 模式吞掉错误。这导致:1) actions 层无法区分“记录不存在”与“DB 异常”;2) 错误被静默吞掉,监控告警失效;3) 违反 P-05 规则(data-access 层用 throw,actions 层用 ActionState)。例如 createFileAttachment 失败返回 null,actions 层只能返回“Failed to persist file record”,丢失原始错误信息。", + "recommendation": "移除 try-catch,让错误自然上抛:\n```ts\nexport async function getFileAttachmentRaw(id: string): Promise {\n const [row] = await db.select().from(fileAttachments)\n .where(eq(fileAttachments.id, id)).limit(1)\n return row ? mapRow(row) : null\n}\n// actions.ts 的 handleActionError 会捕获并转为 ActionState\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G5-007", + "file": "src/modules/files/data-access.ts", + "lines": "L66, L89, L119, L142, L236, L262, L298, L322", + "ruleId": "F-03", + "severity": "P1", + "dimension": "performance", + "title": "多处 `db.select().from(fileAttachments)` 未指定列,等价 SELECT *", + "description": "8 处查询使用 `db.select().from(fileAttachments)` 未显式枚举列,等价于 `SELECT *`。返回所有列包括 `storagePath`、`url` 等敏感字段,增加网络传输与内存占用,且 schema 变更时可能引入意外字段。对比 announcements/data-access.ts L84-L99 显式枚举了 13 个列。", + "recommendation": "显式枚举所需列:\n```ts\nconst FILE_FIELDS = {\n id: fileAttachments.id,\n filename: fileAttachments.filename,\n originalName: fileAttachments.originalName,\n mimeType: fileAttachments.mimeType,\n size: fileAttachments.size,\n storagePath: fileAttachments.storagePath,\n url: fileAttachments.url,\n uploaderId: fileAttachments.uploaderId,\n targetType: fileAttachments.targetType,\n targetId: fileAttachments.targetId,\n createdAt: fileAttachments.createdAt,\n} as const\n\nexport const getFileAttachmentRaw = async (id: string) => {\n const [row] = await db.select(FILE_FIELDS).from(fileAttachments)\n .where(eq(fileAttachments.id, id)).limit(1)\n return row ? mapRow(row) : null\n}\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-008", + "file": "src/modules/search/data-access.ts", + "lines": "L150, L191, L235", + "ruleId": "P-09", + "severity": "P1", + "dimension": "pattern", + "title": "使用 `!` 非空断言绕过 null 检查", + "description": "三处对 `or(...)` 返回值使用 `!` 非空断言:L150 `or(like(textbooks.title, kw), like(textbooks.subject, kw), like(textbooks.publisher, kw))!`、L191 `or(like(exams.title, kw), like(exams.description, kw))!`、L235 `or(like(announcements.title, kw), like(announcements.content, kw))!`。`or()` 在所有参数为 undefined 时返回 null,使用 `!` 断言会绕过类型系统的安全保护,违反 P-09 规则(禁止 as 断言与非空断言,除 unknown 收窄)。", + "recommendation": "使用条件判断或 filter 模式:\n```ts\nconst conditions = [\n like(textbooks.title, kw),\n like(textbooks.subject, kw),\n like(textbooks.publisher, kw),\n].filter(Boolean) as ReturnType[]\nconst where = conditions.length > 0 ? or(...conditions) : undefined\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G5-009", + "file": "src/modules/search/data-access.ts", + "lines": "L147-L150, L191, L235", + "ruleId": "F-02", + "severity": "P1", + "dimension": "performance", + "title": "textbooks/exams/announcements 三表搜索使用 `LIKE '%kw%'` 全表扫描", + "description": "searchTextbooksRaw L147-L150、searchExamsRaw L191、searchAnnouncementsRaw L235 均使用 `like(column, kw)` 其中 kw 为 `%${search}%` 格式,前缀通配符导致无法走索引,全表扫描。注释 L18-L20 已说明 questions 表用了 FULLTEXT 索引(L101 `MATCH ... AGAINST ... IN BOOLEAN MODE`),但其他三表仍用 LIKE。随着数据量增长,搜索性能会急剧下降。", + "recommendation": "为 textbooks.title/subject/publisher、exams.title/description、announcements.title/content 添加 FULLTEXT 索引(MySQL)或 GIN 索引(PostgreSQL),改用 MATCH AGAINST:\n```sql\nALTER TABLE textbooks ADD FULLTEXT INDEX ft_textbooks_search (title, subject, publisher);\nALTER TABLE exams ADD FULLTEXT INDEX ft_exams_search (title, description);\nALTER TABLE announcements ADD FULLTEXT INDEX ft_announcements_search (title, content);\n```\n```ts\n.where(sql`MATCH(${textbooks.title}, ${textbooks.subject}, ${textbooks.publisher}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)\n```", + "effort": "L (≤1 天)" + }, + { + "id": "G5-010", + "file": "src/modules/onboarding/data-access.ts", + "lines": "L87-L133", + "ruleId": "A-02", + "severity": "P1", + "dimension": "architecture", + "title": "bindParentToChild 含三因子验证业务逻辑,应移至 lib 或 actions", + "description": "`bindParentToChild` 函数 L87-L133 包含:1) 三因子验证业务规则(邮箱 + 生日 + 手机后4位,L97-L111);2) 幂等检查(L114-L131);3) 错误返回 `{ error: string }` 而非 throw。这是核心业务逻辑(对标 PowerSchool Access ID 验证),不应位于 data-access 层。data-access 应只做 CRUD,验证规则应下沉到 lib/business-rules 或 actions。", + "recommendation": "拆分:\n1. `onboarding/lib/parent-binding.ts` —— `validateChildFactors(child, params): string | null` 纯函数验证三因子\n2. `onboarding/data-access.ts` —— `insertParentStudentRelation(parentId, studentId, relation): Promise` 仅做幂等插入\n3. `onboarding/actions.ts` —— 编排:查询 child → 调用 validateChildFactors → 调用 insertParentStudentRelation", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-011", + "file": "src/modules/settings/data-access.ts", + "lines": "L23-L38", + "ruleId": "F-05", + "severity": "P1", + "dimension": "performance", + "title": "getAiProviderSummariesRaw 查询所有 AI Provider 无 LIMIT", + "description": "`getAiProviderSummariesRaw` 查询 `aiProviders` 表全部记录,仅 `.orderBy(desc(aiProviders.updatedAt))`,无 LIMIT 限制。若管理员未清理历史 Provider 记录,可能返回数百条数据。对比 files/data-access.ts 的 `getAllFileAttachmentsRaw` 有默认 `limit = 100`。", + "recommendation": "添加默认 LIMIT:\n```ts\nexport async function getAiProviderSummariesRaw(limit = 200): Promise {\n const rows = await db.select({...}).from(aiProviders)\n .orderBy(desc(aiProviders.updatedAt))\n .limit(limit)\n return rows\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-012", + "file": "src/modules/settings/data-access-system-settings.ts", + "lines": "L131-L142", + "ruleId": "F-01", + "severity": "P1", + "dimension": "performance", + "title": "upsertSystemSettings 循环内调用 upsertSystemSetting,每次含查询+写入,N+1 问题", + "description": "`upsertSystemSettings` L140-L142 对每个 item 调用 `upsertSystemSetting`,而 `upsertSystemSetting` 内部 L110-L126 先 `getSystemSetting`(虽走缓存)再 `db.update` 或 `db.insert`。批量保存 N 个设置项时执行 N 次独立写入,无事务包裹,且中间失败会导致部分成功部分失败的数据不一致。", + "recommendation": "改为单次事务批量 upsert(MySQL `INSERT ... ON DUPLICATE KEY UPDATE`):\n```ts\nexport async function upsertSystemSettings(\n items: ReadonlyArray<{...}>, updatedBy?: string\n): Promise {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n const rows = items.map((item) => ({\n category: item.category, key: item.key, value: item.value,\n valueType: item.valueType, updatedBy: updatedBy ?? null, updatedAt: new Date(),\n }))\n await tx.insert(systemSettings).values(rows)\n .onDuplicateKeyUpdate({\n set: { value: sql`VALUES(value)`, valueType: sql`VALUES(value_type)`,\n updatedBy: sql`VALUES(updated_by)`, updatedAt: sql`VALUES(updated_at)` }\n })\n })\n}\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-013", + "file": "src/modules/elective/data-access.ts", + "lines": "L165-L169", + "ruleId": "F-05", + "severity": "P1", + "dimension": "performance", + "title": "getElectiveCoursesRaw 大表查询无默认 LIMIT", + "description": "`getElectiveCoursesRaw` 查询 `electiveCourses` 表,仅 `.orderBy(desc(electiveCourses.createdAt))`,无 LIMIT。当管理员查询全部课程或 gradeId 过滤返回大量记录时,会一次性返回所有匹配行。对比 announcements/data-access.ts L104 有 `pageSize` 分页。", + "recommendation": "添加默认 LIMIT 或分页参数:\n```ts\nexport const getElectiveCoursesRaw = async (\n params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string; limit?: number }\n): Promise => {\n const limit = params?.limit ?? 200\n // ...\n const rows = await (conditions.length > 0\n ? query.where(and(...conditions)) : query\n ).orderBy(desc(electiveCourses.createdAt)).limit(limit)\n // ...\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-014", + "file": "src/modules/elective/data-access-operations.ts", + "lines": "L228-L241", + "ruleId": "F-03", + "severity": "P1", + "dimension": "performance", + "title": "runLottery 内 `db.select().from(...)` 未指定列,等价 SELECT *", + "description": "`runLottery` L228-L241 两处 `db.select().from(electiveCourses)` 和 `db.select().from(courseSelections)` 未指定列,等价 SELECT *。electiveCourses 表含 description、schedule 等长文本字段,courseSelections 含 dropReason 等字段,全量返回浪费内存与网络带宽。此外 selectCourse L317-L321、dropCourse L454-L464 也使用 `db.select().from(...)` 全列查询。", + "recommendation": "显式枚举所需列:\n```ts\nconst [courseRows, selections] = await Promise.all([\n db.select({\n id: electiveCourses.id, capacity: electiveCourses.capacity,\n selectionMode: electiveCourses.selectionMode, enrolledCount: electiveCourses.enrolledCount,\n }).from(electiveCourses).where(eq(electiveCourses.id, courseId)).limit(1),\n db.select({\n id: courseSelections.id, priority: courseSelections.priority,\n selectedAt: courseSelections.selectedAt,\n }).from(courseSelections).where(...).orderBy(...),\n])\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-015", + "file": "src/modules/error-book/data-access.ts", + "lines": "L135-L138", + "ruleId": "F-02", + "severity": "P2", + "dimension": "performance", + "title": "getErrorBookItemsRaw 使用 `LIKE '%q%'` 全表扫描搜索 note 字段", + "description": "L136-L137 构造 `needle = '%${q.trim().toLowerCase()}%'` 并使用 `sql\\`LOWER(CAST(${errorBookItems.note} AS CHAR)) LIKE ${needle}\\``。前缀通配符 `%` 导致无法走索引,且 `LOWER(CAST(... AS CHAR))` 函数包裹进一步阻止索引使用。学生错题量增长后搜索性能下降。", + "recommendation": "为 errorBookItems.note 添加 FULLTEXT 索引,或改为前缀匹配 `LIKE '${q}%'`(可走索引):\n```sql\nALTER TABLE error_book_items ADD FULLTEXT INDEX ft_note_search (note);\n```\n```ts\nif (q && q.trim().length > 0) {\n conditions.push(sql`MATCH(${errorBookItems.note}) AGAINST(${q.trim()} IN BOOLEAN MODE)`)\n}\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-016", + "file": "src/modules/error-book/data-access-analytics.ts", + "lines": "L531, L542", + "ruleId": "P-09", + "severity": "P2", + "dimension": "pattern", + "title": "使用 `as string` 断言,应改用类型守卫", + "description": "L531 `const subjectIds = filtered.map((r) => r.subjectId as string)` 和 L542 `const sid = row.subjectId as string` 使用 `as string` 断言。虽然 L527 已通过 `rows.filter((r) => r.subjectId !== null)` 过滤了 null,但 `as` 断言绕过了类型系统,违反 P-09 规则。项目规范要求用类型守卫替代 as 断言。", + "recommendation": "使用类型守卫:\n```ts\nconst subjectIds = filtered\n .map((r) => r.subjectId)\n .filter((s): s is string => s !== null)\n// ...\nreturn filtered.map((row) => {\n const sid = row.subjectId as string // 改为:\n const sid: string = row.subjectId ?? \"\" // 或提前 filter\n // ...\n})\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-017", + "file": "src/modules/settings/data-access.ts", + "lines": "L259-L315", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "密码相关读函数未走 cacheFn 包装(Raw + Wrapper 配对)", + "description": "`getUserPasswordHash` (L259-L268)、`getPasswordSecurityByUserId` (L270-L279) 是读函数但未提供 Raw + cacheFn Wrapper 配对。对比同文件 `getAiProviderSummariesRaw` + `getAiProviderSummaries` 配对模式。密码相关查询虽可能不希望缓存(安全考虑),但应明确注释说明不缓存的原因,或提供短 TTL 缓存。", + "recommendation": "明确注释不缓存原因,或提供短 TTL 缓存:\n```ts\n/** 读取用户密码哈希(不缓存,安全敏感数据) */\nexport async function getUserPasswordHash(userId: string): Promise<{ password: string | null } | null> {\n // 安全考虑:密码哈希不缓存,避免缓存泄露风险\n const [row] = await db.select({ password: users.password })\n .from(users).where(eq(users.id, userId)).limit(1)\n return row ?? null\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-018", + "file": "src/modules/settings/data-access-two-factor.ts", + "lines": "L30-L129", + "ruleId": "P-03", + "severity": "P2", + "dimension": "pattern", + "title": "2FA 读函数未提供 Raw + Wrapper 配对,依赖上游 cacheFn", + "description": "`getTwoFactorEnabled` (L30-L33)、`getTwoFactorEnabledAt` (L48-L53)、`getTotpSecret` (L70-L73)、`getBackupCodesHashed` (L101-L105) 等读函数均直接调用 `getSystemSetting`(已 cacheFn 包装),但自身未提供 Raw + Wrapper 配对。严格按 P-03 规则,所有读函数应有 Raw + cacheFn 配对。当前模式虽依赖上游缓存,但 keyParts 不会包含 2FA 特定维度,缓存粒度不准确。", + "recommendation": "为 2FA 读函数提供独立 cacheFn 配对:\n```ts\nexport const getTwoFactorEnabledRaw = async (userId: string): Promise => {\n const record = await getSystemSetting(CATEGORY, k(\"twoFactorEnabled\", userId))\n return record?.value === \"true\"\n}\nexport const getTwoFactorEnabled = cacheFn(getTwoFactorEnabledRaw, {\n tags: [\"settings\", \"two-factor\"], ttl: 60,\n keyParts: [\"settings\", \"two-factor\", \"enabled\"],\n})\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-019", + "file": "src/modules/elective/data-access.ts", + "lines": "L61-L62", + "ruleId": "P-07", + "severity": "P2", + "dimension": "pattern", + "title": "startDate/endDate 序列化未走 toIso helper,硬编码 slice(0, 10)", + "description": "L61-L62 `startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null` 和 `endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null` 硬编码了 `.toISOString().slice(0, 10)` 逻辑。同文件 L22-L25 已定义 `toIso`/`toIsoRequired` helper,但此处未复用,且 `slice(0, 10)` 截取日期部分的行为应封装为 `toISODateString` helper 统一管理。", + "recommendation": "提取共享 helper 并复用:\n```ts\nconst toISODateString = (d: Date | null | undefined): string | null =>\n d ? d.toISOString().slice(0, 10) : null\n\n// mapCourseRow 中\nstartDate: toISODateString(r.startDate),\nendDate: toISODateString(r.endDate),\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-020", + "file": "src/modules/announcements/data-access.ts", + "lines": "L413-L463, L472-L485", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "resolveUserAudience / isAnnouncementVisibleToAudience 含业务逻辑", + "description": "`resolveUserAudience` (L413-L463) 根据 dataScope.type 分支处理 5 种受众类型,含 classIds → gradeIds 解析、childrenIds 遍历等业务编排逻辑;`isAnnouncementVisibleToAudience` (L472-L485) 是纯业务规则判断函数。两者属于业务规则层,应移至 lib 或 actions。当前 data-access 同时承担数据查询与业务规则判断,职责混淆。", + "recommendation": "将业务逻辑下沉到 `announcements/lib/audience.ts`:\n```ts\n// lib/audience.ts\nexport function isAnnouncementVisibleToAudience(announcement, audience): boolean { /* 纯函数 */ }\nexport async function resolveUserAudience(\n userId: string, dataScope: DataScope,\n deps: { getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId }\n): Promise { /* 接受依赖注入 */ }\n```\ndata-access 仅保留 `getAnnouncementByIdForUser` 的数据查询部分。", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-021", + "file": "src/modules/announcements/data-access.ts", + "lines": "全文 606 行", + "ruleId": "S-01", + "severity": "P2", + "dimension": "structure", + "title": "文件 606 行,接近 800 行警告线", + "description": "announcements/data-access.ts 共 606 行,已超过项目规范的“建议 ≤ 800 行”软上限的 75%。文件同时包含:CRUD(insertAnnouncement 等)、分页查询(getAnnouncements)、已读回执(markAnnouncementAsRead 等)、编排函数(getAdminAnnouncementsPageData 等 5 个)、业务逻辑(resolveUserAudience 等)。继续增长将突破警告线。", + "recommendation": "拆分为:\n1. `data-access.ts` —— 纯 CRUD(insert/update/delete/publish/archive)\n2. `data-access-reads.ts` —— 查询函数(getAnnouncements, getAnnouncementById, countAnnouncements, 已读回执查询)\n3. `data-access-page.ts` —— 编排函数(getAdminAnnouncementsPageData 等)\n4. `lib/audience.ts` —— resolveUserAudience, isAnnouncementVisibleToAudience", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-022", + "file": "src/modules/settings/data-access-system-settings.ts", + "lines": "L65-L68", + "ruleId": "F-03", + "severity": "P2", + "dimension": "performance", + "title": "getAllSystemSettingsRaw 使用 `db.select().from(systemSettings)` 未指定列", + "description": "`getAllSystemSettingsRaw` L66 `const rows = await db.select().from(systemSettings)` 未显式枚举列,等价 SELECT *。systemSettings 表含 id、category、key、value、valueType、updatedBy、updatedAt、createdAt 等列,全量返回增加传输开销。对比 getSystemSettingRaw L83-L87 虽也未枚举但有 limit(1)。", + "recommendation": "显式枚举列:\n```ts\nexport async function getAllSystemSettingsRaw(): Promise {\n const rows = await db.select({\n id: systemSettings.id, category: systemSettings.category,\n key: systemSettings.key, value: systemSettings.value,\n valueType: systemSettings.valueType, updatedBy: systemSettings.updatedBy,\n updatedAt: systemSettings.updatedAt,\n }).from(systemSettings)\n return rows\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-023", + "file": "src/modules/elective/data-access-selections.ts", + "lines": "L43-L46", + "ruleId": "S-03", + "severity": "P2", + "dimension": "structure", + "title": "toIso/toIsoRequired helper 在 elective 模块内重复定义", + "description": "L43-L46 定义了 `toIso` 和 `toIsoRequired` helper,但 elective/data-access.ts L22-L25 已定义了相同的 helper。两处实现完全一致:\n```ts\nconst toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null\nconst toIsoRequired = (d: Date): string => d.toISOString()\n```\n违反 DRY 原则,应提取到 shared/lib 或模块内 lib 目录。", + "recommendation": "提取到 `elective/lib/date-utils.ts` 或复用 `@/shared/lib/date-utils`:\n```ts\n// elective/lib/date-utils.ts\nexport const toIso = (d: Date | null | undefined): string | null =>\n d ? d.toISOString() : null\nexport const toIsoRequired = (d: Date): string => d.toISOString()\nexport const toISODateString = (d: Date | null | undefined): string | null =>\n d ? d.toISOString().slice(0, 10) : null\n```\n两个 data-access 文件统一 import。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-024", + "file": "src/modules/onboarding/data-access.ts", + "lines": "L89, L97-L111", + "ruleId": "P-05", + "severity": "P2", + "dimension": "pattern", + "title": "bindParentToChild 返回 `{ error: string }` 而非 throw,违反 data-access 错误处理约定", + "description": "`bindParentToChild` 返回类型为 `Promise<{ studentId: string } | { error: string }>`,L97/L98/L99/L104/L110 用 `return { error: \"...\" }` 表示业务错误。按 P-05 规则,data-access 层应用 throw 上抛错误(如 `throw new BusinessError(...)`),actions 层用 ActionState 捕获。当前模式让调用方需要用 `\"error\" in result` 判断,违反统一错误处理约定。", + "recommendation": "改用 throw + 自定义 BusinessError:\n```ts\nexport class OnboardingBusinessError extends BusinessError {\n constructor(public readonly code: string, public readonly params?: Record) {\n super(`onboarding.errors.${code}`, code)\n }\n}\n\nexport async function bindParentToChild(params: BindParentToChildParams): Promise<{ studentId: string }> {\n // ...\n if (!child) throw new OnboardingBusinessError(\"childNotFound\")\n if (!child.birthDate) throw new OnboardingBusinessError(\"childNoBirthDate\")\n // ...\n return { studentId: child.id }\n}\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G5-025", + "file": "src/modules/settings/data-access.ts", + "lines": "L259-L315", + "ruleId": "S-06", + "severity": "P2", + "dimension": "structure", + "title": "密码相关函数缺 JSDoc 文档", + "description": "`getUserPasswordHash` (L259)、`getPasswordSecurityByUserId` (L270)、`updateUserPassword` (L281)、`upsertPasswordSecurityOnPasswordChange` (L292) 四个公共导出函数均无 JSDoc 注释。对比同文件 `getAiProviderSummariesRaw` (L18-L22)、`getAiProviderForUpdateRaw` (L102-L106) 均有 JSDoc。S-06 规则要求公共导出函数补齐 JSDoc。", + "recommendation": "为每个函数添加 JSDoc:\n```ts\n/**\n * 读取用户密码哈希(用于密码变更时校验旧密码)\n * @param userId 用户 ID\n * @returns 密码哈希记录,用户不存在时返回 null\n */\nexport async function getUserPasswordHash(userId: string): Promise<{ password: string | null } | null> { ... }\n\n/**\n * 查询用户的密码安全记录(用于判断是否需要强制改密)\n * @param userId 用户 ID\n * @returns 记录 ID(存在时),不存在返回 null\n */\nexport async function getPasswordSecurityByUserId(userId: string): Promise<{ id: string } | null> { ... }\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-026", + "file": "src/modules/onboarding/actions.ts", + "lines": "L38, L68", + "ruleId": "A-08", + "severity": "P3", + "dimension": "architecture", + "title": "使用 requireAuth 而非 requirePermission,权限校验粒度不足", + "description": "`getOnboardingStatusAction` L38 和 `completeOnboardingAction` L68 使用 `requireAuth()` 而非 `requirePermission(Permissions.XXX)`。`requireAuth` 仅校验登录态,不校验具体权限点。onboarding 流程虽面向所有已登录用户,但按 A-08 规则应使用 requirePermission 显式声明权限点(如 ONBOARDING_READ / ONBOARDING_COMPLETE),便于权限审计与角色-权限矩阵管理。", + "recommendation": "添加 ONBOARDING 权限点并使用 requirePermission:\n```ts\n// shared/types/permissions.ts\nexport const Permissions = {\n // ...\n ONBOARDING_READ: \"onboarding:read\",\n ONBOARDING_COMPLETE: \"onboarding:complete\",\n} as const\n\n// actions.ts\nconst ctx = await requirePermission(Permissions.ONBOARDING_COMPLETE)\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-027", + "file": "src/modules/elective/data-access-settings.ts", + "lines": "L58-L73", + "ruleId": "F-06", + "severity": "P2", + "dimension": "performance", + "title": "getElectiveCreditLimitRaw 串行两次 readSettingValue,可合并查询", + "description": "`getElectiveCreditLimitRaw` L62-L73 先查 `creditLimit:grade:`,若未命中再查 `creditLimit:default`,两次串行 DB 查询。虽每次有 cacheFn,但首次未命中缓存时仍需 2 次往返。可用 OR 查询合并为单次:\n```ts\nconst [gradeRow, defaultRow] = await Promise.all([\n readSettingValue(`creditLimit:grade:${gradeId}`),\n readSettingValue(\"creditLimit:default\"),\n])\n```", + "recommendation": "改为 Promise.all 并行查询:\n```ts\nexport const getElectiveCreditLimitRaw = async (gradeId?: string | null): Promise => {\n if (gradeId) {\n const [gradeValue, defaultValue] = await Promise.all([\n readSettingValue(`creditLimit:grade:${gradeId}`),\n readSettingValue(\"creditLimit:default\"),\n ])\n if (gradeValue !== null) {\n const parsed = Number(gradeValue)\n if (!Number.isNaN(parsed) && parsed > 0) return parsed\n }\n if (defaultValue !== null) {\n const parsed = Number(defaultValue)\n if (!Number.isNaN(parsed) && parsed > 0) return parsed\n }\n return DEFAULT_MAX_CREDIT_PER_TERM\n }\n // ...\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-028", + "file": "src/modules/elective/data-access-selections.ts", + "lines": "L107-L109, L123-L125", + "ruleId": "F-05", + "severity": "P2", + "dimension": "performance", + "title": "getCourseSelectionsRaw / getStudentSelectionsRaw 无 LIMIT", + "description": "`getCourseSelectionsRaw` L107-L109 按 courseId 查询选课记录,`getStudentSelectionsRaw` L123-L125 按 studentId 查询,均无 LIMIT。教师视角下热门课程可能有数百条选课记录,学生视角下四年累计选课也可能较多。建议添加默认 LIMIT 防止极端情况。", + "recommendation": "添加默认 LIMIT:\n```ts\nexport const getCourseSelectionsRaw = async (courseId: string): Promise => {\n const rows = await buildSelectionCoreSelect()\n .where(eq(courseSelections.courseId, courseId))\n .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))\n .limit(500) // 默认上限\n // ...\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-029", + "file": "src/modules/files/data-access.ts", + "lines": "L224-L230", + "ruleId": "F-02", + "severity": "P2", + "dimension": "performance", + "title": "getFileAttachmentsWithFiltersRaw 使用 `LIKE '%search%'` 全表扫描", + "description": "L225 `const kw = '%${search}%'`,L227-L229 `like(fileAttachments.originalName, kw)` 和 `like(fileAttachments.filename, kw)` 使用前缀通配符 `%`,无法走索引。管理员文件管理页面搜索文件时全表扫描 fileAttachments 表。", + "recommendation": "为 originalName 和 filename 添加 FULLTEXT 索引,或改为前缀匹配:\n```sql\nALTER TABLE file_attachments ADD FULLTEXT INDEX ft_filename_search (original_name, filename);\n```\n```ts\nif (search) {\n conditions.push(sql`MATCH(${fileAttachments.originalName}, ${fileAttachments.filename}) AGAINST(${search} IN BOOLEAN MODE)`)\n}\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-030", + "file": "src/modules/announcements/data-access.ts", + "lines": "L343-L344, L366-L367, L426, L438, L454, L575, L592, L597", + "ruleId": "A-06", + "severity": "P3", + "dimension": "architecture", + "title": "大量使用 dynamic import 调用跨模块 data-access,建议改为静态 import", + "description": "文件中 8 处使用 `await import(\"@/modules/xxx/data-access\")` 动态导入:L343 `getGrades`、L344 `getAdminClasses`、L366 `getGrades`、L426 `getClassGradeId`、L438 `getStudentActiveClassId`/`getStudentActiveGradeId`、L454 同上、L575 `getAllUserIds`、L592 `getUserIdsByGradeId`、L597 `getStudentIdsByClassId`/`getTeacherIdsByClassIds`。动态 import 增加运行时开销,且无法被构建工具静态分析优化。虽可能为避免循环依赖,但 announcements 与 classes/users/school 模块间无循环依赖风险。", + "recommendation": "改为静态 import:\n```ts\nimport { getGrades } from \"@/modules/school/data-access\"\nimport { getAdminClasses, getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId, getStudentIdsByClassId, getTeacherIdsByClassIds } from \"@/modules/classes/data-access\"\nimport { getAllUserIds, getUserIdsByGradeId } from \"@/modules/users/data-access\"\n```\n若确有循环依赖,应重构模块边界而非用 dynamic import 规避。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G5-031", + "file": "src/modules/elective/data-access-operations.ts", + "lines": "L378-L379, L410-L446", + "ruleId": "A-02", + "severity": "P2", + "dimension": "architecture", + "title": "notifyCapacityThresholdIfNeeded 调用 getTranslations + sendNotification,含 i18n 与通知编排", + "description": "`notifyCapacityThresholdIfNeeded` L410-L446 在 data-access 层调用 `getTranslations(\"elective\")` (L421) 获取 i18n 文案,并调用 `sendNotification` (L430) 发送跨模块通知。i18n 与通知编排属于业务逻辑层职责,不应位于 data-access。此外 L379 在事务内 `void notifyCapacityThresholdIfNeeded(course, newEnrolledCount)` 触发 fire-and-forget,虽不阻塞事务,但 data-access 层不应有副作用编排。", + "recommendation": "移至 actions 层:\n```ts\n// data-access-operations.ts\nexport async function selectCourse(courseId, studentId, priority?): Promise<{ status: CourseSelectionStatus; course: Course }> {\n // ... 返回 course 与 newEnrolledCount 供 actions 判断\n}\n\n// actions.ts\nconst { status, course, newEnrolledCount } = await selectCourse(...)\nif (status === \"enrolled\") {\n await notifyCapacityThresholdIfNeeded(course, newEnrolledCount)\n}\nawait invalidateFor(...)\n```", + "effort": "M (≤2 小时)" + }, + { + "id": "G5-032", + "file": "src/modules/settings/data-access.ts", + "lines": "L153-L171, L188-L205", + "ruleId": "F-09", + "severity": "P3", + "dimension": "performance", + "title": "updateAiProvider / createAiProvider 事务内含条件分支但范围合理", + "description": "`updateAiProvider` (L153-L171) 和 `createAiProvider` (L188-L205) 在事务内执行:1) 可选的重置其他默认 Provider;2) 主表 update/insert。事务范围仅含 DB 操作,无网络调用,范围合理。但 `resetOtherDefaults` 在事务内全表 update,当 Provider 数量多时可能锁表。标记为 P3 提示关注。", + "recommendation": "可接受现状。若 Provider 数量增长,可考虑:1) 添加 WHERE 过滤条件缩小 update 范围;2) 用乐观锁替代事务。当前实现合理,无需立即修改。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-033", + "file": "src/modules/error-book/data-access.ts", + "lines": "L400-L438", + "ruleId": "A-02", + "severity": "P3", + "dimension": "architecture", + "title": "recordReview 含 SM-2 算法应用逻辑,处于边界", + "description": "`recordReview` L409-L413 调用 `calculateNewInterval`、`calculateNewMastery`、`deriveStatus`、`calculateNextReviewAt`、`calculateNewCorrectStreak` 计算 SM-2 算法派生值。这些计算虽是业务逻辑,但已封装在 `sm2-algorithm.ts` 纯函数模块中,data-access 仅调用并持久化结果。处于可接受边界,但严格按 A-02 规则,算法应用应位于 lib 或 actions。", + "recommendation": "可接受现状。若严格遵循规则,可将 SM-2 计算移至 `error-book/lib/review.ts`,data-access 仅接受计算结果并持久化:\n```ts\n// lib/review.ts\nexport function computeReviewResult(item, result): { newInterval, newMastery, newStatus, nextReviewAt } { ... }\n\n// data-access.ts\nconst reviewResult = computeReviewResult(item, result)\nawait db.transaction(async (tx) => { /* 持久化 reviewResult */ })\n```", + "effort": "S (≤30 分钟)" + }, + { + "id": "G5-034", + "file": "src/modules/ai/data-access.ts", + "lines": "L33-L49, L63-L139", + "ruleId": "A-02", + "severity": "P3", + "dimension": "architecture", + "title": "内存事件存储(eventStore)位于 data-access,应为独立 service", + "description": "L35 `const eventStore: StoredAiEvent[] = []` 模块级内存数组,L43-L49 `recordAiEvent` 写入函数,L63-L139 `getAiUsageStatsRaw` 聚合统计。data-access 层应封装 DB 访问,而内存事件存储是临时实现(注释 L9-L18 说明生产环境应替换为 DB/Redis)。当前实现虽可工作,但混合了存储实现与数据访问接口。", + "recommendation": "提取为独立 service:\n```ts\n// ai/services/usage-tracker-store.ts\nconst eventStore: StoredAiEvent[] = []\nexport function recordAiEvent(event: StoredAiEvent): void { ... }\n\n// ai/data-access.ts\nimport { eventStore } from \"./services/usage-tracker-store\"\nexport async function getAiUsageStatsRaw(): Promise { ... }\n```\n注释已说明是临时实现,生产替换为 DB 后此问题自动消失。", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-035", + "file": "src/modules/elective/data-access-operations.ts", + "lines": "L62-L78", + "ruleId": "S-06", + "severity": "P3", + "dimension": "structure", + "title": "DAY_NORMALIZE_MAP 常量与 normalizeDay 函数缺模块级 JSDoc", + "description": "L62-L78 `DAY_NORMALIZE_MAP` 常量虽有注释说明用途,但 `normalizeDay` 函数 (L76-L78) 无 JSDoc。此函数被 `isScheduleConflict` 内部调用,是冲突检测的核心。按 S-06 规则,公共导出函数应补齐 JSDoc。虽然 `normalizeDay` 未导出,但作为可测试纯函数建议导出并补 JSDoc。", + "recommendation": "添加 JSDoc 并考虑导出便于测试:\n```ts\n/**\n * 将星期字符串归一化为 1-7 数字字符串。\n * 支持中文(周一/星期一)、英文全称(monday)、英文缩写(mon)。\n * @param day 原始星期字符串\n * @returns 归一化后的 1-7 数字字符串,无法识别时返回原值\n */\nexport function normalizeDay(day: string): string {\n return DAY_NORMALIZE_MAP[day.toLowerCase()] ?? day\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-036", + "file": "src/modules/elective/data-access-selections.ts", + "lines": "L104-L112, L120-L128", + "ruleId": "P-08", + "severity": "P3", + "dimension": "pattern", + "title": "getCourseSelectionsRaw / getStudentSelectionsRaw 重复 map+resolve 模式", + "description": "`getCourseSelectionsRaw` L107-L111 和 `getStudentSelectionsRaw` L123-L127 重复了相同的模式:`buildSelectionCoreSelect().where(...).orderBy(...)` → `resolveStudentDisplayNames(rows)` → `rows.map((r) => mapSelectionRow(r, studentNames))`。仅 where 条件和 orderBy 不同,可提取为共享 helper。", + "recommendation": "提取共享查询 helper:\n```ts\nasync function querySelectionsWithDisplayNames(\n where: SQL, orderBy: SQL\n): Promise {\n const rows = await buildSelectionCoreSelect().where(where).orderBy(orderBy)\n const studentNames = await resolveStudentDisplayNames(rows)\n return rows.map((r) => mapSelectionRow(r, studentNames))\n}\n\nexport const getCourseSelectionsRaw = async (courseId: string) =>\n querySelectionsWithDisplayNames(\n eq(courseSelections.courseId, courseId),\n asc(courseSelections.priority)\n )\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-037", + "file": "src/modules/announcements/data-access.ts", + "lines": "L122-L157", + "ruleId": "S-03", + "severity": "P3", + "dimension": "structure", + "title": "getAnnouncements 与 countAnnouncements 的 audience 过滤逻辑重复", + "description": "`getAnnouncementsRaw` L66-L82 和 `countAnnouncements` L133-L149 的 audience 过滤逻辑完全一致:gradeClause、classClause、orClauses 构造。两处复制粘贴,修改时需同步,易遗漏。", + "recommendation": "提取共享 helper:\n```ts\nfunction buildAudienceConditions(audience?: UserAudience): SQL[] {\n if (!audience) return []\n const { gradeIds, classIds } = audience\n const gradeClause = gradeIds.length > 0\n ? and(eq(announcements.type, \"grade\"), inArray(announcements.targetGradeId, gradeIds))\n : undefined\n const classClause = classIds.length > 0\n ? and(eq(announcements.type, \"class\"), inArray(announcements.targetClassId, classIds))\n : undefined\n const orClauses = [eq(announcements.type, \"school\"), gradeClause, classClause]\n .filter((c): c is NonNullable => c !== undefined)\n return orClauses.length > 1 ? [or(...orClauses)] : []\n}\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-038", + "file": "src/modules/settings/actions.ts", + "lines": "L220", + "ruleId": "A-10", + "severity": "P3", + "dimension": "architecture", + "title": "actions.ts 含 console.error 调试代码(actions 层可接受但建议用 logger)", + "description": "L220 `console.error(\"[upsertAiProviderAction] Failed to save AI provider:\", error)` 在 actions 层使用 console.error。A-10 规则主要针对 data-access 层禁止 console.log,actions 层用 console.error 记录错误属常见做法,但项目有 `trackEvent` 与 `logAudit` 统一日志通道,建议统一使用。", + "recommendation": "改用 trackEvent 或统一 logger:\n```ts\nvoid trackEvent({\n event: \"ai.provider_upsert_failed\",\n targetType: \"ai_provider\",\n properties: { error: error instanceof Error ? error.message : String(error) },\n})\nreturn { success: false, message: \"Failed to save AI provider\" }\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-039", + "file": "src/modules/announcements/actions.ts", + "lines": "L42, L92", + "ruleId": "A-10", + "severity": "P3", + "dimension": "architecture", + "title": "actions.ts 含 console.error(handleActionError 与 notifyAnnouncementPublished)", + "description": "L42 `console.error(\\`[announcements] ${actionName} failed:\\`, e)` 和 L92 `console.error(\"Failed to send announcement notifications:\", error)` 在 actions 层使用 console.error。与 G5-038 同理,actions 层可接受但建议统一日志通道。", + "recommendation": "改用 trackEvent 统一上报:\n```ts\nvoid trackEvent({\n event: \"announcement.action_error\",\n targetType: \"announcement\",\n properties: { action: actionName, error: e instanceof Error ? e.message : String(e) },\n})\n```", + "effort": "XS (≤15 分钟)" + }, + { + "id": "G5-040", + "file": "src/modules/error-book/data-access-analytics.ts", + "lines": "L161-L240, L309-L407", + "ruleId": "S-03", + "severity": "P3", + "dimension": "structure", + "title": "getKnowledgePointWeakness 与 getChapterWeakness 的知识点聚合逻辑重复", + "description": "`getKnowledgePointWeaknessRaw` L172-L191 和 `getChapterWeaknessRaw` L320-L338 都执行了相同的模式:1) 查询 errorBookItems 的 status + knowledgePointIds;2) JS 展开知识点到 kpMap;3) 查询 knowledgePoints 表获取名称与 chapterId。两处代码高度相似,仅最终聚合维度不同(按知识点 vs 按章节)。", + "recommendation": "提取共享的知识点聚合查询:\n```ts\nasync function loadKpErrorStats(\n studentIds: string[], subjectId?: string | null\n): Promise> {\n const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)\n const rows = await db.select({\n status: errorBookItems.status,\n knowledgePointIds: errorBookItems.knowledgePointIds,\n }).from(errorBookItems).where(whereClause)\n // ... 展开 kpMap\n return kpMap\n}\n```\n两个函数复用此 helper。", + "effort": "S (≤30 分钟)" + }, + { + "id": "G5-041", + "file": "src/modules/elective/data-access-operations.ts", + "lines": "L137-L174, L185-L220", + "ruleId": "F-01", + "severity": "P2", + "dimension": "performance", + "title": "checkScheduleConflict / checkCreditLimit 在事务内多次查询,可批量优化", + "description": "`checkScheduleConflict` L150-L161 查询学生已选课程 schedule,`checkCreditLimit` L198-L209 查询学生已选课程 credit。两个函数在 `selectCourse` 事务内串行调用 (L347, L353),且都查询了 `courseSelections innerJoin electiveCourses` 相同的 join。可合并为单次查询减少事务内往返。", + "recommendation": "合并为单次查询:\n```ts\nasync function checkScheduleAndCredit(\n tx, studentId, newCourseId, studentGradeId\n): Promise<{ hasConflict: boolean; creditExceeded: boolean; current: number; max: number }> {\n const [newCourse, existingCourses] = await Promise.all([\n tx.select({ schedule: electiveCourses.schedule, credit: electiveCourses.credit })\n .from(electiveCourses).where(eq(electiveCourses.id, newCourseId)).limit(1),\n tx.select({ schedule: electiveCourses.schedule, credit: electiveCourses.credit })\n .from(courseSelections)\n .innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))\n .where(and(eq(courseSelections.studentId, studentId),\n inArray(courseSelections.status, [\"selected\", \"enrolled\", \"waitlist\"]))),\n ])\n // 一次遍历计算 conflict + credit\n}\n```", + "effort": "M (≤2 小时)" + } +] diff --git a/docs/superpowers/plans/2026-07-07-documentation-system-redesign.md b/docs/superpowers/plans/2026-07-07-documentation-system-redesign.md new file mode 100644 index 0000000..d181a44 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-documentation-system-redesign.md @@ -0,0 +1,2682 @@ +# 文档体系重设计实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 建立基于 arch.db 的文档体系,让 AI 越工作越了解项目 + +**Architecture:** 4 个子计划按依赖顺序执行——先建 arch.db 扫描器(基础工具),再重设计文档体系(004 瘦身/005 废弃/known-issues 精简),再修正规范文档,最后分批创建模块 README + +**Tech Stack:** TypeScript, ts-morph, SQLite (better-sqlite3), Next.js 16, ESLint, Vitest + +**Spec:** [docs/superpowers/specs/2026-07-07-documentation-system-redesign-design.md](../specs/2026-07-07-documentation-system-redesign-design.md) + +--- + +## 子计划 1: arch.db 扫描器 + +**目标:** 实现 ts-morph 扫描器 + SQLite schema + 查询 CLI,让 AI 能 `npm run arch:scan` 和 `npm run arch:query` + +### Task 1.1: 安装依赖 + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: 安装 ts-morph 和 better-sqlite3** + +```bash +npm install ts-morph better-sqlite3 +npm install -D @types/better-sqlite3 +``` + +- [ ] **Step 2: 验证安装** + +Run: `node -e "require('ts-morph'); require('better-sqlite3'); console.log('OK')"` +Expected: 输出 `OK` + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(arch-scan): add ts-morph and better-sqlite3 dependencies" +``` + +### Task 1.2: 创建扫描器目录结构 + +**Files:** +- Create: `scripts/arch-scan/schema.ts` +- Create: `scripts/arch-scan/scanner.ts` +- Create: `scripts/arch-scan/query.ts` +- Create: `scripts/arch-scan/cli.ts` +- Create: `scripts/arch-scan/index.ts` + +- [ ] **Step 1: 创建目录** + +```bash +mkdir -p scripts/arch-scan +``` + +- [ ] **Step 2: 创建占位文件(后续任务填充)** + +创建 5 个空文件,每个文件只有头部注释: + +```typescript +// scripts/arch-scan/schema.ts +// SQLite schema 定义 +export {}; +``` + +```typescript +// scripts/arch-scan/scanner.ts +// ts-morph 扫描器 +export {}; +``` + +```typescript +// scripts/arch-scan/query.ts +// 查询函数 +export {}; +``` + +```typescript +// scripts/arch-scan/cli.ts +// CLI 入口 +export {}; +``` + +```typescript +// scripts/arch-scan/index.ts +// 主入口 +export {}; +``` + +- [ ] **Step 3: Commit** + +```bash +git add scripts/arch-scan/ +git commit -m "chore(arch-scan): scaffold directory structure" +``` + +### Task 1.3: 实现 SQLite schema + +**Files:** +- Modify: `scripts/arch-scan/schema.ts` +- Test: `scripts/arch-scan/schema.test.ts` + +- [ ] **Step 1: 编写 schema 测试** + +```typescript +// scripts/arch-scan/schema.test.ts +import { describe, it, expect } from "vitest"; +import Database from "better-sqlite3"; +import { initSchema } from "./schema"; + +describe("initSchema", () => { + it("should create all 12 tables", () => { + const db = new Database(":memory:"); + initSchema(db); + const tables = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + .all() as { name: string }[]; + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain("modules"); + expect(tableNames).toContain("files"); + expect(tableNames).toContain("symbols"); + expect(tableNames).toContain("calls"); + expect(tableNames).toContain("file_imports"); + expect(tableNames).toContain("module_deps"); + expect(tableNames).toContain("tech_tags"); + expect(tableNames).toContain("symbol_tech_tags"); + expect(tableNames).toContain("permissions"); + expect(tableNames).toContain("routes"); + expect(tableNames).toContain("db_tables"); + expect(tableNames).toContain("scan_meta"); + }); + + it("should create all indexes", () => { + const db = new Database(":memory:"); + initSchema(db); + const indexes = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%' ORDER BY name" + ) + .all() as { name: string }[]; + const indexNames = indexes.map((i) => i.name); + expect(indexNames).toContain("idx_symbols_file"); + expect(indexNames).toContain("idx_symbols_name"); + expect(indexNames).toContain("idx_calls_caller"); + expect(indexNames).toContain("idx_calls_callee"); + expect(indexNames).toContain("idx_file_imports_source"); + expect(indexNames).toContain("idx_file_imports_target"); + expect(indexNames).toContain("idx_symbol_tech_tags_tag"); + }); + + it("should be idempotent", () => { + const db = new Database(":memory:"); + expect(() => initSchema(db)).not.toThrow(); + expect(() => initSchema(db)).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `npx vitest run scripts/arch-scan/schema.test.ts` +Expected: FAIL,`initSchema` 未定义 + +- [ ] **Step 3: 实现 initSchema** + +```typescript +// scripts/arch-scan/schema.ts +import type Database from "better-sqlite3"; + +const DDL_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS modules ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + description TEXT, + layer TEXT NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + module_id INTEGER REFERENCES modules(id), + path TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + lines INTEGER, + is_server INTEGER DEFAULT 0, + is_client INTEGER DEFAULT 0, + has_server_only INTEGER DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS symbols ( + id INTEGER PRIMARY KEY, + file_id INTEGER NOT NULL REFERENCES files(id), + name TEXT NOT NULL, + kind TEXT NOT NULL, + is_exported INTEGER DEFAULT 0, + is_async INTEGER DEFAULT 0, + is_server_action INTEGER DEFAULT 0, + signature TEXT, + start_line INTEGER, + end_line INTEGER, + UNIQUE(file_id, name, start_line) + )`, + `CREATE TABLE IF NOT EXISTS calls ( + id INTEGER PRIMARY KEY, + caller_id INTEGER NOT NULL REFERENCES symbols(id), + callee_id INTEGER REFERENCES symbols(id), + callee_external TEXT, + call_line INTEGER, + count INTEGER DEFAULT 1 + )`, + `CREATE TABLE IF NOT EXISTS file_imports ( + id INTEGER PRIMARY KEY, + source_file_id INTEGER NOT NULL REFERENCES files(id), + imported_file_id INTEGER REFERENCES files(id), + import_path TEXT NOT NULL, + is_type_only INTEGER DEFAULT 0, + imported_names TEXT + )`, + `CREATE TABLE IF NOT EXISTS module_deps ( + id INTEGER PRIMARY KEY, + source_module_id INTEGER NOT NULL REFERENCES modules(id), + target_module_id INTEGER NOT NULL REFERENCES modules(id), + dep_type TEXT NOT NULL, + UNIQUE(source_module_id, target_module_id, dep_type) + )`, + `CREATE TABLE IF NOT EXISTS tech_tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + category TEXT + )`, + `CREATE TABLE IF NOT EXISTS symbol_tech_tags ( + symbol_id INTEGER NOT NULL REFERENCES symbols(id), + tag_id INTEGER NOT NULL REFERENCES tech_tags(id), + PRIMARY KEY(symbol_id, tag_id) + )`, + `CREATE TABLE IF NOT EXISTS permissions ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + description TEXT + )`, + `CREATE TABLE IF NOT EXISTS routes ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + file_id INTEGER REFERENCES files(id), + min_permission TEXT + )`, + `CREATE TABLE IF NOT EXISTS db_tables ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + module_id INTEGER REFERENCES modules(id), + description TEXT + )`, + `CREATE TABLE IF NOT EXISTS scan_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id)`, + `CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)`, + `CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_id)`, + `CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_id)`, + `CREATE INDEX IF NOT EXISTS idx_file_imports_source ON file_imports(source_file_id)`, + `CREATE INDEX IF NOT EXISTS idx_file_imports_target ON file_imports(imported_file_id)`, + `CREATE INDEX IF NOT EXISTS idx_symbol_tech_tags_tag ON symbol_tech_tags(tag_id)`, +]; + +export function initSchema(db: Database.Database): void { + for (const stmt of DDL_STATEMENTS) { + db.exec(stmt); + } +} + +export function clearAllData(db: Database.Database): void { + const tables = [ + "symbol_tech_tags", + "calls", + "file_imports", + "module_deps", + "symbols", + "files", + "modules", + "tech_tags", + "permissions", + "routes", + "db_tables", + "scan_meta", + ]; + db.exec("PRAGMA foreign_keys = OFF"); + for (const table of tables) { + db.exec(`DELETE FROM ${table}`); + } + db.exec("PRAGMA foreign_keys = ON"); +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `npx vitest run scripts/arch-scan/schema.test.ts` +Expected: PASS(3 个测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/arch-scan/schema.ts scripts/arch-scan/schema.test.ts +git commit -m "feat(arch-scan): implement SQLite schema with 12 tables and 7 indexes" +``` + +### Task 1.4: 实现模块扫描器 + +**Files:** +- Modify: `scripts/arch-scan/scanner.ts` +- Test: `scripts/arch-scan/scanner.test.ts` + +- [ ] **Step 1: 编写模块扫描测试** + +```typescript +// scripts/arch-scan/scanner.test.ts +import { describe, it, expect } from "vitest"; +import Database from "better-sqlite3"; +import { initSchema, clearAllData } from "./schema"; +import { scanModules } from "./scanner"; + +describe("scanModules", () => { + it("should detect src/modules/* as modules", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + const modules = db + .prepare("SELECT name FROM modules ORDER BY name") + .all() as { name: string }[]; + const names = modules.map((m) => m.name); + expect(names).toContain("exams"); + expect(names).toContain("grades"); + expect(names).toContain("classes"); + }); + + it("should detect shared/ as a module with layer=shared", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/shared"); + const shared = db + .prepare("SELECT * FROM modules WHERE name = 'shared'") + .get() as { layer: string }; + expect(shared.layer).toBe("shared"); + }); + + it("should set layer=modules for src/modules/*", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + const exams = db + .prepare("SELECT * FROM modules WHERE name = 'exams'") + .get() as { layer: string }; + expect(exams.layer).toBe("modules"); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: FAIL,`scanModules` 未定义 + +- [ ] **Step 3: 实现 scanModules** + +```typescript +// scripts/arch-scan/scanner.ts +import type Database from "better-sqlite3"; +import { readdirSync, statSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +interface ModuleRow { + name: string; + path: string; + description: string | null; + layer: string; + created_at: string; +} + +function detectLayer(basePath: string): string { + if (basePath.endsWith("src/modules")) return "modules"; + if (basePath.endsWith("src/shared")) return "shared"; + if (basePath.endsWith("src/app")) return "app"; + return "root"; +} + +function extractDescription(modulePath: string): string | null { + const readmePath = join(modulePath, "README.md"); + if (!existsSync(readmePath)) return null; + const content = readFileFirstLine(readmePath); + return content; +} + +function readFileFirstLine(filePath: string): string { + // 简化实现,实际可使用 fs.readFileSync + try { + const { readFileSync } = require("node:fs"); + const content = readFileSync(filePath, "utf-8"); + const lines = content.split("\n").filter((l: string) => l.trim() && !l.startsWith("#")); + return lines[0]?.slice(0, 200) || null; + } catch { + return null; + } +} + +export function scanModules(db: Database.Database, basePath: string): void { + if (!existsSync(basePath)) return; + const layer = detectLayer(basePath); + const entries = readdirSync(basePath); + const now = new Date().toISOString(); + const insert = db.prepare( + "INSERT OR IGNORE INTO modules (name, path, description, layer, created_at) VALUES (?, ?, ?, ?, ?)" + ); + for (const entry of entries) { + const fullPath = join(basePath, entry); + if (!statSync(fullPath).isDirectory()) continue; + if (entry.startsWith(".") || entry.startsWith("_")) continue; + const description = extractDescription(fullPath); + insert.run(entry, fullPath, description, layer, now); + } +} + +export function scanAllModules(db: Database.Database, projectRoot: string): void { + scanModules(db, join(projectRoot, "src/modules")); + scanModules(db, join(projectRoot, "src/shared")); + // app 层模块(路由组)可选扫描 +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: PASS(3 个测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/arch-scan/scanner.ts scripts/arch-scan/scanner.test.ts +git commit -m "feat(arch-scan): implement module scanner for src/modules and src/shared" +``` + +### Task 1.5: 实现文件扫描器 + +**Files:** +- Modify: `scripts/arch-scan/scanner.ts` +- Test: `scripts/arch-scan/scanner.test.ts` + +- [ ] **Step 1: 添加文件扫描测试** + +在 `scripts/arch-scan/scanner.test.ts` 末尾追加: + +```typescript +describe("scanFiles", () => { + it("should scan .ts and .tsx files in modules", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + const files = db + .prepare("SELECT path, kind FROM files WHERE path LIKE '%exams%' LIMIT 5") + .all() as { path: string; kind: string }[]; + expect(files.length).toBeGreaterThan(0); + const actionFile = files.find((f) => f.kind === "actions"); + expect(actionFile).toBeDefined(); + }); + + it("should detect file kind from filename", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + const actionsFile = db + .prepare("SELECT kind FROM files WHERE path LIKE '%exams/actions.ts'") + .get() as { kind: string }; + expect(actionsFile.kind).toBe("actions"); + const dataAccessFile = db + .prepare("SELECT kind FROM files WHERE path LIKE '%exams/data-access.ts'") + .get() as { kind: string }; + expect(dataAccessFile.kind).toBe("data-access"); + }); + + it("should count lines correctly", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + const file = db + .prepare("SELECT lines FROM files WHERE path LIKE '%exams/actions.ts'") + .get() as { lines: number }; + expect(file.lines).toBeGreaterThan(0); + }); + + it("should detect use server and use client directives", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + const serverFile = db + .prepare("SELECT is_server FROM files WHERE path LIKE '%exams/actions.ts'") + .get() as { is_server: number }; + expect(serverFile.is_server).toBe(1); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: FAIL,`scanFiles` 未定义 + +- [ ] **Step 3: 实现 scanFiles** + +在 `scripts/arch-scan/scanner.ts` 中追加: + +```typescript +import { readFileSync } from "node:fs"; + +const TS_EXTENSIONS = [".ts", ".tsx"]; + +function detectFileKind(filePath: string): string { + const filename = filePath.split("/").pop() || ""; + if (filename === "actions.ts" || filename.match(/^actions-\w+\.ts$/)) return "actions"; + if (filename === "data-access.ts" || filename.match(/^data-access-\w+\.ts$/)) return "data-access"; + if (filename === "schema.ts") return "schema"; + if (filename === "types.ts") return "types"; + if (filename.endsWith(".tsx")) return "component"; + if (filename.startsWith("use-") && filename.endsWith(".ts")) return "hook"; + if (filename === "page.tsx") return "page"; + if (filename === "layout.tsx") return "layout"; + if (filename === "route.ts") return "route"; + if (filename === "error.tsx") return "error"; + if (filename === "loading.tsx") return "loading"; + if (filename === "proxy.ts") return "config"; + if (filename === "next.config.ts") return "config"; + return "lib"; +} + +function countLines(content: string): number { + return content.split("\n").length; +} + +function detectDirectives(content: string): { + isServer: boolean; + isClient: boolean; + hasServerOnly: boolean; +} { + const firstLine = content.split("\n")[0] || ""; + return { + isServer: firstLine.includes('"use server"') || firstLine.includes("'use server'"), + isClient: firstLine.includes('"use client"') || firstLine.includes("'use client'"), + hasServerOnly: content.includes('import "server-only"') || content.includes("import 'server-only'"), + }; +} + +function scanFilesRecursive( + db: Database.Database, + dirPath: string, + moduleName: string +): void { + const entries = readdirSync(dirPath); + for (const entry of entries) { + const fullPath = join(dirPath, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + if (entry.startsWith(".") || entry === "node_modules") continue; + scanFilesRecursive(db, fullPath, moduleName); + continue; + } + if (!TS_EXTENSIONS.some((ext) => entry.endsWith(ext))) continue; + const content = readFileSync(fullPath, "utf-8"); + const directives = detectDirectives(content); + const kind = detectFileKind(fullPath); + const lines = countLines(content); + const moduleId = ( + db.prepare("SELECT id FROM modules WHERE name = ?").get(moduleName) as { + id: number; + } + ).id; + db.prepare( + `INSERT OR REPLACE INTO files (module_id, path, kind, lines, is_server, is_client, has_server_only) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + moduleId, + fullPath.replace(/\\/g, "/"), + kind, + lines, + directives.isServer ? 1 : 0, + directives.isClient ? 1 : 0, + directives.hasServerOnly ? 1 : 0 + ); + } +} + +export function scanFiles(db: Database.Database, basePath: string): void { + if (!existsSync(basePath)) return; + const modules = db + .prepare("SELECT name, path FROM modules") + .all() as { name: string; path: string }[]; + for (const mod of modules) { + if (!existsSync(mod.path)) continue; + scanFilesRecursive(db, mod.path, mod.name); + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: PASS(7 个测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/arch-scan/scanner.ts scripts/arch-scan/scanner.test.ts +git commit -m "feat(arch-scan): implement file scanner with kind detection and directive parsing" +``` + +### Task 1.6: 实现符号扫描器(ts-morph) + +**Files:** +- Modify: `scripts/arch-scan/scanner.ts` +- Test: `scripts/arch-scan/scanner.test.ts` + +- [ ] **Step 1: 添加符号扫描测试** + +在 `scripts/arch-scan/scanner.test.ts` 末尾追加: + +```typescript +describe("scanSymbols", () => { + it("should extract exported functions from actions.ts", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + const symbols = db + .prepare( + "SELECT name, is_exported, is_async FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path LIKE '%exams/actions.ts') LIMIT 5" + ) + .all() as { name: string; is_exported: number; is_async: number }[]; + expect(symbols.length).toBeGreaterThan(0); + const exported = symbols.find((s) => s.is_exported === 1); + expect(exported).toBeDefined(); + }); + + it("should detect Server Action by 'use server' directive", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + const serverActions = db + .prepare("SELECT name FROM symbols WHERE is_server_action = 1 LIMIT 5") + .all() as { name: string }[]; + expect(serverActions.length).toBeGreaterThan(0); + }); + + it("should extract function signatures", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + const withSignature = db + .prepare("SELECT signature FROM symbols WHERE signature IS NOT NULL AND signature != '' LIMIT 1") + .get() as { signature: string }; + expect(withSignature).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: FAIL,`scanSymbols` 未定义 + +- [ ] **Step 3: 实现 scanSymbols(使用 ts-morph)** + +在 `scripts/arch-scan/scanner.ts` 中追加: + +```typescript +import { Project, SyntaxKind, type Node, type FunctionDeclaration, type VariableDeclaration, type ClassDeclaration, type InterfaceDeclaration, type TypeAliasDeclaration } from "ts-morph"; + +function detectSymbolKind(node: Node): string { + if (Node.isFunctionDeclaration(node)) return "function"; + if (Node.isVariableDeclaration(node)) { + const initializer = node.getInitializer(); + if (initializer && SyntaxKind.ArrowFunction === initializer.getKind()) { + return "function"; + } + if (initializer && SyntaxKind.ObjectLiteralExpression === initializer.getKind()) { + return "const"; + } + return "const"; + } + if (Node.isClassDeclaration(node)) return "class"; + if (Node.isInterfaceDeclaration(node)) return "interface"; + if (Node.isTypeAliasDeclaration(node)) return "type"; + return "unknown"; +} + +function extractSignature(node: Node): string { + try { + const text = node.getText(); + // 截取第一行作为签名 + const firstLine = text.split("\n")[0]; + return firstLine.slice(0, 200); + } catch { + return ""; + } +} + +function isExported(node: Node): boolean { + try { + const modifiers = (node as FunctionDeclaration).getModifiers?.() || []; + return modifiers.some((m) => m.getKind() === SyntaxKind.ExportKeyword); + } catch { + return false; + } +} + +function isAsyncFunction(node: Node): boolean { + try { + const modifiers = (node as FunctionDeclaration).getModifiers?.() || []; + return modifiers.some((m) => m.getKind() === SyntaxKind.AsyncKeyword); + } catch { + return false; + } +} + +export function scanSymbols(db: Database.Database): void { + const project = new Project({ + tsConfigFilePath: "./tsconfig.json", + skipAddingFilesFromTsConfig: true, + }); + const files = db + .prepare("SELECT id, path FROM files") + .all() as { id: number; path: string }[]; + const insertSymbol = db.prepare( + `INSERT OR IGNORE INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, signature, start_line, end_line) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + for (const file of files) { + let sourceFile; + try { + sourceFile = project.addSourceFileAtPath(file.path); + } catch { + continue; + } + const fileContent = sourceFile.getFullText(); + const isServerFile = fileContent.startsWith('"use server"') || fileContent.startsWith("'use server'"); + const processNode = (node: Node): void => { + if ( + Node.isFunctionDeclaration(node) || + Node.isClassDeclaration(node) || + Node.isInterfaceDeclaration(node) || + Node.isTypeAliasDeclaration(node) + ) { + const name = (node as FunctionDeclaration).getName?.() || ""; + if (!name) return; + const kind = detectSymbolKind(node); + const exported = isExported(node); + const async = isAsyncFunction(node); + const signature = extractSignature(node); + const startLine = node.getStartLineNumber(); + const endLine = node.getEndLineNumber(); + const isServerAction = isServerFile && exported && async; + insertSymbol.run(file.id, name, kind, exported ? 1 : 0, async ? 1 : 0, isServerAction ? 1 : 0, signature, startLine, endLine); + } + if (Node.isVariableStatement(node)) { + const declarations = node.getDeclarations(); + for (const decl of declarations) { + const name = decl.getName(); + if (!name) continue; + const kind = detectSymbolKind(decl); + const exported = isExported(node); + const signature = extractSignature(decl); + const startLine = decl.getStartLineNumber(); + const endLine = decl.getEndLineNumber(); + insertSymbol.run(file.id, name, kind, exported ? 1 : 0, 0, 0, signature, startLine, endLine); + } + } + }; + sourceFile.forEachChild(processNode); + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: PASS(10 个测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/arch-scan/scanner.ts scripts/arch-scan/scanner.test.ts +git commit -m "feat(arch-scan): implement symbol scanner using ts-morph with signature extraction" +``` + +### Task 1.7: 实现调用关系扫描 + +**Files:** +- Modify: `scripts/arch-scan/scanner.ts` +- Test: `scripts/arch-scan/scanner.test.ts` + +- [ ] **Step 1: 添加调用关系扫描测试** + +在 `scripts/arch-scan/scanner.test.ts` 末尾追加: + +```typescript +describe("scanCalls", () => { + it("should detect calls between symbols", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + scanCalls(db); + const calls = db + .prepare("SELECT count(*) as count FROM calls") + .get() as { count: number }; + expect(calls.count).toBeGreaterThan(0); + }); + + it("should record external calls (callee_external)", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + scanCalls(db); + const externalCalls = db + .prepare("SELECT callee_external FROM calls WHERE callee_external IS NOT NULL LIMIT 5") + .all() as { callee_external: string }[]; + expect(externalCalls.length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: FAIL,`scanCalls` 未定义 + +- [ ] **Step 3: 实现 scanCalls** + +在 `scripts/arch-scan/scanner.ts` 中追加: + +```typescript +function findSymbolIdByName( + db: Database.Database, + name: string, + preferFileId?: number +): number | null { + if (preferFileId !== undefined) { + const sameFile = db + .prepare("SELECT id FROM symbols WHERE name = ? AND file_id = ? LIMIT 1") + .get(name, preferFileId) as { id: number } | undefined; + if (sameFile) return sameFile.id; + } + const anyFile = db + .prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1") + .get(name) as { id: number } | undefined; + return anyFile?.id || null; +} + +export function scanCalls(db: Database.Database): void { + const project = new Project({ + tsConfigFilePath: "./tsconfig.json", + skipAddingFilesFromTsConfig: true, + }); + const files = db + .prepare("SELECT id, path FROM files") + .all() as { id: number; path: string }[]; + const insertCall = db.prepare( + `INSERT INTO calls (caller_id, callee_id, callee_external, call_line, count) + VALUES (?, ?, ?, ?, ?)` + ); + for (const file of files) { + let sourceFile; + try { + sourceFile = project.addSourceFileAtPath(file.path); + } catch { + continue; + } + const fileSymbols = db + .prepare("SELECT id, name, start_line, end_line FROM symbols WHERE file_id = ?") + .all(file.id) as { + id: number; + name: string; + start_line: number; + end_line: number; + }[]; + const symbolByLine = new Map(); + for (const sym of fileSymbols) { + for (let line = sym.start_line; line <= sym.end_line; line++) { + symbolByLine.set(line, sym); + } + } + const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + for (const call of callExpressions) { + const callLine = call.getStartLineNumber(); + const caller = symbolByLine.get(callLine); + if (!caller) continue; + const expr = call.getExpression(); + let calleeName = ""; + if (Node.isPropertyAccessExpression(expr)) { + calleeName = expr.getName(); + } else if (Node.isIdentifier(expr)) { + calleeName = expr.getText(); + } + if (!calleeName) continue; + const calleeId = findSymbolIdByName(db, calleeName, file.id); + const calleeExternal = calleeId ? null : calleeName; + insertCall.run(caller.id, calleeId, calleeExternal, callLine, 1); + } + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: PASS(12 个测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/arch-scan/scanner.ts scripts/arch-scan/scanner.test.ts +git commit -m "feat(arch-scan): implement call relationship scanner with external call detection" +``` + +### Task 1.8: 实现技术标签扫描 + +**Files:** +- Modify: `scripts/arch-scan/scanner.ts` +- Test: `scripts/arch-scan/scanner.test.ts` + +- [ ] **Step 1: 添加技术标签扫描测试** + +在 `scripts/arch-scan/scanner.test.ts` 末尾追加: + +```typescript +describe("scanTechTags", () => { + it("should detect cacheFn usage", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + scanTechTags(db); + const cacheFnFiles = db + .prepare( + `SELECT s.name FROM symbols s + JOIN symbol_tech_tags stt ON s.id = stt.symbol_id + JOIN tech_tags t ON stt.tag_id = t.id + WHERE t.name = 'cacheFn' LIMIT 5` + ) + .all() as { name: string }[]; + expect(cacheFnFiles.length).toBeGreaterThan(0); + }); + + it("should create tech_tags entries", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + scanTechTags(db); + const tags = db + .prepare("SELECT name FROM tech_tags ORDER BY name") + .all() as { name: string }[]; + const tagNames = tags.map((t) => t.name); + expect(tagNames).toContain("cacheFn"); + expect(tagNames).toContain("zustand"); + expect(tagNames).toContain("Drizzle"); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: FAIL,`scanTechTags` 未定义 + +- [ ] **Step 3: 实现 scanTechTags** + +在 `scripts/arch-scan/scanner.ts` 中追加: + +```typescript +interface TechTagRule { + name: string; + category: string; + detect: (content: string, imports: string[]) => boolean; +} + +const TECH_TAG_RULES: TechTagRule[] = [ + { + name: "cacheFn", + category: "cache", + detect: (content) => content.includes("cacheFn("), + }, + { + name: "zustand", + category: "state", + detect: (content, imports) => imports.some((i) => i.includes('"zustand"') || i.includes("'zustand'")), + }, + { + name: "useOptimistic", + category: "state", + detect: (content) => content.includes("useOptimistic("), + }, + { + name: "react-hook-form", + category: "form", + detect: (content, imports) => imports.some((i) => i.includes("react-hook-form")), + }, + { + name: "TanStack Query", + category: "state", + detect: (content, imports) => imports.some((i) => i.includes("@tanstack/react-query")), + }, + { + name: "Server Action", + category: "server", + detect: (content) => content.startsWith('"use server"') || content.startsWith("'use server'"), + }, + { + name: "Tiptap", + category: "ui", + detect: (content, imports) => imports.some((i) => i.includes("@tiptap/")), + }, + { + name: "Drizzle", + category: "db", + detect: (content, imports) => imports.some((i) => i.includes("drizzle-orm")), + }, + { + name: "nuqs", + category: "state", + detect: (content, imports) => imports.some((i) => i.includes('"nuqs"') || i.includes("'nuqs'")), + }, + { + name: "recharts", + category: "ui", + detect: (content, imports) => imports.some((i) => i.includes('"recharts"') || i.includes("'recharts'")), + }, +]; + +export function scanTechTags(db: Database.Database): void { + const insertTag = db.prepare( + "INSERT OR IGNORE INTO tech_tags (name, category) VALUES (?, ?)" + ); + for (const rule of TECH_TAG_RULES) { + insertTag.run(rule.name, rule.category); + } + const getTagId = db.prepare("SELECT id FROM tech_tags WHERE name = ?"); + const insertSymbolTag = db.prepare( + "INSERT OR IGNORE INTO symbol_tech_tags (symbol_id, tag_id) VALUES (?, ?)" + ); + const files = db + .prepare("SELECT id, path FROM files") + .all() as { id: number; path: string }[]; + for (const file of files) { + let content: string; + try { + content = readFileSync(file.path, "utf-8"); + } catch { + continue; + } + const importLines = content + .split("\n") + .filter((l) => l.startsWith("import ")); + const detectedTags = TECH_TAG_RULES.filter((r) => + r.detect(content, importLines) + ); + if (detectedTags.length === 0) continue; + const fileSymbols = db + .prepare("SELECT id FROM symbols WHERE file_id = ?") + .all(file.id) as { id: number }[]; + for (const tag of detectedTags) { + const tagRow = getTagId.get(tag.name) as { id: number }; + for (const sym of fileSymbols) { + insertSymbolTag.run(sym.id, tagRow.id); + } + } + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: PASS(14 个测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/arch-scan/scanner.ts scripts/arch-scan/scanner.test.ts +git commit -m "feat(arch-scan): implement tech tag scanner with 10 heuristic rules" +``` + +### Task 1.9: 实现模块间依赖扫描 + +**Files:** +- Modify: `scripts/arch-scan/scanner.ts` +- Test: `scripts/arch-scan/scanner.test.ts` + +- [ ] **Step 1: 添加模块依赖扫描测试** + +在 `scripts/arch-scan/scanner.test.ts` 末尾追加: + +```typescript +describe("scanModuleDeps", () => { + it("should detect module dependencies from imports", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + scanModuleDeps(db); + const deps = db + .prepare("SELECT count(*) as count FROM module_deps") + .get() as { count: number }; + expect(deps.count).toBeGreaterThan(0); + }); + + it("should detect data-access-call dependency type", () => { + const db = new Database(":memory:"); + initSchema(db); + scanModules(db, "src/modules"); + scanFiles(db, "src/modules"); + scanSymbols(db); + scanCalls(db); + scanModuleDeps(db); + const dataAccessDeps = db + .prepare("SELECT count(*) as count FROM module_deps WHERE dep_type = 'data-access-call'") + .get() as { count: number }; + expect(dataAccessDeps.count).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: FAIL,`scanModuleDeps` 未定义 + +- [ ] **Step 3: 实现 scanModuleDeps** + +在 `scripts/arch-scan/scanner.ts` 中追加: + +```typescript +function extractModuleFromPath(importPath: string): string | null { + // 匹配 @/modules/xxx 或 @/shared/xxx + const modulesMatch = importPath.match(/@\/modules\/([^/"]+)/); + if (modulesMatch) return modulesMatch[1]; + const sharedMatch = importPath.match(/@\/shared/); + if (sharedMatch) return "shared"; + return null; +} + +export function scanModuleDeps(db: Database.Database): void { + const insertDep = db.prepare( + `INSERT OR IGNORE INTO module_deps (source_module_id, target_module_id, dep_type) + VALUES (?, ?, ?)` + ); + // 1. 从 file_imports 聚合 import 类型依赖 + const imports = db + .prepare( + `SELECT fi.source_file_id, fi.import_path, f.module_id AS source_module_id + FROM file_imports fi + JOIN files f ON fi.source_file_id = f.id + WHERE fi.import_path LIKE '%@/modules/%' OR fi.import_path LIKE '%@/shared%'` + ) + .all() as { + source_file_id: number; + import_path: string; + source_module_id: number; + }[]; + const moduleByPath = db + .prepare("SELECT name, id FROM modules") + .all() as { name: string; id: number }[]; + const moduleIdByName = new Map(moduleByPath.map((m) => [m.name, m.id])); + for (const imp of imports) { + const targetModuleName = extractModuleFromPath(imp.import_path); + if (!targetModuleName) continue; + const targetModuleId = moduleIdByName.get(targetModuleName); + if (!targetModuleId) continue; + if (imp.source_module_id === targetModuleId) continue; + insertDep.run(imp.source_module_id, targetModuleId, "import"); + } + // 2. 从 calls 聚合 data-access-call 类型依赖 + const crossModuleCalls = db + .prepare( + `SELECT DISTINCT f1.module_id AS source_module_id, f2.module_id AS target_module_id + FROM calls c + JOIN symbols s1 ON c.caller_id = s1.id + JOIN files f1 ON s1.file_id = f1.id + JOIN symbols s2 ON c.callee_id = s2.id + JOIN files f2 ON s2.file_id = f2.id + WHERE f1.module_id != f2.module_id + AND f2.kind = 'data-access'` + ) + .all() as { source_module_id: number; target_module_id: number }[]; + for (const call of crossModuleCalls) { + insertDep.run(call.source_module_id, call.target_module_id, "data-access-call"); + } +} + +export function scanFileImports(db: Database.Database): void { + const files = db + .prepare("SELECT id, path FROM files") + .all() as { id: number; path: string }[]; + const insertImport = db.prepare( + `INSERT INTO file_imports (source_file_id, imported_file_id, import_path, is_type_only, imported_names) + VALUES (?, ?, ?, ?, ?)` + ); + for (const file of files) { + let content: string; + try { + content = readFileSync(file.path, "utf-8"); + } catch { + continue; + } + const importRegex = /^import\s+(?:type\s+)?(.+?)\s+from\s+["']([^"']+)["']/gm; + let match: RegExpExecArray | null; + while ((match = importRegex.exec(content)) !== null) { + const isTypeOnly = match[0].startsWith("import type"); + const importedNames = match[1]; + const importPath = match[2]; + let importedFileId: number | null = null; + if (importPath.startsWith("@/")) { + const resolvedPath = importPath.replace("@/", "src/").replace(/\.(ts|tsx)$/, ""); + const candidatePaths = [resolvedPath, `${resolvedPath}.ts`, `${resolvedPath}.tsx`, `${resolvedPath}/index.ts`]; + for (const candidate of candidatePaths) { + const result = db + .prepare("SELECT id FROM files WHERE path = ?") + .get(candidate) as { id: number } | undefined; + if (result) { + importedFileId = result.id; + break; + } + } + } + insertImport.run(file.id, importedFileId, importPath, isTypeOnly ? 1 : 0, importedNames); + } + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `npx vitest run scripts/arch-scan/scanner.test.ts` +Expected: PASS(16 个测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/arch-scan/scanner.ts scripts/arch-scan/scanner.test.ts +git commit -m "feat(arch-scan): implement module dependency scanner with import and data-access-call detection" +``` + +### Task 1.10: 实现查询函数 + +**Files:** +- Modify: `scripts/arch-scan/query.ts` +- Test: `scripts/arch-scan/query.test.ts` + +- [ ] **Step 1: 编写查询函数测试** + +```typescript +// scripts/arch-scan/query.test.ts +import { describe, it, expect } from "vitest"; +import Database from "better-sqlite3"; +import { initSchema, clearAllData } from "./schema"; +import { + querySymbolRefs, + querySymbolForwardCalls, + queryModuleDeps, + queryModuleReverseDeps, + queryTechUsage, + queryViolations, +} from "./query"; + +function setupTestDb(): Database.Database { + const db = new Database(":memory:"); + initSchema(db); + // 插入测试数据 + db.prepare( + "INSERT INTO modules (name, path, layer, created_at) VALUES (?, ?, ?, ?)" + ).run("exams", "src/modules/exams", "modules", "2026-07-07"); + db.prepare( + "INSERT INTO modules (name, path, layer, created_at) VALUES (?, ?, ?, ?)" + ).run("grades", "src/modules/grades", "modules", "2026-07-07"); + db.prepare( + "INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)" + ).run(1, "src/modules/exams/actions.ts", "actions", 100); + db.prepare( + "INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)" + ).run(1, "src/modules/exams/data-access.ts", "data-access", 200); + db.prepare( + "INSERT INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ).run(1, "createExamAction", "function", 1, 1, 1, 10, 50); + db.prepare( + "INSERT INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ).run(2, "createExam", "function", 1, 1, 0, 20, 80); + db.prepare( + "INSERT INTO calls (caller_id, callee_id, call_line, count) VALUES (?, ?, ?, ?)" + ).run(1, 2, 30, 1); // createExamAction 调用 createExam + db.prepare( + "INSERT INTO module_deps (source_module_id, target_module_id, dep_type) VALUES (?, ?, ?)" + ).run(1, 2, "data-access-call"); // exams 依赖 grades + return db; +} + +describe("querySymbolRefs", () => { + it("should find callers of a symbol (reverse)", () => { + const db = setupTestDb(); + const refs = querySymbolRefs(db, "createExam"); + expect(refs).toHaveLength(1); + expect(refs[0].caller_name).toBe("createExamAction"); + }); +}); + +describe("querySymbolForwardCalls", () => { + it("should find callees of a symbol (forward)", () => { + const db = setupTestDb(); + const calls = querySymbolForwardCalls(db, "createExamAction"); + expect(calls).toHaveLength(1); + expect(calls[0].callee_name).toBe("createExam"); + }); +}); + +describe("queryModuleDeps", () => { + it("should find module dependencies (forward)", () => { + const db = setupTestDb(); + const deps = queryModuleDeps(db, "exams"); + expect(deps).toContainEqual({ target_module: "grades", dep_type: "data-access-call" }); + }); +}); + +describe("queryModuleReverseDeps", () => { + it("should find module reverse dependencies", () => { + const db = setupTestDb(); + const deps = queryModuleReverseDeps(db, "grades"); + expect(deps).toContainEqual({ source_module: "exams", dep_type: "data-access-call" }); + }); +}); + +describe("queryViolations", () => { + it("should detect files over 800 lines", () => { + const db = setupTestDb(); + // 插入一个超长文件 + db.prepare("INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)").run( + 1, + "src/modules/exams/huge.ts", + "lib", + 900 + ); + const violations = queryViolations(db); + expect(violations.long_files).toContainEqual({ + path: "src/modules/exams/huge.ts", + lines: 900, + }); + }); + + it("should detect Server Actions without requirePermission", () => { + const db = setupTestDb(); + const violations = queryViolations(db); + // createExamAction 是 Server Action 但没有调用 requirePermission + const missing = violations.server_actions_without_permission.find( + (v) => v.name === "createExamAction" + ); + expect(missing).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `npx vitest run scripts/arch-scan/query.test.ts` +Expected: FAIL,查询函数未定义 + +- [ ] **Step 3: 实现查询函数** + +```typescript +// scripts/arch-scan/query.ts +import type Database from "better-sqlite3"; + +export interface SymbolRef { + caller_name: string; + caller_path: string; + caller_line: number; + is_server_action: boolean; + depth: number; + path_chain: string; +} + +export function querySymbolRefs( + db: Database.Database, + symbolName: string, + maxDepth = 10 +): SymbolRef[] { + const sql = ` + WITH RECURSIVE upstream(caller_id, caller_name, caller_path, caller_line, is_sa, depth, path_chain) AS ( + SELECT c.caller_id, s.name, f.path, c.call_line, s.is_server_action, 0, s.name + FROM calls c + JOIN symbols s ON c.caller_id = s.id + JOIN files f ON s.file_id = f.id + WHERE c.callee_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1) + UNION + SELECT c.caller_id, s.name, f.path, c.call_line, s.is_server_action, u.depth + 1, + u.path_chain || ' → ' || s.name + FROM upstream u + JOIN calls c ON c.callee_id = u.caller_id + JOIN symbols s ON c.caller_id = s.id + JOIN files f ON s.file_id = f.id + WHERE u.depth < ? + AND u.caller_id NOT IN (SELECT caller_id FROM upstream) + ) + SELECT caller_name, caller_path, caller_line, is_sa as is_server_action, depth, path_chain + FROM upstream ORDER BY depth, caller_path`; + return db.prepare(sql).all(symbolName, maxDepth) as SymbolRef[]; +} + +export interface SymbolCall { + callee_name: string; + callee_path: string; + callee_line: number; + depth: number; + path_chain: string; +} + +export function querySymbolForwardCalls( + db: Database.Database, + symbolName: string, + maxDepth = 10 +): SymbolCall[] { + const sql = ` + WITH RECURSIVE downstream(callee_id, callee_name, callee_path, callee_line, depth, path_chain) AS ( + SELECT c.callee_id, s.name, f.path, c.call_line, 0, ? + FROM calls c + JOIN symbols s ON c.callee_id = s.id + JOIN files f ON s.file_id = f.id + WHERE c.caller_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1) + UNION + SELECT c.callee_id, s.name, f.path, c.call_line, d.depth + 1, + d.path_chain || ' → ' || s.name + FROM downstream d + JOIN calls c ON c.caller_id = d.callee_id + JOIN symbols s ON c.callee_id = s.id + JOIN files f ON s.file_id = f.id + WHERE d.depth < ? + AND d.callee_id NOT IN (SELECT callee_id FROM downstream) + ) + SELECT callee_name, callee_path, callee_line, depth, path_chain + FROM downstream ORDER BY depth, callee_path`; + return db.prepare(sql).all(symbolName, symbolName, maxDepth) as SymbolCall[]; +} + +export interface ModuleDep { + target_module: string; + dep_type: string; +} + +export function queryModuleDeps( + db: Database.Database, + moduleName: string, + maxDepth = 10 +): ModuleDep[] { + const sql = ` + SELECT DISTINCT m2.name AS target_module, md.dep_type + FROM module_deps md + JOIN modules m ON md.source_module_id = m.id + JOIN modules m2 ON md.target_module_id = m2.id + WHERE m.name = ?`; + return db.prepare(sql).all(moduleName) as ModuleDep[]; +} + +export interface ModuleReverseDep { + source_module: string; + dep_type: string; +} + +export function queryModuleReverseDeps( + db: Database.Database, + moduleName: string, + maxDepth = 10 +): ModuleReverseDep[] { + const sql = ` + SELECT DISTINCT m.name AS source_module, md.dep_type + FROM module_deps md + JOIN modules m ON md.source_module_id = m.id + JOIN modules m2 ON md.target_module_id = m2.id + WHERE m2.name = ?`; + return db.prepare(sql).all(moduleName) as ModuleReverseDep[]; +} + +export interface TechUsage { + symbol_name: string; + file_path: string; + tag_name: string; +} + +export function queryTechUsage( + db: Database.Database, + tagName: string +): TechUsage[] { + const sql = ` + SELECT s.name AS symbol_name, f.path AS file_path, t.name AS tag_name + FROM symbols s + JOIN symbol_tech_tags stt ON s.id = stt.symbol_id + JOIN tech_tags t ON stt.tag_id = t.id + JOIN files f ON s.file_id = f.id + WHERE t.name = ?`; + return db.prepare(sql).all(tagName) as TechUsage[]; +} + +export interface Violations { + long_files: { path: string; lines: number }[]; + server_actions_without_permission: { name: string; path: string }[]; +} + +export function queryViolations(db: Database.Database): Violations { + const longFiles = db + .prepare("SELECT path, lines FROM files WHERE lines > 800 ORDER BY lines DESC") + .all() as { path: string; lines: number }[]; + const serverActionsWithoutPerm = db + .prepare( + `SELECT s.name, f.path FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE s.is_server_action = 1 + AND NOT EXISTS ( + SELECT 1 FROM calls c + JOIN symbols cs ON c.callee_id = cs.id + WHERE c.caller_id = s.id AND cs.name = 'requirePermission' + )` + ) + .all() as { name: string; path: string }[]; + return { + long_files: longFiles, + server_actions_without_permission: serverActionsWithoutPerm, + }; +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: `npx vitest run scripts/arch-scan/query.test.ts` +Expected: PASS(6 个测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/arch-scan/query.ts scripts/arch-scan/query.test.ts +git commit -m "feat(arch-scan): implement query functions for refs, deps, tech usage, and violations" +``` + +### Task 1.11: 实现 CLI 入口 + +**Files:** +- Modify: `scripts/arch-scan/cli.ts` +- Modify: `scripts/arch-scan/index.ts` +- Modify: `package.json` + +- [ ] **Step 1: 实现 CLI 主入口** + +```typescript +// scripts/arch-scan/index.ts +import Database from "better-sqlite3"; +import { initSchema, clearAllData } from "./schema"; +import { + scanAllModules, + scanFiles, + scanSymbols, + scanCalls, + scanTechTags, + scanFileImports, + scanModuleDeps, +} from "./scanner"; +import { querySymbolRefs, querySymbolForwardCalls, queryModuleDeps, queryModuleReverseDeps, queryTechUsage, queryViolations } from "./query"; +import { execSync } from "node:child_process"; + +const DB_PATH = "scripts/arch-scan/arch.db"; + +function getCommitHash(): string { + try { + return execSync("git rev-parse --short HEAD").toString().trim(); + } catch { + return "unknown"; + } +} + +export function runScan(): void { + console.log("Starting architecture scan..."); + const db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + initSchema(db); + clearAllData(db); + console.log("Schema initialized."); + scanAllModules(db, process.cwd()); + console.log("Modules scanned."); + scanFiles(db, "src/modules"); + scanFiles(db, "src/shared"); + console.log("Files scanned."); + scanSymbols(db); + console.log("Symbols scanned."); + scanCalls(db); + console.log("Calls scanned."); + scanTechTags(db); + console.log("Tech tags scanned."); + scanFileImports(db); + scanModuleDeps(db); + console.log("Module dependencies scanned."); + const now = new Date().toISOString(); + const commit = getCommitHash(); + const meta = db.prepare( + "INSERT OR REPLACE INTO scan_meta (key, value) VALUES (?, ?)" + ); + meta.run("scanned_at", now); + meta.run("commit_hash", commit); + meta.run("scanner_version", "1.0.0"); + const stats = { + files: (db.prepare("SELECT count(*) as c FROM files").get() as { c: number }).c, + symbols: (db.prepare("SELECT count(*) as c FROM symbols").get() as { c: number }).c, + calls: (db.prepare("SELECT count(*) as c FROM calls").get() as { c: number }).c, + }; + meta.run("total_files", String(stats.files)); + meta.run("total_symbols", String(stats.symbols)); + meta.run("total_calls", String(stats.calls)); + console.log(`Scan complete: ${stats.files} files, ${stats.symbols} symbols, ${stats.calls} calls`); + console.log(`Commit: ${commit}, Time: ${now}`); + db.close(); +} + +export function runQuery(args: string[]): void { + const db = new Database(DB_PATH, { readonly: true }); + const command = args[0]; + switch (command) { + case "ref": { + const symbol = args[1]; + if (!symbol) { + console.error("Usage: arch:query ref [--forward] [--depth=N]"); + process.exit(1); + } + const forward = args.includes("--forward"); + const depthArg = args.find((a) => a.startsWith("--depth=")); + const depth = depthArg ? parseInt(depthArg.split("=")[1], 10) : 10; + if (forward) { + const calls = querySymbolForwardCalls(db, symbol, depth); + console.log(`▼ ${symbol}`); + for (const call of calls) { + const indent = " ".repeat(call.depth + 1); + console.log(`${indent}└─▶ ${call.callee_name} (${call.callee_path}#L${call.callee_line})`); + } + } else { + const refs = querySymbolRefs(db, symbol, depth); + console.log(`▼ ${symbol}`); + for (const ref of refs) { + const indent = " ".repeat(ref.depth + 1); + console.log(`${indent}├─▼ ${ref.caller_name} (${ref.caller_path}#L${ref.caller_line})${ref.is_server_action ? " [Server Action]" : ""}`); + } + } + break; + } + case "module": { + const module = args[1]; + if (!module) { + console.error("Usage: arch:query module [--reverse] [--depth=N]"); + process.exit(1); + } + const reverse = args.includes("--reverse"); + if (reverse) { + const deps = queryModuleReverseDeps(db, module); + console.log(`▼ ${module} (reverse deps)`); + for (const dep of deps) { + console.log(` └─◀ ${dep.source_module} [${dep.dep_type}]`); + } + } else { + const deps = queryModuleDeps(db, module); + console.log(`▼ ${module} (forward deps)`); + for (const dep of deps) { + console.log(` └─▶ ${dep.target_module} [${dep.dep_type}]`); + } + } + break; + } + case "tech": { + const tag = args[1]; + if (!tag) { + console.error("Usage: arch:query tech "); + process.exit(1); + } + const usage = queryTechUsage(db, tag); + console.log(`▼ tech: ${tag} (${usage.length} usages)`); + for (const u of usage) { + console.log(` └─ ${u.symbol_name} (${u.file_path})`); + } + break; + } + case "violations": { + const v = queryViolations(db); + console.log("▼ Architecture Violations"); + console.log(` Long files (>800 lines): ${v.long_files.length}`); + for (const f of v.long_files) { + console.log(` └─ ${f.path}: ${f.lines} lines`); + } + console.log(` Server Actions without requirePermission: ${v.server_actions_without_permission.length}`); + for (const sa of v.server_actions_without_permission) { + console.log(` └─ ${sa.name} (${sa.path})`); + } + break; + } + case "sql": { + const sql = args.slice(1).join(" "); + if (!sql) { + console.error("Usage: arch:query sql \"\""); + process.exit(1); + } + const rows = db.prepare(sql).all(); + console.log(JSON.stringify(rows, null, 2)); + break; + } + case "repl": { + console.log("Entering SQLite REPL. Type .exit to quit."); + console.log(`DB: ${DB_PATH}`); + // 简化:直接调用 sqlite3 CLI + const { spawn } = require("node:child_process"); + const repl = spawn("sqlite3", [DB_PATH], { stdio: "inherit" }); + repl.on("exit", (code: number) => process.exit(code)); + break; + } + default: + console.error("Unknown command. Available: ref, module, tech, violations, sql, repl"); + process.exit(1); + } + db.close(); +} +``` + +```typescript +// scripts/arch-scan/cli.ts +import { runScan, runQuery } from "./index"; + +const args = process.argv.slice(2); +const command = args[0]; + +if (command === "scan") { + runScan(); +} else if (command === "query") { + runQuery(args.slice(1)); +} else { + console.log("Usage:"); + console.log(" npm run arch:scan"); + console.log(" npm run arch:query [args]"); + console.log(""); + console.log("Commands:"); + console.log(" scan Scan codebase and update arch.db"); + console.log(" query ref [--forward] Query symbol references"); + console.log(" query module [--reverse] Query module dependencies"); + console.log(" query tech Query tech tag usage"); + console.log(" query violations Detect architecture violations"); + console.log(" query sql \"\" Run free SQL"); + console.log(" query repl SQLite REPL"); + process.exit(1); +} +``` + +- [ ] **Step 2: 添加 npm scripts** + +在 `package.json` 的 `scripts` 中添加: + +```json +"arch:scan": "tsx scripts/arch-scan/cli.ts scan", +"arch:query": "tsx scripts/arch-scan/cli.ts query" +``` + +- [ ] **Step 3: 测试 CLI** + +Run: `npm run arch:scan` +Expected: 输出扫描进度,最终显示文件数、符号数、调用数 + +Run: `npm run arch:query -- ref createExam` +Expected: 输出 createExam 的引用者树形图 + +Run: `npm run arch:query -- violations` +Expected: 输出架构违规列表 + +- [ ] **Step 4: Commit** + +```bash +git add scripts/arch-scan/cli.ts scripts/arch-scan/index.ts package.json +git commit -m "feat(arch-scan): implement CLI with scan and query commands" +``` + +### Task 1.12: 配置 .gitignore 和 CI + +**Files:** +- Modify: `.gitignore` +- Create: `scripts/arch-scan/.gitignore` + +- [ ] **Step 1: 配置 arch.db 的 git 跟踪策略** + +arch.db 应该提交到 git(让 AI 工作前能查到),但 WAL/SHM 临时文件不提交: + +```bash +# 创建 scripts/arch-scan/.gitignore +echo "*.wal +*.shm +*.tmp" > scripts/arch-scan/.gitignore +``` + +- [ ] **Step 2: 验证 arch.db 被跟踪** + +Run: `git status` +Expected: `scripts/arch-scan/arch.db` 出现在未跟踪文件中 + +```bash +git add scripts/arch-scan/arch.db scripts/arch-scan/.gitignore +``` + +- [ ] **Step 3: 配置 CI 自动扫描** + +修改 `.gitea/workflows/ci.yml`,在 build job 之后添加 arch-scan step: + +```yaml + arch-scan: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm run arch:scan + - name: Verify arch.db + run: | + if [ ! -f scripts/arch-scan/arch.db ]; then + echo "arch.db not generated" + exit 1 + fi + - name: Check for violations + run: npm run arch:query -- violations + - name: Commit updated arch.db + run: | + git config user.name "CI Bot" + git config user.email "ci@example.com" + git add scripts/arch-scan/arch.db + git commit -m "chore(arch-scan): update arch.db [skip ci]" || true + git push +``` + +- [ ] **Step 4: Commit** + +```bash +git add .gitea/workflows/ci.yml scripts/arch-scan/.gitignore scripts/arch-scan/arch.db +git commit -m "ci(arch-scan): add automatic scan job and commit arch.db on CI" +``` + +### Task 1.13: 子计划 1 验收 + +- [ ] **Step 1: 运行所有测试** + +Run: `npx vitest run scripts/arch-scan/` +Expected: 所有测试 PASS + +- [ ] **Step 2: 运行 lint 和 tsc** + +Run: `npm run lint && npx tsc --noEmit` +Expected: 零错误 + +- [ ] **Step 3: 端到端验证** + +```bash +npm run arch:scan +npm run arch:query -- ref createExam +npm run arch:query -- module exams +npm run arch:query -- tech cacheFn +npm run arch:query -- violations +``` + +Expected: 全部命令成功执行,输出合理结果 + +- [ ] **Step 4: Commit 验收标记** + +```bash +git commit --allow-empty -m "feat(arch-scan): subplan 1 complete - arch.db scanner fully functional" +``` + +--- + +## 子计划 2: 文档体系重设计 + +**目标:** 004 瘦身、005 废弃、known-issues 精简、roadmap 创建、audit 归档 + +**依赖:** 子计划 1(使用 arch.db 查询实际代码结构) + +### Task 2.1: 创建 roadmap/ 目录结构 + +**Files:** +- Create: `docs/architecture/roadmap/README.md` +- Create: `docs/architecture/roadmap/tech-debt.md` +- Create: `docs/architecture/roadmap/decoupling.md` +- Create: `docs/architecture/roadmap/pending-features.md` + +- [ ] **Step 1: 创建 roadmap 索引** + +```markdown + +# 路线图索引 + +> 本目录存放项目长远规划,与架构事实(004)分离。 + +## 文档清单 + +| 文档 | 用途 | +|------|------| +| [tech-debt.md](./tech-debt.md) | 技术债清单 | +| [decoupling.md](./decoupling.md) | 解耦路线图 | +| [pending-features.md](./pending-features.md) | 待开发功能 | + +## 维护规则 + +- 规划实现后从本目录删除,迁入 004 架构事实或 git 历史 +- 不含架构事实,不含经验(经验查 known-issues.md) +``` + +- [ ] **Step 2: 创建 tech-debt.md** + +```markdown + +# 技术债清单 + +> 从 004 第三部分"已知架构问题和技术债"迁入。 + +## 待解决项 + +(从 004 迁入后填充) + +## 已解决项 + +(已解决项删除,git 历史已记录) +``` + +- [ ] **Step 3: 创建 decoupling.md** + +```markdown + +# 解耦路线图 + +> 从 docs/architecture/audit/01_decoupling_roadmap.md 迁入。 + +## 解耦目标 + +(迁入后填充) +``` + +- [ ] **Step 4: 创建 pending-features.md** + +```markdown + +# 待开发功能 + +> 从 004 各模块"未完成项"迁入。 + +## 待开发功能清单 + +(迁入后填充) +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/architecture/roadmap/ +git commit -m "docs(roadmap): create roadmap directory with tech-debt, decoupling, pending-features" +``` + +### Task 2.2: 归档 audit 报告 + +**Files:** +- Move: `docs/architecture/audit/*` → `docs/architecture/audit/archive/` + +- [ ] **Step 1: 创建归档目录** + +```bash +mkdir -p docs/architecture/audit/archive +``` + +- [ ] **Step 2: 移动所有 audit 报告到 archive** + +```bash +# 移动除 01_decoupling_roadmap.md 外的所有报告 +cd docs/architecture/audit +for f in *.md; do + if [ "$f" != "01_decoupling_roadmap.md" ]; then + mv "$f" archive/ + fi +done +``` + +- [ ] **Step 3: 移动 01_decoupling_roadmap.md 到 roadmap/** + +```bash +mv docs/architecture/audit/01_decoupling_roadmap.md docs/architecture/roadmap/decoupling.md +``` + +- [ ] **Step 4: 归档 005_architecture_data.json** + +```bash +mv docs/architecture/005_architecture_data.json docs/architecture/audit/archive/ +``` + +- [ ] **Step 5: 创建 archive README** + +```markdown + +# 历史审查报告归档 + +> 本目录为只读归档,不再更新。 +> 有价值的内容已提取到模块 README 和 known-issues.md。 + +## 归档文件 + +- 005_architecture_data.json(已废弃,由 arch.db 替代) +- 60+ 份模块审查报告(历史参考) +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A docs/architecture/ +git commit -m "docs(audit): archive 60+ audit reports and deprecated 005 JSON" +``` + +### Task 2.3: 004 瘦身 + +**Files:** +- Modify: `docs/architecture/004_architecture_impact_map.md` + +- [ ] **Step 1: 备份原 004** + +```bash +cp docs/architecture/004_architecture_impact_map.md docs/architecture/audit/archive/004_architecture_impact_map_v1.md +``` + +- [ ] **Step 2: 使用 arch.db 提取实际架构数据** + +Run: `npm run arch:query -- sql "SELECT name, path FROM modules WHERE layer='modules' ORDER BY name"` +Expected: 输出所有模块清单 + +Run: `npm run arch:query -- sql "SELECT source_module, target_module FROM module_deps"` +Expected: 输出所有模块依赖关系 + +- [ ] **Step 3: 重写 004 为瘦身后版本** + +将 004 重写为约 500 行的瘦身后版本,包含 6 个章节(分层架构、模块清单表格、依赖关系图、数据流向、核心原则、设计令牌体系)。删除所有: +- 1.1.1-1.1.7 变更日志章节 +- "Phase X.X 新增"标记 +- "P0-X 已修复"标记 +- "未完成项(待后续专项)" +- 各模块"V1/V2/V3/V4"版本描述 +- 函数签名索引(附录 C) +- 模块间依赖矩阵(附录 A) +- 第三部分"已知架构问题和技术债" + +保留: +- 分层架构图 +- 模块清单(精简为表格) +- 依赖关系图 +- 数据流向图 +- 核心架构原则 +- 设计令牌体系 +- 关键参数影响链(附录 B,作为架构决策) + +- [ ] **Step 4: 验证行数** + +Run: `Get-Content docs/architecture/004_architecture_impact_map.md | Measure-Object -Line` +Expected: 约 500 行(±50) + +- [ ] **Step 5: Commit** + +```bash +git add docs/architecture/004_architecture_impact_map.md docs/architecture/audit/archive/004_architecture_impact_map_v1.md +git commit -m "docs(004): slim down architecture map from 4227 to ~500 lines, move history to archive" +``` + +### Task 2.4: known-issues.md 精简 + +**Files:** +- Modify: `docs/troubleshooting/known-issues.md` + +- [ ] **Step 1: 备份原 known-issues** + +```bash +cp docs/troubleshooting/known-issues.md docs/architecture/audit/archive/known-issues_v1.md +``` + +- [ ] **Step 2: 重写 known-issues.md 为索引式** + +重写为 3 段式结构: +1. 全局经验(按主题分区表格) +2. 模块经验(按模块分区表格) +3. 工作经验日志(按时间倒序,50 条上限) + +删除所有: +- 多行代码示例 +- 错误示范列 +- 重复的架构规则(引用 004/project_rules) + +保留并精简为索引式: +- "场景 → 技术"映射 +- 工作经验日志 + +- [ ] **Step 3: 验证行数** + +Run: `Get-Content docs/troubleshooting/known-issues.md | Measure-Object -Line` +Expected: 约 300 行(±50) + +- [ ] **Step 4: Commit** + +```bash +git add docs/troubleshooting/known-issues.md docs/architecture/audit/archive/known-issues_v1.md +git commit -m "docs(known-issues): slim down from 1317 to ~300 lines, remove code examples, restructure as index" +``` + +--- + +## 子计划 3: 规范文档修正 + +**目标:** 修正 project_rules.md 和 coding-standards.md 的跨文档冲突和技术错误 + +**依赖:** 子计划 2(文档体系已重设计) + +### Task 3.1: 修正 project_rules.md + +**Files:** +- Modify: `.trae/rules/project_rules.md` + +- [ ] **Step 1: 补全架构文档清单** + +将"架构文档清单"表格更新为: + +```markdown +### 架构文档清单 + +| 文档 | 用途 | +|------|------| +| `docs/architecture/001_project_overview.md` | 项目概述 | +| `docs/architecture/002_rbac_refactoring.md` | RBAC 重构 | +| `docs/architecture/003_ui_refactoring_plan.md` | UI 重构计划 | +| `docs/architecture/004_architecture_impact_map.md` | 架构设计意图唯一源 | +| `docs/architecture/006_k12_feature_checklist.md` | 标准功能模块清单 | +| `docs/architecture/007_gap_audit_report.md` | 差距审计报告 | +| `docs/architecture/008_module_role_mapping.md` | 模块角色映射 | +| `docs/architecture/roadmap/` | 长远规划(tech-debt/decoupling/pending-features) | +``` + +(注:005 已废弃归档,002 编号冲突需在子计划 3 单独处理) + +- [ ] **Step 2: 新增 arch.db 规则** + +在"架构图优先规则"之后新增: + +```markdown +## 架构元数据库规则(arch.db) + +**AI 工作前必须运行 `npm run arch:scan` 更新 arch.db。** + +1. **arch.db 是代码结构唯一源**:模块、函数、调用关系、依赖关系查询 arch.db,不手动维护 +2. **AI 工作流程**: + - 阶段 1 上下文加载:`npm run arch:scan` → `npm run arch:query` 查询目标模块 → 阅读模块 README → 查 known-issues.md 经验 + - 阶段 2 执行工作:修改代码后立即 `npm run arch:scan` + - 阶段 3 经验沉淀:在 known-issues.md 追加工作经验日志 +3. **查询命令**: + - `npm run arch:query -- ref ` 查符号引用(递归) + - `npm run arch:query -- module ` 查模块依赖 + - `npm run arch:query -- tech ` 查技术使用 + - `npm run arch:query -- violations` 查架构违规 +``` + +- [ ] **Step 3: 新增 AI 工作强制流程** + +在文件末尾新增: + +```markdown +## AI 工作强制流程 + +**所有 AI 工作必须遵循此流程,违反即违规。** + +### 阶段 1: 上下文加载 +1. `npm run arch:scan` 更新 arch.db +2. `npm run arch:query -- module <目标模块>` 查模块依赖 +3. `npm run arch:query -- ref <目标函数> --forward` 查调用链 +4. 阅读 `src/modules/[模块]/README.md` 读模块工作流程 +5. 查 `known-issues.md` "模块经验: <模块>" 分区读相关经验 + - 审核相关经验(检查代码是否仍匹配) + - 若文档自上次审核后已变更 → 重新审核并标记 + - 审核通过 → 使用;失败 → 标记失效,不使用 + +### 阶段 2: 执行工作 +1. 按规划执行 +2. 修改代码后立即运行 `npm run arch:scan` + +### 阶段 3: 经验沉淀(强制,不可跳过) +1. 在 `known-issues.md` "工作经验日志" 区追加一条记录: + - 做了什么 + - 学到什么 + - 下次注意事项 + - 审核状态: 待审核 +2. 若发现新的"场景→技术"映射 → 提炼到对应模块分区 +3. 若发现新的架构决策 → 更新 004 +4. 若代码结构变化 → `npm run arch:scan` 确认 arch.db 已更新 + +### 阶段 4: 提交后审核(人工) +1. 人工审查"待审核"日志条目 +2. 通过 → 标记"已审核 (commit, 审核人)" +3. 失败 → 标记"审核失败,原因:..." +4. 定期(如每两周)将成熟日志提炼到分区表格 +``` + +- [ ] **Step 4: 修正令牌位置描述** + +确保所有令牌位置描述统一为 `src/app/styles/tokens/`。 + +- [ ] **Step 5: Commit** + +```bash +git add .trae/rules/project_rules.md +git commit -m "docs(rules): add arch.db rules, AI workflow, complete architecture doc list" +``` + +### Task 3.2: 修正 coding-standards.md + +**Files:** +- Modify: `docs/standards/coding-standards.md` + +- [ ] **Step 1: 修正令牌位置** + +将第 369 行附近的令牌配置描述从 `globals.css` 改为 `src/app/styles/tokens/`: + +```markdown +### 6.3 设计令牌配置 + +本项目在 `src/app/styles/tokens/` 中使用 CSS 变量定义设计令牌(分层架构): + +- `primitive.css`: 原始色板/字号/间距/阴影(业务代码不直接引用) +- `semantic-light.css` + `semantic-dark.css`: 语义令牌(业务代码唯一引用入口) +- `tailwind-theme.css`: `@theme inline` 将 Semantic 令牌暴露为 Tailwind 类 +- `index.css`: 入口文件 +``` + +- [ ] **Step 2: 更新缓存策略描述** + +将第 403 行附近的缓存策略从 `unstable_cache` 改为 `cacheFn`: + +```markdown +- 缓存策略:服务端请求使用 `cacheFn`(封装 React `cache()` + 自定义缓存层),详见 `shared/lib/cache/` +``` + +- [ ] **Step 3: 更新状态管理章节** + +在第 7.3 节状态管理中新增 5 层状态模型: + +```markdown +### 7.3 状态管理 + +本项目采用 **5 层状态模型**: + +| 层级 | 场景 | 方案 | +|------|------|------| +| L1 URL | 分页、筛选、排序 | `nuqs` | +| L2 Server | 服务端数据 | TanStack Query | +| L3 Client Business | 客户端业务状态 | Zustand slice | +| L4 Global UI | 全局 UI 状态(弹窗、主题) | Zustand ui-store + ModalRoot | +| L5 Form | 表单状态 | react-hook-form + zodResolver | + +**规则**: +- Context 拆分:一个 Context 只负责一类数据 +- 业务数据一律通过路由参数或 TanStack Query 获取,**不存入全局状态** +- Zustand 的 `persist` 中间件必须处理版本迁移和敏感数据加密 +- 优先使用细粒度 Zustand selectors 而非 `useShallow` +``` + +- [ ] **Step 4: 更新 ESLint 配置章节** + +将第 15.1 节 ESLint 配置更新为已实现状态: + +```markdown +### 15.1 ESLint + +**当前配置**(`eslint.config.mjs`)已实现以下规则: + +- `no-restricted-syntax`: 禁止 `#hex` 颜色字面量 +- `design-tokens/no-hardcoded-fonts`: 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量 +- 白名单:`primitive.css`、`email-channel.ts`、`manifest.ts` + +(具体配置见 `eslint.config.mjs`) +``` + +- [ ] **Step 5: 删除 tsconfig "当前差异"过时内容** + +将第 4.1 节中 tsconfig "当前差异"部分更新——如果已升级则删除差异说明,如果未升级则移到 roadmap: + +```markdown +### 4.1 配置(tsconfig.json) + +当前项目配置已符合规范(`target: ES2022`、`noUncheckedIndexedAccess` 等)。具体配置见 `tsconfig.json`。 +``` + +- [ ] **Step 6: Commit** + +```bash +git add docs/standards/coding-standards.md +git commit -m "docs(standards): fix token location, update cache strategy, add 5-layer state model, update ESLint config" +``` + +### Task 3.3: 修正 002 编号冲突 + +**Files:** +- Rename: `docs/architecture/002_rbac_refactoring.md` → `docs/architecture/002a_rbac_refactoring.md` + (或重命名为 `002_rbac_refactoring.md` 和 `002b_role_based_routing.md`) + +- [ ] **Step 1: 确认两个 002 文件** + +```bash +ls docs/architecture/002* +``` + +Expected: `002_rbac_refactoring.md` 和 `002_role_based_routing.md` + +- [ ] **Step 2: 重命名解决冲突** + +```bash +# RBAC 重构保留 002 +# 角色路由重命名为 002b +mv docs/architecture/002_role_based_routing.md docs/architecture/002b_role_based_routing.md +``` + +- [ ] **Step 3: 更新 project_rules.md 中的引用** + +如果 project_rules.md 中引用了 `002_role_based_routing.md`,更新为新名称。 + +- [ ] **Step 4: Commit** + +```bash +git add -A docs/architecture/ .trae/rules/project_rules.md +git commit -m "docs(architecture): resolve 002 numbering conflict by renaming role_based_routing to 002b" +``` + +--- + +## 子计划 4: 模块 README 创建 + +**目标:** 为约 27 个模块各创建 README.md + +**依赖:** 子计划 1(arch.db)+ 子计划 2(文档体系)+ 子计划 3(规范) + +### Task 4.1: 创建标杆模块 README + +**Files:** +- Create: `src/modules/textbooks/README.md` +- Create: `src/modules/grades/README.md` + +- [ ] **Step 1: 查询 textbooks 模块信息** + +```bash +npm run arch:query -- module textbooks +npm run arch:query -- sql "SELECT path, kind, lines FROM files WHERE module_id = (SELECT id FROM modules WHERE name='textbooks') ORDER BY kind, path" +``` + +- [ ] **Step 2: 创建 textbooks/README.md** + +```markdown +# textbooks 教材模块 + +> 经验查 known-issues.md,代码结构查 arch.db,本文件只记工作流程。 + +## 模块职责 +教材管理、知识图谱、章节结构、教材关联。 + +## 核心工作流程 +1. 新增教材: 通过 data-access 创建教材 → 关联年级/科目 +2. 章节管理: 章节树形结构,支持拖拽排序 +3. 知识图谱: 章节关联知识点,graph-layout 布局算法 + +## 关键约束 +- 教材删除前必须解除所有章节关联 +- 知识图谱节点 ID 必须唯一 +- 依赖: grades(年级)、classes(班级) +- 被依赖: lesson-preparation、course-plans、exams + +## 架构决策 +- **为什么用 graph-layout.ts 自定义布局**: 知识图谱需要按章节层级布局,dagre 等通用算法不适合 +- **为什么 data-access 拆分为多文件**: 教材 CRUD + 章节管理 + 图谱查询职责分离,单文件超 800 行 +``` + +- [ ] **Step 3: 创建 grades/README.md** + +```markdown +# grades 成绩模块 + +> 经验查 known-issues.md,代码结构查 arch.db,本文件只记工作流程。 + +## 模块职责 +成绩管理、成绩导入导出、成绩分析、成绩申诉、成绩单生成。 + +## 核心工作流程 +1. 录入成绩: 老师通过 actions-import.ts 批量导入 → data-access-drafts.ts 草稿 → actions-lock.ts 锁定 +2. 成绩查询: 学生/家长通过 actions.ts 查询 → scope-check.ts 权限范围校验 +3. 成绩申诉: 学生提交申诉 → actions-appeal.ts 处理 → 老师审批 +4. 成绩分析: stats-service.ts 统计 → actions-analytics.ts 返回分析数据 + +## 关键约束 +- 成绩锁定后不可修改,必须通过申诉流程 +- parent 路由必须同时校验 parentId 和 studentId,防止信息泄露 +- 批量操作必须用 INSERT batch + UPDATE CASE WHEN,禁止循环 SQL +- 依赖: classes、exams、students +- 被依赖: dashboard、parent + +## 架构决策 +- **为什么 actions 拆分为 6 个文件**: actions.ts(基础 CRUD)+ actions-analytics.ts(分析)+ actions-appeal.ts(申诉)+ actions-draft.ts(草稿)+ actions-import.ts(导入)+ actions-lock.ts(锁定),单文件超 1000 行硬上限 +- **为什么有 scope-check.ts**: 成绩数据敏感,必须按角色(admin/teacher/parent/student)过滤可见范围 +- **为什么用 lib/type-guards.ts**: 替代 `as` 断言,类型安全 +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/modules/textbooks/README.md src/modules/grades/README.md +git commit -m "docs(modules): create README for benchmark modules (textbooks, grades)" +``` + +### Task 4.2: 创建核心业务模块 README + +**Files:** +- Create: `src/modules/exams/README.md` +- Create: `src/modules/homework/README.md` +- Create: `src/modules/questions/README.md` + +- [ ] **Step 1: 查询模块信息** + +```bash +npm run arch:query -- module exams +npm run arch:query -- module homework +npm run arch:query -- module questions +``` + +- [ ] **Step 2: 为每个模块创建 README.md** + +按标杆模块模板创建,包含:模块职责、核心工作流程、关键约束、架构决策。 + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/exams/README.md src/modules/homework/README.md src/modules/questions/README.md +git commit -m "docs(modules): create README for core business modules (exams, homework, questions)" +``` + +### Task 4.3: 创建教学管理模块 README + +**Files:** +- Create: `src/modules/classes/README.md` +- Create: `src/modules/school/README.md` +- Create: `src/modules/scheduling/README.md` +- Create: `src/modules/attendance/README.md` + +- [ ] **Step 1: 查询模块信息** + +```bash +npm run arch:query -- module classes +npm run arch:query -- module school +npm run arch:query -- module scheduling +npm run arch:query -- module attendance +``` + +- [ ] **Step 2: 为每个模块创建 README.md** + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/classes/README.md src/modules/school/README.md src/modules/scheduling/README.md src/modules/attendance/README.md +git commit -m "docs(modules): create README for teaching management modules" +``` + +### Task 4.4: 创建用户沟通模块 README + +**Files:** +- Create: `src/modules/users/README.md` +- Create: `src/modules/messaging/README.md` +- Create: `src/modules/notifications/README.md` +- Create: `src/modules/parent/README.md` + +- [ ] **Step 1: 查询模块信息** + +- [ ] **Step 2: 为每个模块创建 README.md** + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/users/README.md src/modules/messaging/README.md src/modules/notifications/README.md src/modules/parent/README.md +git commit -m "docs(modules): create README for user communication modules" +``` + +### Task 4.5: 创建扩展功能模块 README + +**Files:** +- Create: `src/modules/elective/README.md` +- Create: `src/modules/proctoring/README.md` +- Create: `src/modules/diagnostic/README.md` +- Create: `src/modules/dashboard/README.md` + +- [ ] **Step 1: 查询模块信息** + +- [ ] **Step 2: 为每个模块创建 README.md** + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/elective/README.md src/modules/proctoring/README.md src/modules/diagnostic/README.md src/modules/dashboard/README.md +git commit -m "docs(modules): create README for extension function modules" +``` + +### Task 4.6: 创建其他模块 README + +**Files:** +- Create: `src/modules/announcements/README.md` +- Create: `src/modules/files/README.md` +- Create: `src/modules/settings/README.md` +- Create: `src/modules/auth/README.md` +- Create: `src/modules/layout/README.md` +- Create: `src/modules/student/README.md` +- Create: `src/modules/lesson-preparation/README.md` +- Create: `src/modules/standards/README.md` +- Create: `src/modules/course-plans/README.md` +- Create: `src/modules/audit/README.md` +- Create: `src/modules/rbac/README.md` +- Create: `src/modules/onboarding/README.md` +- Create: `src/modules/ai/README.md` +- Create: `src/modules/adaptive-practice/README.md` +- Create: `src/modules/error-book/README.md` +- Create: `src/modules/search/README.md` +- Create: `src/modules/leave-requests/README.md` +- Create: `src/modules/invitation-codes/README.md` + +- [ ] **Step 1: 批量查询模块信息** + +```bash +for mod in announcements files settings auth layout student lesson-preparation standards course-plans audit rbac onboarding ai adaptive-practice error-book search leave-requests invitation-codes; do + npm run arch:query -- module $mod +done +``` + +- [ ] **Step 2: 批量创建 README.md** + +为每个模块创建 README.md,按标杆模板。 + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/*/README.md +git commit -m "docs(modules): create README for remaining modules" +``` + +### Task 4.7: 子计划 4 验收 + +- [ ] **Step 1: 验证所有模块都有 README** + +Run: `Get-ChildItem -Path src/modules -Directory | ForEach-Object { if (-not (Test-Path "$_\\README.md")) { Write-Output "Missing: $_" } }` +Expected: 无输出(所有模块都有 README) + +- [ ] **Step 2: 验证 README 格式** + +每个 README 必须包含 4 个章节:模块职责、核心工作流程、关键约束、架构决策。 + +- [ ] **Step 3: Commit 验收标记** + +```bash +git commit --allow-empty -m "docs(modules): subplan 4 complete - all modules have README" +``` + +--- + +## 最终验收 + +### Task 5.1: 全局验收 + +- [ ] **Step 1: 运行所有测试** + +Run: `npm run test:unit` +Expected: 所有测试 PASS + +- [ ] **Step 2: 运行 lint 和 tsc** + +Run: `npm run lint && npx tsc --noEmit` +Expected: 零错误 + +- [ ] **Step 3: 端到端验证 arch.db** + +```bash +npm run arch:scan +npm run arch:query -- ref createExam +npm run arch:query -- module exams +npm run arch:query -- tech cacheFn +npm run arch:query -- violations +``` + +- [ ] **Step 4: 验证文档体系** + +```bash +# 检查 004 行数 +Get-Content docs/architecture/004_architecture_impact_map.md | Measure-Object -Line +# Expected: ~500 + +# 检查 known-issues 行数 +Get-Content docs/troubleshooting/known-issues.md | Measure-Object -Line +# Expected: ~300 + +# 检查 005 已归档 +Test-Path docs/architecture/audit/archive/005_architecture_data.json +# Expected: True + +# 检查 roadmap 目录 +Test-Path docs/architecture/roadmap/tech-debt.md +# Expected: True + +# 检查所有模块 README +Get-ChildItem -Path src/modules -Directory | ForEach-Object { Test-Path "$_\\README.md" } +# Expected: 全部 True +``` + +- [ ] **Step 5: 最终 Commit** + +```bash +git commit --allow-empty -m "feat(documentation-system): complete redesign with arch.db, slimmed docs, module READMEs, AI workflow" +``` + +--- + +## Self-Review + +### Spec 覆盖检查 + +| Spec 章节 | 对应任务 | 状态 | +|-----------|---------|------| +| 二、文档体系拓扑 | Task 2.1-2.4 | ✅ | +| 三、arch.db 架构元数据库 | Task 1.1-1.13 | ✅ | +| 四、AI 自我演进机制 | Task 3.1(写入 project_rules.md) | ✅ | +| 五、004 瘦身方案 | Task 2.3 | ✅ | +| 六、规范文档修正要点 | Task 3.1-3.3 | ✅ | +| 七、实施阶段划分 | 全部子计划 | ✅ | +| 八、验收标准 | Task 5.1 | ✅ | + +### Placeholder 扫描 + +- Task 4.2-4.6 中"为每个模块创建 README.md"——这是合理的批量任务描述,每个模块的 README 内容需根据 arch.db 查询结果生成,无法预先写死。✅ 可接受 +- Task 2.3 中"重写 004 为瘦身后版本"——具体内容需根据 arch.db 查询结果生成。✅ 可接受 +- Task 2.4 中"重写 known-issues.md 为索引式"——具体内容需根据现有内容精简。✅ 可接受 + +### Type 一致性 + +- `querySymbolRefs` 返回 `SymbolRef[]`,在 Task 1.10 定义,在 Task 1.11 CLI 中使用——一致 ✅ +- `queryModuleDeps` 返回 `ModuleDep[]`,在 Task 1.10 定义,在 Task 1.11 CLI 中使用——一致 ✅ +- `runScan` 和 `runQuery` 在 Task 1.11 定义——一致 ✅ + +--- + +## 执行方式 + +Plan complete and saved to `docs/superpowers/plans/2026-07-07-documentation-system-redesign.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-07-07-logging-refactor.md b/docs/superpowers/plans/2026-07-07-logging-refactor.md new file mode 100644 index 0000000..30f7d0d --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-logging-refactor.md @@ -0,0 +1,2149 @@ +# 日志系统重构实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 引入 pino 结构化日志、Request ID 贯穿、错误边界上报、静默失败修复、track-event 去重,统一全项目日志基础设施。 + +**Architecture:** pino logger 实例 + AsyncLocalStorage 请求上下文 + `withRequestContext` 高阶函数包装 Server Action / Route Handler。proxy.ts(Edge Runtime)通过 `NextResponse.next({ request: { headers } })` 注入 `x-request-id`,Node.js runtime 部分通过 `headers()` 读取并写入 AsyncLocalStorage。客户端 error.tsx 通过 `useErrorReport` Hook 上报到 `/api/client-error`。 + +**Tech Stack:** Next.js 16.0.10、React 19.2.1、pino 9.x、pino-pretty 11.x、vitest 4.1.0、ESLint 9 + eslint-config-next、@t3-oss/env-nextjs + zod + +**设计文档:** [docs/superpowers/specs/2026-07-07-logging-refactor-design.md](file:///e:/Desktop/CICD/docs/superpowers/specs/2026-07-07-logging-refactor-design.md) + +--- + +## 文件结构概览 + +### 新建文件 +| 路径 | 责任 | +|------|------| +| `src/shared/lib/logger.ts` | pino 实例 + `createModuleLogger(module)` 工厂 | +| `src/shared/lib/request-context.ts` | `AsyncLocalStorage` + `getRequestContext()` | +| `src/shared/lib/with-request-context.ts` | 高阶函数包装 Server Action / Route Handler | +| `src/shared/lib/logger.test.ts` | logger 单元测试 | +| `src/shared/lib/request-context.test.ts` | request-context 单元测试 | +| `src/shared/lib/with-request-context.test.ts` | with-request-context 单元测试 | +| `src/shared/hooks/use-error-report.ts` | error.tsx 客户端错误上报 Hook(含节流) | +| `src/shared/hooks/use-error-report.test.tsx` | Hook 单元测试 | +| `src/app/api/client-error/route.ts` | 客户端错误接收端点 | + +### 修改文件 +| 路径 | 改动 | +|------|------| +| `package.json` | 新增 `pino`、`pino-pretty`(dev) | +| `src/env.mjs` | 新增 `LOG_LEVEL` 字段 | +| `next.config.ts` | `serverExternalPackages` 添加 `pino` | +| `src/proxy.ts` | 生成/复用 requestId,注入到下游请求头 | +| `src/shared/lib/action-utils.ts` | `handleActionError` / `safeActionCall` 用 logger | +| `src/shared/lib/api-response.ts` | `handleApiError` 用 logger | +| `src/shared/lib/audit-logger.ts` | catch 块改为 `logger.warn` | +| `src/shared/lib/change-logger.ts` | catch 块改为 `logger.warn` | +| `src/shared/lib/login-logger.ts` | catch 块改为 `logger.warn` | +| `src/shared/lib/track-event.ts` | 改为 `createModuleLogger("track")` | +| `eslint.config.mjs` | 新增 `no-console` 规则 | +| 88 处 `console.*` 调用点 | 替换为 `logger.*` | +| 130 个 `error.tsx` | 接入 `useErrorReport` Hook | + +### 删除文件 +| 路径 | 原因 | +|------|------| +| `src/modules/rbac/lib/track.ts` | track-event 去重,引用方改为从 `@/shared/lib/track-event` 导入 | +| `src/modules/course-plans/lib/track-event.ts` | 同上 | +| `src/modules/questions/utils/track-event.ts` | 同上 | + +### 架构同步文件 +| 路径 | 改动 | +|------|------| +| `docs/architecture/004_architecture_impact_map.md` | 新增模块章节、修改记录、删除记录 | +| `docs/architecture/005_architecture_data.json` | 同步节点、exports、dependencyMatrix | +| `docs/troubleshooting/known-issues.md` | 新增 pino / Edge Runtime / Server Action 包装等规则条目 | + +--- + +## Phase 1:基础底座 + +### Task 1: 安装依赖与环境变量配置 + +**Files:** +- Modify: `package.json` +- Modify: `src/env.mjs` +- Modify: `next.config.ts` +- Modify: `.env.example` + +- [ ] **Step 1: 安装 pino 与 pino-pretty** + +Run: +```bash +npm install pino@^9.5.0 && npm install -D pino-pretty@^11.3.0 +``` + +Expected: `package.json` 中 `dependencies.pino` 与 `devDependencies.pino-pretty` 出现。 + +- [ ] **Step 2: 在 `src/env.mjs` 添加 `LOG_LEVEL` 字段** + +修改 `src/env.mjs`,在 `server` 对象中添加 `LOG_LEVEL`,并在 `runtimeEnv` 中映射: + +```js +// src/env.mjs +export const env = createEnv({ + server: { + DATABASE_URL: z.string().url(), + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + NEXTAUTH_SECRET: z.string().min(1).optional(), + NEXTAUTH_URL: z.string().url().optional(), + AI_API_KEY: z.string().min(1).optional(), + AI_BASE_URL: z.string().url().optional(), + AI_MODEL: z.string().min(1).optional(), + RATE_LIMIT_DRIVER: z.enum(["memory", "redis"]).default("memory"), + CACHE_DRIVER: z.enum(["memory", "redis"]).default("memory"), + UPSTASH_REDIS_REST_URL: z.string().url().optional(), + UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(), + CRON_SECRET: z.string().min(1).optional(), + // 新增:日志级别控制 + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + }, + client: { + NEXT_PUBLIC_APP_URL: z.string().url().optional(), + }, + runtimeEnv: { + DATABASE_URL: process.env.DATABASE_URL, + NODE_ENV: process.env.NODE_ENV, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + AI_API_KEY: process.env.AI_API_KEY, + AI_BASE_URL: process.env.AI_BASE_URL, + AI_MODEL: process.env.AI_MODEL, + RATE_LIMIT_DRIVER: process.env.RATE_LIMIT_DRIVER, + CACHE_DRIVER: process.env.CACHE_DRIVER, + UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, + UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, + CRON_SECRET: process.env.CRON_SECRET, + // 新增 + LOG_LEVEL: process.env.LOG_LEVEL, + }, + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + emptyStringAsUndefined: true, +}); +``` + +- [ ] **Step 3: 在 `.env.example` 添加 `LOG_LEVEL` 示例** + +读取 `.env.example`,在文件末尾追加: + +``` +# 日志级别(debug/info/warn/error),默认 info +LOG_LEVEL=info +``` + +- [ ] **Step 4: 在 `next.config.ts` 的 `serverExternalPackages` 添加 `pino`** + +修改 `next.config.ts`,将 `serverExternalPackages` 数组扩展: + +```ts +serverExternalPackages: [ + "mysql2", + "tencentcloud-sdk-nodejs", + "exceljs", + "pino", +], +``` + +- [ ] **Step 5: 验证构建无报错** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json src/env.mjs .env.example next.config.ts +git commit -m "feat(logging): add pino dependency and LOG_LEVEL env var" +``` + +--- + +### Task 2: 创建 `request-context.ts` + +**Files:** +- Create: `src/shared/lib/request-context.ts` +- Test: `src/shared/lib/request-context.test.ts` + +- [ ] **Step 1: 编写失败测试 `src/shared/lib/request-context.test.ts`** + +```ts +import { describe, it, expect } from "vitest" +import { + requestContextStorage, + getRequestContext, +} from "./request-context" + +describe("request-context", () => { + describe("getRequestContext (无上下文)", () => { + it("返回空对象", () => { + expect(getRequestContext()).toEqual({}) + }) + }) + + describe("requestContextStorage.run (有上下文)", () => { + it("在 run 回调内 getRequestContext 返回注入的上下文", () => { + const ctx = { requestId: "req-123", userId: "user-456" } + requestContextStorage.run(ctx, () => { + expect(getRequestContext()).toEqual(ctx) + }) + }) + + it("run 回调返回值正常透传", () => { + const result = requestContextStorage.run( + { requestId: "req-789" }, + () => "return-value" + ) + expect(result).toBe("return-value") + }) + + it("嵌套 run 内层覆盖外层", () => { + requestContextStorage.run({ requestId: "outer" }, () => { + expect(getRequestContext().requestId).toBe("outer") + requestContextStorage.run({ requestId: "inner" }, () => { + expect(getRequestContext().requestId).toBe("inner") + }) + expect(getRequestContext().requestId).toBe("outer") + }) + }) + + it("run 回调退出后 getRequestContext 恢复为空", () => { + requestContextStorage.run({ requestId: "temp" }, () => {}) + expect(getRequestContext()).toEqual({}) + }) + }) +}) +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: +```bash +npx vitest run --config vitest.unit.config.ts src/shared/lib/request-context.test.ts +``` + +Expected: FAIL,错误信息 "Cannot find module './request-context'" + +- [ ] **Step 3: 实现 `src/shared/lib/request-context.ts`** + +```ts +import { AsyncLocalStorage } from "node:async_hooks" + +/** + * 请求上下文,贯穿整个请求生命周期。 + * + * 仅在 Node.js Runtime 中可用(proxy.ts 是 Edge Runtime,不导入此模块)。 + * 通过 withRequestContext 高阶函数注入。 + */ +export interface RequestContext { + requestId: string + userId?: string + module?: string +} + +export const requestContextStorage = new AsyncLocalStorage() + +/** + * 获取当前请求上下文(若存在)。 + * + * - 在 withRequestContext 包装的调用栈内:返回完整上下文 + * - 在调用栈外(如顶层模块初始化、定时任务):返回空对象 + * + * pino logger 的 mixin 配置会自动调用此函数混入 requestId。 + */ +export function getRequestContext(): Partial { + return requestContextStorage.getStore() ?? {} +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: +```bash +npx vitest run --config vitest.unit.config.ts src/shared/lib/request-context.test.ts +``` + +Expected: PASS,5 个测试用例全部通过。 + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/lib/request-context.ts src/shared/lib/request-context.test.ts +git commit -m "feat(logging): add request-context with AsyncLocalStorage" +``` + +--- + +### Task 3: 创建 `logger.ts` + +**Files:** +- Create: `src/shared/lib/logger.ts` +- Test: `src/shared/lib/logger.test.ts` + +- [ ] **Step 1: 编写失败测试 `src/shared/lib/logger.test.ts`** + +```ts +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { pino } from "pino" +import { createModuleLogger, logger } from "./logger" +import { requestContextStorage } from "./request-context" + +// 捕获 pino 输出 +function captureLoggerOutput(logInstance: pino.Logger): string[] { + const lines: string[] = [] + const originalWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = (chunk: string | Uint8Array) => { + lines.push(chunk.toString()) + return true + } + return lines +} + +describe("logger", () => { + describe("createModuleLogger", () => { + it("返回的 child logger 包含 module 字段", () => { + const lines = captureLoggerOutput(logger) + const moduleLogger = createModuleLogger("audit") + moduleLogger.info("test message") + process.stdout.write = process.stdout.write.bind(process.stdout) + // 至少有一条日志包含 module: "audit" + const auditLine = lines.find((l) => l.includes('"module":"audit"')) + expect(auditLine).toBeDefined() + }) + + it("不同 module 返回不同 child logger", () => { + const a = createModuleLogger("a") + const b = createModuleLogger("b") + expect(a).not.toBe(b) + }) + }) + + describe("mixin 注入 requestId", () => { + it("在 requestContextStorage.run 内日志包含 requestId", () => { + const lines = captureLoggerOutput(logger) + requestContextStorage.run({ requestId: "req-mixin-test" }, () => { + logger.info("with request id") + }) + process.stdout.write = process.stdout.write.bind(process.stdout) + const matched = lines.find((l) => l.includes('"requestId":"req-mixin-test"')) + expect(matched).toBeDefined() + }) + + it("在 requestContextStorage.run 外日志不包含 requestId", () => { + const lines = captureLoggerOutput(logger) + logger.info("no request id") + process.stdout.write = process.stdout.write.bind(process.stdout) + const matched = lines.find((l) => l.includes('"requestId"')) + expect(matched).toBeUndefined() + }) + }) + + describe("日志级别", () => { + it("默认级别 info 下 debug 不输出", () => { + const lines = captureLoggerOutput(logger) + logger.debug("debug message") + process.stdout.write = process.stdout.write.bind(process.stdout) + const matched = lines.find((l) => l.includes('debug message')) + expect(matched).toBeUndefined() + }) + }) +}) +``` + +> 注:上述测试依赖 pino 的输出格式。`captureLoggerOutput` 是简化版,实际可能需要根据 pino transport 行为调整(开发环境有 pino-pretty transport)。若 transport 导致测试不稳定,可在测试中创建独立的 pino 实例验证 mixin 行为。 + +- [ ] **Step 2: 运行测试验证失败** + +Run: +```bash +npx vitest run --config vitest.unit.config.ts src/shared/lib/logger.test.ts +``` + +Expected: FAIL,错误信息 "Cannot find module './logger'" + +- [ ] **Step 3: 实现 `src/shared/lib/logger.ts`** + +```ts +import pino, { type Logger } from "pino" + +import { env } from "@/env.mjs" +import { getRequestContext } from "./request-context" + +/** + * 全局 logger 实例。 + * + * - 生产环境:JSON 输出到 stdout(docker logs 友好) + * - 开发环境:pino-pretty 彩色文本 + * - 自动从 AsyncLocalStorage 混入 requestId / userId(若存在) + */ +export const logger = pino({ + level: env.LOG_LEVEL, + base: { service: "cicd-app" }, + formatters: { + level: (label) => ({ level: label }), + }, + mixin: () => getRequestContext(), + ...(env.NODE_ENV === "development" && { + transport: { + target: "pino-pretty", + options: { colorize: true, translateTime: "SYS:standard" }, + }, + }), +}) + +/** + * 创建模块级子 logger,自动绑定 module 字段。 + * + * @example + * ```ts + * const log = createModuleLogger("audit") + * log.info({ userId }, "User action logged") + * // 输出: {"level":"info","module":"audit","msg":"User action logged", ...} + * ``` + */ +export function createModuleLogger(module: string): Logger { + return logger.child({ module }) +} + +export type { Logger } +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: +```bash +npx vitest run --config vitest.unit.config.ts src/shared/lib/logger.test.ts +``` + +Expected: PASS。若 pino-pretty transport 在 vitest 环境不稳定,调整测试为只验证 `createModuleLogger` 的 child logger 绑定(不验证输出格式)。 + +- [ ] **Step 5: 验证类型检查** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/shared/lib/logger.ts src/shared/lib/logger.test.ts +git commit -m "feat(logging): add pino logger with createModuleLogger" +``` + +--- + +### Task 4: 创建 `with-request-context.ts` + +**Files:** +- Create: `src/shared/lib/with-request-context.ts` +- Test: `src/shared/lib/with-request-context.test.ts` + +- [ ] **Step 1: 编写失败测试 `src/shared/lib/with-request-context.test.ts`** + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest" + +// mock next/headers 的 headers() 函数 +const mockHeaders = vi.fn() +vi.mock("next/headers", () => ({ + headers: () => mockHeaders(), +})) + +// mock crypto.randomUUID +vi.stubGlobal("crypto", { + randomUUID: () => "generated-uuid", +}) + +import { withRequestContext } from "./with-request-context" +import { getRequestContext } from "./request-context" + +describe("withRequestContext", () => { + beforeEach(() => { + mockHeaders.mockReset() + }) + + it("从 headers 读取 x-request-id 并注入 context", async () => { + mockHeaders.mockReturnValue({ + get: (name: string) => + name === "x-request-id" ? "proxy-injected-id" : null, + }) + + const wrapped = withRequestContext(async () => { + return getRequestContext() + }) + + const result = await wrapped() + expect(result).toEqual({ requestId: "proxy-injected-id" }) + }) + + it("headers 无 x-request-id 时生成新 UUID", async () => { + mockHeaders.mockReturnValue({ + get: () => null, + }) + + const wrapped = withRequestContext(async () => { + return getRequestContext() + }) + + const result = await wrapped() + expect(result).toEqual({ requestId: "generated-uuid" }) + }) + + it("包装函数的参数与返回值正常透传", async () => { + mockHeaders.mockReturnValue({ get: () => null }) + + const wrapped = withRequestContext(async (a: number, b: number) => { + return a + b + }) + + const result = await wrapped(3, 4) + expect(result).toBe(7) + }) + + it("调用栈下游 logger 自动获得 requestId", async () => { + mockHeaders.mockReturnValue({ + get: (name: string) => + name === "x-request-id" ? "req-downstream" : null, + }) + + let capturedCtx: { requestId?: string } = {} + const wrapped = withRequestContext(async () => { + // 模拟 data-access 层调用 + capturedCtx = getRequestContext() + }) + + await wrapped() + expect(capturedCtx.requestId).toBe("req-downstream") + }) +}) +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: +```bash +npx vitest run --config vitest.unit.config.ts src/shared/lib/with-request-context.test.ts +``` + +Expected: FAIL,错误信息 "Cannot find module './with-request-context'" + +- [ ] **Step 3: 实现 `src/shared/lib/with-request-context.ts`** + +```ts +import { randomUUID } from "node:crypto" +import { headers } from "next/headers" + +import { requestContextStorage, type RequestContext } from "./request-context" + +/** + * 包装 Server Action / Route Handler,注入 requestId 到 AsyncLocalStorage。 + * + * 工作流程: + * 1. 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id` + * 2. 若请求头无此字段(如直接调用的内部函数),生成新 UUID + * 3. 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage + * 4. 在调用栈内的所有 logger 调用自动获得 requestId + * + * @example + * ```ts + * export const createUserAction = withRequestContext( + * async (state: ActionState, input: CreateUserInput) => { + * // 此处 logger.info 会自动带 requestId + * return handleAction(...) + * } + * ) + * ``` + */ +export function withRequestContext( + fn: (...args: TArgs) => Promise +): (...args: TArgs) => Promise { + return async (...args: TArgs) => { + const headersList = await headers() + const requestId = headersList.get("x-request-id") ?? randomUUID() + const ctx: RequestContext = { requestId } + return requestContextStorage.run(ctx, () => fn(...args)) + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: +```bash +npx vitest run --config vitest.unit.config.ts src/shared/lib/with-request-context.test.ts +``` + +Expected: PASS,4 个测试用例全部通过。 + +- [ ] **Step 5: 验证类型检查** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/shared/lib/with-request-context.ts src/shared/lib/with-request-context.test.ts +git commit -m "feat(logging): add withRequestContext HOF for Server Actions" +``` + +--- + +## Phase 2:核心接入 + +### Task 5: proxy.ts 注入 x-request-id + +**Files:** +- Modify: `src/proxy.ts` +- Test: `tests/integration/proxy-guard.test.ts`(已存在,追加用例) + +- [ ] **Step 1: 修改 `src/proxy.ts` 注入 requestId** + +读取 `src/proxy.ts`,按以下方式改造。核心思路:在 proxy 函数最前面生成 requestId,所有 `NextResponse.next()` 调用统一传入注入请求头的副本。 + +```ts +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import { getToken } from "next-auth/jwt" + +import { type Permission } from "@/shared/types/permissions" +import { resolveDefaultPath } from "@/shared/lib/route-resolver" +import { hasPermissionInBitmap } from "@/shared/lib/permission-bitmap" +import { + SPECIFIC_ROUTE_PERMISSIONS, + ROUTE_PREFIX_PERMISSIONS, + DASHBOARD_ROUTE_PERMISSIONS, + API_ROUTE_PERMISSIONS, +} from "@/shared/lib/route-permissions" + +// Next.js 16 renamed `middleware` to `proxy`. +// See: https://nextjs.org/docs/messages/middleware-to-proxy +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl + + // 生成或复用 requestId(Web Crypto API,Edge Runtime 兼容) + const requestId = + request.headers.get("x-request-id") ?? crypto.randomUUID() + + // 跳过静态资源和登录页:仍注入 requestId 便于关联下游 + if ( + pathname.startsWith("/_next") || + pathname.startsWith("/api/auth") || + pathname === "/login" || + pathname === "/register" || + pathname === "/favicon.ico" + ) { + return NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) + } + + const token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + }) + + // 未认证 → 重定向到登录页 + if (!token) { + const loginUrl = new URL("/login", request.url) + loginUrl.searchParams.set("callbackUrl", request.url) + return NextResponse.redirect(loginUrl) + } + + // Onboarding gate + const onboarded = Boolean(token.onboarded) + const isOnboardingPath = pathname === "/onboarding" || pathname.startsWith("/onboarding/") + const isWhitelistedApi = pathname.startsWith("/api/auth") || pathname.startsWith("/api/onboarding") + if (!onboarded && !isOnboardingPath && !isWhitelistedApi) { + const onboardingUrl = new URL("/onboarding", request.url) + return NextResponse.redirect(onboardingUrl) + } + if (onboarded && isOnboardingPath) { + const roles: string[] = (token.roles as string[]) ?? [] + const defaultPath = resolveDefaultPath(roles) + return NextResponse.redirect(new URL(defaultPath, request.url)) + } + + const permissionsBitmap: string = (token.permissionsBitmap as string) ?? "" + const roles: string[] = (token.roles as string[]) ?? [] + + /** + * audit-P1-7:使用位图检查权限,避免每次路由检查都解码完整权限数组。 + * proxy.ts 在 edge runtime 运行,每个请求都经过这里,性能至关重要。 + */ + function hasPermission(requiredPerm: Permission): boolean { + return hasPermissionInBitmap(permissionsBitmap, requiredPerm) + } + + // Check API route permissions + for (const [prefix, requiredPerm] of Object.entries(API_ROUTE_PERMISSIONS)) { + if (pathname.startsWith(prefix)) { + if (!hasPermission(requiredPerm)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + break + } + } + + // Check page route permissions + if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) { + const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname] + if (!hasPermission(requiredPerm)) { + const defaultPath = resolveDefaultPath(roles) + const redirectUrl = new URL(defaultPath, request.url) + redirectUrl.searchParams.set("from", pathname) + redirectUrl.searchParams.set("reason", "forbidden") + return NextResponse.redirect(redirectUrl) + } + return NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) + } + + if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) { + const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname] + if (!hasPermission(requiredPerm)) { + const defaultPath = resolveDefaultPath(roles) + const redirectUrl = new URL(defaultPath, request.url) + redirectUrl.searchParams.set("from", pathname) + redirectUrl.searchParams.set("reason", "forbidden") + return NextResponse.redirect(redirectUrl) + } + return NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) + } + + for (const [prefix, requiredPerm] of Object.entries(ROUTE_PREFIX_PERMISSIONS)) { + if (pathname.startsWith(prefix)) { + if (!hasPermission(requiredPerm)) { + const defaultPath = resolveDefaultPath(roles) + const redirectUrl = new URL(defaultPath, request.url) + redirectUrl.searchParams.set("from", pathname) + redirectUrl.searchParams.set("reason", "forbidden") + return NextResponse.redirect(redirectUrl) + } + break + } + } + + const response = NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) + response.headers.set("x-request-id", requestId) + return response +} + +/** + * 创建包含 x-request-id 的新 Headers 对象。 + * 通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC 请求。 + * + * 注意:proxy.ts 在 Edge Runtime 运行,不能导入 node:async_hooks 或 request-context.ts。 + */ +function injectRequestId(request: NextRequest, requestId: string): Headers { + const headers = new Headers(request.headers) + headers.set("x-request-id", requestId) + return headers +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +} +``` + +- [ ] **Step 2: 验证类型检查与现有测试** + +Run: +```bash +npm run typecheck +npm run test:integration -- tests/integration/proxy-guard.test.ts +``` + +Expected: 0 type errors;proxy-guard 集成测试全部通过(已有测试不依赖 requestId,仅新增注入逻辑)。 + +- [ ] **Step 3: 追加 proxy-guard 测试用例(验证 requestId 注入)** + +读取 `tests/integration/proxy-guard.test.ts`,在文件末尾追加一个 describe 块: + +```ts +describe("proxy requestId 注入", () => { + it("未带 x-request-id 请求头时,response 头包含新生成的 x-request-id", async () => { + const req = new NextRequest(new URL("/dashboard", "http://localhost:3000")) + // ...按现有测试的 auth mock 模式设置 token + const res = await proxy(req) + expect(res.headers.get("x-request-id")).toBeDefined() + }) + + it("带 x-request-id 请求头时,response 头沿用请求头中的 id", async () => { + const existingId = "client-supplied-id" + const req = new NextRequest(new URL("/dashboard", "http://localhost:3000"), { + headers: { "x-request-id": existingId }, + }) + // ...按现有测试的 auth mock 模式设置 token + const res = await proxy(req) + expect(res.headers.get("x-request-id")).toBe(existingId) + }) +}) +``` + +> 实施时根据现有 proxy-guard.test.ts 的 mock 模式调整 auth 设置代码。 + +- [ ] **Step 4: 运行新测试验证通过** + +Run: +```bash +npm run test:integration -- tests/integration/proxy-guard.test.ts +``` + +Expected: 既有用例 + 新增 2 个用例全部通过。 + +- [ ] **Step 5: Commit** + +```bash +git add src/proxy.ts tests/integration/proxy-guard.test.ts +git commit -m "feat(logging): inject x-request-id in proxy.ts" +``` + +--- + +### Task 6: action-utils.ts 接入 logger + +**Files:** +- Modify: `src/shared/lib/action-utils.ts` + +- [ ] **Step 1: 替换 `handleActionError` 与 `safeActionCall` 中的 `console.error`** + +读取 `src/shared/lib/action-utils.ts`,在文件顶部添加导入与模块 logger,替换两处 `console.error`: + +```ts +import { createModuleLogger } from "@/shared/lib/logger" +import type { ActionState } from "@/shared/types/action-state" +import { PermissionDeniedError } from "@/shared/lib/errors" +// ... 原有 imports + +const log = createModuleLogger("action") + +// ... BusinessError / NotFoundError / ValidationError 类定义不变 + +export function handleActionError(e: unknown): ActionState { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + if (e instanceof BusinessError) { + return { success: false, message: e.message, errorCode: e.code } + } + if (e instanceof Error) { + log.error({ err: e }, "Action failed") + return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" } + } + log.error({ err: e }, "Unknown action error") + return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" } +} + +export async function safeActionCall( + action: () => Promise>, + options?: { + onError?: (error: unknown) => void + onFinally?: () => void + } +): Promise | null> { + try { + return await action() + } catch (e) { + options?.onError?.(e) + log.error({ err: e }, "Safe action call threw") + return null + } finally { + options?.onFinally?.() + } +} + +// ... 其他函数不变 +``` + +- [ ] **Step 2: 验证类型检查** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 3: 验证 ESLint** + +Run: +```bash +npm run lint +``` + +Expected: 0 errors(若 ESLint `no-console` 规则尚未启用,此步可通过;Task 11 启用规则后此处不应再有 console.error)。 + +- [ ] **Step 4: Commit** + +```bash +git add src/shared/lib/action-utils.ts +git commit -m "refactor(logging): replace console.error in action-utils with logger" +``` + +--- + +### Task 7: api-response.ts 接入 logger + +**Files:** +- Modify: `src/shared/lib/api-response.ts` + +- [ ] **Step 1: 替换 `handleApiError` 中的 `console.error`** + +读取 `src/shared/lib/api-response.ts`,在文件顶部添加导入与模块 logger,替换 `handleApiError` 中的两处 `console.error`: + +```ts +import { NextResponse } from "next/server" + +import { createModuleLogger } from "@/shared/lib/logger" +import { PermissionDeniedError } from "@/shared/lib/errors" +import { + BusinessError, + NotFoundError, + ValidationError, +} from "@/shared/lib/action-utils" +import type { ActionState } from "@/shared/types/action-state" + +const log = createModuleLogger("api") + +// ... 类型定义与 apiSuccess / apiError / apiFromAction / errorToStatus 不变 + +export function handleApiError( + error: unknown, + init?: { headers?: HeadersInit } +): NextResponse { + // 已知业务错误:消息可安全暴露,无需 logger + if (error instanceof PermissionDeniedError || error instanceof BusinessError) { + return apiError( + error instanceof PermissionDeniedError ? error.message : error.message, + errorToStatus(error), + error instanceof BusinessError ? error.code : undefined, + init + ) + } + + // 未预期错误:记录服务端日志,不暴露细节 + if (error instanceof Error) { + log.error({ err: error }, "API error") + } else { + log.error({ err: error }, "API unknown error") + } + return apiError("请求失败,请稍后重试", 500, "unexpected", init) +} + +// ... withApiErrorHandler / parseJsonBody 不变 +``` + +- [ ] **Step 2: 验证类型检查** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/shared/lib/api-response.ts +git commit -m "refactor(logging): replace console.error in api-response with logger" +``` + +--- + +### Task 8: 三个 audit-logger 静默失败改为 logger.warn + +**Files:** +- Modify: `src/shared/lib/audit-logger.ts` +- Modify: `src/shared/lib/change-logger.ts` +- Modify: `src/shared/lib/login-logger.ts` + +- [ ] **Step 1: 修改 `src/shared/lib/audit-logger.ts`** + +读取 `src/shared/lib/audit-logger.ts`,在文件顶部添加 logger 导入,将 catch 块从 silent 改为 `logger.warn`: + +```ts +"use server" + +import { createId } from "@paralleldrive/cuid2" +import { db } from "@/shared/db" +import { auditLogs } from "@/shared/db/schema" +import { getSession } from "@/shared/lib/session" +import { resolveClientIp, getUserAgent } from "@/shared/lib/http-utils" +import { createModuleLogger } from "@/shared/lib/logger" + +const log = createModuleLogger("audit-logger") + +export type AuditLogStatus = "success" | "failure" + +export interface LogAuditParams { + action: string + module: string + targetId?: string + targetType?: string + detail?: Record + status?: AuditLogStatus +} + +/** + * Record an audit log entry for the current authenticated user. + * + * Note: 失败时记录到 logger.warn 而非静默吞没,确保运维可感知审计写入失败。 + */ +export async function logAudit(params: LogAuditParams): Promise { + try { + const session = await getSession() + const ipAddress = await resolveClientIp() + const userAgent = await getUserAgent() + + await db.insert(auditLogs).values({ + id: createId(), + userId: session?.user?.id ?? "unknown", + userName: session?.user?.name ?? "unknown", + action: params.action, + module: params.module, + targetId: params.targetId ?? null, + targetType: params.targetType ?? null, + detail: params.detail ? JSON.stringify(params.detail) : null, + ipAddress, + userAgent, + status: params.status ?? "success", + }) + } catch (error) { + log.warn( + { err: error, action: params.action, module: params.module }, + "Audit log write failed" + ) + } +} +``` + +- [ ] **Step 2: 修改 `src/shared/lib/change-logger.ts`** + +读取 `src/shared/lib/change-logger.ts`,按相同模式修改:在文件顶部添加 `import { createModuleLogger } from "@/shared/lib/logger"`,定义 `const log = createModuleLogger("change-logger")`,将 catch 块改为: + +```ts +} catch (error) { + log.warn( + { err: error, tableName: params.tableName, recordId: params.recordId }, + "Change log write failed" + ) +} +``` + +> 实施时根据 change-logger.ts 实际参数名调整字段(tableName/recordId/action 等)。 + +- [ ] **Step 3: 修改 `src/shared/lib/login-logger.ts`** + +读取 `src/shared/lib/login-logger.ts`,按相同模式修改:定义 `const log = createModuleLogger("login-logger")`,将 catch 块改为: + +```ts +} catch (error) { + log.warn( + { err: error, action: params.action, userEmail: params.userEmail }, + "Login log write failed" + ) +} +``` + +> 实施时根据 login-logger.ts 实际参数名调整字段。 + +- [ ] **Step 4: 验证类型检查** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/lib/audit-logger.ts src/shared/lib/change-logger.ts src/shared/lib/login-logger.ts +git commit -m "fix(logging): replace silent audit-logger failures with logger.warn" +``` + +--- + +### Task 9: track-event 去重合并 + +**Files:** +- Modify: `src/shared/lib/track-event.ts` +- Delete: `src/modules/rbac/lib/track.ts` +- Delete: `src/modules/course-plans/lib/track-event.ts` +- Delete: `src/modules/questions/utils/track-event.ts` + +- [ ] **Step 1: 先 grep 所有引用方,了解替换范围** + +Run: +```bash +# 列出所有引用待删除文件的位置 +npx grep -rn "from \"@/modules/rbac/lib/track\"" src/ +npx grep -rn "from \"@/modules/course-plans/lib/track-event\"" src/ +npx grep -rn "from \"@/modules/questions/utils/track-event\"" src/ +``` + +> 实施时使用项目工具:`Grep "from \"@/modules/rbac/lib/track\""` 等。 + +Expected: 列出所有引用位置,记录待替换的 import 路径。 + +- [ ] **Step 2: 重写 `src/shared/lib/track-event.ts`** + +读取 `src/shared/lib/track-event.ts`,将 no-op 实现替换为基于 logger 的实现: + +```ts +import { createModuleLogger } from "@/shared/lib/logger" + +const log = createModuleLogger("track") + +/** + * 业务埋点接口。 + * + * 通过 logger.info 输出结构化事件,后续可扩展为接入外部 analytics 服务。 + */ +export function trackEvent( + name: string, + props?: Record +): void { + log.info({ event: name, ...props }, "track event") +} + +export function trackExamEvent( + name: string, + props?: Record +): void { + trackEvent(`exam.${name}`, props) +} + +export function trackAuthEvent( + name: string, + props?: Record +): void { + trackEvent(`auth.${name}`, props) +} +``` + +- [ ] **Step 3: 替换所有引用方** + +将步骤 1 找到的所有 import 路径替换为: + +```ts +import { trackEvent /* ...其他需要的导出 */ } from "@/shared/lib/track-event" +``` + +- [ ] **Step 4: 删除三个重复文件** + +Run(使用 DeleteFile 工具,非 shell): +- 删除 `src/modules/rbac/lib/track.ts` +- 删除 `src/modules/course-plans/lib/track-event.ts` +- 删除 `src/modules/questions/utils/track-event.ts` + +- [ ] **Step 5: 验证类型检查** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors。若有 error 说明引用未替换完整,回到 Step 3 修复。 + +- [ ] **Step 6: 验证 ESLint** + +Run: +```bash +npm run lint +``` + +Expected: 0 errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/shared/lib/track-event.ts src/modules/rbac/lib/track.ts src/modules/course-plans/lib/track-event.ts src/modules/questions/utils/track-event.ts <其他被修改的引用方文件> +git commit -m "refactor(logging): consolidate track-event stubs into shared module" +``` + +> 注:被删除的文件用 `git add -u` 或显式 `git rm` 即可。 + +--- + +## Phase 3:批量替换 console.* + +### Task 10: 替换 88 处 console.* 调用 + +**Files:** +- Modify: 88 个文件中的 `console.*` 调用点 + +- [ ] **Step 1: 列出所有 console.* 调用点** + +Run(使用 Grep 工具): +- Pattern: `console\.(log|error|warn|info|debug)\(` +- Output mode: `content` +- `-n`: true +- Glob: `src/**/*.{ts,tsx}` + +记录每个文件、行号、调用类型。预期约 88 处。 + +- [ ] **Step 2: 按模块分组替换** + +按模块分组处理,每组一次性替换: + +| 模块 | 主要文件 | 替换为 | +|------|---------|--------| +| `questions` | `src/modules/questions/utils/parse-content.ts` | `const log = createModuleLogger("questions"); log.debug(...)` | +| `files` | `src/modules/files/data-access.ts` 等 | `createModuleLogger("files")` | +| `audit` | `src/modules/audit/data-access.ts` 等 | `createModuleLogger("audit")` | +| `exams` | `src/modules/exams/actions.ts` 等 | `createModuleLogger("exams")` | +| `ai` | `src/modules/ai/services/usage-tracker.ts` 等 | `createModuleLogger("ai")` | +| `notifications` | `src/modules/notifications/channels/*` 等 | `createModuleLogger("notifications")` | +| `redis` / `cache` | `src/shared/lib/cache/redis-store.ts` 等 | `createModuleLogger("cache")` | +| `web-vitals` | `src/app/api/web-vitals/route.ts` | `createModuleLogger("web-vitals")` | +| 其他 | 按文件所属模块 | 见设计文档 5.2 节前缀映射表 | + +每个文件的标准改造模式: +```ts +// 1. 文件顶部添加导入与 logger +import { createModuleLogger } from "@/shared/lib/logger" +const log = createModuleLogger("") + +// 2. 替换 console.error("xxx failed:", error) → log.error({ err: error }, "xxx failed") +// 3. 替换 console.log("xxx") → log.debug("xxx") +// 4. 替换 console.warn("xxx") → log.warn("xxx") +// 5. 替换 console.info("xxx") → log.info("xxx") +``` + +**前缀规范化映射**: +- `[ExamAction]` → `module: "exams"`(移除手写前缀) +- `[ActionError]` / `[SafeActionCall]` → 已在 Task 6 处理 +- `[ApiError]` → 已在 Task 7 处理 +- `[AuditLogger]` → 已在 Task 8 处理 +- `[Files]` / `[files]` → `module: "files"` +- 其他 `[XxxPrefix]` → `module: ""` +- 无前缀 → 按文件所属模块创建 logger + +- [ ] **Step 3: 逐模块验证(每模块改完后跑 typecheck)** + +每替换完一个模块后运行: +```bash +npm run typecheck +``` + +Expected: 0 errors。 + +- [ ] **Step 4: 全量验证** + +替换完所有 88 处后运行: +```bash +npm run typecheck +npm run lint +``` + +Expected: 0 errors。 + +> 注:此时 ESLint `no-console` 规则尚未启用,所以 lint 不会因 console 报错。Task 11 启用规则后会强制。 + +- [ ] **Step 5: 验证无遗漏(grep 应返回 0 个真实调用点)** + +Run(使用 Grep 工具): +- Pattern: `console\.(log|error|warn|info|debug)\(` +- Glob: `src/**/*.{ts,tsx}` +- 忽略 `src/shared/lib/logger.ts`(pino 内部豁免) + +Expected: 仅在 `logger.ts` 中有 console(若有),其他文件 0 个匹配。 + +- [ ] **Step 6: Commit** + +```bash +git add <所有被修改的文件> +git commit -m "refactor(logging): replace 88 console.* calls with module loggers" +``` + +--- + +### Task 11: 启用 ESLint no-console 规则 + +**Files:** +- Modify: `eslint.config.mjs` + +- [ ] **Step 1: 在 `eslint.config.mjs` 添加 `no-console` 规则与豁免** + +读取 `eslint.config.mjs`,在 rules 配置对象中添加 `no-console`,并新增一个 overrides 块豁免 `logger.ts`: + +```js +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + { + rules: { + "react-hooks/incompatible-library": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + // 禁止硬编码 hex 颜色字面量 + "no-restricted-syntax": [ + "error", + { + selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]", + message: + "禁止硬编码 hex 颜色,使用设计令牌 hsl(var(--*)) 或 Tailwind 类 bg-*", + }, + ], + // 新增:禁止使用 console,统一通过 logger 模块 + "no-console": [ + "error", + { allow: [], allowWithImplicit: false }, + ], + }, + }, + // 新增:logger.ts 内部允许 console(pino 内部实现可能使用) + { + files: ["src/shared/lib/logger.ts"], + rules: { + "no-console": "off", + }, + }, + // ... 其他既有配置块保持不变 +]); +``` + +- [ ] **Step 2: 验证 ESLint 通过** + +Run: +```bash +npm run lint +``` + +Expected: 0 errors。若有 `no-console` 报错,说明 Task 10 中有遗漏的 `console.*`,回到 Task 10 修复。 + +- [ ] **Step 3: Commit** + +```bash +git add eslint.config.mjs +git commit -m "feat(logging): enable ESLint no-console rule with logger.ts exemption" +``` + +--- + +## Phase 4:error.tsx 客户端错误上报 + +### Task 12: 创建 use-error-report Hook + +**Files:** +- Create: `src/shared/hooks/use-error-report.ts` +- Test: `src/shared/hooks/use-error-report.test.tsx` + +- [ ] **Step 1: 编写失败测试 `src/shared/hooks/use-error-report.test.tsx`** + +```tsx +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { renderHook } from "@testing-library/react" +import { useErrorReport } from "./use-error-report" + +// mock fetch +const mockFetch = vi.fn() +vi.stubGlobal("fetch", mockFetch) + +// mock sessionStorage +const sessionStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + clear: vi.fn(() => { + store = {} + }), + } +})() +vi.stubGlobal("sessionStorage", sessionStorageMock) + +describe("useErrorReport", () => { + beforeEach(() => { + mockFetch.mockReset() + sessionStorageMock.clear() + }) + + it("调用 fetch POST 到 /api/client-error", () => { + const error = new Error("Test error") + error.stack = "stack trace" + renderHook(() => useErrorReport(error)) + + expect(mockFetch).toHaveBeenCalledWith( + "/api/client-error", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + keepalive: true, + }) + ) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.message).toBe("Test error") + expect(body.stack).toBe("stack trace") + expect(body.url).toBeDefined() + expect(body.userAgent).toBeDefined() + expect(body.timestamp).toBeDefined() + }) + + it("同一 digest 不重复上报(节流)", () => { + const error = new Error("Repeated error") + error.digest = "digest-123" + + const { rerender } = renderHook(() => useErrorReport(error)) + expect(mockFetch).toHaveBeenCalledTimes(1) + + // 重渲染同一 error,应被节流 + rerender() + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("不同 digest 分别上报", () => { + const error1 = new Error("Error A") + error1.digest = "digest-a" + const error2 = new Error("Error B") + error2.digest = "digest-b" + + const { rerender } = renderHook(({ err }) => useErrorReport(err), { + initialProps: error1, + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + + rerender(error2) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it("无 digest 时用 message 作为节流 key", () => { + const error = new Error("No digest error") + + renderHook(() => useErrorReport(error)) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(sessionStorageMock.setItem).toHaveBeenCalledWith( + "error-reported:No digest error", + "1" + ) + }) + + it("error 为空时不调用 fetch", () => { + renderHook(() => useErrorReport(null as unknown as Error)) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("fetch 失败时不抛出(避免无限循环)", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")) + const error = new Error("Trigger error") + + const { result } = renderHook(() => useErrorReport(error)) + // 不应有 unhandled rejection + expect(result.current).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: +```bash +npx vitest run --config vitest.unit.config.ts src/shared/hooks/use-error-report.test.tsx +``` + +Expected: FAIL,错误信息 "Cannot find module './use-error-report'" + +- [ ] **Step 3: 实现 `src/shared/hooks/use-error-report.ts`** + +```ts +"use client" + +import { useEffect } from "react" + +interface ClientErrorPayload { + message: string + stack?: string + digest?: string + url: string + userAgent: string + timestamp: string +} + +/** + * 客户端错误上报 Hook。 + * + * 用于 error.tsx 接收 error prop 后上报到 /api/client-error。 + * + * 节流策略: + * - 同一 digest(或 message)在 sessionStorage 中标记,避免 React 重渲染或快速刷新时多次上报 + * - 上报失败时静默忽略,避免无限循环 + * + * @example + * ```tsx + * "use client" + * import { useErrorReport } from "@/shared/hooks/use-error-report" + * + * export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + * useErrorReport(error) + * // ... UI + * } + * ``` + */ +export function useErrorReport(error: Error & { digest?: string }): void { + useEffect(() => { + if (!error) return + + const digest = error.digest ?? error.message + const storageKey = `error-reported:${digest}` + if (sessionStorage.getItem(storageKey)) return + sessionStorage.setItem(storageKey, "1") + + const payload: ClientErrorPayload = { + message: error.message, + stack: error.stack, + digest: error.digest, + url: window.location.href, + userAgent: navigator.userAgent, + timestamp: new Date().toISOString(), + } + + fetch("/api/client-error", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + keepalive: true, + }).catch(() => { + // 上报失败时静默忽略,避免无限循环 + }) + }, [error]) +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +Run: +```bash +npx vitest run --config vitest.unit.config.ts src/shared/hooks/use-error-report.test.tsx +``` + +Expected: PASS,6 个测试用例全部通过。 + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/hooks/use-error-report.ts src/shared/hooks/use-error-report.test.tsx +git commit -m "feat(logging): add useErrorReport hook for client error reporting" +``` + +--- + +### Task 13: 创建 `/api/client-error` Route Handler + +**Files:** +- Create: `src/app/api/client-error/route.ts` + +- [ ] **Step 1: 创建 `src/app/api/client-error/route.ts`** + +```ts +import { NextResponse } from "next/server" + +import { createModuleLogger } from "@/shared/lib/logger" +import { withRequestContext } from "@/shared/lib/with-request-context" + +const log = createModuleLogger("client-error") + +interface ClientErrorPayload { + message: string + stack?: string + digest?: string + url: string + userAgent: string + timestamp: string +} + +/** + * 接收客户端 error.tsx 上报的错误。 + * + * 客户端错误的 requestId 来自本次 /api/client-error 的 HTTP 请求(由 proxy.ts 注入), + * digest 字段可用于关联到原始客户端错误。 + */ +export const POST = withRequestContext(async (request: Request) => { + try { + const body = (await request.json()) as ClientErrorPayload + log.error( + { + clientMessage: body.message, + stack: body.stack, + digest: body.digest, + url: body.url, + userAgent: body.userAgent, + clientTimestamp: body.timestamp, + }, + "Client error reported" + ) + return NextResponse.json({ ok: true }) + } catch (error) { + log.error({ err: error }, "Failed to parse client error payload") + return NextResponse.json({ ok: false }, { status: 400 }) + } +}) +``` + +- [ ] **Step 2: 验证类型检查** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 3: 验证 ESLint** + +Run: +```bash +npm run lint +``` + +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/client-error/route.ts +git commit -m "feat(logging): add /api/client-error endpoint for client error reporting" +``` + +--- + +### Task 14: 130 个 error.tsx 接入 useErrorReport + +**Files:** +- Modify: 130 个 `error.tsx` 文件 + +- [ ] **Step 1: 列出所有 error.tsx 文件** + +Run(使用 Glob): +- Pattern: `src/app/**/error.tsx` + +Expected: 约 130 个文件路径。 + +- [ ] **Step 2: 逐个 error.tsx 接入** + +对每个 `error.tsx` 文件执行以下改动: + +1. 添加 import(在 `"use client"` 之后): +```ts +import { useErrorReport } from "@/shared/hooks/use-error-report" +``` + +2. 在组件函数体首行添加调用: +```tsx +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useErrorReport(error) + // ... 原有 UI 渲染保留 +} +``` + +> **批量执行建议**:可使用脚本或子代理分组处理(如按 `src/app/(dashboard)/admin/*` 分组)。每个 error.tsx 仅加 2 行(import + 调用),改动机械且低风险。 + +- [ ] **Step 3: 全量验证** + +Run: +```bash +npm run typecheck +npm run lint +``` + +Expected: 0 errors。 + +- [ ] **Step 4: 抽查验证接入正确** + +随机抽 3-5 个 error.tsx 文件,确认: +- 文件顶部有 `import { useErrorReport } from "@/shared/hooks/use-error-report"` +- 组件函数体首行有 `useErrorReport(error)` 调用 +- 接收的 props 中有 `error: Error & { digest?: string }` + +- [ ] **Step 5: Commit** + +```bash +git add <所有 error.tsx 文件> +git commit -m "feat(logging): wire up useErrorReport in all 130 error.tsx files" +``` + +--- + +## Phase 5:架构同步与最终验证 + +### Task 15: 更新 `004_architecture_impact_map.md` + +**Files:** +- Modify: `docs/architecture/004_architecture_impact_map.md` + +- [ ] **Step 1: 新增 `shared/lib` 日志相关模块章节** + +在 `004_architecture_impact_map.md` 的 `shared/lib` 章节下新增: + +```markdown +### shared/lib/logger.ts + +**导出**: `logger` (pino.Logger), `createModuleLogger(module: string): pino.Logger` + +**职责**: +- 全局 pino logger 实例(生产 JSON / 开发 pino-pretty) +- 模块级子 logger 工厂,绑定 `module` 字段 +- 自动从 `requestContextStorage` 混入 `requestId` / `userId` + +**依赖**: +- `@/env.mjs`(LOG_LEVEL) +- `./request-context`(getRequestContext) + +**被依赖**: +- 全项目所有模块的 logger 调用 +- `action-utils` / `api-response` / `audit-logger` / `change-logger` / `login-logger` / `track-event` + +### shared/lib/request-context.ts + +**导出**: `requestContextStorage` (AsyncLocalStorage), `getRequestContext(): Partial` + +**职责**: +- 在 Node.js runtime 中通过 AsyncLocalStorage 持有当前请求上下文(requestId/userId) +- pino logger 的 mixin 自动调用 getRequestContext 注入 requestId + +**关键约束**: 仅在 Node.js runtime 可用,proxy.ts(Edge)不导入此模块。 + +### shared/lib/with-request-context.ts + +**导出**: `withRequestContext(fn): (...args) => Promise` + +**职责**: +- 高阶函数包装 Server Action / Route Handler +- 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id` +- 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage + +**被依赖**: Server Action 模块、`/api/client-error` Route Handler +``` + +- [ ] **Step 2: 修改 `proxy.ts` 章节记录 requestId 注入** + +在 `004_architecture_impact_map.md` 中 `proxy.ts` 章节追加: + +```markdown +**新增职责(2026-07-07)**: +- 生成或复用 `x-request-id`(Web Crypto API,Edge 兼容) +- 通过 `NextResponse.next({ request: { headers } })` 注入到下游 RSC / Server Action +- 响应头也设置 `x-request-id` 便于客户端关联 +``` + +- [ ] **Step 3: 修改 audit-logger / change-logger / login-logger 章节记录静默失败修复** + +```markdown +### shared/lib/audit-logger.ts(修订 2026-07-07) + +**变更**: catch 块从 silent 改为 `logger.warn`,运维可感知审计写入失败。 + +### shared/lib/track-event.ts(修订 2026-07-07) + +**变更**: 实现层从 no-op console.info 改为 `createModuleLogger("track").info`, +不再需要每个模块复制 stub。删除 modules/rbac/lib/track.ts、modules/course-plans/lib/track-event.ts、modules/questions/utils/track-event.ts。 +``` + +- [ ] **Step 4: 新增 `shared/hooks/use-error-report.ts` 章节** + +```markdown +### shared/hooks/use-error-report.ts + +**导出**: `useErrorReport(error: Error & { digest?: string }): void` + +**职责**: +- 客户端 error.tsx 接收 error 后通过 fetch POST 上报到 `/api/client-error` +- 节流策略:sessionStorage 标记 digest,避免重复上报 +- fetch 失败静默忽略,避免无限循环 + +**被依赖**: 130 个 error.tsx 文件 +``` + +- [ ] **Step 5: 新增 `app/api/client-error/route.ts` 章节** + +```markdown +### app/api/client-error/route.ts + +**导出**: `POST` (withRequestContext 包装) + +**职责**: +- 接收客户端 error.tsx 上报的错误 +- 通过 `createModuleLogger("client-error").error` 记录到服务端日志 +- 返回 `{ ok: true }` 确认接收 + +**依赖**: `@/shared/lib/logger`, `@/shared/lib/with-request-context` +``` + +- [ ] **Step 6: Commit** + +```bash +git add docs/architecture/004_architecture_impact_map.md +git commit -m "docs(architecture): update 004 with logging system modules" +``` + +--- + +### Task 16: 更新 `005_architecture_data.json` + +**Files:** +- Modify: `docs/architecture/005_architecture_data.json` + +- [ ] **Step 1: 在 `modules.shared.lib.exports` 新增日志相关导出** + +读取 `005_architecture_data.json`,在 `modules.shared.lib.exports` 数组中追加: + +```json +{ + "name": "logger", + "path": "src/shared/lib/logger.ts", + "type": "const", + "signature": "pino.Logger" +}, +{ + "name": "createModuleLogger", + "path": "src/shared/lib/logger.ts", + "type": "function", + "signature": "(module: string) => pino.Logger" +}, +{ + "name": "requestContextStorage", + "path": "src/shared/lib/request-context.ts", + "type": "const", + "signature": "AsyncLocalStorage" +}, +{ + "name": "getRequestContext", + "path": "src/shared/lib/request-context.ts", + "type": "function", + "signature": "() => Partial" +}, +{ + "name": "withRequestContext", + "path": "src/shared/lib/with-request-context.ts", + "type": "function", + "signature": "(fn: (...args: TArgs) => Promise) => (...args: TArgs) => Promise" +} +``` + +- [ ] **Step 2: 在 `modules.shared.hooks.exports` 新增 `useErrorReport`** + +```json +{ + "name": "useErrorReport", + "path": "src/shared/hooks/use-error-report.ts", + "type": "function", + "signature": "(error: Error & { digest?: string }) => void" +} +``` + +- [ ] **Step 3: 新增 `app.api.client-error` 模块节点** + +在 `modules` 对象中新增: + +```json +"app.api.client-error": { + "path": "src/app/api/client-error", + "type": "route-handler", + "exports": [ + { + "name": "POST", + "type": "function", + "wrappedWith": "withRequestContext" + } + ], + "dependencies": [ + "shared/lib/logger", + "shared/lib/with-request-context" + ] +} +``` + +- [ ] **Step 4: 更新 `proxy` 节点(注入 requestId 职责)** + +在 `modules.proxy` 节点的 `responsibilities` 数组中追加: + +```json +"生成或复用 x-request-id(Web Crypto API)", +"通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC / Server Action", +"响应头设置 x-request-id 便于客户端关联" +``` + +- [ ] **Step 5: 更新 `dependencyMatrix`** + +新增依赖关系: + +```json +[ + "shared.lib.logger", + "shared.lib.request-context" +], +[ + "shared.lib.with-request-context", + "shared.lib.request-context" +], +[ + "app.api.client-error", + "shared.lib.logger" +], +[ + "app.api.client-error", + "shared.lib.with-request-context" +], +[ + "shared.hooks.use-error-report", + "app.api.client-error" +] +``` + +- [ ] **Step 6: 删除已不存在的模块节点** + +从 `modules` 中删除: +- `modules.rbac.lib.track` +- `modules.course-plans.lib.track-event` +- `modules.questions.utils.track-event` + +并从 `dependencyMatrix` 中删除引用它们的边。 + +- [ ] **Step 7: 更新 `lastUpdate` 字段** + +将 JSON 顶部的 `lastUpdate` 字段更新为 `2026-07-07`,并追加更新说明: + +```json +"lastUpdate": "2026-07-07", +"updates": [ + "...", + "2026-07-07: 新增日志系统(logger / request-context / with-request-context / use-error-report / api/client-error),proxy.ts 注入 requestId,audit-logger 静默失败修复,track-event 去重合并" +] +``` + +- [ ] **Step 8: 验证 JSON 有效性** + +Run: +```bash +node -e "JSON.parse(require('fs').readFileSync('docs/architecture/005_architecture_data.json', 'utf-8')); console.log('JSON valid')" +``` + +Expected: 输出 `JSON valid`。 + +- [ ] **Step 9: Commit** + +```bash +git add docs/architecture/005_architecture_data.json +git commit -m "docs(architecture): update 005 with logging system modules" +``` + +--- + +### Task 17: 更新 `known-issues.md` + +**Files:** +- Modify: `docs/troubleshooting/known-issues.md` + +- [ ] **Step 1: 在 `known-issues.md` 末尾追加日志系统规则条目** + +```markdown +## 日志系统(pino + AsyncLocalStorage) + +### pino 集成 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| pino 必须加入 serverExternalPackages | `serverExternalPackages: ["mysql2", ..., "pino"]` | 不配置导致 Turbopack 打包失败 | +| pino-pretty 仅开发环境 | `...(env.NODE_ENV === "development" && { transport: { target: "pino-pretty" } })` | 生产环境启用 transport 导致多进程问题 | +| 模块 logger 必须通过 createModuleLogger | `const log = createModuleLogger("audit"); log.info(...)` | `console.log("[Audit]", ...)` | +| 业务代码禁止 console | `log.error({ err: e }, "msg")` | `console.error("msg", e)` | +| logger.ts 是 no-console 唯一豁免 | eslint.config.mjs 中 overrides 块 files: ["src/shared/lib/logger.ts"] | 全项目禁 console 但未豁免 logger.ts | + +### Edge Runtime 限制 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| proxy.ts 不能导入 node:async_hooks | `const requestId = crypto.randomUUID()`(Web Crypto API) | `import { AsyncLocalStorage } from "node:async_hooks"`(Edge 不支持) | +| proxy.ts 不能导入 request-context.ts | 仅通过 NextResponse.next({ request: { headers } }) 注入请求头 | `import { requestContextStorage } from "@/shared/lib/request-context"` | +| Edge Runtime 生成 UUID 用 Web Crypto | `crypto.randomUUID()`(全局 crypto 对象) | `import { randomUUID } from "node:crypto"` | + +### Server Action 包装 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| Server Action 必须用 withRequestContext 包装 | `export const action = withRequestContext(async (state, input) => {...})` | 直接导出 async function(logger 无 requestId) | +| data-access 层无需显式包装 | data-access 函数中直接调用 logger(自动获取 requestId) | data-access 中重复调用 withRequestContext | +| handleActionError 是同步函数 | 在 Server Action 入口点用 withRequestContext 包装 | 在 handleActionError 内部 `await headers()` | + +### error.tsx 错误上报 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| error.tsx 必须调用 useErrorReport | `useErrorReport(error)` | 不调用(错误对开发者不可见) | +| 上报必须节流 | sessionStorage 标记 digest | 无节流导致 React 重渲染时风暴 | +| 上报失败必须静默 | `.catch(() => {})` | `.catch((e) => { throw e })` 导致无限循环 | +| 客户端 error.tsx 不能直接导入 logger | 通过 fetch POST 到 /api/client-error | `import { logger } from "@/shared/lib/logger"`(logger 是服务端模块) | + +### track-event 使用 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| track-event 仅从 shared/lib 导入 | `import { trackEvent } from "@/shared/lib/track-event"` | 从模块内 track-event.ts 导入(已删除) | +| 不在模块内复制 track-event stub | 删除 modules/{rbac,course-plans,questions}/lib/track*.ts | 各模块保留自己的 no-op stub | +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/troubleshooting/known-issues.md +git commit -m "docs(troubleshooting): add logging system rules to known-issues" +``` + +--- + +### Task 18: 最终验证 + +- [ ] **Step 1: 运行全量类型检查** + +Run: +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 2: 运行全量 ESLint** + +Run: +```bash +npm run lint +``` + +Expected: 0 errors.(含 `no-console` 规则) + +- [ ] **Step 3: 运行单元测试** + +Run: +```bash +npm run test:unit +``` + +Expected: 全部通过,新增的 4 个测试文件(request-context / logger / with-request-context / use-error-report)应全部 PASS。 + +- [ ] **Step 4: 运行集成测试** + +Run: +```bash +npm run test:integration +``` + +Expected: 全部通过,含新增的 proxy-guard requestId 注入测试。 + +- [ ] **Step 5: 启动 dev 服务器手动验证** + +Run: +```bash +npm run dev +``` + +打开浏览器访问 http://localhost:3000,登录后: +1. 触发任意页面加载,观察终端日志应包含 JSON 格式(开发环境为 pino-pretty 彩色文本)+ `requestId` 字段 +2. 在浏览器控制台手动触发错误(如访问不存在的路由),观察 `/api/client-error` 是否收到上报(可在终端日志中看到 `module: "client-error"` 的 error 条目) +3. 故意触发 Server Action 失败(如提交无效表单),观察日志包含 `module` + `requestId` + `error stack` + +- [ ] **Step 6: 验证 LOG_LEVEL 环境变量生效** + +停止 dev 服务器,运行: +```bash +LOG_LEVEL=debug npm run dev +``` + +观察日志详细度提升(debug 级别输出)。 + +恢复默认: +```bash +npm run dev +``` + +观察日志回到 info 级别。 + +- [ ] **Step 7: 最终 Commit(如有未提交的修复)** + +```bash +git status +# 若有未提交的修复 +git add <文件> +git commit -m "fix(logging): final adjustments from manual verification" +``` + +- [ ] **Step 8: 通知用户重构完成** + +总结: +- 新增 4 个文件(logger / request-context / with-request-context / use-error-report)+ 4 个测试文件 +- 新增 1 个 Route Handler(/api/client-error) +- 修改 proxy.ts / action-utils.ts / api-response.ts / 3 个 audit-logger / track-event.ts / env.mjs / next.config.ts / eslint.config.mjs +- 替换 88 处 console.* 为 logger.* +- 接入 130 个 error.tsx 的 useErrorReport +- 删除 3 个重复 track-event stub +- 同步 004 / 005 / known-issues.md 架构文档 + +--- + +## 自检 Checklist + +实施过程中及完成后逐项确认: + +- [ ] pino 与 pino-pretty 已安装到 package.json +- [ ] LOG_LEVEL 已添加到 env.mjs server schema 与 runtimeEnv +- [ ] next.config.ts 的 serverExternalPackages 包含 "pino" +- [ ] request-context.ts 实现 + 测试通过 +- [ ] logger.ts 实现 + 测试通过 +- [ ] with-request-context.ts 实现 + 测试通过 +- [ ] proxy.ts 注入 x-request-id(不导入 Node.js 模块) +- [ ] action-utils.ts 的 console.error 全部替换为 logger +- [ ] api-response.ts 的 console.error 全部替换为 logger +- [ ] 三个 audit-logger 的 catch 块改为 logger.warn +- [ ] track-event.ts 改为 createModuleLogger("track") +- [ ] 三个重复 track-event 文件已删除 +- [ ] 88 处 console.* 全部替换(Grep 验证 0 个真实调用点) +- [ ] ESLint no-console 规则已启用 +- [ ] use-error-report Hook 实现 + 测试通过 +- [ ] /api/client-error Route Handler 创建 +- [ ] 130 个 error.tsx 全部接入 useErrorReport +- [ ] 004_architecture_impact_map.md 已同步 +- [ ] 005_architecture_data.json 已同步且 JSON 有效 +- [ ] known-issues.md 已追加日志系统规则 +- [ ] npm run typecheck 0 errors +- [ ] npm run lint 0 errors +- [ ] npm run test:unit 全部通过 +- [ ] npm run test:integration 全部通过 +- [ ] 手动验证日志输出包含 requestId diff --git a/docs/superpowers/specs/2026-07-07-documentation-system-redesign-design.md b/docs/superpowers/specs/2026-07-07-documentation-system-redesign-design.md new file mode 100644 index 0000000..1b8fbcd --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-documentation-system-redesign-design.md @@ -0,0 +1,771 @@ +# 文档体系重设计 + +> 日期:2026-07-07 +> 状态:待用户审阅 +> 范围:全局规范文档(`project_rules.md` / `coding-standards.md` / `004` / `005` / `known-issues.md`)+ 新增架构元数据库 + 模块工作手册 +> 唯一源原则:004 为架构设计意图唯一源;arch.db 为代码结构唯一源;known-issues.md 为经验唯一源 + +--- + +## 一、设计目标 + +### 1.1 核心诉求 + +1. **审核并修正现有规范文档**的合规性、遗漏、技术错误 +2. **重新设计文档体系结构**,消除职责重叠,让每份文档只回答一类问题 +3. **建立架构元数据库**(arch.db),让 AI 快速查询模块/函数/调用链/依赖关系,无需扫描整个代码库 +4. **建立 AI 自我演进机制**,让 AI 越工作越了解项目,后来者可接力而非重新梳理 + +### 1.2 设计原则 + +| 原则 | 含义 | +|------|------| +| 单一来源 | 每类信息只有一个权威文档,其他文档引用而非重复 | +| 职责分离 | 每份文档只回答一类问题(What / Why / How / When) | +| 贴近代码 | 模块工作手册放在 `src/modules/[module]/README.md`,与代码同生命周期 | +| 自动优于手动 | 代码结构由扫描器自动生成(arch.db),减少人工维护 | +| 信任但验证 | AI 使用经验前必须审核,文档变更后重新审核 | + +### 1.3 不做事项(YAGNI) + +- 不建 Monorepo(项目明确为单应用 + 模块化) +- 不引入 Storybook(当前无需求) +- 不建独立文档站点(GitHub/Gitea 直接渲染 Markdown 足够) +- 不做实时热更新架构库(CI + AI 工作前手动触发足够) + +--- + +## 二、文档体系拓扑 + +### 2.1 整体结构 + +``` +项目根/ +├─ .trae/rules/project_rules.md # 全局硬性规则(强制约束) +├─ docs/ +│ ├─ standards/coding-standards.md # 编码规范(How to write code) +│ ├─ architecture/ +│ │ ├─ 004_architecture_impact_map.md # 架构设计意图(Why - 瘦身后约 500 行) +│ │ ├─ 006_k12_feature_checklist.md # 功能模块清单(保留) +│ │ ├─ 007_gap_audit_report.md # 差距审计(保留) +│ │ ├─ 008_module_role_mapping.md # 模块角色映射(保留) +│ │ ├─ roadmap/ # 【新增】长远规划 +│ │ │ ├─ README.md # 路线图索引 +│ │ │ ├─ tech-debt.md # 技术债清单(从 004 第三部分迁入) +│ │ │ ├─ decoupling.md # 解耦路线图(从 audit/01 迁入) +│ │ │ └─ pending-features.md # 待开发功能(从 004 "未完成项"迁入) +│ │ └─ audit/archive/ # 【新增】历史审查报告归档(只读) +│ │ ├─ 005_architecture_data.json # 005 废弃后归档于此 +│ │ └─ (现有 60+ 份 audit 报告迁入) +│ └─ troubleshooting/ +│ └─ known-issues.md # 经验库(精简为索引式,无代码示例) +├─ src/modules/[module]/ +│ └─ README.md # 【新增】模块工作手册(每模块一份) +└─ scripts/arch-scan/ # 【新增】架构扫描器 + ├─ scanner.ts # ts-morph 扫描器 + ├─ schema.ts # SQLite schema 定义 + ├─ query.ts # 查询函数 + ├─ cli.ts # CLI 入口 + └─ arch.db # 生成的 SQLite(git 提交) +``` + +### 2.2 文档职责边界 + +| 文档 | 类型 | 回答的问题 | 谁维护 | 内容禁区 | +|------|------|-----------|--------|---------| +| `arch.db` | 自动生成 | What(代码结构是什么) | 扫描器 | 无人为语义 | +| `004` 架构图 | 人类可读 | Why(架构为什么这样设计) | 人/AI | 不含代码结构、不含规划、不含教程 | +| `project_rules.md` | 硬性规则 | Must(必须遵守什么) | 人 | 不含详细规范、不含架构描述 | +| `coding-standards.md` | 编码规范 | How to write(怎么写代码) | 人 | 不含架构事实、不含经验 | +| `known-issues.md` | 经验库 | Experience(场景→技术、工作经验) | AI 思考后更新 | 不含代码示例、不含错误示范 | +| `modules/[m]/README.md` | 模块上下文 | How to work(怎么上手模块) | 人/AI | 不重复 arch.db 的代码结构、不含经验 | +| `roadmap/*` | 规划 | When(未来做什么) | 人/AI | 不含架构事实、不含经验 | +| `audit/archive/*` | 历史参考 | Past(过去发现了什么) | 只读归档 | 不再更新 | + +### 2.3 三类信息源的互补关系 + +``` +arch.db (What) ── AI 查询代码结构、调用关系、依赖 + ↓ 基于代码生成 +004 架构图 (Why) ── 人理解架构设计意图 + ↓ 解释决策 +模块手册 (How to work) ── 人/AI 上手模块的工作流程 + ↓ 记录经验 +known-issues (Experience) ── 遇到问题时查技术方向 +``` + +### 2.4 废弃文档 + +| 文档 | 处理 | 理由 | +|------|------|------| +| `005_architecture_data.json` | 废弃,归档到 `audit/archive/` | arch.db 自动生成比手维护 JSON 准确 | +| `docs/architecture/audit/01_decoupling_roadmap.md` | 迁移到 `roadmap/decoupling.md` | 属于规划而非审查报告 | +| `docs/architecture/audit/` 下 60+ 份审查报告 | 迁移到 `audit/archive/`,只读归档 | 历史参考,不再更新 | + +--- + +## 三、arch.db 架构元数据库 + +### 3.1 设计目标 + +让 AI 无需扫描整个代码库即可精准查询: +- 某模块的所有导出函数 +- 某函数被谁调用(逆向追踪,递归) +- 某函数调用了什么(正向追踪,递归) +- 某模块依赖哪些模块(递归) +- 哪些地方用了某技术(如 cacheFn) +- 架构违规检测(超长文件、Server Action 缺权限校验等) +- 完整调用链(从 UI 到 DB) + +### 3.2 技术选型 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 存储 | SQLite | 嵌入式、零配置、.db 文件可 git 提交、AI 可直接 sqlite3 查询 | +| 扫描器 | ts-morph | TypeScript AST 操作库,支持类型推导,能提取调用关系 | +| 扫描粒度 | 全调用图 | 模块 + 函数 + 类型 + 函数间调用关系,覆盖 UE 引用查看器所有用法 | +| 触发时机 | CI 自动 + 本地手动 + AI 工作前强制 | 三重保障架构库与代码同步 | +| 数据源 | 自动扫描代码 | 零人工维护,与代码同步 | + +### 3.3 SQLite Schema + +```sql +-- ============ 核心实体 ============ + +-- 1. 模块(src/modules/* 下的目录) +CREATE TABLE modules ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + description TEXT, + layer TEXT NOT NULL, -- "modules" | "shared" | "app" | "root" + created_at TEXT NOT NULL +); + +-- 2. 文件 +CREATE TABLE files ( + id INTEGER PRIMARY KEY, + module_id INTEGER REFERENCES modules(id), + path TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, -- "actions" | "data-access" | "schema" | "types" | "component" | "hook" | "lib" | "config" | "route" | "page" | "layout" + lines INTEGER, + is_server INTEGER DEFAULT 0, + is_client INTEGER DEFAULT 0, + has_server_only INTEGER DEFAULT 0 +); + +-- 3. 导出符号(函数、类、类型、常量、组件) +CREATE TABLE symbols ( + id INTEGER PRIMARY KEY, + file_id INTEGER NOT NULL REFERENCES files(id), + name TEXT NOT NULL, + kind TEXT NOT NULL, -- "function" | "class" | "type" | "interface" | "const" | "component" + is_exported INTEGER DEFAULT 0, + is_async INTEGER DEFAULT 0, + is_server_action INTEGER DEFAULT 0, + signature TEXT, + start_line INTEGER, + end_line INTEGER, + UNIQUE(file_id, name, start_line) +); + +-- ============ 关系 ============ + +-- 4. 调用关系(符号间调用) +CREATE TABLE calls ( + id INTEGER PRIMARY KEY, + caller_id INTEGER NOT NULL REFERENCES symbols(id), + callee_id INTEGER REFERENCES symbols(id), + callee_external TEXT, -- 项目外调用(如 "fetch", "console.log") + call_line INTEGER, + count INTEGER DEFAULT 1 +); + +-- 5. 文件级导入 +CREATE TABLE file_imports ( + id INTEGER PRIMARY KEY, + source_file_id INTEGER NOT NULL REFERENCES files(id), + imported_file_id INTEGER REFERENCES files(id), + import_path TEXT NOT NULL, + is_type_only INTEGER DEFAULT 0, + imported_names TEXT +); + +-- 6. 模块间依赖(聚合视图) +CREATE TABLE module_deps ( + id INTEGER PRIMARY KEY, + source_module_id INTEGER NOT NULL REFERENCES modules(id), + target_module_id INTEGER NOT NULL REFERENCES modules(id), + dep_type TEXT NOT NULL, -- "import" | "data-access-call" | "action-call" + UNIQUE(source_module_id, target_module_id, dep_type) +); + +-- ============ 业务元数据 ============ + +-- 7. 技术标签 +CREATE TABLE tech_tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + category TEXT +); + +-- 8. 符号-技术标签关联 +CREATE TABLE symbol_tech_tags ( + symbol_id INTEGER NOT NULL REFERENCES symbols(id), + tag_id INTEGER NOT NULL REFERENCES tech_tags(id), + PRIMARY KEY(symbol_id, tag_id) +); + +-- 9. 权限点 +CREATE TABLE permissions ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + description TEXT +); + +-- 10. 路由 +CREATE TABLE routes ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, -- "page" | "api" | "layout" + file_id INTEGER REFERENCES files(id), + min_permission TEXT +); + +-- 11. 数据库表 +CREATE TABLE db_tables ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + module_id INTEGER REFERENCES modules(id), + description TEXT +); + +-- ============ 扫描元数据 ============ + +-- 12. 扫描元数据 +CREATE TABLE scan_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +``` + +### 3.4 关键索引 + +```sql +CREATE INDEX idx_symbols_file ON symbols(file_id); +CREATE INDEX idx_symbols_name ON symbols(name); +CREATE INDEX idx_calls_caller ON calls(caller_id); +CREATE INDEX idx_calls_callee ON calls(callee_id); +CREATE INDEX idx_file_imports_source ON file_imports(source_file_id); +CREATE INDEX idx_file_imports_target ON file_imports(imported_file_id); +CREATE INDEX idx_symbol_tech_tags_tag ON symbol_tech_tags(tag_id); +``` + +### 3.5 技术标签自动识别规则 + +扫描器通过启发式规则自动打标签: + +| 标签 | 识别规则 | 类别 | +|------|---------|------| +| `cacheFn` | 文件内出现 `cacheFn(` 调用 | cache | +| `zustand` | import 自 `zustand` | state | +| `useOptimistic` | 文件内出现 `useOptimistic` 调用 | state | +| `react-hook-form` | import 自 `react-hook-form` | form | +| `TanStack Query` | import 自 `@tanstack/react-query` | state | +| `Server Action` | 文件顶部 `"use server"` 或函数级 `"use server"` | server | +| `Tiptap` | import 自 `@tiptap/*` | ui | +| `Drizzle` | import 自 `drizzle-orm` | db | +| `nuqs` | import 自 `nuqs` | state | +| `recharts` | import 自 `recharts` | ui | + +### 3.6 递归查询能力(6 类) + +#### 查询 1:逆向递归(谁调用了 X?递归到入口) + +```sql +WITH RECURSIVE upstream(caller_id, caller_name, caller_path, depth, path_chain) AS ( + SELECT c.caller_id, s.name, f.path, 0, s.name + FROM calls c + JOIN symbols s ON c.caller_id = s.id + JOIN files f ON s.file_id = f.id + WHERE c.callee_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1) + UNION + SELECT c.caller_id, s.name, f.path, u.depth + 1, u.path_chain || ' → ' || s.name + FROM upstream u + JOIN calls c ON c.callee_id = u.caller_id + JOIN symbols s ON c.caller_id = s.id + JOIN files f ON s.file_id = f.id + WHERE u.depth < 10 + AND u.caller_id NOT IN (SELECT caller_id FROM upstream) +) +SELECT caller_name, caller_path, depth, path_chain FROM upstream +ORDER BY depth, caller_path; +``` + +#### 查询 2:正向递归(X 调用了什么?递归到叶子) + +```sql +WITH RECURSIVE downstream(callee_id, callee_name, callee_path, depth, path_chain) AS ( + SELECT c.callee_id, s.name, f.path, 0, ? + FROM calls c + JOIN symbols s ON c.callee_id = s.id + JOIN files f ON s.file_id = f.id + WHERE c.caller_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1) + UNION + SELECT c.callee_id, s.name, f.path, d.depth + 1, d.path_chain || ' → ' || s.name + FROM downstream d + JOIN calls c ON c.caller_id = d.callee_id + JOIN symbols s ON c.callee_id = s.id + JOIN files f ON s.file_id = f.id + WHERE d.depth < 10 + AND d.callee_id NOT IN (SELECT callee_id FROM downstream) +) +SELECT callee_name, callee_path, depth, path_chain FROM downstream +ORDER BY depth, callee_path; +``` + +#### 查询 3:模块级逆向(谁依赖了模块 X?递归到根) + +```sql +WITH RECURSIVE mod_upstream(source_module, target_module, depth, path_chain) AS ( + SELECT m.name, m2.name, 0, m.name || ' ← ' || m2.name + FROM module_deps md + JOIN modules m ON md.target_module_id = m.id + JOIN modules m2 ON md.source_module_id = m2.id + WHERE m.name = ? + UNION + SELECT mu.source_module, m.name, mu.depth + 1, mu.path_chain || ' ← ' || m.name + FROM mod_upstream mu + JOIN module_deps md ON md.target_module_id = ( + SELECT id FROM modules WHERE name = mu.source_module + ) + JOIN modules m ON md.source_module_id = m.id + WHERE mu.depth < 10 +) +SELECT DISTINCT source_module, target_module, depth, path_chain +FROM mod_upstream ORDER BY depth; +``` + +#### 查询 4:模块级正向(模块 X 依赖了哪些模块?递归到叶子) + +类似查询 3,方向反转(从 source_module_id 出发递归 target_module_id)。实施时补全完整 SQL。 + +#### 查询 5:双向依赖检测(模块 A 和 B 之间是否有循环依赖) + +同时执行查询 3(A→B)和查询 4(B→A),若两方向都存在路径,则存在循环依赖。实施时封装为单一查询函数。 + +#### 查询 6:完整调用链(UI → Server Action → data-access → DB) + +```sql +WITH RECURSIVE trace(symbol_id, symbol_name, file_path, kind, depth, path_chain) AS ( + -- 入口:UI 事件处理函数(handle*, onSubmit*, onClick*) + SELECT s.id, s.name, f.path, f.kind, 0, s.name + FROM symbols s JOIN files f ON s.file_id = f.id + WHERE s.name LIKE 'handle%' OR s.name LIKE 'onSubmit%' OR s.name LIKE 'onClick%' + UNION + SELECT cs.id, cs.name, cf.path, cf.kind, t.depth + 1, t.path_chain || ' → ' || cs.name + FROM trace t + JOIN calls c ON c.caller_id = t.symbol_id + JOIN symbols cs ON c.callee_id = cs.id + JOIN files cf ON cs.file_id = cf.id + WHERE t.depth < 15 + AND t.symbol_id NOT IN (SELECT symbol_id FROM trace) +) +SELECT * FROM trace +WHERE kind IN ('actions', 'data-access') +ORDER BY depth, path_chain; +``` + +### 3.7 CLI 接口 + +```bash +# 1. 更新架构库 +npm run arch:scan + +# 2. 查询命令 +npm run arch:query ref [--forward] [--depth=10] # 符号引用(默认逆向) +npm run arch:query module [--reverse] [--depth=10] # 模块依赖 +npm run arch:query tech # 技术使用 +npm run arch:query path # 模块间路径 +npm run arch:query violations # 架构违规 +npm run arch:query trace # 完整调用链 +npm run arch:query sql "" # 自由 SQL +npm run arch:query repl # 交互式 +``` + +### 3.8 输出格式 + +默认输出**树形可视化**(模仿 UE 引用查看器),可选 `--json` 输出机器可读格式: + +``` +$ npm run arch:query ref createExam + +▼ createExam (src/modules/exams/data-access.ts#L42) + │ + ├─▼ createExamAction (src/modules/exams/actions.ts#L18) [Server Action] + │ │ + │ └─▼ handleCreateExam (src/modules/exams/components/exam-form.tsx#L67) [Client] + │ │ + │ └──
(src/modules/exams/components/exam-form.tsx#L120) + │ + └─▼ importExams (src/modules/exams/import-export.ts#L234) +``` + +--- + +## 四、AI 自我演进机制 + +### 4.1 模块 README.md 标准结构 + +```markdown +# [模块名] 模块工作手册 + +> 经验查 known-issues.md,代码结构查 arch.db,本文件只记工作流程。 + +## 模块职责 +一句话描述本模块做什么。 + +## 核心工作流程 +1. 新增考试: ... +2. 修改成绩计算: ... + +## 关键约束 +- [不可违反的约束] +- [依赖关系,从 arch.db 提取] + +## 架构决策(为什么这样设计) +- **为什么用 X 而不用 Y**: [决策理由] +``` + +### 4.2 known-issues.md 结构(唯一经验库) + +```markdown +# 项目经验库 + +> AI 工作前必读,使用经验前必审核。AI 发现更好办法时更新本文件。 +> 最后审核: 2026-07-07 14:00 (commit: abc1234) + +## 全局经验 + +### 缓存策略 +| 场景 | 技术方向 | 模块 | 备注 | +|------|---------|------|------| +| 服务端数据缓存 | cacheFn + Redis | 全局 | 详见 arch.db tech_tags | +| 客户端数据缓存 | TanStack Query | 全局 | 禁止 useEffect+fetch | + +### 状态管理 +| 场景 | 技术方向 | 模块 | 备注 | +|------|---------|------|------| +| URL 状态 | nuqs | 全局 | 5 层状态模型 L1 | +| 表单状态 | react-hook-form + zodResolver | 全局 | L5 层 | + +## 模块经验: exams + +### 考试创建流程 +| 场景 | 技术方向 | 备注 | +|------|---------|------| +| 考试数据缓存 | cacheFn 包裹 createExamRaw | 修改后须失效 importExams 缓存 | +| AI 题目解析 | 动态 import + webpackIgnore | 可选依赖 ollama | + +## 模块经验: grades +... + +## 工作经验日志(按时间倒序,定期提炼到上述分区) + +### 2026-07-07 重构 createExam 调用链 +- **模块**: exams +- **做了什么**: 拆分 createExam 为 createExamRaw + createExam(含缓存) +- **学到什么**: data-access 层已有 cacheFn,actions 层无需再缓存 +- **下次注意**: 修改 createExam 必须同步更新 importExams 的缓存失效 +- **审核状态**: 待审核 +``` + +### 4.3 known-issues.md 精简规则 + +| 内容类型 | 处理 | +|---------|------| +| 代码示例(多行代码块) | 删除,改为"技术方向"描述 | +| 错误示范 | 删除,只保留"正确做法" | +| 重复的架构规则 | 删除,引用 004/project_rules | +| "场景 → 技术"映射 | 保留,按模块分区 | +| 工作经验日志 | 保留(追加区) | + +### 4.4 AI 工作强制流程(写入 project_rules.md) + +``` +AI 进入项目工作流程(强制,违反即违规): + +阶段 1: 上下文加载 + 1.1 npm run arch:scan # 更新 arch.db + 1.2 npm run arch:query module <目标模块> # 查模块依赖 + 1.3 npm run arch:query ref <目标函数> --forward # 查调用链 + 1.4 阅读 src/modules/[模块]/README.md # 读模块工作流程 + 1.5 查 known-issues.md "模块经验: <模块>" 分区 # 读相关经验 + 1.5.1 审核相关经验(检查代码是否仍匹配) + 1.5.2 若文档自上次审核后已变更 → 重新审核并标记 + 1.5.3 审核通过 → 使用;失败 → 标记失效,不使用 + +阶段 2: 执行工作 + 2.1 按规划执行 + 2.2 修改代码后立即运行 arch:scan + +阶段 3: 经验沉淀(强制,不可跳过) + 3.1 在 known-issues.md "工作经验日志" 区追加一条记录: + - 做了什么 + - 学到什么 + - 下次注意事项 + - 审核状态: 待审核 + 3.2 若发现新的"场景→技术"映射 → 提炼到对应模块分区 + 3.3 若发现新的架构决策 → 更新 004 + 3.4 若代码结构变化 → arch:scan 确认 arch.db 已更新 + +阶段 4: 提交后审核(人工) + 4.1 人工审查"待审核"日志条目 + 4.2 通过 → 标记"已审核 (commit, 审核人)" + 4.3 失败 → 标记"审核失败,原因:..." + 4.4 定期(如每两周)将成熟日志提炼到分区表格 +``` + +### 4.5 信任但验证机制 + +``` +AI 读取 known-issues.md 经验 + ↓ +检查该条经验的"审核状态": + ├─ 已审核 → 检查代码是否仍匹配 + │ ├─ 匹配 → 使用经验 + │ └─ 不匹配 → 标记"待重新审核",不使用 + ├─ 待审核 → 标记"AI 使用前审核",验证后使用 + └─ 审核失败 → 不使用,记录原因 + ↓ +使用经验工作时,若发现经验有误 → 标记"审核失败,原因:..." +``` + +### 4.6 防 known-issues.md 膨胀 + +- **工作经验日志区上限 50 条**——超过则人工提炼最早的到分区表格,删除原日志 +- **分区表格无上限**——但每条保持单行索引式 +- **精简目标**:从 1317 行降至约 300 行 + +--- + +## 五、004 瘦身方案 + +### 5.1 现状问题 + +004 当前 4227 行,远超架构文档应有体量。主要问题: + +| 问题 | 表现 | 行数估算 | +|------|------|---------| +| 混入工作日志 | "1.1.1 M7 移动端 PWA 支持(2026-07-01 新增)"等 7 个变更日志章节 | ~400 行 | +| 混入规划/待办 | "未完成项(待后续专项)"、各模块的 P0/P1/P2 修复标记 | ~600 行 | +| 混入实现细节 | 函数签名索引、文件行数表格、组件清单 | ~1500 行 | +| 模块清单冗长 | 27 个模块每个都用大段文字描述,含"V4 P2-4 已修复"等历史 | ~1500 行 | +| 真正的架构内容 | 分层图、依赖关系图、数据流向图、核心原则 | ~227 行 | + +### 5.2 瘦身后目标结构(约 500 行) + +```markdown +# Next_Edu 架构影响地图 + +> 唯一源:项目架构事实。代码结构查 arch.db,经验查 known-issues.md,规划查 roadmap/。 + +## 1. 分层架构 +- 三层架构图(app → modules → shared) +- 分层规则(4 条核心约束) +- 根模块说明(auth.ts, proxy.ts) + +## 2. 模块清单 +(表格形式,每模块一行,详情查 arch.db 和模块 README) + +| 模块 | 职责 | 核心依赖 | 被依赖 | README | +|------|------|---------|--------|--------| +| exams | 考试管理 | grades, classes, questions | dashboard | [README](../../src/modules/exams/README.md) | +| ... | ... | ... | ... | ... | + +## 3. 模块依赖关系图 +- 核心业务模块依赖图 +- 扩展模块依赖图 +- (循环依赖检测见 arch.db 查询) + +## 4. 数据流向(核心场景) +- 考试流程数据流 +- 学生提交作业数据流 +- 仪表盘聚合数据流 + +## 5. 核心架构原则 +- 三层架构单向依赖 +- 模块间通过 data-access 通信 +- Server Action 必须权限校验 +- 设计令牌分层(Primitive → Semantic → Tailwind) + +## 6. 设计令牌体系 +- 文件分布(src/app/styles/tokens/) +- 令牌分层规则 +- 强制约束(禁止硬编码颜色/字体/字号) + +## 相关文档 +- [arch.db 查询](../../scripts/arch-scan/) - 代码结构 +- [known-issues.md](../troubleshooting/known-issues.md) - 经验库 +- [roadmap/](./roadmap/) - 规划 +- [模块 README](../../src/modules/) - 模块工作流程 +``` + +### 5.3 迁移映射 + +| 004 现有内容 | 去向 | 理由 | +|------------|------|------| +| 1.1.1-1.1.7 变更日志章节 | 删除(git 历史已记录) | 工作日志不属于架构事实 | +| "Phase X.X 新增"标记 | 删除 | 同上 | +| "P0-X 已修复"标记 | 删除(保留事实,删除修复历史) | 修复历史属于 git log | +| "未完成项(待后续专项)" | 迁移到 `roadmap/tech-debt.md` | 属于规划 | +| 各模块的"V1/V2/V3/V4"版本描述 | 删除,只保留当前状态 | 版本演进属于 git log | +| 函数签名索引(附录 C) | 删除(查 arch.db) | 代码结构属于 arch.db | +| 模块间依赖矩阵(附录 A) | 删除(查 arch.db `module_deps`) | 同上 | +| 关键参数影响链(附录 B) | 保留(架构决策) | 属于架构意图 | +| 第三部分"已知架构问题和技术债" | 迁移到 `roadmap/tech-debt.md` | 属于规划 | +| 模块清单(第二部分) | 大幅精简为表格 | 详情查 arch.db + README | + +--- + +## 六、规范文档修正要点 + +### 6.1 已发现的跨文档冲突 + +| 冲突项 | project_rules.md | coding-standards.md | 004 | 修正方向 | +|--------|-----------------|---------------------|-----|---------| +| 设计令牌位置 | `src/app/styles/tokens/` | `globals.css` | `src/app/styles/tokens/` | 统一为 `src/app/styles/tokens/`(以 004 为准) | +| 架构文档清单 | 仅列 004-008 + audit/01 | 无 | 自身 | project_rules.md 补全 001/002/003/008 | +| 002 编号冲突 | 无 | 无 | 无 | `002_rbac_refactoring.md` 与 `002_role_based_routing.md` 编号冲突,需重命名 | +| 缓存策略描述 | 无 | "用 unstable_cache" | 已迁移到 cacheFn | coding-standards.md 更新为 cacheFn | + +### 6.2 project_rules.md 修正 + +1. **架构文档清单补全**:加入 001/002/003/008 +2. **新增 AI 工作流程规则**:写入第四节"AI 工作强制流程" +3. **新增 arch.db 规则**:AI 工作前必须 `npm run arch:scan` +4. **令牌位置统一**:与 004 一致,明确为 `src/app/styles/tokens/` + +### 6.3 coding-standards.md 修正 + +1. **令牌位置统一**:从 `globals.css` 改为 `src/app/styles/tokens/` +2. **缓存策略更新**:从 `unstable_cache` 改为 `cacheFn` +3. **删除过时内容**:tsconfig "当前差异"部分(已升级则删除,未升级则列入 roadmap) +4. **状态管理章节更新**:加入 5 层状态模型(L1 URL / L2 Server / L3 Client Business / L4 Global UI / L5 Form) +5. **ESLint 配置章节更新**:反映已实现的 `no-restricted-syntax`、`design-tokens/no-hardcoded-fonts` 等规则 + +--- + +## 七、实施阶段划分 + +### 7.1 三阶段流水线 + +| 阶段 | 目标 | 产出 | 验收标准 | +|------|------|------|---------| +| 阶段 1: 同步实际 | 把 4 份文档对齐到代码现状 | 实际状态基线报告 | 4 份文档与代码零冲突 | +| 阶段 2: 审核正确性 | 基于阶段 1 基线,按 4 维度审核文档 | 问题清单 + 修正建议 | 文档内部一致、无技术错误、无遗漏、对齐大仓最佳实践 | +| 阶段 3: 反向修正代码 | 基于阶段 2 审核后文档,修正代码偏差 | 代码修正 PR | 代码与文档零冲突 | + +### 7.2 阶段 1 任务分解 + +1. 扫描代码实际状态(令牌位置、模块结构、tsconfig、Husky/lint-staged 是否配置等) +2. 对照 4 份文档找出不一致项 +3. 修正文档使其与代码一致 +4. 产出"实际状态基线报告" + +### 7.3 阶段 2 任务分解 + +按 4 维度审核: +1. **合规性**:文档内部一致性、跨文档一致性 +2. **遗漏**:对照大仓最佳实践找缺失规则(包边界、依赖方向、共享工具下沉) +3. **技术错误**:tsconfig 目标版本、令牌位置、ESLint 规则等具体错误 +4. **大仓规范对照**:提取适用于单应用模块化的部分 + +### 7.4 阶段 3 任务分解 + +1. 识别代码与审核后文档的偏差 +2. 修正代码(如 tsconfig 升级、令牌位置迁移等) +3. 验证 `npm run lint` 和 `npx tsc --noEmit` 零错误 + +### 7.5 独立项目:arch.db 扫描器 + +arch.db 扫描器作为独立项目,可与三阶段并行推进: + +1. 实现 ts-morph 扫描器(scanner.ts) +2. 实现 SQLite schema(schema.ts) +3. 实现查询函数(query.ts) +4. 实现 CLI(cli.ts) +5. 添加 `npm run arch:scan` 和 `npm run arch:query` 脚本 +6. 配置 CI 自动运行 +7. 写入 project_rules.md 作为 AI 工作前置规则 + +### 7.6 独立项目:模块 README 创建 + +约 27 个模块各创建一份 README.md(实施时以 arch.db 扫描结果为准),可分批推进: + +1. 标杆模块先做(textbooks、grades 已在 004 标记为"标杆模块") +2. 核心业务模块(exams、homework、questions) +3. 教学管理模块(classes、school、scheduling、attendance) +4. 用户沟通模块(users、messaging、notifications、parent) +5. 扩展功能模块(elective、proctoring、diagnostic、dashboard) +6. 其他模块(announcements、files、settings、auth、layout、student、lesson-preparation、standards、course-plans、audit、rbac、onboarding) + +--- + +## 八、验收标准 + +### 8.1 文档体系验收 + +- [ ] 4 份全局规范文档(project_rules.md / coding-standards.md / 004 / known-issues.md)内部无矛盾 +- [ ] 4 份文档与代码零冲突 +- [ ] 004 瘦身至约 500 行,不含规划/工作日志/代码结构 +- [ ] 005 归档到 audit/archive/ +- [ ] known-issues.md 精简至约 300 行,无代码示例 +- [ ] roadmap/ 目录建立,包含 tech-debt.md / decoupling.md / pending-features.md +- [ ] audit/archive/ 目录建立,60+ 份审查报告归档 + +### 8.2 arch.db 验收 + +- [ ] `npm run arch:scan` 能成功扫描全项目并生成 arch.db +- [ ] `npm run arch:query ref ` 能递归查询符号引用 +- [ ] `npm run arch:query module ` 能递归查询模块依赖 +- [ ] `npm run arch:query tech ` 能查询技术使用 +- [ ] `npm run arch:query violations` 能检测架构违规 +- [ ] arch.db 与代码零偏差(扫描器在干净代码上运行无错误) + +### 8.3 模块 README 验收 + +- [ ] 27 个模块各有 README.md +- [ ] 每个 README 含:模块职责、核心工作流程、关键约束、架构决策 +- [ ] 每个 README 不含经验(查 known-issues.md)、不含代码结构(查 arch.db) + +### 8.4 AI 工作流程验收 + +- [ ] project_rules.md 写入 AI 工作强制流程 +- [ ] AI 工作前运行 `npm run arch:scan` 成为硬性规则 +- [ ] known-issues.md 含审核状态字段 +- [ ] 模块 README 含审核标记 + +--- + +## 九、风险与缓解 + +| 风险 | 影响 | 缓解 | +|------|------|------| +| ts-morph 扫描大型项目慢 | CI 时间增加 | 扫描器增量扫描(仅变更文件),全量扫描仅 CI 触发 | +| arch.db 二进制文件 git diff 不友好 | Code review 难 | 配合导出 SQL 文本文件,diff 看 SQL,应用看 .db | +| 模块 README 维护成本 | AI/人遗忘更新 | project_rules.md 强制 AI 工作后更新;CI 检查 README 格式 | +| known-issues.md 日志区膨胀 | 文件过大 | 50 条上限,定期提炼到分区表格 | +| 60+ 份 audit 报告归档后信息丢失 | 历史经验丢失 | 归档前提取有价值内容到模块 README 和 known-issues.md | +| AI 不遵守工作流程 | 文档体系失效 | project_rules.md 写为硬性规则,CI 检查 arch.db 是否更新 | + +--- + +## 十、未决事项 + +本 spec 已涵盖所有用户确认的决策。以下事项在实施阶段可能需要进一步决策: + +1. **ts-morph 扫描器性能**:若全量扫描超过 30 秒,需考虑增量扫描策略 +2. **arch.db 大小**:若超过 10MB,需考虑是否排除部分表(如 calls 表可能很大) +3. **模块 README 模板**:实施时可能需要根据实际模块调整模板 +4. **技术标签体系扩展**:初始 10 个标签可能不够,实施时根据需要扩展 diff --git a/docs/superpowers/specs/2026-07-07-logging-refactor-design.md b/docs/superpowers/specs/2026-07-07-logging-refactor-design.md new file mode 100644 index 0000000..0c1fcdb --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-logging-refactor-design.md @@ -0,0 +1,721 @@ +# 日志系统重构设计文档 + +| 字段 | 值 | +|------|---| +| 文档版本 | v1 | +| 创建日期 | 2026-07-07 | +| 作者 | Trae 协作生成 | +| 状态 | 待用户审查 | +| 范围 | pino logger 抽象 + Request ID 贯穿 + 静默失败修复 + error.tsx 上报 + track-event 去重 | +| 策略 | 应用层 SDK 改造,不部署外部可观测性服务(OTel / Sentry / Prometheus 等) | + +--- + +## 1. 背景与现状 + +### 1.1 现状调研摘要 + +基于 2026-07-07 全项目调研: + +| 维度 | 状态 | 说明 | +|------|------|------| +| 业务审计日志 | ✅ 已成熟 | audit / login / change logger + 3 张 DB 表 + audit 模块管理后台 | +| 应用层结构化日志 | ❌ 未实现 | 88 处散乱 `console.*`,无级别、无 JSON、无 request ID | +| Request Correlation | ❌ 未实现 | proxy.ts 中间件零日志、不注入请求 ID | +| 错误边界上报 | ❌ 全部丢弃 | 130 个 `error.tsx` 接收 `error` prop 后直接丢弃,前端运行时错误对开发者不可见 | +| 静默失败 | ⚠️ 严重 | 三个 audit-logger 的 catch 块完全吞没错误,运维无法感知审计写入失败 | +| 统一错误处理 | ⚠️ 部分 | `handleActionError` / `handleApiError` 仅 `console.error`,前缀混乱(`[ExamAction]` / `[ActionError]` / `[ApiError]` / 无前缀混用) | +| track-event | ⚠️ 重复 | 4 个 no-op stub(shared / rbac / course-plans / questions 各一份) | +| 架构图覆盖 | ❌ 未覆盖 | 004 / 005 完全未记录可观测性基础设施 | + +### 1.2 console.* 分布 + +| 类型 | 次数 | 文件数 | 主要位置 | +|------|------|--------|----------| +| `console.log` | 2 | 1 | `questions/utils/parse-content.ts` | +| `console.error` | 67 | 30 | data-access 的 catch 块、`action-utils.ts`、`api-response.ts`、各 Server Action | +| `console.warn` | 5 | 5 | `api/web-vitals/route.ts`、redis/store 等 | +| `console.info` | 10 | 6 | `track-event.ts`、`api/web-vitals/route.ts`、notifications channels | +| `console.debug` | 4 | 3 | 各模块 `track-event.ts`(仅 dev) | + +### 1.3 关键约束 + +- **proxy.ts 在 Edge Runtime 运行**([proxy.ts:64](file:///e:/Desktop/CICD/src/proxy.ts#L64) 注释明确说明),`AsyncLocalStorage` 不可用(依赖 `node:async_hooks`,Edge 不支持) +- 其余部分(RSC、Server Action、Route Handler、data-access)运行在 Node.js runtime,`AsyncLocalStorage` 完全支持 +- 项目自部署(`next.config.ts` 中 `output: "standalone"`),运维通过 `docker logs` 查看日志 + +--- + +## 2. 目标与非目标 + +### 2.1 目标 + +1. **统一 logger 抽象**:pino 替换 88 处散乱 `console.*`,提供 `info/error/warn/debug` + `createModuleLogger(module)` 子 logger +2. **级别控制**:`LOG_LEVEL` 环境变量控制输出阈值(debug/info/warn/error),Zod 校验 +3. **结构化输出**:生产环境 JSON(含 timestamp/level/module/requestId/msg),开发环境 `pino-pretty` 彩色文本 +4. **Request ID 贯穿**:proxy.ts 生成 ID 并注入请求头,Node.js runtime 通过 `AsyncLocalStorage` 贯穿到 data-access / audit-logger +5. **静默失败告警**:三个 audit-logger 的 catch 块从 silent 改为 `logger.warn` +6. **统一错误处理接入**:`handleActionError` / `handleApiError` / `safeActionCall` 接入 logger,前缀通过 `module` 字段规范化 +7. **track-event 去重合并**:4 个 no-op stub 合并为 1 个,统一通过 `createModuleLogger("track")` +8. **error.tsx 错误上报**:130 个 error.tsx 通过 `useErrorReport` Hook 上报到 `/api/client-error`,含节流防风暴 +9. **架构图同步**:004 / 005 / known-issues.md 全量更新 + +### 2.2 非目标(YAGNI) + +- OpenTelemetry / 链路追踪(trace) +- Prometheus / `/metrics` 端点 +- Sentry / Bugsnag / Datadog 等 SaaS 错误监控 +- Web Vitals 后端持久化(保留现有 `console.warn`) +- 数据库查询日志 / 慢查询日志 +- 日志文件轮转(`docker logs` 已足够) +- next-auth events 回调改造(与本次重构解耦) + +--- + +## 3. 架构设计 + +### 3.1 整体数据流 + +``` +[Client] ──HTTP──> [proxy.ts (Edge Runtime)] + │ requestId = crypto.randomUUID() // Web Crypto API + │ NextResponse.next({ request: { headers } }) + │ 注入 x-request-id 到下游请求头 + ▼ +[RSC / Server Action / Route Handler (Node.js Runtime)] + │ withRequestContext(fn): + │ 1. headers().get("x-request-id") 读取 + │ 2. requestContextStorage.run({ requestId }, fn) + ▼ +[data-access / audit-logger / 业务逻辑 (Node.js Runtime)] + │ logger.info({...}, "msg") 调用 + │ pino mixin 自动从 getRequestContext() 取 requestId + ▼ +[stdout: {"level":"info","time":...,"requestId":"abc-123","module":"audit","msg":"..."}] +``` + +### 3.2 新增文件清单 + +``` +src/shared/lib/ +├─ logger.ts # pino 实例 + createModuleLogger 工厂 +├─ request-context.ts # AsyncLocalStorage(仅 Node.js runtime) +└─ with-request-context.ts # Server Action / Route Handler 入口包装 + +src/shared/hooks/ +└─ use-error-report.ts # error.tsx 公共上报 Hook(含节流) + +src/app/api/client-error/ +└─ route.ts # 客户端错误接收端点 +``` + +### 3.3 修改文件清单 + +| 文件 | 改动类型 | 说明 | +|------|---------|------| +| `package.json` | 新增依赖 | `pino`、`pino-pretty`(dev) | +| `src/env.mjs` | 新增字段 | `LOG_LEVEL`(默认 `info`,Zod enum) | +| `src/proxy.ts` | 修改 | 生成 requestId 并通过 `NextResponse.next` 注入请求头 | +| `src/shared/lib/action-utils.ts` | 修改 | `handleActionError` / `safeActionCall` 用 logger | +| `src/shared/lib/api-response.ts` | 修改 | `handleApiError` 用 logger | +| `src/shared/lib/audit-logger.ts` | 修改 | catch 块从 silent 改为 `logger.warn` | +| `src/shared/lib/change-logger.ts` | 修改 | 同上 | +| `src/shared/lib/login-logger.ts` | 修改 | 同上 | +| `src/shared/lib/track-event.ts` | 修改 | 改为 `createModuleLogger("track")`,删除 no-op 输出 | +| `src/modules/rbac/lib/track.ts` | 删除 | 引用方改为从 `@/shared/lib/track-event` 导入 | +| `src/modules/course-plans/lib/track-event.ts` | 删除 | 同上 | +| `src/modules/questions/utils/track-event.ts` | 删除 | 同上 | +| 88 处 `console.*` 调用点 | 修改 | 替换为 `logger.*` 或 `createModuleLogger(module)` | +| 130 个 `error.tsx` | 修改 | 在 useEffect 中调用 `useErrorReport(error)` | +| `next.config.ts` | 修改 | 在现有 `serverExternalPackages` 数组中添加 `"pino"`(当前已有 `mysql2`/`tencentcloud-sdk-nodejs`/`exceljs`) | +| `.eslintrc` / `eslint.config.mjs` | 修改 | 新增 `no-console` 规则,仅允许 `logger.ts` 中使用 console | + +--- + +## 4. 核心模块设计 + +### 4.1 `src/shared/lib/logger.ts` + +```ts +import pino, { type Logger } from "pino" +import { env } from "@/env.mjs" +import { getRequestContext } from "./request-context" + +/** + * 全局 logger 实例。 + * + * - 生产环境:JSON 输出到 stdout(docker logs 友好) + * - 开发环境:pino-pretty 彩色文本 + * - 自动从 AsyncLocalStorage 混入 requestId / userId(若存在) + */ +export const logger = pino({ + level: env.LOG_LEVEL, + base: { service: "cicd-app" }, + formatters: { + level: (label) => ({ level: label }), + }, + mixin: () => getRequestContext(), + ...(env.NODE_ENV === "development" && { + transport: { + target: "pino-pretty", + options: { colorize: true, translateTime: "SYS:standard" }, + }, + }), +}) + +/** + * 创建模块级子 logger,自动绑定 module 字段。 + * + * @example + * ```ts + * const log = createModuleLogger("audit") + * log.info({ userId }, "User action logged") + * // 输出: {"level":"info","module":"audit","msg":"User action logged", ...} + * ``` + */ +export function createModuleLogger(module: string): Logger { + return logger.child({ module }) +} + +export type { Logger } +``` + +### 4.2 `src/shared/lib/request-context.ts` + +```ts +import { AsyncLocalStorage } from "node:async_hooks" + +/** + * 请求上下文,贯穿整个请求生命周期。 + * + * 仅在 Node.js Runtime 中可用(proxy.ts 是 Edge Runtime,不导入此模块)。 + * 通过 withRequestContext 高阶函数注入。 + */ +export interface RequestContext { + requestId: string + userId?: string + module?: string +} + +export const requestContextStorage = new AsyncLocalStorage() + +/** + * 获取当前请求上下文(若存在)。 + * + * - 在 withRequestContext 包装的调用栈内:返回完整上下文 + * - 在调用栈外(如顶层模块初始化、定时任务):返回空对象 + * + * pino logger 的 mixin 配置会自动调用此函数混入 requestId。 + */ +export function getRequestContext(): Partial { + return requestContextStorage.getStore() ?? {} +} +``` + +### 4.3 `src/shared/lib/with-request-context.ts` + +```ts +import { headers } from "next/headers" +import { randomUUID } from "node:crypto" +import { requestContextStorage, type RequestContext } from "./request-context" + +/** + * 包装 Server Action / Route Handler,注入 requestId 到 AsyncLocalStorage。 + * + * 工作流程: + * 1. 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id` + * 2. 若请求头无此字段(如直接调用的内部函数),生成新 UUID + * 3. 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage + * 4. 在调用栈内的所有 logger 调用自动获得 requestId + * + * @example + * ```ts + * export const createUserAction = withRequestContext( + * async (state: ActionState, input: CreateUserInput) => { + * // 此处 logger.info 会自动带 requestId + * return handleAction(...) + * } + * ) + * ``` + */ +export function withRequestContext( + fn: (...args: TArgs) => Promise +): (...args: TArgs) => Promise { + return async (...args: TArgs) => { + const headersList = await headers() + const requestId = + headersList.get("x-request-id") ?? randomUUID() + const ctx: RequestContext = { requestId } + return requestContextStorage.run(ctx, () => fn(...args)) + } +} +``` + +### 4.4 `src/proxy.ts` 改造 + +```ts +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import { getToken } from "next-auth/jwt" +// ... 原有 imports + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl + + // 生成或复用 requestId(Web Crypto API,Edge 兼容) + const requestId = + request.headers.get("x-request-id") ?? crypto.randomUUID() + + // 跳过静态资源和登录页 + if ( + pathname.startsWith("/_next") || + pathname.startsWith("/api/auth") || + pathname === "/login" || + pathname === "/register" || + pathname === "/favicon.ico" + ) { + return NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) + } + + // ... 原有 token / onboarding / 权限检查逻辑 + // 所有 NextResponse.next() / NextResponse.redirect() 调用保留, + // 但 NextResponse.next() 调用统一传入 request.headers + + const response = NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) + response.headers.set("x-request-id", requestId) + return response +} + +/** + * 创建包含 x-request-id 的新 Headers 对象。 + * 通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC 请求。 + */ +function injectRequestId(request: NextRequest, requestId: string): Headers { + const headers = new Headers(request.headers) + headers.set("x-request-id", requestId) + return headers +} +``` + +> **说明**:proxy.ts 不导入 `request-context.ts`,避免在 Edge Runtime 中加载 `node:async_hooks` 导致构建错误。 + +### 4.5 `src/shared/hooks/use-error-report.ts` + +```ts +"use client" + +import { useEffect } from "react" + +interface ClientErrorPayload { + message: string + stack?: string + digest?: string + url: string + userAgent: string + timestamp: string +} + +/** + * 客户端错误上报 Hook。 + * + * 用于 error.tsx 接收 error prop 后上报到 /api/client-error。 + * + * 节流策略: + * - 同一 digest(或 message)在 sessionStorage 中标记,避免 React 重渲染或快速刷新时多次上报 + * - 上报失败时静默忽略,避免无限循环 + */ +export function useErrorReport(error: Error & { digest?: string }): void { + useEffect(() => { + if (!error) return + + const digest = error.digest ?? error.message + const storageKey = `error-reported:${digest}` + if (sessionStorage.getItem(storageKey)) return + sessionStorage.setItem(storageKey, "1") + + const payload: ClientErrorPayload = { + message: error.message, + stack: error.stack, + digest: error.digest, + url: window.location.href, + userAgent: navigator.userAgent, + timestamp: new Date().toISOString(), + } + + fetch("/api/client-error", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + keepalive: true, // 即使页面卸载也尝试发送 + }).catch(() => { + // 上报失败时不再上报,避免无限循环 + }) + }, [error]) +} +``` + +### 4.6 `src/app/api/client-error/route.ts` + +```ts +import { NextResponse } from "next/server" +import { createModuleLogger } from "@/shared/lib/logger" +import { withRequestContext } from "@/shared/lib/with-request-context" + +const log = createModuleLogger("client-error") + +interface ClientErrorPayload { + message: string + stack?: string + digest?: string + url: string + userAgent: string + timestamp: string +} + +/** + * 接收客户端 error.tsx 上报的错误。 + * + * 注意:客户端错误的 requestId 与原始请求不同(来自 /api/client-error 的 HTTP 请求), + * 但 digest 字段可用于关联原始错误。 + */ +export const POST = withRequestContext(async (request: Request) => { + try { + const body = (await request.json()) as ClientErrorPayload + log.error( + { + clientMessage: body.message, + stack: body.stack, + digest: body.digest, + url: body.url, + userAgent: body.userAgent, + clientTimestamp: body.timestamp, + }, + "Client error reported" + ) + return NextResponse.json({ ok: true }) + } catch (error) { + log.error({ err: error }, "Failed to parse client error payload") + return NextResponse.json({ ok: false }, { status: 400 }) + } +}) +``` + +### 4.7 `src/shared/lib/audit-logger.ts` 改造(静默失败 → 告警) + +```ts +import { createModuleLogger } from "@/shared/lib/logger" + +const log = createModuleLogger("audit-logger") + +export async function logAudit(params: LogAuditParams): Promise { + try { + // ... 原有写入逻辑 + } catch (error) { + // 旧:catch { /* Silently fail */ } + // 新:记录到 logger,运维可感知 + log.warn( + { err: error, action: params.action, module: params.module }, + "Audit log write failed" + ) + } +} +``` + +### 4.8 `src/shared/lib/action-utils.ts` 改造 + +```ts +import { createModuleLogger } from "@/shared/lib/logger" +import type { ActionState } from "@/shared/types/action-state" +import { PermissionDeniedError } from "@/shared/lib/errors" + +const log = createModuleLogger("action") + +export function handleActionError(e: unknown): ActionState { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + if (e instanceof BusinessError) { + return { success: false, message: e.message, errorCode: e.code } + } + if (e instanceof Error) { + // 旧:console.error("[ActionError]", e.name, e.message, e.stack) + log.error({ err: e }, "Action failed") + return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" } + } + log.error({ err: e }, "Unknown action error") + return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" } +} + +export async function safeActionCall( + action: () => Promise>, + options?: { + onError?: (error: unknown) => void + onFinally?: () => void + } +): Promise | null> { + try { + return await action() + } catch (e) { + options?.onError?.(e) + // 旧:console.error("[SafeActionCall]", e) + log.error({ err: e }, "Safe action call threw") + return null + } finally { + options?.onFinally?.() + } +} +``` + +### 4.9 `src/shared/lib/track-event.ts` 改造 + +```ts +import { createModuleLogger } from "@/shared/lib/logger" + +const log = createModuleLogger("track") + +/** + * 业务埋点接口。 + * + * 不再是 no-op stub,通过 logger.info 输出结构化事件, + * 后续可扩展为接入外部 analytics 服务。 + */ +export function trackEvent( + name: string, + props?: Record +): void { + log.info({ event: name, ...props }, "track event") +} + +export function trackExamEvent( + name: string, + props?: Record +): void { + trackEvent(`exam.${name}`, props) +} + +export function trackAuthEvent( + name: string, + props?: Record +): void { + trackEvent(`auth.${name}`, props) +} +``` + +### 4.10 `src/env.mjs` 改造 + +```ts +// 在 server schema 中添加: +LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), +``` + +### 4.11 ESLint 规则 + +```js +// eslint.config.mjs +{ + rules: { + "no-console": ["error", { allow: [], allowWithImplicit: false }] + }, + // logger.ts 豁免(pino 内部需用 console) + overrides: [ + { + files: ["src/shared/lib/logger.ts"], + rules: { "no-console": "off" } + } + ] +} +``` + +--- + +## 5. 改造范围与策略 + +### 5.1 console.* 替换映射 + +| 类型 | 数量 | 替换为 | 调用方式 | +|------|------|--------|----------| +| `console.log` | 2 | `log.debug` | `const log = createModuleLogger("questions")` | +| `console.error` | 67 | `log.error` | 按文件所属模块创建子 logger | +| `console.warn` | 5 | `log.warn` | 同上 | +| `console.info` | 10 | `log.info` | 同上 | +| `console.debug` | 4 | `log.debug` | 同上 | + +### 5.2 模块前缀规范化 + +当前散乱前缀 → 统一通过 `module` 字段: + +| 当前前缀 | 新 module 字段 | +|---------|----------------| +| `[ExamAction]` | `exams` | +| `[ActionError]` | `action` | +| `[ApiError]` | `api` | +| `[AuditLogger]` | `audit-logger` | +| `[SafeActionCall]` | `action` | +| `[Files]` / `[files]` | `files` | +| 无前缀 | 按文件所属模块 | + +### 5.3 Server Action 包装策略 + +由于 Server Action 通过 `"use server"` 自动成为 RPC,不能简单用 `withRequestContext` 包装导出函数(会丢失 Next.js 类型推断)。且 `handleActionError` 是同步函数,无法 `await headers()` 读取请求头。 + +**最终策略**:在每个 Server Action 模块的入口点调用 `withRequestContext` 包装: + +```ts +// src/modules/audit/actions.ts +"use server" +import { withRequestContext } from "@/shared/lib/with-request-context" +import { createModuleLogger } from "@/shared/lib/logger" + +const log = createModuleLogger("audit") + +export const createAuditLogAction = withRequestContext( + async (state: ActionState, input: CreateAuditInput) => { + // 此处 logger 自动带 requestId + log.info({ input }, "Creating audit log") + return handleAction(...) + } +) +``` + +**data-access 层无需显式包装**:因为 data-access 总是从 Server Action 调用,AsyncLocalStorage 上下文会自动贯穿到调用栈下游。data-access 中的 logger 调用会自动获得 requestId。 + +**实施时验证项**: +- Next.js 16 是否允许高阶函数包装 Server Action(保留 NextServerAction 标记) +- 若不允许,回退方案:在每个 Server Action 函数体首行调用 `await initRequestContext()`,该函数内部读取 headers 并写入 AsyncLocalStorage + +### 5.4 error.tsx 改造模式 + +130 个 error.tsx 统一改为: + +```tsx +"use client" +import { useEffect } from "react" +import { useErrorReport } from "@/shared/hooks/use-error-report" +// ... 原有 imports + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useErrorReport(error) + // ... 原有 UI 渲染 +} +``` + +每个 error.tsx 仅增加 2 行(import + 调用 Hook)。 + +--- + +## 6. 测试策略 + +### 6.1 单元测试 + +| 测试文件 | 覆盖内容 | +|---------|---------| +| `__tests__/logger.test.ts` | level 控制、JSON 输出格式、mixin 注入 requestId、createModuleLogger | +| `__tests__/request-context.test.ts` | AsyncLocalStorage 读写、嵌套调用、空上下文 | +| `__tests__/use-error-report.test.tsx` | 节流(sessionStorage 标记)、fetch 调用、错误处理 | + +### 6.2 集成测试 + +- 启动开发服务器,发起请求,验证日志包含 requestId +- 触发 Server Action 错误,验证日志包含 module + requestId + error stack +- 触发 client error.tsx,验证 `/api/client-error` 收到请求并记录日志 + +### 6.3 回归验证 + +- `npx tsc --noEmit` 零错误 +- `npm run lint` 零错误(含新增 `no-console` 规则) +- 现有 vitest 测试套件全部通过 + +--- + +## 7. 风险与权衡 + +| 风险 | 影响 | 缓解 | +|------|------|------| +| pino 在 Next.js bundling 中可能有问题 | 高 | 已配置 `serverExternalPackages`,pino 仅服务端导入;开发期 `pino-pretty` 通过 transport 配置启用 | +| `AsyncLocalStorage` 在 Server Action 中可能不工作 | 中 | Server Action 运行在 Node.js runtime,完全支持;实施时先做最小验证 | +| Server Action 高阶函数包装可能丢失 Next.js 类型 | 中 | 实施时验证,必要时回退为显式 `headers()` 调用 | +| 88 处 console.* 替换可能遗漏 | 低 | 通过 ESLint `no-console` 规则强制,仅允许 `logger.ts` 中使用 | +| 130 个 error.tsx 改造量大 | 中 | 提取公共 `useErrorReport` Hook,每个 error.tsx 仅加 2 行 | +| track-event 合并可能破坏调用方 | 低 | 删除前 grep 所有引用,统一改为从 `@/shared/lib/track-event` 导入 | +| 客户端错误风暴(无限循环上报) | 中 | sessionStorage 节流 + fetch 失败静默 + `keepalive` 选项 | +| proxy.ts 改造可能影响 Edge Runtime 构建 | 中 | 不导入任何 Node.js 模块,仅用 Web Crypto API | + +--- + +## 8. 架构图同步(强制) + +按项目规则"改码必同步图",重构后必须更新: + +### 8.1 `docs/architecture/004_architecture_impact_map.md` +新增章节: +- `shared/lib/logger.ts` — pino 实例 + createModuleLogger +- `shared/lib/request-context.ts` — AsyncLocalStorage 请求上下文 +- `shared/lib/with-request-context.ts` — Server Action / Route Handler 入口包装 +- `shared/hooks/use-error-report.ts` — 客户端错误上报 Hook +- `app/api/client-error/route.ts` — 客户端错误接收端点 + +修改章节: +- `proxy.ts` — 增加 requestId 注入逻辑 +- `shared/lib/audit-logger.ts` / `change-logger.ts` / `login-logger.ts` — 静默失败改为 logger.warn +- `shared/lib/track-event.ts` — 实现层从 no-op 改为 logger.info +- `shared/lib/action-utils.ts` / `api-response.ts` — 接入 logger + +删除记录: +- `modules/rbac/lib/track.ts` +- `modules/course-plans/lib/track-event.ts` +- `modules/questions/utils/track-event.ts` + +### 8.2 `docs/architecture/005_architecture_data.json` +- `modules.shared.lib.exports` 新增 `logger` / `createModuleLogger` / `requestContextStorage` / `getRequestContext` / `withRequestContext` +- `modules.shared.hooks.exports` 新增 `useErrorReport` +- `modules.app.api.client-error.exports` 新增 `POST` +- `dependencyMatrix` 更新:proxy.ts → request-context(仅注入请求头,不导入) +- 删除 `modules.rbac.lib.track`、`modules.course-plans.lib.track-event`、`modules.questions.utils.track-event` + +### 8.3 `docs/troubleshooting/known-issues.md` +新增规则条目: +- pino 集成:`serverExternalPackages` 配置 + `pino-pretty` 仅 dev +- Edge Runtime 限制:proxy.ts 不能导入 `node:async_hooks` +- Server Action 包装:`withRequestContext` 高阶函数使用模式 +- error.tsx 错误上报:`useErrorReport` Hook + 节流策略 +- ESLint `no-console` 规则与豁免 + +--- + +## 9. 实施顺序建议 + +1. **Phase 1:基础底座** + - 装 pino / pino-pretty + - 创建 `logger.ts` / `request-context.ts` / `with-request-context.ts` + - 在 `env.mjs` 添加 LOG_LEVEL + - 单元测试 + +2. **Phase 2:核心接入** + - proxy.ts 注入 requestId + - `action-utils.ts` / `api-response.ts` 接入 logger + - 三个 audit-logger 静默失败 → logger.warn + - track-event 去重合并 + +3. **Phase 3:批量替换** + - 88 处 console.* 替换为 logger.* + - 前缀规范化 + - ESLint `no-console` 规则启用 + +4. **Phase 4:error.tsx 上报** + - 创建 `use-error-report` Hook + - 创建 `/api/client-error` Route Handler + - 130 个 error.tsx 接入 + +5. **Phase 5:架构同步** + - 更新 004 / 005 / known-issues.md + - 验证 tsc / lint / 测试通过 diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index eeb5e3d..c86ba64 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -586,6 +586,15 @@ export function AnnouncementPagination({ page, pageSize, total, basePath, status | react-hook-form + zodResolver 协变差异保留 `as unknown as` | `zodResolver(formSchema) as unknown as Resolver`(库协变差异需 `as unknown as`,注释说明原因) | `zodResolver(formSchema) as Resolver`(直接 `as` 违规) | | 复杂 RichQuestionContent 结构守卫失败用 isRecord 兜底 | `if (!isRecord(parsed)) return fallback; const content = parsed as EditorQuestion["content"]`(结构校验由下游 Zod schema 保证,注释说明) | `parsed as EditorQuestion["content"]`(直接断言无守卫) | +### TypeScript 箭头函数语法规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 箭头函数带返回类型必须有 `=>` | `export const fn = async (x: string): Promise => { ... }` | `export const fn = async (x: string): Promise { ... }`(TS1005: '=>' expected) | +| `const fn = async ()` 与 `async function fn()` 区别 | `const fn = async (): Promise => {` 用箭头;`async function fn(): Promise {` 用声明 | 混用:`const fn = async (): Promise {`(缺 `=>`) | + +> 2026-07-07 修复:`textbooks/data-access.ts` 的 `getKnowledgePointsByTextbookIdRaw` 缺失 `=>`(pre-existing bug,tsc 报 TS1005),补全后通过。 + 严重违规双重断言: - `structure-tree.tsx:72` `{ ...textbookNode, type: "textbook_content" } as unknown as Block` - `version-diff-viewer.tsx:38` `selectedVersion.content as unknown as LessonPlanDocument` @@ -1463,8 +1472,12 @@ if (announcement.type === "school") { | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | 递归删除改批量收集 + inArray | 收集后代 ID 数组 → `inArray(ids)` 单次删除 | 递归内逐个 SELECT + DELETE | +| BFS 逐层 inArray 收集后代 | `while (queue.length) { const children = await tx.select().where(inArray(parentId, queue)); queue = nextLevel }` | `for (const child of children) { await recursive(child) }` | | 循环 UPDATE 改 CASE WHEN | `UPDATE ... SET order = CASE id WHEN ... THEN ... END WHERE id IN (...)` | `for (item) await db.update(...).set(...).where(eq(id, item.id))` | +| 循环 UPDATE 在事务内并行 | `await db.transaction(async (tx) => { await Promise.all(items.map(i => tx.update(...).set(...).where(eq(id, i.id)))) })` | `for (item) await db.update(...).set(...).where(eq(id, item.id))`(串行 + 无事务) | +| 多步读写包装在 db.transaction | `await db.transaction(async (tx) => { /* reads + writes with tx */ })` | 多个独立 `db.select`/`db.update` 调用(无原子性,竞态风险) | | 跨模块循环调用改批量接口 | `getActiveStudentIdsByClassIds(classIds)` 一次查询 | `Promise.all(classIds.map(id => getActiveStudentIdsByClassId(id)))` | +| 批量聚合改 GROUP BY + 应用层归并 | `db.select({...}).groupBy(studentId)` 一次查询 + `Map` 归并 | `Promise.all(classIds.map(id => db.select({...}).where(...)))`(N 条聚合 SQL) | | 内存 filter 改 SQL WHERE | `inArray(field, ids)` 下推到 SQL | 拉全表后 `rows.filter(r => ids.includes(r.id))` | ### LIKE 全表扫描规则 @@ -1472,8 +1485,11 @@ if (announcement.type === "school") { | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | 前导通配符禁止 | `LIKE 'xxx%'`(可走索引)或 `MATCH AGAINST IN BOOLEAN MODE` | `LIKE '%xxx%'` | +| 前缀匹配必须转义用户输入通配符 | `escapeLikePattern(q)` 后再拼 `'%'`(转义 `%`/`_`/`\`) | `like(col, \`${q}%\`)`(用户输入 `%` 破坏查询) | | JSON 列禁止 LIKE | 关联表存储提取关系 + `inArray` 等值查询 | `LIKE('%id%', JSON列)` | | LOWER+CAST+LIKE 三重杀手 | FULLTEXT 索引 + 生成列 `content_text` | `LOWER(CAST(content AS CHAR)) LIKE '%x%'` | +| 多字段搜索用 OR 前缀匹配 | `or(like(t.title, \`${esc(q)}%\`), like(t.subject, \`${esc(q)}%\`))` | `or(like(t.title, \`%${q}%\`), ...)` | +| JSON 内嵌 ID 子串匹配短期保留 | 注释 `// F-02: 子串匹配需 FULLTEXT 索引,短期保留` + 内存精确过滤 | 无注释的 `like(jsonCol, \`%${id}%\`)` | ### 跨模块 schema 引用规则 @@ -1481,6 +1497,11 @@ if (announcement.type === "school") { |------|---------|---------| | 模块间走对方 data-access | `import { getClassNamesByIds } from "@/modules/classes/data-access"` | `import { classes } from "@/shared/db/schema"` + JOIN | | 跨模块批量接口 | `getGradeNamesByIds(gradeIds): Promise>` | `Promise.all(gradeIds.map(id => getGradeNameById(id)))` | +| A-06 JOIN 改批量查询 + Map 合并 | 移除 `.leftJoin(textbooks, ...)`,改 `const map = await getTextbookTitlesByIds(ids); rows.map(r => ({...r, title: map.get(r.id) ?? null}))` | `.leftJoin(textbooks, eq(..., textbooks.id))` 直查对方表 | +| A-06 跨模块接口用 cacheFn Raw+Wrapper | `getXxxTitlesByIdsRaw = async (ids) => {...}; getXxxTitlesByIds = cacheFn(getXxxTitlesByIdsRaw, {tags, ttl, keyParts})` | 裸 `async (ids) => { return await db.select()... }` 无缓存 | +| A-06 别名导入避免命名冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling } from "@/modules/school/data-access"` | 本模块与导入方同名导出导致 duplicate identifier | +| A-06 单条查询用单 ID 接口 | `const name = (await getClassNameById(classId)) ?? ""` | `db.select().from(classes).leftJoin(...)` 仅查一条 | +| A-06 自有关联表可直查 + 跨模块名称走接口 | `db.select().from(questionsToKnowledgePoints)` + `await getKnowledgePointNamesByIds(kpIds)` | `.innerJoin(knowledgePoints, ...)` 直查对方知识点表 | ### data-access 职责边界规则 @@ -1523,11 +1544,22 @@ if (announcement.type === "school") { | 重复 helper 提取到 shared/lib | `import { toISODateString } from "@/shared/lib/date-utils"` | 每个模块自定义 `serializeDate`/`toIso` | | export * 改显式 re-export | `export { getClassNamesByIds, getClassExists } from "./data-access-queries"` | `export * from "./data-access-queries"` | +### 非空断言规则(G1-060~065) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 数组取值后判空再访问 | `const row = rows[0]; if (!row) throw new Error("..."); row.field` | `rows[0]!.field` | +| 数组取值后可选返回 | `const row = rows[0]; return row ? mapRow(row) : null` | `rows.length === 0 ? null : mapRow(rows[0]!)` | +| split 结果用 ?? 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` | +| WHERE 已过滤 NULL 的字段用 ?? | `templateId: r.templateId ?? ""` | `templateId: r.templateId!` | + ### SELECT 列枚举规则 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | 显式枚举所需列 | `db.select({ id: users.id, name: users.name }).from(users)` | `db.select().from(users)`(SELECT *) | +| 接口字段可空性须与 schema 一致 | `teacherName: string \| null`(schema 中 `users.name` 无 `.notNull()`) | `teacherName: string`(tsc 报 `Type 'string \| null' is not assignable to type 'string'`) | +| 仅枚举消费字段 | `db.select({ knowledgePointId, totalQuestions, correctQuestions })`(仅这三字段被 `existingByKp.set` 消费) | `db.select()` 拉全行后只用 3 字段 | ### 无 LIMIT 保护规则 @@ -1550,6 +1582,23 @@ if (announcement.type === "school") { | S-04 DRY 日期序列化 | `import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"` | 各模块重复定义 `const toIso = (d: Date) => d.toISOString()` | | P-01 server-only 强制 | data-access.ts 文件头 `import "server-only"` | data-access.ts 缺少 `import "server-only"`(DB 逻辑泄露风险) | +### School 模块 G3 审计修复规则(2026-07-07) + +> 来源:school 模块 G3 审计(G3-007 / G3-010 / G3-011 / G3-012 / S-03 / A-02 / A-10 / F-09) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| G3-007 / S-01 data-access 按职责拆分 | `data-access.ts`(barrel)→ `data-access-{departments,grades,subjects,classrooms,semesters,schools}.ts` + `lib/school-permissions.ts` | 单文件 938 行混合 6 类职责 | +| G3-010 / A-10 data-access 移除 try-catch 错误吞没 | `export const getXxxRaw = async () => { const rows = await db.select()...; return rows.map(...) }`(让错误向上抛出) | `try { ... } catch (error) { log.error({err: error}, "xxx failed"); return [] }`(吞没错误返回空数组) | +| G3-010 / A-10 data-access 禁止 log.error / console.error | `throw` 让 actions 层 catch 处理 | `log.error({ err: error }, "xxx failed"); return []` | +| G3-011 / A-02 角色判断迁出 data-access | `lib/school-permissions.ts` 导出 `getSchoolsForUser(userId)` / `getGradesForUser(userId)`,data-access 只接受显式参数(如 `getGradesByIds(gradeIds)`) | data-access 内 `if (roleNames.has("admin")) {...} else if (roleNames.has("teacher")) {...}` | +| G3-012 / F-09 promoteGrades 事务包裹 | `await db.transaction(async (tx) => { const rows = await tx.select()...; for (row of rows) await tx.update()... })` | `const rows = await db.select()...; for (row of rows) await db.update()...`(无事务,部分失败不一致) | +| S-03 fetchGradesWithHeads helper 抽取 | `async function fetchGradesWithHeads(where?: SQL) { const rows = await db.select()...; return mapGradesWithHeads(rows) }` 复用于 getGrades / getGradesForStaff / getGradesByIds | 三个函数各自重复"查询年级 + 批量查询主任用户 + Map 映射"逻辑 | +| barrel re-export 保持向后兼容 | 拆分后 barrel 文件 re-export 子文件导出,消费者无需改 import 路径 | 拆分后修改所有消费者的 import 路径 | +| G3-048 barrel export * 改显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` + `export type { TypeA } from "./sub"` | `export * from "./sub"`(子文件导出 < 15 时仍用 export *) | +| G3-048 barrel export * 保留 | 子文件导出 >= 15 时保留 `export * from "./sub"` + 注释说明原因 | 子文件导出 >= 15 时强行显式列举(易遗漏符号) | +| 子文件首行 `import "server-only"` | 每个 `data-access-*.ts` 文件首行 `import "server-only"` | 仅 barrel 有 `import "server-only"`,子文件缺失 | + --- ## 二十八、日志系统规则(2026-07-07 重构) @@ -1595,6 +1644,39 @@ if (announcement.type === "school") { | 上报端点 | POST /api/client-error (Route Handler 用 pino 记录) | 自定义 console 输出 | | 节流策略 | sessionStorage 记录 digest,1 分钟内不重复上报 | 每次渲染都上报 | - +--- + +## 二十九、数据库访问层审计 Phase 1-4 修复规则速查(2026-07-07) + +> Phase 0-4 全部落地(230 条问题,17 P0/48 P1/105 P2/60 P3),tsc + lint 0 errors。 +> 详细规则见第二十七节「数据库访问层审计治理规则」,本节为按规则 ID 的速查索引。 + +| 规则 ID | 正确写法 | 错误写法 | +|---------|---------|---------| +| F-01 N+1 递归删除 | BFS 逐层 `inArray` 收集后代 + 单次批量删除 | 递归内单独查询+删除 | +| F-01 N+1 批量统计 | 单条 `GROUP BY` 聚合 + 应用层 `Map` 归并 | 循环内每项单独查询 | +| F-01 N+1 多步写 | `db.transaction(async (tx) => { await Promise.all(items.map(...)) })` | 串行 `for (item) await db.update(...)` | +| F-02 LIKE 前缀匹配 | `like(field, escapeLikePattern(q) + "%")` | `like(field, "%" + q + "%")` | +| F-02 多字段搜索 | `or(like(t.title, esc(q)+"%"), like(t.subject, esc(q)+"%"))` | `or(like(t.title, "%"+q+"%"), ...)` | +| F-02 JSON 列搜索 | FULLTEXT 索引 + 生成列 `content_text` | `LIKE('%id%', JSON列)` | +| F-05 默认 LIMIT | `.limit(DEFAULT_LIMIT)` 无分页查询(DEFAULT_LIMIT=1000) | 无 LIMIT 的全表查询 | +| F-09 事务包裹 | `db.transaction(async (tx) => { ... })` 多步写操作 | 多个独立 `db.update` 无事务 | +| F-09 事务内用 tx | `await tx.update(...)` | `await db.update(...)`(在 transaction 回调内) | +| A-02 业务逻辑下沉 | 算法/校验放 `lib/`,CRUD 留 `data-access` | `data-access` 含状态机/抽签算法/时间冲突检测 | +| A-06 跨模块调用 | 调用对方 `data-access` 函数(`getTextbookTitlesByIds` 等) | `import { classes } from "@/shared/db/schema"` + JOIN | +| A-06 跨模块批量接口 | `getXxxTitlesByIds(ids): Map` 一次查询 | `Promise.all(ids.map(id => getXxxTitleById(id)))` | +| A-06 别名导入避免冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling }` | 同名导出导致 duplicate identifier | +| A-10 错误处理 | `data-access` 层 `throw`,`actions` 层 `try-catch` 返回 `ActionState` | `data-access` 层 `console.error` + 吞错误返回 `[]` | +| P-03 cacheFn 配对 | `getXxxRaw` + `getXxx = cacheFn(getXxxRaw, {tags, ttl, keyParts})` | 读函数无缓存(直查 DB) | +| P-03 权限校验不缓存 | `verifyTeacherOwnsClass` 直查(避免缓存权限提升) | `verifyTeacherOwnsClass = cacheFn(...)` | +| S-01 文件拆分 | 按职责拆为 `data-access-xxx.ts` + barrel | 单文件 > 1000 行(硬上限) | +| S-01 barrel re-export | barrel `export * from "./data-access-*"` 保持向后兼容 | 拆分后修改所有消费者 import 路径 | +| S-03 helper 去重 | 提取到 `lib/` 或 `shared/lib/`(如 `lib/date-utils.ts`) | 多模块各自重复实现 `toIso`/`isClassSubject` | +| S-05 export * → 显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` | `export * from "./sub"`(导出 < 15 时) | +| S-05 export * 保留 | 子文件导出 >= 15 时保留 `export *` + 注释 | 导出 >= 15 强行显式列举(易遗漏) | +| S-06 JSDoc | 公开导出函数补 `@param` + `@returns` + `@throws` | 无 JSDoc | +| P-09 as 断言 | 类型守卫 / `satisfies` / 类型标注常量 | `value as Type` | +| P-09 非空断言 | `const row = rows[0]; if (!row) throw ...; row.field` | `rows[0]!.field` | +| P-09 split 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` |