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

735 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": "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.tsdata-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_PURGEadmin 专属),改为:\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<void> {\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, L712reportMessage 实现 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<userId, name>。但同模块 L41 已 import getUserNamesByIds from users/data-access且后者返回 Map<userId, UserNameOption>(含 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<AdminUserListResult> => {...}\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 / clampPagerbac/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<AuditLog[]> {\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-032email 前缀匹配,或加 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()` 直接调用,未使用项目统一日期序列化 helperserializeDate / 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<boolean> {...}\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) 无 JSDocgetAdminUsers(L429) 无 JSDocgetAdminUserRoles(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": "修正 JSDocgetDataChangeTableOptionsRaw 的注释应为「获取数据变更日志中所有出现过的表名选项」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>` 直接返回 boolean未包装 ActionState。其他所有 Action 均返回 ActionState<T>。不一致,且 client 调用方无法区分「权限拒绝」与「不是 admin」。",
"recommendation": "改为标准 ActionState\n```ts\nexport async function isAdminRoleAction(roleName: string): Promise<ActionState<boolean>> {\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/dispatchercatch 后决定是否降级。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<T>(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 分钟)"
}
]