From e495a9b1422c75a61f7e0c47a74dc68d0734aa4c Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Wed, 24 Jun 2026 13:32:18 +0800 Subject: [PATCH] Add implementation plan: structured logging (log4js fileSync) --- .../plans/2026-06-24-structured-logging.md | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-structured-logging.md diff --git a/docs/superpowers/plans/2026-06-24-structured-logging.md b/docs/superpowers/plans/2026-06-24-structured-logging.md new file mode 100644 index 0000000..e021341 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-structured-logging.md @@ -0,0 +1,454 @@ +# 结构化日志(log4js fileSync)Implementation Plan + +> **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:** 给生产数据查询工具加上结构化、分级、同步落盘、自带轮转的服务端日志,聚焦错误与崩溃诊断(尤其 export OOM)。 + +**Architecture:** log4js 的 `fileSync` appender(同步写 = 崩溃前可靠落盘)写到 `logs/app/app.log`(10MB×5 轮转),由 `src/server/logger.ts` 单例配置;纯函数 `formatError` 抽到 `log-format.ts` 单测;两个 API 路由替换 `console.*` 为带完整堆栈与耗时字段的 logger 调用。NSSM 的 stdout/stderr 保持不动作兜底。 + +**Tech Stack:** Next 16.2.9(route handlers,server-only)、log4js 6.9.1(新增依赖)、vitest(node 环境,仅测纯逻辑)。 + +## Global Constraints + +- 新增依赖 **log4js@6.9.1**,用其 **`fileSync`** appender(同步写)。部署到 114 时**必须** `npm install`。 +- logger 仅在服务端(API route)使用,依赖 Node `fs`;不得被客户端组件引入。 +- 日志文件:项目根下 `logs/app/app.log`,`maxLogSize=10MB`、`backups=5`,pattern 布局 `[%d{ISO8601}] [%p] [%c] %m`,默认级别 `info`。 +- 测试:vitest(`environment: node`,匹配 `src/**/*.test.ts`)。**仅** `formatError` 做单测;路由日志为副作用,手验。 +- 不改 `dbConfig` 重复、不改业务返回逻辑、不动 NSSM stdout/stderr。 +- commit message 用英文;实现后启动本地 `next dev` 供验证,**通过后再部署**。 + +## File Structure + +| 文件 | 责任 | 动作 | +|------|------|------| +| `src/server/log-format.ts` | 纯函数 `formatError(err)`:提取堆栈/字符串 | 新增 | +| `src/server/log-format.test.ts` | `formatError` 单测 | 新增 | +| `src/server/logger.ts` | log4js configure(fileSync)+ 导出 `apiLogger`/`exportLogger` + 再导出 `formatError` | 新增 | +| `src/app/api/production-data/route.ts` | 失败 ERROR(带栈+workshopNo+dur)、慢查询 WARN(>3s) | 修改 | +| `src/app/api/export-excel/route.ts` | 锁占用/回收 WARN、开始/成功 INFO(rows/bytes/dur)、失败 ERROR(带栈+dur) | 修改 | +| `package.json` | +`log4js` | 修改 | + +--- + +## Task 1: log4js 依赖 + `formatError`(TDD)+ logger 模块 + +**Files:** +- Create: `src/server/log-format.ts` +- Test: `src/server/log-format.test.ts` +- Create: `src/server/logger.ts` +- Modify: `package.json`(`npm install log4js`) + +**Interfaces:** +- Produces: + - `formatError(err: unknown): string`(来自 `log-format.ts`,`logger.ts` 再导出)。 + - `apiLogger`、`exportLogger`:log4js Logger 实例(category 分别为 `"api"`、`"export"`)。 + +- [ ] **Step 1: 安装依赖** + +Run: `npm install log4js` +Expected: `added N packages`,`package.json` 出现 `"log4js": "^6.9.1"`。 + +- [ ] **Step 2: 写失败测试** + +Create `src/server/log-format.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { formatError } from "./log-format"; + +describe("formatError", () => { + it("returns stack for Error", () => { + const e = new Error("boom"); + expect(formatError(e)).toBe(e.stack); + }); + it("falls back to message when stack missing", () => { + const e = new Error("boom"); + e.stack = undefined as unknown as string; + expect(formatError(e)).toBe("boom"); + }); + it("stringifies non-Error primitives", () => { + expect(formatError("oops")).toBe("oops"); + expect(formatError(42)).toBe("42"); + }); + it("handles null / undefined", () => { + expect(formatError(null)).toBe("Unknown error"); + expect(formatError(undefined)).toBe("Unknown error"); + }); +}); +``` + +- [ ] **Step 3: 运行测试确认失败** + +Run: `npx vitest run src/server/log-format.test.ts` +Expected: FAIL — `Cannot find module './log-format'`。 + +- [ ] **Step 4: 写 `formatError` 实现** + +Create `src/server/log-format.ts`: + +```ts +/** + * 把任意错误/值格式化为带堆栈的字符串,用于日志记录。 + * - Error:优先 stack,缺则 message + * - null/undefined:'Unknown error' + * - 其它:String(value) + */ +export function formatError(err: unknown): string { + if (err instanceof Error) return err.stack || err.message; + if (err === null || err === undefined) return "Unknown error"; + return String(err); +} +``` + +- [ ] **Step 5: 运行测试确认通过** + +Run: `npx vitest run src/server/log-format.test.ts` +Expected: PASS(4 用例)。 + +- [ ] **Step 6: 写 logger 模块** + +Create `src/server/logger.ts`: + +```ts +import log4js from "log4js"; + +// fileSync = 同步写:进程 OOM/被杀前日志已落盘,满足崩溃诊断刚需。 +log4js.configure({ + appenders: { + app: { + type: "fileSync", + filename: "logs/app/app.log", + maxLogSize: 10 * 1024 * 1024, // 10MB + backups: 5, + layout: { type: "pattern", pattern: "[%d{ISO8601}] [%p] [%c] %m" }, + }, + }, + categories: { default: { appenders: ["app"], level: "info" } }, +}); + +export { formatError } from "./log-format"; + +export const apiLogger = log4js.getLogger("api"); +export const exportLogger = log4js.getLogger("export"); +``` + +- [ ] **Step 7: 跑全量测试 + lint** + +Run: `npm test` +Expected: 全部 PASS(原 `table-filters`/`record-detail` + 新 `log-format`)。 + +Run: `npm run lint` +Expected: 0 error(`log-format.ts` 被 eslint 覆盖;`logger.ts` 仅在服务端被路由引用,不进客户端)。 + +- [ ] **Step 8: 提交** + +```bash +git add package.json package-lock.json src/server/log-format.ts src/server/log-format.test.ts src/server/logger.ts +git commit -m "Add log4js fileSync logger module and formatError helper" +``` + +--- + +## Task 2: 给 `/api/production-data` 接日志 + +**Files:** +- Modify: `src/app/api/production-data/route.ts` + +**Interfaces:** +- Consumes: Task 1 的 `apiLogger`、`formatError`(`import { apiLogger, formatError } from "../../../server/logger"`)。 + +- [ ] **Step 1: 改 import 与加常量** + +在文件顶部 import 区,`import sql from "mssql";` 之后加: + +```ts +import { apiLogger, formatError } from "../../../server/logger"; + +const SLOW_QUERY_MS = 3000; +``` + +- [ ] **Step 2: 计时 + 失败带栈 + 慢查询 WARN** + +把 `export async function GET(request: NextRequest) {` 内的 try/catch/finally 改为(`dbConfig` 不变,中间序列化逻辑不变,仅在外围加计时与日志): + +```ts + let pool: sql.ConnectionPool | undefined; + const t0 = Date.now(); + try { + pool = await sql.connect(dbConfig); + const result = await pool + .request() + .input("车间号", sql.NVarChar(50), workshopNo) + .execute("[productionContractData].[sp_压力表合同生产数据_按车间号]"); + + const recordset = result.recordset; + if (!recordset || recordset.length === 0) { + return NextResponse.json({ columns: [], data: [], total: 0 }); + } + + const columns = Object.keys(recordset[0]); + const data = recordset.map((row: Record) => { + const serialized: Record = {}; + for (const key of columns) { + const val = row[key]; + if (val instanceof Date) { + serialized[key] = val.toISOString().split("T")[0]; + } else { + serialized[key] = val; + } + } + return serialized; + }); + + const dur = Date.now() - t0; + if (dur > SLOW_QUERY_MS) { + apiLogger.warn(`slow query · workshopNo=${workshopNo} · dur=${dur}ms · rows=${data.length}`); + } + return NextResponse.json({ columns, data, total: data.length }); + } catch (err) { + const dur = Date.now() - t0; + const message = err instanceof Error ? err.message : "Unknown error"; + apiLogger.error(`query failed · workshopNo=${workshopNo} · dur=${dur}ms · ${formatError(err)}`); + return NextResponse.json({ error: message }, { status: 500 }); + } finally { + if (pool) { + await pool.close(); + } + } +``` + +> 注意:删除原 `console.error("DB query error:", message);`,由 `apiLogger.error(...)` 取代。 + +- [ ] **Step 3: 测试 + lint** + +Run: `npm test && npm run lint` +Expected: 测试全 PASS、lint 0 error。 + +- [ ] **Step 4: 提交** + +```bash +git add src/app/api/production-data/route.ts +git commit -m "Log production-data query failures (with stack) and slow queries" +``` + +--- + +## Task 3: 给 `/api/export-excel` 接日志 + +**Files:** +- Modify: `src/app/api/export-excel/route.ts` + +**Interfaces:** +- Consumes: Task 1 的 `exportLogger`、`formatError`(`import { exportLogger, formatError } from "../../../server/logger"`)。 + +- [ ] **Step 1: 加 import** + +`import ExcelJS from "exceljs";` 之后加: + +```ts +import { exportLogger, formatError } from "../../../server/logger"; +``` + +- [ ] **Step 2: 锁回收 WARN(替换 console.warn)** + +把 `acquireExportLock` 里的: + +```ts + if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) { + console.warn("Export lock reclaimed after timeout"); + exportLocked = false; + } +``` + +改为: + +```ts + if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) { + exportLogger.warn("export lock reclaimed after timeout"); + exportLocked = false; + } +``` + +- [ ] **Step 3: 429 占用 WARN + 计时 + 成功 INFO + 失败 ERROR** + +把 `export async function GET() {` 改为(返回逻辑/锁释放不变,仅加日志与计时): + +```ts +export async function GET() { + if (!acquireExportLock()) { + exportLogger.warn("export skipped: another export in progress (429)"); + return NextResponse.json( + { error: "正在导出,请稍候" }, + { status: 429, headers: { "Retry-After": "3" } } + ); + } + const t0 = Date.now(); + exportLogger.info("export started"); + let pool: sql.ConnectionPool | undefined; + try { + pool = await sql.connect(dbConfig); + const result = await pool + .request() + .execute("[productionContractData].[sp_压力表合同生产数据_全部]"); + + const recordset = result.recordset; + if (!recordset || recordset.length === 0) { + exportLogger.warn("export empty: no data"); + return NextResponse.json({ error: "没有数据可导出" }, { status: 404 }); + } + + const columns = Object.keys(recordset[0]); + + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet("压力表合同生产数据"); + + // Header row + const headerRow = sheet.addRow(columns); + headerRow.eachCell((cell) => { + cell.font = { bold: true }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE0EAF6" }, + }; + cell.alignment = { horizontal: "center" }; + }); + + // Data rows + for (const row of recordset) { + const values = columns.map((col) => { + const val = row[col]; + if (val instanceof Date) { + return val; + } + if (val === null || val === undefined) return ""; + return val; + }); + sheet.addRow(values); + } + + // Format date columns and auto-width + const dateColIndices: number[] = []; + const maxWidths: number[] = columns.map((col) => col.length); + + columns.forEach((col, idx) => { + if (DATE_COLUMNS.has(col)) { + dateColIndices.push(idx + 1); + } + }); + + sheet.eachRow((row, rowNumber) => { + row.eachCell({ includeEmpty: false }, (cell, colNumber) => { + if (rowNumber > 1 && dateColIndices.includes(colNumber)) { + if (cell.value instanceof Date) { + cell.numFmt = "yyyy/mm/dd"; + } + } + const text = cell.text || ""; + const width = Math.max(maxWidths[colNumber - 1] || 0, text.length + 2); + maxWidths[colNumber - 1] = width; + }); + }); + + columns.forEach((_, idx) => { + sheet.getColumn(idx + 1).width = Math.min(Math.max(maxWidths[idx], 8), 50); + }); + + sheet.views = [{ state: "frozen", ySplit: 1 }]; + + const buffer = await workbook.xlsx.writeBuffer(); + const bytes = typeof buffer === "object" && buffer && "byteLength" in buffer + ? (buffer as { byteLength: number }).byteLength + : 0; + const dur = Date.now() - t0; + exportLogger.info(`export ok · rows=${recordset.length} · bytes=${bytes} · dur=${dur}ms`); + + return new NextResponse(buffer, { + status: 200, + headers: { + "Content-Type": + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": + "attachment; filename*=UTF-8''" + encodeURIComponent("压力表合同生产数据.xlsx"), + }, + }); + } catch (err) { + const dur = Date.now() - t0; + const message = err instanceof Error ? err.message : "Unknown error"; + exportLogger.error(`export failed · dur=${dur}ms · ${formatError(err)}`); + return NextResponse.json({ error: message }, { status: 500 }); + } finally { + if (pool) { + await pool.close(); + } + releaseExportLock(); + } +} +``` + +> 注意:删除原 `console.error("Excel export error:", message);`,由 `exportLogger.error(...)` 取代。 + +- [ ] **Step 4: 测试 + lint** + +Run: `npm test && npm run lint` +Expected: 测试全 PASS、lint 0 error。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/api/export-excel/route.ts +git commit -m "Log export lifecycle (start/ok/fail/lock) with stack and timing" +``` + +--- + +## Task 4: 本地验证(不部署) + +- [ ] **Step 1: 启动本地服务** + +Run(后台): `npm run dev` +打开: http://localhost:3000 + +- [ ] **Step 2: 触发 production-data 失败,确认 ERROR 带栈** + +临时让 DB 查询失败(如临时把 `.env.local` 的 `DB_SERVER` 改成不可达值再重启,或直接断网/停 DB),查询一个车间号。 +Expected: `logs/app/app.log` 出现一行: +`[...] [ERROR] [api] query failed · workshopNo=<值> · dur=ms · : \n at ...`(**含完整堆栈**)。 + +- [ ] **Step 3: 触发导出,确认 INFO/ERROR** + +恢复 DB,查询出数据后点「导出全部」。 +Expected: `logs/app/app.log` 依次出现: +- `[INFO] [export] export started` +- `[INFO] [export] export ok · rows= · bytes= · dur=ms` + +(可选)导出过程中再点一次导出,确认 `[WARN] [export] export skipped: another export in progress (429)`。 + +- [ ] **Step 4: 确认轮转目录** + +确认 `logs/app/` 目录被创建,`app.log` 存在;NSSM 的 `logs/stdout.log`/`stderr.log` 不再混入这些应用日志(只有 Next 框架输出)。 + +- [ ] **Step 5: 停止本地服务 + 推送** + +Run: 停止 dev;`git push`。 + +> 部署到 114:pull → `npm install`(新增 log4js)→ `next build` → 重启 WebTable。验证通过后再执行。 + +--- + +## Self-Review + +**Spec coverage:** +- 4.1 log4js fileSync → Task 1 Step 6 ✅ +- 4.2 分层(不动 NSSM)→ Global Constraints + Task 4 Step 4 ✅ +- 4.3 logger 模块 + 输出样例 → Task 1 Step 6(pattern 与字段在路由日志中体现)✅ +- 4.4 记什么矩阵:production 失败 ERROR / 慢查询 WARN → Task 2;export 锁占用/回收 WARN、开始/成功 INFO、失败 ERROR、启动 INFO → Task 3 Step 3(含 `export started`);console.* 全替换 → Task 2/3 ✅ +- 4.5 formatError + 路由带栈 → Task 1(formatError)+ Task 2/3(带栈)✅ +- 4.6 涉及文件 → File Structure ✅ +- 5 边界(OOM 落盘/目录/轮转/dev 与生产)→ Global Constraints + Task 4 ✅ +- 6 测试(formatError 单测 + 手验)→ Task 1 Step 2-5 + Task 4 ✅ +- 7 部署注意(npm install)→ Global Constraints + Task 4 Step 5 ✅ + +**Placeholder scan:** 无 TBD/TODO;每步含完整代码与命令。 + +**Type consistency:** `formatError(err: unknown): string` 在 Task 1 定义,Task 2/3 调用一致;`apiLogger`/`exportLogger` 来自 Task 1 的 `logger.ts`;import 路径 `../../../server/logger`(两路由同深,正确)。