# 日志系统重构实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 引入 pino 结构化日志、Request ID 贯穿、错误边界上报、静默失败修复、track-event 去重,统一全项目日志基础设施。 **Architecture:** pino logger 实例 + AsyncLocalStorage 请求上下文 + `withRequestContext` 高阶函数包装 Server Action / Route Handler。proxy.ts(Edge Runtime)通过 `NextResponse.next({ request: { headers } })` 注入 `x-request-id`,Node.js runtime 部分通过 `headers()` 读取并写入 AsyncLocalStorage。客户端 error.tsx 通过 `useErrorReport` Hook 上报到 `/api/client-error`。 **Tech Stack:** Next.js 16.0.10、React 19.2.1、pino 9.x、pino-pretty 11.x、vitest 4.1.0、ESLint 9 + eslint-config-next、@t3-oss/env-nextjs + zod **设计文档:** [docs/superpowers/specs/2026-07-07-logging-refactor-design.md](file:///e:/Desktop/CICD/docs/superpowers/specs/2026-07-07-logging-refactor-design.md) --- ## 文件结构概览 ### 新建文件 | 路径 | 责任 | |------|------| | `src/shared/lib/logger.ts` | pino 实例 + `createModuleLogger(module)` 工厂 | | `src/shared/lib/request-context.ts` | `AsyncLocalStorage` + `getRequestContext()` | | `src/shared/lib/with-request-context.ts` | 高阶函数包装 Server Action / Route Handler | | `src/shared/lib/logger.test.ts` | logger 单元测试 | | `src/shared/lib/request-context.test.ts` | request-context 单元测试 | | `src/shared/lib/with-request-context.test.ts` | with-request-context 单元测试 | | `src/shared/hooks/use-error-report.ts` | error.tsx 客户端错误上报 Hook(含节流) | | `src/shared/hooks/use-error-report.test.tsx` | Hook 单元测试 | | `src/app/api/client-error/route.ts` | 客户端错误接收端点 | ### 修改文件 | 路径 | 改动 | |------|------| | `package.json` | 新增 `pino`、`pino-pretty`(dev) | | `src/env.mjs` | 新增 `LOG_LEVEL` 字段 | | `next.config.ts` | `serverExternalPackages` 添加 `pino` | | `src/proxy.ts` | 生成/复用 requestId,注入到下游请求头 | | `src/shared/lib/action-utils.ts` | `handleActionError` / `safeActionCall` 用 logger | | `src/shared/lib/api-response.ts` | `handleApiError` 用 logger | | `src/shared/lib/audit-logger.ts` | catch 块改为 `logger.warn` | | `src/shared/lib/change-logger.ts` | catch 块改为 `logger.warn` | | `src/shared/lib/login-logger.ts` | catch 块改为 `logger.warn` | | `src/shared/lib/track-event.ts` | 改为 `createModuleLogger("track")` | | `eslint.config.mjs` | 新增 `no-console` 规则 | | 88 处 `console.*` 调用点 | 替换为 `logger.*` | | 130 个 `error.tsx` | 接入 `useErrorReport` Hook | ### 删除文件 | 路径 | 原因 | |------|------| | `src/modules/rbac/lib/track.ts` | track-event 去重,引用方改为从 `@/shared/lib/track-event` 导入 | | `src/modules/course-plans/lib/track-event.ts` | 同上 | | `src/modules/questions/utils/track-event.ts` | 同上 | ### 架构同步文件 | 路径 | 改动 | |------|------| | `docs/architecture/004_architecture_impact_map.md` | 新增模块章节、修改记录、删除记录 | | `docs/architecture/005_architecture_data.json` | 同步节点、exports、dependencyMatrix | | `docs/troubleshooting/known-issues.md` | 新增 pino / Edge Runtime / Server Action 包装等规则条目 | --- ## Phase 1:基础底座 ### Task 1: 安装依赖与环境变量配置 **Files:** - Modify: `package.json` - Modify: `src/env.mjs` - Modify: `next.config.ts` - Modify: `.env.example` - [ ] **Step 1: 安装 pino 与 pino-pretty** Run: ```bash npm install pino@^9.5.0 && npm install -D pino-pretty@^11.3.0 ``` Expected: `package.json` 中 `dependencies.pino` 与 `devDependencies.pino-pretty` 出现。 - [ ] **Step 2: 在 `src/env.mjs` 添加 `LOG_LEVEL` 字段** 修改 `src/env.mjs`,在 `server` 对象中添加 `LOG_LEVEL`,并在 `runtimeEnv` 中映射: ```js // src/env.mjs export const env = createEnv({ server: { DATABASE_URL: z.string().url(), NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NEXTAUTH_SECRET: z.string().min(1).optional(), NEXTAUTH_URL: z.string().url().optional(), AI_API_KEY: z.string().min(1).optional(), AI_BASE_URL: z.string().url().optional(), AI_MODEL: z.string().min(1).optional(), RATE_LIMIT_DRIVER: z.enum(["memory", "redis"]).default("memory"), CACHE_DRIVER: z.enum(["memory", "redis"]).default("memory"), UPSTASH_REDIS_REST_URL: z.string().url().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(), CRON_SECRET: z.string().min(1).optional(), // 新增:日志级别控制 LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), }, client: { NEXT_PUBLIC_APP_URL: z.string().url().optional(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NODE_ENV: process.env.NODE_ENV, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_URL: process.env.NEXTAUTH_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, AI_API_KEY: process.env.AI_API_KEY, AI_BASE_URL: process.env.AI_BASE_URL, AI_MODEL: process.env.AI_MODEL, RATE_LIMIT_DRIVER: process.env.RATE_LIMIT_DRIVER, CACHE_DRIVER: process.env.CACHE_DRIVER, UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, CRON_SECRET: process.env.CRON_SECRET, // 新增 LOG_LEVEL: process.env.LOG_LEVEL, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, emptyStringAsUndefined: true, }); ``` - [ ] **Step 3: 在 `.env.example` 添加 `LOG_LEVEL` 示例** 读取 `.env.example`,在文件末尾追加: ``` # 日志级别(debug/info/warn/error),默认 info LOG_LEVEL=info ``` - [ ] **Step 4: 在 `next.config.ts` 的 `serverExternalPackages` 添加 `pino`** 修改 `next.config.ts`,将 `serverExternalPackages` 数组扩展: ```ts serverExternalPackages: [ "mysql2", "tencentcloud-sdk-nodejs", "exceljs", "pino", ], ``` - [ ] **Step 5: 验证构建无报错** Run: ```bash npm run typecheck ``` Expected: 0 errors. - [ ] **Step 6: Commit** ```bash git add package.json package-lock.json src/env.mjs .env.example next.config.ts git commit -m "feat(logging): add pino dependency and LOG_LEVEL env var" ``` --- ### Task 2: 创建 `request-context.ts` **Files:** - Create: `src/shared/lib/request-context.ts` - Test: `src/shared/lib/request-context.test.ts` - [ ] **Step 1: 编写失败测试 `src/shared/lib/request-context.test.ts`** ```ts import { describe, it, expect } from "vitest" import { requestContextStorage, getRequestContext, } from "./request-context" describe("request-context", () => { describe("getRequestContext (无上下文)", () => { it("返回空对象", () => { expect(getRequestContext()).toEqual({}) }) }) describe("requestContextStorage.run (有上下文)", () => { it("在 run 回调内 getRequestContext 返回注入的上下文", () => { const ctx = { requestId: "req-123", userId: "user-456" } requestContextStorage.run(ctx, () => { expect(getRequestContext()).toEqual(ctx) }) }) it("run 回调返回值正常透传", () => { const result = requestContextStorage.run( { requestId: "req-789" }, () => "return-value" ) expect(result).toBe("return-value") }) it("嵌套 run 内层覆盖外层", () => { requestContextStorage.run({ requestId: "outer" }, () => { expect(getRequestContext().requestId).toBe("outer") requestContextStorage.run({ requestId: "inner" }, () => { expect(getRequestContext().requestId).toBe("inner") }) expect(getRequestContext().requestId).toBe("outer") }) }) it("run 回调退出后 getRequestContext 恢复为空", () => { requestContextStorage.run({ requestId: "temp" }, () => {}) expect(getRequestContext()).toEqual({}) }) }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: ```bash npx vitest run --config vitest.unit.config.ts src/shared/lib/request-context.test.ts ``` Expected: FAIL,错误信息 "Cannot find module './request-context'" - [ ] **Step 3: 实现 `src/shared/lib/request-context.ts`** ```ts import { AsyncLocalStorage } from "node:async_hooks" /** * 请求上下文,贯穿整个请求生命周期。 * * 仅在 Node.js Runtime 中可用(proxy.ts 是 Edge Runtime,不导入此模块)。 * 通过 withRequestContext 高阶函数注入。 */ export interface RequestContext { requestId: string userId?: string module?: string } export const requestContextStorage = new AsyncLocalStorage() /** * 获取当前请求上下文(若存在)。 * * - 在 withRequestContext 包装的调用栈内:返回完整上下文 * - 在调用栈外(如顶层模块初始化、定时任务):返回空对象 * * pino logger 的 mixin 配置会自动调用此函数混入 requestId。 */ export function getRequestContext(): Partial { return requestContextStorage.getStore() ?? {} } ``` - [ ] **Step 4: 运行测试验证通过** Run: ```bash npx vitest run --config vitest.unit.config.ts src/shared/lib/request-context.test.ts ``` Expected: PASS,5 个测试用例全部通过。 - [ ] **Step 5: Commit** ```bash git add src/shared/lib/request-context.ts src/shared/lib/request-context.test.ts git commit -m "feat(logging): add request-context with AsyncLocalStorage" ``` --- ### Task 3: 创建 `logger.ts` **Files:** - Create: `src/shared/lib/logger.ts` - Test: `src/shared/lib/logger.test.ts` - [ ] **Step 1: 编写失败测试 `src/shared/lib/logger.test.ts`** ```ts import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { pino } from "pino" import { createModuleLogger, logger } from "./logger" import { requestContextStorage } from "./request-context" // 捕获 pino 输出 function captureLoggerOutput(logInstance: pino.Logger): string[] { const lines: string[] = [] const originalWrite = process.stdout.write.bind(process.stdout) process.stdout.write = (chunk: string | Uint8Array) => { lines.push(chunk.toString()) return true } return lines } describe("logger", () => { describe("createModuleLogger", () => { it("返回的 child logger 包含 module 字段", () => { const lines = captureLoggerOutput(logger) const moduleLogger = createModuleLogger("audit") moduleLogger.info("test message") process.stdout.write = process.stdout.write.bind(process.stdout) // 至少有一条日志包含 module: "audit" const auditLine = lines.find((l) => l.includes('"module":"audit"')) expect(auditLine).toBeDefined() }) it("不同 module 返回不同 child logger", () => { const a = createModuleLogger("a") const b = createModuleLogger("b") expect(a).not.toBe(b) }) }) describe("mixin 注入 requestId", () => { it("在 requestContextStorage.run 内日志包含 requestId", () => { const lines = captureLoggerOutput(logger) requestContextStorage.run({ requestId: "req-mixin-test" }, () => { logger.info("with request id") }) process.stdout.write = process.stdout.write.bind(process.stdout) const matched = lines.find((l) => l.includes('"requestId":"req-mixin-test"')) expect(matched).toBeDefined() }) it("在 requestContextStorage.run 外日志不包含 requestId", () => { const lines = captureLoggerOutput(logger) logger.info("no request id") process.stdout.write = process.stdout.write.bind(process.stdout) const matched = lines.find((l) => l.includes('"requestId"')) expect(matched).toBeUndefined() }) }) describe("日志级别", () => { it("默认级别 info 下 debug 不输出", () => { const lines = captureLoggerOutput(logger) logger.debug("debug message") process.stdout.write = process.stdout.write.bind(process.stdout) const matched = lines.find((l) => l.includes('debug message')) expect(matched).toBeUndefined() }) }) }) ``` > 注:上述测试依赖 pino 的输出格式。`captureLoggerOutput` 是简化版,实际可能需要根据 pino transport 行为调整(开发环境有 pino-pretty transport)。若 transport 导致测试不稳定,可在测试中创建独立的 pino 实例验证 mixin 行为。 - [ ] **Step 2: 运行测试验证失败** Run: ```bash npx vitest run --config vitest.unit.config.ts src/shared/lib/logger.test.ts ``` Expected: FAIL,错误信息 "Cannot find module './logger'" - [ ] **Step 3: 实现 `src/shared/lib/logger.ts`** ```ts import pino, { type Logger } from "pino" import { env } from "@/env.mjs" import { getRequestContext } from "./request-context" /** * 全局 logger 实例。 * * - 生产环境:JSON 输出到 stdout(docker logs 友好) * - 开发环境:pino-pretty 彩色文本 * - 自动从 AsyncLocalStorage 混入 requestId / userId(若存在) */ export const logger = pino({ level: env.LOG_LEVEL, base: { service: "cicd-app" }, formatters: { level: (label) => ({ level: label }), }, mixin: () => getRequestContext(), ...(env.NODE_ENV === "development" && { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "SYS:standard" }, }, }), }) /** * 创建模块级子 logger,自动绑定 module 字段。 * * @example * ```ts * const log = createModuleLogger("audit") * log.info({ userId }, "User action logged") * // 输出: {"level":"info","module":"audit","msg":"User action logged", ...} * ``` */ export function createModuleLogger(module: string): Logger { return logger.child({ module }) } export type { Logger } ``` - [ ] **Step 4: 运行测试验证通过** Run: ```bash npx vitest run --config vitest.unit.config.ts src/shared/lib/logger.test.ts ``` Expected: PASS。若 pino-pretty transport 在 vitest 环境不稳定,调整测试为只验证 `createModuleLogger` 的 child logger 绑定(不验证输出格式)。 - [ ] **Step 5: 验证类型检查** Run: ```bash npm run typecheck ``` Expected: 0 errors. - [ ] **Step 6: Commit** ```bash git add src/shared/lib/logger.ts src/shared/lib/logger.test.ts git commit -m "feat(logging): add pino logger with createModuleLogger" ``` --- ### Task 4: 创建 `with-request-context.ts` **Files:** - Create: `src/shared/lib/with-request-context.ts` - Test: `src/shared/lib/with-request-context.test.ts` - [ ] **Step 1: 编写失败测试 `src/shared/lib/with-request-context.test.ts`** ```ts import { describe, it, expect, vi, beforeEach } from "vitest" // mock next/headers 的 headers() 函数 const mockHeaders = vi.fn() vi.mock("next/headers", () => ({ headers: () => mockHeaders(), })) // mock crypto.randomUUID vi.stubGlobal("crypto", { randomUUID: () => "generated-uuid", }) import { withRequestContext } from "./with-request-context" import { getRequestContext } from "./request-context" describe("withRequestContext", () => { beforeEach(() => { mockHeaders.mockReset() }) it("从 headers 读取 x-request-id 并注入 context", async () => { mockHeaders.mockReturnValue({ get: (name: string) => name === "x-request-id" ? "proxy-injected-id" : null, }) const wrapped = withRequestContext(async () => { return getRequestContext() }) const result = await wrapped() expect(result).toEqual({ requestId: "proxy-injected-id" }) }) it("headers 无 x-request-id 时生成新 UUID", async () => { mockHeaders.mockReturnValue({ get: () => null, }) const wrapped = withRequestContext(async () => { return getRequestContext() }) const result = await wrapped() expect(result).toEqual({ requestId: "generated-uuid" }) }) it("包装函数的参数与返回值正常透传", async () => { mockHeaders.mockReturnValue({ get: () => null }) const wrapped = withRequestContext(async (a: number, b: number) => { return a + b }) const result = await wrapped(3, 4) expect(result).toBe(7) }) it("调用栈下游 logger 自动获得 requestId", async () => { mockHeaders.mockReturnValue({ get: (name: string) => name === "x-request-id" ? "req-downstream" : null, }) let capturedCtx: { requestId?: string } = {} const wrapped = withRequestContext(async () => { // 模拟 data-access 层调用 capturedCtx = getRequestContext() }) await wrapped() expect(capturedCtx.requestId).toBe("req-downstream") }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: ```bash npx vitest run --config vitest.unit.config.ts src/shared/lib/with-request-context.test.ts ``` Expected: FAIL,错误信息 "Cannot find module './with-request-context'" - [ ] **Step 3: 实现 `src/shared/lib/with-request-context.ts`** ```ts import { randomUUID } from "node:crypto" import { headers } from "next/headers" import { requestContextStorage, type RequestContext } from "./request-context" /** * 包装 Server Action / Route Handler,注入 requestId 到 AsyncLocalStorage。 * * 工作流程: * 1. 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id` * 2. 若请求头无此字段(如直接调用的内部函数),生成新 UUID * 3. 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage * 4. 在调用栈内的所有 logger 调用自动获得 requestId * * @example * ```ts * export const createUserAction = withRequestContext( * async (state: ActionState, input: CreateUserInput) => { * // 此处 logger.info 会自动带 requestId * return handleAction(...) * } * ) * ``` */ export function withRequestContext( fn: (...args: TArgs) => Promise ): (...args: TArgs) => Promise { return async (...args: TArgs) => { const headersList = await headers() const requestId = headersList.get("x-request-id") ?? randomUUID() const ctx: RequestContext = { requestId } return requestContextStorage.run(ctx, () => fn(...args)) } } ``` - [ ] **Step 4: 运行测试验证通过** Run: ```bash npx vitest run --config vitest.unit.config.ts src/shared/lib/with-request-context.test.ts ``` Expected: PASS,4 个测试用例全部通过。 - [ ] **Step 5: 验证类型检查** Run: ```bash npm run typecheck ``` Expected: 0 errors. - [ ] **Step 6: Commit** ```bash git add src/shared/lib/with-request-context.ts src/shared/lib/with-request-context.test.ts git commit -m "feat(logging): add withRequestContext HOF for Server Actions" ``` --- ## Phase 2:核心接入 ### Task 5: proxy.ts 注入 x-request-id **Files:** - Modify: `src/proxy.ts` - Test: `tests/integration/proxy-guard.test.ts`(已存在,追加用例) - [ ] **Step 1: 修改 `src/proxy.ts` 注入 requestId** 读取 `src/proxy.ts`,按以下方式改造。核心思路:在 proxy 函数最前面生成 requestId,所有 `NextResponse.next()` 调用统一传入注入请求头的副本。 ```ts import { NextResponse } from "next/server" import type { NextRequest } from "next/server" import { getToken } from "next-auth/jwt" import { type Permission } from "@/shared/types/permissions" import { resolveDefaultPath } from "@/shared/lib/route-resolver" import { hasPermissionInBitmap } from "@/shared/lib/permission-bitmap" import { SPECIFIC_ROUTE_PERMISSIONS, ROUTE_PREFIX_PERMISSIONS, DASHBOARD_ROUTE_PERMISSIONS, API_ROUTE_PERMISSIONS, } from "@/shared/lib/route-permissions" // Next.js 16 renamed `middleware` to `proxy`. // See: https://nextjs.org/docs/messages/middleware-to-proxy export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl // 生成或复用 requestId(Web Crypto API,Edge Runtime 兼容) const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID() // 跳过静态资源和登录页:仍注入 requestId 便于关联下游 if ( pathname.startsWith("/_next") || pathname.startsWith("/api/auth") || pathname === "/login" || pathname === "/register" || pathname === "/favicon.ico" ) { return NextResponse.next({ request: { headers: injectRequestId(request, requestId) }, }) } const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET, }) // 未认证 → 重定向到登录页 if (!token) { const loginUrl = new URL("/login", request.url) loginUrl.searchParams.set("callbackUrl", request.url) return NextResponse.redirect(loginUrl) } // Onboarding gate const onboarded = Boolean(token.onboarded) const isOnboardingPath = pathname === "/onboarding" || pathname.startsWith("/onboarding/") const isWhitelistedApi = pathname.startsWith("/api/auth") || pathname.startsWith("/api/onboarding") if (!onboarded && !isOnboardingPath && !isWhitelistedApi) { const onboardingUrl = new URL("/onboarding", request.url) return NextResponse.redirect(onboardingUrl) } if (onboarded && isOnboardingPath) { const roles: string[] = (token.roles as string[]) ?? [] const defaultPath = resolveDefaultPath(roles) return NextResponse.redirect(new URL(defaultPath, request.url)) } const permissionsBitmap: string = (token.permissionsBitmap as string) ?? "" const roles: string[] = (token.roles as string[]) ?? [] /** * audit-P1-7:使用位图检查权限,避免每次路由检查都解码完整权限数组。 * proxy.ts 在 edge runtime 运行,每个请求都经过这里,性能至关重要。 */ function hasPermission(requiredPerm: Permission): boolean { return hasPermissionInBitmap(permissionsBitmap, requiredPerm) } // Check API route permissions for (const [prefix, requiredPerm] of Object.entries(API_ROUTE_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (!hasPermission(requiredPerm)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }) } break } } // Check page route permissions if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname] if (!hasPermission(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } return NextResponse.next({ request: { headers: injectRequestId(request, requestId) }, }) } if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname] if (!hasPermission(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } return NextResponse.next({ request: { headers: injectRequestId(request, requestId) }, }) } for (const [prefix, requiredPerm] of Object.entries(ROUTE_PREFIX_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (!hasPermission(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } break } } const response = NextResponse.next({ request: { headers: injectRequestId(request, requestId) }, }) response.headers.set("x-request-id", requestId) return response } /** * 创建包含 x-request-id 的新 Headers 对象。 * 通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC 请求。 * * 注意:proxy.ts 在 Edge Runtime 运行,不能导入 node:async_hooks 或 request-context.ts。 */ function injectRequestId(request: NextRequest, requestId: string): Headers { const headers = new Headers(request.headers) headers.set("x-request-id", requestId) return headers } export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], } ``` - [ ] **Step 2: 验证类型检查与现有测试** Run: ```bash npm run typecheck npm run test:integration -- tests/integration/proxy-guard.test.ts ``` Expected: 0 type errors;proxy-guard 集成测试全部通过(已有测试不依赖 requestId,仅新增注入逻辑)。 - [ ] **Step 3: 追加 proxy-guard 测试用例(验证 requestId 注入)** 读取 `tests/integration/proxy-guard.test.ts`,在文件末尾追加一个 describe 块: ```ts describe("proxy requestId 注入", () => { it("未带 x-request-id 请求头时,response 头包含新生成的 x-request-id", async () => { const req = new NextRequest(new URL("/dashboard", "http://localhost:3000")) // ...按现有测试的 auth mock 模式设置 token const res = await proxy(req) expect(res.headers.get("x-request-id")).toBeDefined() }) it("带 x-request-id 请求头时,response 头沿用请求头中的 id", async () => { const existingId = "client-supplied-id" const req = new NextRequest(new URL("/dashboard", "http://localhost:3000"), { headers: { "x-request-id": existingId }, }) // ...按现有测试的 auth mock 模式设置 token const res = await proxy(req) expect(res.headers.get("x-request-id")).toBe(existingId) }) }) ``` > 实施时根据现有 proxy-guard.test.ts 的 mock 模式调整 auth 设置代码。 - [ ] **Step 4: 运行新测试验证通过** Run: ```bash npm run test:integration -- tests/integration/proxy-guard.test.ts ``` Expected: 既有用例 + 新增 2 个用例全部通过。 - [ ] **Step 5: Commit** ```bash git add src/proxy.ts tests/integration/proxy-guard.test.ts git commit -m "feat(logging): inject x-request-id in proxy.ts" ``` --- ### Task 6: action-utils.ts 接入 logger **Files:** - Modify: `src/shared/lib/action-utils.ts` - [ ] **Step 1: 替换 `handleActionError` 与 `safeActionCall` 中的 `console.error`** 读取 `src/shared/lib/action-utils.ts`,在文件顶部添加导入与模块 logger,替换两处 `console.error`: ```ts import { createModuleLogger } from "@/shared/lib/logger" import type { ActionState } from "@/shared/types/action-state" import { PermissionDeniedError } from "@/shared/lib/errors" // ... 原有 imports const log = createModuleLogger("action") // ... BusinessError / NotFoundError / ValidationError 类定义不变 export function handleActionError(e: unknown): ActionState { if (e instanceof PermissionDeniedError) { return { success: false, message: e.message } } if (e instanceof BusinessError) { return { success: false, message: e.message, errorCode: e.code } } if (e instanceof Error) { log.error({ err: e }, "Action failed") return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" } } log.error({ err: e }, "Unknown action error") return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" } } export async function safeActionCall( action: () => Promise>, options?: { onError?: (error: unknown) => void onFinally?: () => void } ): Promise | null> { try { return await action() } catch (e) { options?.onError?.(e) log.error({ err: e }, "Safe action call threw") return null } finally { options?.onFinally?.() } } // ... 其他函数不变 ``` - [ ] **Step 2: 验证类型检查** Run: ```bash npm run typecheck ``` Expected: 0 errors. - [ ] **Step 3: 验证 ESLint** Run: ```bash npm run lint ``` Expected: 0 errors(若 ESLint `no-console` 规则尚未启用,此步可通过;Task 11 启用规则后此处不应再有 console.error)。 - [ ] **Step 4: Commit** ```bash git add src/shared/lib/action-utils.ts git commit -m "refactor(logging): replace console.error in action-utils with logger" ``` --- ### Task 7: api-response.ts 接入 logger **Files:** - Modify: `src/shared/lib/api-response.ts` - [ ] **Step 1: 替换 `handleApiError` 中的 `console.error`** 读取 `src/shared/lib/api-response.ts`,在文件顶部添加导入与模块 logger,替换 `handleApiError` 中的两处 `console.error`: ```ts import { NextResponse } from "next/server" import { createModuleLogger } from "@/shared/lib/logger" import { PermissionDeniedError } from "@/shared/lib/errors" import { BusinessError, NotFoundError, ValidationError, } from "@/shared/lib/action-utils" import type { ActionState } from "@/shared/types/action-state" const log = createModuleLogger("api") // ... 类型定义与 apiSuccess / apiError / apiFromAction / errorToStatus 不变 export function handleApiError( error: unknown, init?: { headers?: HeadersInit } ): NextResponse { // 已知业务错误:消息可安全暴露,无需 logger if (error instanceof PermissionDeniedError || error instanceof BusinessError) { return apiError( error instanceof PermissionDeniedError ? error.message : error.message, errorToStatus(error), error instanceof BusinessError ? error.code : undefined, init ) } // 未预期错误:记录服务端日志,不暴露细节 if (error instanceof Error) { log.error({ err: error }, "API error") } else { log.error({ err: error }, "API unknown error") } return apiError("请求失败,请稍后重试", 500, "unexpected", init) } // ... withApiErrorHandler / parseJsonBody 不变 ``` - [ ] **Step 2: 验证类型检查** Run: ```bash npm run typecheck ``` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash git add src/shared/lib/api-response.ts git commit -m "refactor(logging): replace console.error in api-response with logger" ``` --- ### Task 8: 三个 audit-logger 静默失败改为 logger.warn **Files:** - Modify: `src/shared/lib/audit-logger.ts` - Modify: `src/shared/lib/change-logger.ts` - Modify: `src/shared/lib/login-logger.ts` - [ ] **Step 1: 修改 `src/shared/lib/audit-logger.ts`** 读取 `src/shared/lib/audit-logger.ts`,在文件顶部添加 logger 导入,将 catch 块从 silent 改为 `logger.warn`: ```ts "use server" import { createId } from "@paralleldrive/cuid2" import { db } from "@/shared/db" import { auditLogs } from "@/shared/db/schema" import { getSession } from "@/shared/lib/session" import { resolveClientIp, getUserAgent } from "@/shared/lib/http-utils" import { createModuleLogger } from "@/shared/lib/logger" const log = createModuleLogger("audit-logger") export type AuditLogStatus = "success" | "failure" export interface LogAuditParams { action: string module: string targetId?: string targetType?: string detail?: Record status?: AuditLogStatus } /** * Record an audit log entry for the current authenticated user. * * Note: 失败时记录到 logger.warn 而非静默吞没,确保运维可感知审计写入失败。 */ export async function logAudit(params: LogAuditParams): Promise { try { const session = await getSession() const ipAddress = await resolveClientIp() const userAgent = await getUserAgent() await db.insert(auditLogs).values({ id: createId(), userId: session?.user?.id ?? "unknown", userName: session?.user?.name ?? "unknown", action: params.action, module: params.module, targetId: params.targetId ?? null, targetType: params.targetType ?? null, detail: params.detail ? JSON.stringify(params.detail) : null, ipAddress, userAgent, status: params.status ?? "success", }) } catch (error) { log.warn( { err: error, action: params.action, module: params.module }, "Audit log write failed" ) } } ``` - [ ] **Step 2: 修改 `src/shared/lib/change-logger.ts`** 读取 `src/shared/lib/change-logger.ts`,按相同模式修改:在文件顶部添加 `import { createModuleLogger } from "@/shared/lib/logger"`,定义 `const log = createModuleLogger("change-logger")`,将 catch 块改为: ```ts } catch (error) { log.warn( { err: error, tableName: params.tableName, recordId: params.recordId }, "Change log write failed" ) } ``` > 实施时根据 change-logger.ts 实际参数名调整字段(tableName/recordId/action 等)。 - [ ] **Step 3: 修改 `src/shared/lib/login-logger.ts`** 读取 `src/shared/lib/login-logger.ts`,按相同模式修改:定义 `const log = createModuleLogger("login-logger")`,将 catch 块改为: ```ts } catch (error) { log.warn( { err: error, action: params.action, userEmail: params.userEmail }, "Login log write failed" ) } ``` > 实施时根据 login-logger.ts 实际参数名调整字段。 - [ ] **Step 4: 验证类型检查** Run: ```bash npm run typecheck ``` Expected: 0 errors. - [ ] **Step 5: Commit** ```bash git add src/shared/lib/audit-logger.ts src/shared/lib/change-logger.ts src/shared/lib/login-logger.ts git commit -m "fix(logging): replace silent audit-logger failures with logger.warn" ``` --- ### Task 9: track-event 去重合并 **Files:** - Modify: `src/shared/lib/track-event.ts` - Delete: `src/modules/rbac/lib/track.ts` - Delete: `src/modules/course-plans/lib/track-event.ts` - Delete: `src/modules/questions/utils/track-event.ts` - [ ] **Step 1: 先 grep 所有引用方,了解替换范围** Run: ```bash # 列出所有引用待删除文件的位置 npx grep -rn "from \"@/modules/rbac/lib/track\"" src/ npx grep -rn "from \"@/modules/course-plans/lib/track-event\"" src/ npx grep -rn "from \"@/modules/questions/utils/track-event\"" src/ ``` > 实施时使用项目工具:`Grep "from \"@/modules/rbac/lib/track\""` 等。 Expected: 列出所有引用位置,记录待替换的 import 路径。 - [ ] **Step 2: 重写 `src/shared/lib/track-event.ts`** 读取 `src/shared/lib/track-event.ts`,将 no-op 实现替换为基于 logger 的实现: ```ts import { createModuleLogger } from "@/shared/lib/logger" const log = createModuleLogger("track") /** * 业务埋点接口。 * * 通过 logger.info 输出结构化事件,后续可扩展为接入外部 analytics 服务。 */ export function trackEvent( name: string, props?: Record ): void { log.info({ event: name, ...props }, "track event") } export function trackExamEvent( name: string, props?: Record ): void { trackEvent(`exam.${name}`, props) } export function trackAuthEvent( name: string, props?: Record ): void { trackEvent(`auth.${name}`, props) } ``` - [ ] **Step 3: 替换所有引用方** 将步骤 1 找到的所有 import 路径替换为: ```ts import { trackEvent /* ...其他需要的导出 */ } from "@/shared/lib/track-event" ``` - [ ] **Step 4: 删除三个重复文件** Run(使用 DeleteFile 工具,非 shell): - 删除 `src/modules/rbac/lib/track.ts` - 删除 `src/modules/course-plans/lib/track-event.ts` - 删除 `src/modules/questions/utils/track-event.ts` - [ ] **Step 5: 验证类型检查** Run: ```bash npm run typecheck ``` Expected: 0 errors。若有 error 说明引用未替换完整,回到 Step 3 修复。 - [ ] **Step 6: 验证 ESLint** Run: ```bash npm run lint ``` Expected: 0 errors. - [ ] **Step 7: Commit** ```bash git add src/shared/lib/track-event.ts src/modules/rbac/lib/track.ts src/modules/course-plans/lib/track-event.ts src/modules/questions/utils/track-event.ts <其他被修改的引用方文件> git commit -m "refactor(logging): consolidate track-event stubs into shared module" ``` > 注:被删除的文件用 `git add -u` 或显式 `git rm` 即可。 --- ## Phase 3:批量替换 console.* ### Task 10: 替换 88 处 console.* 调用 **Files:** - Modify: 88 个文件中的 `console.*` 调用点 - [ ] **Step 1: 列出所有 console.* 调用点** Run(使用 Grep 工具): - Pattern: `console\.(log|error|warn|info|debug)\(` - Output mode: `content` - `-n`: true - Glob: `src/**/*.{ts,tsx}` 记录每个文件、行号、调用类型。预期约 88 处。 - [ ] **Step 2: 按模块分组替换** 按模块分组处理,每组一次性替换: | 模块 | 主要文件 | 替换为 | |------|---------|--------| | `questions` | `src/modules/questions/utils/parse-content.ts` | `const log = createModuleLogger("questions"); log.debug(...)` | | `files` | `src/modules/files/data-access.ts` 等 | `createModuleLogger("files")` | | `audit` | `src/modules/audit/data-access.ts` 等 | `createModuleLogger("audit")` | | `exams` | `src/modules/exams/actions.ts` 等 | `createModuleLogger("exams")` | | `ai` | `src/modules/ai/services/usage-tracker.ts` 等 | `createModuleLogger("ai")` | | `notifications` | `src/modules/notifications/channels/*` 等 | `createModuleLogger("notifications")` | | `redis` / `cache` | `src/shared/lib/cache/redis-store.ts` 等 | `createModuleLogger("cache")` | | `web-vitals` | `src/app/api/web-vitals/route.ts` | `createModuleLogger("web-vitals")` | | 其他 | 按文件所属模块 | 见设计文档 5.2 节前缀映射表 | 每个文件的标准改造模式: ```ts // 1. 文件顶部添加导入与 logger import { createModuleLogger } from "@/shared/lib/logger" const log = createModuleLogger("") // 2. 替换 console.error("xxx failed:", error) → log.error({ err: error }, "xxx failed") // 3. 替换 console.log("xxx") → log.debug("xxx") // 4. 替换 console.warn("xxx") → log.warn("xxx") // 5. 替换 console.info("xxx") → log.info("xxx") ``` **前缀规范化映射**: - `[ExamAction]` → `module: "exams"`(移除手写前缀) - `[ActionError]` / `[SafeActionCall]` → 已在 Task 6 处理 - `[ApiError]` → 已在 Task 7 处理 - `[AuditLogger]` → 已在 Task 8 处理 - `[Files]` / `[files]` → `module: "files"` - 其他 `[XxxPrefix]` → `module: ""` - 无前缀 → 按文件所属模块创建 logger - [ ] **Step 3: 逐模块验证(每模块改完后跑 typecheck)** 每替换完一个模块后运行: ```bash npm run typecheck ``` Expected: 0 errors。 - [ ] **Step 4: 全量验证** 替换完所有 88 处后运行: ```bash npm run typecheck npm run lint ``` Expected: 0 errors。 > 注:此时 ESLint `no-console` 规则尚未启用,所以 lint 不会因 console 报错。Task 11 启用规则后会强制。 - [ ] **Step 5: 验证无遗漏(grep 应返回 0 个真实调用点)** Run(使用 Grep 工具): - Pattern: `console\.(log|error|warn|info|debug)\(` - Glob: `src/**/*.{ts,tsx}` - 忽略 `src/shared/lib/logger.ts`(pino 内部豁免) Expected: 仅在 `logger.ts` 中有 console(若有),其他文件 0 个匹配。 - [ ] **Step 6: Commit** ```bash git add <所有被修改的文件> git commit -m "refactor(logging): replace 88 console.* calls with module loggers" ``` --- ### Task 11: 启用 ESLint no-console 规则 **Files:** - Modify: `eslint.config.mjs` - [ ] **Step 1: 在 `eslint.config.mjs` 添加 `no-console` 规则与豁免** 读取 `eslint.config.mjs`,在 rules 配置对象中添加 `no-console`,并新增一个 overrides 块豁免 `logger.ts`: ```js import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname, join } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, { rules: { "react-hooks/incompatible-library": "off", "@typescript-eslint/no-unused-vars": [ "warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_", }, ], // 禁止硬编码 hex 颜色字面量 "no-restricted-syntax": [ "error", { selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]", message: "禁止硬编码 hex 颜色,使用设计令牌 hsl(var(--*)) 或 Tailwind 类 bg-*", }, ], // 新增:禁止使用 console,统一通过 logger 模块 "no-console": [ "error", { allow: [], allowWithImplicit: false }, ], }, }, // 新增:logger.ts 内部允许 console(pino 内部实现可能使用) { files: ["src/shared/lib/logger.ts"], rules: { "no-console": "off", }, }, // ... 其他既有配置块保持不变 ]); ``` - [ ] **Step 2: 验证 ESLint 通过** Run: ```bash npm run lint ``` Expected: 0 errors。若有 `no-console` 报错,说明 Task 10 中有遗漏的 `console.*`,回到 Task 10 修复。 - [ ] **Step 3: Commit** ```bash git add eslint.config.mjs git commit -m "feat(logging): enable ESLint no-console rule with logger.ts exemption" ``` --- ## Phase 4:error.tsx 客户端错误上报 ### Task 12: 创建 use-error-report Hook **Files:** - Create: `src/shared/hooks/use-error-report.ts` - Test: `src/shared/hooks/use-error-report.test.tsx` - [ ] **Step 1: 编写失败测试 `src/shared/hooks/use-error-report.test.tsx`** ```tsx import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { renderHook } from "@testing-library/react" import { useErrorReport } from "./use-error-report" // mock fetch const mockFetch = vi.fn() vi.stubGlobal("fetch", mockFetch) // mock sessionStorage const sessionStorageMock = (() => { let store: Record = {} return { getItem: vi.fn((key: string) => store[key] ?? null), setItem: vi.fn((key: string, value: string) => { store[key] = value }), clear: vi.fn(() => { store = {} }), } })() vi.stubGlobal("sessionStorage", sessionStorageMock) describe("useErrorReport", () => { beforeEach(() => { mockFetch.mockReset() sessionStorageMock.clear() }) it("调用 fetch POST 到 /api/client-error", () => { const error = new Error("Test error") error.stack = "stack trace" renderHook(() => useErrorReport(error)) expect(mockFetch).toHaveBeenCalledWith( "/api/client-error", expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json" }, keepalive: true, }) ) const body = JSON.parse(mockFetch.mock.calls[0][1].body) expect(body.message).toBe("Test error") expect(body.stack).toBe("stack trace") expect(body.url).toBeDefined() expect(body.userAgent).toBeDefined() expect(body.timestamp).toBeDefined() }) it("同一 digest 不重复上报(节流)", () => { const error = new Error("Repeated error") error.digest = "digest-123" const { rerender } = renderHook(() => useErrorReport(error)) expect(mockFetch).toHaveBeenCalledTimes(1) // 重渲染同一 error,应被节流 rerender() expect(mockFetch).toHaveBeenCalledTimes(1) }) it("不同 digest 分别上报", () => { const error1 = new Error("Error A") error1.digest = "digest-a" const error2 = new Error("Error B") error2.digest = "digest-b" const { rerender } = renderHook(({ err }) => useErrorReport(err), { initialProps: error1, }) expect(mockFetch).toHaveBeenCalledTimes(1) rerender(error2) expect(mockFetch).toHaveBeenCalledTimes(2) }) it("无 digest 时用 message 作为节流 key", () => { const error = new Error("No digest error") renderHook(() => useErrorReport(error)) expect(mockFetch).toHaveBeenCalledTimes(1) expect(sessionStorageMock.setItem).toHaveBeenCalledWith( "error-reported:No digest error", "1" ) }) it("error 为空时不调用 fetch", () => { renderHook(() => useErrorReport(null as unknown as Error)) expect(mockFetch).not.toHaveBeenCalled() }) it("fetch 失败时不抛出(避免无限循环)", async () => { mockFetch.mockRejectedValueOnce(new Error("Network error")) const error = new Error("Trigger error") const { result } = renderHook(() => useErrorReport(error)) // 不应有 unhandled rejection expect(result.current).toBeUndefined() }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: ```bash npx vitest run --config vitest.unit.config.ts src/shared/hooks/use-error-report.test.tsx ``` Expected: FAIL,错误信息 "Cannot find module './use-error-report'" - [ ] **Step 3: 实现 `src/shared/hooks/use-error-report.ts`** ```ts "use client" import { useEffect } from "react" interface ClientErrorPayload { message: string stack?: string digest?: string url: string userAgent: string timestamp: string } /** * 客户端错误上报 Hook。 * * 用于 error.tsx 接收 error prop 后上报到 /api/client-error。 * * 节流策略: * - 同一 digest(或 message)在 sessionStorage 中标记,避免 React 重渲染或快速刷新时多次上报 * - 上报失败时静默忽略,避免无限循环 * * @example * ```tsx * "use client" * import { useErrorReport } from "@/shared/hooks/use-error-report" * * export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { * useErrorReport(error) * // ... UI * } * ``` */ export function useErrorReport(error: Error & { digest?: string }): void { useEffect(() => { if (!error) return const digest = error.digest ?? error.message const storageKey = `error-reported:${digest}` if (sessionStorage.getItem(storageKey)) return sessionStorage.setItem(storageKey, "1") const payload: ClientErrorPayload = { message: error.message, stack: error.stack, digest: error.digest, url: window.location.href, userAgent: navigator.userAgent, timestamp: new Date().toISOString(), } fetch("/api/client-error", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), keepalive: true, }).catch(() => { // 上报失败时静默忽略,避免无限循环 }) }, [error]) } ``` - [ ] **Step 4: 运行测试验证通过** Run: ```bash npx vitest run --config vitest.unit.config.ts src/shared/hooks/use-error-report.test.tsx ``` Expected: PASS,6 个测试用例全部通过。 - [ ] **Step 5: Commit** ```bash git add src/shared/hooks/use-error-report.ts src/shared/hooks/use-error-report.test.tsx git commit -m "feat(logging): add useErrorReport hook for client error reporting" ``` --- ### Task 13: 创建 `/api/client-error` Route Handler **Files:** - Create: `src/app/api/client-error/route.ts` - [ ] **Step 1: 创建 `src/app/api/client-error/route.ts`** ```ts import { NextResponse } from "next/server" import { createModuleLogger } from "@/shared/lib/logger" import { withRequestContext } from "@/shared/lib/with-request-context" const log = createModuleLogger("client-error") interface ClientErrorPayload { message: string stack?: string digest?: string url: string userAgent: string timestamp: string } /** * 接收客户端 error.tsx 上报的错误。 * * 客户端错误的 requestId 来自本次 /api/client-error 的 HTTP 请求(由 proxy.ts 注入), * digest 字段可用于关联到原始客户端错误。 */ export const POST = withRequestContext(async (request: Request) => { try { const body = (await request.json()) as ClientErrorPayload log.error( { clientMessage: body.message, stack: body.stack, digest: body.digest, url: body.url, userAgent: body.userAgent, clientTimestamp: body.timestamp, }, "Client error reported" ) return NextResponse.json({ ok: true }) } catch (error) { log.error({ err: error }, "Failed to parse client error payload") return NextResponse.json({ ok: false }, { status: 400 }) } }) ``` - [ ] **Step 2: 验证类型检查** Run: ```bash npm run typecheck ``` Expected: 0 errors. - [ ] **Step 3: 验证 ESLint** Run: ```bash npm run lint ``` Expected: 0 errors. - [ ] **Step 4: Commit** ```bash git add src/app/api/client-error/route.ts git commit -m "feat(logging): add /api/client-error endpoint for client error reporting" ``` --- ### Task 14: 130 个 error.tsx 接入 useErrorReport **Files:** - Modify: 130 个 `error.tsx` 文件 - [ ] **Step 1: 列出所有 error.tsx 文件** Run(使用 Glob): - Pattern: `src/app/**/error.tsx` Expected: 约 130 个文件路径。 - [ ] **Step 2: 逐个 error.tsx 接入** 对每个 `error.tsx` 文件执行以下改动: 1. 添加 import(在 `"use client"` 之后): ```ts import { useErrorReport } from "@/shared/hooks/use-error-report" ``` 2. 在组件函数体首行添加调用: ```tsx export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { useErrorReport(error) // ... 原有 UI 渲染保留 } ``` > **批量执行建议**:可使用脚本或子代理分组处理(如按 `src/app/(dashboard)/admin/*` 分组)。每个 error.tsx 仅加 2 行(import + 调用),改动机械且低风险。 - [ ] **Step 3: 全量验证** Run: ```bash npm run typecheck npm run lint ``` Expected: 0 errors。 - [ ] **Step 4: 抽查验证接入正确** 随机抽 3-5 个 error.tsx 文件,确认: - 文件顶部有 `import { useErrorReport } from "@/shared/hooks/use-error-report"` - 组件函数体首行有 `useErrorReport(error)` 调用 - 接收的 props 中有 `error: Error & { digest?: string }` - [ ] **Step 5: Commit** ```bash git add <所有 error.tsx 文件> git commit -m "feat(logging): wire up useErrorReport in all 130 error.tsx files" ``` --- ## Phase 5:架构同步与最终验证 ### Task 15: 更新 `004_architecture_impact_map.md` **Files:** - Modify: `docs/architecture/004_architecture_impact_map.md` - [ ] **Step 1: 新增 `shared/lib` 日志相关模块章节** 在 `004_architecture_impact_map.md` 的 `shared/lib` 章节下新增: ```markdown ### shared/lib/logger.ts **导出**: `logger` (pino.Logger), `createModuleLogger(module: string): pino.Logger` **职责**: - 全局 pino logger 实例(生产 JSON / 开发 pino-pretty) - 模块级子 logger 工厂,绑定 `module` 字段 - 自动从 `requestContextStorage` 混入 `requestId` / `userId` **依赖**: - `@/env.mjs`(LOG_LEVEL) - `./request-context`(getRequestContext) **被依赖**: - 全项目所有模块的 logger 调用 - `action-utils` / `api-response` / `audit-logger` / `change-logger` / `login-logger` / `track-event` ### shared/lib/request-context.ts **导出**: `requestContextStorage` (AsyncLocalStorage), `getRequestContext(): Partial` **职责**: - 在 Node.js runtime 中通过 AsyncLocalStorage 持有当前请求上下文(requestId/userId) - pino logger 的 mixin 自动调用 getRequestContext 注入 requestId **关键约束**: 仅在 Node.js runtime 可用,proxy.ts(Edge)不导入此模块。 ### shared/lib/with-request-context.ts **导出**: `withRequestContext(fn): (...args) => Promise` **职责**: - 高阶函数包装 Server Action / Route Handler - 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id` - 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage **被依赖**: Server Action 模块、`/api/client-error` Route Handler ``` - [ ] **Step 2: 修改 `proxy.ts` 章节记录 requestId 注入** 在 `004_architecture_impact_map.md` 中 `proxy.ts` 章节追加: ```markdown **新增职责(2026-07-07)**: - 生成或复用 `x-request-id`(Web Crypto API,Edge 兼容) - 通过 `NextResponse.next({ request: { headers } })` 注入到下游 RSC / Server Action - 响应头也设置 `x-request-id` 便于客户端关联 ``` - [ ] **Step 3: 修改 audit-logger / change-logger / login-logger 章节记录静默失败修复** ```markdown ### shared/lib/audit-logger.ts(修订 2026-07-07) **变更**: catch 块从 silent 改为 `logger.warn`,运维可感知审计写入失败。 ### shared/lib/track-event.ts(修订 2026-07-07) **变更**: 实现层从 no-op console.info 改为 `createModuleLogger("track").info`, 不再需要每个模块复制 stub。删除 modules/rbac/lib/track.ts、modules/course-plans/lib/track-event.ts、modules/questions/utils/track-event.ts。 ``` - [ ] **Step 4: 新增 `shared/hooks/use-error-report.ts` 章节** ```markdown ### shared/hooks/use-error-report.ts **导出**: `useErrorReport(error: Error & { digest?: string }): void` **职责**: - 客户端 error.tsx 接收 error 后通过 fetch POST 上报到 `/api/client-error` - 节流策略:sessionStorage 标记 digest,避免重复上报 - fetch 失败静默忽略,避免无限循环 **被依赖**: 130 个 error.tsx 文件 ``` - [ ] **Step 5: 新增 `app/api/client-error/route.ts` 章节** ```markdown ### app/api/client-error/route.ts **导出**: `POST` (withRequestContext 包装) **职责**: - 接收客户端 error.tsx 上报的错误 - 通过 `createModuleLogger("client-error").error` 记录到服务端日志 - 返回 `{ ok: true }` 确认接收 **依赖**: `@/shared/lib/logger`, `@/shared/lib/with-request-context` ``` - [ ] **Step 6: Commit** ```bash git add docs/architecture/004_architecture_impact_map.md git commit -m "docs(architecture): update 004 with logging system modules" ``` --- ### Task 16: 更新 `005_architecture_data.json` **Files:** - Modify: `docs/architecture/005_architecture_data.json` - [ ] **Step 1: 在 `modules.shared.lib.exports` 新增日志相关导出** 读取 `005_architecture_data.json`,在 `modules.shared.lib.exports` 数组中追加: ```json { "name": "logger", "path": "src/shared/lib/logger.ts", "type": "const", "signature": "pino.Logger" }, { "name": "createModuleLogger", "path": "src/shared/lib/logger.ts", "type": "function", "signature": "(module: string) => pino.Logger" }, { "name": "requestContextStorage", "path": "src/shared/lib/request-context.ts", "type": "const", "signature": "AsyncLocalStorage" }, { "name": "getRequestContext", "path": "src/shared/lib/request-context.ts", "type": "function", "signature": "() => Partial" }, { "name": "withRequestContext", "path": "src/shared/lib/with-request-context.ts", "type": "function", "signature": "(fn: (...args: TArgs) => Promise) => (...args: TArgs) => Promise" } ``` - [ ] **Step 2: 在 `modules.shared.hooks.exports` 新增 `useErrorReport`** ```json { "name": "useErrorReport", "path": "src/shared/hooks/use-error-report.ts", "type": "function", "signature": "(error: Error & { digest?: string }) => void" } ``` - [ ] **Step 3: 新增 `app.api.client-error` 模块节点** 在 `modules` 对象中新增: ```json "app.api.client-error": { "path": "src/app/api/client-error", "type": "route-handler", "exports": [ { "name": "POST", "type": "function", "wrappedWith": "withRequestContext" } ], "dependencies": [ "shared/lib/logger", "shared/lib/with-request-context" ] } ``` - [ ] **Step 4: 更新 `proxy` 节点(注入 requestId 职责)** 在 `modules.proxy` 节点的 `responsibilities` 数组中追加: ```json "生成或复用 x-request-id(Web Crypto API)", "通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC / Server Action", "响应头设置 x-request-id 便于客户端关联" ``` - [ ] **Step 5: 更新 `dependencyMatrix`** 新增依赖关系: ```json [ "shared.lib.logger", "shared.lib.request-context" ], [ "shared.lib.with-request-context", "shared.lib.request-context" ], [ "app.api.client-error", "shared.lib.logger" ], [ "app.api.client-error", "shared.lib.with-request-context" ], [ "shared.hooks.use-error-report", "app.api.client-error" ] ``` - [ ] **Step 6: 删除已不存在的模块节点** 从 `modules` 中删除: - `modules.rbac.lib.track` - `modules.course-plans.lib.track-event` - `modules.questions.utils.track-event` 并从 `dependencyMatrix` 中删除引用它们的边。 - [ ] **Step 7: 更新 `lastUpdate` 字段** 将 JSON 顶部的 `lastUpdate` 字段更新为 `2026-07-07`,并追加更新说明: ```json "lastUpdate": "2026-07-07", "updates": [ "...", "2026-07-07: 新增日志系统(logger / request-context / with-request-context / use-error-report / api/client-error),proxy.ts 注入 requestId,audit-logger 静默失败修复,track-event 去重合并" ] ``` - [ ] **Step 8: 验证 JSON 有效性** Run: ```bash node -e "JSON.parse(require('fs').readFileSync('docs/architecture/005_architecture_data.json', 'utf-8')); console.log('JSON valid')" ``` Expected: 输出 `JSON valid`。 - [ ] **Step 9: Commit** ```bash git add docs/architecture/005_architecture_data.json git commit -m "docs(architecture): update 005 with logging system modules" ``` --- ### Task 17: 更新 `known-issues.md` **Files:** - Modify: `docs/troubleshooting/known-issues.md` - [ ] **Step 1: 在 `known-issues.md` 末尾追加日志系统规则条目** ```markdown ## 日志系统(pino + AsyncLocalStorage) ### pino 集成 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | pino 必须加入 serverExternalPackages | `serverExternalPackages: ["mysql2", ..., "pino"]` | 不配置导致 Turbopack 打包失败 | | pino-pretty 仅开发环境 | `...(env.NODE_ENV === "development" && { transport: { target: "pino-pretty" } })` | 生产环境启用 transport 导致多进程问题 | | 模块 logger 必须通过 createModuleLogger | `const log = createModuleLogger("audit"); log.info(...)` | `console.log("[Audit]", ...)` | | 业务代码禁止 console | `log.error({ err: e }, "msg")` | `console.error("msg", e)` | | logger.ts 是 no-console 唯一豁免 | eslint.config.mjs 中 overrides 块 files: ["src/shared/lib/logger.ts"] | 全项目禁 console 但未豁免 logger.ts | ### Edge Runtime 限制 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | proxy.ts 不能导入 node:async_hooks | `const requestId = crypto.randomUUID()`(Web Crypto API) | `import { AsyncLocalStorage } from "node:async_hooks"`(Edge 不支持) | | proxy.ts 不能导入 request-context.ts | 仅通过 NextResponse.next({ request: { headers } }) 注入请求头 | `import { requestContextStorage } from "@/shared/lib/request-context"` | | Edge Runtime 生成 UUID 用 Web Crypto | `crypto.randomUUID()`(全局 crypto 对象) | `import { randomUUID } from "node:crypto"` | ### Server Action 包装 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | Server Action 必须用 withRequestContext 包装 | `export const action = withRequestContext(async (state, input) => {...})` | 直接导出 async function(logger 无 requestId) | | data-access 层无需显式包装 | data-access 函数中直接调用 logger(自动获取 requestId) | data-access 中重复调用 withRequestContext | | handleActionError 是同步函数 | 在 Server Action 入口点用 withRequestContext 包装 | 在 handleActionError 内部 `await headers()` | ### error.tsx 错误上报 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | error.tsx 必须调用 useErrorReport | `useErrorReport(error)` | 不调用(错误对开发者不可见) | | 上报必须节流 | sessionStorage 标记 digest | 无节流导致 React 重渲染时风暴 | | 上报失败必须静默 | `.catch(() => {})` | `.catch((e) => { throw e })` 导致无限循环 | | 客户端 error.tsx 不能直接导入 logger | 通过 fetch POST 到 /api/client-error | `import { logger } from "@/shared/lib/logger"`(logger 是服务端模块) | ### track-event 使用 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | track-event 仅从 shared/lib 导入 | `import { trackEvent } from "@/shared/lib/track-event"` | 从模块内 track-event.ts 导入(已删除) | | 不在模块内复制 track-event stub | 删除 modules/{rbac,course-plans,questions}/lib/track*.ts | 各模块保留自己的 no-op stub | ``` - [ ] **Step 2: Commit** ```bash git add docs/troubleshooting/known-issues.md git commit -m "docs(troubleshooting): add logging system rules to known-issues" ``` --- ### Task 18: 最终验证 - [ ] **Step 1: 运行全量类型检查** Run: ```bash npm run typecheck ``` Expected: 0 errors. - [ ] **Step 2: 运行全量 ESLint** Run: ```bash npm run lint ``` Expected: 0 errors.(含 `no-console` 规则) - [ ] **Step 3: 运行单元测试** Run: ```bash npm run test:unit ``` Expected: 全部通过,新增的 4 个测试文件(request-context / logger / with-request-context / use-error-report)应全部 PASS。 - [ ] **Step 4: 运行集成测试** Run: ```bash npm run test:integration ``` Expected: 全部通过,含新增的 proxy-guard requestId 注入测试。 - [ ] **Step 5: 启动 dev 服务器手动验证** Run: ```bash npm run dev ``` 打开浏览器访问 http://localhost:3000,登录后: 1. 触发任意页面加载,观察终端日志应包含 JSON 格式(开发环境为 pino-pretty 彩色文本)+ `requestId` 字段 2. 在浏览器控制台手动触发错误(如访问不存在的路由),观察 `/api/client-error` 是否收到上报(可在终端日志中看到 `module: "client-error"` 的 error 条目) 3. 故意触发 Server Action 失败(如提交无效表单),观察日志包含 `module` + `requestId` + `error stack` - [ ] **Step 6: 验证 LOG_LEVEL 环境变量生效** 停止 dev 服务器,运行: ```bash LOG_LEVEL=debug npm run dev ``` 观察日志详细度提升(debug 级别输出)。 恢复默认: ```bash npm run dev ``` 观察日志回到 info 级别。 - [ ] **Step 7: 最终 Commit(如有未提交的修复)** ```bash git status # 若有未提交的修复 git add <文件> git commit -m "fix(logging): final adjustments from manual verification" ``` - [ ] **Step 8: 通知用户重构完成** 总结: - 新增 4 个文件(logger / request-context / with-request-context / use-error-report)+ 4 个测试文件 - 新增 1 个 Route Handler(/api/client-error) - 修改 proxy.ts / action-utils.ts / api-response.ts / 3 个 audit-logger / track-event.ts / env.mjs / next.config.ts / eslint.config.mjs - 替换 88 处 console.* 为 logger.* - 接入 130 个 error.tsx 的 useErrorReport - 删除 3 个重复 track-event stub - 同步 004 / 005 / known-issues.md 架构文档 --- ## 自检 Checklist 实施过程中及完成后逐项确认: - [ ] pino 与 pino-pretty 已安装到 package.json - [ ] LOG_LEVEL 已添加到 env.mjs server schema 与 runtimeEnv - [ ] next.config.ts 的 serverExternalPackages 包含 "pino" - [ ] request-context.ts 实现 + 测试通过 - [ ] logger.ts 实现 + 测试通过 - [ ] with-request-context.ts 实现 + 测试通过 - [ ] proxy.ts 注入 x-request-id(不导入 Node.js 模块) - [ ] action-utils.ts 的 console.error 全部替换为 logger - [ ] api-response.ts 的 console.error 全部替换为 logger - [ ] 三个 audit-logger 的 catch 块改为 logger.warn - [ ] track-event.ts 改为 createModuleLogger("track") - [ ] 三个重复 track-event 文件已删除 - [ ] 88 处 console.* 全部替换(Grep 验证 0 个真实调用点) - [ ] ESLint no-console 规则已启用 - [ ] use-error-report Hook 实现 + 测试通过 - [ ] /api/client-error Route Handler 创建 - [ ] 130 个 error.tsx 全部接入 useErrorReport - [ ] 004_architecture_impact_map.md 已同步 - [ ] 005_architecture_data.json 已同步且 JSON 有效 - [ ] known-issues.md 已追加日志系统规则 - [ ] npm run typecheck 0 errors - [ ] npm run lint 0 errors - [ ] npm run test:unit 全部通过 - [ ] npm run test:integration 全部通过 - [ ] 手动验证日志输出包含 requestId