fix: 修复NapCat WebUI文件管理与登录SSE状态监视

This commit is contained in:
sunlei 2026-06-24 20:16:16 +08:00
parent 89f68f6db0
commit dfca035749
7 changed files with 454 additions and 5 deletions

2
API.md
View File

@ -383,6 +383,8 @@ NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `
NapCat WebUI Gateway 是独立部署的内部代理服务,生产镜像由 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js`K8s 服务名为 `kt-napcat-webui-gateway`,端口 `48086`。API 侧只通过内部路由 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL` 创建、续期、撤销会话和交换一次性 ticket浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL` 下的代理页面、静态资源和 WebSocket 转发,不能直连 NapCat 容器 WebUI。
Gateway 只改写 NapCat HTML/JS/CSS 中需要浏览器直连的绝对根路径:`/webui/*`、`/api/*`、`/files/*` 和 `/plugin/*`。NapCat 文件管理的 `File` 路由属于 axios `baseURL="/api"` 下的 API 子路径,页面源码里的 `"/File/list"` 必须保持原样,由浏览器最终请求 `/api/File/list`;不能把 `/File/*` 当作独立静态根路径改写到 Gateway session 前缀,否则会形成 `/webui/api/napcat-webui/session/.../File/list` 并让文件管理拿到 HTML。
必需环境变量:`NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`。生产 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` 只来自 Jenkins 私有 `.env.production` 生成的 `kt-template-online-api-env` Secret不写入 Git 或 manifest 字面量。
部署验收使用:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全验收要求浏览器永远不接收 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。

View File

@ -141,7 +141,7 @@ export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) {
)}/webui`;
const rewritten = input.body.replace(
/(^|[\s"'`(=,:])\/(webui|api|File|plugin)(?=\/|[?#"'`)]|$)/g,
/(^|[\s"'`(=,:])\/(webui|api|files|plugin)(?=\/|[?#"'`)]|$)/g,
(_match, leader: string, root: string) =>
`${leader}${gatewayWebuiPrefix}/${root}`,
);

View File

@ -581,7 +581,13 @@ export class ToolsService {
* @param err -
*/
isNapcatAlreadyLoggedInError(err: unknown) {
return this.getErrorMessage(err).includes('QQ Is Logined');
const message = this.getErrorMessage(err);
return (
message.includes('QQ Is Logined') ||
(message.includes('当前账号') &&
message.includes('已登录') &&
message.includes('无法重复登录'))
);
}
/**

View File

@ -34,6 +34,10 @@ type PendingQrcodeUpdateOptions = {
clearStaleQrcode?: boolean;
requireFresh?: boolean;
};
type ScanStatusMonitorDeadline = {
expiresAt: number;
qrcode: string;
};
@Injectable()
export class QqbotNapcatLoginService {
@ -45,6 +49,14 @@ export class QqbotNapcatLoginService {
string,
Set<(event: QqbotLoginScanEvent) => void>
> = {};
private readonly scanStatusMonitorTimers: Record<
string,
NodeJS.Timeout | undefined
> = {};
private readonly scanStatusMonitorDeadlines: Record<
string,
ScanStatusMonitorDeadline | undefined
> = {};
private readonly refreshStartTasks: Record<
string,
Promise<QqbotLoginScanResult> | undefined
@ -53,7 +65,10 @@ export class QqbotNapcatLoginService {
/**
* NapCat回调状态
*/
clear: () => this.loginSessionStore.clear(),
clear: () => {
this.stopAllScanStatusMonitors();
this.loginSessionStore.clear();
},
/**
* NapCat回调数据
* @param sessionId - NapCat IDNapCat
@ -1507,6 +1522,9 @@ export class QqbotNapcatLoginService {
status,
step,
});
if (this.shouldMonitorScanStatus(session)) {
this.startScanStatusMonitor(session);
}
}
/**
@ -1709,10 +1727,130 @@ export class QqbotNapcatLoginService {
* @param sessionId - NapCat IDNapCat
*/
private cleanupSessionEvents(sessionId: string) {
this.stopScanStatusMonitor(sessionId);
delete this.sessionEventLogCache[sessionId];
delete this.sessionEventListenerCache[sessionId];
}
/**
* Determines whether a pending QR session needs server-side polling so SSE can progress without browser polling.
* @param session - Login session whose QR, preparation flags, and terminal status decide monitor ownership.
* @returns True when the backend should keep reconciling NapCat status for this session.
*/
private shouldMonitorScanStatus(session: QqbotLoginScanSession) {
return (
session.status === 'pending' &&
!!session.qrcode &&
!session.preparingContainer &&
!session.preparingRelogin
);
}
/**
* Starts one bounded status monitor for a QR session and lets the timer avoid holding the Node process open.
* @param session - Pending QR session whose id is used to drive later status reconciliation.
*/
private startScanStatusMonitor(session: QqbotLoginScanSession) {
this.ensureScanStatusMonitorDeadline(session);
if (this.hasScanStatusMonitorDeadlinePassed(session)) {
void this.expireSession(session);
return;
}
if (this.scanStatusMonitorTimers[session.id]) return;
const timer = setTimeout(() => {
this.scanStatusMonitorTimers[session.id] = undefined;
void this.runScanStatusMonitor(session.id);
}, this.getLoginPollIntervalMs());
timer.unref?.();
this.scanStatusMonitorTimers[session.id] = timer;
}
/**
* Stops any server-side status monitor for a login session.
* @param sessionId - Session key whose timer should no longer reconcile NapCat state.
*/
private stopScanStatusMonitor(sessionId: string) {
const timer = this.scanStatusMonitorTimers[sessionId];
if (timer) clearTimeout(timer);
delete this.scanStatusMonitorTimers[sessionId];
delete this.scanStatusMonitorDeadlines[sessionId];
}
/**
* Stops every server-side QR status monitor before the backing session store is cleared.
*/
private stopAllScanStatusMonitors() {
Object.keys(this.scanStatusMonitorTimers).forEach((sessionId) => {
this.stopScanStatusMonitor(sessionId);
});
}
/**
* Captures the active QR code deadline so monitor polling cannot extend the same QR session forever.
* @param session - Pending QR session whose current QR URL and expiry form the monitor deadline snapshot.
*/
private ensureScanStatusMonitorDeadline(session: QqbotLoginScanSession) {
if (!session.qrcode) return;
const current = this.scanStatusMonitorDeadlines[session.id];
if (current?.qrcode === session.qrcode) return;
this.scanStatusMonitorDeadlines[session.id] = {
expiresAt: session.expiresAt,
qrcode: session.qrcode,
};
}
/**
* Checks the monitor-owned QR deadline instead of the session expiry that status polling may renew.
* @param session - Pending QR session whose monitor snapshot decides terminal expiry.
* @returns True when the monitored QR should be expired by the backend monitor.
*/
private hasScanStatusMonitorDeadlinePassed(
session: QqbotLoginScanSession,
) {
const deadline = this.scanStatusMonitorDeadlines[session.id];
if (deadline && session.qrcode && deadline.qrcode !== session.qrcode) {
this.ensureScanStatusMonitorDeadline(session);
return false;
}
return !!deadline && Date.now() > deadline.expiresAt;
}
/**
* Polls the same status path used by Admin so SSE can emit success/expired events after QR generation.
* @param sessionId - Pending QR session id to reload from the session store before each poll.
*/
private async runScanStatusMonitor(sessionId: string) {
try {
const session = await this.loginSessionStore.get(sessionId);
if (!session || !this.shouldMonitorScanStatus(session)) return;
if (this.hasScanStatusMonitorDeadlinePassed(session)) {
await this.expireSession(session);
return;
}
const result = await this.status(sessionId);
if (result.status !== 'pending') return;
const current =
this.loginSessionStore.getCached(sessionId) ||
(await this.loginSessionStore.get(sessionId));
if (current && this.shouldMonitorScanStatus(current)) {
if (this.hasScanStatusMonitorDeadlinePassed(current)) {
await this.expireSession(current);
return;
}
this.startScanStatusMonitor(current);
}
} catch {
const current = this.loginSessionStore.getCached(sessionId);
if (current && this.shouldMonitorScanStatus(current)) {
if (this.hasScanStatusMonitorDeadlinePassed(current)) {
await this.expireSession(current);
return;
}
this.startScanStatusMonitor(current);
}
}
}
/**
* NapCat
* @param session - session 使 `status``errorMessage``expiresAt``qrcode`

View File

@ -351,6 +351,11 @@ describe('NapcatWebuiProxyService redirect rewriting', () => {
let credentialClient: {
getCredential: jest.Mock;
};
let upstreamRequests: Array<{
authorization?: string;
method?: string;
url?: string;
}>;
/**
* Starts a tiny NapCat-like upstream that redirects `/webui` to `/webui/`.
@ -358,6 +363,25 @@ describe('NapcatWebuiProxyService redirect rewriting', () => {
*/
async function startRedirectUpstream() {
const server = createServer((req, res) => {
upstreamRequests.push({
authorization: req.headers.authorization,
method: req.method,
url: req.url,
});
if (req.url === '/api/File/list?path=%2F') {
res.statusCode = HttpStatus.OK;
res.setHeader('content-type', 'application/json; charset=UTF-8');
res.end(
JSON.stringify({
code: 0,
data: [{ isDirectory: true, name: 'app', size: 0 }],
message: 'success',
}),
);
return;
}
if (req.url === '/webui') {
res.statusCode = HttpStatus.MOVED_PERMANENTLY;
res.setHeader('content-type', 'text/html; charset=UTF-8');
@ -386,6 +410,7 @@ describe('NapcatWebuiProxyService redirect rewriting', () => {
}
beforeEach(async () => {
upstreamRequests = [];
const started = await startRedirectUpstream();
upstream = started.server;
upstreamBaseUrl = started.baseUrl;
@ -446,4 +471,26 @@ describe('NapcatWebuiProxyService redirect rewriting', () => {
`/napcat-webui/session/${SESSION_ID}/webui/webui/`,
);
});
it('forwards NapCat File API requests under /api without duplicating the Gateway session prefix', async () => {
const response = await request(app.getHttpServer())
.get(`/napcat-webui/session/${SESSION_ID}/webui/api/File/list?path=%2F`)
.expect(HttpStatus.OK);
expect(response.body).toEqual({
code: 0,
data: [{ isDirectory: true, name: 'app', size: 0 }],
message: 'success',
});
expect(upstreamRequests).toContainEqual({
authorization: 'Bearer credential-1',
method: 'GET',
url: '/api/File/list?path=%2F',
});
expect(
upstreamRequests.some((item) =>
item.url?.includes('/api/napcat-webui/session/'),
),
).toBe(false);
});
});

View File

@ -17,7 +17,7 @@ describe('NapcatWebuiProxyService response rewriting', () => {
body: [
'<script type="module" src="/webui/assets/index.js"></script>',
'<link rel="stylesheet" href="/webui/assets/index.css">',
'<script>const baseURL="/api"; const file="/File/font/upload/webui";</script>',
'<script>const baseURL="/api"; const file="/File/list?path=%2F"; const theme="/files/theme.css";</script>',
'url("/webui/fonts/CustomFont.woff")',
].join(''),
sessionId: 'sess_1',
@ -32,8 +32,10 @@ describe('NapcatWebuiProxyService response rewriting', () => {
expect(rewritten).toContain(
'baseURL="/napcat-webui/session/sess_1/webui/api"',
);
expect(rewritten).toContain('file="/File/list?path=%2F"');
expect(rewritten).not.toContain('/webui/api/napcat-webui/session');
expect(rewritten).toContain(
'file="/napcat-webui/session/sess_1/webui/File/font/upload/webui"',
'theme="/napcat-webui/session/sess_1/webui/files/theme.css"',
);
expect(rewritten).toContain(
'url("/napcat-webui/session/sess_1/webui/webui/fonts/CustomFont.woff")',

View File

@ -1054,6 +1054,66 @@ describe('QqbotNapcatLoginService', () => {
);
});
it('confirms real login status when quick login reports already logged in using the NapCat Chinese message', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-quick-already-online-cn',
name: 'napcat-1914728559',
};
const accountService = {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
};
const containerService = {
bindAccount: jest.fn().mockResolvedValue(undefined),
resetRuntimeLoginState: jest.fn(),
restartRuntimeContainer: jest.fn(),
};
const refreshService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (refreshService as any).createSession({
accountId: 'account-1',
container,
expectedSelfId: '1914728559',
mode: 'refresh',
preparingRelogin: true,
status: 'pending',
});
(refreshService as any).sessions.set(session.id, session);
mockWebuiLoginPost(refreshService, {
quickError: '当前账号(1914728559)已登录,无法重复登录',
});
const getLoginStatus = jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockResolvedValue({ isLogin: true });
jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({
nickname: 'Mirror',
online: true,
uin: '1914728559',
});
const refreshQrcode = jest
.spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('fallback-qrcode');
await (refreshService as any).prepareReloginQrcode(session, container);
expect(getLoginStatus).toHaveBeenCalledWith(container, true);
expect(refreshQrcode).not.toHaveBeenCalled();
expect(session.status).toBe('success');
const events = (refreshService as any).sessionEventLogs.get(session.id);
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: 'NapCat 已登录,已确认真实在线状态',
step: 'login-success',
}),
]),
);
});
it('keeps binding errors terminal after already-logged quick status is confirmed online', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
@ -1114,6 +1174,200 @@ describe('QqbotNapcatLoginService', () => {
expect(steps).toContain('relogin-error');
});
it('monitors a ready qrcode session and publishes login success without frontend polling', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-monitor-qrcode',
name: 'napcat-monitor-qrcode',
};
const accountService = {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
};
const containerService = {
bindAccount: jest.fn().mockResolvedValue(undefined),
};
const monitorService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS' ? 10 : undefined,
),
} as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (monitorService as any).createSession({
accountId: 'account-1',
container,
expectedSelfId: '1914728559',
mode: 'refresh',
qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615',
status: 'pending',
});
(monitorService as any).sessions.set(session.id, session);
jest
.spyOn(monitorService as any, 'getSessionContainer')
.mockResolvedValue(container);
const getLoginStatus = jest
.spyOn(monitorService as any, 'getLoginStatus')
.mockResolvedValue({ isLogin: true });
jest.spyOn(monitorService as any, 'getLoginInfo').mockResolvedValue({
nick: 'Mirror',
online: true,
uin: '1914728559',
});
(monitorService as any).publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(getLoginStatus).toHaveBeenCalledWith(container);
expect(session.status).toBe('success');
expect((monitorService as any).sessionEventLogs.get(session.id)).toEqual(
expect.arrayContaining([
expect.objectContaining({ step: 'login-success' }),
]),
);
});
it('does not let the server-side qrcode monitor extend the original qrcode deadline on temporary errors', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-monitor-temporary-error',
name: 'napcat-monitor-temporary-error',
};
const monitorService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) => {
if (key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS') return 10;
if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') return 25;
return undefined;
}),
} as unknown as ConfigService,
{
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
} as unknown as QqbotAccountService,
{
bindAccount: jest.fn().mockResolvedValue(undefined),
} as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (monitorService as any).createSession({
accountId: 'account-1',
container,
expectedSelfId: '1914728559',
mode: 'refresh',
qrcode: 'https://txz.qq.com/p?k=temporary&f=1600001615',
status: 'pending',
});
(monitorService as any).sessions.set(session.id, session);
jest
.spyOn(monitorService as any, 'getSessionContainer')
.mockResolvedValue(container);
jest
.spyOn(monitorService as any, 'getLoginStatus')
.mockRejectedValue(new Error('NapCat 请求超时'));
const publishResult = jest.spyOn(
monitorService as any,
'publishScanResultEvent',
);
(monitorService as any).publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
await new Promise((resolve) => setTimeout(resolve, 80));
expect(session.status).toBe('expired');
expect(publishResult).toHaveBeenCalledWith(
session,
'session-expired',
'error',
expect.any(String),
);
});
it('refreshes the monitor deadline when a new qrcode is published while a timer is already active', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-monitor-new-qrcode',
name: 'napcat-monitor-new-qrcode',
};
let qrTtlReads = 0;
const monitorService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) => {
if (key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS') return 60;
if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') {
qrTtlReads += 1;
return qrTtlReads <= 2 ? 25 : 120;
}
return undefined;
}),
} as unknown as ConfigService,
{
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
} as unknown as QqbotAccountService,
{
bindAccount: jest.fn().mockResolvedValue(undefined),
} as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const firstQrcode = 'https://txz.qq.com/p?k=first&f=1600001615';
const nextQrcode = 'https://txz.qq.com/p?k=next&f=1600001615';
const session = (monitorService as any).createSession({
accountId: 'account-1',
container,
expectedSelfId: '1914728559',
mode: 'refresh',
qrcode: firstQrcode,
status: 'pending',
});
(monitorService as any).sessions.set(session.id, session);
jest
.spyOn(monitorService as any, 'getSessionContainer')
.mockResolvedValue(container);
jest.spyOn(monitorService as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
qrcodeurl: nextQrcode,
});
const publishResult = jest.spyOn(
monitorService as any,
'publishScanResultEvent',
);
(monitorService as any).publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
await new Promise((resolve) => setTimeout(resolve, 10));
session.qrcode = nextQrcode;
(monitorService as any).publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已刷新',
);
await new Promise((resolve) => setTimeout(resolve, 80));
expect(session.status).toBe('pending');
expect(
(monitorService as any).scanStatusMonitorDeadlines[session.id]?.qrcode,
).toBe(nextQrcode);
expect(
publishResult.mock.calls.some((call) => call[1] === 'session-expired'),
).toBe(false);
(monitorService as any).sessions.clear();
});
it('does not rebuild even when the refresh session already has a rebuild count', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',