[ { "id": "G2-001", "file": "src/modules/exams/data-access.ts", "lines": "L1", "ruleId": "P-01", "severity": "P0", "dimension": "pattern", "title": "文件首行缺少 import \"server-only\" 标记", "description": "data-access.ts 首行为 `import { db } from \"@/shared/db\"`,未在文件头声明 `import \"server-only\"`。该文件包含直接 DB 访问(exams/examQuestions 表的 CRUD),若被客户端组件意外引入,会将数据库连接与查询逻辑泄露到客户端 bundle,造成安全漏洞。同模块的 data-access-error-collection.ts(L1)与 data-access-cross-module.ts(L1)均已正确声明,唯独主文件遗漏。", "recommendation": "在文件第一行(所有 import 之前)添加 `import \"server-only\"`。注意:必须位于首行,否则 next.js 的 server-only 边界检测可能不生效。", "effort": "XS (≤15 分钟)" }, { "id": "G2-002", "file": "src/modules/grades/data-access-appeals.ts", "lines": "L122-L151", "ruleId": "F-01", "severity": "P1", "dimension": "performance", "title": "getPendingAppealsForReviewRaw 在 JS 层过滤班级范围而非 SQL WHERE", "description": "函数 WHERE 子句仅过滤 `gradeAppeals.status = 'pending'`(L134),未对 classIds 加任何过滤,导致 SQL 返回全库所有 pending 申诉(含 gradeRecord 全字段 innerJoin),随后在 L141 用 `rows.filter((r) => classIds.includes(r.gradeRecord.classId))` 在 JS 层过滤。代码注释写明「在 JS 层过滤班级范围(避免复杂 SQL join)」,但 innerJoin gradeRecords 已存在,加 `inArray(gradeRecords.classId, classIds)` 并不复杂。当 pending 申诉总量增长时(全校维度),单次查询会拉取大量无关行,造成内存与网络压力;同时若 JS filter 被误删将引发跨班级数据泄露。", "recommendation": "在 L132-L137 的 `and()` 内追加 `inArray(gradeRecords.classId, classIds)` 条件(classIds 为空时已在 L123 提前返回),删除 L140-L141 的 JS 层 filter,直接返回 rows.map(...)。这样既收窄 SQL 结果集,又消除数据泄露风险。", "effort": "S (≤30 分钟)" }, { "id": "G2-003", "file": "src/modules/adaptive-practice/data-access-analytics.ts", "lines": "L311-L384", "ruleId": "F-01", "severity": "P0", "dimension": "performance", "title": "getTeacherClassPracticeOverviewsRaw 在 Promise.all 内对每个班级循环发起 2 条 SQL(2N+1 模式)", "description": "函数对 classIds 数组执行两次 Promise.all 循环:(1) L320-L325 对每个 classId 调用 `getActiveStudentIdsByClassId(classId)`(每班 1 条 SQL,共 N 条);(2) L328-L365 对每个班级再发起 1 条 `db.select().from(practiceSessions).where(inArray(studentId, ...))` 聚合查询(共 N 条)。加上 L317 的 getClassNamesByIds(1 条),总计 2N+1 条 SQL。当教师所教班级数 N 较大(如年级主任辖 10+ 班级)时,单次请求产生 20+ 条 SQL,且 Promise.all 仅并发 IO 不减少 DB 负载。", "recommendation": "改为批量查询:(1) 一次性获取所有班级的学生 ID 映射(可用单条 SQL `SELECT classId, studentId FROM class_members WHERE classId IN (...) AND status='active'` 后在 JS 层 groupBy);(2) 用单条聚合 SQL `SELECT classId, count(...), SUM(...), COUNT(DISTINCT studentId) FROM practiceSessions WHERE studentId IN (全部学生) GROUP BY studentId` 后在 JS 层按班级归并;或直接 JOIN class_members 按 classId 分组。目标:将 2N+1 降至 2-3 条 SQL。", "effort": "M (≤2 小时)" }, { "id": "G2-004", "file": "src/modules/adaptive-practice/data-access-analytics.ts", "lines": "L320-L325", "ruleId": "F-08", "severity": "P1", "dimension": "performance", "title": "跨模块在循环内多次调用 getActiveStudentIdsByClassId(classes 模块)", "description": "在 Promise.all 内对每个 classId 单独调用 `@/modules/classes/data-access` 的 `getActiveStudentIdsByClassId`,属于 F-08 跨模块多次调用 getXxxByIds 模式。该函数内部本身可能已 cacheFn 包装,但首次填充缓存时仍会产生 N 条 SQL。应改用批量接口 `getActiveStudentIdsByClassIds(classIds)`(如不存在则需在 classes 模块新增)。", "recommendation": "在 classes/data-access 新增 `getActiveStudentIdsByClassIds(classIds: string[]): Promise>` 批量接口(单条 SQL `WHERE classId IN (...)` 后 groupBy),本函数改为一次调用获取全量映射。与 G2-003 的修复可合并执行。", "effort": "M (≤2 小时)" }, { "id": "G2-005", "file": "src/modules/grades/data-access-analytics.ts", "lines": "L1-L831", "ruleId": "S-01", "severity": "P1", "dimension": "structure", "title": "文件 831 行超过 800 行警告阈值", "description": "文件总计 831 行,超过 S-01 规则的 800 行警告线(虽未达 1000 行硬性上限)。文件内含多个独立分析维度:年级分布(getGradeDistribution*)、班级统计(getClassGradeStats*)、学生摘要(getStudentGradeSummary*)、排名(getClassRanking*)等。职责虽同属 grades 分析,但可按分析维度进一步拆分以提升可维护性。", "recommendation": "按分析维度拆分为 data-access-analytics-grade-distribution.ts / data-access-analytics-class-stats.ts / data-access-analytics-student-summary.ts 等,每个子文件 ≤ 300 行。或暂不拆分但监控增长,一旦逼近 1000 行必须拆分。", "effort": "L (≤1 天)" }, { "id": "G2-006", "file": "src/modules/homework/data-access.ts", "lines": "L207-L212", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "data-access 内联 computeOverdueCount 业务计算闭包", "description": "在 getHomeworkAssignmentsRaw 的数据组装段内定义了 `computeOverdueCount` 闭包,包含条件分支 `if (!dueAt || dueAt > now) return 0` 及逾期人数推导逻辑 `Math.max(0, targetCount - submittedCount)`。虽为纯计算(非状态机),但「逾期」的业务定义(dueAt 已过且未提交)属于业务规则,下沉到 data-access 后未来若规则变更(如加宽限期、按作业类型区分)需改 data-access 而非 actions/lib。属 A-02 边界情形。", "recommendation": "将 computeOverdueCount 提取到 homework/lib/overdue.ts 作为纯函数 `computeOverdueCount(dueAt, targetCount, submittedCount, now)`,data-access 仅负责数据获取与组装,业务规则集中到 lib。优先级较低,可在重构窗口处理。", "effort": "S (≤30 分钟)" }, { "id": "G2-007", "file": "src/modules/grades/data-access-drafts.ts", "lines": "L367-L382", "ruleId": "P-04", "severity": "P2", "dimension": "pattern", "title": "releaseDraftLock 返回值依赖隐式类型推断的元组解构", "description": "L367-L378 执行 `db.update(gradeDrafts).set(...).where(...)` 后,L381 用 `const [header] = result` 解构,L382 返回 `(header?.affectedRows ?? 0) > 0`。drizzle MySQL 的 update 返回类型为 `MySqlRawQueryResult`(即 `[ResultSetHeader, FieldPacket[]]`),header 类型由推断得到。代码逻辑正确,但依赖 drizzle 内部类型推断而非显式标注,未来 drizzle 版本变更返回类型时可能静默失效。函数签名已显式标注 `Promise`(L364),属轻微模式偏差。", "recommendation": "可在解构处补充类型注释 `const [header] = result as [ResultSetHeader, unknown]`(此处 as 属从 unknown/drizzle 内部类型收窄,符合豁免);或保持现状但增加单元测试覆盖锁释放场景。优先级低。", "effort": "XS (≤15 分钟)" }, { "id": "G2-008", "file": "src/modules/diagnostic/data-access.ts", "lines": "L1-L553", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "诊断掌握度累积计算函数(updateMasteryFrom*)含业务规则分支", "description": "文件含 3 个掌握度累积函数:updateMasteryFromSubmission / updateMasteryFromHomeworkSubmission / updateMasteryFromExamScore。这些函数内部包含掌握度合并算法(加权平均/最大值取值等业务规则)与 DB 写入混合。掌握度计算属于诊断业务规则,理想分层应将算法提取到 diagnostic/lib/mastery-calculator.ts,data-access 仅负责读写 knowledgePointMastery 表。当前实现可行但职责混合,属 A-02 边界。", "recommendation": "提取纯函数 `computeMasteryAfterSubmission(current: MasteryState, submission: SubmissionInput): MasteryState` 到 diagnostic/lib/,data-access 函数改为:读取当前掌握度 → 调用纯函数计算新值 → 写回 DB。优先级中等,可在掌握度算法需调整时一并重构。", "effort": "M (≤2 小时)" }, { "id": "G2-009", "file": "src/modules/adaptive-practice/data-access.ts", "lines": "L307", "ruleId": "P-09", "severity": "P3", "dimension": "pattern", "title": "sourceMeta as unknown 用于 JSON 序列化字段写入(属豁免范畴)", "description": "L307 `sourceMeta: sourceMeta as unknown` 将类型化对象转为 unknown 以写入 JSON 列。此处的 as 属于「向 unknown 转换」的合规用法(框架 P-09 豁免:从 unknown 收窄或反向序列化)。仅作记录,非违规。", "recommendation": "无需修改。若追求严谨,可改用 `JSON.parse(JSON.stringify(sourceMeta))` 显式序列化,但当前写法已合规。", "effort": "XS (≤15 分钟)" }, { "id": "G2-010", "file": "src/modules/exams/data-access.ts", "lines": "L316", "ruleId": "P-09", "severity": "P3", "dimension": "pattern", "title": "new Map(generated.map((q) => [q.id, q] as const)) 使用 as const 构造 Map(属豁免)", "description": "L316 `[q.id, q] as const` 用于向 Map 构造器提供 readonly tuple 类型。as const 属于 TypeScript 类型工具的合规用法(P-09 豁免),非类型断言违规。仅作记录。", "recommendation": "无需修改。", "effort": "XS (≤15 分钟)" }, { "id": "G2-011", "file": "src/modules/homework/data-access-write.ts", "lines": "L16,L20", "ruleId": "P-09", "severity": "P3", "dimension": "pattern", "title": "import 语句中的 as 为模块别名(非类型断言)", "description": "L16 `getClassTeacherById as getClassTeacherIdFromClass` 与 L20 `getExamWithQuestionsForHomework as getExamWithQuestionsFromExams` 为 ES module import 别名,用于避免跨模块同名函数冲突。非 P-09 规则所约束的类型断言。仅作记录,零违规。", "recommendation": "无需修改。", "effort": "XS (≤15 分钟)" } ]