Compare commits

..

4 Commits

Author SHA1 Message Date
Misaka_Company
cd2f8b75df Ignore runtime logs directory 2026-06-24 13:53:09 +08:00
Misaka_Company
bc0e95157c Log export lifecycle (start/ok/fail/lock) with stack and timing 2026-06-24 13:40:27 +08:00
Misaka_Company
3371a8af2a Log production-data query failures (with stack) and slow queries 2026-06-24 13:39:17 +08:00
Misaka_Company
2a5f5763b4 Add log4js fileSync logger module and formatError helper 2026-06-24 13:35:10 +08:00
8 changed files with 161 additions and 5 deletions

5
.gitignore vendored
View File

@@ -42,4 +42,7 @@ next-env.d.ts
# .gitignore # .gitignore
CLAUDE.local.md CLAUDE.local.md
.claude/ .claude/
# runtime logs (log4js app logs, NSSM stdout/stderr)
/logs/

80
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"antd": "^6.4.3", "antd": "^6.4.3",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"log4js": "^6.9.1",
"mssql": "^12.5.5", "mssql": "^12.5.5",
"next": "16.2.9", "next": "16.2.9",
"react": "19.2.4", "react": "19.2.4",
@@ -4894,6 +4895,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-format": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
"integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.21", "version": "1.11.21",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
@@ -5952,7 +5962,6 @@
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/for-each": { "node_modules/for-each": {
@@ -5977,6 +5986,20 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -7079,6 +7102,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -7698,6 +7730,22 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/log4js": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz",
"integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==",
"license": "Apache-2.0",
"dependencies": {
"date-format": "^4.0.14",
"debug": "^4.3.4",
"flatted": "^3.2.7",
"rfdc": "^1.3.0",
"streamroller": "^3.1.5"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -8584,6 +8632,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "2.7.1", "version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
@@ -9054,6 +9108,20 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/streamroller": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
"integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==",
"license": "MIT",
"dependencies": {
"date-format": "^4.0.14",
"debug": "^4.3.4",
"fs-extra": "^8.1.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -9693,6 +9761,15 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.12.2", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
@@ -9848,6 +9925,7 @@
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",

View File

@@ -17,6 +17,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"antd": "^6.4.3", "antd": "^6.4.3",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"log4js": "^6.9.1",
"mssql": "^12.5.5", "mssql": "^12.5.5",
"next": "16.2.9", "next": "16.2.9",
"react": "19.2.4", "react": "19.2.4",

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import sql from "mssql"; import sql from "mssql";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { exportLogger, formatError } from "../../../server/logger";
const dbConfig = { const dbConfig = {
server: process.env.DB_SERVER!, server: process.env.DB_SERVER!,
@@ -55,7 +56,7 @@ const EXPORT_LOCK_TIMEOUT_MS = 90_000; // safety: reclaim a stuck lock after 90s
function acquireExportLock(): boolean { function acquireExportLock(): boolean {
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) { if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
console.warn("Export lock reclaimed after timeout"); exportLogger.warn("export lock reclaimed after timeout");
exportLocked = false; exportLocked = false;
} }
if (exportLocked) return false; if (exportLocked) return false;
@@ -70,11 +71,14 @@ function releaseExportLock() {
export async function GET() { export async function GET() {
if (!acquireExportLock()) { if (!acquireExportLock()) {
exportLogger.warn("export skipped: another export in progress (429)");
return NextResponse.json( return NextResponse.json(
{ error: "正在导出,请稍候" }, { error: "正在导出,请稍候" },
{ status: 429, headers: { "Retry-After": "3" } } { status: 429, headers: { "Retry-After": "3" } }
); );
} }
const t0 = Date.now();
exportLogger.info("export started");
let pool: sql.ConnectionPool | undefined; let pool: sql.ConnectionPool | undefined;
try { try {
pool = await sql.connect(dbConfig); pool = await sql.connect(dbConfig);
@@ -84,6 +88,7 @@ export async function GET() {
const recordset = result.recordset; const recordset = result.recordset;
if (!recordset || recordset.length === 0) { if (!recordset || recordset.length === 0) {
exportLogger.warn("export empty: no data");
return NextResponse.json({ error: "没有数据可导出" }, { status: 404 }); return NextResponse.json({ error: "没有数据可导出" }, { status: 404 });
} }
@@ -151,6 +156,12 @@ export async function GET() {
// Stream to buffer // Stream to buffer
const buffer = await workbook.xlsx.writeBuffer(); 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, { return new NextResponse(buffer, {
status: 200, status: 200,
@@ -162,8 +173,9 @@ export async function GET() {
}, },
}); });
} catch (err) { } catch (err) {
const dur = Date.now() - t0;
const message = err instanceof Error ? err.message : "Unknown error"; const message = err instanceof Error ? err.message : "Unknown error";
console.error("Excel export error:", message); exportLogger.error(`export failed · dur=${dur}ms · ${formatError(err)}`);
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ error: message }, { status: 500 });
} finally { } finally {
if (pool) { if (pool) {

View File

@@ -1,5 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import sql from "mssql"; import sql from "mssql";
import { apiLogger, formatError } from "../../../server/logger";
const SLOW_QUERY_MS = 3000;
const dbConfig = { const dbConfig = {
server: process.env.DB_SERVER!, server: process.env.DB_SERVER!,
@@ -25,6 +28,7 @@ export async function GET(request: NextRequest) {
} }
let pool: sql.ConnectionPool | undefined; let pool: sql.ConnectionPool | undefined;
const t0 = Date.now();
try { try {
pool = await sql.connect(dbConfig); pool = await sql.connect(dbConfig);
const result = await pool const result = await pool
@@ -53,10 +57,15 @@ export async function GET(request: NextRequest) {
return serialized; 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 }); return NextResponse.json({ columns, data, total: data.length });
} catch (err) { } catch (err) {
const dur = Date.now() - t0;
const message = err instanceof Error ? err.message : "Unknown error"; const message = err instanceof Error ? err.message : "Unknown error";
console.error("DB query error:", message); apiLogger.error(`query failed · workshopNo=${workshopNo} · dur=${dur}ms · ${formatError(err)}`);
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ error: message }, { status: 500 });
} finally { } finally {
if (pool) { if (pool) {

View File

@@ -0,0 +1,22 @@
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");
});
});

11
src/server/log-format.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* 把任意错误/值格式化为带堆栈的字符串,用于日志记录。
* - 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);
}

20
src/server/logger.ts Normal file
View File

@@ -0,0 +1,20 @@
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");