diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index 67db6c9..87d14d0 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -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 | 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( - 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( @@ -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( + 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( + 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` 字段生成结果。 diff --git a/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts b/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts index c82a9c6..64742d1 100644 --- a/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts @@ -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((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); + }); });