From 7edbb97e70412b4e5b56c787393279c00090f8db Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 16 Jun 2026 00:37:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=BB=BANapCat=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=BF=90=E8=A1=8C=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/refactor-v3/00-full-schema.sql | 5 +- sql/refactor-v3/99-verify.sql | 29 +++ src/modules/qqbot/core/qqbot-core.module.ts | 12 + .../login/qqbot-napcat-login.service.ts | 180 +++++++++----- src/modules/qqbot/napcat/index.ts | 6 + .../infrastructure/persistence/index.ts | 10 + .../napcat-account-binding.entity.ts | 38 +++ .../persistence/napcat-container.entity.ts | 38 +++ .../napcat-login-challenge.entity.ts | 56 +++++ .../napcat-login-session.entity.ts | 65 +++++ .../napcat-login-state-store.service.ts | 226 ++++++++++++++++++ .../napcat-runtime-cleanup.entity.ts | 39 +++ .../qqbot/napcat/device-identity.spec.ts | 14 ++ .../napcat-persistent-login-state.spec.ts | 160 ++++++++++++- 14 files changed, 813 insertions(+), 65 deletions(-) create mode 100644 src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts create mode 100644 src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts create mode 100644 src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-challenge.entity.ts create mode 100644 src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-session.entity.ts create mode 100644 src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts create mode 100644 src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-cleanup.entity.ts diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index e705d24..9ba53ea 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -951,11 +951,14 @@ CREATE TABLE IF NOT EXISTS napcat_account_binding ( CREATE TABLE IF NOT EXISTS napcat_login_session ( id BIGINT NOT NULL PRIMARY KEY, - account_id BIGINT NOT NULL, + account_id BIGINT NULL, session_key VARCHAR(128) NOT NULL, login_stage VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, progress_message VARCHAR(255) NOT NULL, + session_payload JSON NULL, + expires_at DATETIME NULL, + completed_at DATETIME NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_napcat_login_session_key (session_key), diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index 858a5b8..f75882e 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -5,7 +5,12 @@ SELECT 'platform_setting' AS table_name, COUNT(*) AS row_count FROM platform_set SELECT 'admin_dict' AS table_name, COUNT(*) AS row_count FROM admin_dict; SELECT 'qqbot_command' AS table_name, COUNT(*) AS row_count FROM qqbot_command; SELECT 'qqbot_plugin' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin; +SELECT 'napcat_container' AS table_name, COUNT(*) AS row_count FROM napcat_container; SELECT 'napcat_device_identity' AS table_name, COUNT(*) AS row_count FROM napcat_device_identity; +SELECT 'napcat_account_binding' AS table_name, COUNT(*) AS row_count FROM napcat_account_binding; +SELECT 'napcat_login_session' AS table_name, COUNT(*) AS row_count FROM napcat_login_session; +SELECT 'napcat_login_challenge' AS table_name, COUNT(*) AS row_count FROM napcat_login_challenge; +SELECT 'napcat_runtime_cleanup' AS table_name, COUNT(*) AS row_count FROM napcat_runtime_cleanup; SELECT 'seed_admin_user' AS check_name, COUNT(*) AS matched_rows FROM admin_user @@ -67,3 +72,27 @@ FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'napcat_device_identity' AND index_name = 'uk_napcat_device_identity_account'; + +SELECT 'index_napcat_account_binding_account' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_account_binding' + AND index_name = 'uk_napcat_account_binding_account'; + +SELECT 'index_napcat_login_session_key' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_login_session' + AND index_name = 'uk_napcat_login_session_key'; + +SELECT 'index_napcat_login_challenge_session' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_login_challenge' + AND index_name = 'idx_napcat_login_challenge_session'; + +SELECT 'index_napcat_runtime_cleanup_session' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_runtime_cleanup' + AND index_name = 'idx_napcat_runtime_cleanup_session'; diff --git a/src/modules/qqbot/core/qqbot-core.module.ts b/src/modules/qqbot/core/qqbot-core.module.ts index 897b0ae..d2f04c8 100644 --- a/src/modules/qqbot/core/qqbot-core.module.ts +++ b/src/modules/qqbot/core/qqbot-core.module.ts @@ -25,8 +25,14 @@ import { QqbotDashboardService } from '@/modules/qqbot/core/application/dashboar import { QqbotDedupe } from '@/modules/qqbot/core/infrastructure/persistence/dedupe/qqbot-dedupe.entity'; import { QqbotDedupeService } from '@/modules/qqbot/core/application/dedupe/qqbot-dedupe.service'; import { QqbotEventService } from '@/modules/qqbot/core/application/event/qqbot-event.service'; +import { NapcatAccountBinding } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity'; +import { NapcatContainer } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity'; import { NapcatDeviceIdentity } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-device-identity.entity'; import { NapcatDeviceIdentityService } from '@/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service'; +import { NapcatLoginChallengeEntity } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-login-challenge.entity'; +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'; +import { NapcatRuntimeCleanup } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-cleanup.entity'; import { QqbotConversation } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-conversation.entity'; import { QqbotMessageController } from '@/modules/qqbot/core/contract/message/qqbot-message.controller'; import { QqbotMessage } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-message.entity'; @@ -61,7 +67,12 @@ export const QQBOT_CORE_ENTITIES = [ QqbotConversation, QqbotDedupe, QqbotMessage, + NapcatAccountBinding, + NapcatContainer, NapcatDeviceIdentity, + NapcatLoginChallengeEntity, + NapcatLoginSession, + NapcatRuntimeCleanup, QqbotAccountNapcat, QqbotNapcatContainer, QqbotRule, @@ -90,6 +101,7 @@ export const QQBOT_CORE_PROVIDERS = [ QqbotEventService, QqbotMessageService, NapcatDeviceIdentityService, + NapcatLoginStateStoreService, QqbotNapcatLoginService, QqbotNapcatWatchdogService, QqbotNapcatContainerService, diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index 368d259..63124a8 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -1,7 +1,7 @@ import * as http from 'http'; import * as https from 'https'; import { createHash, randomUUID } from 'crypto'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Observable } from 'rxjs'; import { throwVbenError, ToolsService } from '@/common'; @@ -25,28 +25,59 @@ import type { QrcodeRefreshOptions, } from '@/modules/qqbot/core/contract/qqbot.types'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; +import { NapcatLoginStateStoreService } from '../../infrastructure/persistence/napcat-login-state-store.service'; import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service'; @Injectable() export class QqbotNapcatLoginService { - private readonly sessions = new Map(); - private readonly sessionEventLogs = new Map(); - private readonly sessionEventListeners = new Map< + private readonly fallbackLoginSessionStore = + new NapcatLoginStateStoreService(); + private readonly sessionEventLogCache: Record = + {}; + private readonly sessionEventListenerCache: Record< string, Set<(event: QqbotLoginScanEvent) => void> - >(); - private readonly credentials = new Map< + > = {}; + readonly sessions = { + clear: () => this.loginSessionStore.clear(), + get: (sessionId: string) => this.loginSessionStore.getCached(sessionId), + has: (sessionId: string) => this.loginSessionStore.has(sessionId), + set: (sessionId: string, session: QqbotLoginScanSession) => { + if (!session.id) session.id = sessionId; + this.loginSessionStore.set(session); + }, + }; + readonly sessionEventLogs = { + clear: () => + Object.keys(this.sessionEventLogCache).forEach((sessionId) => { + delete this.sessionEventLogCache[sessionId]; + }), + get: (sessionId: string) => this.sessionEventLogCache[sessionId], + }; + readonly sessionEventListeners = { + clear: () => + Object.keys(this.sessionEventListenerCache).forEach((sessionId) => { + delete this.sessionEventListenerCache[sessionId]; + }), + }; + private readonly credentials: Record< string, - { credential: string; expiresAt: number } - >(); + { credential: string; expiresAt: number } | undefined + > = {}; constructor( private readonly configService: ConfigService, private readonly accountService: QqbotAccountService, private readonly containerService: QqbotNapcatContainerService, private readonly toolsService: ToolsService, + @Optional() + private readonly loginStateStore?: NapcatLoginStateStoreService, ) {} + private get loginSessionStore() { + return this.loginStateStore || this.fallbackLoginSessionStore; + } + async startCreate() { const container = await this.containerService.prepareCreateContainer(); return this.startScan({ mode: 'create' }, container); @@ -76,7 +107,7 @@ export class QqbotNapcatLoginService { } async refreshQrcode(sessionId: string) { - const session = this.getSession(sessionId); + const session = await this.getSession(sessionId); if (session.status !== 'pending') { return this.toResult(session); } @@ -136,7 +167,7 @@ export class QqbotNapcatLoginService { }); session.expiresAt = Date.now() + this.getSessionTtlMs(); session.errorMessage = undefined; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'qrcode-ready', @@ -155,7 +186,7 @@ export class QqbotNapcatLoginService { } async status(sessionId: string) { - const session = this.getSession(sessionId); + const session = await this.getSession(sessionId); if (session.status !== 'pending') { return this.toResult(session); } @@ -234,7 +265,7 @@ export class QqbotNapcatLoginService { } else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) { await this.tryUpdatePendingQrcode(container, session, status); } - this.sessions.set(session.id, session); + this.persistLoginSession(session); return this.toResult(session); } @@ -242,7 +273,7 @@ export class QqbotNapcatLoginService { } async submitCaptcha(sessionId: string, input: QqbotLoginCaptchaSubmitInput) { - const session = this.getSession(sessionId); + const session = await this.getSession(sessionId); if (session.status !== 'pending') { return this.toResult(session); } @@ -305,29 +336,31 @@ export class QqbotNapcatLoginService { } events(sessionId: string) { - this.getSession(sessionId); + if (!this.loginSessionStore.getCached(sessionId)) { + void this.loginSessionStore.get(sessionId); + } return new Observable<{ data: QqbotLoginScanEvent }>((subscriber) => { const listener = (event: QqbotLoginScanEvent) => { subscriber.next({ data: event }); }; - (this.sessionEventLogs.get(sessionId) || []).forEach(listener); + (this.sessionEventLogCache[sessionId] || []).forEach(listener); const listeners = - this.sessionEventListeners.get(sessionId) || + this.sessionEventListenerCache[sessionId] || new Set<(event: QqbotLoginScanEvent) => void>(); listeners.add(listener); - this.sessionEventListeners.set(sessionId, listeners); + this.sessionEventListenerCache[sessionId] = listeners; return () => { listeners.delete(listener); if (listeners.size <= 0) { - this.sessionEventListeners.delete(sessionId); + delete this.sessionEventListenerCache[sessionId]; } }; }); } async cancel(sessionId: string) { - const session = this.sessions.get(sessionId); + const session = await this.loginSessionStore.get(sessionId); if (session) { await this.cleanupPasswordLoginContext(session); this.publishScanEvent(session, { @@ -336,7 +369,7 @@ export class QqbotNapcatLoginService { status: 'info', step: 'session-cancelled', }); - this.sessions.delete(sessionId); + this.loginSessionStore.delete(sessionId); await this.cleanupSessionContainer(session); this.cleanupSessionEvents(sessionId); } @@ -365,7 +398,7 @@ export class QqbotNapcatLoginService { }); session.lastRestartedAt = Date.now(); session.errorMessage = this.getReloginPreparingMessage(options); - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'session-created', @@ -394,7 +427,7 @@ export class QqbotNapcatLoginService { session.lastRestartedAt = Date.now(); session.errorMessage = loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码'; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'container-restarted', @@ -426,7 +459,7 @@ export class QqbotNapcatLoginService { qrcode, status: 'pending', }); - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'qrcode-ready', @@ -486,7 +519,7 @@ export class QqbotNapcatLoginService { session.errorMessage = undefined; session.passwordMd5 = undefined; session.preparingRelogin = false; - this.sessions.set(session.id, session); + this.persistLoginSession(session); const result = { ...this.toResult(session), accountId, @@ -562,7 +595,7 @@ export class QqbotNapcatLoginService { ); session.newDeviceStatus = 'qr-pending'; session.errorMessage = '需要新设备验证二维码'; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'new-device-required', @@ -580,7 +613,7 @@ export class QqbotNapcatLoginService { qrcode.pullQrCodeSig || session.newDevicePullQrCodeSig; session.newDeviceStatus = qrcode.status; session.errorMessage = '新设备二维码待扫码'; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'new-device-qrcode-ready', @@ -631,7 +664,7 @@ export class QqbotNapcatLoginService { session.newDeviceQrcode = undefined; session.newDeviceStatus = 'verified'; session.errorMessage = '新设备验证成功,继续登录'; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'new-device-verified', @@ -680,7 +713,7 @@ export class QqbotNapcatLoginService { session.newDeviceStatus = status; session.errorMessage = message; session.expiresAt = Date.now() + this.getSessionTtlMs(); - this.sessions.set(session.id, session); + this.persistLoginSession(session); if (shouldPublish) { this.publishScanResultEvent(session, step, 'processing', message); } @@ -695,7 +728,7 @@ export class QqbotNapcatLoginService { session.newDeviceQrcode = undefined; session.newDeviceStatus = 'failed'; session.errorMessage = message; - this.sessions.set(session.id, session); + this.persistLoginSession(session); return this.failCaptchaLogin(session, container, message); } @@ -767,12 +800,12 @@ export class QqbotNapcatLoginService { ...input, createdAt: Date.now(), }; - const logs = this.sessionEventLogs.get(session.id) || []; + const logs = this.sessionEventLogCache[session.id] || []; logs.push(event); - this.sessionEventLogs.set(session.id, logs.slice(-50)); - this.sessionEventListeners - .get(session.id) - ?.forEach((listener) => listener(event)); + this.sessionEventLogCache[session.id] = logs.slice(-50); + this.sessionEventListenerCache[session.id]?.forEach((listener) => + listener(event), + ); } private publishScanResultEvent( @@ -783,7 +816,7 @@ export class QqbotNapcatLoginService { ) { if (session.status === 'pending') { session.expiresAt = Date.now() + this.getSessionTtlMs(); - this.sessions.set(session.id, session); + this.persistLoginSession(session); } this.publishScanEvent(session, { message, @@ -794,8 +827,8 @@ export class QqbotNapcatLoginService { } private cleanupSessionEvents(sessionId: string) { - this.sessionEventLogs.delete(sessionId); - this.sessionEventListeners.delete(sessionId); + delete this.sessionEventLogCache[sessionId]; + delete this.sessionEventListenerCache[sessionId]; } private keepSessionPending( @@ -807,7 +840,7 @@ export class QqbotNapcatLoginService { session.errorMessage = errorMessage; session.expiresAt = Date.now() + this.getSessionTtlMs(); if (clearQrcode) session.qrcode = undefined; - this.sessions.set(session.id, session); + this.persistLoginSession(session); return this.toResult(session); } @@ -830,7 +863,7 @@ export class QqbotNapcatLoginService { session.qrcode = undefined; session.errorMessage = message; session.expiresAt = Date.now() + this.getSessionTtlMs(); - this.sessions.set(session.id, session); + this.persistLoginSession(session); if (shouldPublish) { this.publishScanResultEvent( session, @@ -859,7 +892,7 @@ export class QqbotNapcatLoginService { session.errorMessage = errorMessage; session.passwordMd5 = undefined; session.preparingRelogin = false; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanEvent(session, { message: errorMessage, result: this.toResult(session), @@ -899,7 +932,11 @@ export class QqbotNapcatLoginService { session.errorMessage = failureMessage; session.passwordMd5 = undefined; session.preparingRelogin = false; - this.sessions.set(session.id, session); + this.persistLoginSession(session); + this.persistRuntimeCleanup(session, { + errorMessage: failureMessage, + status: 'failed', + }); this.publishScanEvent(session, { message: failureMessage, result: this.toResult(session), @@ -911,12 +948,33 @@ export class QqbotNapcatLoginService { session.captchaUrl = undefined; session.passwordMd5 = undefined; - this.sessions.set(session.id, session); + this.persistLoginSession(session); + this.persistRuntimeCleanup(session, { status: 'success' }); return true; } - private getSession(sessionId: string) { - const session = this.sessions.get(sessionId); + private persistLoginSession(session: QqbotLoginScanSession) { + this.loginSessionStore.set(session); + this.persistLoginChallenge(session); + } + + private persistLoginChallenge(session: QqbotLoginScanSession) { + this.loginSessionStore.recordCaptchaChallenge(session); + this.loginSessionStore.recordNewDeviceChallenge(session); + } + + private persistRuntimeCleanup( + session: QqbotLoginScanSession, + input: { errorMessage?: string; status: 'failed' | 'pending' | 'success' }, + ) { + this.loginSessionStore.recordRuntimeCleanup(session, { + cleanupType: 'password-login-env', + ...input, + }); + } + + private async getSession(sessionId: string) { + const session = await this.loginSessionStore.get(sessionId); if (!session) { throwVbenError('扫码会话不存在或已过期'); } @@ -930,9 +988,9 @@ export class QqbotNapcatLoginService { private async cleanupSessions() { const now = Date.now(); const expiredSessions: QqbotLoginScanSession[] = []; - this.sessions.forEach((session, sessionId) => { + this.loginSessionStore.forEach((session, sessionId) => { if (session.status !== 'pending' || now > session.expiresAt) { - this.sessions.delete(sessionId); + this.loginSessionStore.delete(sessionId); expiredSessions.push(session); } }); @@ -952,7 +1010,7 @@ export class QqbotNapcatLoginService { 'error', session.errorMessage, ); - this.sessions.delete(session.id); + this.loginSessionStore.delete(session.id); await this.closeSession(session); return this.toResult(session); } @@ -967,7 +1025,7 @@ export class QqbotNapcatLoginService { session.passwordMd5 = undefined; session.preparingRelogin = false; this.publishScanResultEvent(session, 'login-error', 'error', errorMessage); - this.sessions.delete(session.id); + this.loginSessionStore.delete(session.id); await this.closeSession(session); return this.toResult(session); } @@ -975,7 +1033,7 @@ export class QqbotNapcatLoginService { private async closeSession(session: QqbotLoginScanSession) { await this.cleanupPasswordLoginContext(session); await this.cleanupSessionContainer(session); - this.sessions.delete(session.id); + this.loginSessionStore.delete(session.id); this.cleanupSessionEvents(session.id); } @@ -1311,10 +1369,10 @@ export class QqbotNapcatLoginService { ); } } finally { - const current = this.sessions.get(session.id); + const current = this.loginSessionStore.getCached(session.id); if (current === session && current.status === 'pending') { current.preparingRelogin = false; - this.sessions.set(current.id, current); + this.persistLoginSession(current); } } } @@ -1326,7 +1384,7 @@ export class QqbotNapcatLoginService { ) { let loginInfo: NapcatLoginInfo; session.errorMessage = 'NapCat 正在尝试快速登录,请稍后'; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'quick-login-start', @@ -1416,7 +1474,7 @@ export class QqbotNapcatLoginService { .update(password, 'utf8') .digest('hex'); session.errorMessage = 'NapCat 正在尝试密码登录,请稍后'; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'password-login-start', @@ -1574,7 +1632,7 @@ export class QqbotNapcatLoginService { session.captchaUrl = undefined; session.errorMessage = undefined; session.expiresAt = Date.now() + this.getSessionTtlMs(); - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'qrcode-ready', @@ -1768,7 +1826,7 @@ export class QqbotNapcatLoginService { session.errorMessage = reason ? `快速登录未完成:${reason},${nextStepMessage}` : `快速登录未完成,${nextStepMessage}`; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'quick-login-fallback', @@ -1784,7 +1842,7 @@ export class QqbotNapcatLoginService { session.errorMessage = reason ? `密码登录未完成:${reason},开始生成二维码` : '密码登录未完成,开始生成二维码'; - this.sessions.set(session.id, session); + this.persistLoginSession(session); this.publishScanResultEvent( session, 'password-login-fallback', @@ -1808,7 +1866,7 @@ export class QqbotNapcatLoginService { return; } - this.credentials.delete(this.getCredentialCacheKey(container)); + delete this.credentials[this.getCredentialCacheKey(container)]; if (options.waitForReady === false) return; onProgress?.('napcat-ready-wait', '等待 NapCat WebUI 启动'); @@ -1833,7 +1891,7 @@ export class QqbotNapcatLoginService { } } - this.credentials.delete(this.getCredentialCacheKey(container)); + delete this.credentials[this.getCredentialCacheKey(container)]; if (options.waitForReady === false) return; await this.toolsService.sleep(this.getRestartDelayMs()); @@ -1846,7 +1904,7 @@ export class QqbotNapcatLoginService { private async getCredential(container: QqbotNapcatRuntime) { const cacheKey = this.getCredentialCacheKey(container); - const cached = this.credentials.get(cacheKey); + const cached = this.credentials[cacheKey]; if (cached && Date.now() < cached.expiresAt) { return cached.credential; } @@ -1861,10 +1919,10 @@ export class QqbotNapcatLoginService { if (!data.Credential) { throwVbenError('NapCat WebUI 登录失败'); } - this.credentials.set(cacheKey, { + this.credentials[cacheKey] = { credential: data.Credential, expiresAt: Date.now() + 50 * 60 * 1000, - }); + }; return data.Credential; } diff --git a/src/modules/qqbot/napcat/index.ts b/src/modules/qqbot/napcat/index.ts index 7ed61e7..87375f5 100644 --- a/src/modules/qqbot/napcat/index.ts +++ b/src/modules/qqbot/napcat/index.ts @@ -1,4 +1,10 @@ +export * from './infrastructure/persistence/napcat-account-binding.entity'; +export * from './infrastructure/persistence/napcat-container.entity'; export * from './infrastructure/persistence/napcat-device-identity.entity'; +export * from './infrastructure/persistence/napcat-login-challenge.entity'; +export * from './infrastructure/persistence/napcat-login-session.entity'; +export * from './infrastructure/persistence/napcat-login-state-store.service'; +export * from './infrastructure/persistence/napcat-runtime-cleanup.entity'; export * from './infrastructure/integration/device/napcat-device-identity.service'; export * from './infrastructure/persistence/qqbot-account-napcat.entity'; export * from './infrastructure/persistence/qqbot-napcat-container.entity'; diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/index.ts b/src/modules/qqbot/napcat/infrastructure/persistence/index.ts index dc3f502..1818c91 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/index.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/index.ts @@ -1,4 +1,9 @@ +import { NapcatAccountBinding } from './napcat-account-binding.entity'; +import { NapcatContainer } from './napcat-container.entity'; import { NapcatDeviceIdentity } from './napcat-device-identity.entity'; +import { NapcatLoginChallengeEntity } from './napcat-login-challenge.entity'; +import { NapcatLoginSession } from './napcat-login-session.entity'; +import { NapcatRuntimeCleanup } from './napcat-runtime-cleanup.entity'; import { QqbotAccountNapcat } from './qqbot-account-napcat.entity'; import { QqbotNapcatContainer } from './qqbot-napcat-container.entity'; @@ -14,7 +19,12 @@ export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = { } as const; export const NAPCAT_RUNTIME_ENTITIES = [ + NapcatAccountBinding, + NapcatContainer, NapcatDeviceIdentity, + NapcatLoginChallengeEntity, + NapcatLoginSession, + NapcatRuntimeCleanup, QqbotAccountNapcat, QqbotNapcatContainer, ]; diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts new file mode 100644 index 0000000..2e6b0d9 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts @@ -0,0 +1,38 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtUpdateDateColumn, +} from '@/common'; +import type { QqbotAccountNapcatBindStatus } from '@/modules/qqbot/core/contract/qqbot.types'; + +@Entity('napcat_account_binding') +@Index('uk_napcat_account_binding_account', ['accountId'], { unique: true }) +export class NapcatAccountBinding { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ name: 'container_id', type: 'bigint' }) + containerId: string; + + @Column({ name: 'device_identity_id', type: 'bigint' }) + deviceIdentityId: string; + + @Column({ length: 32 }) + status: QqbotAccountNapcatBindStatus; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts new file mode 100644 index 0000000..a018387 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts @@ -0,0 +1,38 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtUpdateDateColumn, +} from '@/common'; +import type { QqbotNapcatContainerStatus } from '@/modules/qqbot/core/contract/qqbot.types'; + +@Entity('napcat_container') +@Index('uk_napcat_container_name', ['containerName'], { unique: true }) +export class NapcatContainer { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ length: 128, name: 'container_name' }) + containerName: string; + + @Column({ length: 255, name: 'image_name' }) + imageName: string; + + @Column({ length: 32 }) + status: QqbotNapcatContainerStatus; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-challenge.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-challenge.entity.ts new file mode 100644 index 0000000..e767ff3 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-challenge.entity.ts @@ -0,0 +1,56 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, + KtUpdateDateColumn, +} from '@/common'; + +export type NapcatLoginChallengeType = 'captcha' | 'new-device'; + +@Entity('napcat_login_challenge') +@Index('idx_napcat_login_challenge_session', ['sessionId']) +export class NapcatLoginChallengeEntity { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'session_id', type: 'bigint' }) + sessionId: string; + + @Column({ length: 64, name: 'challenge_type' }) + challengeType: NapcatLoginChallengeType; + + @Column({ length: 32 }) + status: string; + + @Column({ default: null, name: 'challenge_url', nullable: true, type: 'text' }) + challengeUrl: null | string; + + @Column({ + default: null, + name: 'challenge_payload', + nullable: true, + type: 'json', + }) + challengePayload: null | Record; + + @KtDateTimeColumn({ + default: null, + name: 'resolved_at', + nullable: true, + type: 'datetime', + }) + resolvedAt: KtDateTime | null; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-session.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-session.entity.ts new file mode 100644 index 0000000..ae8e1c1 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-session.entity.ts @@ -0,0 +1,65 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, + KtUpdateDateColumn, +} from '@/common'; +import type { + QqbotLoginScanSession, + QqbotLoginScanStatus, +} from '@/modules/qqbot/core/contract/qqbot.types'; + +@Entity('napcat_login_session') +@Index('uk_napcat_login_session_key', ['sessionKey'], { unique: true }) +@Index('idx_napcat_login_session_account', ['accountId']) +export class NapcatLoginSession { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ default: null, name: 'account_id', nullable: true, type: 'bigint' }) + accountId: null | string; + + @Column({ length: 128, name: 'session_key' }) + sessionKey: string; + + @Column({ length: 64, name: 'login_stage' }) + loginStage: string; + + @Column({ length: 32 }) + status: QqbotLoginScanStatus; + + @Column({ length: 255, name: 'progress_message' }) + progressMessage: string; + + @Column({ default: null, name: 'session_payload', nullable: true, type: 'json' }) + sessionPayload: null | QqbotLoginScanSession; + + @KtDateTimeColumn({ + default: null, + name: 'expires_at', + nullable: true, + type: 'datetime', + }) + expiresAt: KtDateTime | null; + + @KtDateTimeColumn({ + default: null, + name: 'completed_at', + nullable: true, + type: 'datetime', + }) + completedAt: KtDateTime | null; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts new file mode 100644 index 0000000..cb892da --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts @@ -0,0 +1,226 @@ +import { Injectable, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import type { QqbotLoginScanSession } from '@/modules/qqbot/core/contract/qqbot.types'; +import { + NapcatLoginChallengeEntity, + type NapcatLoginChallengeType, +} from './napcat-login-challenge.entity'; +import { NapcatLoginSession } from './napcat-login-session.entity'; +import { + NapcatRuntimeCleanup, + type NapcatRuntimeCleanupStatus, +} from './napcat-runtime-cleanup.entity'; + +type NapcatLoginStoreCache = Record; + +@Injectable() +export class NapcatLoginStateStoreService { + private readonly cache: NapcatLoginStoreCache = {}; + private readonly pendingSessionWrites: Record | undefined> = + {}; + + constructor( + @Optional() + @InjectRepository(NapcatLoginSession) + private readonly loginSessionRepository?: Repository, + @Optional() + @InjectRepository(NapcatLoginChallengeEntity) + private readonly loginChallengeRepository?: Repository, + @Optional() + @InjectRepository(NapcatRuntimeCleanup) + private readonly runtimeCleanupRepository?: Repository, + ) {} + + getCached(sessionId: string) { + return this.cache[sessionId]; + } + + has(sessionId: string) { + return !!this.cache[sessionId]; + } + + clear() { + Object.keys(this.cache).forEach((sessionId) => { + delete this.cache[sessionId]; + }); + } + + async get(sessionId: string) { + const cached = this.getCached(sessionId); + if (cached) return cached; + if (!this.loginSessionRepository) return undefined; + + const persisted = await this.loginSessionRepository.findOne({ + where: { sessionKey: sessionId }, + }); + const session = persisted?.sessionPayload; + if (session) this.cache[session.id] = session; + return session; + } + + set(session: QqbotLoginScanSession) { + this.cache[session.id] = session; + this.enqueueSessionWrite(session.id, () => this.persistSession(session)); + } + + delete(sessionId: string) { + delete this.cache[sessionId]; + this.enqueueSessionWrite(sessionId, () => this.markCompleted(sessionId)); + } + + forEach( + iterator: (session: QqbotLoginScanSession, sessionId: string) => void, + ) { + Object.entries(this.cache).forEach(([sessionId, session]) => + iterator(session, sessionId), + ); + } + + recordCaptchaChallenge(session: QqbotLoginScanSession) { + if (!session.captchaUrl) return; + void this.saveChallenge({ + challengePayload: { + expectedSelfId: session.expectedSelfId, + passwordMd5Present: !!session.passwordMd5, + }, + challengeType: 'captcha', + challengeUrl: session.captchaUrl, + session, + status: 'pending', + }); + } + + recordNewDeviceChallenge(session: QqbotLoginScanSession) { + if (!session.newDeviceStatus) return; + void this.saveChallenge({ + challengePayload: { + deviceVerifyUrl: session.deviceVerifyUrl, + newDevicePullQrCodeSig: session.newDevicePullQrCodeSig, + newDeviceQrcode: session.newDeviceQrcode, + }, + challengeType: 'new-device', + challengeUrl: session.deviceVerifyUrl || session.newDeviceQrcode || null, + session, + status: session.newDeviceStatus, + }); + } + + recordRuntimeCleanup( + session: QqbotLoginScanSession, + input: { + cleanupType: string; + errorMessage?: string; + status: NapcatRuntimeCleanupStatus; + }, + ) { + if (!this.runtimeCleanupRepository) return; + const cleanup = this.runtimeCleanupRepository.create({ + cleanupType: input.cleanupType, + errorMessage: input.errorMessage || null, + sessionId: session.id, + status: input.status, + }); + void this.runtimeCleanupRepository.save(cleanup); + } + + async flushSessionWrites(sessionId?: string) { + if (sessionId) { + await this.pendingSessionWrites[sessionId]; + return; + } + await Promise.all( + Object.values(this.pendingSessionWrites).filter( + (write): write is Promise => !!write, + ), + ); + } + + private enqueueSessionWrite(sessionId: string, writer: () => Promise) { + const previous = this.pendingSessionWrites[sessionId] || Promise.resolve(); + const queued = previous.catch(() => undefined).then(writer); + const tracked = queued.finally(() => { + if (this.pendingSessionWrites[sessionId] === tracked) { + delete this.pendingSessionWrites[sessionId]; + } + }); + this.pendingSessionWrites[sessionId] = tracked; + void tracked.catch(() => undefined); + } + + private async persistSession(session: QqbotLoginScanSession) { + if (!this.loginSessionRepository) return; + const current = await this.loginSessionRepository.findOne({ + where: { sessionKey: session.id }, + }); + const entity = this.loginSessionRepository.create({ + ...(current || {}), + accountId: session.accountId || null, + completedAt: + session.status === 'pending' + ? null + : (new Date() as NapcatLoginSession['completedAt']), + expiresAt: new Date(session.expiresAt) as NapcatLoginSession['expiresAt'], + loginStage: this.pickLoginStage(session), + progressMessage: session.errorMessage || this.pickProgressMessage(session), + sessionKey: session.id, + sessionPayload: session, + status: session.status, + }); + await this.loginSessionRepository.save(entity); + } + + private async markCompleted(sessionId: string) { + if (!this.loginSessionRepository) return; + await this.loginSessionRepository.update( + { sessionKey: sessionId }, + { + completedAt: new Date() as NapcatLoginSession['completedAt'], + }, + ); + } + + private async saveChallenge(input: { + challengePayload: null | Record; + challengeType: NapcatLoginChallengeType; + challengeUrl: null | string; + session: QqbotLoginScanSession; + status: string; + }) { + if (!this.loginChallengeRepository) return; + const entity = this.loginChallengeRepository.create({ + challengePayload: input.challengePayload, + challengeType: input.challengeType, + challengeUrl: input.challengeUrl, + resolvedAt: this.isResolvedChallenge(input.status) + ? (new Date() as NapcatLoginChallengeEntity['resolvedAt']) + : null, + sessionId: input.session.id, + status: input.status, + }); + await this.loginChallengeRepository.save(entity); + } + + private isResolvedChallenge(status: string) { + return ['failed', 'expired', 'verified'].includes(status); + } + + private pickLoginStage(session: QqbotLoginScanSession) { + if (session.newDeviceStatus) return 'new-device'; + if (session.captchaUrl) return 'captcha'; + if (session.passwordMd5) return 'password'; + if (session.preparingRelogin) return 'quick'; + if (session.qrcode) return 'manual-qr'; + return session.status; + } + + private pickProgressMessage(session: QqbotLoginScanSession) { + if (session.status === 'success') return '登录成功'; + if (session.status === 'error') return '登录失败'; + if (session.status === 'expired') return '扫码会话已过期'; + if (session.newDeviceStatus) return '需要新设备验证二维码'; + if (session.captchaUrl) return '需要验证码'; + if (session.qrcode) return '正在生成手动二维码'; + return '登录处理中'; + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-cleanup.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-cleanup.entity.ts new file mode 100644 index 0000000..33a981d --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-cleanup.entity.ts @@ -0,0 +1,39 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtUpdateDateColumn, +} from '@/common'; + +export type NapcatRuntimeCleanupStatus = 'failed' | 'pending' | 'success'; + +@Entity('napcat_runtime_cleanup') +@Index('idx_napcat_runtime_cleanup_session', ['sessionId']) +export class NapcatRuntimeCleanup { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'session_id', type: 'bigint' }) + sessionId: string; + + @Column({ length: 64, name: 'cleanup_type' }) + cleanupType: string; + + @Column({ length: 32 }) + status: NapcatRuntimeCleanupStatus; + + @Column({ default: null, name: 'error_message', nullable: true, type: 'text' }) + errorMessage: null | string; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/test/modules/qqbot/napcat/device-identity.spec.ts b/test/modules/qqbot/napcat/device-identity.spec.ts index 59b8ff5..5ee4425 100644 --- a/test/modules/qqbot/napcat/device-identity.spec.ts +++ b/test/modules/qqbot/napcat/device-identity.spec.ts @@ -1,6 +1,8 @@ import { getMetadataArgsStorage } from 'typeorm'; import { ToolsService } from '@/common'; import { + NapcatAccountBinding, + NapcatContainer, NapcatDeviceIdentity, NapcatDeviceIdentityService, NAPCAT_RUNTIME_DOMAIN_CONTRACT, @@ -97,6 +99,18 @@ describe('NapCat device identity persistence', () => { ); }); + it('maps target container and account binding entities to the v3 SQL schema', () => { + for (const entity of [NapcatContainer, NapcatAccountBinding]) { + expect(NAPCAT_RUNTIME_ENTITIES).toContain(entity); + + const tableName = getEntityTableName(entity); + const columns = getEntityColumnNames(entity); + + expect(tableName).toBeTruthy(); + schema.expectTableColumns(tableName || '', columns); + } + }); + it('reuses data dir, hostname, machine-id path, and MAC when rebuilding the same account container', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( diff --git a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts index ebf6282..6303b57 100644 --- a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +++ b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts @@ -1,11 +1,61 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import { getMetadataArgsStorage } from 'typeorm'; +import { + NapcatLoginChallengeEntity, + NapcatLoginSession, + NapcatLoginStateStoreService, + NapcatRuntimeCleanup, + NAPCAT_RUNTIME_ENTITIES, +} from '../../../../src/modules/qqbot/napcat'; +import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; const repoRoot = join(__dirname, '../../../..'); +const schema = readRefactorV3SqlSchema(); const readSource = (relativePath: string) => readFileSync(join(repoRoot, relativePath), 'utf8'); +type EntityClass = new (...args: never[]) => unknown; + +const getEntityTableName = (entity: EntityClass) => { + return getMetadataArgsStorage().tables.find( + (table) => table.target === entity, + )?.name; +}; + +const getEntityColumnNames = (entity: EntityClass) => { + return getMetadataArgsStorage() + .columns.filter((column) => column.target === entity) + .map((column) => `${column.options.name || column.propertyName}`); +}; + +const createRepository = >() => { + const rows: T[] = []; + return { + create: jest.fn((input: Partial) => ({ ...input }) as T), + findOne: jest.fn(async ({ where }: { where: Record }) => { + return ( + rows.find((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ) || null + ); + }), + rows, + save: jest.fn(async (input: T) => { + rows.push(input); + return input; + }), + update: jest.fn(async (where: Record, input: Partial) => { + 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 }; + }), + }; +}; + describe('NapCat persistent login state contract', () => { it('owns the approved third-phase persistence tables', () => { const persistenceRoot = join( @@ -45,15 +95,119 @@ describe('NapCat persistent login state contract', () => { loginSource.includes(signal), ); const missingPersistenceSignals = [ - 'loginSession', - 'loginChallenge', - 'runtimeCleanup', + 'loginSessionStore', + 'recordCaptchaChallenge', + 'recordNewDeviceChallenge', + 'recordRuntimeCleanup', ].filter((signal) => !loginSource.includes(signal)); expect(bannedMemorySignals).toEqual([]); expect(missingPersistenceSignals).toEqual([]); }); + it('maps login session, challenge, and cleanup entities to the v3 SQL schema', () => { + expect(NAPCAT_RUNTIME_ENTITIES).toEqual( + expect.arrayContaining([ + NapcatLoginSession, + NapcatLoginChallengeEntity, + NapcatRuntimeCleanup, + ]), + ); + + for (const entity of [ + NapcatLoginSession, + NapcatLoginChallengeEntity, + NapcatRuntimeCleanup, + ]) { + const tableName = getEntityTableName(entity); + const columns = getEntityColumnNames(entity); + expect(tableName).toBeTruthy(); + schema.expectTableColumns(tableName || '', columns); + } + }); + + it('keeps NapCat runtime tables covered by the v3 verification SQL', () => { + const verifySql = readSource('sql/refactor-v3/99-verify.sql'); + const requiredSignals = [ + 'napcat_container', + 'napcat_device_identity', + 'napcat_account_binding', + 'napcat_login_session', + 'napcat_login_challenge', + 'napcat_runtime_cleanup', + 'uk_napcat_login_session_key', + 'idx_napcat_login_challenge_session', + 'idx_napcat_runtime_cleanup_session', + ]; + + expect( + requiredSignals.filter((signal) => !verifySql.includes(signal)), + ).toEqual([]); + }); + + it('persists session snapshots, challenge state, and cleanup blockers', async () => { + const loginSessionRepository = createRepository(); + const loginChallengeRepository = + createRepository(); + const runtimeCleanupRepository = createRepository(); + const store = new NapcatLoginStateStoreService( + loginSessionRepository as any, + loginChallengeRepository as any, + runtimeCleanupRepository as any, + ); + + const session = { + captchaUrl: 'https://captcha.example/proof', + containerId: 'container-1', + containerName: 'kt-qqbot-napcat-10001', + createdAt: 1000, + errorMessage: '需要验证码', + expiresAt: Date.now() + 60_000, + expectedSelfId: '10001', + id: 'scan-session-1', + mode: 'refresh', + passwordMd5: 'md5', + status: 'pending', + webuiPort: 6099, + } as const; + + store.set(session); + store.recordCaptchaChallenge(session); + store.recordRuntimeCleanup(session, { + cleanupType: 'password-login-env', + errorMessage: '运行态清理失败', + status: 'failed', + }); + + await store.flushSessionWrites('scan-session-1'); + await Promise.resolve(); + + expect(loginSessionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + loginStage: 'captcha', + sessionKey: 'scan-session-1', + sessionPayload: session, + status: 'pending', + }), + ); + expect(loginChallengeRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + challengeType: 'captcha', + challengeUrl: 'https://captcha.example/proof', + sessionId: 'scan-session-1', + status: 'pending', + }), + ); + expect(runtimeCleanupRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + cleanupType: 'password-login-env', + errorMessage: '运行态清理失败', + sessionId: 'scan-session-1', + status: 'failed', + }), + ); + }); + it('keeps captcha and new-device challenges recoverable and separate', () => { const stateMachine = readSource( 'src/modules/qqbot/napcat/domain/login/napcat-login-state-machine.ts',