From 0375fd46618b10a3c441a22e67a0c9a8758407c4 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 14:10:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A6=81=E6=AD=A2NapCat=20WebUI?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E6=BF=80=E6=B4=BB=E6=9C=AA=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...apcat-webui-gateway-implementation-plan.md | 2 +- ...webui-gateway-implementation-plan.zh-CN.md | 2 +- .../napcat-webui-gateway-session.service.ts | 4 +- .../session-store.spec.ts | 44 +++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md b/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md index 5123f5f..1d9b300 100644 --- a/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md +++ b/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md @@ -773,7 +773,7 @@ if (older) { } ``` -The `heartbeat()` implementation must reject `revoked`, `expired`, `failed`, missing sessions, and sessions whose user/account index no longer points at the requested `sessionId`. `requireBootstrapSession()` accepts only non-terminal, non-expired, currently indexed sessions for one-time ticket bootstrap. `requireProxySession()` accepts only `active`, non-expired, currently indexed sessions. +The `heartbeat()` implementation must extend active sessions only. It must reject `created`, `revoked`, `expired`, `failed`, missing sessions, and sessions whose user/account index no longer points at the requested `sessionId`. `requireBootstrapSession()` accepts only non-terminal, non-expired, currently indexed sessions for one-time ticket bootstrap. `markActive()` is the only method that may promote a `created` session to `active`. `requireProxySession()` accepts only `active`, non-expired, currently indexed sessions. - [ ] **Step 7: Implement Redis store and ticket service** diff --git a/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md b/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md index 3281003..41349fe 100644 --- a/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md +++ b/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md @@ -302,7 +302,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/ - [ ] **Step 6:实现 session service** -创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireBootstrapSession`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。`requireBootstrapSession()` 只接受非终态、未过期且 user/account index 仍指向当前 `sessionId` 的 session;`requireProxySession()` 只接受 `active`、未过期且 index 仍指向当前 `sessionId` 的 session。 +创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireBootstrapSession`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。`heartbeat()` 只延长 active session,必须拒绝 `created`、终态、过期、缺失或 user/account index 不再指向当前 `sessionId` 的 session。`requireBootstrapSession()` 只接受非终态、未过期且 user/account index 仍指向当前 `sessionId` 的 session;只有 `markActive()` 可以把 `created` session 提升为 `active`;`requireProxySession()` 只接受 `active`、未过期且 index 仍指向当前 `sessionId` 的 session。 - [ ] **Step 7:实现 Redis store 和 ticket service** diff --git a/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts b/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts index 92b2272..be71125 100644 --- a/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts +++ b/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts @@ -86,13 +86,13 @@ export class NapcatWebuiGatewaySessionService { } /** - * Extends an Admin-owned active Gateway session. + * Extends an Admin-owned Gateway session that bootstrap already activated. * @param input - Session id plus Admin ownership and request evidence. * @returns Browser-safe lifecycle result. */ async heartbeat(input: NapcatWebuiGatewayLifecycleInput) { const adminUserId = this.requireLifecycleAdminUserId(input.adminUserId); - const session = await this.requireUsableSession(input.sessionId); + const session = await this.requireProxySession(input.sessionId); this.assertOwner(session, adminUserId); const now = this.config.now(); const expiresAt = now + this.config.ttlMs(); diff --git a/test/apps/napcat-webui-gateway/session-store.spec.ts b/test/apps/napcat-webui-gateway/session-store.spec.ts index 2f8bcd7..950f6c3 100644 --- a/test/apps/napcat-webui-gateway/session-store.spec.ts +++ b/test/apps/napcat-webui-gateway/session-store.spec.ts @@ -381,6 +381,8 @@ describe('NapcatWebuiGatewaySessionService', () => { ); const session = await service.create(createSessionInput()); + await service.markActive(session.sessionId); + await expect( service.heartbeat({ adminUserId: 'admin-2', @@ -395,6 +397,25 @@ describe('NapcatWebuiGatewaySessionService', () => { ).rejects.toThrow('Gateway session owner mismatch'); }); + it('rejects heartbeat for created sessions without activating them', async () => { + const store = new MemorySessionStore(); + const service = new NapcatWebuiGatewaySessionService( + store, + createConfig({ value: 1000 }) as never, + ); + const session = await service.create(createSessionInput()); + + await expect( + service.heartbeat({ + adminUserId: 'admin-1', + sessionId: session.sessionId, + }), + ).rejects.toThrow('Gateway session is not active'); + expect(await store.find(session.sessionId)).toMatchObject({ + status: 'created', + }); + }); + it('keeps created sessions out of proxy access until bootstrap marks them active', async () => { const store = new MemorySessionStore(); const currentTime = { value: 1000 }; @@ -813,6 +834,11 @@ describe('InternalSessionController', () => { .send(createSessionInput()) .expect(HttpStatus.CREATED); const sessionId = createResponse.body.sessionId; + await store.update(sessionId, { + activeAt: 1000, + lastSeenAt: 1000, + status: 'active', + }); await request(app.getHttpServer()) .post(`/internal/sessions/${sessionId}/heartbeat`) @@ -851,6 +877,24 @@ describe('InternalSessionController', () => { .expect(HttpStatus.BAD_REQUEST); }); + it('rejects heartbeat for created sessions with gone status', async () => { + const createResponse = await request(app.getHttpServer()) + .post('/internal/sessions') + .set('x-kt-gateway-secret', INTERNAL_SECRET) + .send(createSessionInput()) + .expect(HttpStatus.CREATED); + const sessionId = createResponse.body.sessionId; + + await request(app.getHttpServer()) + .post(`/internal/sessions/${sessionId}/heartbeat`) + .set('x-kt-gateway-secret', INTERNAL_SECRET) + .send({ adminUserId: 'admin-1' }) + .expect(HttpStatus.GONE); + expect(await store.find(sessionId)).toMatchObject({ + status: 'created', + }); + }); + it('rejects invalid create-session payloads with bad request status', async () => { await request(app.getHttpServer()) .post('/internal/sessions')