feat: 重建NapCat登录运行态持久化
This commit is contained in:
parent
69705281fa
commit
7edbb97e70
@ -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),
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 '登录处理中';
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user