fix: 修复NapCat扫码会话持久化
This commit is contained in:
parent
3647ec9a7c
commit
2eea7b254b
@ -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',
|
||||
|
||||
@ -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 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({
|
||||
...(current || {}),
|
||||
...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,
|
||||
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')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 () => {
|
||||
const loginSessionRepository = createRepository<NapcatLoginSession>();
|
||||
const loginChallengeRepository =
|
||||
|
||||
@ -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<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(
|
||||
{ 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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user