[ { "id": "G4-001", "file": "src/modules/messaging/data-access.ts", "lines": "L1-L1089", "ruleId": "S-01", "severity": "P0", "dimension": "structure", "title": "messaging/data-access.ts 超 1000 行硬性上限", "description": "文件总长 1089 行,违反项目硬性规则「任何文件不超过 1000 行,超过必须拆分」。文件混合了消息 CRUD、群发、撤回、举报、屏蔽、草稿、模板、附件 8 类职责。", "recommendation": "按职责拆分为:(1) data-access-messages.ts(消息 CRUD + 线程);(2) data-access-group.ts(群发 sendGroupMessage);(3) data-access-recall.ts(撤回 + 批量操作);(4) data-access-reports.ts(举报 + 屏蔽);(5) data-access-drafts.ts(草稿 CRUD);(6) data-access-templates.ts(模板 CRUD);(7) data-access-recipients.ts(收件人解析器)。原 data-access.ts 仅作 barrel re-export。", "effort": "L (≤1d)" }, { "id": "G4-002", "file": "src/modules/parent/", "lines": "—", "ruleId": "A-08", "severity": "P0", "dimension": "architecture", "title": "parent 模块缺失 actions.ts,data-access 被 app/ 直接引用", "description": "parent 模块目录下只有 data-access.ts 与 types.ts,无 actions.ts。Grep 证实 src/app/(dashboard)/parent/children/[studentId]/page.tsx、parent/leave/page.tsx、parent/elective/page.tsx 三处页面直接 import @/modules/parent/data-access,绕过 Server Action 层与 requirePermission 校验。getParentDashboardData / getChildDashboardData / getChildren 等敏感数据查询无任何权限校验。", "recommendation": "新建 src/modules/parent/actions.ts,为每个对外暴露的读函数包装 Server Action:\n```ts\n\"use server\"\nimport { requirePermission } from \"@/shared/lib/auth-guard\"\nimport { Permissions } from \"@/shared/types/permissions\"\nimport { getParentDashboardData } from \"./data-access\"\nexport async function getParentDashboardDataAction() {\n const ctx = await requirePermission(Permissions.PARENT_VIEW)\n return getParentDashboardData(ctx.userId)\n}\n```\n3 个页面改为调用 Action。", "effort": "M (≤2h)" }, { "id": "G4-003", "file": "src/modules/audit/actions.ts", "lines": "L192-L225", "ruleId": "A-08", "severity": "P0", "dimension": "architecture", "title": "purgeAuditLogsAction 使用 AUDIT_LOG_READ 权限执行破坏性清理", "description": "purgeAuditLogsAction 在 L197 调用 `await requirePermission(Permissions.AUDIT_LOG_READ)`,但该 Action 调用 purgeExpiredAuditLogs 会物理删除审计日志。读权限用于删除操作是严重权限提权漏洞——任何能查看审计日志的用户都能清空审计痕迹。", "recommendation": "新增专用权限点 Permissions.AUDIT_LOG_PURGE(admin 专属),改为:\n```ts\nawait requirePermission(Permissions.AUDIT_LOG_PURGE)\n```\n同步更新 src/shared/types/permissions.ts 与角色-权限映射。", "effort": "S (≤30 分钟)" }, { "id": "G4-004", "file": "src/modules/audit/actions.ts", "lines": "L163-L190", "ruleId": "A-08", "severity": "P1", "dimension": "architecture", "title": "saveAuditRetentionConfigAction 用读权限执行写操作", "description": "saveAuditRetentionConfigAction 在 L167 调用 `requirePermission(Permissions.AUDIT_LOG_READ)`,但该 Action 调用 saveAuditRetentionConfig 写入保留策略配置。读权限不应授予配置写入能力。", "recommendation": "新增 Permissions.AUDIT_RETENTION_MANAGE 或复用 Permissions.AUDIT_LOG_EXPORT,将 requirePermission 改为该写权限点。", "effort": "S (≤30 分钟)" }, { "id": "G4-005", "file": "src/modules/messaging/data-access.ts", "lines": "L1-L1089", "ruleId": "S-02", "severity": "P1", "dimension": "structure", "title": "messaging/data-access.ts 导出 42 个函数 + 4 个常量/接口,远超 20 上限", "description": "Grep 统计 `^export (async )?(function|const)` 共 44 个导出(含 Raw+Wrapper 配对),加上 SendGroupMessageInput/SendGroupResult 2 个 interface 共 46 个公共导出。职责混杂导致单文件难以维护。", "recommendation": "与 G4-001 拆分方案同步执行,拆分后每个 data-access-*.ts 导出数控制在 8-12 个。", "effort": "L (≤1d)" }, { "id": "G4-006", "file": "src/modules/messaging/data-access.ts", "lines": "L449-L476", "ruleId": "A-02", "severity": "P1", "dimension": "architecture", "title": "recallMessage 在 data-access 层嵌入状态机业务逻辑", "description": "recallMessage 函数内部实现 4 态状态机(\"ok\"/\"not_found\"/\"expired\"/\"already_recalled\"),包含时间窗口校验(MESSAGE_RECALL_WINDOW_MS = 2 分钟)、已撤回判断、elapsed 时间计算。这些是业务规则,不应放在 data-access 层。data-access 应只做 DB 读写。", "recommendation": "将状态机移至 actions.ts:\n```ts\n// data-access 只保留纯 DB 操作\nexport async function markMessageRecalled(id: string): Promise {\n await db.update(messages).set({ recalledAt: new Date() }).where(eq(messages.id, id))\n}\nexport async function getMessageForRecallCheck(id: string, userId: string) {\n return db.select({id, senderId, recalledAt, createdAt}).from(messages)\n .where(and(eq(messages.id, id), eq(messages.senderId, userId))).limit(1)\n}\n// actions.ts 中 recallMessageAction 实现状态机\n```", "effort": "M (≤2h)" }, { "id": "G4-007", "file": "src/modules/messaging/data-access.ts", "lines": "L695-L718, L641-L668", "ruleId": "A-02", "severity": "P1", "dimension": "architecture", "title": "blockUser / reportMessage 在 data-access 层实现防重复业务规则", "description": "blockUser 实现 self_block/already_blocked 双业务校验(L699, L712);reportMessage 实现 already_reported 防重复校验(L644-L656)。这些是业务规则,应在 actions 层通过 Zod + 状态判断完成,data-access 仅提供 unique 索引写入与查询原语。", "recommendation": "data-access 层只暴露纯 insert/block 查询;actions 层负责状态判断与错误码映射。可利用 DB unique 索引(userBlocks(blockerId, blockedId))直接 insert + catch 冲突判定 already_blocked,省去一次 SELECT。", "effort": "M (≤2h)" }, { "id": "G4-008", "file": "src/modules/messaging/data-access.ts", "lines": "L617-L630", "ruleId": "A-02", "severity": "P1", "dimension": "architecture", "title": "getMessageDetailPageData 是页面编排函数,不应在 data-access 层", "description": "getMessageDetailPageData 内部组合 getMessageById + 条件性 markMessageAsRead,是典型的页面层编排逻辑(orchestration)。文件头注释 L20 明确说「getMessagesPageData 已迁出至 messages/page.tsx」,但本函数仍保留在 data-access,违反同层职责一致性。", "recommendation": "删除该函数,将其逻辑移至 app/(dashboard)/messages/[id]/page.tsx 或包装为 getMessageDetailAction Server Action。", "effort": "S (≤30 分钟)" }, { "id": "G4-009", "file": "src/modules/auth/data-access.ts", "lines": "L48-L84", "ruleId": "F-09", "severity": "P1", "dimension": "performance", "title": "createUser 两次 INSERT 无事务,存在数据不一致风险", "description": "createUser 先 db.insert(users)(L55),再 db.query.roles.findFirst 查角色(L71),最后 db.insert(usersToRoles)(L78)。三次操作无事务包裹。若第二步 roleRow 未找到抛错,用户已写入但无角色;若第三步失败,用户存在但无角色关联,导致下次登录 resolvePermissions 失败。", "recommendation": "用 db.transaction 包裹:\n```ts\nawait db.transaction(async (tx) => {\n await tx.insert(users).values({...})\n const roleRow = await tx.query.roles.findFirst({ where: eq(roles.name, roleName) })\n if (!roleRow) throw new Error('DEFAULT_ROLE_NOT_FOUND')\n await tx.insert(usersToRoles).values({ userId, roleId: roleRow.id })\n})\n```\n注意:抛错会回滚用户记录,避免孤儿用户。", "effort": "S (≤30 分钟)" }, { "id": "G4-010", "file": "src/modules/messaging/data-access.ts", "lines": "L137-L141", "ruleId": "F-02", "severity": "P1", "dimension": "performance", "title": "getMessages 对 messages.subject/content 使用 LIKE '%kw%' 全表扫描", "description": "L138-139 `like(messages.subject, kw), like(messages.content, kw)` 中 kw = `%${params.keyword.trim()}%`。前导通配符使 B-tree 索引失效,messages 表增长后查询退化。同时 getMessages 还要 count() 同条件总数,单次列表请求触发 2 次全表扫描。", "recommendation": "(1) 短期:对短关键词改前缀匹配 `kw%` 可用索引;(2) 长期:在 messages 表加 FULLTEXT 索引 `ALTER TABLE messages ADD FULLTEXT idx_subject_content(subject, content)`,改用 `match(messages.subject, messages.content).against(kw)`;(3) count 也可考虑用估算值或缓存。", "effort": "L (≤1d)" }, { "id": "G4-011", "file": "src/modules/messaging/data-access.ts", "lines": "L506,L517,L531,L542,L555,L805,L807", "ruleId": "P-09", "severity": "P2", "dimension": "pattern", "title": "messaging/data-access.ts 出现 7 处 `as` 类型断言", "description": "Grep 证实 7 处 `as` 断言:(1) L506/517/531/542/555 `role: \"admin\" as RecipientRole` 等 5 处把 string literal 断言为联合类型 RecipientRole;(2) L805 `r.reason as MessageReportReason`;(3) L807 `r.status as MessageReport[\"status\"]`。规则 P-09 要求 `as` 出现次数为 0(除 unknown 收窄)。", "recommendation": "(1) RecipientRole 字面量断言:改用类型守卫函数 `function toRecipientRole(v: string): RecipientRole { return RECIPIENT_ROLES.includes(v) ? (v as RecipientRole) : 'admin' }` 或在 map 回调显式标注返回类型让 TS 推断;(2) reason/status 断言:仿照 notifications/data-access.ts L39-49 的 isNotificationType 类型守卫模式。", "effort": "M (≤2h)" }, { "id": "G4-012", "file": "src/modules/messaging/actions.ts", "lines": "L812", "ruleId": "P-09", "severity": "P2", "dimension": "pattern", "title": "messaging/actions.ts L812 `as` 联合类型断言", "description": "L812 `reason: input.reason as \"spam\" | \"harassment\" | \"inappropriate\" | \"other\"` 直接把 Zod 解析后的 string 断言为联合类型。ReportMessageSchema 应在 Zod 层用 z.enum() 收窄类型,避免后续 `as`。", "recommendation": "修改 schema.ts 的 ReportMessageSchema:\n```ts\nreason: z.enum(['spam', 'harassment', 'inappropriate', 'other'])\n```\n然后删除 `as` 断言,TS 会从 Zod 推断正确类型。", "effort": "XS (≤15 分钟)" }, { "id": "G4-013", "file": "src/modules/messaging/data-access.ts", "lines": "L86-L94", "ruleId": "S-07", "severity": "P2", "dimension": "structure", "title": "resolveUserNames 与 users/data-access.getUserNamesByIds 逻辑重复", "description": "messaging/data-access.ts L86-94 自定义 resolveUserNames 函数,查询 users 表返回 Map。但同模块 L41 已 import getUserNamesByIds from users/data-access,且后者返回 Map(含 id/name/email)。功能高度重复,违反 S-07 跨模块重复查询逻辑。", "recommendation": "删除 resolveUserNames,统一使用 getUserNamesByIds:\n```ts\nconst nameMap = await getUserNamesByIds(userIds)\n// 取值改为 nameMap.get(id)?.name ?? null\n```\n可获得 cacheFn 缓存收益。", "effort": "S (≤30 分钟)" }, { "id": "G4-014", "file": "src/modules/messaging/data-access.ts", "lines": "L815-L825, L843-L850, L1001-L1012, L673-L688, L740-L755, L761-L776, L781-L787", "ruleId": "P-03", "severity": "P2", "dimension": "pattern", "title": "多个读函数未走 cacheFn Raw+Wrapper 配对", "description": "以下读函数均直接 export async function 而未提供 Raw + cacheFn Wrapper 配对:getMessageReports(L815)、getUserBlocks(L843)、getMessageDraftById(L1001)、hasUserReportedMessage(L673)、isUserBlocked(L740)、isEitherUserBlocked(L761)、getBlockedUserIds(L781)、getMessageAttachments(L864)。违反 P-03「读函数是否走 cacheFn 包装 - 全部覆盖」。", "recommendation": "为每个读函数补齐 Raw + Wrapper 配对:\n```ts\nexport const getMessageReportsRaw = async (...) => {...}\nexport const getMessageReports = cacheFn(getMessageReportsRaw, { tags: ['messaging'], ttl: 60, keyParts: ['messaging', 'getMessageReports'] })\n```\n注意 hasUserReportedMessage 等布尔回传函数 ttl 可设短(30s)。", "effort": "M (≤2h)" }, { "id": "G4-015", "file": "src/modules/users/data-access.ts", "lines": "L429-L498", "ruleId": "P-03", "severity": "P2", "dimension": "pattern", "title": "getAdminUsers / getAdminUserRoles 读函数未走 cacheFn", "description": "getAdminUsers(L429) 与 getAdminUserRoles(L495) 是 admin 后台读函数,均未提供 Raw + Wrapper 配对,直接 export async function。getAdminUsers 内部还有 2 次 SQL(用户列表 + count)+ 1 次批量查角色,无缓存导致每次后台访问都全量打 DB。", "recommendation": "补齐 cacheFn 包装:\n```ts\nexport const getAdminUsersRaw = async (params): Promise => {...}\nexport const getAdminUsers = cacheFn(getAdminUsersRaw, { tags: ['users'], ttl: 60, keyParts: ['users', 'getAdminUsers'] })\n```\ngetAdminUserRoles 同理。", "effort": "S (≤30 分钟)" }, { "id": "G4-016", "file": "src/modules/rbac/data-access-assignments.ts", "lines": "L95-L177", "ruleId": "P-03", "severity": "P2", "dimension": "pattern", "title": "getUserRoleAssignments 读函数未走 cacheFn", "description": "getUserRoleAssignments 是分页读函数,未提供 Raw + Wrapper 配对。该函数被 rbac/actions.ts 的角色分配页面调用,无缓存导致每次列表访问都触发 2 次 SQL + 1 次批量查角色。", "recommendation": "拆为 getUserRoleAssignmentsRaw + getUserRoleAssignments = cacheFn(...) 配对,ttl 设 60s。", "effort": "S (≤30 分钟)" }, { "id": "G4-017", "file": "src/modules/audit/data-access.ts", "lines": "L88, L146, L165, L225, L248, L267, L380, L445, L470", "ruleId": "A-10", "severity": "P2", "dimension": "architecture", "title": "audit/data-access.ts 9 处 console.error 调试代码", "description": "Grep 证实 9 处 `console.error(...)`:L88 getAuditLogs、L146 getLoginLogs、L165 getAuditModuleOptions、L225 getDataChangeLogs、L248 getDataChangeStats、L267 getDataChangeTableOptions、L380 getAuditOverviewStats、L445 getAuditTrend、L470 getDataChangeActionStats。规则 A-10 明确禁止 data-access 含 console.log 调试代码。", "recommendation": "接入统一日志服务(shared/lib/logger,需先创建)。过渡期可改为:\n```ts\nimport { logger } from '@/shared/lib/logger'\ncatch (error) { logger.error('getAuditLogs failed', { error }); throw error }\n```\n或直接删除 try-catch 让上层处理(data-access 应 throw,不应吞错)。", "effort": "S (≤30 分钟)" }, { "id": "G4-018", "file": "src/modules/notifications/data-access.ts", "lines": "L261, L282", "ruleId": "A-10", "severity": "P2", "dimension": "architecture", "title": "notifications/data-access.ts 含 console.info / console.error", "description": "L261 `console.info('[NotificationLog] OK/FAIL ...')`、L282 `console.error('[NotificationLog] Failed to persist log:', dbError)`。代码已标注 TODO V3-P2-8 接入统一日志服务但未实施。违反 A-10。", "recommendation": "创建 shared/lib/logger 后替换;过渡期可用 trackEvent 写入 audit_logs。console.info 至少应改为可关闭的 debug 级别。", "effort": "S (≤30 分钟)" }, { "id": "G4-019", "file": "src/modules/auth/actions.ts", "lines": "L248-L250", "ruleId": "A-10", "severity": "P3", "dimension": "architecture", "title": "auth/actions.ts 含 console.warn 调试代码", "description": "L248-250 `console.warn('[register] Invitation code ... was already consumed ...')` 在 actions 层打印邀请码与邮箱到日志,可能泄露用户隐私信息到日志文件。", "recommendation": "改用 trackEvent 上报埋点(不含 email 明文),或改为 logger.warn 并脱敏 email。删除 console.warn。", "effort": "XS (≤15 分钟)" }, { "id": "G4-020", "file": "src/modules/audit/data-access.ts", "lines": "L14-L22, L27-L35", "ruleId": "S-03", "severity": "P2", "dimension": "structure", "title": "clampPageSize / clampPage 在 audit 与 rbac 重复定义", "description": "audit/data-access.ts L24-35 定义 DEFAULT_PAGE_SIZE / MAX_PAGE_SIZE / clampPageSize / clampPage;rbac/data-access-assignments.ts L11-22 完全相同地重复定义这 4 个常量与函数。违反 S-03 重复 helper 应提取到 shared/lib。", "recommendation": "提取到 shared/lib/pagination.ts:\n```ts\nexport const DEFAULT_PAGE_SIZE = 20\nexport const MAX_PAGE_SIZE = 100\nexport function clampPageSize(size?: number): number {...}\nexport function clampPage(page?: number): number {...}\nexport function computeOffset(page: number, pageSize: number): number { return (page - 1) * pageSize }\n```\n两处 import 替换。", "effort": "S (≤30 分钟)" }, { "id": "G4-021", "file": "src/modules/notifications/data-access.ts", "lines": "L37, L67 (messaging), L37 (notifications)", "ruleId": "S-03", "severity": "P2", "dimension": "structure", "title": "toIso / toIsoRequired 在多个模块重复定义", "description": "notifications/data-access.ts L37 `const toIsoRequired = (d: Date): string => d.toISOString()`;messaging/data-access.ts L67-69 `toIso` + `toIsoRequired`;audit/data-access.ts L22 `toIso`;parent/data-access.ts L63 直接调用 `r.createdAt.toISOString()`。多处重复实现日期序列化 helper,违反 S-03 与 P-07(日期序列化走 helper)。", "recommendation": "在 shared/lib/datetime.ts 统一导出:\n```ts\nexport const toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null\nexport const toIsoRequired = (d: Date): string => d.toISOString()\nexport const toIsoDateString = (d: Date): string => d.toISOString().slice(0, 10)\n```\n各模块 import 替换本地实现。", "effort": "S (≤30 分钟)" }, { "id": "G4-022", "file": "src/modules/messaging/data-access.ts", "lines": "L960-L993", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "updateMessageDraft 在 data-access 实现乐观锁版本冲突业务逻辑", "description": "updateMessageDraft 内部实现乐观锁:查询 existing.version → 比对 expectedVersion → 返回 \"ok\"/\"not_found\"/\"conflict\" 三态。这是业务状态机,应在 actions 层处理。data-access 应只暴露 getVersion + update 两个原语。", "recommendation": "拆分:data-access 提供 getMessageDraftVersion(id, userId) + updateMessageDraftRaw(id, userId, data, expectedVersion)(用 WHERE version = expectedVersion 实现原子检查);actions 层根据 affectedRows 判定冲突。", "effort": "M (≤2h)" }, { "id": "G4-023", "file": "src/modules/messaging/data-access.ts", "lines": "L348-L362, L400-L426", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "toggleMessageStar / bulkToggleMessagesStar 在 data-access 层做状态分支", "description": "toggleMessageStar 先 SELECT 当前 isStarred,再 UPDATE 为相反值;bulkToggleMessagesStar 更复杂——SELECT 后按 toStar/toUnstar 分组分别 UPDATE。这是条件分支业务逻辑,应在 actions 层完成。", "recommendation": "data-access 暴露 setMessageStarred(ids, userId, starred: boolean) 原语;actions 层先查询当前状态、决定目标值、调用原语。或更优:用 SQL `SET isStarred = NOT isStarred WHERE id IN (...)` 单语句完成翻转。", "effort": "M (≤2h)" }, { "id": "G4-024", "file": "src/modules/parent/data-access.ts", "lines": "L214-L237, L245-L268", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "parent/data-access 含 dashboard 编排逻辑", "description": "getChildDashboardDataRaw(L214) 内部 Promise.all 调用 6 个跨模块 data-access 函数(getStudentClasses、getStudentSchedule、getStudentHomeworkAssignments、getStudentDashboardGrades、getStudentGradeSummary、getStudentExamResults),是典型的 dashboard 编排。getParentDashboardDataRaw(L245) 同理。data-access 层应只负责本模块表查询,跨模块编排应在 actions 或 services 层。", "recommendation": "新建 src/modules/parent/services/parent-dashboard-service.ts 容纳编排逻辑;data-access 只保留 getChildren / verifyParentChildRelation / getParentIdsByStudentIds 等本模块表查询。", "effort": "M (≤2h)" }, { "id": "G4-025", "file": "src/modules/parent/data-access.ts", "lines": "L260-L262", "ruleId": "F-01", "severity": "P2", "dimension": "performance", "title": "getParentDashboardData 并行 N 次 getChildDashboardData,每次内部 6 次跨模块查询", "description": "L260-262 `Promise.all(relations.map((r) => getChildDashboardData(r.studentId, r.relation)))`。若家长有 N 个孩子,触发 N × 6 = 6N 次跨模块 data-access 调用,每调用可能再触发 DB 查询。多子女家长场景下性能差。", "recommendation": "重构为批量查询:getStudentClasses(studentIds[]) / getStudentSchedule(studentIds[]) 等批量接口,一次拉取所有孩子数据,再在内存按 studentId 分组组装。需 classes/homework/grades 模块提供批量查询函数。", "effort": "L (≤1d)" }, { "id": "G4-026", "file": "src/modules/audit/data-access.ts", "lines": "L281-L294, L299-L312, L317-L330", "ruleId": "F-01", "severity": "P2", "dimension": "performance", "title": "三个 ForExport 函数用 while 循环分页拉取全表,N 次往返", "description": "getAuditLogsForExport / getLoginLogsForExport / getDataChangeLogsForExport 均 `while (hasMore) { result = await getXxxLogs({page, pageSize: 100}); ... }`。导出大表时每 100 条一次 DB 往返,10 万条审计日志 = 1000 次查询。且每次都走 cacheFn 包装层,无意义缓存。", "recommendation": "新增不带分页的导出专用查询(流式或单次大查询):\n```ts\nexport async function getAuditLogsForExportRaw(params): Promise {\n return db.select().from(auditLogs).where(where).orderBy(desc(auditLogs.createdAt)).limit(100000)\n}\n```\n或用 cursor-based 流式导出。导出函数不应走 cacheFn(数据量大、不复用)。", "effort": "M (≤2h)" }, { "id": "G4-027", "file": "src/modules/notifications/data-access.ts", "lines": "L290-L294", "ruleId": "F-01", "severity": "P2", "dimension": "performance", "title": "logNotificationSendBatch 用 Promise.all 串行 N 次 INSERT", "description": "L294 `Promise.all(results.map((result) => logNotificationSend(result, payload)))`。每个 logNotificationSend 内部 L268-278 一次 db.insert(notificationLogs)。N 条日志 = N 次 INSERT 往返,应批量插入。注意 Promise.all 是并发但 DB 连接池有限,仍 N 次查询。", "recommendation": "改批量 INSERT:\n```ts\nexport async function logNotificationSendBatch(results, payload) {\n const rows = results.map(r => ({ id: createId(), userId: payload.userId, title: payload.title, channel: r.channel, status: r.success ? 'success' : 'failure', messageId: r.messageId ?? null, error: r.error ?? null, sentAt: r.sentAt }))\n await db.insert(notificationLogs).values(rows)\n}\n```\n一次 INSERT 完成所有日志写入。", "effort": "S (≤30 分钟)" }, { "id": "G4-028", "file": "src/modules/messaging/data-access.ts", "lines": "L176, L197-198, L226, L237, L502, L820, L844, L913, L1002, L1041", "ruleId": "F-03", "severity": "P2", "dimension": "performance", "title": "messaging/data-access 多处 db.select().from(table) 未显式枚举列", "description": "多处使用 `db.select().from(messages)` / `db.select().from(users)` / `db.select().from(messageDrafts)` / `db.select().from(messageTemplates)` / `db.select().from(messageReports)` / `db.select().from(userBlocks)` 全列查询。其中 L502 `db.select({ id, name, email }).from(users)` 是好的反例。messages 表含 content 长文本字段,列表查询全列拉取浪费带宽。", "recommendation": "列表查询显式枚举所需列:\n```ts\ndb.select({ id: messages.id, senderId: messages.senderId, receiverId: messages.receiverId, subject: messages.subject, content: messages.content, isRead: messages.isRead, isStarred: messages.isStarred, recalledAt: messages.recalledAt, readAt: messages.readAt, parentMessageId: messages.parentMessageId, groupMessageId: messages.groupMessageId, createdAt: messages.createdAt }).from(messages)\n```\n列表场景若不需 content,可省略该列。", "effort": "M (≤2h)" }, { "id": "G4-029", "file": "src/modules/audit/data-access.ts", "lines": "L56, L117, L194", "ruleId": "F-03", "severity": "P2", "dimension": "performance", "title": "audit/data-access 三个分页查询用 db.select() 全列", "description": "getAuditLogsRaw L56、getLoginLogsRaw L117、getDataChangeLogsRaw L194 均 `db.select().from(auditLogs/loginLogs/dataChangeLogs)`。audit_logs 表含 detail (JSON)、userAgent (长字符串) 等大字段,分页列表全列拉取浪费。dataChangeLogs.oldValue/newValue 是大 JSON。", "recommendation": "列表查询显式枚举列,详情字段(detail / oldValue / newValue / userAgent)按需在详情页查询时拉取。", "effort": "S (≤30 分钟)" }, { "id": "G4-030", "file": "src/modules/rbac/data-access.ts", "lines": "L39", "ruleId": "F-03", "severity": "P3", "dimension": "performance", "title": "getRolesRaw 用 db.select().from(roles) 全列查询", "description": "L39 `db.select().from(roles).orderBy(roles.name)` 查询所有角色。roles 表通常很小(< 20 行),影响有限,但仍应显式枚举列以避免 schema 变更后意外暴露字段。", "recommendation": "改为 `db.select({ id, name, description, isSystem, isEnabled, createdAt, updatedAt }).from(roles)`。", "effort": "XS (≤15 分钟)" }, { "id": "G4-031", "file": "src/modules/users/data-access.ts", "lines": "L449-L457", "ruleId": "F-03", "severity": "P2", "dimension": "performance", "title": "getAdminUsers 用 db.select() 全列查询 users 表", "description": "L451-452 `db.select().from(users).where(where).orderBy(...)`。users 表含 password (bcrypt hash)、image、address 等敏感或大字段,全列拉取既浪费又可能泄露 password hash 到内存对象。", "recommendation": "显式枚举所需列:`db.select({ id, name, email, phone, createdAt }).from(users)`。绝不能 select password 列。", "effort": "XS (≤15 分钟)" }, { "id": "G4-032", "file": "src/modules/users/data-access.ts", "lines": "L441-L444", "ruleId": "F-02", "severity": "P2", "dimension": "performance", "title": "getAdminUsers 对 name/email 使用 ilike '%search%' 全表扫描", "description": "L443 `or(ilike(users.name, search), ilike(users.email, search))`,search = `%${params.search}%`。前导通配符使索引失效,users 表增长后搜索退化。", "recommendation": "(1) 短期:email 改前缀匹配 `search%`(用户邮箱通常前缀输入);(2) 长期:加 FULLTEXT 索引或用 Elasticsearch。", "effort": "M (≤2h)" }, { "id": "G4-033", "file": "src/modules/rbac/data-access-assignments.ts", "lines": "L107-L108", "ruleId": "F-02", "severity": "P2", "dimension": "performance", "title": "getUserRoleAssignments 对 name/email 使用 ilike '%term%' 全表扫描", "description": "L108 `or(ilike(users.name, term), ilike(users.email, term))`,term = `%${params.search}%`。同 G4-032 问题。", "recommendation": "同 G4-032:email 前缀匹配,或加 FULLTEXT 索引。", "effort": "M (≤2h)" }, { "id": "G4-034", "file": "src/modules/audit/data-access.ts", "lines": "L47", "ruleId": "F-02", "severity": "P3", "dimension": "performance", "title": "getAuditLogsRaw 对 action 字段使用 like '%action%' 模糊匹配", "description": "L47 `like(auditLogs.action, \\`%${params.action}%\\`)`。action 字段通常是固定枚举值(如 'user.login'),用 `%xxx%` 匹配既慢又可能误匹配('user.login' 会匹配 'admin.user.login')。应改 eq 精确匹配。", "recommendation": "改为 `eq(auditLogs.action, params.action)`;若需多值匹配,用 inArray([actions])。", "effort": "XS (≤15 分钟)" }, { "id": "G4-035", "file": "src/modules/messaging/data-access.ts", "lines": "L502", "ruleId": "F-05", "severity": "P2", "dimension": "performance", "title": "resolveAdminRecipients 全量查询 users 表无 LIMIT", "description": "L502 `db.select({ id, name, email }).from(users)` 无 limit。admin 角色收件人解析时全量拉取所有用户,超大学校(万级用户)会 OOM。注释虽在 users/data-access.ts getAllUserIds 提到 P3-7 加 LIMIT 1000,但此处未应用。", "recommendation": "加分页或 LIMIT:\n```ts\ndb.select({ id, name, email }).from(users).limit(1000)\n```\n或改用 getTeachersByIds / 按角色筛选避免全量。", "effort": "XS (≤15 分钟)" }, { "id": "G4-036", "file": "src/modules/messaging/data-access.ts", "lines": "L520-L532", "ruleId": "F-08", "severity": "P2", "dimension": "performance", "title": "resolveGradeManagedRecipients 循环 N 次调用 getClassesByGradeId", "description": "L525 `await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g)))`。年级主任管理的年级通常 1-3 个,但模式上仍是 N 次跨模块调用。每个 getClassesByGradeId 内部一次 DB 查询,N 个年级 = N 次查询。classes 模块缺少 getClassesByGradeIds(批量) 接口。", "recommendation": "在 classes/data-access 新增 `getClassesByGradeIds(gradeIds: string[])` 批量查询接口,本处改为单次调用。", "effort": "M (≤2h)" }, { "id": "G4-037", "file": "src/modules/messaging/data-access.ts", "lines": "L549", "ruleId": "F-08", "severity": "P2", "dimension": "performance", "title": "resolveChildrenRecipients 循环 N 次调用 getStudentActiveClassId", "description": "L549 `await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id)))`。家长有 N 个孩子则 N 次调用。多子女家长场景下性能差。", "recommendation": "在 classes/data-access 新增 `getStudentActiveClassIds(studentIds: string[])` 批量查询接口。", "effort": "M (≤2h)" }, { "id": "G4-038", "file": "src/modules/audit/data-access.ts", "lines": "L370", "ruleId": "F-10", "severity": "P3", "dimension": "performance", "title": "getAuditOverviewStatsRaw 含 count() 全表统计", "description": "L370 `db.select({ value: count() }).from(auditLogs)` 无 WHERE 过滤,统计审计日志总数。audit_logs 表会持续增长(保留期 180 天),全表 count 在大表上慢(MyISAM 快但 InnoDB 慢)。", "recommendation": "(1) 用元数据表缓存总数,定时刷新;(2) 或用 `SELECT table_rows FROM information_schema.tables WHERE table_name='audit_logs'`(近似值);(3) 或限定统计近 30 天。", "effort": "S (≤30 分钟)" }, { "id": "G4-039", "file": "src/modules/notifications/data-access.ts", "lines": "L98", "ruleId": "F-03", "severity": "P3", "dimension": "performance", "title": "getNotificationsRaw 用 db.select() 全列查询", "description": "L98 `db.select().from(messageNotifications).where(where).orderBy(...)`。messageNotifications.content 可能为长文本,列表查询全列拉取浪费。", "recommendation": "显式枚举列,content 字段按需拉取。", "effort": "XS (≤15 分钟)" }, { "id": "G4-040", "file": "src/modules/users/data-access.ts", "lines": "L196", "ruleId": "P-07", "severity": "P3", "dimension": "pattern", "title": "getUsersDashboardStatsRaw 直接调用 toISOString 而非 helper", "description": "L196 `createdAt: u.createdAt.toISOString()` 直接调用,未使用项目统一日期序列化 helper(serializeDate / toISODateString)。其他模块(messaging/notifications/audit)均使用 toIso/toIsoRequired helper。", "recommendation": "import { toIsoRequired } from '@/shared/lib/datetime'(需先创建,见 G4-021),替换为 `createdAt: toIsoRequired(u.createdAt)`。", "effort": "XS (≤15 分钟)" }, { "id": "G4-041", "file": "src/modules/parent/data-access.ts", "lines": "L63", "ruleId": "P-07", "severity": "P3", "dimension": "pattern", "title": "getChildrenRaw 直接调用 toISOString 而非 helper", "description": "L63 `createdAt: r.createdAt.toISOString()` 直接调用,与 G4-040 同类问题。", "recommendation": "同 G4-021 / G4-040:使用统一 helper。", "effort": "XS (≤15 分钟)" }, { "id": "G4-042", "file": "src/modules/rbac/data-access.ts", "lines": "L28-L31", "ruleId": "P-07", "severity": "P3", "dimension": "pattern", "title": "toRoleRecord 返回 Date 对象而非 ISO 字符串", "description": "L28-31 `createdAt: row.createdAt, updatedAt: row.updatedAt` 直接返回 Date 对象。其他模块(notifications/audit/messaging)均返回 ISO 字符串。RoleRecord 类型定义可能是 Date,但跨层传递 Date 在 Server Action 序列化时会丢失时区信息,应统一为 ISO 字符串。", "recommendation": "改为 `createdAt: toIsoRequired(row.createdAt), updatedAt: toIsoRequired(row.updatedAt)`;同步更新 RoleRecord 类型为 string。", "effort": "S (≤30 分钟)" }, { "id": "G4-043", "file": "src/modules/rbac/data-access-assignments.ts", "lines": "L160", "ruleId": "P-07", "severity": "P3", "dimension": "pattern", "title": "getUserRoleAssignments 返回 Date 对象而非 ISO 字符串", "description": "L160 `createdAt: u.createdAt` 直接返回 Date。同 G4-042 问题。", "recommendation": "改为 `createdAt: toIsoRequired(u.createdAt)`;同步更新 UserRoleAssignment 类型。", "effort": "XS (≤15 分钟)" }, { "id": "G4-044", "file": "src/modules/users/data-access.ts", "lines": "L510-L538", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "deleteUserById 在 data-access 层嵌入 last-admin 保护业务逻辑", "description": "deleteUserById L510-538 实现「最后管理员保护」:查询 admin 角色 → count admin 数量 → 若 ≤1 再检查目标是否 admin → 抛错。这是业务安全规则,应在 actions 层校验,data-access 只做 delete 原语。", "recommendation": "actions.ts deleteUserAction 在调用 deleteUserById 前先调用 isLastAdmin(userId) 校验:\n```ts\n// data-access 暴露 isLastAdmin\nexport async function isLastAdmin(userId: string): Promise {...}\n// actions.ts\nif (await isLastAdmin(userId)) return { success: false, message: 'Cannot delete last admin' }\nawait deleteUserById(userId)\n```\ndata-access.deleteUserById 只保留 `db.delete(users).where(eq(users.id, userId))`。", "effort": "S (≤30 分钟)" }, { "id": "G4-045", "file": "src/modules/rbac/data-access.ts", "lines": "L146-L201", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "rbac/data-access 多处嵌入 admin 角色保护业务逻辑", "description": "updateRole(L151-153 admin name 锁)、deleteRole(L177-179 system role 锁)、setRoleEnabled(L192-194 admin disable 锁)、setRolePermissions(L233-235 admin perm 锁) 均在 data-access 层嵌入角色保护业务规则。这些是 RBAC 安全策略,应在 actions 层统一校验。", "recommendation": "data-access 层提供纯 CRUD 原语;actions.ts 在调用前校验 isAdminRole / isSystemRole 并抛错。可复用 rbac/actions.ts 已有的 isAdminRole 函数。", "effort": "M (≤2h)" }, { "id": "G4-046", "file": "src/modules/rbac/data-access-assignments.ts", "lines": "L52-L89", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "assignRolesToUser 在 data-access 层做角色存在性/disabled 校验", "description": "assignRolesToUser L57-58 校验 user 存在、L71-75 校验 role 名全部存在、L78 过滤 disabled 角色。这些是业务校验,应在 actions 层完成。data-access 应只做 transactional insert/delete。", "recommendation": "actions.ts assignUserRolesAction 在调用前用 Zod + 业务校验:检查 user 存在、role 名有效、无 disabled。data-access 只暴露 replaceUserRoles(userId, roleIds) 原语。", "effort": "M (≤2h)" }, { "id": "G4-047", "file": "src/modules/rbac/data-access-assignments.ts", "lines": "L164-L166", "ruleId": "A-02", "severity": "P2", "dimension": "architecture", "title": "getUserRoleAssignments 在 data-access 层 post-fetch 过滤角色", "description": "L164-166 `const filtered = params?.role ? items.filter((i) => i.roleNames.includes(params.role ?? '')) : items`。这是在内存中做角色过滤,但 total 仍是未过滤前的总数(L168),导致分页 totalPages 错误。这是业务逻辑 + bug。", "recommendation": "把 role 过滤下推到 SQL:用 EXISTS 子查询或 JOIN usersToRoles。或至少在 SQL 层用 `inArray(users.id, (db.select({userId}).from(usersToRoles).innerJoin(roles...).where(eq(roles.name, role))))` 子查询过滤。同时修正 total 计算。", "effort": "M (≤2h)" }, { "id": "G4-048", "file": "src/modules/messaging/actions.ts", "lines": "L1-L972", "ruleId": "S-01", "severity": "P2", "dimension": "structure", "title": "messaging/actions.ts 972 行,接近上限", "description": "文件 972 行,已超过 React 组件 / actions 建议 800 行上限(虽然 actions 硬上限是 1000)。文件包含 25+ 个 Server Action,覆盖消息 CRUD、群发、撤回、批量、草稿、模板、举报、屏蔽、附件 9 类功能。", "recommendation": "按功能拆分为 actions-messages.ts / actions-group.ts / actions-drafts.ts / actions-templates.ts / actions-reports.ts / actions-blocks.ts / actions-attachments.ts。原 actions.ts 作 barrel re-export。", "effort": "M (≤2h)" }, { "id": "G4-049", "file": "src/modules/users/data-access.ts", "lines": "L26, L429, L495", "ruleId": "S-06", "severity": "P3", "dimension": "structure", "title": "getUserProfileRaw / getAdminUsers / getAdminUserRoles 缺 JSDoc", "description": "getUserProfileRaw(L26) 无 JSDoc;getAdminUsers(L429) 无 JSDoc;getAdminUserRoles(L495) 无 JSDoc。其他函数(updateUserProfileById、updateUserAvatar、deleteUserById)均有 JSDoc。规则 S-06 要求公共导出函数补齐 JSDoc。", "recommendation": "为这 3 个函数补 JSDoc,说明用途、参数、返回值、副作用。", "effort": "XS (≤15 分钟)" }, { "id": "G4-050", "file": "src/modules/messaging/data-access.ts", "lines": "L325, L348, L369, L384, L400", "ruleId": "S-06", "severity": "P3", "dimension": "structure", "title": "messaging/data-access 多个公共函数仅有单行注释缺 JSDoc", "description": "markMessageAsRead(L325)、toggleMessageStar(L348)、bulkMarkMessagesAsRead(L369)、bulkDeleteMessages(L384)、bulkToggleMessagesStar(L400) 等函数仅有 `/** P2-1: ... */` 单行注释,缺标准 JSDoc(@param / @returns / @throws)。", "recommendation": "补全 JSDoc,至少说明参数语义、返回值含义、异常情况。", "effort": "S (≤30 分钟)" }, { "id": "G4-051", "file": "src/modules/notifications/data-access.ts", "lines": "L156, L163, L170, L177", "ruleId": "S-06", "severity": "P3", "dimension": "structure", "title": "notifications/data-access 部分 CRUD 函数缺 JSDoc", "description": "markNotificationAsRead(L156)、markAllNotificationsAsRead(L163)、archiveNotification(L170)、unarchiveNotification(L177) 4 个公共函数均无 JSDoc。createNotification / createNotifications / getUserContactInfoRaw 等有 JSDoc。", "recommendation": "为这 4 个函数补 JSDoc。", "effort": "XS (≤15 分钟)" }, { "id": "G4-052", "file": "src/modules/parent/data-access.ts", "lines": "L42, L97", "ruleId": "S-06", "severity": "P3", "dimension": "structure", "title": "parent/data-access getChildrenRaw / getChildBasicInfoRaw 缺 JSDoc", "description": "getChildrenRaw(L42) 与 getChildBasicInfoRaw(L97) 是模块核心读函数,均无 JSDoc。其他函数(verifyParentChildRelationRaw、getParentIdsByStudentIdsRaw)有 JSDoc。", "recommendation": "补全 JSDoc,说明入参与返回结构。", "effort": "XS (≤15 分钟)" }, { "id": "G4-053", "file": "src/modules/audit/data-access.ts", "lines": "L272-L279", "ruleId": "S-06", "severity": "P3", "dimension": "structure", "title": "getDataChangeTableOptions JSDoc 错位", "description": "L272-274 的 JSDoc 注释 `Export-ready: fetch all audit logs matching params` 是给 getAuditLogsForExport 用的,但实际位于 getDataChangeTableOptionsRaw 上方,且内容描述与函数名不符(注释说 audit logs,函数查 dataChangeLogs.tableName 选项)。", "recommendation": "修正 JSDoc:getDataChangeTableOptionsRaw 的注释应为「获取数据变更日志中所有出现过的表名选项」;getAuditLogsForExport 的注释应放在 L281 上方。", "effort": "XS (≤15 分钟)" }, { "id": "G4-054", "file": "src/modules/rbac/data-access.ts", "lines": "L22-L32", "ruleId": "S-06", "severity": "P3", "dimension": "structure", "title": "toRoleRecord 内部 helper 无 JSDoc", "description": "toRoleRecord(L22) 是 row→record 映射 helper,无 JSDoc。虽是内部函数,但项目规则建议 helper 也补简短说明。", "recommendation": "补单行 JSDoc:`/** 将 roles 表行映射为 RoleRecord 类型 */`", "effort": "XS (≤15 分钟)" }, { "id": "G4-055", "file": "src/modules/rbac/actions.ts", "lines": "L393-L394", "ruleId": "S-08", "severity": "P3", "dimension": "structure", "title": "isAdminRole 作为 Server Action 但不返回 ActionState", "description": "L393-394 `export async function isAdminRole(roleName: string): Promise` 直接返回 boolean,未包装 ActionState。其他所有 Action 均返回 ActionState。不一致,且 client 调用方无法区分「权限拒绝」与「不是 admin」。", "recommendation": "改为标准 ActionState:\n```ts\nexport async function isAdminRoleAction(roleName: string): Promise> {\n try { await requirePermission(Permissions.ROLE_READ); return { success: true, data: roleName === ADMIN_ROLE_NAME } }\n catch (e) { return { success: false, message: '...' } }\n}\n```\n或下沉为纯 data-access 函数(非 Action)。", "effort": "XS (≤15 分钟)" }, { "id": "G4-056", "file": "src/modules/auth/data-access.ts", "lines": "L25-L33", "ruleId": "P-03", "severity": "P3", "dimension": "pattern", "title": "isEmailAvailable 读函数未走 cacheFn Raw+Wrapper 配对", "description": "isEmailAvailable(L25) 是读函数,直接 export async function,未提供 Raw + Wrapper 配对。虽是注册前可用性检查(缓存可能引入脏读),但模式不一致。其他模块读函数均配对。", "recommendation": "若担心缓存影响可用性判断,可设 ttl: 5s 短缓存,至少模式一致。或显式标注 `// 不缓存:注册可用性检查需实时` 并在 lint 规则中加豁免。", "effort": "XS (≤15 分钟)" }, { "id": "G4-057", "file": "src/modules/notifications/data-access.ts", "lines": "L252-L285", "ruleId": "A-02", "severity": "P3", "dimension": "architecture", "title": "logNotificationSend 含 try-catch + console 降级,偏业务编排", "description": "logNotificationSend(L252-285) 内部 try-catch DB 写入失败时降级为 console.error,是日志写入的容错策略,属业务编排而非纯数据访问。data-access 应只做 DB 写入,失败应 throw 由上层决定降级。", "recommendation": "data-access 层只做 `db.insert(notificationLogs).values(...)`,失败 throw;上层(channels/dispatcher)catch 后决定是否降级。console 部分按 G4-018 处理。", "effort": "S (≤30 分钟)" }, { "id": "G4-058", "file": "src/modules/messaging/data-access.ts", "lines": "L617-L630", "ruleId": "S-05", "severity": "P2", "dimension": "structure", "title": "getMessageDetailPageData 疑似 dead code", "description": "文件头注释 L18-20 明确说「getMessagesPageData 已迁出至 messages/page.tsx 页面层,保持模块独立性」。getMessageDetailPageData 是同类编排函数,仍保留在 data-access。需确认是否被调用,若无调用方则为 dead code。", "recommendation": "Grep 调用方:`getMessageDetailPageData`。若无 app/ 或 actions 调用,删除;若有调用,按 G4-008 迁移至页面层。", "effort": "XS (≤15 分钟)" }, { "id": "G4-059", "file": "src/modules/messaging/data-access.ts", "lines": "L67-L69", "ruleId": "P-08", "severity": "P3", "dimension": "pattern", "title": "messaging/data-access 用本地 toIso/toIsoRequired 而非 mapListItem 模式", "description": "L67-69 定义 toIso/toIsoRequired 本地 helper,每个 map* 函数(mapMessage、mapDraft、mapTemplate、mapUserBlock、mapMessageReport)内联调用。规则 P-08 建议列表项映射走 mapListItem 模式统一。当前 5 个 mapper 各自实现,虽结构相似但无统一抽象。", "recommendation": "提取 shared/lib/map-helpers.ts 通用 `mapListItem(row, mapper)` 工具,或至少在模块内统一 mapper 签名风格。优先级低,当前实现可读性尚可。", "effort": "M (≤2h)" }, { "id": "G4-060", "file": "src/modules/users/data-access.ts", "lines": "L476-L484", "ruleId": "P-08", "severity": "P3", "dimension": "pattern", "title": "getAdminUsers 列表项 inline map 而非 mapListItem 模式", "description": "L476-483 `items: userRows.map((u) => ({ id, name, email, roles, phone, createdAt }))` 内联 map。其他模块(messaging/notifications)均有专用 mapper 函数(mapMessage/mapNotification)。", "recommendation": "提取 `mapAdminUserListItem(row, rolesByUserId)` mapper 函数,与 mapMessage 等保持一致风格。", "effort": "XS (≤15 分钟)" }, { "id": "G4-061", "file": "src/modules/audit/data-access.ts", "lines": "L67-L81, L128-L139, L206-L218", "ruleId": "P-08", "severity": "P3", "dimension": "pattern", "title": "audit/data-access 三个分页查询 inline map 列表项", "description": "getAuditLogsRaw L67-81、getLoginLogsRaw L128-139、getDataChangeLogsRaw L206-218 均用 `rows.map((r) => ({...}))` 内联映射,无独立 mapper 函数。与 messaging/notifications 模式不一致。", "recommendation": "提取 mapAuditLog / mapLoginLog / mapDataChangeLog mapper 函数,便于复用与单测。", "effort": "S (≤30 分钟)" } ]