- 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
603 lines
64 KiB
JSON
603 lines
64 KiB
JSON
[
|
||
{
|
||
"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)"
|
||
}
|
||
]
|