fix: 修复NapCat扫码会话持久化

This commit is contained in:
sunlei 2026-06-23 10:03:10 +08:00
parent 3647ec9a7c
commit 2eea7b254b
4 changed files with 192 additions and 9 deletions

View File

@ -1844,6 +1844,8 @@ export class QqbotNapcatLoginService {
if (!cleaned) return this.toResult(session); if (!cleaned) return this.toResult(session);
session.status = 'expired'; session.status = 'expired';
session.errorMessage = session.errorMessage || '扫码会话已过期'; session.errorMessage = session.errorMessage || '扫码会话已过期';
this.persistLoginSession(session);
await this.loginSessionStore.flushSessionWrites(session.id);
this.publishScanResultEvent( this.publishScanResultEvent(
session, session,
'session-expired', 'session-expired',

View File

@ -215,7 +215,9 @@ export class NapcatLoginStateStoreService {
} }
}); });
this.pendingSessionWrites[sessionId] = tracked; 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) { private async persistSession(session: QqbotLoginScanSession) {
if (!this.loginSessionRepository) return; if (!this.loginSessionRepository) return;
const current = await this.loginSessionRepository.findOne({ const snapshot = this.toSessionPersistenceSnapshot(session);
where: { sessionKey: session.id }, const updateResult = await this.loginSessionRepository.update(
}); { sessionKey: session.id },
const entity = this.loginSessionRepository.create({ snapshot as any,
...(current || {}), );
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<NapcatLoginSession> {
return {
accountId: session.accountId || null, accountId: session.accountId || null,
completedAt: completedAt:
session.status === 'pending' session.status === 'pending'
@ -238,11 +266,28 @@ export class NapcatLoginStateStoreService {
loginStage: this.pickLoginStage(session), loginStage: this.pickLoginStage(session),
progressMessage: progressMessage:
session.errorMessage || this.pickProgressMessage(session), session.errorMessage || this.pickProgressMessage(session),
sessionKey: session.id,
sessionPayload: session, sessionPayload: session,
status: session.status, 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')
);
} }
/** /**

View File

@ -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<NapcatLoginSession>();
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 () => { it('recovers captcha, new-device, and cleanup blockers after cache miss', async () => {
const loginSessionRepository = createRepository<NapcatLoginSession>(); const loginSessionRepository = createRepository<NapcatLoginSession>();
const loginChallengeRepository = const loginChallengeRepository =

View File

@ -20,6 +20,8 @@ import { ToolsService } from '@/common';
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
import { QqbotNapcatLoginService } from '@/modules/qqbot/napcat/application/login/qqbot-napcat-login.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 { 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', () => { describe('QqbotNapcatLoginService', () => {
/** /**
@ -52,6 +54,52 @@ describe('QqbotNapcatLoginService', () => {
}); });
const toolsService = new ToolsService(); const toolsService = new ToolsService();
/**
* TypeORM Repository
* @returns rows Repository sessionKey update/save
*/
const createLoginSessionRepository = () => {
const rows: NapcatLoginSession[] = [];
return {
create: jest.fn(
(input: Partial<NapcatLoginSession>) =>
({ ...input }) as NapcatLoginSession,
),
findOne: jest.fn(async ({ where }: { where: Record<string, any> }) => {
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<string, any>,
input: Partial<NapcatLoginSession>,
) => {
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( const service = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService, { get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService, {} as QqbotAccountService,
@ -3239,6 +3287,54 @@ describe('QqbotNapcatLoginService', () => {
expect(getLoginStatus).not.toHaveBeenCalled(); 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 () => { 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', { (service as any).sessions.set('session-stale-relogin-qrcode', {
accountId: 'account-1', accountId: 'account-1',