fix: 修复 QQBot 更新登录链路

This commit is contained in:
sunlei 2026-06-04 11:07:29 +08:00
parent b0cf9323b8
commit fbaaee8bd7
3 changed files with 155 additions and 4 deletions

View File

@ -55,6 +55,7 @@ export class QqbotNapcatLoginService {
{
accountId: account.id,
expectedSelfId: account.selfId,
forceRelogin: true,
mode: 'refresh',
},
container,
@ -83,12 +84,16 @@ export class QqbotNapcatLoginService {
}
if (loginStatus.isOffline) {
if (session.mode === 'refresh') {
await this.resetNapcatForLogin(container, { waitForReady: false });
} else {
await this.restartNapcatForLogin(container, { waitForReady: false });
}
session.lastRestartedAt = Date.now();
return this.keepSessionPending(
session,
loginStatus.loginError ||
'NapCat 账号已离线,已重启容器并重新生成二维码',
'NapCat 账号已离线,已重新生成二维码',
true,
);
}
@ -162,6 +167,7 @@ export class QqbotNapcatLoginService {
options: {
accountId?: string;
expectedSelfId?: string;
forceRelogin?: boolean;
mode: QqbotLoginScanMode;
},
container: QqbotNapcatRuntime,
@ -169,9 +175,17 @@ export class QqbotNapcatLoginService {
await this.cleanupSessions();
try {
if (options.forceRelogin) {
await this.resetNapcatForLogin(container);
}
const loginStatus = await this.getLoginStatus(container, true);
if (loginStatus.isOffline) {
if (options.forceRelogin) {
await this.resetNapcatForLogin(container, { waitForReady: false });
} else {
await this.restartNapcatForLogin(container, { waitForReady: false });
}
const session = this.createSession({
...options,
container,
@ -180,7 +194,18 @@ export class QqbotNapcatLoginService {
session.lastRestartedAt = Date.now();
session.errorMessage =
loginStatus.loginError ||
'NapCat 账号已离线,已重启容器并重新生成二维码';
'NapCat 账号已离线,已重新生成二维码';
this.sessions.set(session.id, session);
return this.toResult(session);
}
if (options.forceRelogin && loginStatus.isLogin) {
const session = this.createSession({
...options,
container,
status: 'pending',
});
session.errorMessage = 'NapCat 正在重新生成二维码,请稍后刷新';
this.sessions.set(session.id, session);
return this.toResult(session);
}
@ -588,6 +613,24 @@ export class QqbotNapcatLoginService {
return this.requestNapcat<T>(container, path, body, credential);
}
private async resetNapcatForLogin(
container: QqbotNapcatRuntime,
options: NapcatRestartOptions = {},
) {
const resetByContainer =
await this.containerService.resetRuntimeLoginState(container);
if (!resetByContainer) {
await this.restartNapcatForLogin(container, options);
return;
}
this.credentials.delete(this.getCredentialCacheKey(container));
if (options.waitForReady === false) return;
await this.toolsService.sleep(this.getRestartDelayMs());
await this.getLoginStatus(container, true);
}
private async restartNapcatForLogin(
container: QqbotNapcatRuntime,
options: NapcatRestartOptions = {},

View File

@ -166,6 +166,34 @@ export class QqbotNapcatContainerService {
return true;
}
async resetRuntimeLoginState(runtime: QqbotNapcatRuntime) {
if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) {
return false;
}
const container = await this.containerRepository.findOne({
where: {
id: runtime.id,
isDeleted: false,
},
});
if (!container) {
throwVbenError('NapCat 容器不存在或已删除');
}
const script = this.buildRemoteResetLoginStateScript(container);
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
await this.containerRepository.update(
{ id: runtime.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return true;
}
private async removeContainer(containerId: string) {
const container = await this.containerRepository.findOne({
where: {
@ -221,6 +249,39 @@ fi
`;
}
private buildRemoteResetLoginStateScript(container: QqbotNapcatContainer) {
const dataDir = this.sh(container.dataDir || '');
const name = this.sh(container.name);
const rootDir = this.sh(this.getRootDir());
return `
set -eu
NAME=${name}
DATA_DIR=${dataDir}
ROOT_DIR=${rootDir}
if [ -z "$DATA_DIR" ] || [ "$DATA_DIR" = "/" ]; then
echo "unsafe empty data dir" >&2
exit 1
fi
case "$DATA_DIR" in
"$ROOT_DIR"/*)
;;
*)
echo "skip unsafe data dir: $DATA_DIR" >&2
exit 1
;;
esac
docker exec "$NAME" rm -f /app/napcat/cache/qrcode.png >/dev/null 2>&1 || true
docker stop "$NAME" >/dev/null 2>&1 || true
mkdir -p "$DATA_DIR/QQ"
find "$DATA_DIR/QQ" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
docker start "$NAME" >/dev/null
`;
}
private async getPrimaryRuntime(accountId: string) {
const binding = await this.bindingRepository.findOne({
order: {

View File

@ -90,12 +90,59 @@ describe('QqbotNapcatLoginService', () => {
{
accountId: 'account-1',
expectedSelfId: '10001',
forceRelogin: true,
mode: 'refresh',
},
existingContainer,
);
});
it('resets the existing container login state before refresh login scan', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-current',
name: 'napcat-10001',
};
const containerService = {
resetRuntimeLoginState: jest.fn().mockResolvedValue(true),
};
const refreshService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
jest
.spyOn((refreshService as any).toolsService, 'sleep')
.mockResolvedValue(undefined);
jest
.spyOn(refreshService as any, 'cleanupSessions')
.mockResolvedValue(undefined);
jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockResolvedValueOnce({ isLogin: false })
.mockResolvedValueOnce({ isLogin: false });
jest
.spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('fresh-qrcode');
const result = await (refreshService as any).startScan(
{
accountId: 'account-1',
expectedSelfId: '10001',
forceRelogin: true,
mode: 'refresh',
},
container,
);
expect(result.status).toBe('pending');
expect(result.qrcode).toBe('fresh-qrcode');
expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith(
container,
);
});
it('requires a qrcode different from the current one when refreshing qrcode', async () => {
(service as any).sessions.set('session-refresh-qrcode', {
containerId: 'container-3',