16 KiB
结构化日志(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,用其
fileSyncappender(同步写)。部署到 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:
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:
/**
* 把任意错误/值格式化为带堆栈的字符串,用于日志记录。
* - 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:
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: 提交
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"; 之后加:
import { apiLogger, formatError } from "../../../server/logger";
const SLOW_QUERY_MS = 3000;
- Step 2: 计时 + 失败带栈 + 慢查询 WARN
把 export async function GET(request: NextRequest) { 内的 try/catch/finally 改为(dbConfig 不变,中间序列化逻辑不变,仅在外围加计时与日志):
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<string, unknown>) => {
const serialized: Record<string, unknown> = {};
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: 提交
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"; 之后加:
import { exportLogger, formatError } from "../../../server/logger";
- Step 2: 锁回收 WARN(替换 console.warn)
把 acquireExportLock 里的:
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
console.warn("Export lock reclaimed after timeout");
exportLocked = false;
}
改为:
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() { 改为(返回逻辑/锁释放不变,仅加日志与计时):
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: 提交
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=<N>ms · <Error 类型>: <msg>\n at ...(含完整堆栈)。
- Step 3: 触发导出,确认 INFO/ERROR
恢复 DB,查询出数据后点「导出全部」。
Expected: logs/app/app.log 依次出现:
[INFO] [export] export started[INFO] [export] export ok · rows=<N> · bytes=<N> · dur=<N>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(两路由同深,正确)。