feat: 重建NapCat登录运行态持久化

This commit is contained in:
sunlei 2026-06-16 00:37:08 +08:00
parent 69705281fa
commit 7edbb97e70
14 changed files with 813 additions and 65 deletions

View File

@ -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),

View File

@ -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';

View File

@ -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,

View File

@ -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<string, QqbotLoginScanSession>();
private readonly sessionEventLogs = new Map<string, QqbotLoginScanEvent[]>();
private readonly sessionEventListeners = new Map<
private readonly fallbackLoginSessionStore =
new NapcatLoginStateStoreService();
private readonly sessionEventLogCache: Record<string, QqbotLoginScanEvent[]> =
{};
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;
}

View File

@ -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';

View File

@ -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,
];

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<string, unknown>;
@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);
}
}

View File

@ -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);
}
}

View File

@ -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<string, QqbotLoginScanSession>;
@Injectable()
export class NapcatLoginStateStoreService {
private readonly cache: NapcatLoginStoreCache = {};
private readonly pendingSessionWrites: Record<string, Promise<void> | undefined> =
{};
constructor(
@Optional()
@InjectRepository(NapcatLoginSession)
private readonly loginSessionRepository?: Repository<NapcatLoginSession>,
@Optional()
@InjectRepository(NapcatLoginChallengeEntity)
private readonly loginChallengeRepository?: Repository<NapcatLoginChallengeEntity>,
@Optional()
@InjectRepository(NapcatRuntimeCleanup)
private readonly runtimeCleanupRepository?: Repository<NapcatRuntimeCleanup>,
) {}
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<void> => !!write,
),
);
}
private enqueueSessionWrite(sessionId: string, writer: () => Promise<void>) {
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<string, unknown>;
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 '登录处理中';
}
}

View File

@ -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);
}
}

View File

@ -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(

View File

@ -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 = <T extends Record<string, any>>() => {
const rows: T[] = [];
return {
create: jest.fn((input: Partial<T>) => ({ ...input }) as T),
findOne: jest.fn(async ({ where }: { where: Record<string, any> }) => {
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<string, any>, input: Partial<T>) => {
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<NapcatLoginSession>();
const loginChallengeRepository =
createRepository<NapcatLoginChallengeEntity>();
const runtimeCleanupRepository = createRepository<NapcatRuntimeCleanup>();
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',