docs(architecture): update impact map, data, audit reports, superpowers docs
- Update 004_architecture_impact_map.md and 005_architecture_data.json - Add audit reports: data-access-audit-framework-v1, data-access-audit-v1-data.json, data-access-audit-v1, g1-g5 audit outputs - Add superpowers plans and specs (logging-refactor, documentation-system-redesign) - Update troubleshooting/known-issues.md
This commit is contained in:
@@ -586,6 +586,15 @@ export function AnnouncementPagination({ page, pageSize, total, basePath, status
|
||||
| react-hook-form + zodResolver 协变差异保留 `as unknown as` | `zodResolver(formSchema) as unknown as Resolver<ExamFormValues>`(库协变差异需 `as unknown as`,注释说明原因) | `zodResolver(formSchema) as Resolver<ExamFormValues>`(直接 `as` 违规) |
|
||||
| 复杂 RichQuestionContent 结构守卫失败用 isRecord 兜底 | `if (!isRecord(parsed)) return fallback; const content = parsed as EditorQuestion["content"]`(结构校验由下游 Zod schema 保证,注释说明) | `parsed as EditorQuestion["content"]`(直接断言无守卫) |
|
||||
|
||||
### TypeScript 箭头函数语法规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 箭头函数带返回类型必须有 `=>` | `export const fn = async (x: string): Promise<void> => { ... }` | `export const fn = async (x: string): Promise<void> { ... }`(TS1005: '=>' expected) |
|
||||
| `const fn = async ()` 与 `async function fn()` 区别 | `const fn = async (): Promise<T> => {` 用箭头;`async function fn(): Promise<T> {` 用声明 | 混用:`const fn = async (): Promise<T> {`(缺 `=>`) |
|
||||
|
||||
> 2026-07-07 修复:`textbooks/data-access.ts` 的 `getKnowledgePointsByTextbookIdRaw` 缺失 `=>`(pre-existing bug,tsc 报 TS1005),补全后通过。
|
||||
|
||||
严重违规双重断言:
|
||||
- `structure-tree.tsx:72` `{ ...textbookNode, type: "textbook_content" } as unknown as Block`
|
||||
- `version-diff-viewer.tsx:38` `selectedVersion.content as unknown as LessonPlanDocument`
|
||||
@@ -1463,8 +1472,12 @@ if (announcement.type === "school") {
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 递归删除改批量收集 + inArray | 收集后代 ID 数组 → `inArray(ids)` 单次删除 | 递归内逐个 SELECT + DELETE |
|
||||
| BFS 逐层 inArray 收集后代 | `while (queue.length) { const children = await tx.select().where(inArray(parentId, queue)); queue = nextLevel }` | `for (const child of children) { await recursive(child) }` |
|
||||
| 循环 UPDATE 改 CASE WHEN | `UPDATE ... SET order = CASE id WHEN ... THEN ... END WHERE id IN (...)` | `for (item) await db.update(...).set(...).where(eq(id, item.id))` |
|
||||
| 循环 UPDATE 在事务内并行 | `await db.transaction(async (tx) => { await Promise.all(items.map(i => tx.update(...).set(...).where(eq(id, i.id)))) })` | `for (item) await db.update(...).set(...).where(eq(id, item.id))`(串行 + 无事务) |
|
||||
| 多步读写包装在 db.transaction | `await db.transaction(async (tx) => { /* reads + writes with tx */ })` | 多个独立 `db.select`/`db.update` 调用(无原子性,竞态风险) |
|
||||
| 跨模块循环调用改批量接口 | `getActiveStudentIdsByClassIds(classIds)` 一次查询 | `Promise.all(classIds.map(id => getActiveStudentIdsByClassId(id)))` |
|
||||
| 批量聚合改 GROUP BY + 应用层归并 | `db.select({...}).groupBy(studentId)` 一次查询 + `Map` 归并 | `Promise.all(classIds.map(id => db.select({...}).where(...)))`(N 条聚合 SQL) |
|
||||
| 内存 filter 改 SQL WHERE | `inArray(field, ids)` 下推到 SQL | 拉全表后 `rows.filter(r => ids.includes(r.id))` |
|
||||
|
||||
### LIKE 全表扫描规则
|
||||
@@ -1472,8 +1485,11 @@ if (announcement.type === "school") {
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 前导通配符禁止 | `LIKE 'xxx%'`(可走索引)或 `MATCH AGAINST IN BOOLEAN MODE` | `LIKE '%xxx%'` |
|
||||
| 前缀匹配必须转义用户输入通配符 | `escapeLikePattern(q)` 后再拼 `'%'`(转义 `%`/`_`/`\`) | `like(col, \`${q}%\`)`(用户输入 `%` 破坏查询) |
|
||||
| JSON 列禁止 LIKE | 关联表存储提取关系 + `inArray` 等值查询 | `LIKE('%id%', JSON列)` |
|
||||
| LOWER+CAST+LIKE 三重杀手 | FULLTEXT 索引 + 生成列 `content_text` | `LOWER(CAST(content AS CHAR)) LIKE '%x%'` |
|
||||
| 多字段搜索用 OR 前缀匹配 | `or(like(t.title, \`${esc(q)}%\`), like(t.subject, \`${esc(q)}%\`))` | `or(like(t.title, \`%${q}%\`), ...)` |
|
||||
| JSON 内嵌 ID 子串匹配短期保留 | 注释 `// F-02: 子串匹配需 FULLTEXT 索引,短期保留` + 内存精确过滤 | 无注释的 `like(jsonCol, \`%${id}%\`)` |
|
||||
|
||||
### 跨模块 schema 引用规则
|
||||
|
||||
@@ -1481,6 +1497,11 @@ if (announcement.type === "school") {
|
||||
|------|---------|---------|
|
||||
| 模块间走对方 data-access | `import { getClassNamesByIds } from "@/modules/classes/data-access"` | `import { classes } from "@/shared/db/schema"` + JOIN |
|
||||
| 跨模块批量接口 | `getGradeNamesByIds(gradeIds): Promise<Map<string,string>>` | `Promise.all(gradeIds.map(id => getGradeNameById(id)))` |
|
||||
| A-06 JOIN 改批量查询 + Map 合并 | 移除 `.leftJoin(textbooks, ...)`,改 `const map = await getTextbookTitlesByIds(ids); rows.map(r => ({...r, title: map.get(r.id) ?? null}))` | `.leftJoin(textbooks, eq(..., textbooks.id))` 直查对方表 |
|
||||
| A-06 跨模块接口用 cacheFn Raw+Wrapper | `getXxxTitlesByIdsRaw = async (ids) => {...}; getXxxTitlesByIds = cacheFn(getXxxTitlesByIdsRaw, {tags, ttl, keyParts})` | 裸 `async (ids) => { return await db.select()... }` 无缓存 |
|
||||
| A-06 别名导入避免命名冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling } from "@/modules/school/data-access"` | 本模块与导入方同名导出导致 duplicate identifier |
|
||||
| A-06 单条查询用单 ID 接口 | `const name = (await getClassNameById(classId)) ?? ""` | `db.select().from(classes).leftJoin(...)` 仅查一条 |
|
||||
| A-06 自有关联表可直查 + 跨模块名称走接口 | `db.select().from(questionsToKnowledgePoints)` + `await getKnowledgePointNamesByIds(kpIds)` | `.innerJoin(knowledgePoints, ...)` 直查对方知识点表 |
|
||||
|
||||
### data-access 职责边界规则
|
||||
|
||||
@@ -1523,11 +1544,22 @@ if (announcement.type === "school") {
|
||||
| 重复 helper 提取到 shared/lib | `import { toISODateString } from "@/shared/lib/date-utils"` | 每个模块自定义 `serializeDate`/`toIso` |
|
||||
| export * 改显式 re-export | `export { getClassNamesByIds, getClassExists } from "./data-access-queries"` | `export * from "./data-access-queries"` |
|
||||
|
||||
### 非空断言规则(G1-060~065)
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 数组取值后判空再访问 | `const row = rows[0]; if (!row) throw new Error("..."); row.field` | `rows[0]!.field` |
|
||||
| 数组取值后可选返回 | `const row = rows[0]; return row ? mapRow(row) : null` | `rows.length === 0 ? null : mapRow(rows[0]!)` |
|
||||
| split 结果用 ?? 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` |
|
||||
| WHERE 已过滤 NULL 的字段用 ?? | `templateId: r.templateId ?? ""` | `templateId: r.templateId!` |
|
||||
|
||||
### SELECT 列枚举规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 显式枚举所需列 | `db.select({ id: users.id, name: users.name }).from(users)` | `db.select().from(users)`(SELECT *) |
|
||||
| 接口字段可空性须与 schema 一致 | `teacherName: string \| null`(schema 中 `users.name` 无 `.notNull()`) | `teacherName: string`(tsc 报 `Type 'string \| null' is not assignable to type 'string'`) |
|
||||
| 仅枚举消费字段 | `db.select({ knowledgePointId, totalQuestions, correctQuestions })`(仅这三字段被 `existingByKp.set` 消费) | `db.select()` 拉全行后只用 3 字段 |
|
||||
|
||||
### 无 LIMIT 保护规则
|
||||
|
||||
@@ -1550,6 +1582,23 @@ if (announcement.type === "school") {
|
||||
| S-04 DRY 日期序列化 | `import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"` | 各模块重复定义 `const toIso = (d: Date) => d.toISOString()` |
|
||||
| P-01 server-only 强制 | data-access.ts 文件头 `import "server-only"` | data-access.ts 缺少 `import "server-only"`(DB 逻辑泄露风险) |
|
||||
|
||||
### School 模块 G3 审计修复规则(2026-07-07)
|
||||
|
||||
> 来源:school 模块 G3 审计(G3-007 / G3-010 / G3-011 / G3-012 / S-03 / A-02 / A-10 / F-09)
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| G3-007 / S-01 data-access 按职责拆分 | `data-access.ts`(barrel)→ `data-access-{departments,grades,subjects,classrooms,semesters,schools}.ts` + `lib/school-permissions.ts` | 单文件 938 行混合 6 类职责 |
|
||||
| G3-010 / A-10 data-access 移除 try-catch 错误吞没 | `export const getXxxRaw = async () => { const rows = await db.select()...; return rows.map(...) }`(让错误向上抛出) | `try { ... } catch (error) { log.error({err: error}, "xxx failed"); return [] }`(吞没错误返回空数组) |
|
||||
| G3-010 / A-10 data-access 禁止 log.error / console.error | `throw` 让 actions 层 catch 处理 | `log.error({ err: error }, "xxx failed"); return []` |
|
||||
| G3-011 / A-02 角色判断迁出 data-access | `lib/school-permissions.ts` 导出 `getSchoolsForUser(userId)` / `getGradesForUser(userId)`,data-access 只接受显式参数(如 `getGradesByIds(gradeIds)`) | data-access 内 `if (roleNames.has("admin")) {...} else if (roleNames.has("teacher")) {...}` |
|
||||
| G3-012 / F-09 promoteGrades 事务包裹 | `await db.transaction(async (tx) => { const rows = await tx.select()...; for (row of rows) await tx.update()... })` | `const rows = await db.select()...; for (row of rows) await db.update()...`(无事务,部分失败不一致) |
|
||||
| S-03 fetchGradesWithHeads helper 抽取 | `async function fetchGradesWithHeads(where?: SQL) { const rows = await db.select()...; return mapGradesWithHeads(rows) }` 复用于 getGrades / getGradesForStaff / getGradesByIds | 三个函数各自重复"查询年级 + 批量查询主任用户 + Map 映射"逻辑 |
|
||||
| barrel re-export 保持向后兼容 | 拆分后 barrel 文件 re-export 子文件导出,消费者无需改 import 路径 | 拆分后修改所有消费者的 import 路径 |
|
||||
| G3-048 barrel export * 改显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` + `export type { TypeA } from "./sub"` | `export * from "./sub"`(子文件导出 < 15 时仍用 export *) |
|
||||
| G3-048 barrel export * 保留 | 子文件导出 >= 15 时保留 `export * from "./sub"` + 注释说明原因 | 子文件导出 >= 15 时强行显式列举(易遗漏符号) |
|
||||
| 子文件首行 `import "server-only"` | 每个 `data-access-*.ts` 文件首行 `import "server-only"` | 仅 barrel 有 `import "server-only"`,子文件缺失 |
|
||||
|
||||
---
|
||||
|
||||
## 二十八、日志系统规则(2026-07-07 重构)
|
||||
@@ -1595,6 +1644,39 @@ if (announcement.type === "school") {
|
||||
| 上报端点 | POST /api/client-error (Route Handler 用 pino 记录) | 自定义 console 输出 |
|
||||
| 节流策略 | sessionStorage 记录 digest,1 分钟内不重复上报 | 每次渲染都上报 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 二十九、数据库访问层审计 Phase 1-4 修复规则速查(2026-07-07)
|
||||
|
||||
> Phase 0-4 全部落地(230 条问题,17 P0/48 P1/105 P2/60 P3),tsc + lint 0 errors。
|
||||
> 详细规则见第二十七节「数据库访问层审计治理规则」,本节为按规则 ID 的速查索引。
|
||||
|
||||
| 规则 ID | 正确写法 | 错误写法 |
|
||||
|---------|---------|---------|
|
||||
| F-01 N+1 递归删除 | BFS 逐层 `inArray` 收集后代 + 单次批量删除 | 递归内单独查询+删除 |
|
||||
| F-01 N+1 批量统计 | 单条 `GROUP BY` 聚合 + 应用层 `Map` 归并 | 循环内每项单独查询 |
|
||||
| F-01 N+1 多步写 | `db.transaction(async (tx) => { await Promise.all(items.map(...)) })` | 串行 `for (item) await db.update(...)` |
|
||||
| F-02 LIKE 前缀匹配 | `like(field, escapeLikePattern(q) + "%")` | `like(field, "%" + q + "%")` |
|
||||
| F-02 多字段搜索 | `or(like(t.title, esc(q)+"%"), like(t.subject, esc(q)+"%"))` | `or(like(t.title, "%"+q+"%"), ...)` |
|
||||
| F-02 JSON 列搜索 | FULLTEXT 索引 + 生成列 `content_text` | `LIKE('%id%', JSON列)` |
|
||||
| F-05 默认 LIMIT | `.limit(DEFAULT_LIMIT)` 无分页查询(DEFAULT_LIMIT=1000) | 无 LIMIT 的全表查询 |
|
||||
| F-09 事务包裹 | `db.transaction(async (tx) => { ... })` 多步写操作 | 多个独立 `db.update` 无事务 |
|
||||
| F-09 事务内用 tx | `await tx.update(...)` | `await db.update(...)`(在 transaction 回调内) |
|
||||
| A-02 业务逻辑下沉 | 算法/校验放 `lib/`,CRUD 留 `data-access` | `data-access` 含状态机/抽签算法/时间冲突检测 |
|
||||
| A-06 跨模块调用 | 调用对方 `data-access` 函数(`getTextbookTitlesByIds` 等) | `import { classes } from "@/shared/db/schema"` + JOIN |
|
||||
| A-06 跨模块批量接口 | `getXxxTitlesByIds(ids): Map<id, title>` 一次查询 | `Promise.all(ids.map(id => getXxxTitleById(id)))` |
|
||||
| A-06 别名导入避免冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling }` | 同名导出导致 duplicate identifier |
|
||||
| A-10 错误处理 | `data-access` 层 `throw`,`actions` 层 `try-catch` 返回 `ActionState` | `data-access` 层 `console.error` + 吞错误返回 `[]` |
|
||||
| P-03 cacheFn 配对 | `getXxxRaw` + `getXxx = cacheFn(getXxxRaw, {tags, ttl, keyParts})` | 读函数无缓存(直查 DB) |
|
||||
| P-03 权限校验不缓存 | `verifyTeacherOwnsClass` 直查(避免缓存权限提升) | `verifyTeacherOwnsClass = cacheFn(...)` |
|
||||
| S-01 文件拆分 | 按职责拆为 `data-access-xxx.ts` + barrel | 单文件 > 1000 行(硬上限) |
|
||||
| S-01 barrel re-export | barrel `export * from "./data-access-*"` 保持向后兼容 | 拆分后修改所有消费者 import 路径 |
|
||||
| S-03 helper 去重 | 提取到 `lib/` 或 `shared/lib/`(如 `lib/date-utils.ts`) | 多模块各自重复实现 `toIso`/`isClassSubject` |
|
||||
| S-05 export * → 显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` | `export * from "./sub"`(导出 < 15 时) |
|
||||
| S-05 export * 保留 | 子文件导出 >= 15 时保留 `export *` + 注释 | 导出 >= 15 强行显式列举(易遗漏) |
|
||||
| S-06 JSDoc | 公开导出函数补 `@param` + `@returns` + `@throws` | 无 JSDoc |
|
||||
| P-09 as 断言 | 类型守卫 / `satisfies` / 类型标注常量 | `value as Type` |
|
||||
| P-09 非空断言 | `const row = rows[0]; if (!row) throw ...; row.field` | `rows[0]!.field` |
|
||||
| P-09 split 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` |
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user