From 2eea7b254bffd75e876583133d1a211394b61d86 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 23 Jun 2026 10:03:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E4=BC=9A=E8=AF=9D=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/qqbot-napcat-login.service.ts | 2 + .../napcat-login-state-store.service.ts | 63 ++++++++++-- .../napcat-persistent-login-state.spec.ts | 40 ++++++++ .../qqbot-napcat-login.service.spec.ts | 96 +++++++++++++++++++ 4 files changed, 192 insertions(+), 9 deletions(-) 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 4c6ea43..634ac89 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 @@ -1844,6 +1844,8 @@ export class QqbotNapcatLoginService { if (!cleaned) return this.toResult(session); session.status = 'expired'; session.errorMessage = session.errorMessage || '扫码会话已过期'; + this.persistLoginSession(session); + await this.loginSessionStore.flushSessionWrites(session.id); this.publishScanResultEvent( session, 'session-expired', 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 c2fa5ea..2d7e3f7 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 @@ -215,7 +215,9 @@ export class NapcatLoginStateStoreService { } }); this.pendingSessionWrites[sessionId] = tracked; - void tracked.catch(() => undefined); + void tracked.catch((err) => + this.warnPersistenceError('登录会话持久化失败', err), + ); } /** @@ -224,11 +226,37 @@ export class NapcatLoginStateStoreService { */ private async persistSession(session: QqbotLoginScanSession) { if (!this.loginSessionRepository) return; - const current = await this.loginSessionRepository.findOne({ - where: { sessionKey: session.id }, - }); - const entity = this.loginSessionRepository.create({ - ...(current || {}), + const snapshot = this.toSessionPersistenceSnapshot(session); + const updateResult = await this.loginSessionRepository.update( + { sessionKey: session.id }, + snapshot as any, + ); + if (updateResult.affected) return; + + try { + const entity = this.loginSessionRepository.create({ + ...snapshot, + sessionKey: session.id, + }); + await this.loginSessionRepository.save(entity); + } catch (err) { + if (!this.isDuplicateSessionKeyError(err)) throw err; + await this.loginSessionRepository.update( + { sessionKey: session.id }, + snapshot as any, + ); + } + } + + /** + * Builds the database snapshot for a scan session without carrying stale entity fields from an older row. + * @param session - Runtime scan session whose status, QR, expiry and challenge markers are the source of truth. + * @returns Partial entity used for update-first persistence by session key. + */ + private toSessionPersistenceSnapshot( + session: QqbotLoginScanSession, + ): Partial { + return { accountId: session.accountId || null, completedAt: session.status === 'pending' @@ -238,11 +266,28 @@ export class NapcatLoginStateStoreService { loginStage: this.pickLoginStage(session), progressMessage: session.errorMessage || this.pickProgressMessage(session), - sessionKey: session.id, sessionPayload: session, status: session.status, - }); - await this.loginSessionRepository.save(entity); + }; + } + + /** + * Detects the session-key duplicate race that can happen when two workers insert the same scan session concurrently. + * @param err - Database error raised by TypeORM save; MySQL duplicate-key metadata may appear as code, errno or message text. + * @returns True when retrying as an update by sessionKey is safe. + */ + private isDuplicateSessionKeyError(err: unknown) { + const detail = + err && typeof err === 'object' + ? (err as { code?: string; errno?: number; message?: string }) + : undefined; + const message = detail?.message || ''; + return ( + detail?.code === 'ER_DUP_ENTRY' || + detail?.errno === 1062 || + message.includes('uk_napcat_login_session_key') || + message.includes('Duplicate entry') + ); } /** 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 e8438ec..f391261 100644 --- a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +++ b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts @@ -295,6 +295,46 @@ describe('NapCat persistent login state contract', () => { ); }); + it('replaces the persisted qrcode snapshot when a later status poll returns a fresher qrcode', async () => { + const loginSessionRepository = createRepository(); + const store = new NapcatLoginStateStoreService( + loginSessionRepository as any, + ); + + const session = { + containerId: 'container-refreshing-qrcode', + containerName: 'kt-qqbot-napcat-refreshing-qrcode', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + id: 'refreshing-qrcode-session', + mode: 'refresh', + qrcode: 'https://txz.qq.com/p?k=old', + status: 'pending', + webuiPort: 6099, + } as const; + + store.set(session); + await store.flushSessionWrites(session.id); + + store.set({ + ...session, + qrcode: 'https://txz.qq.com/p?k=fresh', + }); + await store.flushSessionWrites(session.id); + + expect(loginSessionRepository.rows).toHaveLength(1); + expect(loginSessionRepository.rows[0]).toEqual( + expect.objectContaining({ + loginStage: 'manual-qr', + sessionKey: session.id, + status: 'pending', + }), + ); + expect(loginSessionRepository.rows[0].sessionPayload?.qrcode).toBe( + 'https://txz.qq.com/p?k=fresh', + ); + }); + 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 c9eba09..c9beb8f 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -20,6 +20,8 @@ import { ToolsService } from '@/common'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import { QqbotNapcatLoginService } from '@/modules/qqbot/napcat/application/login/qqbot-napcat-login.service'; import { QqbotNapcatContainerService } from '@/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; +import { NapcatLoginSession } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-login-session.entity'; +import { NapcatLoginStateStoreService } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service'; describe('QqbotNapcatLoginService', () => { /** @@ -52,6 +54,52 @@ describe('QqbotNapcatLoginService', () => { }); const toolsService = new ToolsService(); + /** + * 创建用于登录会话持久化断言的 TypeORM Repository 替身。 + * @returns 带内存 rows 的最小 Repository;按 sessionKey 模拟 update/save 行为。 + */ + const createLoginSessionRepository = () => { + const rows: NapcatLoginSession[] = []; + return { + create: jest.fn( + (input: Partial) => + ({ ...input }) as NapcatLoginSession, + ), + findOne: jest.fn(async ({ where }: { where: Record }) => { + return ( + rows.find((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ) || null + ); + }), + rows, + save: jest.fn(async (input: NapcatLoginSession) => { + const index = rows.findIndex( + (row) => + (input.id && row.id === input.id) || + (input.sessionKey && row.sessionKey === input.sessionKey), + ); + if (index >= 0) { + rows[index] = { ...rows[index], ...input } as NapcatLoginSession; + return rows[index]; + } + rows.push(input); + return input; + }), + update: jest.fn( + async ( + where: Record, + input: Partial, + ) => { + const row = rows.find((item) => + Object.entries(where).every(([key, value]) => item[key] === value), + ); + if (row) Object.assign(row, input); + return { affected: row ? 1 : 0 }; + }, + ), + }; + }; const service = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, @@ -3239,6 +3287,54 @@ describe('QqbotNapcatLoginService', () => { expect(getLoginStatus).not.toHaveBeenCalled(); }); + it('persists expired scan sessions before removing them from the runtime cache', async () => { + const loginSessionRepository = createLoginSessionRepository(); + const loginStateStore = new NapcatLoginStateStoreService( + loginSessionRepository as any, + ); + const expireService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + { + removeUnboundContainer: jest.fn().mockResolvedValue(undefined), + } as unknown as QqbotNapcatContainerService, + new ToolsService(), + loginStateStore, + ); + + const session = { + containerId: 'container-expired-persist', + containerName: 'napcat-expired-persist', + createdAt: Date.now() - 120_000, + expiresAt: Date.now() - 1, + id: 'session-expired-persist', + mode: 'refresh', + qrcode: 'expired-qrcode', + status: 'pending', + webuiPort: 6110, + } as const; + (expireService as any).sessions.set(session.id, session); + await loginStateStore.flushSessionWrites(session.id); + + const result = await expireService.status(session.id); + await loginStateStore.flushSessionWrites(session.id); + + expect(result.status).toBe('expired'); + expect(loginSessionRepository.rows).toHaveLength(1); + expect(loginSessionRepository.rows[0]).toEqual( + expect.objectContaining({ + sessionKey: session.id, + status: 'expired', + }), + ); + expect(loginSessionRepository.rows[0].completedAt).toEqual( + expect.any(Date), + ); + expect(loginSessionRepository.rows[0].sessionPayload?.status).toBe( + 'expired', + ); + }); + 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',