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

495 lines
48 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": "G5-001",
"file": "src/modules/onboarding/data-access.ts",
"lines": "L1-L5",
"ruleId": "P-01",
"severity": "P0",
"dimension": "pattern",
"title": "data-access 文件缺少 `import \"server-only\"` 文件头",
"description": "文件首行直接为 `import { eq, and } from \"drizzle-orm\"`,未声明 `import \"server-only\"`,导致此 data-access 模块可能被客户端代码意外引入,存在将 DB schema 与查询逻辑泄露到客户端 bundle 的安全风险。同模块其他 data-access如 elective/data-access.ts L1、settings/data-access.ts L1均规范声明了 `import \"server-only\"`。",
"recommendation": "在文件第一行添加 `import \"server-only\"`,与项目其他 data-access 文件保持一致:\n```ts\nimport \"server-only\"\n\nimport { eq, and } from \"drizzle-orm\"\n// ...\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-002",
"file": "src/modules/onboarding/actions.ts",
"lines": "L15-L16, L72-L76",
"ruleId": "A-09",
"severity": "P0",
"dimension": "architecture",
"title": "actions.ts 含直接 DB 查询,违反三层架构",
"description": "actions.ts 在 L15-L16 直接 `import { db } from \"@/shared/db\"` 与 `import { users } from \"@/shared/db/schema\"`,并在 completeOnboardingAction 内部 L72-L76 直接执行 `db.select({ onboardedAt: users.onboardedAt }).from(users).where(eq(users.id, userId)).limit(1)`。按架构规则 `app/ → modules/ → shared/` 单向依赖actions 层是编排层,应通过 data-access 访问 DB不得直查 schema 表。直查 DB 还会绕过 cacheFn 缓存层。",
"recommendation": "在 onboarding/data-access.ts 中新增 `getUserOnboardedAt(userId): Promise<Date | null>` 读函数(含 cacheFn 包装actions.ts 改为调用该函数:\n```ts\n// data-access.ts\nexport const getUserOnboardedAtRaw = async (userId: string): Promise<Date | null> => {\n const [row] = await db.select({ onboardedAt: users.onboardedAt })\n .from(users).where(eq(users.id, userId)).limit(1)\n return row?.onboardedAt ?? null\n}\nexport const getUserOnboardedAt = cacheFn(getUserOnboardedAtRaw, {\n tags: [\"onboarding\"], ttl: 300, keyParts: [\"onboarding\", \"onboarded-at\"],\n})\n\n// actions.ts\nimport { getUserOnboardedAt } from \"./data-access\"\nconst existingOnboardedAt = await getUserOnboardedAt(userId)\nif (existingOnboardedAt) { /* ... */ }\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-003",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L19-L46, L90-L174, L222-L304, L306-L396",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "data-access 含大量业务逻辑状态机、冲突检测、抽签算法、i18n 通知)",
"description": "文件混入了大量本应位于 actions 或 lib 的业务逻辑:\n1. L19-L46 自定义 `ElectiveBusinessError` 业务错误类(含 i18n code 映射)\n2. L62-L78 `DAY_NORMALIZE_MAP` 星期归一化映射\n3. L90-L119 `parseSchedule` 时间段解析(含正则)\n4. L125-L131 `isScheduleConflict` 时间冲突判定\n5. L137-L174 `checkScheduleConflict` 业务校验\n6. L185-L220 `checkCreditLimit` 学分上限业务校验\n7. L222-L304 `runLottery` Fisher-Yates 抽签算法\n8. L410-L446 `notifyCapacityThresholdIfNeeded` 调用 `getTranslations` + `sendNotification` 跨模块通知\ndata-access 层应只做 CRUD 与简单映射,业务规则、状态机、跨模块编排应下沉到 lib 或 actions。",
"recommendation": "拆分为三层:\n1. `elective/lib/schedule-conflict.ts` —— 纯函数 `parseSchedule` / `isScheduleConflict` / `normalizeDay`\n2. `elective/lib/lottery.ts` —— `runLotteryShuffle` 纯函数\n3. `elective/lib/business-rules.ts` —— `checkScheduleConflict` / `checkCreditLimit` / `ElectiveBusinessError`(接受 tx 与必要 data-access 函数作为参数)\n4. `elective/actions.ts` —— `notifyCapacityThresholdIfNeeded` 移至 actions含 i18n + sendNotification\ndata-access-operations.ts 只保留 `selectCourse` / `dropCourse` / `runLottery` 的 DB 写入部分。",
"effort": "L (≤1 天)"
},
{
"id": "G5-004",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L222-L304",
"ruleId": "S-08",
"severity": "P1",
"dimension": "structure",
"title": "runLottery 中 DB 写入与抽签算法混淆,职责不清",
"description": "`runLottery` 函数同时承担1) 查询课程与选课记录DB 访问2) Fisher-Yates shuffle 抽签业务算法3) 事务内批量更新状态DB 写入)。函数 83 行,复杂度高,难以单元测试抽签逻辑(需 mock DB。",
"recommendation": "拆分为:\n```ts\n// lib/lottery.ts\nexport function runLotteryShuffle(selections: CourseSelection[], capacity: number): {\n enrolledIds: string[]; waitlistIds: string[]\n} { /* 纯函数 Fisher-Yates */ }\n\n// data-access-operations.ts\nexport async function persistLotteryResult(\n courseId: string, enrolledIds: string[], waitlistIds: string[], capacity: number\n): Promise<void> { /* 仅 DB 写入 */ }\n\n// actions.ts\nexport async function runLotteryAction(...) {\n const [course, selections] = await Promise.all([...])\n const { enrolledIds, waitlistIds } = runLotteryShuffle(selections, course.capacity)\n await persistLotteryResult(courseId, enrolledIds, waitlistIds, course.capacity)\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-005",
"file": "src/modules/files/data-access.ts",
"lines": "L55, L73, L101, L126, L150, L169, L186, L195, L244, L281, L305, L328",
"ruleId": "A-10",
"severity": "P1",
"dimension": "architecture",
"title": "data-access 含 12 处 console.error 调试代码",
"description": "文件中几乎所有函数都用 `console.error(...)` 记录错误L55 `createFileAttachment failed`、L73 `getFileAttachment failed`、L101 `getFileAttachmentsByTarget failed`、L126 `getFileAttachmentsByUploader failed`、L150 `getAllFileAttachments failed`、L169 `deleteFileAttachment failed`、L186/L195 `deleteFileAttachments batch/single failed`、L244 `getFileAttachmentsWithFilters failed`、L281 `getFileStats failed`、L305 `getFileByUrl failed`、L328 `getFileAttachmentsByIds failed`。data-access 层应通过 throw 上抛错误由 actions 层统一处理,不应自行 console 输出,污染生产日志且违反 A-10 规则。",
"recommendation": "删除所有 `console.error`,改为 throw 上抛:\n```ts\nexport async function createFileAttachment(data: CreateFileAttachmentInput): Promise<FileAttachment | null> {\n await db.insert(fileAttachments).values({...})\n return getFileAttachment(data.id)\n}\n// actions 层已有 handleActionError 统一处理\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-006",
"file": "src/modules/files/data-access.ts",
"lines": "L35-L58, L63-L76, L87-L105, L116-L129, L140-L153, L212-L248, L259-L284, L295-L308, L319-L331",
"ruleId": "P-05",
"severity": "P1",
"dimension": "pattern",
"title": "data-access 层用 try-catch 返回 null/false/空数组,违反 throw 上抛约定",
"description": "所有函数均用 `try { ... } catch (error) { console.error(...); return null/[]/false }` 模式吞掉错误。这导致1) actions 层无法区分“记录不存在”与“DB 异常”2) 错误被静默吞掉监控告警失效3) 违反 P-05 规则data-access 层用 throwactions 层用 ActionState。例如 createFileAttachment 失败返回 nullactions 层只能返回“Failed to persist file record”丢失原始错误信息。",
"recommendation": "移除 try-catch让错误自然上抛\n```ts\nexport async function getFileAttachmentRaw(id: string): Promise<FileAttachment | null> {\n const [row] = await db.select().from(fileAttachments)\n .where(eq(fileAttachments.id, id)).limit(1)\n return row ? mapRow(row) : null\n}\n// actions.ts 的 handleActionError 会捕获并转为 ActionState\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-007",
"file": "src/modules/files/data-access.ts",
"lines": "L66, L89, L119, L142, L236, L262, L298, L322",
"ruleId": "F-03",
"severity": "P1",
"dimension": "performance",
"title": "多处 `db.select().from(fileAttachments)` 未指定列,等价 SELECT *",
"description": "8 处查询使用 `db.select().from(fileAttachments)` 未显式枚举列,等价于 `SELECT *`。返回所有列包括 `storagePath`、`url` 等敏感字段,增加网络传输与内存占用,且 schema 变更时可能引入意外字段。对比 announcements/data-access.ts L84-L99 显式枚举了 13 个列。",
"recommendation": "显式枚举所需列:\n```ts\nconst FILE_FIELDS = {\n id: fileAttachments.id,\n filename: fileAttachments.filename,\n originalName: fileAttachments.originalName,\n mimeType: fileAttachments.mimeType,\n size: fileAttachments.size,\n storagePath: fileAttachments.storagePath,\n url: fileAttachments.url,\n uploaderId: fileAttachments.uploaderId,\n targetType: fileAttachments.targetType,\n targetId: fileAttachments.targetId,\n createdAt: fileAttachments.createdAt,\n} as const\n\nexport const getFileAttachmentRaw = async (id: string) => {\n const [row] = await db.select(FILE_FIELDS).from(fileAttachments)\n .where(eq(fileAttachments.id, id)).limit(1)\n return row ? mapRow(row) : null\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-008",
"file": "src/modules/search/data-access.ts",
"lines": "L150, L191, L235",
"ruleId": "P-09",
"severity": "P1",
"dimension": "pattern",
"title": "使用 `!` 非空断言绕过 null 检查",
"description": "三处对 `or(...)` 返回值使用 `!` 非空断言L150 `or(like(textbooks.title, kw), like(textbooks.subject, kw), like(textbooks.publisher, kw))!`、L191 `or(like(exams.title, kw), like(exams.description, kw))!`、L235 `or(like(announcements.title, kw), like(announcements.content, kw))!`。`or()` 在所有参数为 undefined 时返回 null使用 `!` 断言会绕过类型系统的安全保护,违反 P-09 规则(禁止 as 断言与非空断言,除 unknown 收窄)。",
"recommendation": "使用条件判断或 filter 模式:\n```ts\nconst conditions = [\n like(textbooks.title, kw),\n like(textbooks.subject, kw),\n like(textbooks.publisher, kw),\n].filter(Boolean) as ReturnType<typeof like>[]\nconst where = conditions.length > 0 ? or(...conditions) : undefined\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-009",
"file": "src/modules/search/data-access.ts",
"lines": "L147-L150, L191, L235",
"ruleId": "F-02",
"severity": "P1",
"dimension": "performance",
"title": "textbooks/exams/announcements 三表搜索使用 `LIKE '%kw%'` 全表扫描",
"description": "searchTextbooksRaw L147-L150、searchExamsRaw L191、searchAnnouncementsRaw L235 均使用 `like(column, kw)` 其中 kw 为 `%${search}%` 格式,前缀通配符导致无法走索引,全表扫描。注释 L18-L20 已说明 questions 表用了 FULLTEXT 索引L101 `MATCH ... AGAINST ... IN BOOLEAN MODE`),但其他三表仍用 LIKE。随着数据量增长搜索性能会急剧下降。",
"recommendation": "为 textbooks.title/subject/publisher、exams.title/description、announcements.title/content 添加 FULLTEXT 索引MySQL或 GIN 索引PostgreSQL改用 MATCH AGAINST\n```sql\nALTER TABLE textbooks ADD FULLTEXT INDEX ft_textbooks_search (title, subject, publisher);\nALTER TABLE exams ADD FULLTEXT INDEX ft_exams_search (title, description);\nALTER TABLE announcements ADD FULLTEXT INDEX ft_announcements_search (title, content);\n```\n```ts\n.where(sql`MATCH(${textbooks.title}, ${textbooks.subject}, ${textbooks.publisher}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)\n```",
"effort": "L (≤1 天)"
},
{
"id": "G5-010",
"file": "src/modules/onboarding/data-access.ts",
"lines": "L87-L133",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "bindParentToChild 含三因子验证业务逻辑,应移至 lib 或 actions",
"description": "`bindParentToChild` 函数 L87-L133 包含1) 三因子验证业务规则(邮箱 + 生日 + 手机后4位L97-L1112) 幂等检查L114-L1313) 错误返回 `{ error: string }` 而非 throw。这是核心业务逻辑对标 PowerSchool Access ID 验证),不应位于 data-access 层。data-access 应只做 CRUD验证规则应下沉到 lib/business-rules 或 actions。",
"recommendation": "拆分:\n1. `onboarding/lib/parent-binding.ts` —— `validateChildFactors(child, params): string | null` 纯函数验证三因子\n2. `onboarding/data-access.ts` —— `insertParentStudentRelation(parentId, studentId, relation): Promise<void>` 仅做幂等插入\n3. `onboarding/actions.ts` —— 编排:查询 child → 调用 validateChildFactors → 调用 insertParentStudentRelation",
"effort": "M (≤2 小时)"
},
{
"id": "G5-011",
"file": "src/modules/settings/data-access.ts",
"lines": "L23-L38",
"ruleId": "F-05",
"severity": "P1",
"dimension": "performance",
"title": "getAiProviderSummariesRaw 查询所有 AI Provider 无 LIMIT",
"description": "`getAiProviderSummariesRaw` 查询 `aiProviders` 表全部记录,仅 `.orderBy(desc(aiProviders.updatedAt))`,无 LIMIT 限制。若管理员未清理历史 Provider 记录,可能返回数百条数据。对比 files/data-access.ts 的 `getAllFileAttachmentsRaw` 有默认 `limit = 100`。",
"recommendation": "添加默认 LIMIT\n```ts\nexport async function getAiProviderSummariesRaw(limit = 200): Promise<AiProviderSummary[]> {\n const rows = await db.select({...}).from(aiProviders)\n .orderBy(desc(aiProviders.updatedAt))\n .limit(limit)\n return rows\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-012",
"file": "src/modules/settings/data-access-system-settings.ts",
"lines": "L131-L142",
"ruleId": "F-01",
"severity": "P1",
"dimension": "performance",
"title": "upsertSystemSettings 循环内调用 upsertSystemSetting每次含查询+写入N+1 问题",
"description": "`upsertSystemSettings` L140-L142 对每个 item 调用 `upsertSystemSetting`,而 `upsertSystemSetting` 内部 L110-L126 先 `getSystemSetting`(虽走缓存)再 `db.update` 或 `db.insert`。批量保存 N 个设置项时执行 N 次独立写入,无事务包裹,且中间失败会导致部分成功部分失败的数据不一致。",
"recommendation": "改为单次事务批量 upsertMySQL `INSERT ... ON DUPLICATE KEY UPDATE`\n```ts\nexport async function upsertSystemSettings(\n items: ReadonlyArray<{...}>, updatedBy?: string\n): Promise<void> {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n const rows = items.map((item) => ({\n category: item.category, key: item.key, value: item.value,\n valueType: item.valueType, updatedBy: updatedBy ?? null, updatedAt: new Date(),\n }))\n await tx.insert(systemSettings).values(rows)\n .onDuplicateKeyUpdate({\n set: { value: sql`VALUES(value)`, valueType: sql`VALUES(value_type)`,\n updatedBy: sql`VALUES(updated_by)`, updatedAt: sql`VALUES(updated_at)` }\n })\n })\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-013",
"file": "src/modules/elective/data-access.ts",
"lines": "L165-L169",
"ruleId": "F-05",
"severity": "P1",
"dimension": "performance",
"title": "getElectiveCoursesRaw 大表查询无默认 LIMIT",
"description": "`getElectiveCoursesRaw` 查询 `electiveCourses` 表,仅 `.orderBy(desc(electiveCourses.createdAt))`,无 LIMIT。当管理员查询全部课程或 gradeId 过滤返回大量记录时,会一次性返回所有匹配行。对比 announcements/data-access.ts L104 有 `pageSize` 分页。",
"recommendation": "添加默认 LIMIT 或分页参数:\n```ts\nexport const getElectiveCoursesRaw = async (\n params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string; limit?: number }\n): Promise<ElectiveCourseWithDetails[]> => {\n const limit = params?.limit ?? 200\n // ...\n const rows = await (conditions.length > 0\n ? query.where(and(...conditions)) : query\n ).orderBy(desc(electiveCourses.createdAt)).limit(limit)\n // ...\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-014",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L228-L241",
"ruleId": "F-03",
"severity": "P1",
"dimension": "performance",
"title": "runLottery 内 `db.select().from(...)` 未指定列,等价 SELECT *",
"description": "`runLottery` L228-L241 两处 `db.select().from(electiveCourses)` 和 `db.select().from(courseSelections)` 未指定列,等价 SELECT *。electiveCourses 表含 description、schedule 等长文本字段courseSelections 含 dropReason 等字段,全量返回浪费内存与网络带宽。此外 selectCourse L317-L321、dropCourse L454-L464 也使用 `db.select().from(...)` 全列查询。",
"recommendation": "显式枚举所需列:\n```ts\nconst [courseRows, selections] = await Promise.all([\n db.select({\n id: electiveCourses.id, capacity: electiveCourses.capacity,\n selectionMode: electiveCourses.selectionMode, enrolledCount: electiveCourses.enrolledCount,\n }).from(electiveCourses).where(eq(electiveCourses.id, courseId)).limit(1),\n db.select({\n id: courseSelections.id, priority: courseSelections.priority,\n selectedAt: courseSelections.selectedAt,\n }).from(courseSelections).where(...).orderBy(...),\n])\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-015",
"file": "src/modules/error-book/data-access.ts",
"lines": "L135-L138",
"ruleId": "F-02",
"severity": "P2",
"dimension": "performance",
"title": "getErrorBookItemsRaw 使用 `LIKE '%q%'` 全表扫描搜索 note 字段",
"description": "L136-L137 构造 `needle = '%${q.trim().toLowerCase()}%'` 并使用 `sql\\`LOWER(CAST(${errorBookItems.note} AS CHAR)) LIKE ${needle}\\``。前缀通配符 `%` 导致无法走索引,且 `LOWER(CAST(... AS CHAR))` 函数包裹进一步阻止索引使用。学生错题量增长后搜索性能下降。",
"recommendation": "为 errorBookItems.note 添加 FULLTEXT 索引,或改为前缀匹配 `LIKE '${q}%'`(可走索引):\n```sql\nALTER TABLE error_book_items ADD FULLTEXT INDEX ft_note_search (note);\n```\n```ts\nif (q && q.trim().length > 0) {\n conditions.push(sql`MATCH(${errorBookItems.note}) AGAINST(${q.trim()} IN BOOLEAN MODE)`)\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-016",
"file": "src/modules/error-book/data-access-analytics.ts",
"lines": "L531, L542",
"ruleId": "P-09",
"severity": "P2",
"dimension": "pattern",
"title": "使用 `as string` 断言,应改用类型守卫",
"description": "L531 `const subjectIds = filtered.map((r) => r.subjectId as string)` 和 L542 `const sid = row.subjectId as string` 使用 `as string` 断言。虽然 L527 已通过 `rows.filter((r) => r.subjectId !== null)` 过滤了 null但 `as` 断言绕过了类型系统,违反 P-09 规则。项目规范要求用类型守卫替代 as 断言。",
"recommendation": "使用类型守卫:\n```ts\nconst subjectIds = filtered\n .map((r) => r.subjectId)\n .filter((s): s is string => s !== null)\n// ...\nreturn filtered.map((row) => {\n const sid = row.subjectId as string // 改为:\n const sid: string = row.subjectId ?? \"\" // 或提前 filter\n // ...\n})\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-017",
"file": "src/modules/settings/data-access.ts",
"lines": "L259-L315",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "密码相关读函数未走 cacheFn 包装Raw + Wrapper 配对)",
"description": "`getUserPasswordHash` (L259-L268)、`getPasswordSecurityByUserId` (L270-L279) 是读函数但未提供 Raw + cacheFn Wrapper 配对。对比同文件 `getAiProviderSummariesRaw` + `getAiProviderSummaries` 配对模式。密码相关查询虽可能不希望缓存(安全考虑),但应明确注释说明不缓存的原因,或提供短 TTL 缓存。",
"recommendation": "明确注释不缓存原因,或提供短 TTL 缓存:\n```ts\n/** 读取用户密码哈希(不缓存,安全敏感数据) */\nexport async function getUserPasswordHash(userId: string): Promise<{ password: string | null } | null> {\n // 安全考虑:密码哈希不缓存,避免缓存泄露风险\n const [row] = await db.select({ password: users.password })\n .from(users).where(eq(users.id, userId)).limit(1)\n return row ?? null\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-018",
"file": "src/modules/settings/data-access-two-factor.ts",
"lines": "L30-L129",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "2FA 读函数未提供 Raw + Wrapper 配对,依赖上游 cacheFn",
"description": "`getTwoFactorEnabled` (L30-L33)、`getTwoFactorEnabledAt` (L48-L53)、`getTotpSecret` (L70-L73)、`getBackupCodesHashed` (L101-L105) 等读函数均直接调用 `getSystemSetting`(已 cacheFn 包装),但自身未提供 Raw + Wrapper 配对。严格按 P-03 规则,所有读函数应有 Raw + cacheFn 配对。当前模式虽依赖上游缓存,但 keyParts 不会包含 2FA 特定维度,缓存粒度不准确。",
"recommendation": "为 2FA 读函数提供独立 cacheFn 配对:\n```ts\nexport const getTwoFactorEnabledRaw = async (userId: string): Promise<boolean> => {\n const record = await getSystemSetting(CATEGORY, k(\"twoFactorEnabled\", userId))\n return record?.value === \"true\"\n}\nexport const getTwoFactorEnabled = cacheFn(getTwoFactorEnabledRaw, {\n tags: [\"settings\", \"two-factor\"], ttl: 60,\n keyParts: [\"settings\", \"two-factor\", \"enabled\"],\n})\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-019",
"file": "src/modules/elective/data-access.ts",
"lines": "L61-L62",
"ruleId": "P-07",
"severity": "P2",
"dimension": "pattern",
"title": "startDate/endDate 序列化未走 toIso helper硬编码 slice(0, 10)",
"description": "L61-L62 `startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null` 和 `endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null` 硬编码了 `.toISOString().slice(0, 10)` 逻辑。同文件 L22-L25 已定义 `toIso`/`toIsoRequired` helper但此处未复用且 `slice(0, 10)` 截取日期部分的行为应封装为 `toISODateString` helper 统一管理。",
"recommendation": "提取共享 helper 并复用:\n```ts\nconst toISODateString = (d: Date | null | undefined): string | null =>\n d ? d.toISOString().slice(0, 10) : null\n\n// mapCourseRow 中\nstartDate: toISODateString(r.startDate),\nendDate: toISODateString(r.endDate),\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-020",
"file": "src/modules/announcements/data-access.ts",
"lines": "L413-L463, L472-L485",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "resolveUserAudience / isAnnouncementVisibleToAudience 含业务逻辑",
"description": "`resolveUserAudience` (L413-L463) 根据 dataScope.type 分支处理 5 种受众类型,含 classIds → gradeIds 解析、childrenIds 遍历等业务编排逻辑;`isAnnouncementVisibleToAudience` (L472-L485) 是纯业务规则判断函数。两者属于业务规则层,应移至 lib 或 actions。当前 data-access 同时承担数据查询与业务规则判断,职责混淆。",
"recommendation": "将业务逻辑下沉到 `announcements/lib/audience.ts`\n```ts\n// lib/audience.ts\nexport function isAnnouncementVisibleToAudience(announcement, audience): boolean { /* 纯函数 */ }\nexport async function resolveUserAudience(\n userId: string, dataScope: DataScope,\n deps: { getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId }\n): Promise<UserAudience | null> { /* 接受依赖注入 */ }\n```\ndata-access 仅保留 `getAnnouncementByIdForUser` 的数据查询部分。",
"effort": "M (≤2 小时)"
},
{
"id": "G5-021",
"file": "src/modules/announcements/data-access.ts",
"lines": "全文 606 行",
"ruleId": "S-01",
"severity": "P2",
"dimension": "structure",
"title": "文件 606 行,接近 800 行警告线",
"description": "announcements/data-access.ts 共 606 行,已超过项目规范的“建议 ≤ 800 行”软上限的 75%。文件同时包含CRUDinsertAnnouncement 等、分页查询getAnnouncements、已读回执markAnnouncementAsRead 等、编排函数getAdminAnnouncementsPageData 等 5 个、业务逻辑resolveUserAudience 等)。继续增长将突破警告线。",
"recommendation": "拆分为:\n1. `data-access.ts` —— 纯 CRUDinsert/update/delete/publish/archive\n2. `data-access-reads.ts` —— 查询函数getAnnouncements, getAnnouncementById, countAnnouncements, 已读回执查询)\n3. `data-access-page.ts` —— 编排函数getAdminAnnouncementsPageData 等)\n4. `lib/audience.ts` —— resolveUserAudience, isAnnouncementVisibleToAudience",
"effort": "M (≤2 小时)"
},
{
"id": "G5-022",
"file": "src/modules/settings/data-access-system-settings.ts",
"lines": "L65-L68",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "getAllSystemSettingsRaw 使用 `db.select().from(systemSettings)` 未指定列",
"description": "`getAllSystemSettingsRaw` L66 `const rows = await db.select().from(systemSettings)` 未显式枚举列,等价 SELECT *。systemSettings 表含 id、category、key、value、valueType、updatedBy、updatedAt、createdAt 等列,全量返回增加传输开销。对比 getSystemSettingRaw L83-L87 虽也未枚举但有 limit(1)。",
"recommendation": "显式枚举列:\n```ts\nexport async function getAllSystemSettingsRaw(): Promise<SystemSettingRecord[]> {\n const rows = await db.select({\n id: systemSettings.id, category: systemSettings.category,\n key: systemSettings.key, value: systemSettings.value,\n valueType: systemSettings.valueType, updatedBy: systemSettings.updatedBy,\n updatedAt: systemSettings.updatedAt,\n }).from(systemSettings)\n return rows\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-023",
"file": "src/modules/elective/data-access-selections.ts",
"lines": "L43-L46",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "toIso/toIsoRequired helper 在 elective 模块内重复定义",
"description": "L43-L46 定义了 `toIso` 和 `toIsoRequired` helper但 elective/data-access.ts L22-L25 已定义了相同的 helper。两处实现完全一致\n```ts\nconst toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null\nconst toIsoRequired = (d: Date): string => d.toISOString()\n```\n违反 DRY 原则,应提取到 shared/lib 或模块内 lib 目录。",
"recommendation": "提取到 `elective/lib/date-utils.ts` 或复用 `@/shared/lib/date-utils`\n```ts\n// elective/lib/date-utils.ts\nexport const toIso = (d: Date | null | undefined): string | null =>\n d ? d.toISOString() : null\nexport const toIsoRequired = (d: Date): string => d.toISOString()\nexport const toISODateString = (d: Date | null | undefined): string | null =>\n d ? d.toISOString().slice(0, 10) : null\n```\n两个 data-access 文件统一 import。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-024",
"file": "src/modules/onboarding/data-access.ts",
"lines": "L89, L97-L111",
"ruleId": "P-05",
"severity": "P2",
"dimension": "pattern",
"title": "bindParentToChild 返回 `{ error: string }` 而非 throw违反 data-access 错误处理约定",
"description": "`bindParentToChild` 返回类型为 `Promise<{ studentId: string } | { error: string }>`L97/L98/L99/L104/L110 用 `return { error: \"...\" }` 表示业务错误。按 P-05 规则data-access 层应用 throw 上抛错误(如 `throw new BusinessError(...)`actions 层用 ActionState 捕获。当前模式让调用方需要用 `\"error\" in result` 判断,违反统一错误处理约定。",
"recommendation": "改用 throw + 自定义 BusinessError\n```ts\nexport class OnboardingBusinessError extends BusinessError {\n constructor(public readonly code: string, public readonly params?: Record<string, string | number>) {\n super(`onboarding.errors.${code}`, code)\n }\n}\n\nexport async function bindParentToChild(params: BindParentToChildParams): Promise<{ studentId: string }> {\n // ...\n if (!child) throw new OnboardingBusinessError(\"childNotFound\")\n if (!child.birthDate) throw new OnboardingBusinessError(\"childNoBirthDate\")\n // ...\n return { studentId: child.id }\n}\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-025",
"file": "src/modules/settings/data-access.ts",
"lines": "L259-L315",
"ruleId": "S-06",
"severity": "P2",
"dimension": "structure",
"title": "密码相关函数缺 JSDoc 文档",
"description": "`getUserPasswordHash` (L259)、`getPasswordSecurityByUserId` (L270)、`updateUserPassword` (L281)、`upsertPasswordSecurityOnPasswordChange` (L292) 四个公共导出函数均无 JSDoc 注释。对比同文件 `getAiProviderSummariesRaw` (L18-L22)、`getAiProviderForUpdateRaw` (L102-L106) 均有 JSDoc。S-06 规则要求公共导出函数补齐 JSDoc。",
"recommendation": "为每个函数添加 JSDoc\n```ts\n/**\n * 读取用户密码哈希(用于密码变更时校验旧密码)\n * @param userId 用户 ID\n * @returns 密码哈希记录,用户不存在时返回 null\n */\nexport async function getUserPasswordHash(userId: string): Promise<{ password: string | null } | null> { ... }\n\n/**\n * 查询用户的密码安全记录(用于判断是否需要强制改密)\n * @param userId 用户 ID\n * @returns 记录 ID存在时不存在返回 null\n */\nexport async function getPasswordSecurityByUserId(userId: string): Promise<{ id: string } | null> { ... }\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-026",
"file": "src/modules/onboarding/actions.ts",
"lines": "L38, L68",
"ruleId": "A-08",
"severity": "P3",
"dimension": "architecture",
"title": "使用 requireAuth 而非 requirePermission权限校验粒度不足",
"description": "`getOnboardingStatusAction` L38 和 `completeOnboardingAction` L68 使用 `requireAuth()` 而非 `requirePermission(Permissions.XXX)`。`requireAuth` 仅校验登录态不校验具体权限点。onboarding 流程虽面向所有已登录用户,但按 A-08 规则应使用 requirePermission 显式声明权限点(如 ONBOARDING_READ / ONBOARDING_COMPLETE便于权限审计与角色-权限矩阵管理。",
"recommendation": "添加 ONBOARDING 权限点并使用 requirePermission\n```ts\n// shared/types/permissions.ts\nexport const Permissions = {\n // ...\n ONBOARDING_READ: \"onboarding:read\",\n ONBOARDING_COMPLETE: \"onboarding:complete\",\n} as const\n\n// actions.ts\nconst ctx = await requirePermission(Permissions.ONBOARDING_COMPLETE)\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-027",
"file": "src/modules/elective/data-access-settings.ts",
"lines": "L58-L73",
"ruleId": "F-06",
"severity": "P2",
"dimension": "performance",
"title": "getElectiveCreditLimitRaw 串行两次 readSettingValue可合并查询",
"description": "`getElectiveCreditLimitRaw` L62-L73 先查 `creditLimit:grade:<gradeId>`,若未命中再查 `creditLimit:default`,两次串行 DB 查询。虽每次有 cacheFn但首次未命中缓存时仍需 2 次往返。可用 OR 查询合并为单次:\n```ts\nconst [gradeRow, defaultRow] = await Promise.all([\n readSettingValue(`creditLimit:grade:${gradeId}`),\n readSettingValue(\"creditLimit:default\"),\n])\n```",
"recommendation": "改为 Promise.all 并行查询:\n```ts\nexport const getElectiveCreditLimitRaw = async (gradeId?: string | null): Promise<number> => {\n if (gradeId) {\n const [gradeValue, defaultValue] = await Promise.all([\n readSettingValue(`creditLimit:grade:${gradeId}`),\n readSettingValue(\"creditLimit:default\"),\n ])\n if (gradeValue !== null) {\n const parsed = Number(gradeValue)\n if (!Number.isNaN(parsed) && parsed > 0) return parsed\n }\n if (defaultValue !== null) {\n const parsed = Number(defaultValue)\n if (!Number.isNaN(parsed) && parsed > 0) return parsed\n }\n return DEFAULT_MAX_CREDIT_PER_TERM\n }\n // ...\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-028",
"file": "src/modules/elective/data-access-selections.ts",
"lines": "L107-L109, L123-L125",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getCourseSelectionsRaw / getStudentSelectionsRaw 无 LIMIT",
"description": "`getCourseSelectionsRaw` L107-L109 按 courseId 查询选课记录,`getStudentSelectionsRaw` L123-L125 按 studentId 查询,均无 LIMIT。教师视角下热门课程可能有数百条选课记录学生视角下四年累计选课也可能较多。建议添加默认 LIMIT 防止极端情况。",
"recommendation": "添加默认 LIMIT\n```ts\nexport const getCourseSelectionsRaw = async (courseId: string): Promise<CourseSelectionWithDetails[]> => {\n const rows = await buildSelectionCoreSelect()\n .where(eq(courseSelections.courseId, courseId))\n .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))\n .limit(500) // 默认上限\n // ...\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-029",
"file": "src/modules/files/data-access.ts",
"lines": "L224-L230",
"ruleId": "F-02",
"severity": "P2",
"dimension": "performance",
"title": "getFileAttachmentsWithFiltersRaw 使用 `LIKE '%search%'` 全表扫描",
"description": "L225 `const kw = '%${search}%'`L227-L229 `like(fileAttachments.originalName, kw)` 和 `like(fileAttachments.filename, kw)` 使用前缀通配符 `%`,无法走索引。管理员文件管理页面搜索文件时全表扫描 fileAttachments 表。",
"recommendation": "为 originalName 和 filename 添加 FULLTEXT 索引,或改为前缀匹配:\n```sql\nALTER TABLE file_attachments ADD FULLTEXT INDEX ft_filename_search (original_name, filename);\n```\n```ts\nif (search) {\n conditions.push(sql`MATCH(${fileAttachments.originalName}, ${fileAttachments.filename}) AGAINST(${search} IN BOOLEAN MODE)`)\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-030",
"file": "src/modules/announcements/data-access.ts",
"lines": "L343-L344, L366-L367, L426, L438, L454, L575, L592, L597",
"ruleId": "A-06",
"severity": "P3",
"dimension": "architecture",
"title": "大量使用 dynamic import 调用跨模块 data-access建议改为静态 import",
"description": "文件中 8 处使用 `await import(\"@/modules/xxx/data-access\")` 动态导入L343 `getGrades`、L344 `getAdminClasses`、L366 `getGrades`、L426 `getClassGradeId`、L438 `getStudentActiveClassId`/`getStudentActiveGradeId`、L454 同上、L575 `getAllUserIds`、L592 `getUserIdsByGradeId`、L597 `getStudentIdsByClassId`/`getTeacherIdsByClassIds`。动态 import 增加运行时开销,且无法被构建工具静态分析优化。虽可能为避免循环依赖,但 announcements 与 classes/users/school 模块间无循环依赖风险。",
"recommendation": "改为静态 import\n```ts\nimport { getGrades } from \"@/modules/school/data-access\"\nimport { getAdminClasses, getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId, getStudentIdsByClassId, getTeacherIdsByClassIds } from \"@/modules/classes/data-access\"\nimport { getAllUserIds, getUserIdsByGradeId } from \"@/modules/users/data-access\"\n```\n若确有循环依赖应重构模块边界而非用 dynamic import 规避。",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-031",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L378-L379, L410-L446",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "notifyCapacityThresholdIfNeeded 调用 getTranslations + sendNotification含 i18n 与通知编排",
"description": "`notifyCapacityThresholdIfNeeded` L410-L446 在 data-access 层调用 `getTranslations(\"elective\")` (L421) 获取 i18n 文案,并调用 `sendNotification` (L430) 发送跨模块通知。i18n 与通知编排属于业务逻辑层职责,不应位于 data-access。此外 L379 在事务内 `void notifyCapacityThresholdIfNeeded(course, newEnrolledCount)` 触发 fire-and-forget虽不阻塞事务但 data-access 层不应有副作用编排。",
"recommendation": "移至 actions 层:\n```ts\n// data-access-operations.ts\nexport async function selectCourse(courseId, studentId, priority?): Promise<{ status: CourseSelectionStatus; course: Course }> {\n // ... 返回 course 与 newEnrolledCount 供 actions 判断\n}\n\n// actions.ts\nconst { status, course, newEnrolledCount } = await selectCourse(...)\nif (status === \"enrolled\") {\n await notifyCapacityThresholdIfNeeded(course, newEnrolledCount)\n}\nawait invalidateFor(...)\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-032",
"file": "src/modules/settings/data-access.ts",
"lines": "L153-L171, L188-L205",
"ruleId": "F-09",
"severity": "P3",
"dimension": "performance",
"title": "updateAiProvider / createAiProvider 事务内含条件分支但范围合理",
"description": "`updateAiProvider` (L153-L171) 和 `createAiProvider` (L188-L205) 在事务内执行1) 可选的重置其他默认 Provider2) 主表 update/insert。事务范围仅含 DB 操作,无网络调用,范围合理。但 `resetOtherDefaults` 在事务内全表 update当 Provider 数量多时可能锁表。标记为 P3 提示关注。",
"recommendation": "可接受现状。若 Provider 数量增长可考虑1) 添加 WHERE 过滤条件缩小 update 范围2) 用乐观锁替代事务。当前实现合理,无需立即修改。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-033",
"file": "src/modules/error-book/data-access.ts",
"lines": "L400-L438",
"ruleId": "A-02",
"severity": "P3",
"dimension": "architecture",
"title": "recordReview 含 SM-2 算法应用逻辑,处于边界",
"description": "`recordReview` L409-L413 调用 `calculateNewInterval`、`calculateNewMastery`、`deriveStatus`、`calculateNextReviewAt`、`calculateNewCorrectStreak` 计算 SM-2 算法派生值。这些计算虽是业务逻辑,但已封装在 `sm2-algorithm.ts` 纯函数模块中data-access 仅调用并持久化结果。处于可接受边界,但严格按 A-02 规则,算法应用应位于 lib 或 actions。",
"recommendation": "可接受现状。若严格遵循规则,可将 SM-2 计算移至 `error-book/lib/review.ts`data-access 仅接受计算结果并持久化:\n```ts\n// lib/review.ts\nexport function computeReviewResult(item, result): { newInterval, newMastery, newStatus, nextReviewAt } { ... }\n\n// data-access.ts\nconst reviewResult = computeReviewResult(item, result)\nawait db.transaction(async (tx) => { /* 持久化 reviewResult */ })\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-034",
"file": "src/modules/ai/data-access.ts",
"lines": "L33-L49, L63-L139",
"ruleId": "A-02",
"severity": "P3",
"dimension": "architecture",
"title": "内存事件存储eventStore位于 data-access应为独立 service",
"description": "L35 `const eventStore: StoredAiEvent[] = []` 模块级内存数组L43-L49 `recordAiEvent` 写入函数L63-L139 `getAiUsageStatsRaw` 聚合统计。data-access 层应封装 DB 访问,而内存事件存储是临时实现(注释 L9-L18 说明生产环境应替换为 DB/Redis。当前实现虽可工作但混合了存储实现与数据访问接口。",
"recommendation": "提取为独立 service\n```ts\n// ai/services/usage-tracker-store.ts\nconst eventStore: StoredAiEvent[] = []\nexport function recordAiEvent(event: StoredAiEvent): void { ... }\n\n// ai/data-access.ts\nimport { eventStore } from \"./services/usage-tracker-store\"\nexport async function getAiUsageStatsRaw(): Promise<AiUsageStats> { ... }\n```\n注释已说明是临时实现生产替换为 DB 后此问题自动消失。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-035",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L62-L78",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "DAY_NORMALIZE_MAP 常量与 normalizeDay 函数缺模块级 JSDoc",
"description": "L62-L78 `DAY_NORMALIZE_MAP` 常量虽有注释说明用途,但 `normalizeDay` 函数 (L76-L78) 无 JSDoc。此函数被 `isScheduleConflict` 内部调用,是冲突检测的核心。按 S-06 规则,公共导出函数应补齐 JSDoc。虽然 `normalizeDay` 未导出,但作为可测试纯函数建议导出并补 JSDoc。",
"recommendation": "添加 JSDoc 并考虑导出便于测试:\n```ts\n/**\n * 将星期字符串归一化为 1-7 数字字符串。\n * 支持中文(周一/星期一、英文全称monday、英文缩写mon。\n * @param day 原始星期字符串\n * @returns 归一化后的 1-7 数字字符串,无法识别时返回原值\n */\nexport function normalizeDay(day: string): string {\n return DAY_NORMALIZE_MAP[day.toLowerCase()] ?? day\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-036",
"file": "src/modules/elective/data-access-selections.ts",
"lines": "L104-L112, L120-L128",
"ruleId": "P-08",
"severity": "P3",
"dimension": "pattern",
"title": "getCourseSelectionsRaw / getStudentSelectionsRaw 重复 map+resolve 模式",
"description": "`getCourseSelectionsRaw` L107-L111 和 `getStudentSelectionsRaw` L123-L127 重复了相同的模式:`buildSelectionCoreSelect().where(...).orderBy(...)` → `resolveStudentDisplayNames(rows)` → `rows.map((r) => mapSelectionRow(r, studentNames))`。仅 where 条件和 orderBy 不同,可提取为共享 helper。",
"recommendation": "提取共享查询 helper\n```ts\nasync function querySelectionsWithDisplayNames(\n where: SQL, orderBy: SQL\n): Promise<CourseSelectionWithDetails[]> {\n const rows = await buildSelectionCoreSelect().where(where).orderBy(orderBy)\n const studentNames = await resolveStudentDisplayNames(rows)\n return rows.map((r) => mapSelectionRow(r, studentNames))\n}\n\nexport const getCourseSelectionsRaw = async (courseId: string) =>\n querySelectionsWithDisplayNames(\n eq(courseSelections.courseId, courseId),\n asc(courseSelections.priority)\n )\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-037",
"file": "src/modules/announcements/data-access.ts",
"lines": "L122-L157",
"ruleId": "S-03",
"severity": "P3",
"dimension": "structure",
"title": "getAnnouncements 与 countAnnouncements 的 audience 过滤逻辑重复",
"description": "`getAnnouncementsRaw` L66-L82 和 `countAnnouncements` L133-L149 的 audience 过滤逻辑完全一致gradeClause、classClause、orClauses 构造。两处复制粘贴,修改时需同步,易遗漏。",
"recommendation": "提取共享 helper\n```ts\nfunction buildAudienceConditions(audience?: UserAudience): SQL[] {\n if (!audience) return []\n const { gradeIds, classIds } = audience\n const gradeClause = gradeIds.length > 0\n ? and(eq(announcements.type, \"grade\"), inArray(announcements.targetGradeId, gradeIds))\n : undefined\n const classClause = classIds.length > 0\n ? and(eq(announcements.type, \"class\"), inArray(announcements.targetClassId, classIds))\n : undefined\n const orClauses = [eq(announcements.type, \"school\"), gradeClause, classClause]\n .filter((c): c is NonNullable<typeof c> => c !== undefined)\n return orClauses.length > 1 ? [or(...orClauses)] : []\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-038",
"file": "src/modules/settings/actions.ts",
"lines": "L220",
"ruleId": "A-10",
"severity": "P3",
"dimension": "architecture",
"title": "actions.ts 含 console.error 调试代码actions 层可接受但建议用 logger",
"description": "L220 `console.error(\"[upsertAiProviderAction] Failed to save AI provider:\", error)` 在 actions 层使用 console.error。A-10 规则主要针对 data-access 层禁止 console.logactions 层用 console.error 记录错误属常见做法,但项目有 `trackEvent` 与 `logAudit` 统一日志通道,建议统一使用。",
"recommendation": "改用 trackEvent 或统一 logger\n```ts\nvoid trackEvent({\n event: \"ai.provider_upsert_failed\",\n targetType: \"ai_provider\",\n properties: { error: error instanceof Error ? error.message : String(error) },\n})\nreturn { success: false, message: \"Failed to save AI provider\" }\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-039",
"file": "src/modules/announcements/actions.ts",
"lines": "L42, L92",
"ruleId": "A-10",
"severity": "P3",
"dimension": "architecture",
"title": "actions.ts 含 console.errorhandleActionError 与 notifyAnnouncementPublished",
"description": "L42 `console.error(\\`[announcements] ${actionName} failed:\\`, e)` 和 L92 `console.error(\"Failed to send announcement notifications:\", error)` 在 actions 层使用 console.error。与 G5-038 同理actions 层可接受但建议统一日志通道。",
"recommendation": "改用 trackEvent 统一上报:\n```ts\nvoid trackEvent({\n event: \"announcement.action_error\",\n targetType: \"announcement\",\n properties: { action: actionName, error: e instanceof Error ? e.message : String(e) },\n})\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-040",
"file": "src/modules/error-book/data-access-analytics.ts",
"lines": "L161-L240, L309-L407",
"ruleId": "S-03",
"severity": "P3",
"dimension": "structure",
"title": "getKnowledgePointWeakness 与 getChapterWeakness 的知识点聚合逻辑重复",
"description": "`getKnowledgePointWeaknessRaw` L172-L191 和 `getChapterWeaknessRaw` L320-L338 都执行了相同的模式1) 查询 errorBookItems 的 status + knowledgePointIds2) JS 展开知识点到 kpMap3) 查询 knowledgePoints 表获取名称与 chapterId。两处代码高度相似仅最终聚合维度不同按知识点 vs 按章节)。",
"recommendation": "提取共享的知识点聚合查询:\n```ts\nasync function loadKpErrorStats(\n studentIds: string[], subjectId?: string | null\n): Promise<Map<string, { errorCount: number; masteredCount: number }>> {\n const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)\n const rows = await db.select({\n status: errorBookItems.status,\n knowledgePointIds: errorBookItems.knowledgePointIds,\n }).from(errorBookItems).where(whereClause)\n // ... 展开 kpMap\n return kpMap\n}\n```\n两个函数复用此 helper。",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-041",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L137-L174, L185-L220",
"ruleId": "F-01",
"severity": "P2",
"dimension": "performance",
"title": "checkScheduleConflict / checkCreditLimit 在事务内多次查询,可批量优化",
"description": "`checkScheduleConflict` L150-L161 查询学生已选课程 schedule`checkCreditLimit` L198-L209 查询学生已选课程 credit。两个函数在 `selectCourse` 事务内串行调用 (L347, L353),且都查询了 `courseSelections innerJoin electiveCourses` 相同的 join。可合并为单次查询减少事务内往返。",
"recommendation": "合并为单次查询:\n```ts\nasync function checkScheduleAndCredit(\n tx, studentId, newCourseId, studentGradeId\n): Promise<{ hasConflict: boolean; creditExceeded: boolean; current: number; max: number }> {\n const [newCourse, existingCourses] = await Promise.all([\n tx.select({ schedule: electiveCourses.schedule, credit: electiveCourses.credit })\n .from(electiveCourses).where(eq(electiveCourses.id, newCourseId)).limit(1),\n tx.select({ schedule: electiveCourses.schedule, credit: electiveCourses.credit })\n .from(courseSelections)\n .innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))\n .where(and(eq(courseSelections.studentId, studentId),\n inArray(courseSelections.status, [\"selected\", \"enrolled\", \"waitlist\"]))),\n ])\n // 一次遍历计算 conflict + credit\n}\n```",
"effort": "M (≤2 小时)"
}
]