Skip to content

Commit eb157dc

Browse files
margropclaude
andcommitted
feat: 管理员调试模式与调试日志导出
管理员可在「设置 → 用户管理」开启调试模式,复现问题后导出调试日志发给作者排查: - 调试模式下记录全部级别的服务端日志(不受 LOG_LEVEL 限制)和前端上报的日志,保存在内存中,最多 5000 条,24 小时后自动关闭 - 记录时脱敏:API Key、密码、令牌、Cookie 替换为 [REDACTED],图片 data URL 只保留类型和长度 - 导出文件包含运行版本、系统环境和脱敏后的配置 - 前端注册全局错误监听,未捕获异常与未处理的 Promise 拒绝上报到服务端日志 - 抽出通用下载函数 downloadFile,数据备份与调试日志导出共用 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7f12272 commit eb157dc

22 files changed

Lines changed: 785 additions & 27 deletions

‎.github/ISSUE_TEMPLATE/bug_report.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,5 @@ body:
5454
id: logs
5555
attributes:
5656
label: 日志或截图 / Logs or screenshots
57-
description: 已脱敏的服务端日志、浏览器控制台错误或截图
57+
description: 已脱敏的服务端日志、浏览器控制台错误或截图。管理员可在「设置 → 用户管理」开启调试模式、复现后导出调试日志并拖入此处 / Redacted server logs, browser console errors or screenshots. Admins can turn on debug mode under Settings → User Management, reproduce the problem and drop the exported debug log here
5858
render: text

‎README.en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ Practice statistics show total practice, accuracy and active months, with distri
169169
- **🔑 One-time setup link**: fresh installs no longer ship an admin with a fixed password. On first start the server generates a setup link that works only once; open it to choose the admin email and password. The desktop and standalone Android apps open it automatically; on fnOS you enter the admin email and password in the install wizard instead; Docker, npm and source runs print it in the startup log (the token is also in `config/setup-token` in the data directory). Instances upgraded from earlier versions keep their accounts.
170170
- **🧭 Setup wizard**: on first sign-in the admin goes through a wizard: change the default password (only for the default admin of earlier versions) → set up an AI provider (free MiniMax Code recommended) → create the first notebook. The last two steps can be skipped, and the wizard can be run again from **Settings → User Management**. Instances already in use are not interrupted after upgrading.
171171
- **🔐 Multiple users and admin console**: sign-up and sign-in, with data isolated per user. New sign-ups are off by default (the sign-in page then hides the sign-up link and asks visitors to contact the administrator) and can be turned on under **Settings → User Management**, where admins can also cap daily AI use per user and see today's usage. Administrators open the admin console from **Settings → User Management** to view usage statistics and enable, disable or delete users. Sign-in and sign-up limit repeated failures to block password guessing.
172+
- **🐞 Debug logs**: when something goes wrong, an admin turns on debug mode under "Settings → User Management", reproduces the problem, then exports the debug log (JSON) and sends it to the author. Debug mode records every server log, including `debug` level, plus frontend errors; API keys, passwords and tokens are removed on export. The log lives in memory only, debug mode turns off after 24 hours, and a restart clears it.
172173
- **🔌 Openclaw integration**: an external Openclaw agent can upload question images in bulk via `POST /api/openclaw/batch-upload`; see [Environment variables](#-environment-variables).
173174

174175
## 🚀 Quick Start

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@
169169
- **🔑 一次性初始化链接**:全新安装不再预置固定密码的管理员。服务首次启动时生成只能使用一次的初始化链接,打开后自行设置管理员邮箱和密码:桌面版、安卓单机版自动打开;飞牛OS 直接在安装向导中填写管理员邮箱和密码;Docker、npm、源码运行打印在启动日志中(也可在数据目录的 `config/setup-token` 中找到令牌)。从旧版本升级的实例保留原有账号。
170170
- **🧭 安装向导**:管理员首次登录时进入向导:修改默认密码(仅旧版本的默认管理员需要)→ 配置 AI 渠道(推荐免费的 MiniMax Code)→ 创建第一个错题本,后两步可跳过;可在「设置 → 用户管理」中重新运行。已在使用的实例升级后不会自动弹出。
171171
- **🔐 多用户与管理后台**:支持注册、登录,数据按用户隔离;新用户注册默认关闭(关闭时登录页不显示注册入口,改为提示联系管理员),管理员可在「设置 → 用户管理」中开启,并可限制每人每天的 AI 使用次数、查看今日用量;管理员可在「设置 → 用户管理」进入管理后台,查看使用统计,启用/禁用或删除用户。登录和注册带失败次数限制,防止暴力破解密码。
172+
- **🐞 调试日志**:遇到问题时,管理员在「设置 → 用户管理」中打开调试模式,复现问题后导出调试日志(JSON)发给作者。调试模式下记录包括 debug 级别在内的全部服务端日志和前端错误,导出时自动去除 API Key、密码和令牌;日志只保存在内存中,24 小时后自动关闭,服务重启后清空。
172173
- **🔌 Openclaw 集成**:外部 Openclaw agent 可通过 `POST /api/openclaw/batch-upload` 批量上传题目图片,配置见[环境变量](#-环境变量)。
173174

174175
## 🚀 快速开始

‎doc/LOGGING_GUIDE.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- [最佳实践](#最佳实践)
1212
- [故障排查](#故障排查)
1313
- [前端日志](#前端日志-frontend-logger)
14+
- [调试模式与调试日志导出](#调试模式与调试日志导出)
1415

1516
---
1617

@@ -364,9 +365,28 @@ frontendLogger.forceFlush();
364365
}
365366
```
366367
368+
浏览器中未捕获的异常和未处理的 Promise 拒绝由 `installGlobalErrorLogging()`(在 `src/components/providers.tsx` 中注册)以 `[GlobalError]` 前缀上报。
369+
370+
---
371+
372+
## 调试模式与调试日志导出
373+
374+
管理员在「设置 → 用户管理 → 调试日志」中开启调试模式后,`src/lib/debug-log.ts` 会把**所有级别**的服务端日志(不受 `LOG_LEVEL` 限制,包括 `debug`、`trace` 和 `logger.box`)以及经 `/api/logs/frontend` 上报的前端日志写入内存缓冲区,复现问题后可导出 JSON 文件发给作者。
375+
376+
| 接口 | 说明 |
377+
|------|------|
378+
| `GET /api/admin/debug-logs` | 调试模式状态与已记录条数 |
379+
| `POST /api/admin/debug-logs` | `{ "enabled": true/false }` 开启(同时清空上一轮日志)或关闭 |
380+
| `DELETE /api/admin/debug-logs` | 清空已记录的日志 |
381+
| `GET /api/admin/debug-logs/export` | 下载调试日志:运行版本、系统环境、脱敏后的配置和日志条目 |
382+
383+
- **脱敏**:字段名含 API Key、password、secret、authorization、cookie、各类 token(不含 `maxTokens` 等用量字段)的值替换为 `[REDACTED]`;字符串中的 `Bearer` 令牌、`sk-` 开头的 Key、URL 中的 `key` 参数同样替换;图片 data URL 只保留类型和长度;单个字符串最长保留 10000 字符。
384+
- **上限**:最多 5000 条、约 20 MB,超出后丢弃最早的记录;开启 24 小时后自动关闭。
385+
- **存储**:只在服务进程内存中(挂在 `globalThis` 上,各路由共享),服务重启后调试模式关闭、日志清空。
386+
367387
---
368388
369-
**文档更新时间**: 2025-12-19
389+
**文档更新时间**: 2026-10-31
370390
371391
## 📚 相关文档
372392

‎doc/wiki/FAQ.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,4 @@
7777

7878
## 如何反馈问题?
7979

80-
在 [GitHub Issues](https://github.com/margrop/wrong-notebook/issues) 中提供运行版本、部署方式、浏览器、复现步骤和脱敏后的日志。版本可从设置“关于”或 `/api/health` 获取;不要提交 API Key、密码、会话信息或完整数据库。
80+
在 [GitHub Issues](https://github.com/margrop/wrong-notebook/issues) 中提供运行版本、部署方式、浏览器、复现步骤和脱敏后的日志。管理员可在“设置 → 用户管理”中打开调试模式、复现问题后导出调试日志一并附上,详见[调试日志](Settings-and-Users#调试日志)。版本可从设置“关于”或 `/api/health` 获取;不要提交 API Key、密码、会话信息或完整数据库。

‎doc/wiki/Settings-and-Users.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,16 @@
6363
## 查看运行版本
6464

6565
设置中的“关于”和 `GET /api/health` 可查看运行版本。排查问题时记录版本、复现步骤及脱敏后的错误提示;不要发送实际 API Key 或完整配置文件。
66+
67+
## 调试日志
68+
69+
遇到难以描述的问题时,管理员可以导出调试日志发给作者:
70+
71+
1. 打开“设置 → 用户管理”,打开“调试日志”中的“调试模式”开关(开启时会清空上一轮记录)。
72+
2. 复现问题,面板中会显示已记录的条数。
73+
3. 点击“导出调试日志”,得到 `wrong-notebook-debug-<时间>.json`,随 Issue 一并发送;之后可关闭开关。
74+
75+
调试模式下会记录全部服务端日志(包括 `LOG_LEVEL` 未输出的 debug、trace 级别,如 AI 请求与响应)和所有用户在浏览器中的未捕获错误;导出文件还包含运行版本、系统环境和脱敏后的配置。
76+
77+
- API Key、密码、令牌、Cookie 等字段导出时替换为 `[REDACTED]`,图片只保留类型和长度;日志中仍可能有题目内容和用户名,发送前可自行检查。
78+
- 日志只保存在服务进程内存中,最多保留 5000 条(超出时丢弃最早的记录);开启 24 小时后自动关闭,服务重启后清空。

‎doc/wiki/en/FAQ-en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,4 @@ First check that the original `data/` and `config/` are still mounted at `/app/d
7777

7878
## How do I report a problem?
7979

80-
Open a [GitHub issue](https://github.com/margrop/wrong-notebook/issues) with the running version, deployment method, browser, steps to reproduce and redacted logs. The version is shown in "About" in the settings or at `/api/health`; do not include API keys, passwords, session data or a full database.
80+
Open a [GitHub issue](https://github.com/margrop/wrong-notebook/issues) with the running version, deployment method, browser, steps to reproduce and redacted logs. Admins can turn on debug mode under "Settings → User Management", reproduce the problem and attach the exported debug log; see [Debug logs](Settings-and-Users-en#debug-logs). The version is shown in "About" in the settings or at `/api/health`; do not include API keys, passwords, session data or a full database.

‎doc/wiki/en/Settings-and-Users-en.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,16 @@ The task list lives in the server process's memory. Navigating away usually does
6363
## Check the running version
6464

6565
"About" in the settings and `GET /api/health` both show the running version. When troubleshooting, note the version, the steps to reproduce and the redacted error message; do not send real API keys or full configuration files.
66+
67+
## Debug logs
68+
69+
When a problem is hard to describe, an admin can export a debug log and send it to the author:
70+
71+
1. Open "Settings → User Management" and turn on "Debug mode" under "Debug Logs" (turning it on clears the previous recording).
72+
2. Reproduce the problem; the panel shows how many entries have been recorded.
73+
3. Click "Export Debug Log" to download `wrong-notebook-debug-<time>.json` and attach it to your issue. You can then turn debug mode off.
74+
75+
Debug mode records every server log, including the `debug` and `trace` levels that `LOG_LEVEL` normally hides (such as AI requests and responses), plus uncaught browser errors from all users. The export also contains the running version, system environment and redacted configuration.
76+
77+
- API keys, passwords, tokens, cookies and similar fields are replaced with `[REDACTED]` on export, and images keep only their type and size. The log may still contain question text and usernames, so review it before sending if you like.
78+
- The log lives only in the server process's memory and keeps at most 5000 entries (the oldest are discarded first). Debug mode turns off 24 hours after it is enabled, and a restart clears the log.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* /api/admin/debug-logs 集成测试:管理员权限、开关调试模式、导出脱敏日志
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
const mocks = vi.hoisted(() => ({
7+
getServerSession: vi.fn(),
8+
getAppConfig: vi.fn(() => ({ aiProvider: 'gemini', gemini: { apiKey: 'AIza-real-key', model: 'gemini-2.5-flash' } })),
9+
}));
10+
11+
vi.mock('next-auth', () => ({ getServerSession: mocks.getServerSession }));
12+
vi.mock('@/lib/auth', () => ({ authOptions: {} }));
13+
vi.mock('@/lib/config', () => ({ getAppConfig: mocks.getAppConfig }));
14+
15+
import { DELETE, GET, POST } from '@/app/api/admin/debug-logs/route';
16+
import { GET as EXPORT } from '@/app/api/admin/debug-logs/export/route';
17+
import { clearDebugLogs, setDebugMode } from '@/lib/debug-log';
18+
import { createLogger } from '@/lib/logger';
19+
20+
const admin = { user: { id: 'admin-1', role: 'admin' }, expires: '2099-01-01' };
21+
const user = { user: { id: 'user-1', role: 'user' }, expires: '2099-01-01' };
22+
23+
function toggle(body: unknown) {
24+
return POST(new Request('http://localhost/api/admin/debug-logs', { method: 'POST', body: JSON.stringify(body) }));
25+
}
26+
27+
describe('/api/admin/debug-logs', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks();
30+
mocks.getServerSession.mockResolvedValue(admin);
31+
});
32+
33+
afterEach(() => {
34+
setDebugMode(false);
35+
clearDebugLogs();
36+
});
37+
38+
it('未登录返回 401,普通用户返回 403', async () => {
39+
mocks.getServerSession.mockResolvedValue(null);
40+
expect((await GET()).status).toBe(401);
41+
mocks.getServerSession.mockResolvedValue(user);
42+
expect((await GET()).status).toBe(403);
43+
expect((await toggle({ enabled: true })).status).toBe(403);
44+
expect((await EXPORT()).status).toBe(403);
45+
expect((await DELETE()).status).toBe(403);
46+
});
47+
48+
it('参数不合法返回 400', async () => {
49+
expect((await toggle({ enabled: 'yes' })).status).toBe(400);
50+
});
51+
52+
it('开启后记录日志,导出包含环境信息且已脱敏,清空后条数归零', async () => {
53+
const status = await (await toggle({ enabled: true })).json();
54+
expect(status).toMatchObject({ enabled: true });
55+
expect(status.entryCount).toBeGreaterThan(0);
56+
57+
createLogger('ai:test').debug({ headers: { Authorization: 'Bearer secret-token' }, prompt: 'solve x' }, 'AI request');
58+
59+
const response = await EXPORT();
60+
expect(response.headers.get('Content-Disposition')).toMatch(/attachment; filename="wrong-notebook-debug-.+\.json"/);
61+
const text = await response.text();
62+
expect(text).not.toContain('secret-token');
63+
expect(text).not.toContain('AIza-real-key');
64+
const report = JSON.parse(text);
65+
expect(report).toMatchObject({
66+
format: 'wrong-notebook-debug-log',
67+
debug: { enabled: true },
68+
config: { aiProvider: 'gemini', gemini: { apiKey: '[REDACTED]', model: 'gemini-2.5-flash' } },
69+
});
70+
expect(report.app.version).toBeTruthy();
71+
expect(report.entries).toContainEqual(expect.objectContaining({ module: 'ai:test', level: 'debug', msg: 'AI request', prompt: 'solve x' }));
72+
73+
expect(await (await DELETE()).json()).toMatchObject({ enabled: true, entryCount: 0 });
74+
expect(await (await toggle({ enabled: false })).json()).toMatchObject({ enabled: false });
75+
});
76+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import {
3+
DEBUG_MODE_DURATION_MS,
4+
MAX_DEBUG_ENTRIES,
5+
MAX_STRING_LENGTH,
6+
clearDebugLogs,
7+
getDebugLogEntries,
8+
getDebugLogStatus,
9+
isDebugModeEnabled,
10+
recordDebugLog,
11+
sanitizeForDebug,
12+
setDebugMode,
13+
} from '@/lib/debug-log';
14+
import { createLogger } from '@/lib/logger';
15+
16+
afterEach(() => {
17+
setDebugMode(false);
18+
clearDebugLogs();
19+
});
20+
21+
describe('调试日志', () => {
22+
it('调试模式关闭时不记录', () => {
23+
recordDebugLog('info', {}, 'ignored');
24+
expect(getDebugLogEntries()).toHaveLength(0);
25+
});
26+
27+
it('开启后记录所有级别,开启时清空上一轮日志,关闭后保留日志', () => {
28+
setDebugMode(true);
29+
recordDebugLog('debug', { module: 'x' }, 'first');
30+
expect(getDebugLogEntries()).toHaveLength(1);
31+
32+
setDebugMode(true);
33+
recordDebugLog('trace', {}, 'second');
34+
expect(getDebugLogEntries().map((e) => e.msg)).toEqual(['second']);
35+
36+
setDebugMode(false);
37+
recordDebugLog('info', {}, 'after off');
38+
expect(getDebugLogStatus()).toMatchObject({ enabled: false, entryCount: 1 });
39+
});
40+
41+
it('超过时限后自动关闭', () => {
42+
const start = Date.UTC(2026, 0, 1);
43+
setDebugMode(true, start);
44+
expect(isDebugModeEnabled(start + DEBUG_MODE_DURATION_MS - 1)).toBe(true);
45+
expect(isDebugModeEnabled(start + DEBUG_MODE_DURATION_MS)).toBe(false);
46+
});
47+
48+
it('超过条数上限时丢弃最早的记录', () => {
49+
setDebugMode(true);
50+
for (let i = 0; i < MAX_DEBUG_ENTRIES + 3; i++) recordDebugLog('info', {}, `m${i}`);
51+
const entries = getDebugLogEntries();
52+
expect(entries).toHaveLength(MAX_DEBUG_ENTRIES);
53+
expect(entries[0].msg).toBe('m3');
54+
expect(getDebugLogStatus().droppedCount).toBe(3);
55+
});
56+
57+
it('logger 在调试模式下记录低于 LOG_LEVEL 的日志', () => {
58+
setDebugMode(true);
59+
createLogger('debug-test').trace({ step: 1 }, 'trace message');
60+
expect(getDebugLogEntries()).toEqual([
61+
expect.objectContaining({ level: 'trace', module: 'debug-test', step: 1, msg: 'trace message' }),
62+
]);
63+
});
64+
});
65+
66+
describe('调试日志脱敏', () => {
67+
it('去掉密钥类字段,保留 token 用量等普通字段', () => {
68+
const result = sanitizeForDebug({
69+
apiKey: 'AIza-secret',
70+
nested: { password: 'p', accessToken: 't', Authorization: 'Bearer x', maxTokens: 100, totalTokens: 5 },
71+
emptyKey: { apiKey: '' },
72+
});
73+
expect(result).toEqual({
74+
apiKey: '[REDACTED]',
75+
nested: { password: '[REDACTED]', accessToken: '[REDACTED]', Authorization: '[REDACTED]', maxTokens: 100, totalTokens: 5 },
76+
emptyKey: { apiKey: '' },
77+
});
78+
});
79+
80+
it('字符串中的 Bearer 令牌、sk- Key、URL key 参数和图片 data URL 被替换', () => {
81+
const text = sanitizeForDebug('Bearer abc.def sk-1234567890abcdef https://x/v1?key=AIzaSecret&a=1 data:image/png;base64,AAAABBBB') as string;
82+
expect(text).not.toMatch(/abc\.def|1234567890abcdef|AIzaSecret|AAAABBBB/);
83+
expect(text).toContain('[data:image/png,');
84+
expect(text).toContain('&a=1');
85+
});
86+
87+
it('超长字符串截断,循环引用和 Error 可序列化', () => {
88+
const circular: Record<string, unknown> = { name: 'a' };
89+
circular.self = circular;
90+
const result = sanitizeForDebug({
91+
long: 'x'.repeat(MAX_STRING_LENGTH + 10),
92+
circular,
93+
error: new TypeError('boom'),
94+
}) as Record<string, Record<string, unknown> | string>;
95+
expect((result.long as string).length).toBeLessThan(MAX_STRING_LENGTH + 50);
96+
expect((result.circular as Record<string, unknown>).self).toBe('[Circular]');
97+
expect(result.error).toMatchObject({ name: 'TypeError', message: 'boom' });
98+
expect(() => JSON.stringify(result)).not.toThrow();
99+
});
100+
});

0 commit comments

Comments
 (0)