From 8a03e8c8b91f3056f053988f670beb06fcc29369 Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 4 Jun 2026 08:45:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E4=BF=AE=E5=A4=8DQQBot=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=99=BB=E5=BD=95=E4=BA=8C=E7=BB=B4=E7=A0=81=E5=88=B7?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqbot-napcat-login.service.spec.ts | 87 +++++++++++++++++++ .../account/qqbot-napcat-login.service.ts | 87 +++++++++++++++---- 2 files changed, 155 insertions(+), 19 deletions(-) diff --git a/src/qqbot/account/qqbot-napcat-login.service.spec.ts b/src/qqbot/account/qqbot-napcat-login.service.spec.ts index 6e335a3..525918f 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -53,6 +53,93 @@ describe('QqbotNapcatLoginService', () => { expect(getQrcode).not.toHaveBeenCalled(); }); + it('reuses the existing account container when refreshing login', async () => { + const account = { + id: 'account-1', + selfId: '10001', + }; + const existingContainer = { + id: 'container-current', + name: 'napcat-10001', + }; + const accountService = { + findById: jest.fn().mockResolvedValue(account), + }; + const containerService = { + prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + ); + const startScan = jest + .spyOn(refreshService as any, 'startScan') + .mockResolvedValue({ sessionId: 'session-refresh' }); + + await refreshService.startRefresh('account-1'); + + expect(containerService.prepareAccountContainer).toHaveBeenCalledWith( + account, + ); + expect(startScan).toHaveBeenCalledWith( + { + accountId: 'account-1', + expectedSelfId: '10001', + mode: 'refresh', + }, + existingContainer, + ); + }); + + it('requires a qrcode different from the current one when refreshing qrcode', async () => { + (service as any).sessions.set('session-refresh-qrcode', { + containerId: 'container-3', + containerName: 'napcat-3', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + id: 'session-refresh-qrcode', + mode: 'refresh', + qrcode: 'old-qrcode', + status: 'pending', + webuiPort: 6103, + }); + const container = { id: 'container-3' }; + jest + .spyOn(service as any, 'getSessionContainer') + .mockResolvedValue(container); + jest.spyOn(service as any, 'callRefreshQrcode').mockResolvedValue(''); + jest.spyOn(service as any, 'getQrcode').mockResolvedValue('new-qrcode'); + + const result = await service.refreshQrcode('session-refresh-qrcode'); + + expect(result.qrcode).toBe('new-qrcode'); + expect((service as any).getQrcode).toHaveBeenCalledWith(container, true, { + requireFresh: true, + staleQrcode: 'old-qrcode', + }); + }); + + it('retries while NapCat still exposes the stale qrcode', async () => { + jest.spyOn(service as any, 'sleep').mockResolvedValue(undefined); + jest + .spyOn(service as any, 'postNapcat') + .mockResolvedValueOnce({ qrcode: 'old-qrcode' }) + .mockResolvedValueOnce({ qrcode: 'new-qrcode' }); + + const result = await (service as any).getQrcode( + { id: 'container-4' }, + true, + { + requireFresh: true, + staleQrcode: 'old-qrcode', + }, + ); + + expect(result).toBe('new-qrcode'); + expect((service as any).postNapcat).toHaveBeenCalledTimes(2); + }); + it('does not replace current qrcode with expired status qrcode', async () => { (service as any).sessions.set('session-2', { containerId: 'container-2', diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index 9510ae4..0d5f0d3 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -57,6 +57,15 @@ type QqbotLoginScanSession = { webuiPort?: null | number; }; +type QrcodeLookupOptions = { + requireFresh?: boolean; + staleQrcode?: string; +}; + +type QrcodeRefreshOptions = QrcodeLookupOptions & { + fallbackStatus?: NapcatLoginStatus; +}; + export type QqbotLoginScanResult = { accountId?: string; containerId?: string; @@ -116,7 +125,10 @@ export class QqbotNapcatLoginService { } const container = await this.getSessionContainer(session); - session.qrcode = await this.refreshOrGetQrcode(container, true); + session.qrcode = await this.refreshOrGetQrcode(container, true, { + requireFresh: true, + staleQrcode: session.qrcode, + }); session.expiresAt = Date.now() + this.getSessionTtlMs(); session.errorMessage = undefined; this.sessions.set(session.id, session); @@ -174,11 +186,11 @@ export class QqbotNapcatLoginService { return this.completeLogin(session, container); } - const qrcode = await this.refreshOrGetQrcode( - container, - true, - loginStatus, - ); + const qrcode = await this.refreshOrGetQrcode(container, true, { + fallbackStatus: loginStatus, + requireFresh: this.isExpiredQrcodeStatus(loginStatus), + staleQrcode: loginStatus.qrcodeurl, + }); const session = this.createSession({ ...options, container, @@ -390,7 +402,11 @@ export class QqbotNapcatLoginService { }); } - private async getQrcode(container: QqbotNapcatRuntime, retry = false) { + private async getQrcode( + container: QqbotNapcatRuntime, + retry = false, + options: QrcodeLookupOptions = {}, + ) { return this.executeNapcatRequest(retry, async () => { try { const data = await this.postNapcat( @@ -399,16 +415,16 @@ export class QqbotNapcatLoginService { ); const qrcode = this.pickQrcode(data); if (!qrcode) { - return this.getQrcodeFromStatus(container); + return this.getQrcodeFromStatus(container, options); } - return qrcode; + return this.ensureFreshQrcode(qrcode, options); } catch (err) { if (this.isAlreadyLoggedIn(err)) { const status = await this.getLoginStatus(container); - return status.qrcodeurl || ''; + return this.ensureFreshQrcode(status.qrcodeurl || '', options); } if (this.isQrcodePending(err)) { - return this.getQrcodeFromStatus(container); + return this.getQrcodeFromStatus(container, options); } throw err; } @@ -418,31 +434,63 @@ export class QqbotNapcatLoginService { private async refreshOrGetQrcode( container: QqbotNapcatRuntime, retry = false, - fallbackStatus?: NapcatLoginStatus, + options: QrcodeRefreshOptions = {}, ) { + const lookupOptions: QrcodeLookupOptions = { + requireFresh: options.requireFresh, + staleQrcode: options.staleQrcode, + }; try { const refreshedQrcode = await this.callRefreshQrcode(container, retry); - if (refreshedQrcode) return refreshedQrcode; - return await this.getQrcode(container, retry); + if (refreshedQrcode) { + return this.ensureFreshQrcode(refreshedQrcode, lookupOptions); + } + return await this.getQrcode(container, retry, lookupOptions); } catch (err) { if ( - fallbackStatus?.qrcodeurl && - !this.isExpiredQrcodeStatus(fallbackStatus) + !lookupOptions.requireFresh && + options.fallbackStatus?.qrcodeurl && + !this.isExpiredQrcodeStatus(options.fallbackStatus) ) { - return fallbackStatus.qrcodeurl; + return options.fallbackStatus.qrcodeurl; } throw err; } } - private async getQrcodeFromStatus(container: QqbotNapcatRuntime) { + private async getQrcodeFromStatus( + container: QqbotNapcatRuntime, + options: QrcodeLookupOptions = {}, + ) { const status = await this.getLoginStatus(container); if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) { - return status.qrcodeurl; + return this.ensureFreshQrcode(status.qrcodeurl, options); + } + if (options.requireFresh && status.qrcodeurl) { + throw new Error('NapCat 二维码仍未刷新'); } throwVbenError('NapCat 未返回登录二维码'); } + private ensureFreshQrcode(qrcode: string, options: QrcodeLookupOptions = {}) { + const normalized = `${qrcode || ''}`.trim(); + if (options.requireFresh && !normalized) { + throw new Error('NapCat 二维码仍未刷新'); + } + if ( + normalized && + options.requireFresh && + this.isSameQrcode(normalized, options.staleQrcode) + ) { + throw new Error('NapCat 二维码仍未刷新'); + } + return normalized; + } + + private isSameQrcode(left: string, right?: string) { + return !!right && left.trim() === right.trim(); + } + private pickQrcode(data?: NapcatQrcode | null) { if (!data) return ''; return `${data.qrcode || data.qrcodeurl || data.url || ''}`.trim(); @@ -582,6 +630,7 @@ export class QqbotNapcatLoginService { 'ETIMEDOUT', 'NapCat 请求超时', 'NapCat 未返回登录二维码', + 'NapCat 二维码仍未刷新', 'QRCode Get Error', 'socket hang up', ].some((keyword) => message.includes(keyword));