fix: 缓存NapCat WebUI登录凭证

This commit is contained in:
sunlei 2026-06-26 11:25:03 +08:00
parent d9b3b9751a
commit e3ab00c00f
2 changed files with 355 additions and 8 deletions

View File

@ -49,12 +49,29 @@ type CreateManagedContainerOptions = {
startRemote?: boolean;
};
type NapcatWebuiCredentialCacheEntry = {
credential: string;
expiresAt: number;
};
const NAPCAT_WEBUI_CREDENTIAL_TTL_MS = 50 * 60 * 1000;
@Injectable()
export class QqbotNapcatContainerService {
private readonly configWriterService: NapcatConfigWriterService;
private readonly runtimeProfileService: NapcatRuntimeProfileService;
private readonly webuiCredentials: Record<
string,
NapcatWebuiCredentialCacheEntry | undefined
> = {};
private readonly webuiCredentialRequests: Record<
string,
Promise<string> | undefined
> = {};
/**
* QqbotNapcatContainerService
* @param configService - Runtime configuration source for NAS SSH, Docker image, port pool, and profile defaults.
@ -894,13 +911,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
try {
const runtime = this.toRuntime(container);
const credential = await this.getNapcatCredential(runtime);
const status = await this.requestNapcat<NapcatLoginStatus>(
runtime,
'/api/QQLogin/CheckLoginStatus',
{},
credential,
);
const status = await this.requestNapcatLoginStatus(runtime);
const snapshot = this.toRuntimeStatusSnapshot(
status,
containerOnline,
@ -1693,9 +1704,40 @@ ${file.content}EOF`;
/**
* NapCat
* @param runtime - runtime 使 `webuiToken`
* @param runtime - NapCat runtime使WebUI token Credential
* @returns NapCat WebUI Bearer Credential
*/
private async getNapcatCredential(runtime: QqbotNapcatRuntime) {
const cacheKey = this.getNapcatCredentialCacheKey(runtime);
const cached = this.webuiCredentials[cacheKey];
if (cached && Date.now() < cached.expiresAt) {
return cached.credential;
}
const pending = this.webuiCredentialRequests[cacheKey];
if (pending) {
return pending;
}
const request = this.fetchNapcatCredential(runtime, cacheKey);
this.webuiCredentialRequests[cacheKey] = request;
try {
return await request;
} finally {
delete this.webuiCredentialRequests[cacheKey];
}
}
/**
* NapCat WebUI Bearer Credential
* @param runtime - NapCat runtime WebUI token `/api/auth/login`
* @param cacheKey - token single-flight
* @returns NapCat WebUI Credential
*/
private async fetchNapcatCredential(
runtime: QqbotNapcatRuntime,
cacheKey: string,
) {
const token = runtime.webuiToken || '';
const hash = createHash('sha256').update(`${token}.napcat`).digest('hex');
const data = await this.requestNapcat<NapcatCredential>(
@ -1706,9 +1748,78 @@ ${file.content}EOF`;
if (!data.Credential) {
throwVbenError('NapCat WebUI 登录失败');
}
this.webuiCredentials[cacheKey] = {
credential: data.Credential,
expiresAt: Date.now() + NAPCAT_WEBUI_CREDENTIAL_TTL_MS,
};
return data.Credential;
}
/**
* NapCat QQ WebUI Credential
* @param runtime - NapCat runtime WebUI token
* @returns NapCat WebUI QQ
*/
private async requestNapcatLoginStatus(runtime: QqbotNapcatRuntime) {
const credential = await this.getNapcatCredential(runtime);
try {
return await this.requestNapcat<NapcatLoginStatus>(
runtime,
'/api/QQLogin/CheckLoginStatus',
{},
credential,
);
} catch (err) {
if (!this.isNapcatCredentialRejected(err)) {
throw err;
}
this.clearNapcatCredential(runtime, credential);
const refreshedCredential = await this.getNapcatCredential(runtime);
return this.requestNapcat<NapcatLoginStatus>(
runtime,
'/api/QQLogin/CheckLoginStatus',
{},
refreshedCredential,
);
}
}
/**
* NapCat WebUI Credential
* @param runtime - NapCat runtime`id/baseUrl/token`
* @param rejectedCredential - NapCat Credential
*/
private clearNapcatCredential(
runtime: QqbotNapcatRuntime,
rejectedCredential?: string,
) {
const cacheKey = this.getNapcatCredentialCacheKey(runtime);
const cached = this.webuiCredentials[cacheKey];
if (rejectedCredential && cached?.credential !== rejectedCredential) {
return;
}
delete this.webuiCredentials[cacheKey];
}
/**
* NapCat WebUI Credential
* @param err - NapCat WebUI `Unauthorized` token
* @returns true Credential
*/
private isNapcatCredentialRejected(err: unknown) {
const message = this.toolsService.getErrorMessage(err);
return /Unauthorized|token is invalid/i.test(message);
}
/**
* NapCat WebUI Credential
* @param runtime - NapCat runtime`id/baseUrl` `webuiToken`
* @returns API credential 使
*/
private getNapcatCredentialCacheKey(runtime: QqbotNapcatRuntime) {
return [runtime.id || runtime.baseUrl, runtime.webuiToken || ''].join('\n');
}
/**
* NapCat
* @param status - NapCat列表使 `loginError`

View File

@ -82,4 +82,240 @@ describe('QqbotNapcatContainerService runtime status', () => {
}),
);
});
it('reuses WebUI credential when checking the same running container repeatedly', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
const requestNapcat = jest
.spyOn(service as any, 'requestNapcat')
.mockImplementation(async (_runtime, path: string) => {
if (path === '/api/auth/login') {
return { Credential: 'credential-1' };
}
if (path === '/api/QQLogin/CheckLoginStatus') {
return {
isLogin: true,
isOffline: false,
loginError: '',
online: true,
qrcodeurl: '',
};
}
throw new Error(`unexpected path ${path}`);
});
const container = {
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
name: 'napcat-1',
status: 'running',
webuiPort: 6100,
webuiToken: 'token',
} as any;
await service.inspectRuntimeStatus(container);
await service.inspectRuntimeStatus(container);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/auth/login',
),
).toHaveLength(1);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus',
),
).toHaveLength(2);
});
it('deduplicates concurrent WebUI credential requests for the same container', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
const requestNapcat = jest
.spyOn(service as any, 'requestNapcat')
.mockImplementation(async (_runtime, path: string) => {
if (path === '/api/auth/login') {
await new Promise((resolve) => setTimeout(resolve, 1));
return { Credential: 'credential-1' };
}
if (path === '/api/QQLogin/CheckLoginStatus') {
return {
isLogin: true,
isOffline: false,
loginError: '',
online: true,
qrcodeurl: '',
};
}
throw new Error(`unexpected path ${path}`);
});
const container = {
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
name: 'napcat-1',
status: 'running',
webuiPort: 6100,
webuiToken: 'token',
} as any;
await Promise.all([
service.inspectRuntimeStatus(container),
service.inspectRuntimeStatus(container),
]);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/auth/login',
),
).toHaveLength(1);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus',
),
).toHaveLength(2);
});
it('refreshes cached WebUI credential once when NapCat rejects status auth', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
let credentialIndex = 0;
let statusChecks = 0;
const requestNapcat = jest
.spyOn(service as any, 'requestNapcat')
.mockImplementation(
async (_runtime, path: string, _body, credential?: string) => {
if (path === '/api/auth/login') {
credentialIndex += 1;
return { Credential: `credential-${credentialIndex}` };
}
if (path === '/api/QQLogin/CheckLoginStatus') {
statusChecks += 1;
if (statusChecks > 1 && credential === 'credential-1') {
throw new Error('Unauthorized');
}
return {
isLogin: true,
isOffline: false,
loginError: '',
online: true,
qrcodeurl: '',
};
}
throw new Error(`unexpected path ${path}`);
},
);
const container = {
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
name: 'napcat-1',
status: 'running',
webuiPort: 6100,
webuiToken: 'token',
} as any;
await service.inspectRuntimeStatus(container);
const snapshot = await service.inspectRuntimeStatus(container);
expect(snapshot).toEqual(
expect.objectContaining({
qqLoginStatus: 'online',
webuiOnline: true,
}),
);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/auth/login',
),
).toHaveLength(2);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus',
),
).toHaveLength(3);
});
it('keeps a refreshed credential when a later stale status request is rejected', async () => {
const repository = {
update: jest.fn(),
};
const service = createService(repository);
let credentialIndex = 0;
let staleMode = false;
let staleStatusChecks = 0;
let releaseSecondStaleReject: (() => void) | undefined;
const secondStaleReject = new Promise<void>((resolve) => {
releaseSecondStaleReject = resolve;
});
const requestNapcat = jest
.spyOn(service as any, 'requestNapcat')
.mockImplementation(
async (_runtime, path: string, _body, credential?: string) => {
if (path === '/api/auth/login') {
credentialIndex += 1;
return {
Credential:
credentialIndex === 1
? 'credential-old'
: 'credential-new',
};
}
if (path === '/api/QQLogin/CheckLoginStatus') {
if (staleMode && credential === 'credential-old') {
staleStatusChecks += 1;
if (staleStatusChecks === 1) {
throw new Error('Unauthorized');
}
await secondStaleReject;
throw new Error('Unauthorized');
}
if (staleMode && credential === 'credential-new') {
releaseSecondStaleReject?.();
}
return {
isLogin: true,
isOffline: false,
loginError: '',
online: true,
qrcodeurl: '',
};
}
throw new Error(`unexpected path ${path}`);
},
);
const container = {
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
lastError: null,
name: 'napcat-1',
status: 'running',
webuiPort: 6100,
webuiToken: 'token',
} as any;
await service.inspectRuntimeStatus(container);
staleMode = true;
await Promise.all([
service.inspectRuntimeStatus(container),
service.inspectRuntimeStatus(container),
]);
expect(
requestNapcat.mock.calls.filter(
([, path]: unknown[]) => path === '/api/auth/login',
),
).toHaveLength(2);
});
});