fix: 修复NapCat验证码会话持久化

This commit is contained in:
sunlei 2026-06-16 19:24:01 +08:00
parent 041052900a
commit 749df7cd7e
7 changed files with 74 additions and 10 deletions

View File

@ -117,7 +117,7 @@ CREATE TABLE IF NOT EXISTS `napcat_login_session` (
CREATE TABLE IF NOT EXISTS `napcat_login_challenge` ( CREATE TABLE IF NOT EXISTS `napcat_login_challenge` (
`id` bigint NOT NULL, `id` bigint NOT NULL,
`session_id` bigint NOT NULL, `session_id` varchar(64) NOT NULL,
`challenge_type` varchar(64) NOT NULL, `challenge_type` varchar(64) NOT NULL,
`status` varchar(32) NOT NULL, `status` varchar(32) NOT NULL,
`challenge_url` text, `challenge_url` text,
@ -131,7 +131,7 @@ CREATE TABLE IF NOT EXISTS `napcat_login_challenge` (
CREATE TABLE IF NOT EXISTS `napcat_runtime_cleanup` ( CREATE TABLE IF NOT EXISTS `napcat_runtime_cleanup` (
`id` bigint NOT NULL, `id` bigint NOT NULL,
`session_id` bigint NOT NULL, `session_id` varchar(64) NOT NULL,
`cleanup_type` varchar(64) NOT NULL, `cleanup_type` varchar(64) NOT NULL,
`status` varchar(32) NOT NULL, `status` varchar(32) NOT NULL,
`error_message` text, `error_message` text,

View File

@ -948,7 +948,7 @@ CREATE TABLE IF NOT EXISTS napcat_login_session (
CREATE TABLE IF NOT EXISTS napcat_login_challenge ( CREATE TABLE IF NOT EXISTS napcat_login_challenge (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY,
session_id BIGINT NOT NULL, session_id VARCHAR(64) NOT NULL,
challenge_type VARCHAR(64) NOT NULL, challenge_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
challenge_url TEXT NULL, challenge_url TEXT NULL,
@ -961,7 +961,7 @@ CREATE TABLE IF NOT EXISTS napcat_login_challenge (
CREATE TABLE IF NOT EXISTS napcat_runtime_cleanup ( CREATE TABLE IF NOT EXISTS napcat_runtime_cleanup (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY,
session_id BIGINT NOT NULL, session_id VARCHAR(64) NOT NULL,
cleanup_type VARCHAR(64) NOT NULL, cleanup_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
error_message TEXT NULL, error_message TEXT NULL,

View File

@ -108,3 +108,17 @@ FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND table_name = 'napcat_runtime_cleanup' AND table_name = 'napcat_runtime_cleanup'
AND index_name = 'idx_napcat_runtime_cleanup_session'; AND index_name = 'idx_napcat_runtime_cleanup_session';
SELECT 'column_napcat_login_challenge_session_id_varchar' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'napcat_login_challenge'
AND column_name = 'session_id'
AND column_type = 'varchar(64)';
SELECT 'column_napcat_runtime_cleanup_session_id_varchar' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'napcat_runtime_cleanup'
AND column_name = 'session_id'
AND column_type = 'varchar(64)';

View File

@ -15,7 +15,7 @@ export class NapcatLoginChallengeEntity {
@PrimaryColumn({ type: 'bigint' }) @PrimaryColumn({ type: 'bigint' })
id: string; id: string;
@Column({ name: 'session_id', type: 'bigint' }) @Column({ length: 64, name: 'session_id', type: 'varchar' })
sessionId: string; sessionId: string;
@Column({ length: 64, name: 'challenge_type' }) @Column({ length: 64, name: 'challenge_type' })

View File

@ -1,4 +1,4 @@
import { Injectable, Optional } from '@nestjs/common'; import { Injectable, Logger, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import type { QqbotLoginScanSession } from '@/modules/qqbot/core/contract/qqbot.types'; import type { QqbotLoginScanSession } from '@/modules/qqbot/core/contract/qqbot.types';
@ -16,6 +16,7 @@ type NapcatLoginStoreCache = Record<string, QqbotLoginScanSession>;
@Injectable() @Injectable()
export class NapcatLoginStateStoreService { export class NapcatLoginStateStoreService {
private readonly logger = new Logger(NapcatLoginStateStoreService.name);
private readonly cache: NapcatLoginStoreCache = {}; private readonly cache: NapcatLoginStoreCache = {};
private readonly pendingSessionWrites: Record<string, Promise<void> | undefined> = private readonly pendingSessionWrites: Record<string, Promise<void> | undefined> =
{}; {};
@ -91,7 +92,9 @@ export class NapcatLoginStateStoreService {
challengeUrl: session.captchaUrl, challengeUrl: session.captchaUrl,
session, session,
status: 'pending', status: 'pending',
}); }).catch((err) =>
this.warnPersistenceError('登录验证码 challenge 持久化失败', err),
);
} }
recordNewDeviceChallenge(session: QqbotLoginScanSession) { recordNewDeviceChallenge(session: QqbotLoginScanSession) {
@ -106,7 +109,9 @@ export class NapcatLoginStateStoreService {
challengeUrl: session.deviceVerifyUrl || session.newDeviceQrcode || null, challengeUrl: session.deviceVerifyUrl || session.newDeviceQrcode || null,
session, session,
status: session.newDeviceStatus, status: session.newDeviceStatus,
}); }).catch((err) =>
this.warnPersistenceError('新设备 challenge 持久化失败', err),
);
} }
recordRuntimeCleanup( recordRuntimeCleanup(
@ -124,7 +129,11 @@ export class NapcatLoginStateStoreService {
sessionId: session.id, sessionId: session.id,
status: input.status, status: input.status,
}); });
void this.runtimeCleanupRepository.save(cleanup); void this.runtimeCleanupRepository
.save(cleanup)
.catch((err) =>
this.warnPersistenceError('运行态清理记录持久化失败', err),
);
} }
async flushSessionWrites(sessionId?: string) { async flushSessionWrites(sessionId?: string) {
@ -295,6 +304,16 @@ export class NapcatLoginStateStoreService {
await this.loginChallengeRepository.save(entity); await this.loginChallengeRepository.save(entity);
} }
private warnPersistenceError(message: string, err: unknown) {
const detail =
err instanceof Error
? err.message
: typeof err === 'string'
? err
: JSON.stringify(err);
this.logger.warn(`${message}: ${detail || 'unknown error'}`);
}
private isResolvedChallenge(status: string) { private isResolvedChallenge(status: string) {
return ['failed', 'expired', 'verified'].includes(status); return ['failed', 'expired', 'verified'].includes(status);
} }

View File

@ -14,7 +14,7 @@ export class NapcatRuntimeCleanup {
@PrimaryColumn({ type: 'bigint' }) @PrimaryColumn({ type: 'bigint' })
id: string; id: string;
@Column({ name: 'session_id', type: 'bigint' }) @Column({ length: 64, name: 'session_id', type: 'varchar' })
sessionId: string; sessionId: string;
@Column({ length: 64, name: 'cleanup_type' }) @Column({ length: 64, name: 'cleanup_type' })

View File

@ -30,6 +30,18 @@ const getEntityColumnNames = (entity: EntityClass) => {
.map((column) => `${column.options.name || column.propertyName}`); .map((column) => `${column.options.name || column.propertyName}`);
}; };
const getEntityColumnOptions = (entity: EntityClass, propertyName: string) => {
return getMetadataArgsStorage().columns.find(
(column) => column.target === entity && column.propertyName === propertyName,
)?.options;
};
const getSchemaColumnDefinition = (tableName: string, columnName: string) => {
return schema
.getTableColumns(tableName)
.find((column) => column.name === columnName)?.definition;
};
const createRepository = <T extends Record<string, any>>() => { const createRepository = <T extends Record<string, any>>() => {
const rows: T[] = []; const rows: T[] = [];
return { return {
@ -126,6 +138,25 @@ describe('NapCat persistent login state contract', () => {
} }
}); });
it('stores challenge and cleanup session ids as uuid-safe text', () => {
expect(
getEntityColumnOptions(NapcatLoginChallengeEntity, 'sessionId'),
).toMatchObject({ length: 64, type: 'varchar' });
expect(getEntityColumnOptions(NapcatRuntimeCleanup, 'sessionId')).toMatchObject(
{ length: 64, type: 'varchar' },
);
expect(
getSchemaColumnDefinition('napcat_login_challenge', 'session_id'),
).toMatch(/^session_id VARCHAR\(64\) NOT NULL/i);
expect(
getSchemaColumnDefinition('napcat_runtime_cleanup', 'session_id'),
).toMatch(/^session_id VARCHAR\(64\) NOT NULL/i);
const qqbotInitSql = readSource('sql/qqbot-init.sql');
expect(qqbotInitSql).toContain('`session_id` varchar(64) NOT NULL');
});
it('keeps NapCat runtime tables covered by the v3 verification SQL', () => { it('keeps NapCat runtime tables covered by the v3 verification SQL', () => {
const verifySql = readSource('sql/refactor-v3/99-verify.sql'); const verifySql = readSource('sql/refactor-v3/99-verify.sql');
const requiredSignals = [ const requiredSignals = [