From 0497233798e6ae6cbb5ab758afe4d849f7d15694 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 23 Jun 2026 01:29:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E7=8A=B6=E6=80=81=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/qqbot-account.service.ts | 23 +++++ .../login/qqbot-napcat-login.service.ts | 89 +++++++++++++++++++ .../napcat-login-state-store.service.ts | 5 ++ .../napcat-persistent-login-state.spec.ts | 31 +++++++ .../qqbot-napcat-login.service.spec.ts | 81 +++++++++++++++++ 5 files changed, 229 insertions(+) diff --git a/src/modules/qqbot/core/application/account/qqbot-account.service.ts b/src/modules/qqbot/core/application/account/qqbot-account.service.ts index ad9cd6e..7a51430 100644 --- a/src/modules/qqbot/core/application/account/qqbot-account.service.ts +++ b/src/modules/qqbot/core/application/account/qqbot-account.service.ts @@ -28,6 +28,7 @@ import type { QqbotAccountAbilityType, QqbotAccountListItem, QqbotConnectionRole, + QqbotNapcatRuntimeLoginStatus, QqbotNapcatWebuiStatus, QqbotRuntimeContainerStatus, } from '../../contract/qqbot.types'; @@ -546,6 +547,28 @@ export class QqbotAccountService { ); } + /** + * Persists the QQ-login-only status reported by NapCat WebUI without changing OneBot or container connectivity. + * @param selfId - QQ account number whose NapCat login state was just probed. + * @param qqLoginStatus - WebUI login state such as qrcode pending, offline, or online. + * @param lastError - Optional human-facing reason; null clears stale risk/offline text when the state is actionable. + */ + async markQqLoginStatus( + selfId: string, + qqLoginStatus: QqbotNapcatRuntimeLoginStatus, + lastError?: null | string, + ) { + const payload: Partial = { + qqLoginStatus, + }; + if (lastError !== undefined) { + payload.lastError = lastError + ? this.toolsService.toColumnText(lastError, 500) + : null; + } + await this.accountRepository.update({ selfId }, payload); + } + /** * 查询 QQBot 核心数据。 * @param account - account 输入;驱动 `toolsService.toTrimmedString()` 的 QQBot步骤。 diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index 9c76b8c..0f331d7 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -21,6 +21,7 @@ import type { QqbotLoginScanResult, QqbotLoginScanSession, QqbotLoginScanStatus, + QqbotNapcatRuntimeLoginStatus, QqbotNapcatRuntime, QrcodeLookupOptions, QrcodeRefreshOptions, @@ -242,6 +243,9 @@ export class QqbotNapcatLoginService { return this.toResult(session); } if (session.preparingRelogin) { + if (this.recoverStaleReloginPreparation(session)) { + return this.refreshQrcode(sessionId); + } return this.keepSessionPending( session, session.errorMessage || 'NapCat 正在尝试快速登录,请稍后', @@ -272,6 +276,9 @@ export class QqbotNapcatLoginService { true, ); } + if (!loginStatus.isLogin) { + await this.syncSessionQqLoginStatus(session, loginStatus); + } if (loginStatus.isOffline && session.mode !== 'refresh') { await this.restartNapcatForLogin(container, { waitForReady: false }); @@ -354,6 +361,7 @@ export class QqbotNapcatLoginService { ); } if (!status.isLogin) { + await this.syncSessionQqLoginStatus(session, status); const captchaUrl = this.getCaptchaUrlFromStatus(status); if (captchaUrl) { return this.keepPasswordCaptchaPending( @@ -912,6 +920,83 @@ export class QqbotNapcatLoginService { }; } + /** + * Persists the QQ-login-only status observed during a scan session without altering container or OneBot state. + * @param session - Login session that carries the target QQ number for refresh-login flows. + * @param status - Latest NapCat WebUI CheckLoginStatus response used as the QQ login source of truth. + */ + private async syncSessionQqLoginStatus( + session: QqbotLoginScanSession, + status: NapcatLoginStatus, + ) { + const selfId = this.toolsService.toTrimmedString(session.expectedSelfId); + if (!selfId) return; + + const marker = ( + this.accountService as unknown as { + markQqLoginStatus?: ( + selfId: string, + qqLoginStatus: QqbotNapcatRuntimeLoginStatus, + lastError?: null | string, + ) => Promise; + } + ).markQqLoginStatus; + if (!marker) return; + + const qqLoginStatus = this.toSessionQqLoginStatus(status); + const lastError = this.toSessionQqLoginError(status, qqLoginStatus); + await marker.call(this.accountService, selfId, qqLoginStatus, lastError); + } + + /** + * Converts a NapCat WebUI login probe into the account-table QQ login status vocabulary. + * @param status - Raw WebUI CheckLoginStatus payload returned by the current container. + * @returns Persistable QQ-login-only state. + */ + private toSessionQqLoginStatus( + status: NapcatLoginStatus, + ): QqbotNapcatRuntimeLoginStatus { + const message = this.toolsService.toTrimmedString(status.loginError); + if (status.isLogin) return 'online'; + if ( + this.toolsService.isNapcatExpiredQrcodeStatus(status) || + message.includes('二维码已过期') + ) { + return 'qrcode_expired'; + } + if (status.qrcodeurl) return 'qrcode_pending'; + if ( + status.isOffline || + this.toolsService.isNapcatOfflineLoginMessage(message) + ) { + return 'offline'; + } + return 'unknown'; + } + + /** + * Selects the account error text that should accompany a QQ-login-only status update. + * @param status - Raw WebUI status containing the optional NapCat login error text. + * @param qqLoginStatus - Normalized state that decides whether stale error text should be cleared. + * @returns Null to clear stale errors, a reason string, or undefined when the account row should be left unchanged. + */ + private toSessionQqLoginError( + status: NapcatLoginStatus, + qqLoginStatus: QqbotNapcatRuntimeLoginStatus, + ) { + const message = this.toolsService.toTrimmedString(status.loginError); + if (qqLoginStatus === 'online' || qqLoginStatus === 'qrcode_pending') { + return message || null; + } + if (qqLoginStatus === 'offline') { + return message || 'NapCat 账号已离线,请重新扫码登录'; + } + if (qqLoginStatus === 'qrcode_expired') { + return message || 'NapCat 登录二维码已过期'; + } + return message || undefined; + } + /** * 启动New Device Verification。 * @param session - session 输入;使用 `status`、`captchaUrl`、`qrcode`、`deviceVerifyUrl` 字段生成结果。 @@ -2178,6 +2263,10 @@ export class QqbotNapcatLoginService { 'processing', '等待扫码确认', ); + await this.syncSessionQqLoginStatus(session, { + isLogin: false, + qrcodeurl: session.qrcode, + }); } catch (err) { const message = this.toolsService.getErrorMessage(err); if (this.toolsService.isNapcatTemporaryError(err)) { diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts index 4cbe6e8..c2fa5ea 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts @@ -251,6 +251,11 @@ export class NapcatLoginStateStoreService { */ private async markCompleted(sessionId: string) { if (!this.loginSessionRepository) return; + const current = await this.loginSessionRepository.findOne({ + where: { sessionKey: sessionId }, + }); + if (!current || current.status === 'pending') return; + await this.loginSessionRepository.update( { sessionKey: sessionId }, { diff --git a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts index 5f67d13..e8438ec 100644 --- a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +++ b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts @@ -264,6 +264,37 @@ describe('NapCat persistent login state contract', () => { ); }); + it('does not mark a still-pending scan session completed when it is removed from the runtime cache', async () => { + const loginSessionRepository = createRepository(); + const store = new NapcatLoginStateStoreService( + loginSessionRepository as any, + ); + + store.set({ + containerId: 'container-pending-delete', + containerName: 'kt-qqbot-napcat-pending-delete', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + id: 'pending-delete-session', + mode: 'refresh', + qrcode: 'https://txz.qq.com/p?k=fresh', + status: 'pending', + webuiPort: 6099, + }); + await store.flushSessionWrites('pending-delete-session'); + + store.delete('pending-delete-session'); + await store.flushSessionWrites('pending-delete-session'); + + expect(loginSessionRepository.rows[0]).toEqual( + expect.objectContaining({ + completedAt: null, + sessionKey: 'pending-delete-session', + status: 'pending', + }), + ); + }); + it('recovers captcha, new-device, and cleanup blockers after cache miss', async () => { const loginSessionRepository = createRepository(); const loginChallengeRepository = diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 6711e00..4b113bf 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -3107,6 +3107,87 @@ describe('QqbotNapcatLoginService', () => { expect(getLoginStatus).not.toHaveBeenCalled(); }); + it('refreshes stale relogin qrcode from NapCat status instead of staying in quick login pending', async () => { + (service as any).sessions.set('session-stale-relogin-qrcode', { + accountId: 'account-1', + containerId: 'container-stale-relogin', + containerName: 'napcat-stale-relogin', + createdAt: Date.now(), + errorMessage: 'NapCat 正在尝试快速登录,请稍后', + expectedSelfId: '10001', + expiresAt: Date.now() + 60_000, + id: 'session-stale-relogin-qrcode', + lastRestartedAt: 1, + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + webuiPort: 6110, + }); + const container = { id: 'container-stale-relogin' }; + jest + .spyOn(service as any, 'getSessionContainer') + .mockResolvedValue(container); + jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + qrcodeurl: 'status-qrcode', + }); + jest + .spyOn(service as any, 'callRefreshQrcode') + .mockResolvedValue('fresh-qrcode'); + + const result = await service.refreshQrcode('session-stale-relogin-qrcode'); + + expect(result.status).toBe('pending'); + expect(result.qrcode).toBe('fresh-qrcode'); + expect(result.errorMessage).toBeUndefined(); + expect((service as any).sessions.get('session-stale-relogin-qrcode')).toEqual( + expect.objectContaining({ + preparingRelogin: false, + qrcode: 'fresh-qrcode', + }), + ); + }); + + it('syncs account qrcode status when scan status reads a NapCat qrcode', async () => { + const accountService = { + markQqLoginStatus: jest.fn(), + }; + const syncService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + {} as QqbotNapcatContainerService, + new ToolsService(), + ); + (syncService as any).sessions.set('session-sync-qrcode-status', { + accountId: 'account-1', + containerId: 'container-sync-status', + containerName: 'napcat-sync-status', + createdAt: Date.now(), + expectedSelfId: '10001', + expiresAt: Date.now() + 60_000, + id: 'session-sync-qrcode-status', + mode: 'refresh', + status: 'pending', + webuiPort: 6111, + }); + jest + .spyOn(syncService as any, 'getSessionContainer') + .mockResolvedValue({ id: 'container-sync-status' }); + jest.spyOn(syncService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + qrcodeurl: 'status-qrcode', + }); + + const result = await syncService.status('session-sync-qrcode-status'); + + expect(result.qrcode).toBe('status-qrcode'); + expect(accountService.markQqLoginStatus).toHaveBeenCalledWith( + '10001', + 'qrcode_pending', + null, + ); + }); + it('replays scan progress events to late SSE subscribers', () => { const session = { containerId: 'container-events',