Files
NextEdu/docs/architecture/audit/archive/g3-audit-output.json

603 lines
64 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[
{
"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-06modules 之间应通过对方 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()` 获取当前教师 IDL31、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-L186145 行)与 `getGradeManagedClassesRaw`L193-L316124 行)有大量重复代码:\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-L193148 行)包含:\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-L439155 行)包含:\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、updatedAtSELECT * 会返回所有字段。",
"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- getStudentIdsByClassIdL147-L153\n- getStudentIdsByClassIdsL159-L166\n- getTeacherIdsByClassIdsL208-L228\n- getClassesByGradeIdL352-L359\n- getClassIdsByGradeIdsL365-L373\n- getClassNamesByIdsL334-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- getGradesRawL92-L149全量查询\n- getGradesForStaffRawL178-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-L304fallback 到旧格式 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、getClassIdsByGradeIdsSubquery26 个)。此外还有 `export * from \"./data-access-stats\"` 等 6 个 re-export实际导出函数总数达 50+。",
"recommendation": "按职责拆分为多个文件:\n- data-access.ts主入口re-export\n- data-access-teacher-scope.tsgetSessionTeacherId、getAccessibleClassIdsForTeacher、verifyTeacherOwnsClass、getTeacherScopeData\n- data-access-class-queries.tsgetClassExists、getClassNameById、getClassGradeId、getClassNamesByIds、getClassesByGradeId 等)\n- data-access-student-queries.tsgetStudentIdsByClassId、getStudentActiveClass、getStudentActiveGradeId 等)\n- data-access-helpers.tscompareClassLike、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 循环内查询 DBN+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 行,但仍偏高)。文件包含:教师班级查询、教师选项查询、教师科目查询、班级 CRUDcreateTeacherClass、updateTeacherClass、deleteTeacherClass、邀请码管理ensureClassInvitationCode、regenerateClassInvitationCode、学生注册enrollStudentByInvitationCode、enrollTeacherByInvitationCode、enrollStudentByEmail、科目教师分配setClassSubjectTeachers、DataScope 辅助getTeacherScopeData。职责过多。",
"recommendation": "进一步拆分:\n- data-access-teacher-queries.tsgetTeacherClasses、getTeacherOptions、getTeacherTeachingSubjects、getTeacherScopeData\n- data-access-teacher-mutations.tscreateTeacherClass、updateTeacherClass、deleteTeacherClass、setClassSubjectTeachers\n- data-access-teacher-enrollment.tsenrollStudentByInvitationCode、enrollTeacherByInvitationCode、enrollStudentByEmail、setStudentEnrollmentStatus\n- data-access-teacher-invitations.tsensureClassInvitationCode、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-L509219 行)。",
"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)"
}
]