fix: 修复NapCat扫码会话持久化
This commit is contained in:
parent
3647ec9a7c
commit
2eea7b254b
@ -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',
|
||||||
|
|||||||
@ -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 },
|
||||||
|
snapshot as any,
|
||||||
|
);
|
||||||
|
if (updateResult.affected) return;
|
||||||
|
|
||||||
|
try {
|
||||||
const entity = this.loginSessionRepository.create({
|
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,
|
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')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user