Files
NextEdu/docs/superpowers/plans/2026-07-07-logging-refactor.md
SpecialX 5d9981fd7d
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
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
2026-07-07 16:23:35 +08:00

63 KiB
Raw Blame History

日志系统重构实施计划

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.tsEdge Runtime通过 NextResponse.next({ request: { headers } }) 注入 x-request-idNode.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


文件结构概览

新建文件

路径 责任
src/shared/lib/logger.ts pino 实例 + createModuleLogger(module) 工厂
src/shared/lib/request-context.ts AsyncLocalStorage<RequestContext> + 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 新增 pinopino-prettydev
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:

npm install pino@^9.5.0 && npm install -D pino-pretty@^11.3.0

Expected: package.jsondependencies.pinodevDependencies.pino-pretty 出现。

  • Step 2: 在 src/env.mjs 添加 LOG_LEVEL 字段

修改 src/env.mjs,在 server 对象中添加 LOG_LEVEL,并在 runtimeEnv 中映射:

// 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.tsserverExternalPackages 添加 pino

修改 next.config.ts,将 serverExternalPackages 数组扩展:

serverExternalPackages: [
  "mysql2",
  "tencentcloud-sdk-nodejs",
  "exceljs",
  "pino",
],
  • Step 5: 验证构建无报错

Run:

npm run typecheck

Expected: 0 errors.

  • Step 6: Commit
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

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:

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
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<RequestContext>()

/**
 * 获取当前请求上下文(若存在)。
 *
 * - 在 withRequestContext 包装的调用栈内:返回完整上下文
 * - 在调用栈外(如顶层模块初始化、定时任务):返回空对象
 *
 * pino logger 的 mixin 配置会自动调用此函数混入 requestId。
 */
export function getRequestContext(): Partial<RequestContext> {
  return requestContextStorage.getStore() ?? {}
}
  • Step 4: 运行测试验证通过

Run:

npx vitest run --config vitest.unit.config.ts src/shared/lib/request-context.test.ts

Expected: PASS5 个测试用例全部通过。

  • Step 5: Commit
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

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:

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
import pino, { type Logger } from "pino"

import { env } from "@/env.mjs"
import { getRequestContext } from "./request-context"

/**
 * 全局 logger 实例。
 *
 * - 生产环境JSON 输出到 stdoutdocker 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:

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:

npm run typecheck

Expected: 0 errors.

  • Step 6: Commit
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

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:

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
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<User>, input: CreateUserInput) => {
 *     // 此处 logger.info 会自动带 requestId
 *     return handleAction(...)
 *   }
 * )
 * ```
 */
export function withRequestContext<TArgs extends unknown[], TResult>(
  fn: (...args: TArgs) => Promise<TResult>
): (...args: TArgs) => Promise<TResult> {
  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:

npx vitest run --config vitest.unit.config.ts src/shared/lib/with-request-context.test.ts

Expected: PASS4 个测试用例全部通过。

  • Step 5: 验证类型检查

Run:

npm run typecheck

Expected: 0 errors.

  • Step 6: Commit
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() 调用统一传入注入请求头的副本。

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

  // 生成或复用 requestIdWeb Crypto APIEdge 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:

npm run typecheck
npm run test:integration -- tests/integration/proxy-guard.test.ts

Expected: 0 type errorsproxy-guard 集成测试全部通过(已有测试不依赖 requestId仅新增注入逻辑

  • Step 3: 追加 proxy-guard 测试用例(验证 requestId 注入)

读取 tests/integration/proxy-guard.test.ts,在文件末尾追加一个 describe 块:

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:

npm run test:integration -- tests/integration/proxy-guard.test.ts

Expected: 既有用例 + 新增 2 个用例全部通过。

  • Step 5: Commit
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: 替换 handleActionErrorsafeActionCall 中的 console.error

读取 src/shared/lib/action-utils.ts,在文件顶部添加导入与模块 logger替换两处 console.error

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<never> {
  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<T>(
  action: () => Promise<ActionState<T>>,
  options?: {
    onError?: (error: unknown) => void
    onFinally?: () => void
  }
): Promise<ActionState<T> | 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:

npm run typecheck

Expected: 0 errors.

  • Step 3: 验证 ESLint

Run:

npm run lint

Expected: 0 errors若 ESLint no-console 规则尚未启用此步可通过Task 11 启用规则后此处不应再有 console.error

  • Step 4: Commit
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

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:

npm run typecheck

Expected: 0 errors.

  • Step 3: Commit
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

"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<string, unknown>
  status?: AuditLogStatus
}

/**
 * Record an audit log entry for the current authenticated user.
 *
 * Note: 失败时记录到 logger.warn 而非静默吞没,确保运维可感知审计写入失败。
 */
export async function logAudit(params: LogAuditParams): Promise<void> {
  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 块改为:

} 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 块改为:

} catch (error) {
  log.warn(
    { err: error, action: params.action, userEmail: params.userEmail },
    "Login log write failed"
  )
}

实施时根据 login-logger.ts 实际参数名调整字段。

  • Step 4: 验证类型检查

Run:

npm run typecheck

Expected: 0 errors.

  • Step 5: Commit
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:

# 列出所有引用待删除文件的位置
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 的实现:

import { createModuleLogger } from "@/shared/lib/logger"

const log = createModuleLogger("track")

/**
 * 业务埋点接口。
 *
 * 通过 logger.info 输出结构化事件,后续可扩展为接入外部 analytics 服务。
 */
export function trackEvent(
  name: string,
  props?: Record<string, unknown>
): void {
  log.info({ event: name, ...props }, "track event")
}

export function trackExamEvent(
  name: string,
  props?: Record<string, unknown>
): void {
  trackEvent(`exam.${name}`, props)
}

export function trackAuthEvent(
  name: string,
  props?: Record<string, unknown>
): void {
  trackEvent(`auth.${name}`, props)
}
  • Step 3: 替换所有引用方

将步骤 1 找到的所有 import 路径替换为:

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:

npm run typecheck

Expected: 0 errors。若有 error 说明引用未替换完整,回到 Step 3 修复。

  • Step 6: 验证 ESLint

Run:

npm run lint

Expected: 0 errors.

  • Step 7: Commit
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 节前缀映射表

每个文件的标准改造模式:

// 1. 文件顶部添加导入与 logger
import { createModuleLogger } from "@/shared/lib/logger"
const log = createModuleLogger("<module>")

// 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: "<prefix 去除括号且小写>"

  • 无前缀 → 按文件所属模块创建 logger

  • Step 3: 逐模块验证(每模块改完后跑 typecheck

每替换完一个模块后运行:

npm run typecheck

Expected: 0 errors。

  • Step 4: 全量验证

替换完所有 88 处后运行:

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.tspino 内部豁免)

Expected: 仅在 logger.ts 中有 console若有其他文件 0 个匹配。

  • Step 6: Commit
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

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 内部允许 consolepino 内部实现可能使用)
  {
    files: ["src/shared/lib/logger.ts"],
    rules: {
      "no-console": "off",
    },
  },
  // ... 其他既有配置块保持不变
]);
  • Step 2: 验证 ESLint 通过

Run:

npm run lint

Expected: 0 errors。若有 no-console 报错,说明 Task 10 中有遗漏的 console.*,回到 Task 10 修复。

  • Step 3: Commit
git add eslint.config.mjs
git commit -m "feat(logging): enable ESLint no-console rule with logger.ts exemption"

Phase 4error.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

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<string, string> = {}
  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:

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
"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:

npx vitest run --config vitest.unit.config.ts src/shared/hooks/use-error-report.test.tsx

Expected: PASS6 个测试用例全部通过。

  • Step 5: Commit
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

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:

npm run typecheck

Expected: 0 errors.

  • Step 3: 验证 ESLint

Run:

npm run lint

Expected: 0 errors.

  • Step 4: Commit
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" 之后):
import { useErrorReport } from "@/shared/hooks/use-error-report"
  1. 在组件函数体首行添加调用:
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:

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

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.mdshared/lib 章节下新增:

### 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<RequestContext>), `getRequestContext(): Partial<RequestContext>`

**职责**: 
- 在 Node.js runtime 中通过 AsyncLocalStorage 持有当前请求上下文requestId/userId
- pino logger 的 mixin 自动调用 getRequestContext 注入 requestId

**关键约束**: 仅在 Node.js runtime 可用proxy.tsEdge不导入此模块。

### shared/lib/with-request-context.ts

**导出**: `withRequestContext<TArgs, TResult>(fn): (...args) => Promise<TResult>`

**职责**: 
- 高阶函数包装 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.mdproxy.ts 章节追加:

**新增职责2026-07-07**: 
- 生成或复用 `x-request-id`Web Crypto APIEdge 兼容)
- 通过 `NextResponse.next({ request: { headers } })` 注入到下游 RSC / Server Action
- 响应头也设置 `x-request-id` 便于客户端关联
  • Step 3: 修改 audit-logger / change-logger / login-logger 章节记录静默失败修复
### 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 章节
### 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 章节
### 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
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 数组中追加:

{
  "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<RequestContext>"
},
{
  "name": "getRequestContext",
  "path": "src/shared/lib/request-context.ts",
  "type": "function",
  "signature": "() => Partial<RequestContext>"
},
{
  "name": "withRequestContext",
  "path": "src/shared/lib/with-request-context.ts",
  "type": "function",
  "signature": "<TArgs, TResult>(fn: (...args: TArgs) => Promise<TResult>) => (...args: TArgs) => Promise<TResult>"
}
  • Step 2: 在 modules.shared.hooks.exports 新增 useErrorReport
{
  "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 对象中新增:

"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 数组中追加:

"生成或复用 x-request-idWeb Crypto API",
"通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC / Server Action",
"响应头设置 x-request-id 便于客户端关联"
  • Step 5: 更新 dependencyMatrix

新增依赖关系:

[
  "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,并追加更新说明:

"lastUpdate": "2026-07-07",
"updates": [
  "...",
  "2026-07-07: 新增日志系统logger / request-context / with-request-context / use-error-report / api/client-errorproxy.ts 注入 requestIdaudit-logger 静默失败修复track-event 去重合并"
]
  • Step 8: 验证 JSON 有效性

Run:

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
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 末尾追加日志系统规则条目

## 日志系统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 functionlogger 无 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
git add docs/troubleshooting/known-issues.md
git commit -m "docs(troubleshooting): add logging system rules to known-issues"

Task 18: 最终验证

  • Step 1: 运行全量类型检查

Run:

npm run typecheck

Expected: 0 errors.

  • Step 2: 运行全量 ESLint

Run:

npm run lint

Expected: 0 errors.(含 no-console 规则)

  • Step 3: 运行单元测试

Run:

npm run test:unit

Expected: 全部通过,新增的 4 个测试文件request-context / logger / with-request-context / use-error-report应全部 PASS。

  • Step 4: 运行集成测试

Run:

npm run test:integration

Expected: 全部通过,含新增的 proxy-guard requestId 注入测试。

  • Step 5: 启动 dev 服务器手动验证

Run:

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 服务器,运行:

LOG_LEVEL=debug npm run dev

观察日志详细度提升debug 级别输出)。

恢复默认:

npm run dev

观察日志回到 info 级别。

  • Step 7: 最终 Commit如有未提交的修复
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