- 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
2150 lines
63 KiB
Markdown
2150 lines
63 KiB
Markdown
# 日志系统重构实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 引入 pino 结构化日志、Request ID 贯穿、错误边界上报、静默失败修复、track-event 去重,统一全项目日志基础设施。
|
||
|
||
**Architecture:** pino logger 实例 + AsyncLocalStorage 请求上下文 + `withRequestContext` 高阶函数包装 Server Action / Route Handler。proxy.ts(Edge Runtime)通过 `NextResponse.next({ request: { headers } })` 注入 `x-request-id`,Node.js runtime 部分通过 `headers()` 读取并写入 AsyncLocalStorage。客户端 error.tsx 通过 `useErrorReport` Hook 上报到 `/api/client-error`。
|
||
|
||
**Tech Stack:** Next.js 16.0.10、React 19.2.1、pino 9.x、pino-pretty 11.x、vitest 4.1.0、ESLint 9 + eslint-config-next、@t3-oss/env-nextjs + zod
|
||
|
||
**设计文档:** [docs/superpowers/specs/2026-07-07-logging-refactor-design.md](file:///e:/Desktop/CICD/docs/superpowers/specs/2026-07-07-logging-refactor-design.md)
|
||
|
||
---
|
||
|
||
## 文件结构概览
|
||
|
||
### 新建文件
|
||
| 路径 | 责任 |
|
||
|------|------|
|
||
| `src/shared/lib/logger.ts` | pino 实例 + `createModuleLogger(module)` 工厂 |
|
||
| `src/shared/lib/request-context.ts` | `AsyncLocalStorage<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: PASS,5 个测试用例全部通过。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/shared/lib/request-context.ts src/shared/lib/request-context.test.ts
|
||
git commit -m "feat(logging): add request-context with AsyncLocalStorage"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 创建 `logger.ts`
|
||
|
||
**Files:**
|
||
- Create: `src/shared/lib/logger.ts`
|
||
- Test: `src/shared/lib/logger.test.ts`
|
||
|
||
- [ ] **Step 1: 编写失败测试 `src/shared/lib/logger.test.ts`**
|
||
|
||
```ts
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||
import { pino } from "pino"
|
||
import { createModuleLogger, logger } from "./logger"
|
||
import { requestContextStorage } from "./request-context"
|
||
|
||
// 捕获 pino 输出
|
||
function captureLoggerOutput(logInstance: pino.Logger): string[] {
|
||
const lines: string[] = []
|
||
const originalWrite = process.stdout.write.bind(process.stdout)
|
||
process.stdout.write = (chunk: string | Uint8Array) => {
|
||
lines.push(chunk.toString())
|
||
return true
|
||
}
|
||
return lines
|
||
}
|
||
|
||
describe("logger", () => {
|
||
describe("createModuleLogger", () => {
|
||
it("返回的 child logger 包含 module 字段", () => {
|
||
const lines = captureLoggerOutput(logger)
|
||
const moduleLogger = createModuleLogger("audit")
|
||
moduleLogger.info("test message")
|
||
process.stdout.write = process.stdout.write.bind(process.stdout)
|
||
// 至少有一条日志包含 module: "audit"
|
||
const auditLine = lines.find((l) => l.includes('"module":"audit"'))
|
||
expect(auditLine).toBeDefined()
|
||
})
|
||
|
||
it("不同 module 返回不同 child logger", () => {
|
||
const a = createModuleLogger("a")
|
||
const b = createModuleLogger("b")
|
||
expect(a).not.toBe(b)
|
||
})
|
||
})
|
||
|
||
describe("mixin 注入 requestId", () => {
|
||
it("在 requestContextStorage.run 内日志包含 requestId", () => {
|
||
const lines = captureLoggerOutput(logger)
|
||
requestContextStorage.run({ requestId: "req-mixin-test" }, () => {
|
||
logger.info("with request id")
|
||
})
|
||
process.stdout.write = process.stdout.write.bind(process.stdout)
|
||
const matched = lines.find((l) => l.includes('"requestId":"req-mixin-test"'))
|
||
expect(matched).toBeDefined()
|
||
})
|
||
|
||
it("在 requestContextStorage.run 外日志不包含 requestId", () => {
|
||
const lines = captureLoggerOutput(logger)
|
||
logger.info("no request id")
|
||
process.stdout.write = process.stdout.write.bind(process.stdout)
|
||
const matched = lines.find((l) => l.includes('"requestId"'))
|
||
expect(matched).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe("日志级别", () => {
|
||
it("默认级别 info 下 debug 不输出", () => {
|
||
const lines = captureLoggerOutput(logger)
|
||
logger.debug("debug message")
|
||
process.stdout.write = process.stdout.write.bind(process.stdout)
|
||
const matched = lines.find((l) => l.includes('debug message'))
|
||
expect(matched).toBeUndefined()
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
> 注:上述测试依赖 pino 的输出格式。`captureLoggerOutput` 是简化版,实际可能需要根据 pino transport 行为调整(开发环境有 pino-pretty transport)。若 transport 导致测试不稳定,可在测试中创建独立的 pino 实例验证 mixin 行为。
|
||
|
||
- [ ] **Step 2: 运行测试验证失败**
|
||
|
||
Run:
|
||
```bash
|
||
npx vitest run --config vitest.unit.config.ts src/shared/lib/logger.test.ts
|
||
```
|
||
|
||
Expected: FAIL,错误信息 "Cannot find module './logger'"
|
||
|
||
- [ ] **Step 3: 实现 `src/shared/lib/logger.ts`**
|
||
|
||
```ts
|
||
import pino, { type Logger } from "pino"
|
||
|
||
import { env } from "@/env.mjs"
|
||
import { getRequestContext } from "./request-context"
|
||
|
||
/**
|
||
* 全局 logger 实例。
|
||
*
|
||
* - 生产环境:JSON 输出到 stdout(docker logs 友好)
|
||
* - 开发环境:pino-pretty 彩色文本
|
||
* - 自动从 AsyncLocalStorage 混入 requestId / userId(若存在)
|
||
*/
|
||
export const logger = pino({
|
||
level: env.LOG_LEVEL,
|
||
base: { service: "cicd-app" },
|
||
formatters: {
|
||
level: (label) => ({ level: label }),
|
||
},
|
||
mixin: () => getRequestContext(),
|
||
...(env.NODE_ENV === "development" && {
|
||
transport: {
|
||
target: "pino-pretty",
|
||
options: { colorize: true, translateTime: "SYS:standard" },
|
||
},
|
||
}),
|
||
})
|
||
|
||
/**
|
||
* 创建模块级子 logger,自动绑定 module 字段。
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* const log = createModuleLogger("audit")
|
||
* log.info({ userId }, "User action logged")
|
||
* // 输出: {"level":"info","module":"audit","msg":"User action logged", ...}
|
||
* ```
|
||
*/
|
||
export function createModuleLogger(module: string): Logger {
|
||
return logger.child({ module })
|
||
}
|
||
|
||
export type { Logger }
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试验证通过**
|
||
|
||
Run:
|
||
```bash
|
||
npx vitest run --config vitest.unit.config.ts src/shared/lib/logger.test.ts
|
||
```
|
||
|
||
Expected: PASS。若 pino-pretty transport 在 vitest 环境不稳定,调整测试为只验证 `createModuleLogger` 的 child logger 绑定(不验证输出格式)。
|
||
|
||
- [ ] **Step 5: 验证类型检查**
|
||
|
||
Run:
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/shared/lib/logger.ts src/shared/lib/logger.test.ts
|
||
git commit -m "feat(logging): add pino logger with createModuleLogger"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 创建 `with-request-context.ts`
|
||
|
||
**Files:**
|
||
- Create: `src/shared/lib/with-request-context.ts`
|
||
- Test: `src/shared/lib/with-request-context.test.ts`
|
||
|
||
- [ ] **Step 1: 编写失败测试 `src/shared/lib/with-request-context.test.ts`**
|
||
|
||
```ts
|
||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||
|
||
// mock next/headers 的 headers() 函数
|
||
const mockHeaders = vi.fn()
|
||
vi.mock("next/headers", () => ({
|
||
headers: () => mockHeaders(),
|
||
}))
|
||
|
||
// mock crypto.randomUUID
|
||
vi.stubGlobal("crypto", {
|
||
randomUUID: () => "generated-uuid",
|
||
})
|
||
|
||
import { withRequestContext } from "./with-request-context"
|
||
import { getRequestContext } from "./request-context"
|
||
|
||
describe("withRequestContext", () => {
|
||
beforeEach(() => {
|
||
mockHeaders.mockReset()
|
||
})
|
||
|
||
it("从 headers 读取 x-request-id 并注入 context", async () => {
|
||
mockHeaders.mockReturnValue({
|
||
get: (name: string) =>
|
||
name === "x-request-id" ? "proxy-injected-id" : null,
|
||
})
|
||
|
||
const wrapped = withRequestContext(async () => {
|
||
return getRequestContext()
|
||
})
|
||
|
||
const result = await wrapped()
|
||
expect(result).toEqual({ requestId: "proxy-injected-id" })
|
||
})
|
||
|
||
it("headers 无 x-request-id 时生成新 UUID", async () => {
|
||
mockHeaders.mockReturnValue({
|
||
get: () => null,
|
||
})
|
||
|
||
const wrapped = withRequestContext(async () => {
|
||
return getRequestContext()
|
||
})
|
||
|
||
const result = await wrapped()
|
||
expect(result).toEqual({ requestId: "generated-uuid" })
|
||
})
|
||
|
||
it("包装函数的参数与返回值正常透传", async () => {
|
||
mockHeaders.mockReturnValue({ get: () => null })
|
||
|
||
const wrapped = withRequestContext(async (a: number, b: number) => {
|
||
return a + b
|
||
})
|
||
|
||
const result = await wrapped(3, 4)
|
||
expect(result).toBe(7)
|
||
})
|
||
|
||
it("调用栈下游 logger 自动获得 requestId", async () => {
|
||
mockHeaders.mockReturnValue({
|
||
get: (name: string) =>
|
||
name === "x-request-id" ? "req-downstream" : null,
|
||
})
|
||
|
||
let capturedCtx: { requestId?: string } = {}
|
||
const wrapped = withRequestContext(async () => {
|
||
// 模拟 data-access 层调用
|
||
capturedCtx = getRequestContext()
|
||
})
|
||
|
||
await wrapped()
|
||
expect(capturedCtx.requestId).toBe("req-downstream")
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试验证失败**
|
||
|
||
Run:
|
||
```bash
|
||
npx vitest run --config vitest.unit.config.ts src/shared/lib/with-request-context.test.ts
|
||
```
|
||
|
||
Expected: FAIL,错误信息 "Cannot find module './with-request-context'"
|
||
|
||
- [ ] **Step 3: 实现 `src/shared/lib/with-request-context.ts`**
|
||
|
||
```ts
|
||
import { randomUUID } from "node:crypto"
|
||
import { headers } from "next/headers"
|
||
|
||
import { requestContextStorage, type RequestContext } from "./request-context"
|
||
|
||
/**
|
||
* 包装 Server Action / Route Handler,注入 requestId 到 AsyncLocalStorage。
|
||
*
|
||
* 工作流程:
|
||
* 1. 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id`
|
||
* 2. 若请求头无此字段(如直接调用的内部函数),生成新 UUID
|
||
* 3. 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage
|
||
* 4. 在调用栈内的所有 logger 调用自动获得 requestId
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* export const createUserAction = withRequestContext(
|
||
* async (state: ActionState<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: PASS,4 个测试用例全部通过。
|
||
|
||
- [ ] **Step 5: 验证类型检查**
|
||
|
||
Run:
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/shared/lib/with-request-context.ts src/shared/lib/with-request-context.test.ts
|
||
git commit -m "feat(logging): add withRequestContext HOF for Server Actions"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2:核心接入
|
||
|
||
### Task 5: proxy.ts 注入 x-request-id
|
||
|
||
**Files:**
|
||
- Modify: `src/proxy.ts`
|
||
- Test: `tests/integration/proxy-guard.test.ts`(已存在,追加用例)
|
||
|
||
- [ ] **Step 1: 修改 `src/proxy.ts` 注入 requestId**
|
||
|
||
读取 `src/proxy.ts`,按以下方式改造。核心思路:在 proxy 函数最前面生成 requestId,所有 `NextResponse.next()` 调用统一传入注入请求头的副本。
|
||
|
||
```ts
|
||
import { NextResponse } from "next/server"
|
||
import type { NextRequest } from "next/server"
|
||
import { getToken } from "next-auth/jwt"
|
||
|
||
import { type Permission } from "@/shared/types/permissions"
|
||
import { resolveDefaultPath } from "@/shared/lib/route-resolver"
|
||
import { hasPermissionInBitmap } from "@/shared/lib/permission-bitmap"
|
||
import {
|
||
SPECIFIC_ROUTE_PERMISSIONS,
|
||
ROUTE_PREFIX_PERMISSIONS,
|
||
DASHBOARD_ROUTE_PERMISSIONS,
|
||
API_ROUTE_PERMISSIONS,
|
||
} from "@/shared/lib/route-permissions"
|
||
|
||
// Next.js 16 renamed `middleware` to `proxy`.
|
||
// See: https://nextjs.org/docs/messages/middleware-to-proxy
|
||
export async function proxy(request: NextRequest) {
|
||
const { pathname } = request.nextUrl
|
||
|
||
// 生成或复用 requestId(Web Crypto API,Edge Runtime 兼容)
|
||
const requestId =
|
||
request.headers.get("x-request-id") ?? crypto.randomUUID()
|
||
|
||
// 跳过静态资源和登录页:仍注入 requestId 便于关联下游
|
||
if (
|
||
pathname.startsWith("/_next") ||
|
||
pathname.startsWith("/api/auth") ||
|
||
pathname === "/login" ||
|
||
pathname === "/register" ||
|
||
pathname === "/favicon.ico"
|
||
) {
|
||
return NextResponse.next({
|
||
request: { headers: injectRequestId(request, requestId) },
|
||
})
|
||
}
|
||
|
||
const token = await getToken({
|
||
req: request,
|
||
secret: process.env.NEXTAUTH_SECRET,
|
||
})
|
||
|
||
// 未认证 → 重定向到登录页
|
||
if (!token) {
|
||
const loginUrl = new URL("/login", request.url)
|
||
loginUrl.searchParams.set("callbackUrl", request.url)
|
||
return NextResponse.redirect(loginUrl)
|
||
}
|
||
|
||
// Onboarding gate
|
||
const onboarded = Boolean(token.onboarded)
|
||
const isOnboardingPath = pathname === "/onboarding" || pathname.startsWith("/onboarding/")
|
||
const isWhitelistedApi = pathname.startsWith("/api/auth") || pathname.startsWith("/api/onboarding")
|
||
if (!onboarded && !isOnboardingPath && !isWhitelistedApi) {
|
||
const onboardingUrl = new URL("/onboarding", request.url)
|
||
return NextResponse.redirect(onboardingUrl)
|
||
}
|
||
if (onboarded && isOnboardingPath) {
|
||
const roles: string[] = (token.roles as string[]) ?? []
|
||
const defaultPath = resolveDefaultPath(roles)
|
||
return NextResponse.redirect(new URL(defaultPath, request.url))
|
||
}
|
||
|
||
const permissionsBitmap: string = (token.permissionsBitmap as string) ?? ""
|
||
const roles: string[] = (token.roles as string[]) ?? []
|
||
|
||
/**
|
||
* audit-P1-7:使用位图检查权限,避免每次路由检查都解码完整权限数组。
|
||
* proxy.ts 在 edge runtime 运行,每个请求都经过这里,性能至关重要。
|
||
*/
|
||
function hasPermission(requiredPerm: Permission): boolean {
|
||
return hasPermissionInBitmap(permissionsBitmap, requiredPerm)
|
||
}
|
||
|
||
// Check API route permissions
|
||
for (const [prefix, requiredPerm] of Object.entries(API_ROUTE_PERMISSIONS)) {
|
||
if (pathname.startsWith(prefix)) {
|
||
if (!hasPermission(requiredPerm)) {
|
||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
// Check page route permissions
|
||
if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) {
|
||
const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname]
|
||
if (!hasPermission(requiredPerm)) {
|
||
const defaultPath = resolveDefaultPath(roles)
|
||
const redirectUrl = new URL(defaultPath, request.url)
|
||
redirectUrl.searchParams.set("from", pathname)
|
||
redirectUrl.searchParams.set("reason", "forbidden")
|
||
return NextResponse.redirect(redirectUrl)
|
||
}
|
||
return NextResponse.next({
|
||
request: { headers: injectRequestId(request, requestId) },
|
||
})
|
||
}
|
||
|
||
if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) {
|
||
const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname]
|
||
if (!hasPermission(requiredPerm)) {
|
||
const defaultPath = resolveDefaultPath(roles)
|
||
const redirectUrl = new URL(defaultPath, request.url)
|
||
redirectUrl.searchParams.set("from", pathname)
|
||
redirectUrl.searchParams.set("reason", "forbidden")
|
||
return NextResponse.redirect(redirectUrl)
|
||
}
|
||
return NextResponse.next({
|
||
request: { headers: injectRequestId(request, requestId) },
|
||
})
|
||
}
|
||
|
||
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PREFIX_PERMISSIONS)) {
|
||
if (pathname.startsWith(prefix)) {
|
||
if (!hasPermission(requiredPerm)) {
|
||
const defaultPath = resolveDefaultPath(roles)
|
||
const redirectUrl = new URL(defaultPath, request.url)
|
||
redirectUrl.searchParams.set("from", pathname)
|
||
redirectUrl.searchParams.set("reason", "forbidden")
|
||
return NextResponse.redirect(redirectUrl)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
const response = NextResponse.next({
|
||
request: { headers: injectRequestId(request, requestId) },
|
||
})
|
||
response.headers.set("x-request-id", requestId)
|
||
return response
|
||
}
|
||
|
||
/**
|
||
* 创建包含 x-request-id 的新 Headers 对象。
|
||
* 通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC 请求。
|
||
*
|
||
* 注意:proxy.ts 在 Edge Runtime 运行,不能导入 node:async_hooks 或 request-context.ts。
|
||
*/
|
||
function injectRequestId(request: NextRequest, requestId: string): Headers {
|
||
const headers = new Headers(request.headers)
|
||
headers.set("x-request-id", requestId)
|
||
return headers
|
||
}
|
||
|
||
export const config = {
|
||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证类型检查与现有测试**
|
||
|
||
Run:
|
||
```bash
|
||
npm run typecheck
|
||
npm run test:integration -- tests/integration/proxy-guard.test.ts
|
||
```
|
||
|
||
Expected: 0 type errors;proxy-guard 集成测试全部通过(已有测试不依赖 requestId,仅新增注入逻辑)。
|
||
|
||
- [ ] **Step 3: 追加 proxy-guard 测试用例(验证 requestId 注入)**
|
||
|
||
读取 `tests/integration/proxy-guard.test.ts`,在文件末尾追加一个 describe 块:
|
||
|
||
```ts
|
||
describe("proxy requestId 注入", () => {
|
||
it("未带 x-request-id 请求头时,response 头包含新生成的 x-request-id", async () => {
|
||
const req = new NextRequest(new URL("/dashboard", "http://localhost:3000"))
|
||
// ...按现有测试的 auth mock 模式设置 token
|
||
const res = await proxy(req)
|
||
expect(res.headers.get("x-request-id")).toBeDefined()
|
||
})
|
||
|
||
it("带 x-request-id 请求头时,response 头沿用请求头中的 id", async () => {
|
||
const existingId = "client-supplied-id"
|
||
const req = new NextRequest(new URL("/dashboard", "http://localhost:3000"), {
|
||
headers: { "x-request-id": existingId },
|
||
})
|
||
// ...按现有测试的 auth mock 模式设置 token
|
||
const res = await proxy(req)
|
||
expect(res.headers.get("x-request-id")).toBe(existingId)
|
||
})
|
||
})
|
||
```
|
||
|
||
> 实施时根据现有 proxy-guard.test.ts 的 mock 模式调整 auth 设置代码。
|
||
|
||
- [ ] **Step 4: 运行新测试验证通过**
|
||
|
||
Run:
|
||
```bash
|
||
npm run test:integration -- tests/integration/proxy-guard.test.ts
|
||
```
|
||
|
||
Expected: 既有用例 + 新增 2 个用例全部通过。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/proxy.ts tests/integration/proxy-guard.test.ts
|
||
git commit -m "feat(logging): inject x-request-id in proxy.ts"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: action-utils.ts 接入 logger
|
||
|
||
**Files:**
|
||
- Modify: `src/shared/lib/action-utils.ts`
|
||
|
||
- [ ] **Step 1: 替换 `handleActionError` 与 `safeActionCall` 中的 `console.error`**
|
||
|
||
读取 `src/shared/lib/action-utils.ts`,在文件顶部添加导入与模块 logger,替换两处 `console.error`:
|
||
|
||
```ts
|
||
import { createModuleLogger } from "@/shared/lib/logger"
|
||
import type { ActionState } from "@/shared/types/action-state"
|
||
import { PermissionDeniedError } from "@/shared/lib/errors"
|
||
// ... 原有 imports
|
||
|
||
const log = createModuleLogger("action")
|
||
|
||
// ... BusinessError / NotFoundError / ValidationError 类定义不变
|
||
|
||
export function handleActionError(e: unknown): ActionState<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 内部允许 console(pino 内部实现可能使用)
|
||
{
|
||
files: ["src/shared/lib/logger.ts"],
|
||
rules: {
|
||
"no-console": "off",
|
||
},
|
||
},
|
||
// ... 其他既有配置块保持不变
|
||
]);
|
||
```
|
||
|
||
- [ ] **Step 2: 验证 ESLint 通过**
|
||
|
||
Run:
|
||
```bash
|
||
npm run lint
|
||
```
|
||
|
||
Expected: 0 errors。若有 `no-console` 报错,说明 Task 10 中有遗漏的 `console.*`,回到 Task 10 修复。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add eslint.config.mjs
|
||
git commit -m "feat(logging): enable ESLint no-console rule with logger.ts exemption"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4:error.tsx 客户端错误上报
|
||
|
||
### Task 12: 创建 use-error-report Hook
|
||
|
||
**Files:**
|
||
- Create: `src/shared/hooks/use-error-report.ts`
|
||
- Test: `src/shared/hooks/use-error-report.test.tsx`
|
||
|
||
- [ ] **Step 1: 编写失败测试 `src/shared/hooks/use-error-report.test.tsx`**
|
||
|
||
```tsx
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||
import { renderHook } from "@testing-library/react"
|
||
import { useErrorReport } from "./use-error-report"
|
||
|
||
// mock fetch
|
||
const mockFetch = vi.fn()
|
||
vi.stubGlobal("fetch", mockFetch)
|
||
|
||
// mock sessionStorage
|
||
const sessionStorageMock = (() => {
|
||
let store: Record<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: PASS,6 个测试用例全部通过。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/shared/hooks/use-error-report.ts src/shared/hooks/use-error-report.test.tsx
|
||
git commit -m "feat(logging): add useErrorReport hook for client error reporting"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: 创建 `/api/client-error` Route Handler
|
||
|
||
**Files:**
|
||
- Create: `src/app/api/client-error/route.ts`
|
||
|
||
- [ ] **Step 1: 创建 `src/app/api/client-error/route.ts`**
|
||
|
||
```ts
|
||
import { NextResponse } from "next/server"
|
||
|
||
import { createModuleLogger } from "@/shared/lib/logger"
|
||
import { withRequestContext } from "@/shared/lib/with-request-context"
|
||
|
||
const log = createModuleLogger("client-error")
|
||
|
||
interface ClientErrorPayload {
|
||
message: string
|
||
stack?: string
|
||
digest?: string
|
||
url: string
|
||
userAgent: string
|
||
timestamp: string
|
||
}
|
||
|
||
/**
|
||
* 接收客户端 error.tsx 上报的错误。
|
||
*
|
||
* 客户端错误的 requestId 来自本次 /api/client-error 的 HTTP 请求(由 proxy.ts 注入),
|
||
* digest 字段可用于关联到原始客户端错误。
|
||
*/
|
||
export const POST = withRequestContext(async (request: Request) => {
|
||
try {
|
||
const body = (await request.json()) as ClientErrorPayload
|
||
log.error(
|
||
{
|
||
clientMessage: body.message,
|
||
stack: body.stack,
|
||
digest: body.digest,
|
||
url: body.url,
|
||
userAgent: body.userAgent,
|
||
clientTimestamp: body.timestamp,
|
||
},
|
||
"Client error reported"
|
||
)
|
||
return NextResponse.json({ ok: true })
|
||
} catch (error) {
|
||
log.error({ err: error }, "Failed to parse client error payload")
|
||
return NextResponse.json({ ok: false }, { status: 400 })
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 验证类型检查**
|
||
|
||
Run:
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: 验证 ESLint**
|
||
|
||
Run:
|
||
```bash
|
||
npm run lint
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/api/client-error/route.ts
|
||
git commit -m "feat(logging): add /api/client-error endpoint for client error reporting"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: 130 个 error.tsx 接入 useErrorReport
|
||
|
||
**Files:**
|
||
- Modify: 130 个 `error.tsx` 文件
|
||
|
||
- [ ] **Step 1: 列出所有 error.tsx 文件**
|
||
|
||
Run(使用 Glob):
|
||
- Pattern: `src/app/**/error.tsx`
|
||
|
||
Expected: 约 130 个文件路径。
|
||
|
||
- [ ] **Step 2: 逐个 error.tsx 接入**
|
||
|
||
对每个 `error.tsx` 文件执行以下改动:
|
||
|
||
1. 添加 import(在 `"use client"` 之后):
|
||
```ts
|
||
import { useErrorReport } from "@/shared/hooks/use-error-report"
|
||
```
|
||
|
||
2. 在组件函数体首行添加调用:
|
||
```tsx
|
||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||
useErrorReport(error)
|
||
// ... 原有 UI 渲染保留
|
||
}
|
||
```
|
||
|
||
> **批量执行建议**:可使用脚本或子代理分组处理(如按 `src/app/(dashboard)/admin/*` 分组)。每个 error.tsx 仅加 2 行(import + 调用),改动机械且低风险。
|
||
|
||
- [ ] **Step 3: 全量验证**
|
||
|
||
Run:
|
||
```bash
|
||
npm run typecheck
|
||
npm run lint
|
||
```
|
||
|
||
Expected: 0 errors。
|
||
|
||
- [ ] **Step 4: 抽查验证接入正确**
|
||
|
||
随机抽 3-5 个 error.tsx 文件,确认:
|
||
- 文件顶部有 `import { useErrorReport } from "@/shared/hooks/use-error-report"`
|
||
- 组件函数体首行有 `useErrorReport(error)` 调用
|
||
- 接收的 props 中有 `error: Error & { digest?: string }`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add <所有 error.tsx 文件>
|
||
git commit -m "feat(logging): wire up useErrorReport in all 130 error.tsx files"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5:架构同步与最终验证
|
||
|
||
### Task 15: 更新 `004_architecture_impact_map.md`
|
||
|
||
**Files:**
|
||
- Modify: `docs/architecture/004_architecture_impact_map.md`
|
||
|
||
- [ ] **Step 1: 新增 `shared/lib` 日志相关模块章节**
|
||
|
||
在 `004_architecture_impact_map.md` 的 `shared/lib` 章节下新增:
|
||
|
||
```markdown
|
||
### shared/lib/logger.ts
|
||
|
||
**导出**: `logger` (pino.Logger), `createModuleLogger(module: string): pino.Logger`
|
||
|
||
**职责**:
|
||
- 全局 pino logger 实例(生产 JSON / 开发 pino-pretty)
|
||
- 模块级子 logger 工厂,绑定 `module` 字段
|
||
- 自动从 `requestContextStorage` 混入 `requestId` / `userId`
|
||
|
||
**依赖**:
|
||
- `@/env.mjs`(LOG_LEVEL)
|
||
- `./request-context`(getRequestContext)
|
||
|
||
**被依赖**:
|
||
- 全项目所有模块的 logger 调用
|
||
- `action-utils` / `api-response` / `audit-logger` / `change-logger` / `login-logger` / `track-event`
|
||
|
||
### shared/lib/request-context.ts
|
||
|
||
**导出**: `requestContextStorage` (AsyncLocalStorage<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` 章节追加:
|
||
|
||
```markdown
|
||
**新增职责(2026-07-07)**:
|
||
- 生成或复用 `x-request-id`(Web Crypto API,Edge 兼容)
|
||
- 通过 `NextResponse.next({ request: { headers } })` 注入到下游 RSC / Server Action
|
||
- 响应头也设置 `x-request-id` 便于客户端关联
|
||
```
|
||
|
||
- [ ] **Step 3: 修改 audit-logger / change-logger / login-logger 章节记录静默失败修复**
|
||
|
||
```markdown
|
||
### shared/lib/audit-logger.ts(修订 2026-07-07)
|
||
|
||
**变更**: catch 块从 silent 改为 `logger.warn`,运维可感知审计写入失败。
|
||
|
||
### shared/lib/track-event.ts(修订 2026-07-07)
|
||
|
||
**变更**: 实现层从 no-op console.info 改为 `createModuleLogger("track").info`,
|
||
不再需要每个模块复制 stub。删除 modules/rbac/lib/track.ts、modules/course-plans/lib/track-event.ts、modules/questions/utils/track-event.ts。
|
||
```
|
||
|
||
- [ ] **Step 4: 新增 `shared/hooks/use-error-report.ts` 章节**
|
||
|
||
```markdown
|
||
### shared/hooks/use-error-report.ts
|
||
|
||
**导出**: `useErrorReport(error: Error & { digest?: string }): void`
|
||
|
||
**职责**:
|
||
- 客户端 error.tsx 接收 error 后通过 fetch POST 上报到 `/api/client-error`
|
||
- 节流策略:sessionStorage 标记 digest,避免重复上报
|
||
- fetch 失败静默忽略,避免无限循环
|
||
|
||
**被依赖**: 130 个 error.tsx 文件
|
||
```
|
||
|
||
- [ ] **Step 5: 新增 `app/api/client-error/route.ts` 章节**
|
||
|
||
```markdown
|
||
### app/api/client-error/route.ts
|
||
|
||
**导出**: `POST` (withRequestContext 包装)
|
||
|
||
**职责**:
|
||
- 接收客户端 error.tsx 上报的错误
|
||
- 通过 `createModuleLogger("client-error").error` 记录到服务端日志
|
||
- 返回 `{ ok: true }` 确认接收
|
||
|
||
**依赖**: `@/shared/lib/logger`, `@/shared/lib/with-request-context`
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add docs/architecture/004_architecture_impact_map.md
|
||
git commit -m "docs(architecture): update 004 with logging system modules"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: 更新 `005_architecture_data.json`
|
||
|
||
**Files:**
|
||
- Modify: `docs/architecture/005_architecture_data.json`
|
||
|
||
- [ ] **Step 1: 在 `modules.shared.lib.exports` 新增日志相关导出**
|
||
|
||
读取 `005_architecture_data.json`,在 `modules.shared.lib.exports` 数组中追加:
|
||
|
||
```json
|
||
{
|
||
"name": "logger",
|
||
"path": "src/shared/lib/logger.ts",
|
||
"type": "const",
|
||
"signature": "pino.Logger"
|
||
},
|
||
{
|
||
"name": "createModuleLogger",
|
||
"path": "src/shared/lib/logger.ts",
|
||
"type": "function",
|
||
"signature": "(module: string) => pino.Logger"
|
||
},
|
||
{
|
||
"name": "requestContextStorage",
|
||
"path": "src/shared/lib/request-context.ts",
|
||
"type": "const",
|
||
"signature": "AsyncLocalStorage<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-id(Web Crypto API)",
|
||
"通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC / Server Action",
|
||
"响应头设置 x-request-id 便于客户端关联"
|
||
```
|
||
|
||
- [ ] **Step 5: 更新 `dependencyMatrix`**
|
||
|
||
新增依赖关系:
|
||
|
||
```json
|
||
[
|
||
"shared.lib.logger",
|
||
"shared.lib.request-context"
|
||
],
|
||
[
|
||
"shared.lib.with-request-context",
|
||
"shared.lib.request-context"
|
||
],
|
||
[
|
||
"app.api.client-error",
|
||
"shared.lib.logger"
|
||
],
|
||
[
|
||
"app.api.client-error",
|
||
"shared.lib.with-request-context"
|
||
],
|
||
[
|
||
"shared.hooks.use-error-report",
|
||
"app.api.client-error"
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 6: 删除已不存在的模块节点**
|
||
|
||
从 `modules` 中删除:
|
||
- `modules.rbac.lib.track`
|
||
- `modules.course-plans.lib.track-event`
|
||
- `modules.questions.utils.track-event`
|
||
|
||
并从 `dependencyMatrix` 中删除引用它们的边。
|
||
|
||
- [ ] **Step 7: 更新 `lastUpdate` 字段**
|
||
|
||
将 JSON 顶部的 `lastUpdate` 字段更新为 `2026-07-07`,并追加更新说明:
|
||
|
||
```json
|
||
"lastUpdate": "2026-07-07",
|
||
"updates": [
|
||
"...",
|
||
"2026-07-07: 新增日志系统(logger / request-context / with-request-context / use-error-report / api/client-error),proxy.ts 注入 requestId,audit-logger 静默失败修复,track-event 去重合并"
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 8: 验证 JSON 有效性**
|
||
|
||
Run:
|
||
```bash
|
||
node -e "JSON.parse(require('fs').readFileSync('docs/architecture/005_architecture_data.json', 'utf-8')); console.log('JSON valid')"
|
||
```
|
||
|
||
Expected: 输出 `JSON valid`。
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add docs/architecture/005_architecture_data.json
|
||
git commit -m "docs(architecture): update 005 with logging system modules"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: 更新 `known-issues.md`
|
||
|
||
**Files:**
|
||
- Modify: `docs/troubleshooting/known-issues.md`
|
||
|
||
- [ ] **Step 1: 在 `known-issues.md` 末尾追加日志系统规则条目**
|
||
|
||
```markdown
|
||
## 日志系统(pino + AsyncLocalStorage)
|
||
|
||
### pino 集成
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| pino 必须加入 serverExternalPackages | `serverExternalPackages: ["mysql2", ..., "pino"]` | 不配置导致 Turbopack 打包失败 |
|
||
| pino-pretty 仅开发环境 | `...(env.NODE_ENV === "development" && { transport: { target: "pino-pretty" } })` | 生产环境启用 transport 导致多进程问题 |
|
||
| 模块 logger 必须通过 createModuleLogger | `const log = createModuleLogger("audit"); log.info(...)` | `console.log("[Audit]", ...)` |
|
||
| 业务代码禁止 console | `log.error({ err: e }, "msg")` | `console.error("msg", e)` |
|
||
| logger.ts 是 no-console 唯一豁免 | eslint.config.mjs 中 overrides 块 files: ["src/shared/lib/logger.ts"] | 全项目禁 console 但未豁免 logger.ts |
|
||
|
||
### Edge Runtime 限制
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| proxy.ts 不能导入 node:async_hooks | `const requestId = crypto.randomUUID()`(Web Crypto API) | `import { AsyncLocalStorage } from "node:async_hooks"`(Edge 不支持) |
|
||
| proxy.ts 不能导入 request-context.ts | 仅通过 NextResponse.next({ request: { headers } }) 注入请求头 | `import { requestContextStorage } from "@/shared/lib/request-context"` |
|
||
| Edge Runtime 生成 UUID 用 Web Crypto | `crypto.randomUUID()`(全局 crypto 对象) | `import { randomUUID } from "node:crypto"` |
|
||
|
||
### Server Action 包装
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| Server Action 必须用 withRequestContext 包装 | `export const action = withRequestContext(async (state, input) => {...})` | 直接导出 async function(logger 无 requestId) |
|
||
| data-access 层无需显式包装 | data-access 函数中直接调用 logger(自动获取 requestId) | data-access 中重复调用 withRequestContext |
|
||
| handleActionError 是同步函数 | 在 Server Action 入口点用 withRequestContext 包装 | 在 handleActionError 内部 `await headers()` |
|
||
|
||
### error.tsx 错误上报
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| error.tsx 必须调用 useErrorReport | `useErrorReport(error)` | 不调用(错误对开发者不可见) |
|
||
| 上报必须节流 | sessionStorage 标记 digest | 无节流导致 React 重渲染时风暴 |
|
||
| 上报失败必须静默 | `.catch(() => {})` | `.catch((e) => { throw e })` 导致无限循环 |
|
||
| 客户端 error.tsx 不能直接导入 logger | 通过 fetch POST 到 /api/client-error | `import { logger } from "@/shared/lib/logger"`(logger 是服务端模块) |
|
||
|
||
### track-event 使用
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| track-event 仅从 shared/lib 导入 | `import { trackEvent } from "@/shared/lib/track-event"` | 从模块内 track-event.ts 导入(已删除) |
|
||
| 不在模块内复制 track-event stub | 删除 modules/{rbac,course-plans,questions}/lib/track*.ts | 各模块保留自己的 no-op stub |
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/troubleshooting/known-issues.md
|
||
git commit -m "docs(troubleshooting): add logging system rules to known-issues"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: 最终验证
|
||
|
||
- [ ] **Step 1: 运行全量类型检查**
|
||
|
||
Run:
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 2: 运行全量 ESLint**
|
||
|
||
Run:
|
||
```bash
|
||
npm run lint
|
||
```
|
||
|
||
Expected: 0 errors.(含 `no-console` 规则)
|
||
|
||
- [ ] **Step 3: 运行单元测试**
|
||
|
||
Run:
|
||
```bash
|
||
npm run test:unit
|
||
```
|
||
|
||
Expected: 全部通过,新增的 4 个测试文件(request-context / logger / with-request-context / use-error-report)应全部 PASS。
|
||
|
||
- [ ] **Step 4: 运行集成测试**
|
||
|
||
Run:
|
||
```bash
|
||
npm run test:integration
|
||
```
|
||
|
||
Expected: 全部通过,含新增的 proxy-guard requestId 注入测试。
|
||
|
||
- [ ] **Step 5: 启动 dev 服务器手动验证**
|
||
|
||
Run:
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
打开浏览器访问 http://localhost:3000,登录后:
|
||
1. 触发任意页面加载,观察终端日志应包含 JSON 格式(开发环境为 pino-pretty 彩色文本)+ `requestId` 字段
|
||
2. 在浏览器控制台手动触发错误(如访问不存在的路由),观察 `/api/client-error` 是否收到上报(可在终端日志中看到 `module: "client-error"` 的 error 条目)
|
||
3. 故意触发 Server Action 失败(如提交无效表单),观察日志包含 `module` + `requestId` + `error stack`
|
||
|
||
- [ ] **Step 6: 验证 LOG_LEVEL 环境变量生效**
|
||
|
||
停止 dev 服务器,运行:
|
||
```bash
|
||
LOG_LEVEL=debug npm run dev
|
||
```
|
||
|
||
观察日志详细度提升(debug 级别输出)。
|
||
|
||
恢复默认:
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
观察日志回到 info 级别。
|
||
|
||
- [ ] **Step 7: 最终 Commit(如有未提交的修复)**
|
||
|
||
```bash
|
||
git status
|
||
# 若有未提交的修复
|
||
git add <文件>
|
||
git commit -m "fix(logging): final adjustments from manual verification"
|
||
```
|
||
|
||
- [ ] **Step 8: 通知用户重构完成**
|
||
|
||
总结:
|
||
- 新增 4 个文件(logger / request-context / with-request-context / use-error-report)+ 4 个测试文件
|
||
- 新增 1 个 Route Handler(/api/client-error)
|
||
- 修改 proxy.ts / action-utils.ts / api-response.ts / 3 个 audit-logger / track-event.ts / env.mjs / next.config.ts / eslint.config.mjs
|
||
- 替换 88 处 console.* 为 logger.*
|
||
- 接入 130 个 error.tsx 的 useErrorReport
|
||
- 删除 3 个重复 track-event stub
|
||
- 同步 004 / 005 / known-issues.md 架构文档
|
||
|
||
---
|
||
|
||
## 自检 Checklist
|
||
|
||
实施过程中及完成后逐项确认:
|
||
|
||
- [ ] pino 与 pino-pretty 已安装到 package.json
|
||
- [ ] LOG_LEVEL 已添加到 env.mjs server schema 与 runtimeEnv
|
||
- [ ] next.config.ts 的 serverExternalPackages 包含 "pino"
|
||
- [ ] request-context.ts 实现 + 测试通过
|
||
- [ ] logger.ts 实现 + 测试通过
|
||
- [ ] with-request-context.ts 实现 + 测试通过
|
||
- [ ] proxy.ts 注入 x-request-id(不导入 Node.js 模块)
|
||
- [ ] action-utils.ts 的 console.error 全部替换为 logger
|
||
- [ ] api-response.ts 的 console.error 全部替换为 logger
|
||
- [ ] 三个 audit-logger 的 catch 块改为 logger.warn
|
||
- [ ] track-event.ts 改为 createModuleLogger("track")
|
||
- [ ] 三个重复 track-event 文件已删除
|
||
- [ ] 88 处 console.* 全部替换(Grep 验证 0 个真实调用点)
|
||
- [ ] ESLint no-console 规则已启用
|
||
- [ ] use-error-report Hook 实现 + 测试通过
|
||
- [ ] /api/client-error Route Handler 创建
|
||
- [ ] 130 个 error.tsx 全部接入 useErrorReport
|
||
- [ ] 004_architecture_impact_map.md 已同步
|
||
- [ ] 005_architecture_data.json 已同步且 JSON 有效
|
||
- [ ] known-issues.md 已追加日志系统规则
|
||
- [ ] npm run typecheck 0 errors
|
||
- [ ] npm run lint 0 errors
|
||
- [ ] npm run test:unit 全部通过
|
||
- [ ] npm run test:integration 全部通过
|
||
- [ ] 手动验证日志输出包含 requestId
|