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();
});
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 () => {
(service as any).sessions.set('session-2', {
containerId: 'container-2',

View File

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