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 (
|
CREATE TABLE IF NOT EXISTS napcat_login_session (
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
account_id BIGINT NOT NULL,
|
account_id BIGINT NULL,
|
||||||
session_key VARCHAR(128) NOT NULL,
|
session_key VARCHAR(128) NOT NULL,
|
||||||
login_stage VARCHAR(64) NOT NULL,
|
login_stage VARCHAR(64) NOT NULL,
|
||||||
status VARCHAR(32) NOT NULL,
|
status VARCHAR(32) NOT NULL,
|
||||||
progress_message VARCHAR(255) 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,
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY uk_napcat_login_session_key (session_key),
|
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 '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_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 '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_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
|
SELECT 'seed_admin_user' AS check_name, COUNT(*) AS matched_rows
|
||||||
FROM admin_user
|
FROM admin_user
|
||||||
@ -67,3 +72,27 @@ FROM information_schema.statistics
|
|||||||
WHERE table_schema = DATABASE()
|
WHERE table_schema = DATABASE()
|
||||||
AND table_name = 'napcat_device_identity'
|
AND table_name = 'napcat_device_identity'
|
||||||
AND index_name = 'uk_napcat_device_identity_account';
|
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 { QqbotDedupe } from '@/modules/qqbot/core/infrastructure/persistence/dedupe/qqbot-dedupe.entity';
|
||||||
import { QqbotDedupeService } from '@/modules/qqbot/core/application/dedupe/qqbot-dedupe.service';
|
import { QqbotDedupeService } from '@/modules/qqbot/core/application/dedupe/qqbot-dedupe.service';
|
||||||
import { QqbotEventService } from '@/modules/qqbot/core/application/event/qqbot-event.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 { 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 { 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 { QqbotConversation } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-conversation.entity';
|
||||||
import { QqbotMessageController } from '@/modules/qqbot/core/contract/message/qqbot-message.controller';
|
import { QqbotMessageController } from '@/modules/qqbot/core/contract/message/qqbot-message.controller';
|
||||||
import { QqbotMessage } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-message.entity';
|
import { QqbotMessage } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-message.entity';
|
||||||
@ -61,7 +67,12 @@ export const QQBOT_CORE_ENTITIES = [
|
|||||||
QqbotConversation,
|
QqbotConversation,
|
||||||
QqbotDedupe,
|
QqbotDedupe,
|
||||||
QqbotMessage,
|
QqbotMessage,
|
||||||
|
NapcatAccountBinding,
|
||||||
|
NapcatContainer,
|
||||||
NapcatDeviceIdentity,
|
NapcatDeviceIdentity,
|
||||||
|
NapcatLoginChallengeEntity,
|
||||||
|
NapcatLoginSession,
|
||||||
|
NapcatRuntimeCleanup,
|
||||||
QqbotAccountNapcat,
|
QqbotAccountNapcat,
|
||||||
QqbotNapcatContainer,
|
QqbotNapcatContainer,
|
||||||
QqbotRule,
|
QqbotRule,
|
||||||
@ -90,6 +101,7 @@ export const QQBOT_CORE_PROVIDERS = [
|
|||||||
QqbotEventService,
|
QqbotEventService,
|
||||||
QqbotMessageService,
|
QqbotMessageService,
|
||||||
NapcatDeviceIdentityService,
|
NapcatDeviceIdentityService,
|
||||||
|
NapcatLoginStateStoreService,
|
||||||
QqbotNapcatLoginService,
|
QqbotNapcatLoginService,
|
||||||
QqbotNapcatWatchdogService,
|
QqbotNapcatWatchdogService,
|
||||||
QqbotNapcatContainerService,
|
QqbotNapcatContainerService,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { throwVbenError, ToolsService } from '@/common';
|
import { throwVbenError, ToolsService } from '@/common';
|
||||||
@ -25,28 +25,59 @@ import type {
|
|||||||
QrcodeRefreshOptions,
|
QrcodeRefreshOptions,
|
||||||
} from '@/modules/qqbot/core/contract/qqbot.types';
|
} from '@/modules/qqbot/core/contract/qqbot.types';
|
||||||
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
|
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';
|
import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotNapcatLoginService {
|
export class QqbotNapcatLoginService {
|
||||||
private readonly sessions = new Map<string, QqbotLoginScanSession>();
|
private readonly fallbackLoginSessionStore =
|
||||||
private readonly sessionEventLogs = new Map<string, QqbotLoginScanEvent[]>();
|
new NapcatLoginStateStoreService();
|
||||||
private readonly sessionEventListeners = new Map<
|
private readonly sessionEventLogCache: Record<string, QqbotLoginScanEvent[]> =
|
||||||
|
{};
|
||||||
|
private readonly sessionEventListenerCache: Record<
|
||||||
string,
|
string,
|
||||||
Set<(event: QqbotLoginScanEvent) => void>
|
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,
|
string,
|
||||||
{ credential: string; expiresAt: number }
|
{ credential: string; expiresAt: number } | undefined
|
||||||
>();
|
> = {};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly accountService: QqbotAccountService,
|
private readonly accountService: QqbotAccountService,
|
||||||
private readonly containerService: QqbotNapcatContainerService,
|
private readonly containerService: QqbotNapcatContainerService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
|
@Optional()
|
||||||
|
private readonly loginStateStore?: NapcatLoginStateStoreService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private get loginSessionStore() {
|
||||||
|
return this.loginStateStore || this.fallbackLoginSessionStore;
|
||||||
|
}
|
||||||
|
|
||||||
async startCreate() {
|
async startCreate() {
|
||||||
const container = await this.containerService.prepareCreateContainer();
|
const container = await this.containerService.prepareCreateContainer();
|
||||||
return this.startScan({ mode: 'create' }, container);
|
return this.startScan({ mode: 'create' }, container);
|
||||||
@ -76,7 +107,7 @@ export class QqbotNapcatLoginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshQrcode(sessionId: string) {
|
async refreshQrcode(sessionId: string) {
|
||||||
const session = this.getSession(sessionId);
|
const session = await this.getSession(sessionId);
|
||||||
if (session.status !== 'pending') {
|
if (session.status !== 'pending') {
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
@ -136,7 +167,7 @@ export class QqbotNapcatLoginService {
|
|||||||
});
|
});
|
||||||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
session.errorMessage = undefined;
|
session.errorMessage = undefined;
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'qrcode-ready',
|
'qrcode-ready',
|
||||||
@ -155,7 +186,7 @@ export class QqbotNapcatLoginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async status(sessionId: string) {
|
async status(sessionId: string) {
|
||||||
const session = this.getSession(sessionId);
|
const session = await this.getSession(sessionId);
|
||||||
if (session.status !== 'pending') {
|
if (session.status !== 'pending') {
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
@ -234,7 +265,7 @@ export class QqbotNapcatLoginService {
|
|||||||
} else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) {
|
} else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) {
|
||||||
await this.tryUpdatePendingQrcode(container, session, status);
|
await this.tryUpdatePendingQrcode(container, session, status);
|
||||||
}
|
}
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,7 +273,7 @@ export class QqbotNapcatLoginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async submitCaptcha(sessionId: string, input: QqbotLoginCaptchaSubmitInput) {
|
async submitCaptcha(sessionId: string, input: QqbotLoginCaptchaSubmitInput) {
|
||||||
const session = this.getSession(sessionId);
|
const session = await this.getSession(sessionId);
|
||||||
if (session.status !== 'pending') {
|
if (session.status !== 'pending') {
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
@ -305,29 +336,31 @@ export class QqbotNapcatLoginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
events(sessionId: string) {
|
events(sessionId: string) {
|
||||||
this.getSession(sessionId);
|
if (!this.loginSessionStore.getCached(sessionId)) {
|
||||||
|
void this.loginSessionStore.get(sessionId);
|
||||||
|
}
|
||||||
return new Observable<{ data: QqbotLoginScanEvent }>((subscriber) => {
|
return new Observable<{ data: QqbotLoginScanEvent }>((subscriber) => {
|
||||||
const listener = (event: QqbotLoginScanEvent) => {
|
const listener = (event: QqbotLoginScanEvent) => {
|
||||||
subscriber.next({ data: event });
|
subscriber.next({ data: event });
|
||||||
};
|
};
|
||||||
(this.sessionEventLogs.get(sessionId) || []).forEach(listener);
|
(this.sessionEventLogCache[sessionId] || []).forEach(listener);
|
||||||
const listeners =
|
const listeners =
|
||||||
this.sessionEventListeners.get(sessionId) ||
|
this.sessionEventListenerCache[sessionId] ||
|
||||||
new Set<(event: QqbotLoginScanEvent) => void>();
|
new Set<(event: QqbotLoginScanEvent) => void>();
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
this.sessionEventListeners.set(sessionId, listeners);
|
this.sessionEventListenerCache[sessionId] = listeners;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
if (listeners.size <= 0) {
|
if (listeners.size <= 0) {
|
||||||
this.sessionEventListeners.delete(sessionId);
|
delete this.sessionEventListenerCache[sessionId];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(sessionId: string) {
|
async cancel(sessionId: string) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = await this.loginSessionStore.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
await this.cleanupPasswordLoginContext(session);
|
await this.cleanupPasswordLoginContext(session);
|
||||||
this.publishScanEvent(session, {
|
this.publishScanEvent(session, {
|
||||||
@ -336,7 +369,7 @@ export class QqbotNapcatLoginService {
|
|||||||
status: 'info',
|
status: 'info',
|
||||||
step: 'session-cancelled',
|
step: 'session-cancelled',
|
||||||
});
|
});
|
||||||
this.sessions.delete(sessionId);
|
this.loginSessionStore.delete(sessionId);
|
||||||
await this.cleanupSessionContainer(session);
|
await this.cleanupSessionContainer(session);
|
||||||
this.cleanupSessionEvents(sessionId);
|
this.cleanupSessionEvents(sessionId);
|
||||||
}
|
}
|
||||||
@ -365,7 +398,7 @@ export class QqbotNapcatLoginService {
|
|||||||
});
|
});
|
||||||
session.lastRestartedAt = Date.now();
|
session.lastRestartedAt = Date.now();
|
||||||
session.errorMessage = this.getReloginPreparingMessage(options);
|
session.errorMessage = this.getReloginPreparingMessage(options);
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'session-created',
|
'session-created',
|
||||||
@ -394,7 +427,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.lastRestartedAt = Date.now();
|
session.lastRestartedAt = Date.now();
|
||||||
session.errorMessage =
|
session.errorMessage =
|
||||||
loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码';
|
loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码';
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'container-restarted',
|
'container-restarted',
|
||||||
@ -426,7 +459,7 @@ export class QqbotNapcatLoginService {
|
|||||||
qrcode,
|
qrcode,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
});
|
});
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'qrcode-ready',
|
'qrcode-ready',
|
||||||
@ -486,7 +519,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.errorMessage = undefined;
|
session.errorMessage = undefined;
|
||||||
session.passwordMd5 = undefined;
|
session.passwordMd5 = undefined;
|
||||||
session.preparingRelogin = false;
|
session.preparingRelogin = false;
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
const result = {
|
const result = {
|
||||||
...this.toResult(session),
|
...this.toResult(session),
|
||||||
accountId,
|
accountId,
|
||||||
@ -562,7 +595,7 @@ export class QqbotNapcatLoginService {
|
|||||||
);
|
);
|
||||||
session.newDeviceStatus = 'qr-pending';
|
session.newDeviceStatus = 'qr-pending';
|
||||||
session.errorMessage = '需要新设备验证二维码';
|
session.errorMessage = '需要新设备验证二维码';
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'new-device-required',
|
'new-device-required',
|
||||||
@ -580,7 +613,7 @@ export class QqbotNapcatLoginService {
|
|||||||
qrcode.pullQrCodeSig || session.newDevicePullQrCodeSig;
|
qrcode.pullQrCodeSig || session.newDevicePullQrCodeSig;
|
||||||
session.newDeviceStatus = qrcode.status;
|
session.newDeviceStatus = qrcode.status;
|
||||||
session.errorMessage = '新设备二维码待扫码';
|
session.errorMessage = '新设备二维码待扫码';
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'new-device-qrcode-ready',
|
'new-device-qrcode-ready',
|
||||||
@ -631,7 +664,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.newDeviceQrcode = undefined;
|
session.newDeviceQrcode = undefined;
|
||||||
session.newDeviceStatus = 'verified';
|
session.newDeviceStatus = 'verified';
|
||||||
session.errorMessage = '新设备验证成功,继续登录';
|
session.errorMessage = '新设备验证成功,继续登录';
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'new-device-verified',
|
'new-device-verified',
|
||||||
@ -680,7 +713,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.newDeviceStatus = status;
|
session.newDeviceStatus = status;
|
||||||
session.errorMessage = message;
|
session.errorMessage = message;
|
||||||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
if (shouldPublish) {
|
if (shouldPublish) {
|
||||||
this.publishScanResultEvent(session, step, 'processing', message);
|
this.publishScanResultEvent(session, step, 'processing', message);
|
||||||
}
|
}
|
||||||
@ -695,7 +728,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.newDeviceQrcode = undefined;
|
session.newDeviceQrcode = undefined;
|
||||||
session.newDeviceStatus = 'failed';
|
session.newDeviceStatus = 'failed';
|
||||||
session.errorMessage = message;
|
session.errorMessage = message;
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
return this.failCaptchaLogin(session, container, message);
|
return this.failCaptchaLogin(session, container, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -767,12 +800,12 @@ export class QqbotNapcatLoginService {
|
|||||||
...input,
|
...input,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
const logs = this.sessionEventLogs.get(session.id) || [];
|
const logs = this.sessionEventLogCache[session.id] || [];
|
||||||
logs.push(event);
|
logs.push(event);
|
||||||
this.sessionEventLogs.set(session.id, logs.slice(-50));
|
this.sessionEventLogCache[session.id] = logs.slice(-50);
|
||||||
this.sessionEventListeners
|
this.sessionEventListenerCache[session.id]?.forEach((listener) =>
|
||||||
.get(session.id)
|
listener(event),
|
||||||
?.forEach((listener) => listener(event));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishScanResultEvent(
|
private publishScanResultEvent(
|
||||||
@ -783,7 +816,7 @@ export class QqbotNapcatLoginService {
|
|||||||
) {
|
) {
|
||||||
if (session.status === 'pending') {
|
if (session.status === 'pending') {
|
||||||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
}
|
}
|
||||||
this.publishScanEvent(session, {
|
this.publishScanEvent(session, {
|
||||||
message,
|
message,
|
||||||
@ -794,8 +827,8 @@ export class QqbotNapcatLoginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private cleanupSessionEvents(sessionId: string) {
|
private cleanupSessionEvents(sessionId: string) {
|
||||||
this.sessionEventLogs.delete(sessionId);
|
delete this.sessionEventLogCache[sessionId];
|
||||||
this.sessionEventListeners.delete(sessionId);
|
delete this.sessionEventListenerCache[sessionId];
|
||||||
}
|
}
|
||||||
|
|
||||||
private keepSessionPending(
|
private keepSessionPending(
|
||||||
@ -807,7 +840,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.errorMessage = errorMessage;
|
session.errorMessage = errorMessage;
|
||||||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
if (clearQrcode) session.qrcode = undefined;
|
if (clearQrcode) session.qrcode = undefined;
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -830,7 +863,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.qrcode = undefined;
|
session.qrcode = undefined;
|
||||||
session.errorMessage = message;
|
session.errorMessage = message;
|
||||||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
if (shouldPublish) {
|
if (shouldPublish) {
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
@ -859,7 +892,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.errorMessage = errorMessage;
|
session.errorMessage = errorMessage;
|
||||||
session.passwordMd5 = undefined;
|
session.passwordMd5 = undefined;
|
||||||
session.preparingRelogin = false;
|
session.preparingRelogin = false;
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanEvent(session, {
|
this.publishScanEvent(session, {
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
result: this.toResult(session),
|
result: this.toResult(session),
|
||||||
@ -899,7 +932,11 @@ export class QqbotNapcatLoginService {
|
|||||||
session.errorMessage = failureMessage;
|
session.errorMessage = failureMessage;
|
||||||
session.passwordMd5 = undefined;
|
session.passwordMd5 = undefined;
|
||||||
session.preparingRelogin = false;
|
session.preparingRelogin = false;
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
|
this.persistRuntimeCleanup(session, {
|
||||||
|
errorMessage: failureMessage,
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
this.publishScanEvent(session, {
|
this.publishScanEvent(session, {
|
||||||
message: failureMessage,
|
message: failureMessage,
|
||||||
result: this.toResult(session),
|
result: this.toResult(session),
|
||||||
@ -911,12 +948,33 @@ export class QqbotNapcatLoginService {
|
|||||||
|
|
||||||
session.captchaUrl = undefined;
|
session.captchaUrl = undefined;
|
||||||
session.passwordMd5 = undefined;
|
session.passwordMd5 = undefined;
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
|
this.persistRuntimeCleanup(session, { status: 'success' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSession(sessionId: string) {
|
private persistLoginSession(session: QqbotLoginScanSession) {
|
||||||
const session = this.sessions.get(sessionId);
|
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) {
|
if (!session) {
|
||||||
throwVbenError('扫码会话不存在或已过期');
|
throwVbenError('扫码会话不存在或已过期');
|
||||||
}
|
}
|
||||||
@ -930,9 +988,9 @@ export class QqbotNapcatLoginService {
|
|||||||
private async cleanupSessions() {
|
private async cleanupSessions() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const expiredSessions: QqbotLoginScanSession[] = [];
|
const expiredSessions: QqbotLoginScanSession[] = [];
|
||||||
this.sessions.forEach((session, sessionId) => {
|
this.loginSessionStore.forEach((session, sessionId) => {
|
||||||
if (session.status !== 'pending' || now > session.expiresAt) {
|
if (session.status !== 'pending' || now > session.expiresAt) {
|
||||||
this.sessions.delete(sessionId);
|
this.loginSessionStore.delete(sessionId);
|
||||||
expiredSessions.push(session);
|
expiredSessions.push(session);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -952,7 +1010,7 @@ export class QqbotNapcatLoginService {
|
|||||||
'error',
|
'error',
|
||||||
session.errorMessage,
|
session.errorMessage,
|
||||||
);
|
);
|
||||||
this.sessions.delete(session.id);
|
this.loginSessionStore.delete(session.id);
|
||||||
await this.closeSession(session);
|
await this.closeSession(session);
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
@ -967,7 +1025,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.passwordMd5 = undefined;
|
session.passwordMd5 = undefined;
|
||||||
session.preparingRelogin = false;
|
session.preparingRelogin = false;
|
||||||
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
|
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
|
||||||
this.sessions.delete(session.id);
|
this.loginSessionStore.delete(session.id);
|
||||||
await this.closeSession(session);
|
await this.closeSession(session);
|
||||||
return this.toResult(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
@ -975,7 +1033,7 @@ export class QqbotNapcatLoginService {
|
|||||||
private async closeSession(session: QqbotLoginScanSession) {
|
private async closeSession(session: QqbotLoginScanSession) {
|
||||||
await this.cleanupPasswordLoginContext(session);
|
await this.cleanupPasswordLoginContext(session);
|
||||||
await this.cleanupSessionContainer(session);
|
await this.cleanupSessionContainer(session);
|
||||||
this.sessions.delete(session.id);
|
this.loginSessionStore.delete(session.id);
|
||||||
this.cleanupSessionEvents(session.id);
|
this.cleanupSessionEvents(session.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1311,10 +1369,10 @@ export class QqbotNapcatLoginService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
const current = this.sessions.get(session.id);
|
const current = this.loginSessionStore.getCached(session.id);
|
||||||
if (current === session && current.status === 'pending') {
|
if (current === session && current.status === 'pending') {
|
||||||
current.preparingRelogin = false;
|
current.preparingRelogin = false;
|
||||||
this.sessions.set(current.id, current);
|
this.persistLoginSession(current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1326,7 +1384,7 @@ export class QqbotNapcatLoginService {
|
|||||||
) {
|
) {
|
||||||
let loginInfo: NapcatLoginInfo;
|
let loginInfo: NapcatLoginInfo;
|
||||||
session.errorMessage = 'NapCat 正在尝试快速登录,请稍后';
|
session.errorMessage = 'NapCat 正在尝试快速登录,请稍后';
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'quick-login-start',
|
'quick-login-start',
|
||||||
@ -1416,7 +1474,7 @@ export class QqbotNapcatLoginService {
|
|||||||
.update(password, 'utf8')
|
.update(password, 'utf8')
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
session.errorMessage = 'NapCat 正在尝试密码登录,请稍后';
|
session.errorMessage = 'NapCat 正在尝试密码登录,请稍后';
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'password-login-start',
|
'password-login-start',
|
||||||
@ -1574,7 +1632,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.captchaUrl = undefined;
|
session.captchaUrl = undefined;
|
||||||
session.errorMessage = undefined;
|
session.errorMessage = undefined;
|
||||||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'qrcode-ready',
|
'qrcode-ready',
|
||||||
@ -1768,7 +1826,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.errorMessage = reason
|
session.errorMessage = reason
|
||||||
? `快速登录未完成:${reason},${nextStepMessage}`
|
? `快速登录未完成:${reason},${nextStepMessage}`
|
||||||
: `快速登录未完成,${nextStepMessage}`;
|
: `快速登录未完成,${nextStepMessage}`;
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'quick-login-fallback',
|
'quick-login-fallback',
|
||||||
@ -1784,7 +1842,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.errorMessage = reason
|
session.errorMessage = reason
|
||||||
? `密码登录未完成:${reason},开始生成二维码`
|
? `密码登录未完成:${reason},开始生成二维码`
|
||||||
: '密码登录未完成,开始生成二维码';
|
: '密码登录未完成,开始生成二维码';
|
||||||
this.sessions.set(session.id, session);
|
this.persistLoginSession(session);
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'password-login-fallback',
|
'password-login-fallback',
|
||||||
@ -1808,7 +1866,7 @@ export class QqbotNapcatLoginService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.credentials.delete(this.getCredentialCacheKey(container));
|
delete this.credentials[this.getCredentialCacheKey(container)];
|
||||||
if (options.waitForReady === false) return;
|
if (options.waitForReady === false) return;
|
||||||
|
|
||||||
onProgress?.('napcat-ready-wait', '等待 NapCat WebUI 启动');
|
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;
|
if (options.waitForReady === false) return;
|
||||||
|
|
||||||
await this.toolsService.sleep(this.getRestartDelayMs());
|
await this.toolsService.sleep(this.getRestartDelayMs());
|
||||||
@ -1846,7 +1904,7 @@ export class QqbotNapcatLoginService {
|
|||||||
|
|
||||||
private async getCredential(container: QqbotNapcatRuntime) {
|
private async getCredential(container: QqbotNapcatRuntime) {
|
||||||
const cacheKey = this.getCredentialCacheKey(container);
|
const cacheKey = this.getCredentialCacheKey(container);
|
||||||
const cached = this.credentials.get(cacheKey);
|
const cached = this.credentials[cacheKey];
|
||||||
if (cached && Date.now() < cached.expiresAt) {
|
if (cached && Date.now() < cached.expiresAt) {
|
||||||
return cached.credential;
|
return cached.credential;
|
||||||
}
|
}
|
||||||
@ -1861,10 +1919,10 @@ export class QqbotNapcatLoginService {
|
|||||||
if (!data.Credential) {
|
if (!data.Credential) {
|
||||||
throwVbenError('NapCat WebUI 登录失败');
|
throwVbenError('NapCat WebUI 登录失败');
|
||||||
}
|
}
|
||||||
this.credentials.set(cacheKey, {
|
this.credentials[cacheKey] = {
|
||||||
credential: data.Credential,
|
credential: data.Credential,
|
||||||
expiresAt: Date.now() + 50 * 60 * 1000,
|
expiresAt: Date.now() + 50 * 60 * 1000,
|
||||||
});
|
};
|
||||||
return data.Credential;
|
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-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/integration/device/napcat-device-identity.service';
|
||||||
export * from './infrastructure/persistence/qqbot-account-napcat.entity';
|
export * from './infrastructure/persistence/qqbot-account-napcat.entity';
|
||||||
export * from './infrastructure/persistence/qqbot-napcat-container.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 { 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 { QqbotAccountNapcat } from './qqbot-account-napcat.entity';
|
||||||
import { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
|
import { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
|
||||||
|
|
||||||
@ -14,7 +19,12 @@ export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const NAPCAT_RUNTIME_ENTITIES = [
|
export const NAPCAT_RUNTIME_ENTITIES = [
|
||||||
|
NapcatAccountBinding,
|
||||||
|
NapcatContainer,
|
||||||
NapcatDeviceIdentity,
|
NapcatDeviceIdentity,
|
||||||
|
NapcatLoginChallengeEntity,
|
||||||
|
NapcatLoginSession,
|
||||||
|
NapcatRuntimeCleanup,
|
||||||
QqbotAccountNapcat,
|
QqbotAccountNapcat,
|
||||||
QqbotNapcatContainer,
|
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 { getMetadataArgsStorage } from 'typeorm';
|
||||||
import { ToolsService } from '@/common';
|
import { ToolsService } from '@/common';
|
||||||
import {
|
import {
|
||||||
|
NapcatAccountBinding,
|
||||||
|
NapcatContainer,
|
||||||
NapcatDeviceIdentity,
|
NapcatDeviceIdentity,
|
||||||
NapcatDeviceIdentityService,
|
NapcatDeviceIdentityService,
|
||||||
NAPCAT_RUNTIME_DOMAIN_CONTRACT,
|
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 () => {
|
it('reuses data dir, hostname, machine-id path, and MAC when rebuilding the same account container', async () => {
|
||||||
const repository = createIdentityRepository();
|
const repository = createIdentityRepository();
|
||||||
const service = new NapcatDeviceIdentityService(
|
const service = new NapcatDeviceIdentityService(
|
||||||
|
|||||||
@ -1,11 +1,61 @@
|
|||||||
import { existsSync, readFileSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
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 repoRoot = join(__dirname, '../../../..');
|
||||||
|
const schema = readRefactorV3SqlSchema();
|
||||||
|
|
||||||
const readSource = (relativePath: string) =>
|
const readSource = (relativePath: string) =>
|
||||||
readFileSync(join(repoRoot, relativePath), 'utf8');
|
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', () => {
|
describe('NapCat persistent login state contract', () => {
|
||||||
it('owns the approved third-phase persistence tables', () => {
|
it('owns the approved third-phase persistence tables', () => {
|
||||||
const persistenceRoot = join(
|
const persistenceRoot = join(
|
||||||
@ -45,15 +95,119 @@ describe('NapCat persistent login state contract', () => {
|
|||||||
loginSource.includes(signal),
|
loginSource.includes(signal),
|
||||||
);
|
);
|
||||||
const missingPersistenceSignals = [
|
const missingPersistenceSignals = [
|
||||||
'loginSession',
|
'loginSessionStore',
|
||||||
'loginChallenge',
|
'recordCaptchaChallenge',
|
||||||
'runtimeCleanup',
|
'recordNewDeviceChallenge',
|
||||||
|
'recordRuntimeCleanup',
|
||||||
].filter((signal) => !loginSource.includes(signal));
|
].filter((signal) => !loginSource.includes(signal));
|
||||||
|
|
||||||
expect(bannedMemorySignals).toEqual([]);
|
expect(bannedMemorySignals).toEqual([]);
|
||||||
expect(missingPersistenceSignals).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', () => {
|
it('keeps captcha and new-device challenges recoverable and separate', () => {
|
||||||
const stateMachine = readSource(
|
const stateMachine = readSource(
|
||||||
'src/modules/qqbot/napcat/domain/login/napcat-login-state-machine.ts',
|
'src/modules/qqbot/napcat/domain/login/napcat-login-state-machine.ts',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user