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

2150 lines
63 KiB
Markdown
Raw Blame History

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