fix(api): 修复QQBot更新登录二维码刷新

This commit is contained in:
sunlei 2026-06-04 08:45:07 +08:00
parent 3720819b96
commit 8a03e8c8b9
2 changed files with 155 additions and 19 deletions

View File

@ -53,6 +53,93 @@ describe('QqbotNapcatLoginService', () => {
expect(getQrcode).not.toHaveBeenCalled(); expect(getQrcode).not.toHaveBeenCalled();
}); });
it('reuses the existing account container when refreshing login', async () => {
const account = {
id: 'account-1',
selfId: '10001',
};
const existingContainer = {
id: 'container-current',
name: 'napcat-10001',
};
const accountService = {
findById: jest.fn().mockResolvedValue(account),
};
const containerService = {
prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer),
};
const refreshService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
);
const startScan = jest
.spyOn(refreshService as any, 'startScan')
.mockResolvedValue({ sessionId: 'session-refresh' });
await refreshService.startRefresh('account-1');
expect(containerService.prepareAccountContainer).toHaveBeenCalledWith(
account,
);
expect(startScan).toHaveBeenCalledWith(
{
accountId: 'account-1',
expectedSelfId: '10001',
mode: 'refresh',
},
existingContainer,
);
});
it('requires a qrcode different from the current one when refreshing qrcode', async () => {
(service as any).sessions.set('session-refresh-qrcode', {
containerId: 'container-3',
containerName: 'napcat-3',
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
id: 'session-refresh-qrcode',
mode: 'refresh',
qrcode: 'old-qrcode',
status: 'pending',
webuiPort: 6103,
});
const container = { id: 'container-3' };
jest
.spyOn(service as any, 'getSessionContainer')
.mockResolvedValue(container);
jest.spyOn(service as any, 'callRefreshQrcode').mockResolvedValue('');
jest.spyOn(service as any, 'getQrcode').mockResolvedValue('new-qrcode');
const result = await service.refreshQrcode('session-refresh-qrcode');
expect(result.qrcode).toBe('new-qrcode');
expect((service as any).getQrcode).toHaveBeenCalledWith(container, true, {
requireFresh: true,
staleQrcode: 'old-qrcode',
});
});
it('retries while NapCat still exposes the stale qrcode', async () => {
jest.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
jest
.spyOn(service as any, 'postNapcat')
.mockResolvedValueOnce({ qrcode: 'old-qrcode' })
.mockResolvedValueOnce({ qrcode: 'new-qrcode' });
const result = await (service as any).getQrcode(
{ id: 'container-4' },
true,
{
requireFresh: true,
staleQrcode: 'old-qrcode',
},
);
expect(result).toBe('new-qrcode');
expect((service as any).postNapcat).toHaveBeenCalledTimes(2);
});
it('does not replace current qrcode with expired status qrcode', async () => { it('does not replace current qrcode with expired status qrcode', async () => {
(service as any).sessions.set('session-2', { (service as any).sessions.set('session-2', {
containerId: 'container-2', containerId: 'container-2',

View File

@ -57,6 +57,15 @@ type QqbotLoginScanSession = {
webuiPort?: null | number; webuiPort?: null | number;
}; };
type QrcodeLookupOptions = {
requireFresh?: boolean;
staleQrcode?: string;
};
type QrcodeRefreshOptions = QrcodeLookupOptions & {
fallbackStatus?: NapcatLoginStatus;
};
export type QqbotLoginScanResult = { export type QqbotLoginScanResult = {
accountId?: string; accountId?: string;
containerId?: string; containerId?: string;
@ -116,7 +125,10 @@ export class QqbotNapcatLoginService {
} }
const container = await this.getSessionContainer(session); const container = await this.getSessionContainer(session);
session.qrcode = await this.refreshOrGetQrcode(container, true); session.qrcode = await this.refreshOrGetQrcode(container, true, {
requireFresh: true,
staleQrcode: session.qrcode,
});
session.expiresAt = Date.now() + this.getSessionTtlMs(); session.expiresAt = Date.now() + this.getSessionTtlMs();
session.errorMessage = undefined; session.errorMessage = undefined;
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
@ -174,11 +186,11 @@ export class QqbotNapcatLoginService {
return this.completeLogin(session, container); return this.completeLogin(session, container);
} }
const qrcode = await this.refreshOrGetQrcode( const qrcode = await this.refreshOrGetQrcode(container, true, {
container, fallbackStatus: loginStatus,
true, requireFresh: this.isExpiredQrcodeStatus(loginStatus),
loginStatus, staleQrcode: loginStatus.qrcodeurl,
); });
const session = this.createSession({ const session = this.createSession({
...options, ...options,
container, container,
@ -390,7 +402,11 @@ export class QqbotNapcatLoginService {
}); });
} }
private async getQrcode(container: QqbotNapcatRuntime, retry = false) { private async getQrcode(
container: QqbotNapcatRuntime,
retry = false,
options: QrcodeLookupOptions = {},
) {
return this.executeNapcatRequest(retry, async () => { return this.executeNapcatRequest(retry, async () => {
try { try {
const data = await this.postNapcat<NapcatQrcode>( const data = await this.postNapcat<NapcatQrcode>(
@ -399,16 +415,16 @@ export class QqbotNapcatLoginService {
); );
const qrcode = this.pickQrcode(data); const qrcode = this.pickQrcode(data);
if (!qrcode) { if (!qrcode) {
return this.getQrcodeFromStatus(container); return this.getQrcodeFromStatus(container, options);
} }
return qrcode; return this.ensureFreshQrcode(qrcode, options);
} catch (err) { } catch (err) {
if (this.isAlreadyLoggedIn(err)) { if (this.isAlreadyLoggedIn(err)) {
const status = await this.getLoginStatus(container); const status = await this.getLoginStatus(container);
return status.qrcodeurl || ''; return this.ensureFreshQrcode(status.qrcodeurl || '', options);
} }
if (this.isQrcodePending(err)) { if (this.isQrcodePending(err)) {
return this.getQrcodeFromStatus(container); return this.getQrcodeFromStatus(container, options);
} }
throw err; throw err;
} }
@ -418,31 +434,63 @@ export class QqbotNapcatLoginService {
private async refreshOrGetQrcode( private async refreshOrGetQrcode(
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
retry = false, retry = false,
fallbackStatus?: NapcatLoginStatus, options: QrcodeRefreshOptions = {},
) { ) {
const lookupOptions: QrcodeLookupOptions = {
requireFresh: options.requireFresh,
staleQrcode: options.staleQrcode,
};
try { try {
const refreshedQrcode = await this.callRefreshQrcode(container, retry); const refreshedQrcode = await this.callRefreshQrcode(container, retry);
if (refreshedQrcode) return refreshedQrcode; if (refreshedQrcode) {
return await this.getQrcode(container, retry); return this.ensureFreshQrcode(refreshedQrcode, lookupOptions);
}
return await this.getQrcode(container, retry, lookupOptions);
} catch (err) { } catch (err) {
if ( if (
fallbackStatus?.qrcodeurl && !lookupOptions.requireFresh &&
!this.isExpiredQrcodeStatus(fallbackStatus) options.fallbackStatus?.qrcodeurl &&
!this.isExpiredQrcodeStatus(options.fallbackStatus)
) { ) {
return fallbackStatus.qrcodeurl; return options.fallbackStatus.qrcodeurl;
} }
throw err; throw err;
} }
} }
private async getQrcodeFromStatus(container: QqbotNapcatRuntime) { private async getQrcodeFromStatus(
container: QqbotNapcatRuntime,
options: QrcodeLookupOptions = {},
) {
const status = await this.getLoginStatus(container); const status = await this.getLoginStatus(container);
if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) { if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) {
return status.qrcodeurl; return this.ensureFreshQrcode(status.qrcodeurl, options);
}
if (options.requireFresh && status.qrcodeurl) {
throw new Error('NapCat 二维码仍未刷新');
} }
throwVbenError('NapCat 未返回登录二维码'); throwVbenError('NapCat 未返回登录二维码');
} }
private ensureFreshQrcode(qrcode: string, options: QrcodeLookupOptions = {}) {
const normalized = `${qrcode || ''}`.trim();
if (options.requireFresh && !normalized) {
throw new Error('NapCat 二维码仍未刷新');
}
if (
normalized &&
options.requireFresh &&
this.isSameQrcode(normalized, options.staleQrcode)
) {
throw new Error('NapCat 二维码仍未刷新');
}
return normalized;
}
private isSameQrcode(left: string, right?: string) {
return !!right && left.trim() === right.trim();
}
private pickQrcode(data?: NapcatQrcode | null) { private pickQrcode(data?: NapcatQrcode | null) {
if (!data) return ''; if (!data) return '';
return `${data.qrcode || data.qrcodeurl || data.url || ''}`.trim(); return `${data.qrcode || data.qrcodeurl || data.url || ''}`.trim();
@ -582,6 +630,7 @@ export class QqbotNapcatLoginService {
'ETIMEDOUT', 'ETIMEDOUT',
'NapCat 请求超时', 'NapCat 请求超时',
'NapCat 未返回登录二维码', 'NapCat 未返回登录二维码',
'NapCat 二维码仍未刷新',
'QRCode Get Error', 'QRCode Get Error',
'socket hang up', 'socket hang up',
].some((keyword) => message.includes(keyword)); ].some((keyword) => message.includes(keyword));