docs(architecture): sync logging refactor to 004/005 and known-issues

Task 15: 004 架构影响地图新增 1.1.7 日志系统重构章节(架构图、核心组件表、Request ID 贯穿链路、Edge Runtime 限制、替换范围、环境变量),shared/lib 清单新增 logger.ts/request-context.ts/with-request-context.ts/track-event.ts 更新,hooks 清单新增 use-error-report.ts。

Task 16: 005 架构数据 JSON 新增 3 个 shared/lib 文件节点、1 个 hooks 节点、1 个 apiRoute(/api/client-error)、5 个 dependencyMatrix 依赖关系,JSON 有效性验证通过。

Task 17: known-issues.md 新增二十八、日志系统规则章节,含 5 个规则表(pino 使用、Edge Runtime 限制、Request ID 贯穿、ESLint no-console、客户端错误上报)。
This commit is contained in:
SpecialX
2026-07-07 13:00:00 +08:00
parent 7c1b764b59
commit 4e6d397d8e
3 changed files with 933 additions and 29 deletions

View File

@@ -72,6 +72,7 @@ const label = t(question.type) // t("single_choice") → 找不
|------|------|
| Node.js 服务端驱动标记为外部包 | `serverExternalPackages: ["mysql2"]` |
| 输出模式 | `output: "standalone"` |
| Tailwind v4 排除非源码目录防止误扫描 | `@source not "../../docs"; @source not "../../scripts"; @source not "../../tests";` 置于 `globals.css` | 不配置docs 中的 `[length:var(--space-N)]` 字符串被扫描成类,生成 `length: var(--*)` 致 CSS 解析失败 `Unexpected token Delim('*')` |
---
@@ -570,6 +571,20 @@ export function AnnouncementPagination({ page, pageSize, total, basePath, status
| AI 输出 patch 类型用 `Record<string, unknown>` | `interface NodeContentUpdate { data: Record<string, unknown> }`(诚实反映 Zod `z.record(z.string(), z.unknown())` 校验后的 untrusted 输出,强制调用方走 `mergeBlockDataPatch` | `interface NodeContentUpdate { data: Partial<BlockData> }`(伪装成可信类型,调用方直接 `as BlockData` 断言) |
| switch 分发用类型守卫而非 `as` | `case "objective": return isObjectiveBlockData(data) ? flattenObjective(data, t) : []` | `case "objective": return flattenObjective(data as ObjectiveBlockData, t)` |
| TextbookContentNode 分支用 `unknown` 中间变量 | `const merged: unknown = { ...n, ...patch }; return merged as TextbookContentNode`(结构类型不兼容 Block.data 联合,从 unknown 收窄需 `as` | `return { ...n, ...patch } as unknown as TextbookContentNode`(双重断言) |
| React Flow `node.data` 收窄用类型守卫 | `const nodeData = isGraphLayoutNodeData(node.data) ? node.data : undefined` | `node.data as GraphLayoutNodeData` |
| React Flow `EdgeProps.data` 双重断言用类型守卫 | `const edgeData = isGraphEdgeData(data) ? data : undefined` | `data as unknown as GraphEdgeData | undefined` |
| react-force-graph-2d `NodeObject` 收窄用类型守卫 | `if (!isKpGraphNode(node)) return; const kpNode = node` | `const kpNode = node as KpGraphNode` |
| react-force-graph-2d `LinkObject` 收窄用类型守卫 | `if (!isKpGraphLink(link)) return; const kpLink = link` | `const kpLink = link as KpGraphLink` |
| 力导向边 source/target 字符串排除后守卫 | `if (typeof source === "string") return; if (!isKpGraphNode(source)) return` | `kpLink.source as KpGraphNode` |
| next/dynamic 第三方库类型限制保留 `as` | `as unknown as ComponentType<React.ComponentProps<typeof ForceGraph2DType>>`next/dynamic 返回 `ComponentType` 与库默认导出类型不兼容,需 `as unknown as` 保留完整 props 类型) | 无(库类型限制无法用守卫替代) |
| DB JSON `unknown` 字段收窄用 `isRecord` | `if (!isRecord(value)) return value; const record = value` | `const record = value as Record<string, unknown>` |
| Tiptap `JSONContent` 收窄用守卫 | `isJSONContent(v) ? v : fallback` / `isJSONContentArray(arr) ? arr : []` | `slice.content.toJSON() as JSONContent[]` / `node as JSONContent` |
| dnd-kit `UniqueIdentifier` 转 string 用 `toSortableId` | `toSortableId(event.active.id)` / `toSortableId(over.id)` | `event.active.id as string` / `over.id as string` |
| Tiptap NodeView `node.attrs` 收窄用守卫 | `const attrs = isQuestionBlockAttrs(node.attrs) ? node.attrs : { questionId: "", type: "single_choice", score: 0 }` | `node.attrs as QuestionBlockAttrs` |
| AI 接口返回 `doc` 字段守卫失败显式报错 | `if (!isJSONContent(result.data.doc)) { toast.error(t("...")); return }` | `result.data.doc as EditorJSONContent` |
| next-intl `t()` params 类型放宽 | `type TranslationFn = (key: string, params?: Record<string, string \| number \| Date>) => string` | `t(key, params as Record<string, string \| number \| Date> \| undefined)` |
| 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"]`(直接断言无守卫) |
严重违规双重断言:
- `structure-tree.tsx:72` `{ ...textbookNode, type: "textbook_content" } as unknown as Block`
@@ -587,6 +602,31 @@ export function AnnouncementPagination({ page, pageSize, total, basePath, status
- `messaging/data-access.ts` — 5 处字面量 `as RecipientRole` 移除 + 2 处 `mapMessageReport` 改用 `toXxx` 辅助
- `messaging/components/message-report-block.tsx` — `setReason(v as MessageReportReason)` 替换为 `if (isMessageReportReason(v)) setReason(v)`
**教材模块类型守卫修复2026-07-07涉及文件**
- `textbooks/lib/type-guards.ts`(新建)— `isKpWithRelations` / `isGraphNodeData` / `isGraphLayoutNodeData` / `isGraphEdgeData` / `isKpGraphNode` / `isKpGraphLink` 6 个守卫
- `textbooks/types.ts` — `KpGraphNode` / `KpGraphLink` 接口从 force-graph.tsx 迁移至此(供守卫模块引用,避免组件文件被守卫模块反向依赖)
- `textbooks/components/graph-kp-node.tsx` — 2 处 `as``data.kp as KpWithRelations`、`data.graphData as GraphNodeData | undefined`)改用守卫;`extractNodeData` 返回 `| null`,校验失败组件渲染 null
- `textbooks/components/graph-prerequisite-edge.tsx` — `data as unknown as GraphEdgeData | undefined` 双重断言改用 `isGraphEdgeData` 守卫
- `textbooks/components/knowledge-graph-node.tsx` — `node.data as GraphLayoutNodeData` 改用 `isGraphLayoutNodeData` 守卫
- `textbooks/components/force-graph.tsx` — 6 处 `as``node as KpGraphNode` ×3、`link as KpGraphLink`、`kpLink.source as KpGraphNode`、`kpLink.target as KpGraphNode`)改用 `isKpGraphNode`/`isKpGraphLink` 守卫;保留 Line 80 `as unknown as ComponentType`next/dynamic 第三方库类型限制)
**exams 模块类型守卫修复2026-07-07涉及文件**
- `exams/lib/type-guards.ts`(新建)— `isRecord` / `isSimpleTextContent` / `isQuestionContentObj` / `isJSONContent` / `isJSONContentArray` / `toSortableId` / `isQuestionBlockAttrs` 共 7 个守卫/转换函数;`isJSONContent` 返回 `v is JSONContent`(依赖 `@tiptap/react` 类型)
- `exams/ai-pipeline/request.ts` — 2 处 `as Record<string, unknown>` 改用 `isRecord` 守卫;`as const` 字面量断言保留
- `exams/components/exam-preview-utils.ts` — `isPreviewBackgroundTask` 内 `as` 改用 `isRecord`
- `exams/components/exam-actions.tsx` — `isRawStructureNode` 守卫用 `isRecord` 替代 `typeof === "object"`
- `exams/components/assembly/structure-editor.tsx` — 9 处 `as` 修复(`as string` ×4 改 `toSortableId``as QuestionContent` ×2 改 `isQuestionContentObj``as Record` 改 `isRecord`
- `exams/components/assembly/question-bank-list.tsx` — `extractQuestionText` 用 `isRecord``parsedContent` 用 `isSimpleTextContent`
- `exams/components/assembly/exam-paper-preview.tsx` — `as QuestionContent` ×2 改用 `isQuestionContentObj` + 显式映射 ChoiceOption
- `exams/components/exam-columns.tsx` — `TranslationFn` 类型签名放宽 params 类型匹配 next-intl
- `exams/components/exam-data-table.tsx` — 移除 `t(key, params as ...)` 断言,类型由 TranslationFn 保证
- `exams/components/exam-form.tsx` — `zodResolver(formSchema) as Resolver` → `as unknown as Resolver`(合规:库协变差异,加注释)
- `exams/editor/selection-toolbar.tsx` — `as JSONContent[]` / `as JSONContent` 改用 `isJSONContentArray` + `.filter(isJSONContent)`
- `exams/editor/exam-nodes-to-editor-doc.ts` — `typeof raw === "object"` 改 `isRecord`;保留 `as EditorQuestion["content"]` 加注释(结构由下游 Zod 保证)
- `exams/editor/exam-rich-editor-inner.tsx` — 移除 `editor.getJSON() as EditorJSONContent` ×2`node.attrs.type as QuestionBlockType` 改用 `isRichQuestionType` 守卫
- `exams/editor/extensions/question-block.tsx` — `node.attrs as QuestionBlockAttrs` 改用 `isQuestionBlockAttrs` 守卫,失败回退默认 attrs
- `exams/components/exam-rich-form.tsx` — `result.data.doc as EditorJSONContent` 改用 `isJSONContent` 守卫,失败 toast 报错并 return
### 非空断言 `!.` 规则
| 规则 | 正确写法 | 错误写法 |
@@ -601,9 +641,13 @@ export function AnnouncementPagination({ page, pageSize, total, basePath, status
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| effect 中读 localStorage 用 `useEffectEvent` 或加依赖 | `useEffect(() => { setRecentIds(readRecentTextbookIds()) }, [])` 改用 `useSyncExternalStore` 或初始化函数 | `useEffect(() => { setRecentIds(readRecentTextbookIds()) }, [])` 触发 set-state-in-effect |
| 禁止 `eslint-disable-next-line` | 修正依赖数组或用 `useCallback` 包裹 | `// eslint-disable-next-line react-hooks/exhaustive-deps` |
| 挂载时从 localStorage 恢复 + 含表单副作用可豁免 | `// eslint-disable-next-line react-hooks/set-state-in-effect -- 挂载时恢复 localStorage` + `setXxx(data)` | 裸 `setXxx(data)` 触发 set-state-in-effect |
| 禁止 `eslint-disable-next-line`(仅限 exhaustive-deps | 修正依赖数组或用 `useCallback` 包裹 | `// eslint-disable-next-line react-hooks/exhaustive-deps` |
| ref 禁止渲染期访问,改用 `useState` 惰性初始化 | `const [q] = useState(() => new PQueue({...}))` | `const r = useRef(null); if (!r.current) r.current = new PQueue(...)` |
| useEffect 依赖数组须含闭包引用变量 | `useEffect(() => { f(t) }, [t])` | `useEffect(() => { f(t) }, [])` 漏 `t` |
| 闭包捕获的 state 恒为初始值,条件判断无意义需移除 | `if (active && usingSSE) { fetch() }`(移除 `count === 0` | `if (active && usingSSE && count === 0)` 在 `[]` effect 内恒真 |
涉及文件:`template-picker.tsx:72`error、`schedule-dialog.tsx:53`warning、`lesson-plan-editor.tsx:81`disable
涉及文件:`template-picker.tsx:72`error、`schedule-dialog.tsx:53`warning、`lesson-plan-editor.tsx:81`disable、`use-exam-preview-tasks.ts:28,36`refs+setState、`unread-message-badge.tsx:92`(闭包陷阱)、`practice-result-view.tsx:26`unused、`seed-grade5-chinese.ts`unused imports
### Tailwind 任意值规则
@@ -1377,4 +1421,180 @@ if (announcement.type === "school") {
| i18n 键同步新增 | SectionErrorBoundary 的 namespace 需有 `error.boundaryTitle` 等键 | 新增 namespace 但不补 i18n 键 |
| 架构文档同步 004/005 | 每批闭环后更新 exports/lastUpdate | 全部完成后才更新 |
## 二十六、`as` 断言违规修复规则2026-07-07
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 禁止 `as` 断言(除 unknown 转换/测试) | 类型守卫 / `as unknown as T` + 注释 | `value as T` |
| JSON.parse 结果必须经 unknown 中转 | `const raw: unknown = JSON.parse(x); const d = raw as T` | `JSON.parse(x) as T` |
| localStorage 数据需字段校验 | `isRecord` + 逐字段 `typeof` 校验 | `JSON.parse(x) as MyType` |
| URL search param 需类型守卫 | `isTimeRange(v) ? v : "today"` | `v as TimeRange` |
| HTTP 响应需类型守卫 | `isFileUploadResult(raw) ? raw : fallback` | `(await res.json()) as Result` |
| DB enum 字段需守卫转换 | `toLeaveType(row.leaveType)` | `row.leaveType as LeaveType` |
| Select onValueChange 需守卫 | `isAttendancePeriod(v) ? v : "full_day"` | `v as AttendancePeriod` |
| 表单 event.currentTarget 需 instanceof | `if (e.currentTarget instanceof HTMLFormElement)` | `e.currentTarget as HTMLFormElement` |
| 空对象赋值需显式标注 | `const empty: Record<string, string> = {}` | `{} as Record<string, string>` |
| Set<X>.has(value) 需改 Set<string> | `const SET = new Set<string>([...]); SET.has(value)` | `SET.has(value as X)` |
| readonly X[] 转 readonly string[] | `const ARR: readonly string[] = [...]` | `ARR.includes(value as X)` |
| 联合类型收窄用 filter 守卫 | `.filter((n): n is 1\|2\|3 => n>=1 && n<=3)` | `.map(Number) as Array<1\|2\|3>` |
| Drizzle or() 返回 SQL\|undefined | `const r = or(...); return r ?? null` | `or(...) as SQL` |
| Zod schema + 类型守卫组合 | `parsed.data.filter(isPermission)` | `parsed.data as Permission[]` |
| 模块私有类型守卫放 lib/type-guards.ts | `lib/type-guards.ts` 导出 `isX` / `toX` | 在组件内联 `as` 断言 |
## 二十七、数据库访问层审计治理规则2026-07-07 v1 审计)
> 来源:[data-access-audit-v1.md](../architecture/audit/data-access-audit-v1.md)
### server-only 文件头规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 首行必须 `import "server-only"` | `import "server-only"\nimport { db } from ...` | `import { db } from ...`(首行缺失) |
### cacheFn 包装规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 读函数走 Raw + Wrapper 配对 | `getXxxRaw = async () => {...}; getXxx = cacheFn(getXxxRaw, {tags, ttl, keyParts})` | `getXxx = async () => { /* 直查 DB */ }` |
| 权限校验函数不缓存 | `verifyTeacherOwnsClass` 直查(避免缓存权限提升) | `verifyTeacherOwnsClass = cacheFn(...)` |
### N+1 循环 SQL 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 递归删除改批量收集 + inArray | 收集后代 ID 数组 → `inArray(ids)` 单次删除 | 递归内逐个 SELECT + DELETE |
| 循环 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))` |
| 跨模块循环调用改批量接口 | `getActiveStudentIdsByClassIds(classIds)` 一次查询 | `Promise.all(classIds.map(id => getActiveStudentIdsByClassId(id)))` |
| 内存 filter 改 SQL WHERE | `inArray(field, ids)` 下推到 SQL | 拉全表后 `rows.filter(r => ids.includes(r.id))` |
### LIKE 全表扫描规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 前导通配符禁止 | `LIKE 'xxx%'`(可走索引)或 `MATCH AGAINST IN BOOLEAN MODE` | `LIKE '%xxx%'` |
| JSON 列禁止 LIKE | 关联表存储提取关系 + `inArray` 等值查询 | `LIKE('%id%', JSON列)` |
| LOWER+CAST+LIKE 三重杀手 | FULLTEXT 索引 + 生成列 `content_text` | `LOWER(CAST(content AS CHAR)) LIKE '%x%'` |
### 跨模块 schema 引用规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 模块间走对方 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)))` |
### data-access 职责边界规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 不含业务逻辑 | data-access 仅 CRUD + 映射 | 时间校验/状态机/抽签算法/角色判断 |
| 状态机移至 actions 或 lib | `actions.ts` 编排校验+状态迁移,调用 data-access 原子函数 | data-access 内 `if (status === 'pending') { ... } else if ...` |
| 纯计算函数移至 lib | `lib/schedule-conflict.ts` 导出 `isScheduleConflict` | data-access 内定义 `function isScheduleConflict(...)` |
| 跨模块编排移至 actions | actions 调用多个 data-access + 纯计算 | data-access 内调用 `getGradeRecords` + 归一化计算 |
### 事务规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 多步写必须包裹事务 | `await db.transaction(async (tx) => { ... })` | 循环内独立 `db.update(...)` 无事务 |
| 事务内用 tx 而非 db | `await tx.update(...)` | `await db.update(...)`(在 transaction 回调内) |
### 错误处理规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 层 throw 上抛 | `throw new Error("...")` 让 actions 处理 | `try { ... } catch { console.error(...); return null/[] }` |
| actions 层用 ActionState | `try { ... } catch (e) { return { success: false, message: handleActionError(e) } }` | actions 层 throw 不捕获 |
| 禁止 console.error 调试 | `throw new Error("...")` | `console.error("xxx failed:", error); return []` |
### 权限校验规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 每个模块必须有 actions.ts | `src/modules/xxx/actions.ts` 包装 Server Action | app/ 页面直接 import data-access |
| 破坏性操作用专属权限点 | `requirePermission(Permissions.AUDIT_LOG_PURGE)` | `requirePermission(Permissions.AUDIT_LOG_READ)` 执行删除 |
| 读权限不执行写操作 | 写操作用 `XXX_MANAGE`/`XXX_WRITE` 权限 | `XXX_READ` 权限执行 INSERT/UPDATE/DELETE |
### 文件结构规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 单文件 ≤ 800 行 | 超过时拆分为 `data-access-{职责}.ts` | 单文件 1000+ 行混合多类职责 |
| 单文件导出函数 ≤ 20 | 按职责拆分CRUD/跨模块/导入导出) | 单文件导出 40+ 函数 |
| 重复 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"` |
### SELECT 列枚举规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 显式枚举所需列 | `db.select({ id: users.id, name: users.name }).from(users)` | `db.select().from(users)`SELECT * |
### 无 LIMIT 保护规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 列表查询加默认 LIMIT | `.limit(pageSize).offset(offset)` 或 `.limit(500)` | 无 LIMIT 的全表查询 |
| 统计走 SQL 聚合 | `SELECT COUNT(*), SUM(CASE WHEN ...) FROM ... WHERE ...` | 拉全表后内存循环统计 |
### Phase 0 P0 修复规则2026-07-07 落地)
> 来源:审计 v1 Phase 0 P0 修复6 项 P0 全部落地tsc + lint 0 errors
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| A-09 actions 禁止直查 DB | actions.ts 调用 data-access 函数(如 `getUserOnboardedAt()` / `markUserOnboarded()` | actions.ts 中 `db.select` / `db.update` / `db.transaction` + `import { db } from '@/shared/db'` |
| 权限点注册全链路同步 | `permissions.ts` 常量 → `permissions.ts` ROLE_PERMISSIONS_SEED → `permission-bitmap.ts` → `permission-catalog.ts` → i18n `rbac.json`zh-CN/en五处同步 | 仅在 `permissions.ts` 添加常量 |
| 权限提权修复 | 破坏性操作用独立权限点 `requirePermission(Permissions.AUDIT_LOG_PURGE)` | 用低权限 `AUDIT_LOG_READ` 覆盖清理操作(`purgeAuditLogsAction` |
| A-08 缺失 actions.ts | app/ 调用 `@/modules/parent/actions` 的 Server Action | app/ 直接 import `@/modules/parent/data-access` |
| S-01 文件超 1000 行拆分 | 拆分为 `data-access-{core,bulk,group,templates,reports}.ts` + barrel `export * from "./data-access-*"` | 单文件超过 1000 行(如 messaging/data-access.ts 1089 行) |
| 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 逻辑泄露风险) |
---
## 二十八、日志系统规则2026-07-07 重构)
### pino logger 使用
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 服务端日志统一用 pino | `import { createModuleLogger } from "@/shared/lib/logger"; const log = createModuleLogger("module-name"); log.info({...}, "msg")` | `console.log/error/warn/info(...)` |
| 模块级 logger 命名 | `createModuleLogger("audit")` / `createModuleLogger("exams")` / `createModuleLogger("client-error")` | `createModuleLogger("Audit")` / `createModuleLogger("a")` |
| 日志参数顺序 | `log.error({ err: error, userId }, "操作描述")` | `log.error("操作描述", error)` |
| 错误对象字段名 | `log.error({ err: error }, "msg")` | `log.error({ error }, "msg")` |
### Edge Runtime 限制
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| proxy.ts 不能导入 Node.js 模块 | `const requestId = crypto.randomUUID()` (Web Crypto API) | `import { randomUUID } from "node:crypto"` |
| Edge Runtime 路由不能导入 pino | `export const runtime = "nodejs"` + `import { createModuleLogger }` | `export const runtime = "edge"` + `import { createModuleLogger }` |
| 客户端组件不能导入服务端 logger | 客户端 track-event 改为纯 no-op | 客户端 .tsx 中 `import { createModuleLogger }` |
### Request ID 贯穿
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| Server Action 包装 | `export const myAction = withRequestContext(async (args) => {...})` | 直接在 Server Action 中 `requestContextStorage.run(...)` |
| Request ID 注入 | proxy.ts 通过 `NextResponse.next({ request: { headers } })` 注入 | proxy.ts 中 `requestContextStorage.run(...)` (Edge Runtime 不支持) |
| 读取 Request ID | `getRequestContext().requestId` (从 AsyncLocalStorage) | `headers().get("x-request-id")` (重复读取) |
### ESLint no-console 规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 服务端 .ts 文件 | `import { createModuleLogger } from "@/shared/lib/logger"` | `console.error(...)` (ESLint error) |
| 客户端 .tsx 文件 | 暂时豁免(留待客户端错误上报机制处理) | - |
| scripts/ 和 tests/ | 允许 console脚本/测试场景) | - |
### 客户端错误上报
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| error.tsx 错误上报 | `useErrorReport(error)` (Hook 自动 sendBeacon) | `useEffect(() => { console.error(error) }, [error])` |
| 上报端点 | POST /api/client-error (Route Handler 用 pino 记录) | 自定义 console 输出 |
| 节流策略 | sessionStorage 记录 digest,1 分钟内不重复上报 | 每次渲染都上报 |