Explorar o código

增加log采集和管理

chengjie hai 2 semanas
pai
achega
133204bbf1

+ 2 - 0
.gitignore

@@ -34,3 +34,5 @@ src/web_crawler/Throne-of-Magical-Arcana/Throne-of-Magical-Arcana.txt
34 34
 src/web_crawler/Throne-of-Magical-Arcana/Throne-of-Magical-Arcana.epub
35 35
 __pycache__/
36 36
 *.py[cod]
37
+.pm2-error-log-cursors.json
38
+.error-log-poll-state.json

+ 26 - 1
README.md

@@ -49,6 +49,12 @@ npm install
49 49
 mysql -u root -p < doc/init.sql
50 50
 ```
51 51
 
52
+错误日志表可以手动初始化,也会在服务启动后自动尝试创建:
53
+
54
+```bash
55
+mysql -u root -p kylx365_db < doc/system_error_logs.sql
56
+```
57
+
52 58
 ## 环境变量
53 59
 
54 60
 可以通过环境变量自定义配置:
@@ -59,6 +65,12 @@ mysql -u root -p < doc/init.sql
59 65
 - `DB_USER`: 数据库用户名
60 66
 - `DB_PASSWORD`: 数据库密码
61 67
 - `DB_NAME`: 数据库名称
68
+- `INTERNAL_API_TOKEN`: 内部错误日志 API token,生产环境必须配置
69
+- `ERROR_LOG_CAPTURE_CONSOLE`: 是否采集 `console.error`,默认 `true`
70
+- `ERROR_LOG_EXIT_ON_FATAL`: 捕获进程级 fatal error 后是否退出交给 PM2 重启,默认 `true`
71
+- `ERROR_LOG_API_URL`: 本机轮询脚本访问的错误日志 API 地址
72
+- `ERROR_LOG_API_TOKEN`: 本机轮询脚本使用的内部 token
73
+- `ERROR_LOG_START_FROM_BEGINNING`: 本机轮询脚本首次运行时是否从 ID 0 拉取历史错误,默认 `false`
62 74
 
63 75
 ## 运行
64 76
 
@@ -68,6 +80,19 @@ npm run dev
68 80
 
69 81
 # 生产环境
70 82
 npm run start
83
+
84
+# 服务器上采集 PM2 error log
85
+npm run error-log:pm2
86
+
87
+# 本机轮询内部错误日志 API
88
+ERROR_LOG_API_URL=https://your-domain/internal/error-logs ERROR_LOG_API_TOKEN=your-token npm run error-log:poll
89
+```
90
+
91
+内部错误日志接口:
92
+
93
+```bash
94
+curl -H "x-internal-token: your-token" "http://localhost:3050/internal/error-logs?since_id=0&status=all"
95
+curl -H "x-internal-token: your-token" -X POST "http://localhost:3050/internal/error-logs/1/mark-fixed"
71 96
 ```
72 97
 
73 98
 ## API端点
@@ -79,4 +104,4 @@ npm run start
79 104
 
80 105
 ```bash
81 106
 curl http://localhost:3000/api/Ping
82
-```
107
+```

+ 36 - 0
doc/system_error_logs.sql

@@ -0,0 +1,36 @@
1
+CREATE TABLE IF NOT EXISTS SystemErrorLogs (
2
+  ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
3
+  AppName VARCHAR(100) NOT NULL DEFAULT 'miaoguo_system_server',
4
+  Env VARCHAR(32) NULL,
5
+  Host VARCHAR(128) NULL,
6
+  PM2Process VARCHAR(128) NULL,
7
+  PID INT NULL,
8
+  Level VARCHAR(32) NOT NULL DEFAULT 'error',
9
+  Source VARCHAR(64) NOT NULL DEFAULT 'application',
10
+  Message VARCHAR(1024) NOT NULL,
11
+  Stack MEDIUMTEXT NULL,
12
+  FilePath VARCHAR(512) NULL,
13
+  LineNumber INT NULL,
14
+  ColumnNumber INT NULL,
15
+  RequestMethod VARCHAR(16) NULL,
16
+  RequestUrl VARCHAR(2048) NULL,
17
+  RequestQuery MEDIUMTEXT NULL,
18
+  RequestBody MEDIUMTEXT NULL,
19
+  UserID VARCHAR(64) NULL,
20
+  NodeVersion VARCHAR(32) NULL,
21
+  GitCommit VARCHAR(64) NULL,
22
+  Fingerprint CHAR(64) NOT NULL,
23
+  Count INT UNSIGNED NOT NULL DEFAULT 1,
24
+  Status VARCHAR(32) NOT NULL DEFAULT 'open',
25
+  Extra MEDIUMTEXT NULL,
26
+  FirstSeenAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
27
+  LastSeenAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
28
+  CreateTime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+  UpdateTime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
30
+  PRIMARY KEY (ID),
31
+  UNIQUE KEY uk_SystemErrorLogs_Fingerprint (Fingerprint),
32
+  KEY idx_SystemErrorLogs_Status_LastSeenAt (Status, LastSeenAt),
33
+  KEY idx_SystemErrorLogs_LastSeenAt (LastSeenAt),
34
+  KEY idx_SystemErrorLogs_Source (Source),
35
+  KEY idx_SystemErrorLogs_FilePath (FilePath(191))
36
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

+ 2 - 0
package.json

@@ -13,6 +13,8 @@
13 13
     "start": "./scripts/use-node-version.sh cross-env NODE_ENV=production node src/app.js",
14 14
     "prebuild": "node scripts/check-node-version.js",
15 15
     "build": "./scripts/use-node-version.sh cross-env NODE_ENV=production node src/build.js",
16
+    "error-log:pm2": "./scripts/use-node-version.sh node scripts/collect-pm2-error-logs.js",
17
+    "error-log:poll": "./scripts/use-node-version.sh node scripts/poll-error-logs.js",
16 18
     "test": "./scripts/use-node-version.sh node",
17 19
     "node": "./scripts/use-node-version.sh node",
18 20
     "check-version": "./scripts/use-node-version.sh node -v"

+ 143 - 0
scripts/collect-pm2-error-logs.js

@@ -0,0 +1,143 @@
1
+#!/usr/bin/env node
2
+import fs from 'node:fs';
3
+import fsp from 'node:fs/promises';
4
+import os from 'node:os';
5
+import path from 'node:path';
6
+import { recordError } from '../src/util/errorLogger.js';
7
+
8
+const logDir = process.env.PM2_ERROR_LOG_DIR || path.join(os.homedir(), '.pm2', 'logs');
9
+const stateFile = process.env.PM2_ERROR_LOG_STATE_FILE || path.join(process.cwd(), '.pm2-error-log-cursors.json');
10
+const pollIntervalMs = Number(process.env.PM2_ERROR_LOG_POLL_INTERVAL_MS || 30000);
11
+const readExisting = process.env.PM2_ERROR_LOG_READ_EXISTING === 'true';
12
+
13
+async function loadState() {
14
+  try {
15
+    return JSON.parse(await fsp.readFile(stateFile, 'utf8'));
16
+  } catch (error) {
17
+    return {};
18
+  }
19
+}
20
+
21
+async function saveState(state) {
22
+  await fsp.writeFile(stateFile, JSON.stringify(state, null, 2));
23
+}
24
+
25
+async function listPm2ErrorLogs() {
26
+  try {
27
+    const files = await fsp.readdir(logDir);
28
+    return files
29
+      .filter((file) => file.endsWith('-error.log'))
30
+      .map((file) => path.join(logDir, file));
31
+  } catch (error) {
32
+    console.error(`[pm2-error-log-collector] cannot read log dir ${logDir}:`, error.message || error);
33
+    return [];
34
+  }
35
+}
36
+
37
+async function readRange(filePath, start, end) {
38
+  if (end < start) {
39
+    return '';
40
+  }
41
+
42
+  let content = '';
43
+  const stream = fs.createReadStream(filePath, {
44
+    start,
45
+    end,
46
+    encoding: 'utf8',
47
+  });
48
+
49
+  for await (const chunk of stream) {
50
+    content += chunk;
51
+  }
52
+
53
+  return content;
54
+}
55
+
56
+function splitErrorBlocks(content) {
57
+  const lines = content.replace(/\r\n/g, '\n').split('\n');
58
+  const blocks = [];
59
+  let current = [];
60
+
61
+  const startsNewBlock = (line) => {
62
+    return (
63
+      /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(line) ||
64
+      /^(Error|TypeError|ReferenceError|SyntaxError|RangeError|UnhandledPromiseRejection|uncaughtException|unhandledRejection)\b/.test(line)
65
+    );
66
+  };
67
+
68
+  for (const line of lines) {
69
+    if (startsNewBlock(line) && current.length > 0) {
70
+      blocks.push(current.join('\n').trim());
71
+      current = [];
72
+    }
73
+    current.push(line);
74
+  }
75
+
76
+  if (current.length > 0) {
77
+    blocks.push(current.join('\n').trim());
78
+  }
79
+
80
+  return blocks.filter(Boolean);
81
+}
82
+
83
+function getMessageFromBlock(block) {
84
+  const line = block
85
+    .split('\n')
86
+    .map((item) => item.trim())
87
+    .find((item) => item && !item.startsWith('at '));
88
+
89
+  return line
90
+    ? line.replace(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s*/, '')
91
+    : 'PM2 error log';
92
+}
93
+
94
+async function collectOnce(state) {
95
+  const files = await listPm2ErrorLogs();
96
+
97
+  for (const filePath of files) {
98
+    let stat;
99
+    try {
100
+      stat = await fsp.stat(filePath);
101
+    } catch (error) {
102
+      continue;
103
+    }
104
+
105
+    const previousOffset = state[filePath];
106
+    if (previousOffset === undefined && !readExisting) {
107
+      state[filePath] = stat.size;
108
+      continue;
109
+    }
110
+
111
+    const start = Math.min(previousOffset || 0, stat.size);
112
+    const content = await readRange(filePath, start, stat.size - 1);
113
+    state[filePath] = stat.size;
114
+
115
+    for (const block of splitErrorBlocks(content)) {
116
+      await recordError(new Error(getMessageFromBlock(block)), null, {
117
+        source: 'pm2-log',
118
+        stack: block,
119
+        message: getMessageFromBlock(block),
120
+        extra: { logFile: filePath },
121
+      });
122
+    }
123
+  }
124
+
125
+  await saveState(state);
126
+}
127
+
128
+async function main() {
129
+  console.log(`[pm2-error-log-collector] watching ${logDir}`);
130
+  const state = await loadState();
131
+
132
+  await collectOnce(state);
133
+  setInterval(() => {
134
+    collectOnce(state).catch((error) => {
135
+      console.error('[pm2-error-log-collector] collect failed:', error);
136
+    });
137
+  }, pollIntervalMs);
138
+}
139
+
140
+main().catch((error) => {
141
+  console.error('[pm2-error-log-collector] fatal:', error);
142
+  process.exit(1);
143
+});

+ 136 - 0
scripts/poll-error-logs.js

@@ -0,0 +1,136 @@
1
+#!/usr/bin/env node
2
+import fsp from 'node:fs/promises';
3
+import path from 'node:path';
4
+import { execFile } from 'node:child_process';
5
+
6
+const apiUrl = process.env.ERROR_LOG_API_URL || 'http://127.0.0.1:3050/internal/error-logs';
7
+const token = process.env.ERROR_LOG_API_TOKEN || process.env.INTERNAL_API_TOKEN || '';
8
+const stateFile = process.env.ERROR_LOG_POLL_STATE_FILE || path.join(process.cwd(), '.error-log-poll-state.json');
9
+const pollIntervalMs = Number(process.env.ERROR_LOG_POLL_INTERVAL_MS || 60000);
10
+const limit = Number(process.env.ERROR_LOG_POLL_LIMIT || 50);
11
+const enableNotify = process.env.ERROR_LOG_NOTIFY === 'true';
12
+const startFromBeginning = process.env.ERROR_LOG_START_FROM_BEGINNING === 'true';
13
+
14
+async function loadState() {
15
+  try {
16
+    return {
17
+      isNew: false,
18
+      state: JSON.parse(await fsp.readFile(stateFile, 'utf8')),
19
+    };
20
+  } catch (error) {
21
+    return {
22
+      isNew: true,
23
+      state: {
24
+        sinceId: process.env.ERROR_LOG_SINCE_ID === undefined ? null : Number(process.env.ERROR_LOG_SINCE_ID || 0),
25
+      },
26
+    };
27
+  }
28
+}
29
+
30
+async function saveState(state) {
31
+  await fsp.writeFile(stateFile, JSON.stringify(state, null, 2));
32
+}
33
+
34
+function formatLog(item) {
35
+  const location = item.FilePath ? `${item.FilePath}:${item.LineNumber || 0}:${item.ColumnNumber || 0}` : 'unknown location';
36
+  const request = item.RequestUrl ? ` ${item.RequestMethod || 'REQUEST'} ${item.RequestUrl}` : '';
37
+  return [
38
+    `[${item.ID}] ${item.Source} ${item.Status} x${item.Count}`,
39
+    item.Message,
40
+    location,
41
+    request.trim(),
42
+  ].filter(Boolean).join('\n');
43
+}
44
+
45
+function notify(item) {
46
+  if (!enableNotify || process.platform !== 'darwin') {
47
+    return;
48
+  }
49
+
50
+  const title = `New ${item.Source} error #${item.ID}`;
51
+  const message = String(item.Message || '').replace(/"/g, '\\"').slice(0, 180);
52
+
53
+  execFile('osascript', [
54
+    '-e',
55
+    `display notification "${message}" with title "${title}"`,
56
+  ]);
57
+}
58
+
59
+function buildApiUrl({ sinceId = 0, latest = false } = {}) {
60
+  const url = new URL(apiUrl);
61
+  if (latest) {
62
+    url.pathname = url.pathname.replace(/\/$/, '') + '/latest';
63
+  } else {
64
+    url.searchParams.set('since_id', sinceId);
65
+  }
66
+  url.searchParams.set('status', 'all');
67
+  url.searchParams.set('limit', limit);
68
+  return url;
69
+}
70
+
71
+async function fetchErrorLogs(url) {
72
+  if (!token) {
73
+    throw new Error('ERROR_LOG_API_TOKEN or INTERNAL_API_TOKEN is required');
74
+  }
75
+
76
+  const response = await fetch(url, {
77
+    headers: {
78
+      'x-internal-token': token,
79
+    },
80
+  });
81
+
82
+  if (!response.ok) {
83
+    throw new Error(`HTTP ${response.status}: ${await response.text()}`);
84
+  }
85
+
86
+  const data = await response.json();
87
+  if (data.errcode !== 10000) {
88
+    throw new Error(data.errMsg || `Unexpected response: ${JSON.stringify(data)}`);
89
+  }
90
+
91
+  return data.result?.list || [];
92
+}
93
+
94
+async function fetchLatestSinceId() {
95
+  const list = await fetchErrorLogs(buildApiUrl({ latest: true }));
96
+  return list.reduce((maxId, item) => Math.max(maxId, Number(item.ID || 0)), 0);
97
+}
98
+
99
+async function pollOnce(state) {
100
+  const list = await fetchErrorLogs(buildApiUrl({ sinceId: state.sinceId || 0 }));
101
+  if (list.length === 0) {
102
+    return;
103
+  }
104
+
105
+  for (const item of list) {
106
+    console.log('\n' + formatLog(item));
107
+    notify(item);
108
+    state.sinceId = Math.max(Number(state.sinceId || 0), Number(item.ID || 0));
109
+  }
110
+
111
+  await saveState(state);
112
+}
113
+
114
+async function main() {
115
+  const loaded = await loadState();
116
+  const state = loaded.state;
117
+
118
+  if (loaded.isNew && state.sinceId === null) {
119
+    state.sinceId = startFromBeginning ? 0 : await fetchLatestSinceId();
120
+    await saveState(state);
121
+  }
122
+
123
+  console.log(`[error-log-poller] polling ${apiUrl} every ${pollIntervalMs}ms from ID ${state.sinceId || 0}`);
124
+
125
+  await pollOnce(state);
126
+  setInterval(() => {
127
+    pollOnce(state).catch((error) => {
128
+      console.error('[error-log-poller] poll failed:', error.message || error);
129
+    });
130
+  }, pollIntervalMs);
131
+}
132
+
133
+main().catch((error) => {
134
+  console.error('[error-log-poller] fatal:', error.message || error);
135
+  process.exit(1);
136
+});

+ 134 - 0
src/api/internal/errorLogController.js

@@ -0,0 +1,134 @@
1
+import config from '../../config/index.js';
2
+import {
3
+  getErrorLogById,
4
+  listErrorLogs,
5
+  updateErrorLogStatus,
6
+} from '../../model/systemErrorLog.js';
7
+
8
+const allowedStatuses = new Set(['open', 'fixed', 'ignored', 'reopened']);
9
+
10
+function getInternalToken(ctx) {
11
+  const authorization = ctx.get('authorization') || '';
12
+  if (authorization.toLowerCase().startsWith('bearer ')) {
13
+    return authorization.slice(7).trim();
14
+  }
15
+
16
+  return ctx.get('x-internal-token') || ctx.query.token || ctx.query.internal_token || '';
17
+}
18
+
19
+function parseStatusList(status) {
20
+  if (!status) {
21
+    return ['open', 'reopened'];
22
+  }
23
+
24
+  if (String(status).toLowerCase() === 'all') {
25
+    return null;
26
+  }
27
+
28
+  return String(status)
29
+    .split(',')
30
+    .map((item) => item.trim())
31
+    .filter(Boolean);
32
+}
33
+
34
+function parseLimit(limit) {
35
+  return Math.min(Math.max(Number(limit) || 50, 1), 200);
36
+}
37
+
38
+export async function requireInternalToken(ctx, next) {
39
+  if (!config.internalApiToken) {
40
+    ctx.status = 503;
41
+    ctx.body = {
42
+      errcode: 10503,
43
+      errMsg: 'INTERNAL_API_TOKEN is not configured',
44
+    };
45
+    return;
46
+  }
47
+
48
+  if (getInternalToken(ctx) !== config.internalApiToken) {
49
+    ctx.status = 401;
50
+    ctx.body = {
51
+      errcode: 10401,
52
+      errMsg: 'Unauthorized',
53
+    };
54
+    return;
55
+  }
56
+
57
+  await next();
58
+}
59
+
60
+export async function ListErrorLogs(ctx) {
61
+  const list = await listErrorLogs({
62
+    sinceId: ctx.query.since_id || ctx.query.sinceId || 0,
63
+    limit: parseLimit(ctx.query.limit),
64
+    statusList: parseStatusList(ctx.query.status),
65
+    source: ctx.query.source || '',
66
+    fingerprint: ctx.query.fingerprint || '',
67
+    order: ctx.query.order || 'ASC',
68
+  });
69
+
70
+  const nextSinceId = list.reduce((maxId, item) => Math.max(maxId, Number(item.ID || 0)), Number(ctx.query.since_id || ctx.query.sinceId || 0));
71
+
72
+  ctx.body = {
73
+    errcode: 10000,
74
+    result: {
75
+      list,
76
+      next_since_id: nextSinceId,
77
+    },
78
+  };
79
+}
80
+
81
+export async function ListLatestErrorLogs(ctx) {
82
+  const list = await listErrorLogs({
83
+    sinceId: 0,
84
+    limit: parseLimit(ctx.query.limit),
85
+    statusList: parseStatusList(ctx.query.status),
86
+    source: ctx.query.source || '',
87
+    fingerprint: ctx.query.fingerprint || '',
88
+    order: 'DESC',
89
+  });
90
+
91
+  ctx.body = {
92
+    errcode: 10000,
93
+    result: {
94
+      list,
95
+      next_since_id: list.reduce((maxId, item) => Math.max(maxId, Number(item.ID || 0)), 0),
96
+    },
97
+  };
98
+}
99
+
100
+export async function GetErrorLog(ctx) {
101
+  const item = await getErrorLogById(ctx.params.id);
102
+  if (!item) {
103
+    ctx.status = 404;
104
+    ctx.body = {
105
+      errcode: 10404,
106
+      errMsg: 'Error log not found',
107
+    };
108
+    return;
109
+  }
110
+
111
+  ctx.body = {
112
+    errcode: 10000,
113
+    result: item,
114
+  };
115
+}
116
+
117
+export async function UpdateErrorLogStatus(ctx) {
118
+  const status = ctx.request.body?.status || ctx.query.status || 'fixed';
119
+
120
+  if (!allowedStatuses.has(status)) {
121
+    ctx.status = 400;
122
+    ctx.body = {
123
+      errcode: 10400,
124
+      errMsg: 'Invalid status',
125
+    };
126
+    return;
127
+  }
128
+
129
+  await updateErrorLogStatus(ctx.params.id, status);
130
+
131
+  ctx.body = {
132
+    errcode: 10000,
133
+  };
134
+}

+ 18 - 0
src/api/internal/routes.js

@@ -0,0 +1,18 @@
1
+import Router from '@koa/router';
2
+import {
3
+  GetErrorLog,
4
+  ListErrorLogs,
5
+  ListLatestErrorLogs,
6
+  UpdateErrorLogStatus,
7
+  requireInternalToken,
8
+} from './errorLogController.js';
9
+
10
+const router = new Router();
11
+
12
+router.get('/internal/error-logs', requireInternalToken, ListErrorLogs);
13
+router.get('/internal/error-logs/latest', requireInternalToken, ListLatestErrorLogs);
14
+router.get('/internal/error-logs/:id', requireInternalToken, GetErrorLog);
15
+router.post('/internal/error-logs/:id/mark-fixed', requireInternalToken, UpdateErrorLogStatus);
16
+router.post('/internal/error-logs/:id/status', requireInternalToken, UpdateErrorLogStatus);
17
+
18
+export default router;

+ 14 - 7
src/app.js

@@ -6,10 +6,12 @@ import path from 'path';
6 6
 import { fileURLToPath } from 'url';
7 7
 import config from './config/index.js';
8 8
 import { decryptUrlMiddle } from './util/crypto/index.js';
9
+import { registerErrorLogging } from './util/errorLogger.js';
9 10
 import { stringUtils } from './util/stringClass.js';
10 11
 import xmlBodyParser from './middleware/xmlBodyParser.js';
11 12
 import queryParamSanitizer from './middleware/queryParamSanitizer.js';
12 13
 
14
+import internalRouter from './api/internal/routes.js';
13 15
 import commonRouter from './api/common/routes.js';
14 16
 import mpsRouter from './api/mps/routes.js';
15 17
 import yjbdcRouter from './api/yjbdc/routes.js';
@@ -42,13 +44,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
42 44
 
43 45
 const app = new Koa();
44 46
 
45
-// 使用中间件
46
-app.use(xmlBodyParser());
47
-app.use(bodyParser());
48
-app.use(queryParamSanitizer());
49
-
50
-// 静态文件服务
51
-app.use(serve(__dirname + '/../public'));
47
+registerErrorLogging(app);
52 48
 
53 49
 // 错误处理
54 50
 app.use(async (ctx, next) => {
@@ -65,6 +61,14 @@ app.use(async (ctx, next) => {
65 61
     }
66 62
 });
67 63
 
64
+// 使用中间件
65
+app.use(xmlBodyParser());
66
+app.use(bodyParser());
67
+app.use(queryParamSanitizer());
68
+
69
+// 静态文件服务
70
+app.use(serve(__dirname + '/../public'));
71
+
68 72
 // 设置签名密钥
69 73
 app.keys = ['some secret hurr'];
70 74
 
@@ -84,6 +88,9 @@ const SessionConfig = {
84 88
 // 使用session中间件
85 89
 app.use(session(SessionConfig, app));
86 90
 
91
+// 内部运维接口
92
+app.use(internalRouter.routes());
93
+app.use(internalRouter.allowedMethods());
87 94
 
88 95
 //中间件
89 96
 app.use(decryptUrlMiddle());

+ 20 - 4
src/config/index.js

@@ -1,9 +1,28 @@
1 1
 import devConfig from './dev.js';
2 2
 import prodConfig from './prod.js';
3 3
 
4
+if (!process.env.NODE_ENV)
5
+    process.env.NODE_ENV='development';
6
+
7
+const runtimeEnv = process.env.NODE_ENV || 'development';
8
+const defaultInternalApiToken = runtimeEnv === 'production' ? '' : 'dev-internal-token';
9
+const toNumber = (value, fallback) => {
10
+    const number = Number(value);
11
+    return Number.isFinite(number) ? number : fallback;
12
+};
13
+
4 14
 // 公共数据库配置
5 15
 const commonConfig = {
6 16
     port: process.env.PORT || 3000,
17
+    internalApiToken: process.env.INTERNAL_API_TOKEN || defaultInternalApiToken,
18
+    errorLogging: {
19
+        appName: process.env.ERROR_LOG_APP_NAME || 'miaoguo_system_server',
20
+        captureConsoleError: process.env.ERROR_LOG_CAPTURE_CONSOLE !== 'false',
21
+        exitOnFatal: process.env.ERROR_LOG_EXIT_ON_FATAL !== 'false',
22
+        fatalExitDelayMs: toNumber(process.env.ERROR_LOG_FATAL_EXIT_DELAY_MS, 1500),
23
+        maxPayloadLength: toNumber(process.env.ERROR_LOG_MAX_PAYLOAD_LENGTH, 8000),
24
+        maxStackLength: toNumber(process.env.ERROR_LOG_MAX_STACK_LENGTH, 30000)
25
+    },
7 26
     CDNUrl: 'https://cdn.example.com/',
8 27
     database: {
9 28
         multipleStatements: true,
@@ -157,10 +176,7 @@ const commonConfig = {
157 176
     ]
158 177
 };
159 178
 
160
-if (!process.env.NODE_ENV)
161
-    process.env.NODE_ENV='development';
162
-
163
-const env = process.env.NODE_ENV || 'development';
179
+const env = runtimeEnv;
164 180
 const envConfig = env === 'production' ? prodConfig : devConfig;
165 181
 
166 182
 // 合并配置,环境特定配置会覆盖公共配置

+ 151 - 0
src/model/systemErrorLog.js

@@ -0,0 +1,151 @@
1
+import { query } from '../util/db.js';
2
+
3
+let ensureTablePromise = null;
4
+
5
+export async function ensureSystemErrorLogsTable() {
6
+  if (!ensureTablePromise) {
7
+    const sql = `
8
+      CREATE TABLE IF NOT EXISTS SystemErrorLogs (
9
+        ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
10
+        AppName VARCHAR(100) NOT NULL DEFAULT 'miaoguo_system_server',
11
+        Env VARCHAR(32) NULL,
12
+        Host VARCHAR(128) NULL,
13
+        PM2Process VARCHAR(128) NULL,
14
+        PID INT NULL,
15
+        Level VARCHAR(32) NOT NULL DEFAULT 'error',
16
+        Source VARCHAR(64) NOT NULL DEFAULT 'application',
17
+        Message VARCHAR(1024) NOT NULL,
18
+        Stack MEDIUMTEXT NULL,
19
+        FilePath VARCHAR(512) NULL,
20
+        LineNumber INT NULL,
21
+        ColumnNumber INT NULL,
22
+        RequestMethod VARCHAR(16) NULL,
23
+        RequestUrl VARCHAR(2048) NULL,
24
+        RequestQuery MEDIUMTEXT NULL,
25
+        RequestBody MEDIUMTEXT NULL,
26
+        UserID VARCHAR(64) NULL,
27
+        NodeVersion VARCHAR(32) NULL,
28
+        GitCommit VARCHAR(64) NULL,
29
+        Fingerprint CHAR(64) NOT NULL,
30
+        Count INT UNSIGNED NOT NULL DEFAULT 1,
31
+        Status VARCHAR(32) NOT NULL DEFAULT 'open',
32
+        Extra MEDIUMTEXT NULL,
33
+        FirstSeenAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+        LastSeenAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
35
+        CreateTime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
+        UpdateTime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
37
+        PRIMARY KEY (ID),
38
+        UNIQUE KEY uk_SystemErrorLogs_Fingerprint (Fingerprint),
39
+        KEY idx_SystemErrorLogs_Status_LastSeenAt (Status, LastSeenAt),
40
+        KEY idx_SystemErrorLogs_LastSeenAt (LastSeenAt),
41
+        KEY idx_SystemErrorLogs_Source (Source),
42
+        KEY idx_SystemErrorLogs_FilePath (FilePath(191))
43
+      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
44
+    `;
45
+
46
+    ensureTablePromise = query(sql).catch((error) => {
47
+      ensureTablePromise = null;
48
+      throw error;
49
+    });
50
+  }
51
+
52
+  return ensureTablePromise;
53
+}
54
+
55
+export async function upsertErrorLog(log) {
56
+  await ensureSystemErrorLogsTable();
57
+
58
+  const sql = `
59
+    INSERT INTO SystemErrorLogs SET ?
60
+    ON DUPLICATE KEY UPDATE
61
+      AppName=VALUES(AppName),
62
+      Env=VALUES(Env),
63
+      Host=VALUES(Host),
64
+      PM2Process=VALUES(PM2Process),
65
+      PID=VALUES(PID),
66
+      Level=VALUES(Level),
67
+      Source=VALUES(Source),
68
+      Message=VALUES(Message),
69
+      Stack=VALUES(Stack),
70
+      FilePath=VALUES(FilePath),
71
+      LineNumber=VALUES(LineNumber),
72
+      ColumnNumber=VALUES(ColumnNumber),
73
+      RequestMethod=VALUES(RequestMethod),
74
+      RequestUrl=VALUES(RequestUrl),
75
+      RequestQuery=VALUES(RequestQuery),
76
+      RequestBody=VALUES(RequestBody),
77
+      UserID=VALUES(UserID),
78
+      NodeVersion=VALUES(NodeVersion),
79
+      GitCommit=VALUES(GitCommit),
80
+      Extra=VALUES(Extra),
81
+      Count=\`Count\` + 1,
82
+      Status=IF(\`Status\`='fixed', 'reopened', \`Status\`),
83
+      LastSeenAt=VALUES(LastSeenAt),
84
+      UpdateTime=NOW()
85
+  `;
86
+
87
+  return await query(sql, [log]);
88
+}
89
+
90
+export async function listErrorLogs(options = {}) {
91
+  await ensureSystemErrorLogsTable();
92
+
93
+  const {
94
+    sinceId = 0,
95
+    limit = 50,
96
+    statusList = ['open', 'reopened'],
97
+    source,
98
+    fingerprint,
99
+    order = 'ASC',
100
+  } = options;
101
+
102
+  const where = ['ID > ?'];
103
+  const params = [Number(sinceId) || 0];
104
+
105
+  if (statusList && statusList.length > 0) {
106
+    where.push('Status IN (?)');
107
+    params.push(statusList);
108
+  }
109
+
110
+  if (source) {
111
+    where.push('Source = ?');
112
+    params.push(source);
113
+  }
114
+
115
+  if (fingerprint) {
116
+    where.push('Fingerprint = ?');
117
+    params.push(fingerprint);
118
+  }
119
+
120
+  params.push(Math.min(Math.max(Number(limit) || 50, 1), 200));
121
+
122
+  const orderSql = String(order).toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
123
+  const sql = `
124
+    SELECT
125
+      ID, AppName, Env, Host, PM2Process, PID, Level, Source, Message,
126
+      Stack, FilePath, LineNumber, ColumnNumber, RequestMethod, RequestUrl,
127
+      RequestQuery, RequestBody, UserID, NodeVersion, GitCommit, Fingerprint,
128
+      Count, Status, Extra, FirstSeenAt, LastSeenAt, CreateTime, UpdateTime
129
+    FROM SystemErrorLogs
130
+    WHERE ${where.join(' AND ')}
131
+    ORDER BY ID ${orderSql}
132
+    LIMIT ?
133
+  `;
134
+
135
+  return await query(sql, params);
136
+}
137
+
138
+export async function getErrorLogById(id) {
139
+  await ensureSystemErrorLogsTable();
140
+
141
+  const sql = 'SELECT * FROM SystemErrorLogs WHERE ID=? LIMIT 1';
142
+  const rows = await query(sql, [Number(id) || 0]);
143
+  return rows[0] || null;
144
+}
145
+
146
+export async function updateErrorLogStatus(id, status) {
147
+  await ensureSystemErrorLogsTable();
148
+
149
+  const sql = 'UPDATE SystemErrorLogs SET Status=?, UpdateTime=NOW() WHERE ID=?';
150
+  return await query(sql, [status, Number(id) || 0]);
151
+}

+ 321 - 0
src/util/errorLogger.js

@@ -0,0 +1,321 @@
1
+import os from 'node:os';
2
+import path from 'node:path';
3
+import { execSync } from 'node:child_process';
4
+import { createHash } from 'node:crypto';
5
+import { fileURLToPath } from 'node:url';
6
+import config from '../config/index.js';
7
+import {
8
+  ensureSystemErrorLogsTable,
9
+  upsertErrorLog,
10
+} from '../model/systemErrorLog.js';
11
+
12
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+const projectRoot = path.resolve(__dirname, '../..');
14
+const originalConsoleError = console.error.bind(console);
15
+const sensitiveKeyPattern = /password|passwd|token|secret|authorization|cookie|session|apikey|api_key|access_token|refresh_token/i;
16
+
17
+let gitCommitCache;
18
+let processHandlersRegistered = false;
19
+let consoleErrorCaptureInstalled = false;
20
+let fatalExitStarted = false;
21
+
22
+function truncateString(value, maxLength) {
23
+  if (value === null || value === undefined) {
24
+    return null;
25
+  }
26
+
27
+  const str = String(value);
28
+  if (str.length <= maxLength) {
29
+    return str;
30
+  }
31
+
32
+  return `${str.slice(0, maxLength)}... [truncated ${str.length - maxLength} chars]`;
33
+}
34
+
35
+function redactValue(value, depth = 0) {
36
+  if (depth > 5) {
37
+    return '[MaxDepth]';
38
+  }
39
+
40
+  if (Array.isArray(value)) {
41
+    return value.slice(0, 50).map((item) => redactValue(item, depth + 1));
42
+  }
43
+
44
+  if (value && typeof value === 'object') {
45
+    const result = {};
46
+    for (const [key, item] of Object.entries(value)) {
47
+      result[key] = sensitiveKeyPattern.test(key) ? '[REDACTED]' : redactValue(item, depth + 1);
48
+    }
49
+    return result;
50
+  }
51
+
52
+  return value;
53
+}
54
+
55
+function stringifyForDb(value, maxLength = config.errorLogging.maxPayloadLength) {
56
+  if (value === undefined || value === null) {
57
+    return null;
58
+  }
59
+
60
+  try {
61
+    return truncateString(JSON.stringify(redactValue(value)), maxLength);
62
+  } catch (error) {
63
+    return truncateString(String(value), maxLength);
64
+  }
65
+}
66
+
67
+function getGitCommit() {
68
+  if (gitCommitCache !== undefined) {
69
+    return gitCommitCache;
70
+  }
71
+
72
+  try {
73
+    gitCommitCache = execSync('git rev-parse --short=12 HEAD', {
74
+      cwd: projectRoot,
75
+      stdio: ['ignore', 'pipe', 'ignore'],
76
+    }).toString().trim();
77
+  } catch (error) {
78
+    gitCommitCache = '';
79
+  }
80
+
81
+  return gitCommitCache;
82
+}
83
+
84
+function toError(value) {
85
+  if (value instanceof Error) {
86
+    return value;
87
+  }
88
+
89
+  const message = typeof value === 'string' ? value : stringifyForDb(value, 1000) || 'Unknown error';
90
+  return new Error(message);
91
+}
92
+
93
+function parseStackLocation(stack) {
94
+  if (!stack) {
95
+    return {};
96
+  }
97
+
98
+  const lines = String(stack).split('\n');
99
+  for (const line of lines) {
100
+    if (
101
+      line.includes('node:internal') ||
102
+      line.includes('/node_modules/') ||
103
+      line.includes('\\node_modules\\') ||
104
+      line.includes('<anonymous>')
105
+    ) {
106
+      continue;
107
+    }
108
+
109
+    const match = line.match(/((?:file:\/\/)?\/[^)\s]+):(\d+):(\d+)/);
110
+    if (!match) {
111
+      continue;
112
+    }
113
+
114
+    return {
115
+      filePath: match[1].replace(/^file:\/\//, ''),
116
+      lineNumber: Number(match[2]),
117
+      columnNumber: Number(match[3]),
118
+    };
119
+  }
120
+
121
+  return {};
122
+}
123
+
124
+function findUserId(value) {
125
+  if (!value || typeof value !== 'object') {
126
+    return null;
127
+  }
128
+
129
+  return value.UserID || value.userId || value.userID || value.UserId || value.uid || null;
130
+}
131
+
132
+function normalizeFingerprintText(value) {
133
+  return String(value || '')
134
+    .replace(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<timestamp>')
135
+    .replace(/\bpid[:=]\s*\d+\b/gi, 'pid:<number>');
136
+}
137
+
138
+function getRequestInfo(ctx) {
139
+  if (!ctx) {
140
+    return {};
141
+  }
142
+
143
+  const query = ctx.query || {};
144
+  const body = ctx.request?.body || {};
145
+
146
+  return {
147
+    RequestMethod: truncateString(ctx.method, 16),
148
+    RequestUrl: truncateString(ctx.originalUrl || ctx.url, 2048),
149
+    RequestQuery: stringifyForDb(query),
150
+    RequestBody: stringifyForDb(body),
151
+    UserID: truncateString(findUserId(query) || findUserId(body), 64),
152
+  };
153
+}
154
+
155
+function makeFingerprint({ source, message, filePath, lineNumber, stack }) {
156
+  const businessFrame = filePath ? `${filePath}:${lineNumber || 0}` : '';
157
+  const firstStackLine = stack ? String(stack).split('\n').find((line) => line.trim()) : '';
158
+  const raw = [
159
+    source || 'application',
160
+    normalizeFingerprintText(truncateString(message, 500)),
161
+    businessFrame,
162
+    normalizeFingerprintText(firstStackLine || ''),
163
+  ].join('|');
164
+
165
+  return createHash('sha256').update(raw).digest('hex');
166
+}
167
+
168
+function getPm2ProcessName() {
169
+  return (
170
+    process.env.pm_name ||
171
+    process.env.name ||
172
+    process.env.NODE_APP_INSTANCE ||
173
+    process.env.pm_id ||
174
+    ''
175
+  );
176
+}
177
+
178
+function buildLogRecord(errorInput, ctx, options = {}) {
179
+  const error = toError(errorInput);
180
+  const stack = truncateString(options.stack || error.stack || '', config.errorLogging.maxStackLength);
181
+  const message = truncateString(options.message || error.message || String(errorInput), 1024) || 'Unknown error';
182
+  const location = parseStackLocation(stack);
183
+  const source = truncateString(options.source || 'application', 64);
184
+  const now = new Date();
185
+
186
+  const record = {
187
+    AppName: truncateString(config.errorLogging.appName, 100),
188
+    Env: truncateString(process.env.NODE_ENV || 'development', 32),
189
+    Host: truncateString(os.hostname(), 128),
190
+    PM2Process: truncateString(getPm2ProcessName(), 128),
191
+    PID: process.pid,
192
+    Level: truncateString(options.level || 'error', 32),
193
+    Source: source,
194
+    Message: message,
195
+    Stack: stack,
196
+    FilePath: truncateString(location.filePath || options.filePath || '', 512),
197
+    LineNumber: location.lineNumber || options.lineNumber || null,
198
+    ColumnNumber: location.columnNumber || options.columnNumber || null,
199
+    NodeVersion: truncateString(process.version, 32),
200
+    GitCommit: truncateString(getGitCommit(), 64),
201
+    LastSeenAt: now,
202
+    Extra: stringifyForDb(options.extra || null),
203
+    ...getRequestInfo(ctx),
204
+  };
205
+
206
+  record.Fingerprint = makeFingerprint({
207
+    source,
208
+    message,
209
+    stack,
210
+    filePath: record.FilePath,
211
+    lineNumber: record.LineNumber,
212
+  });
213
+
214
+  return record;
215
+}
216
+
217
+export async function recordError(errorInput, ctx = null, options = {}) {
218
+  try {
219
+    const record = buildLogRecord(errorInput, ctx, options);
220
+    return await upsertErrorLog(record);
221
+  } catch (error) {
222
+    originalConsoleError('[errorLogger] failed to record error:', error);
223
+    return null;
224
+  }
225
+}
226
+
227
+function normalizeConsoleErrorArgs(args) {
228
+  const firstError = args.find((arg) => arg instanceof Error);
229
+  const stack = firstError?.stack || args.map((arg) => (arg instanceof Error ? arg.stack : String(arg))).join('\n');
230
+  const message = args.map((arg) => {
231
+    if (arg instanceof Error) {
232
+      return arg.message;
233
+    }
234
+    if (typeof arg === 'string') {
235
+      return arg;
236
+    }
237
+    return stringifyForDb(arg, 1000);
238
+  }).filter(Boolean).join(' ');
239
+
240
+  return {
241
+    message: truncateString(message || 'console.error', 1024),
242
+    stack: truncateString(stack, config.errorLogging.maxStackLength),
243
+  };
244
+}
245
+
246
+export function installConsoleErrorCapture() {
247
+  if (consoleErrorCaptureInstalled) {
248
+    return;
249
+  }
250
+
251
+  consoleErrorCaptureInstalled = true;
252
+  console.error = (...args) => {
253
+    originalConsoleError(...args);
254
+
255
+    const normalized = normalizeConsoleErrorArgs(args);
256
+    void recordError(new Error(normalized.message), null, {
257
+      source: 'console.error',
258
+      stack: normalized.stack,
259
+      message: normalized.message,
260
+      extra: { args: normalized.message },
261
+    });
262
+  };
263
+}
264
+
265
+function scheduleFatalExit() {
266
+  if (!config.errorLogging.exitOnFatal || fatalExitStarted) {
267
+    return;
268
+  }
269
+
270
+  fatalExitStarted = true;
271
+  const timer = setTimeout(() => {
272
+    process.exit(1);
273
+  }, config.errorLogging.fatalExitDelayMs);
274
+
275
+  timer.unref();
276
+}
277
+
278
+async function handleFatalProcessError(errorInput, source) {
279
+  const error = toError(errorInput);
280
+  originalConsoleError(`[${source}]`, error);
281
+  scheduleFatalExit();
282
+
283
+  await recordError(error, null, {
284
+    source,
285
+    level: 'fatal',
286
+  });
287
+
288
+  if (config.errorLogging.exitOnFatal) {
289
+    process.exit(1);
290
+  }
291
+}
292
+
293
+export function registerErrorLogging(app) {
294
+  if (processHandlersRegistered) {
295
+    return;
296
+  }
297
+
298
+  processHandlersRegistered = true;
299
+
300
+  ensureSystemErrorLogsTable().catch((error) => {
301
+    originalConsoleError('[errorLogger] failed to ensure SystemErrorLogs table:', error.message || error);
302
+  });
303
+
304
+  if (app) {
305
+    app.on('error', (error, ctx) => {
306
+      void recordError(error, ctx, { source: 'koa' });
307
+    });
308
+  }
309
+
310
+  process.on('unhandledRejection', (reason) => {
311
+    void handleFatalProcessError(reason, 'unhandledRejection');
312
+  });
313
+
314
+  process.on('uncaughtException', (error) => {
315
+    void handleFatalProcessError(error, 'uncaughtException');
316
+  });
317
+
318
+  if (config.errorLogging.captureConsoleError) {
319
+    installConsoleErrorCapture();
320
+  }
321
+}