[ { "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` 读函数(含 cacheFn 包装),actions.ts 改为调用该函数:\n```ts\n// data-access.ts\nexport const getUserOnboardedAtRaw = async (userId: string): Promise => {\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 { /* 仅 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 {\n await db.insert(fileAttachments).values({...})\n return getFileAttachment(data.id)\n}\n// actions 层已有 handleActionError 统一处理\n```", "effort": "S (≤30 分钟)" }, { "id": "G5-006", "file": "src/modules/files/data-access.ts", "lines": "L35-L58, L63-L76, L87-L105, L116-L129, L140-L153, L212-L248, L259-L284, L295-L308, L319-L331", "ruleId": "P-05", "severity": "P1", "dimension": "pattern", "title": "data-access 层用 try-catch 返回 null/false/空数组,违反 throw 上抛约定", "description": "所有函数均用 `try { ... } catch (error) { console.error(...); return null/[]/false }` 模式吞掉错误。这导致:1) actions 层无法区分“记录不存在”与“DB 异常”;2) 错误被静默吞掉,监控告警失效;3) 违反 P-05 规则(data-access 层用 throw,actions 层用 ActionState)。例如 createFileAttachment 失败返回 null,actions 层只能返回“Failed to persist file record”,丢失原始错误信息。", "recommendation": "移除 try-catch,让错误自然上抛:\n```ts\nexport async function getFileAttachmentRaw(id: string): Promise {\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[]\nconst where = conditions.length > 0 ? or(...conditions) : undefined\n```", "effort": "S (≤30 分钟)" }, { "id": "G5-009", "file": "src/modules/search/data-access.ts", "lines": "L147-L150, L191, L235", "ruleId": "F-02", "severity": "P1", "dimension": "performance", "title": "textbooks/exams/announcements 三表搜索使用 `LIKE '%kw%'` 全表扫描", "description": "searchTextbooksRaw L147-L150、searchExamsRaw L191、searchAnnouncementsRaw L235 均使用 `like(column, kw)` 其中 kw 为 `%${search}%` 格式,前缀通配符导致无法走索引,全表扫描。注释 L18-L20 已说明 questions 表用了 FULLTEXT 索引(L101 `MATCH ... AGAINST ... IN BOOLEAN MODE`),但其他三表仍用 LIKE。随着数据量增长,搜索性能会急剧下降。", "recommendation": "为 textbooks.title/subject/publisher、exams.title/description、announcements.title/content 添加 FULLTEXT 索引(MySQL)或 GIN 索引(PostgreSQL),改用 MATCH AGAINST:\n```sql\nALTER TABLE textbooks ADD FULLTEXT INDEX ft_textbooks_search (title, subject, publisher);\nALTER TABLE exams ADD FULLTEXT INDEX ft_exams_search (title, description);\nALTER TABLE announcements ADD FULLTEXT INDEX ft_announcements_search (title, content);\n```\n```ts\n.where(sql`MATCH(${textbooks.title}, ${textbooks.subject}, ${textbooks.publisher}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)\n```", "effort": "L (≤1 天)" }, { "id": "G5-010", "file": "src/modules/onboarding/data-access.ts", "lines": "L87-L133", "ruleId": "A-02", "severity": "P1", "dimension": "architecture", "title": "bindParentToChild 含三因子验证业务逻辑,应移至 lib 或 actions", "description": "`bindParentToChild` 函数 L87-L133 包含:1) 三因子验证业务规则(邮箱 + 生日 + 手机后4位,L97-L111);2) 幂等检查(L114-L131);3) 错误返回 `{ error: string }` 而非 throw。这是核心业务逻辑(对标 PowerSchool Access ID 验证),不应位于 data-access 层。data-access 应只做 CRUD,验证规则应下沉到 lib/business-rules 或 actions。", "recommendation": "拆分:\n1. `onboarding/lib/parent-binding.ts` —— `validateChildFactors(child, params): string | null` 纯函数验证三因子\n2. `onboarding/data-access.ts` —— `insertParentStudentRelation(parentId, studentId, relation): Promise` 仅做幂等插入\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 {\n const rows = await db.select({...}).from(aiProviders)\n .orderBy(desc(aiProviders.updatedAt))\n .limit(limit)\n return rows\n}\n```", "effort": "XS (≤15 分钟)" }, { "id": "G5-012", "file": "src/modules/settings/data-access-system-settings.ts", "lines": "L131-L142", "ruleId": "F-01", "severity": "P1", "dimension": "performance", "title": "upsertSystemSettings 循环内调用 upsertSystemSetting,每次含查询+写入,N+1 问题", "description": "`upsertSystemSettings` L140-L142 对每个 item 调用 `upsertSystemSetting`,而 `upsertSystemSetting` 内部 L110-L126 先 `getSystemSetting`(虽走缓存)再 `db.update` 或 `db.insert`。批量保存 N 个设置项时执行 N 次独立写入,无事务包裹,且中间失败会导致部分成功部分失败的数据不一致。", "recommendation": "改为单次事务批量 upsert(MySQL `INSERT ... ON DUPLICATE KEY UPDATE`):\n```ts\nexport async function upsertSystemSettings(\n items: ReadonlyArray<{...}>, updatedBy?: string\n): Promise {\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 => {\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 => {\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 { /* 接受依赖注入 */ }\n```\ndata-access 仅保留 `getAnnouncementByIdForUser` 的数据查询部分。", "effort": "M (≤2 小时)" }, { "id": "G5-021", "file": "src/modules/announcements/data-access.ts", "lines": "全文 606 行", "ruleId": "S-01", "severity": "P2", "dimension": "structure", "title": "文件 606 行,接近 800 行警告线", "description": "announcements/data-access.ts 共 606 行,已超过项目规范的“建议 ≤ 800 行”软上限的 75%。文件同时包含:CRUD(insertAnnouncement 等)、分页查询(getAnnouncements)、已读回执(markAnnouncementAsRead 等)、编排函数(getAdminAnnouncementsPageData 等 5 个)、业务逻辑(resolveUserAudience 等)。继续增长将突破警告线。", "recommendation": "拆分为:\n1. `data-access.ts` —— 纯 CRUD(insert/update/delete/publish/archive)\n2. `data-access-reads.ts` —— 查询函数(getAnnouncements, getAnnouncementById, countAnnouncements, 已读回执查询)\n3. `data-access-page.ts` —— 编排函数(getAdminAnnouncementsPageData 等)\n4. `lib/audience.ts` —— resolveUserAudience, isAnnouncementVisibleToAudience", "effort": "M (≤2 小时)" }, { "id": "G5-022", "file": "src/modules/settings/data-access-system-settings.ts", "lines": "L65-L68", "ruleId": "F-03", "severity": "P2", "dimension": "performance", "title": "getAllSystemSettingsRaw 使用 `db.select().from(systemSettings)` 未指定列", "description": "`getAllSystemSettingsRaw` L66 `const rows = await db.select().from(systemSettings)` 未显式枚举列,等价 SELECT *。systemSettings 表含 id、category、key、value、valueType、updatedBy、updatedAt、createdAt 等列,全量返回增加传输开销。对比 getSystemSettingRaw L83-L87 虽也未枚举但有 limit(1)。", "recommendation": "显式枚举列:\n```ts\nexport async function getAllSystemSettingsRaw(): Promise {\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) {\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:`,若未命中再查 `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 => {\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 => {\n const rows = await buildSelectionCoreSelect()\n .where(eq(courseSelections.courseId, courseId))\n .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))\n .limit(500) // 默认上限\n // ...\n}\n```", "effort": "XS (≤15 分钟)" }, { "id": "G5-029", "file": "src/modules/files/data-access.ts", "lines": "L224-L230", "ruleId": "F-02", "severity": "P2", "dimension": "performance", "title": "getFileAttachmentsWithFiltersRaw 使用 `LIKE '%search%'` 全表扫描", "description": "L225 `const kw = '%${search}%'`,L227-L229 `like(fileAttachments.originalName, kw)` 和 `like(fileAttachments.filename, kw)` 使用前缀通配符 `%`,无法走索引。管理员文件管理页面搜索文件时全表扫描 fileAttachments 表。", "recommendation": "为 originalName 和 filename 添加 FULLTEXT 索引,或改为前缀匹配:\n```sql\nALTER TABLE file_attachments ADD FULLTEXT INDEX ft_filename_search (original_name, filename);\n```\n```ts\nif (search) {\n conditions.push(sql`MATCH(${fileAttachments.originalName}, ${fileAttachments.filename}) AGAINST(${search} IN BOOLEAN MODE)`)\n}\n```", "effort": "M (≤2 小时)" }, { "id": "G5-030", "file": "src/modules/announcements/data-access.ts", "lines": "L343-L344, L366-L367, L426, L438, L454, L575, L592, L597", "ruleId": "A-06", "severity": "P3", "dimension": "architecture", "title": "大量使用 dynamic import 调用跨模块 data-access,建议改为静态 import", "description": "文件中 8 处使用 `await import(\"@/modules/xxx/data-access\")` 动态导入:L343 `getGrades`、L344 `getAdminClasses`、L366 `getGrades`、L426 `getClassGradeId`、L438 `getStudentActiveClassId`/`getStudentActiveGradeId`、L454 同上、L575 `getAllUserIds`、L592 `getUserIdsByGradeId`、L597 `getStudentIdsByClassId`/`getTeacherIdsByClassIds`。动态 import 增加运行时开销,且无法被构建工具静态分析优化。虽可能为避免循环依赖,但 announcements 与 classes/users/school 模块间无循环依赖风险。", "recommendation": "改为静态 import:\n```ts\nimport { getGrades } from \"@/modules/school/data-access\"\nimport { getAdminClasses, getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId, getStudentIdsByClassId, getTeacherIdsByClassIds } from \"@/modules/classes/data-access\"\nimport { getAllUserIds, getUserIdsByGradeId } from \"@/modules/users/data-access\"\n```\n若确有循环依赖,应重构模块边界而非用 dynamic import 规避。", "effort": "S (≤30 分钟)" }, { "id": "G5-031", "file": "src/modules/elective/data-access-operations.ts", "lines": "L378-L379, L410-L446", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "notifyCapacityThresholdIfNeeded 调用 getTranslations + sendNotification,含 i18n 与通知编排", "description": "`notifyCapacityThresholdIfNeeded` L410-L446 在 data-access 层调用 `getTranslations(\"elective\")` (L421) 获取 i18n 文案,并调用 `sendNotification` (L430) 发送跨模块通知。i18n 与通知编排属于业务逻辑层职责,不应位于 data-access。此外 L379 在事务内 `void notifyCapacityThresholdIfNeeded(course, newEnrolledCount)` 触发 fire-and-forget,虽不阻塞事务,但 data-access 层不应有副作用编排。", "recommendation": "移至 actions 层:\n```ts\n// data-access-operations.ts\nexport async function selectCourse(courseId, studentId, priority?): Promise<{ status: CourseSelectionStatus; course: Course }> {\n // ... 返回 course 与 newEnrolledCount 供 actions 判断\n}\n\n// actions.ts\nconst { status, course, newEnrolledCount } = await selectCourse(...)\nif (status === \"enrolled\") {\n await notifyCapacityThresholdIfNeeded(course, newEnrolledCount)\n}\nawait invalidateFor(...)\n```", "effort": "M (≤2 小时)" }, { "id": "G5-032", "file": "src/modules/settings/data-access.ts", "lines": "L153-L171, L188-L205", "ruleId": "F-09", "severity": "P3", "dimension": "performance", "title": "updateAiProvider / createAiProvider 事务内含条件分支但范围合理", "description": "`updateAiProvider` (L153-L171) 和 `createAiProvider` (L188-L205) 在事务内执行:1) 可选的重置其他默认 Provider;2) 主表 update/insert。事务范围仅含 DB 操作,无网络调用,范围合理。但 `resetOtherDefaults` 在事务内全表 update,当 Provider 数量多时可能锁表。标记为 P3 提示关注。", "recommendation": "可接受现状。若 Provider 数量增长,可考虑:1) 添加 WHERE 过滤条件缩小 update 范围;2) 用乐观锁替代事务。当前实现合理,无需立即修改。", "effort": "XS (≤15 分钟)" }, { "id": "G5-033", "file": "src/modules/error-book/data-access.ts", "lines": "L400-L438", "ruleId": "A-02", "severity": "P3", "dimension": "architecture", "title": "recordReview 含 SM-2 算法应用逻辑,处于边界", "description": "`recordReview` L409-L413 调用 `calculateNewInterval`、`calculateNewMastery`、`deriveStatus`、`calculateNextReviewAt`、`calculateNewCorrectStreak` 计算 SM-2 算法派生值。这些计算虽是业务逻辑,但已封装在 `sm2-algorithm.ts` 纯函数模块中,data-access 仅调用并持久化结果。处于可接受边界,但严格按 A-02 规则,算法应用应位于 lib 或 actions。", "recommendation": "可接受现状。若严格遵循规则,可将 SM-2 计算移至 `error-book/lib/review.ts`,data-access 仅接受计算结果并持久化:\n```ts\n// lib/review.ts\nexport function computeReviewResult(item, result): { newInterval, newMastery, newStatus, nextReviewAt } { ... }\n\n// data-access.ts\nconst reviewResult = computeReviewResult(item, result)\nawait db.transaction(async (tx) => { /* 持久化 reviewResult */ })\n```", "effort": "S (≤30 分钟)" }, { "id": "G5-034", "file": "src/modules/ai/data-access.ts", "lines": "L33-L49, L63-L139", "ruleId": "A-02", "severity": "P3", "dimension": "architecture", "title": "内存事件存储(eventStore)位于 data-access,应为独立 service", "description": "L35 `const eventStore: StoredAiEvent[] = []` 模块级内存数组,L43-L49 `recordAiEvent` 写入函数,L63-L139 `getAiUsageStatsRaw` 聚合统计。data-access 层应封装 DB 访问,而内存事件存储是临时实现(注释 L9-L18 说明生产环境应替换为 DB/Redis)。当前实现虽可工作,但混合了存储实现与数据访问接口。", "recommendation": "提取为独立 service:\n```ts\n// ai/services/usage-tracker-store.ts\nconst eventStore: StoredAiEvent[] = []\nexport function recordAiEvent(event: StoredAiEvent): void { ... }\n\n// ai/data-access.ts\nimport { eventStore } from \"./services/usage-tracker-store\"\nexport async function getAiUsageStatsRaw(): Promise { ... }\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 {\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 => c !== undefined)\n return orClauses.length > 1 ? [or(...orClauses)] : []\n}\n```", "effort": "XS (≤15 分钟)" }, { "id": "G5-038", "file": "src/modules/settings/actions.ts", "lines": "L220", "ruleId": "A-10", "severity": "P3", "dimension": "architecture", "title": "actions.ts 含 console.error 调试代码(actions 层可接受但建议用 logger)", "description": "L220 `console.error(\"[upsertAiProviderAction] Failed to save AI provider:\", error)` 在 actions 层使用 console.error。A-10 规则主要针对 data-access 层禁止 console.log,actions 层用 console.error 记录错误属常见做法,但项目有 `trackEvent` 与 `logAudit` 统一日志通道,建议统一使用。", "recommendation": "改用 trackEvent 或统一 logger:\n```ts\nvoid trackEvent({\n event: \"ai.provider_upsert_failed\",\n targetType: \"ai_provider\",\n properties: { error: error instanceof Error ? error.message : String(error) },\n})\nreturn { success: false, message: \"Failed to save AI provider\" }\n```", "effort": "XS (≤15 分钟)" }, { "id": "G5-039", "file": "src/modules/announcements/actions.ts", "lines": "L42, L92", "ruleId": "A-10", "severity": "P3", "dimension": "architecture", "title": "actions.ts 含 console.error(handleActionError 与 notifyAnnouncementPublished)", "description": "L42 `console.error(\\`[announcements] ${actionName} failed:\\`, e)` 和 L92 `console.error(\"Failed to send announcement notifications:\", error)` 在 actions 层使用 console.error。与 G5-038 同理,actions 层可接受但建议统一日志通道。", "recommendation": "改用 trackEvent 统一上报:\n```ts\nvoid trackEvent({\n event: \"announcement.action_error\",\n targetType: \"announcement\",\n properties: { action: actionName, error: e instanceof Error ? e.message : String(e) },\n})\n```", "effort": "XS (≤15 分钟)" }, { "id": "G5-040", "file": "src/modules/error-book/data-access-analytics.ts", "lines": "L161-L240, L309-L407", "ruleId": "S-03", "severity": "P3", "dimension": "structure", "title": "getKnowledgePointWeakness 与 getChapterWeakness 的知识点聚合逻辑重复", "description": "`getKnowledgePointWeaknessRaw` L172-L191 和 `getChapterWeaknessRaw` L320-L338 都执行了相同的模式:1) 查询 errorBookItems 的 status + knowledgePointIds;2) JS 展开知识点到 kpMap;3) 查询 knowledgePoints 表获取名称与 chapterId。两处代码高度相似,仅最终聚合维度不同(按知识点 vs 按章节)。", "recommendation": "提取共享的知识点聚合查询:\n```ts\nasync function loadKpErrorStats(\n studentIds: string[], subjectId?: string | null\n): Promise> {\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 小时)" } ]