fix(qqbot): 完善账号容器链路

This commit is contained in:
sunlei 2026-06-02 07:34:38 +08:00
parent aa4b46950b
commit c51535eed8
5 changed files with 176 additions and 49 deletions

View File

@ -28,7 +28,6 @@ QQBOT_EVENT_BUS=mqtt
QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse
QQBOT_REVERSE_WS_TOKEN=
QQBOT_AUTO_REGISTER_ACCOUNT=true
QQBOT_REQUIRE_ALLOWLIST=false
QQBOT_API_TIMEOUT_MS=10000
QQBOT_SEND_RATE_PER_SECOND=1
NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099
@ -40,6 +39,7 @@ QQBOT_NAPCAT_CONTAINER_MODE=
QQBOT_NAPCAT_SSH_TARGET=nas
QQBOT_NAPCAT_SSH_PORT=
QQBOT_NAPCAT_SSH_KEY_PATH=
QQBOT_NAPCAT_SSH_TIMEOUT_MS=120000
QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances
QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest
QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat

View File

@ -91,7 +91,7 @@ export class QqbotAccountController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '取消 QQBot 扫码登录会话' })
async cancelScan(@Query() query: QqbotAccountScanStatusDto) {
return vbenSuccess(this.napcatLoginService.cancel(query.sessionId));
return vbenSuccess(await this.napcatLoginService.cancel(query.sessionId));
}
@Post('delete')

View File

@ -184,8 +184,12 @@ export class QqbotAccountService {
}
async save(body: QqbotAccountBodyDto) {
await this.assertSelfIdAvailable(body.selfId);
const account = this.accountRepository.create(this.normalizeBody(body));
const payload = this.normalizeBody(body);
const restored = await this.restoreDeletedAccount(payload);
if (restored) return restored.id;
await this.assertSelfIdAvailable(payload.selfId || '');
const account = this.accountRepository.create(payload);
const saved = await this.accountRepository.save(account);
return saved.id;
}
@ -268,15 +272,42 @@ export class QqbotAccountService {
private async assertSelfIdAvailable(selfId: string, id?: string) {
const exists = await this.accountRepository.findOne({
where: {
isDeleted: false,
selfId,
},
});
if (exists && exists.id !== id) {
throwVbenError('QQBot 账号 selfId 已存在');
throwVbenError(
exists.isDeleted
? 'QQBot 账号 selfId 已存在于已删除账号,请通过新增恢复该账号'
: 'QQBot 账号 selfId 已存在',
);
}
}
private async restoreDeletedAccount(payload: Partial<QqbotAccount>) {
if (!payload.selfId) return null;
const existing = await this.accountRepository.findOne({
where: {
selfId: payload.selfId,
},
});
if (!existing || !existing.isDeleted) return null;
await this.accountRepository.update(
{ id: existing.id },
{
...payload,
clientRole: null,
connectStatus: 'offline',
isDeleted: false,
lastError: null,
lastHeartbeatAt: null,
},
);
return existing;
}
private normalizeBody(body: Partial<QqbotAccountBodyDto>) {
return {
accessToken: normalizeNullableString(body.accessToken),
@ -284,7 +315,8 @@ export class QqbotAccountService {
enabled: body.enabled ?? true,
name: body.name || '',
remark: body.remark || '',
selfId: body.selfId,
selfId:
typeof body.selfId === 'string' ? body.selfId.trim() : body.selfId,
} as Partial<QqbotAccount>;
}
}

View File

@ -124,9 +124,7 @@ export class QqbotNapcatLoginService {
async status(sessionId: string) {
const session = this.getSession(sessionId);
if (Date.now() > session.expiresAt) {
session.status = 'expired';
this.sessions.set(session.id, session);
return this.toResult(session);
return this.expireSession(session);
}
const container = await this.getSessionContainer(session);
@ -141,8 +139,12 @@ export class QqbotNapcatLoginService {
return this.completeLogin(session, container);
}
cancel(sessionId: string) {
this.sessions.delete(sessionId);
async cancel(sessionId: string) {
const session = this.sessions.get(sessionId);
if (session) {
this.sessions.delete(sessionId);
await this.cleanupSessionContainer(session);
}
return true;
}
@ -154,34 +156,44 @@ export class QqbotNapcatLoginService {
},
container: QqbotNapcatRuntime,
): Promise<QqbotLoginScanResult> {
this.cleanupSessions();
await this.cleanupSessions();
const loginStatus = await this.getLoginStatus(container, true);
if (loginStatus.isLogin) {
try {
const loginStatus = await this.getLoginStatus(container, true);
if (loginStatus.isLogin) {
const session = this.createSession({
...options,
container,
qrcode: loginStatus.qrcodeurl,
status: 'success',
});
return this.completeLogin(session, container);
}
let qrcode = this.isExpiredQrcodeStatus(loginStatus)
? ''
: loginStatus.qrcodeurl || '';
if (!qrcode) {
await this.callRefreshQrcode(container, true);
qrcode = await this.getQrcode(container, true);
}
const session = this.createSession({
...options,
container,
qrcode: loginStatus.qrcodeurl,
status: 'success',
qrcode,
status: 'pending',
});
return this.completeLogin(session, container);
this.sessions.set(session.id, session);
return this.toResult(session);
} catch (err) {
const cleanupError = await this.cleanupRuntimeContainer(container);
if (cleanupError) {
throwVbenError(
`${this.getErrorMessage(err)};清理未绑定容器失败:${cleanupError}`,
);
}
throw err;
}
let qrcode = this.isExpiredQrcodeStatus(loginStatus)
? ''
: loginStatus.qrcodeurl || '';
if (!qrcode) {
await this.callRefreshQrcode(container, true);
qrcode = await this.getQrcode(container, true);
}
const session = this.createSession({
...options,
container,
qrcode,
status: 'pending',
});
this.sessions.set(session.id, session);
return this.toResult(session);
}
private async completeLogin(
@ -191,16 +203,13 @@ export class QqbotNapcatLoginService {
const loginInfo = await this.getLoginInfo(container);
const selfId = this.getSelfId(loginInfo);
if (!selfId) {
session.status = 'error';
session.errorMessage = 'NapCat 已登录但未返回 QQ 号';
this.sessions.set(session.id, session);
return this.toResult(session);
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号');
}
if (session.expectedSelfId && session.expectedSelfId !== selfId) {
session.status = 'error';
session.errorMessage = `当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`;
this.sessions.set(session.id, session);
return this.toResult(session);
return this.failSession(
session,
`当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`,
);
}
const accountId = await this.accountService.ensureScannedAccount({
@ -271,13 +280,60 @@ export class QqbotNapcatLoginService {
return this.containerService.findRuntimeById(session.containerId);
}
private cleanupSessions() {
private async cleanupSessions() {
const now = Date.now();
const expiredSessions: QqbotLoginScanSession[] = [];
this.sessions.forEach((session, sessionId) => {
if (session.status !== 'pending' || now > session.expiresAt) {
this.sessions.delete(sessionId);
expiredSessions.push(session);
}
});
await Promise.all(
expiredSessions.map((session) => this.cleanupSessionContainer(session)),
);
}
private async expireSession(session: QqbotLoginScanSession) {
session.status = 'expired';
session.errorMessage = session.errorMessage || '扫码会话已过期';
this.sessions.delete(session.id);
await this.cleanupSessionContainer(session);
return this.toResult(session);
}
private async failSession(
session: QqbotLoginScanSession,
errorMessage: string,
) {
session.status = 'error';
session.errorMessage = errorMessage;
this.sessions.delete(session.id);
await this.cleanupSessionContainer(session);
return this.toResult(session);
}
private async cleanupSessionContainer(session: QqbotLoginScanSession) {
const cleanupError = await this.cleanupRuntimeContainer({
baseUrl: '',
id: session.containerId,
name: session.containerName || '',
webuiPort: session.webuiPort,
});
if (cleanupError) {
session.errorMessage = session.errorMessage
? `${session.errorMessage};清理未绑定容器失败:${cleanupError}`
: `清理未绑定容器失败:${cleanupError}`;
}
}
private async cleanupRuntimeContainer(container: QqbotNapcatRuntime) {
try {
await this.containerService.removeUnboundContainer(container.id);
return null;
} catch (err) {
return this.getErrorMessage(err);
}
}
private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) {

View File

@ -137,6 +137,20 @@ export class QqbotNapcatContainerService {
return { deletedContainers };
}
async removeUnboundContainer(containerId?: string) {
if (!containerId) return false;
const bindingCount = await this.bindingRepository.count({
where: {
containerId,
isDeleted: false,
},
});
if (bindingCount > 0) return false;
return this.removeContainer(containerId);
}
private async removeContainer(containerId: string) {
const container = await this.containerRepository.findOne({
where: {
@ -507,6 +521,13 @@ docker run -d \\
return `${this.configService.get<string>(key) || defaultValue}`.trim();
}
private getProcessTimeoutMs() {
const timeoutMs = Number(
this.getConfig('QQBOT_NAPCAT_SSH_TIMEOUT_MS', '120000'),
);
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000;
}
private sh(value: string) {
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
}
@ -516,21 +537,39 @@ docker run -d \\
const child = spawn(command, args, {
windowsHide: true,
});
let settled = false;
let stdout = '';
let stderr = '';
const timeoutMs = this.getProcessTimeoutMs();
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGTERM');
reject(new Error(`${command} timeout after ${timeoutMs}ms`));
}, timeoutMs);
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
callback();
};
child.stdout.on('data', (chunk) => {
stdout += Buffer.from(chunk).toString('utf8');
});
child.stderr.on('data', (chunk) => {
stderr += Buffer.from(chunk).toString('utf8');
});
child.on('error', reject);
child.on('error', (err) => {
finish(() => reject(err));
});
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error((stderr || stdout || `${command} failed`).trim()));
finish(() => {
if (code === 0) {
resolve();
return;
}
reject(new Error((stderr || stdout || `${command} failed`).trim()));
});
});
child.stdin.write(input);
child.stdin.end();