- 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
63 KiB
日志系统重构实施计划
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
文件结构概览
新建文件
| 路径 | 责任 |
|---|---|
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 |
新增 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:
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 中映射:
// 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 数组扩展:
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: PASS,5 个测试用例全部通过。
- 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 输出到 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:
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: PASS,4 个测试用例全部通过。
- 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
// 生成或复用 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:
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 块:
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: 替换
handleActionError与safeActionCall中的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.ts(pino 内部豁免)
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 内部允许 console(pino 内部实现可能使用)
{
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 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
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: PASS,6 个测试用例全部通过。
- 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 文件执行以下改动:
- 添加 import(在
"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 渲染保留
}
批量执行建议:可使用脚本或子代理分组处理(如按
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.md 的 shared/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.ts(Edge)不导入此模块。
### 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.md 中 proxy.ts 章节追加:
**新增职责(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 章节记录静默失败修复
### 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-id(Web 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.trackmodules.course-plans.lib.track-eventmodules.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-error),proxy.ts 注入 requestId,audit-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 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
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,登录后:
- 触发任意页面加载,观察终端日志应包含 JSON 格式(开发环境为 pino-pretty 彩色文本)+
requestId字段 - 在浏览器控制台手动触发错误(如访问不存在的路由),观察
/api/client-error是否收到上报(可在终端日志中看到module: "client-error"的 error 条目) - 故意触发 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