docs(architecture): update impact map, data, audit reports, superpowers docs
- Update 004_architecture_impact_map.md and 005_architecture_data.json - Add audit reports: data-access-audit-framework-v1, data-access-audit-v1-data.json, data-access-audit-v1, g1-g5 audit outputs - Add superpowers plans and specs (logging-refactor, documentation-system-redesign) - Update troubleshooting/known-issues.md
This commit is contained in:
@@ -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<typeof gradeRecords.$inferInsert>;**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<classId, Map<ClassSubject, TeacherOption|null>>)+ `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<T = unknown> = {
|
||||
|
||||
### 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<T = unknown> = {
|
||||
- `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<T = unknown> = {
|
||||
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<id, title>` | 供 lesson-preparation 批量解析教材标题 |
|
||||
| textbooks | `getChapterTitlesByIds(ids): Map<id, title>` | 供 lesson-preparation 批量解析章节标题 |
|
||||
| textbooks | `getKnowledgePointNamesByIds(ids): Map<id, name>` | 供 questions 批量解析知识点名称 |
|
||||
| questions | `getQuestionCountByKpIds(ids): Map<id, count>` | 供 textbooks/graph 批量查关联题目数 |
|
||||
| diagnostic | `getStudentMasteryByKpIds(sid, ids): Map<id, MasterySnapshot>` | 供 textbooks/graph 查学生掌握度 |
|
||||
| diagnostic | `getClassMasteryByKpIds(sids, ids): Map<id, MasterySnapshot>` | 供 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<Class[]>
|
||||
getStudentClasses(studentId: string): Promise<Class[]>
|
||||
getClassStudents(classId: string): Promise<Student[]>
|
||||
getClassHomeworkInsights(classId: string): Promise<ClassHomeworkInsights> // ✅ P0-7 已修复:通过 homework/data-access-classes 获取数据
|
||||
getActiveStudentIdsByClassIdsBatch(classIds: string[]): Promise<Map<string, string[]>> // 批量获取多班级活跃学生 ID,消除 N+1(F-01)
|
||||
|
||||
// grades/data-access.ts
|
||||
getGradeRecords(params: GetGradeRecordsParams & { scope: DataScope; currentUserId?: string; limit?: number; offset?: number }): Promise<PaginatedGradeRecords>
|
||||
|
||||
File diff suppressed because one or more lines are too long
206
docs/architecture/audit/data-access-audit-framework-v1.md
Normal file
206
docs/architecture/audit/data-access-audit-framework-v1.md
Normal file
@@ -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<T>` | 无隐式推断 | 深读 |
|
||||
| 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
|
||||
171
docs/architecture/audit/data-access-audit-v1-data.json
Normal file
171
docs/architecture/audit/data-access-audit-v1-data.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
525
docs/architecture/audit/data-access-audit-v1.md
Normal file
525
docs/architecture/audit/data-access-audit-v1.md
Normal file
@@ -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
|
||||
806
docs/architecture/audit/g1-audit-output.json
Normal file
806
docs/architecture/audit/g1-audit-output.json
Normal file
@@ -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<Map<string,string>>`,单次 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<T>`,依赖 TypeScript 推断。违反 P-04 规则'函数返回值必须显式标注,特别是 Promise<T>'。",
|
||||
"recommendation": "定义返回类型并显式标注:`export const getQuestionsRaw = async (params: GetQuestionsParams = {}): Promise<QuestionsListResult> => { ... }`,将返回结构提取为命名类型。",
|
||||
"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 分钟)"
|
||||
}
|
||||
]
|
||||
134
docs/architecture/audit/g2-data-access-audit.json
Normal file
134
docs/architecture/audit/g2-data-access-audit.json
Normal file
@@ -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<Map<string, string[]>>` 批量接口(单条 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<boolean>`(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 分钟)"
|
||||
}
|
||||
]
|
||||
602
docs/architecture/audit/g3-audit-output.json
Normal file
602
docs/architecture/audit/g3-audit-output.json
Normal file
@@ -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<Map<string, string>> => { /* 原 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<void> {\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<void> {\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<DepartmentListItem[]> => {\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<ActionState<SchoolListItem[]>> {\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<ScheduleEntry[]> {\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<ClassesDashboardStats> => {\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<AdminClassListItem[]> {\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<string> {\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<string[]> => {\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<AdminClassListItem[]> => {\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<GradeListItem[]> {\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<ProctoringEventWithDetails[]> => {\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<string> {\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<ScheduleConflict[]> {\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<number, typeof rows>()\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<number>`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)"
|
||||
}
|
||||
]
|
||||
734
docs/architecture/audit/g4-audit-output.json
Normal file
734
docs/architecture/audit/g4-audit-output.json
Normal file
@@ -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<void> {\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<userId, name>。但同模块 L41 已 import getUserNamesByIds from users/data-access,且后者返回 Map<userId, UserNameOption>(含 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<AdminUserListResult> => {...}\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<AuditLog[]> {\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<boolean> {...}\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>` 直接返回 boolean,未包装 ActionState。其他所有 Action 均返回 ActionState<T>。不一致,且 client 调用方无法区分「权限拒绝」与「不是 admin」。",
|
||||
"recommendation": "改为标准 ActionState:\n```ts\nexport async function isAdminRoleAction(roleName: string): Promise<ActionState<boolean>> {\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<T>(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 分钟)"
|
||||
}
|
||||
]
|
||||
494
docs/architecture/audit/g5-audit-output.json
Normal file
494
docs/architecture/audit/g5-audit-output.json
Normal file
@@ -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<Date | null>` 读函数(含 cacheFn 包装),actions.ts 改为调用该函数:\n```ts\n// data-access.ts\nexport const getUserOnboardedAtRaw = async (userId: string): Promise<Date | null> => {\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<void> { /* 仅 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<FileAttachment | null> {\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<FileAttachment | null> {\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<typeof like>[]\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<void>` 仅做幂等插入\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<AiProviderSummary[]> {\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<void> {\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<ElectiveCourseWithDetails[]> => {\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<boolean> => {\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<UserAudience | null> { /* 接受依赖注入 */ }\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<SystemSettingRecord[]> {\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<string, string | number>) {\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:<gradeId>`,若未命中再查 `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<number> => {\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<CourseSelectionWithDetails[]> => {\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<AiUsageStats> { ... }\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<CourseSelectionWithDetails[]> {\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<typeof c> => 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<Map<string, { errorCount: number; masteredCount: number }>> {\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 小时)"
|
||||
}
|
||||
]
|
||||
2682
docs/superpowers/plans/2026-07-07-documentation-system-redesign.md
Normal file
2682
docs/superpowers/plans/2026-07-07-documentation-system-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
2149
docs/superpowers/plans/2026-07-07-logging-refactor.md
Normal file
2149
docs/superpowers/plans/2026-07-07-logging-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 <symbolName> [--forward] [--depth=10] # 符号引用(默认逆向)
|
||||
npm run arch:query module <moduleName> [--reverse] [--depth=10] # 模块依赖
|
||||
npm run arch:query tech <techTag> # 技术使用
|
||||
npm run arch:query path <moduleA> <moduleB> # 模块间路径
|
||||
npm run arch:query violations # 架构违规
|
||||
npm run arch:query trace <entrySymbol> # 完整调用链
|
||||
npm run arch:query sql "<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]
|
||||
│ │
|
||||
│ └── <Form onSubmit> (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 <symbol>` 能递归查询符号引用
|
||||
- [ ] `npm run arch:query module <module>` 能递归查询模块依赖
|
||||
- [ ] `npm run arch:query tech <tag>` 能查询技术使用
|
||||
- [ ] `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 个标签可能不够,实施时根据需要扩展
|
||||
721
docs/superpowers/specs/2026-07-07-logging-refactor-design.md
Normal file
721
docs/superpowers/specs/2026-07-07-logging-refactor-design.md
Normal file
@@ -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<RequestContext>()
|
||||
|
||||
/**
|
||||
* 获取当前请求上下文(若存在)。
|
||||
*
|
||||
* - 在 withRequestContext 包装的调用栈内:返回完整上下文
|
||||
* - 在调用栈外(如顶层模块初始化、定时任务):返回空对象
|
||||
*
|
||||
* pino logger 的 mixin 配置会自动调用此函数混入 requestId。
|
||||
*/
|
||||
export function getRequestContext(): Partial<RequestContext> {
|
||||
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<User>, input: CreateUserInput) => {
|
||||
* // 此处 logger.info 会自动带 requestId
|
||||
* return handleAction(...)
|
||||
* }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function withRequestContext<TArgs extends unknown[], TResult>(
|
||||
fn: (...args: TArgs) => Promise<TResult>
|
||||
): (...args: TArgs) => Promise<TResult> {
|
||||
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<void> {
|
||||
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<never> {
|
||||
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<T>(
|
||||
action: () => Promise<ActionState<T>>,
|
||||
options?: {
|
||||
onError?: (error: unknown) => void
|
||||
onFinally?: () => void
|
||||
}
|
||||
): Promise<ActionState<T> | 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<string, unknown>
|
||||
): void {
|
||||
log.info({ event: name, ...props }, "track event")
|
||||
}
|
||||
|
||||
export function trackExamEvent(
|
||||
name: string,
|
||||
props?: Record<string, unknown>
|
||||
): void {
|
||||
trackEvent(`exam.${name}`, props)
|
||||
}
|
||||
|
||||
export function trackAuthEvent(
|
||||
name: string,
|
||||
props?: Record<string, unknown>
|
||||
): 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<void>, 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 / 测试通过
|
||||
@@ -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<ExamFormValues>`(库协变差异需 `as unknown as`,注释说明原因) | `zodResolver(formSchema) as Resolver<ExamFormValues>`(直接 `as` 违规) |
|
||||
| 复杂 RichQuestionContent 结构守卫失败用 isRecord 兜底 | `if (!isRecord(parsed)) return fallback; const content = parsed as EditorQuestion["content"]`(结构校验由下游 Zod schema 保证,注释说明) | `parsed as EditorQuestion["content"]`(直接断言无守卫) |
|
||||
|
||||
### TypeScript 箭头函数语法规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 箭头函数带返回类型必须有 `=>` | `export const fn = async (x: string): Promise<void> => { ... }` | `export const fn = async (x: string): Promise<void> { ... }`(TS1005: '=>' expected) |
|
||||
| `const fn = async ()` 与 `async function fn()` 区别 | `const fn = async (): Promise<T> => {` 用箭头;`async function fn(): Promise<T> {` 用声明 | 混用:`const fn = async (): Promise<T> {`(缺 `=>`) |
|
||||
|
||||
> 2026-07-07 修复:`textbooks/data-access.ts` 的 `getKnowledgePointsByTextbookIdRaw` 缺失 `=>`(pre-existing bug,tsc 报 TS1005),补全后通过。
|
||||
|
||||
严重违规双重断言:
|
||||
- `structure-tree.tsx:72` `{ ...textbookNode, type: "textbook_content" } as unknown as Block`
|
||||
- `version-diff-viewer.tsx:38` `selectedVersion.content as unknown as LessonPlanDocument`
|
||||
@@ -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<Map<string,string>>` | `Promise.all(gradeIds.map(id => getGradeNameById(id)))` |
|
||||
| A-06 JOIN 改批量查询 + Map 合并 | 移除 `.leftJoin(textbooks, ...)`,改 `const map = await getTextbookTitlesByIds(ids); rows.map(r => ({...r, title: map.get(r.id) ?? null}))` | `.leftJoin(textbooks, eq(..., textbooks.id))` 直查对方表 |
|
||||
| A-06 跨模块接口用 cacheFn Raw+Wrapper | `getXxxTitlesByIdsRaw = async (ids) => {...}; getXxxTitlesByIds = cacheFn(getXxxTitlesByIdsRaw, {tags, ttl, keyParts})` | 裸 `async (ids) => { return await db.select()... }` 无缓存 |
|
||||
| A-06 别名导入避免命名冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling } from "@/modules/school/data-access"` | 本模块与导入方同名导出导致 duplicate identifier |
|
||||
| A-06 单条查询用单 ID 接口 | `const name = (await getClassNameById(classId)) ?? ""` | `db.select().from(classes).leftJoin(...)` 仅查一条 |
|
||||
| A-06 自有关联表可直查 + 跨模块名称走接口 | `db.select().from(questionsToKnowledgePoints)` + `await getKnowledgePointNamesByIds(kpIds)` | `.innerJoin(knowledgePoints, ...)` 直查对方知识点表 |
|
||||
|
||||
### data-access 职责边界规则
|
||||
|
||||
@@ -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<id, title>` 一次查询 | `Promise.all(ids.map(id => getXxxTitleById(id)))` |
|
||||
| A-06 别名导入避免冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling }` | 同名导出导致 duplicate identifier |
|
||||
| A-10 错误处理 | `data-access` 层 `throw`,`actions` 层 `try-catch` 返回 `ActionState` | `data-access` 层 `console.error` + 吞错误返回 `[]` |
|
||||
| P-03 cacheFn 配对 | `getXxxRaw` + `getXxx = cacheFn(getXxxRaw, {tags, ttl, keyParts})` | 读函数无缓存(直查 DB) |
|
||||
| P-03 权限校验不缓存 | `verifyTeacherOwnsClass` 直查(避免缓存权限提升) | `verifyTeacherOwnsClass = cacheFn(...)` |
|
||||
| S-01 文件拆分 | 按职责拆为 `data-access-xxx.ts` + barrel | 单文件 > 1000 行(硬上限) |
|
||||
| S-01 barrel re-export | barrel `export * from "./data-access-*"` 保持向后兼容 | 拆分后修改所有消费者 import 路径 |
|
||||
| S-03 helper 去重 | 提取到 `lib/` 或 `shared/lib/`(如 `lib/date-utils.ts`) | 多模块各自重复实现 `toIso`/`isClassSubject` |
|
||||
| S-05 export * → 显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` | `export * from "./sub"`(导出 < 15 时) |
|
||||
| S-05 export * 保留 | 子文件导出 >= 15 时保留 `export *` + 注释 | 导出 >= 15 强行显式列举(易遗漏) |
|
||||
| S-06 JSDoc | 公开导出函数补 `@param` + `@returns` + `@throws` | 无 JSDoc |
|
||||
| P-09 as 断言 | 类型守卫 / `satisfies` / 类型标注常量 | `value as Type` |
|
||||
| P-09 非空断言 | `const row = rows[0]; if (!row) throw ...; row.field` | `rows[0]!.field` |
|
||||
| P-09 split 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` |
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user