chengjie 2 周之前
父節點
當前提交
57f46d86b4

+ 12 - 1
.vscode/tasks.json

@@ -15,6 +15,17 @@
15 15
                 "clear": true
16 16
             },
17 17
             "problemMatcher": []
18
+        },
19
+        {
20
+            "label": "检查近期服务端 Bug",
21
+            "type": "shell",
22
+            "command": "${workspaceFolder}/local-error-monitor/run.sh",
23
+            "presentation": {
24
+                "reveal": "always",
25
+                "panel": "dedicated",
26
+                "clear": true
27
+            },
28
+            "problemMatcher": []
18 29
         }
19 30
     ]
20
-}
31
+}

+ 6 - 0
README.md

@@ -95,6 +95,12 @@ curl -H "x-internal-token: your-token" "http://localhost:3050/internal/error-log
95 95
 curl -H "x-internal-token: your-token" -X POST "http://localhost:3050/internal/error-logs/1/mark-fixed"
96 96
 ```
97 97
 
98
+完整错误日志采集和本机检查配置见:
99
+
100
+```bash
101
+doc/server_error_log_monitor.md
102
+```
103
+
98 104
 ## API端点
99 105
 
100 106
 

+ 332 - 0
doc/server_error_log_monitor.md

@@ -0,0 +1,332 @@
1
+# 服务器错误日志检查配置说明
2
+
3
+本文档记录 `miaoguo_system_server` 的服务端错误日志采集、入库、查询、本机检查和 Codex 定期检查配置。
4
+
5
+## 目标
6
+
7
+线上 Node.js 服务出现异常时,把错误写入 MySQL 的 `SystemErrorLogs` 表,并提供内部 API 查询。
8
+
9
+当前链路:
10
+
11
+```text
12
+Koa / console.error / unhandledRejection / uncaughtException
13
+        -> SystemErrorLogs
14
+PM2 error log
15
+        -> scripts/collect-pm2-error-logs.js
16
+        -> SystemErrorLogs
17
+本机 VS Code / Codex
18
+        -> SSH 到服务器
19
+        -> curl http://127.0.0.1:3050/internal/error-logs
20
+```
21
+
22
+## 相关文件
23
+
24
+- `src/util/errorLogger.js`: 应用内错误采集入口
25
+- `src/model/systemErrorLog.js`: `SystemErrorLogs` 表和查询模型
26
+- `src/api/internal/routes.js`: 内部错误日志 API 路由
27
+- `src/api/internal/errorLogController.js`: 内部 API controller
28
+- `scripts/collect-pm2-error-logs.js`: 服务器 PM2 error log 兜底采集
29
+- `scripts/poll-error-logs.js`: 本机持续轮询脚本
30
+- `local-error-monitor/`: 本机一键检查工具
31
+- `.vscode/tasks.json`: VS Code 任务配置
32
+- `doc/system_error_logs.sql`: 建表 SQL
33
+
34
+## 服务器配置
35
+
36
+### 1. 创建错误日志表
37
+
38
+在服务器项目目录执行:
39
+
40
+```bash
41
+mysql -u cdb_outerroot -p kylx365_db < doc/system_error_logs.sql
42
+```
43
+
44
+服务启动时也会自动尝试创建表,但首次部署建议手动执行一次,便于确认权限正常。
45
+
46
+### 2. 设置内部 API token
47
+
48
+生成 token:
49
+
50
+```bash
51
+openssl rand -hex 32
52
+```
53
+
54
+生产环境必须设置:
55
+
56
+```bash
57
+INTERNAL_API_TOKEN=你的token
58
+```
59
+
60
+注意: token 不要提交到 git,不要写入公开文档。
61
+
62
+### 3. 启动 Node 24 的 web 服务
63
+
64
+服务器默认 `node` 可能是旧版本,所以 PM2 要使用 Node 24 的解释器。
65
+
66
+先确认 Node 24 路径:
67
+
68
+```bash
69
+nvm which 24.1.0
70
+```
71
+
72
+示例路径:
73
+
74
+```bash
75
+/root/.nvm/versions/node/v24.1.0/bin/node
76
+```
77
+
78
+如果已有 `app-24`,重启时带上 token:
79
+
80
+```bash
81
+INTERNAL_API_TOKEN=你的token pm2 restart app-24 --update-env
82
+```
83
+
84
+如果需要重新启动:
85
+
86
+```bash
87
+INTERNAL_API_TOKEN=你的token pm2 start src/app.js \
88
+  --name app-24 \
89
+  --interpreter /root/.nvm/versions/node/v24.1.0/bin/node \
90
+  --update-env
91
+```
92
+
93
+### 4. 启动 PM2 error log 兜底采集
94
+
95
+推荐直接启动脚本,不经过 `npm`,避免落到旧 Node。
96
+
97
+```bash
98
+INTERNAL_API_TOKEN=你的token pm2 start scripts/collect-pm2-error-logs.js \
99
+  --name error-log-pm2 \
100
+  --interpreter /root/.nvm/versions/node/v24.1.0/bin/node \
101
+  --update-env
102
+```
103
+
104
+检查状态:
105
+
106
+```bash
107
+pm2 list
108
+pm2 logs error-log-pm2 --lines 50
109
+```
110
+
111
+正常日志类似:
112
+
113
+```text
114
+[pm2-error-log-collector] watching /root/.pm2/logs
115
+```
116
+
117
+保存 PM2 配置:
118
+
119
+```bash
120
+pm2 save
121
+```
122
+
123
+## 服务器验证
124
+
125
+在服务器上执行:
126
+
127
+```bash
128
+curl -H "x-internal-token: 你的token" \
129
+  "http://127.0.0.1:3050/internal/error-logs/latest?status=all"
130
+```
131
+
132
+正常返回:
133
+
134
+```json
135
+{"errcode":10000,"result":{"list":[],"next_since_id":0}}
136
+```
137
+
138
+无 token 应返回 401:
139
+
140
+```bash
141
+curl "http://127.0.0.1:3050/internal/error-logs/latest?status=all"
142
+```
143
+
144
+正常返回:
145
+
146
+```json
147
+{"errcode":10401,"errMsg":"Unauthorized"}
148
+```
149
+
150
+手动写一条测试错误:
151
+
152
+```bash
153
+/root/.nvm/versions/node/v24.1.0/bin/node -e "import('./src/util/errorLogger.js').then(async ({recordError}) => { await recordError(new Error('manual test error log')); console.log('ok'); process.exit(0); }).catch((err) => { console.error(err); process.exit(1); })"
154
+```
155
+
156
+## 本机 VS Code 一键检查
157
+
158
+本机工具目录:
159
+
160
+```bash
161
+local-error-monitor/
162
+```
163
+
164
+### 1. 配置 `.env`
165
+
166
+复制模板:
167
+
168
+```bash
169
+cp local-error-monitor/.env.example local-error-monitor/.env
170
+```
171
+
172
+编辑:
173
+
174
+```bash
175
+ERROR_LOG_SSH_HOST=root@81.68.248.121
176
+ERROR_LOG_REMOTE_API_URL=http://127.0.0.1:3050/internal/error-logs
177
+ERROR_LOG_API_TOKEN=你的token
178
+ERROR_LOG_CHECK_LIMIT=20
179
+ERROR_LOG_CHECK_STATUS=open,reopened
180
+```
181
+
182
+说明:
183
+
184
+- 本机不会直接访问公网 `3050`。
185
+- 脚本会 SSH 到服务器,然后在服务器上访问 `127.0.0.1:3050`。
186
+- `.env` 已被 `local-error-monitor/.gitignore` 忽略,不应提交。
187
+
188
+### 2. 配置 SSH 免密
189
+
190
+建议使用 SSH key,不要把服务器密码写入项目。
191
+
192
+本机生成 key:
193
+
194
+```bash
195
+ssh-keygen -t ed25519 -C "miaoguo-error-monitor"
196
+```
197
+
198
+安装到服务器:
199
+
200
+```bash
201
+cat ~/.ssh/id_ed25519.pub | ssh root@81.68.248.121 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"
202
+```
203
+
204
+测试:
205
+
206
+```bash
207
+ssh root@81.68.248.121 "echo ok"
208
+```
209
+
210
+不再要求输入密码即可。
211
+
212
+也可以配置 `~/.ssh/config`:
213
+
214
+```sshconfig
215
+Host miaoguo-server
216
+  HostName 81.68.248.121
217
+  User root
218
+  IdentityFile ~/.ssh/id_ed25519
219
+```
220
+
221
+然后把 `.env` 改为:
222
+
223
+```bash
224
+ERROR_LOG_SSH_HOST=miaoguo-server
225
+```
226
+
227
+### 3. VS Code 一键检查
228
+
229
+在 VS Code 打开项目后:
230
+
231
+1. `Cmd + Shift + P`
232
+2. 选择 `Tasks: Run Task`
233
+3. 选择 `检查近期服务端 Bug`
234
+
235
+它实际执行:
236
+
237
+```bash
238
+local-error-monitor/run.sh
239
+```
240
+
241
+检查结果保存到:
242
+
243
+```bash
244
+local-error-monitor/data/recent-error-check.txt
245
+```
246
+
247
+检查状态保存到:
248
+
249
+```bash
250
+local-error-monitor/data/check-state.json
251
+```
252
+
253
+下次检查只显示上次之后的新错误。
254
+
255
+如果要从头重新检查,删除状态文件:
256
+
257
+```bash
258
+rm local-error-monitor/data/check-state.json
259
+```
260
+
261
+## Codex 定期检查
262
+
263
+当前已配置 Codex 自动化:
264
+
265
+```text
266
+ID: miaoguo
267
+名称: 定期检查 miaoguo 服务端错误日志
268
+频率: 每 1 小时
269
+状态: ACTIVE
270
+```
271
+
272
+它会定期运行:
273
+
274
+```bash
275
+/Users/chengjie/Documents/git/miaoguo_system_server/local-error-monitor/run.sh
276
+```
277
+
278
+如果没有新错误,会简短报告没有新错误。
279
+
280
+如果有新错误,会在当前 Codex 任务里列出错误摘要。之后可以让 Codex 根据日志定位代码并修复。
281
+
282
+## 常见问题
283
+
284
+### Unauthorized
285
+
286
+说明 SSH 和接口路径已经通了,但 token 不正确。
287
+
288
+检查:
289
+
290
+- 本机 `local-error-monitor/.env` 的 `ERROR_LOG_API_TOKEN`
291
+- 服务器 PM2 进程的 `INTERNAL_API_TOKEN`
292
+
293
+服务器验证:
294
+
295
+```bash
296
+curl -H "x-internal-token: 你的token" \
297
+  "http://127.0.0.1:3050/internal/error-logs/latest?status=all"
298
+```
299
+
300
+### fetch failed
301
+
302
+通常是本机直接访问公网 `3050` 失败,或者 SSH 隧道方式未启动。
303
+
304
+现在推荐使用 `local-error-monitor/check-once.js` 的 SSH 远程 curl 模式,不需要本机直接访问公网端口。
305
+
306
+### pm2: command not found
307
+
308
+非交互 SSH 可能不会加载完整 PATH。排查 PM2 时可以用登录 shell:
309
+
310
+```bash
311
+ssh root@81.68.248.121 'bash -lc "pm2 list"'
312
+```
313
+
314
+### Unexpected token 或 ESM 报错
315
+
316
+说明使用了旧 Node。服务器默认 Node 可能是 12,需要显式使用 Node 24:
317
+
318
+```bash
319
+/root/.nvm/versions/node/v24.1.0/bin/node
320
+```
321
+
322
+PM2 启动脚本时使用:
323
+
324
+```bash
325
+--interpreter /root/.nvm/versions/node/v24.1.0/bin/node
326
+```
327
+
328
+## 安全注意
329
+
330
+- `INTERNAL_API_TOKEN` 和 `ERROR_LOG_API_TOKEN` 不要提交到 git。
331
+- token 泄露后应重新生成并重启 `app-24`、`error-log-pm2`。
332
+- 本机检查建议走 SSH,不建议开放公网 `3050`。

+ 8 - 0
local-error-monitor/.env.example

@@ -0,0 +1,8 @@
1
+ERROR_LOG_SSH_HOST=root@81.68.248.121
2
+ERROR_LOG_REMOTE_API_URL=http://127.0.0.1:3050/internal/error-logs
3
+ERROR_LOG_API_TOKEN=replace-with-your-new-token
4
+ERROR_LOG_CHECK_LIMIT=20
5
+ERROR_LOG_CHECK_STATUS=open,reopened
6
+ERROR_LOG_POLL_INTERVAL_MS=60000
7
+ERROR_LOG_NOTIFY=true
8
+ERROR_LOG_START_FROM_BEGINNING=false

+ 3 - 0
local-error-monitor/.gitignore

@@ -0,0 +1,3 @@
1
+.env
2
+data/*
3
+!data/.gitkeep

+ 53 - 0
local-error-monitor/README.md

@@ -0,0 +1,53 @@
1
+# 本机错误日志检查
2
+
3
+这个目录用于在本机检查远程服务器写入 `SystemErrorLogs` 的错误日志。
4
+
5
+完整安装、配置、排查说明见:
6
+
7
+```bash
8
+doc/server_error_log_monitor.md
9
+```
10
+
11
+## 第一次配置
12
+
13
+复制配置文件:
14
+
15
+```bash
16
+cp local-error-monitor/.env.example local-error-monitor/.env
17
+```
18
+
19
+编辑 `local-error-monitor/.env`:
20
+
21
+```bash
22
+ERROR_LOG_SSH_HOST=root@服务器IP
23
+ERROR_LOG_REMOTE_API_URL=http://127.0.0.1:3050/internal/error-logs
24
+ERROR_LOG_API_TOKEN=你的新token
25
+```
26
+
27
+## VS Code 一键检查
28
+
29
+在 VS Code 中:
30
+
31
+1. `Cmd + Shift + P`
32
+2. 选择 `Tasks: Run Task`
33
+3. 选择 `检查近期服务端 Bug`
34
+
35
+检查结果会显示在 VS Code Terminal,并写入:
36
+
37
+```bash
38
+local-error-monitor/data/recent-error-check.txt
39
+```
40
+
41
+检查状态会保存在:
42
+
43
+```bash
44
+local-error-monitor/data/check-state.json
45
+```
46
+
47
+下次检查只显示上次之后的新错误。
48
+
49
+也可以直接运行:
50
+
51
+```bash
52
+./local-error-monitor/run.sh
53
+```

+ 175 - 0
local-error-monitor/check-once.js

@@ -0,0 +1,175 @@
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
+import { fileURLToPath } from 'node:url';
6
+
7
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+const monitorDir = __dirname;
9
+const dataDir = path.join(monitorDir, 'data');
10
+const envFile = path.join(monitorDir, '.env');
11
+
12
+function parseEnv(content) {
13
+  const env = {};
14
+  for (const line of content.split(/\r?\n/)) {
15
+    const trimmed = line.trim();
16
+    if (!trimmed || trimmed.startsWith('#')) {
17
+      continue;
18
+    }
19
+
20
+    const index = trimmed.indexOf('=');
21
+    if (index === -1) {
22
+      continue;
23
+    }
24
+
25
+    const key = trimmed.slice(0, index).trim();
26
+    const value = trimmed.slice(index + 1).trim().replace(/^['"]|['"]$/g, '');
27
+    env[key] = value;
28
+  }
29
+  return env;
30
+}
31
+
32
+async function loadEnv() {
33
+  const content = await fsp.readFile(envFile, 'utf8');
34
+  return {
35
+    ...process.env,
36
+    ...parseEnv(content),
37
+  };
38
+}
39
+
40
+async function readJson(filePath, fallback) {
41
+  try {
42
+    return JSON.parse(await fsp.readFile(filePath, 'utf8'));
43
+  } catch (error) {
44
+    return fallback;
45
+  }
46
+}
47
+
48
+async function writeJson(filePath, value) {
49
+  await fsp.mkdir(path.dirname(filePath), { recursive: true });
50
+  await fsp.writeFile(filePath, JSON.stringify(value, null, 2));
51
+}
52
+
53
+function execFileAsync(command, args, options = {}) {
54
+  return new Promise((resolve, reject) => {
55
+    execFile(command, args, options, (error, stdout, stderr) => {
56
+      if (error) {
57
+        error.stdout = stdout;
58
+        error.stderr = stderr;
59
+        reject(error);
60
+        return;
61
+      }
62
+      resolve({ stdout, stderr });
63
+    });
64
+  });
65
+}
66
+
67
+function shellQuote(value) {
68
+  return `'${String(value).replace(/'/g, `'\\''`)}'`;
69
+}
70
+
71
+function makeUrl(baseUrl, params) {
72
+  const url = new URL(baseUrl);
73
+  for (const [key, value] of Object.entries(params)) {
74
+    if (value !== undefined && value !== null && value !== '') {
75
+      url.searchParams.set(key, value);
76
+    }
77
+  }
78
+  return url.toString();
79
+}
80
+
81
+async function fetchViaSsh(env, url) {
82
+  const sshHost = env.ERROR_LOG_SSH_HOST;
83
+  if (!sshHost) {
84
+    throw new Error('ERROR_LOG_SSH_HOST is required for one-click check');
85
+  }
86
+
87
+  const remoteCommand = [
88
+    'curl',
89
+    '-sS',
90
+    '--max-time',
91
+    String(env.ERROR_LOG_CURL_TIMEOUT || 15),
92
+    '-H',
93
+    `x-internal-token: ${env.ERROR_LOG_API_TOKEN || env.INTERNAL_API_TOKEN || ''}`,
94
+    url,
95
+  ].map(shellQuote).join(' ');
96
+
97
+  const sshArgs = [
98
+    '-o',
99
+    `ConnectTimeout=${env.ERROR_LOG_SSH_CONNECT_TIMEOUT || 8}`,
100
+    sshHost,
101
+    remoteCommand,
102
+  ];
103
+
104
+  const { stdout } = await execFileAsync('ssh', sshArgs);
105
+  return JSON.parse(stdout);
106
+}
107
+
108
+function formatLog(item) {
109
+  const location = item.FilePath ? `${item.FilePath}:${item.LineNumber || 0}:${item.ColumnNumber || 0}` : 'unknown location';
110
+  const request = item.RequestUrl ? `${item.RequestMethod || 'REQUEST'} ${item.RequestUrl}` : '';
111
+  const time = item.LastSeenAt || item.CreateTime || '';
112
+
113
+  return [
114
+    `#${item.ID} ${item.Source || 'error'} ${item.Status || ''} x${item.Count || 1} ${time}`,
115
+    item.Message || '',
116
+    location,
117
+    request,
118
+  ].filter(Boolean).join('\n');
119
+}
120
+
121
+async function main() {
122
+  const env = await loadEnv();
123
+  const token = env.ERROR_LOG_API_TOKEN || env.INTERNAL_API_TOKEN || '';
124
+  if (!token) {
125
+    throw new Error('ERROR_LOG_API_TOKEN is required in local-error-monitor/.env');
126
+  }
127
+
128
+  const stateFile = env.ERROR_LOG_CHECK_STATE_FILE || path.join(dataDir, 'check-state.json');
129
+  const outputFile = env.ERROR_LOG_CHECK_OUTPUT_FILE || path.join(dataDir, 'recent-error-check.txt');
130
+  const state = await readJson(stateFile, { sinceId: Number(env.ERROR_LOG_SINCE_ID || 0) });
131
+  const limit = Math.min(Math.max(Number(env.ERROR_LOG_CHECK_LIMIT || 20), 1), 100);
132
+  const apiBaseUrl = env.ERROR_LOG_REMOTE_API_URL || 'http://127.0.0.1:3050/internal/error-logs';
133
+
134
+  const url = makeUrl(apiBaseUrl, {
135
+    since_id: state.sinceId || 0,
136
+    status: env.ERROR_LOG_CHECK_STATUS || 'open,reopened',
137
+    limit,
138
+  });
139
+
140
+  const data = await fetchViaSsh(env, url);
141
+  if (data.errcode !== 10000) {
142
+    throw new Error(data.errMsg || `Unexpected response: ${JSON.stringify(data)}`);
143
+  }
144
+
145
+  const list = data.result?.list || [];
146
+  const checkedAt = new Date().toISOString();
147
+
148
+  let output;
149
+  if (list.length === 0) {
150
+    output = `[${checkedAt}] No new server errors since ID ${state.sinceId || 0}.`;
151
+  } else {
152
+    const nextSinceId = list.reduce((maxId, item) => Math.max(maxId, Number(item.ID || 0)), Number(state.sinceId || 0));
153
+    state.sinceId = nextSinceId;
154
+    await writeJson(stateFile, state);
155
+
156
+    output = [
157
+      `[${checkedAt}] Found ${list.length} new server error(s). Next since ID: ${nextSinceId}`,
158
+      '',
159
+      ...list.map(formatLog),
160
+    ].join('\n\n');
161
+  }
162
+
163
+  await fsp.mkdir(dataDir, { recursive: true });
164
+  await fsp.writeFile(outputFile, output + '\n');
165
+  console.log(output);
166
+  console.log(`\nSaved to: ${outputFile}`);
167
+}
168
+
169
+main().catch((error) => {
170
+  console.error('[error-log-check] failed:', error.message || error);
171
+  if (error.stderr) {
172
+    console.error(error.stderr);
173
+  }
174
+  process.exit(1);
175
+});

+ 7 - 0
local-error-monitor/check-once.sh

@@ -0,0 +1,7 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+cd "$ROOT_DIR"
6
+
7
+node local-error-monitor/check-once.js

+ 1 - 0
local-error-monitor/data/.gitkeep

@@ -0,0 +1 @@
1
+

+ 5 - 0
local-error-monitor/run.sh

@@ -0,0 +1,5 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+exec "$SCRIPT_DIR/check-once.sh"