# QQBot NapCat WebUI Gateway Implementation Plan > **Execution note:** Execute this plan task-by-task with the KT-local workflow and use the checkboxes to track plan state. **Goal:** Build an independent NapCat WebUI Gateway service and an Admin second-level route that opens the selected QQBot account's original NapCat WebUI with full operation capability and route-bound session cleanup. **Architecture:** The API service remains the Admin-authenticated authority that resolves a QQBot account to its active NapCat container and requests a short-lived Gateway session. The new `kt-napcat-webui-gateway` process owns session storage, one-time bootstrap tickets, NapCat WebUI credential exchange, HTTP/static/API/WebSocket proxying, and audit events. Admin opens `/qqbot/account/:accountId/napcat-webui`, creates a route-bound session, heartbeats while mounted, and revokes on route leave. **Tech Stack:** NestJS 11, Express adapter, TypeORM/MySQL, Redis through `@nestjs-modules/ioredis` + `ioredis`, `http-proxy-middleware` for Express/WebSocket proxying, Vue 3 TSX, VueUse `useIntervalFn`, Vben Admin, antdv-next, K8s, Jenkins, Caddy/Admin domain route. --- ## Source References - Spec: `docs/specs/2026-06-24-qqbot-napcat-webui-gateway-design.md` - Chinese spec: `docs/specs/2026-06-24-qqbot-napcat-webui-gateway-design.zh-CN.md` - `http-proxy-middleware` supports Express proxy middleware and WebSocket upgrades through `ws: true` and upgrade handling: - `http-proxy-middleware` WebSocket recipe documents `ws: true`, manual `server.on('upgrade', proxy.upgrade)`, multiple targets, and path rewriting: - `@nestjs-modules/ioredis` provides Nest `RedisModule.forRoot` and `@InjectRedis()` over `ioredis`: - `connect-redis` is intentionally not used because it is an Express session store, while this Gateway needs API-created sessions, one-time tickets, target metadata, concurrent-session revocation, Credential cache, and audit events: - VueUse `useIntervalFn` wraps `setInterval` with pause/resume controls and is already available in Admin: ## Scope Check This is one deployable workstream even though it spans API, Gateway, Admin, and deployment files. The pieces are not independently useful: Admin cannot load a WebUI route without API session creation, API session creation cannot pass online smoke without Gateway, and Gateway cannot be safely exposed without the route lifecycle and deploy route. ## File Structure ### API repository: `D:\MyFiles\KT\Node\kt-template-online-api` - Modify `package.json` and `pnpm-lock.yaml`: add `@nestjs-modules/ioredis`, `ioredis`, and `http-proxy-middleware`, plus gateway start scripts. - Create `src/apps/napcat-webui-gateway/main.ts`: standalone Nest bootstrap on port `48086`. - Create `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`: Gateway module imports config, logger, TypeORM, `RedisModule`, and gateway providers/controllers. - Create `src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts`: reads Gateway env with bounded defaults. - Create `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts`: session, audit, target, and proxy types. - Create `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`: Redis-backed session and ticket store. - Create `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts`: one-time bootstrap ticket generation and redemption. - Create `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts`: server-side WebUI token to Credential exchange. - Create `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`: HTTP, header, redirect, cookie, and WebSocket proxy assembly. - Create `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts`: session create, active mark, heartbeat, revoke, expire, and concurrent-session policy. - Create `src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts`: internal service-to-service session API. - Create `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`: bootstrap and public iframe/proxy entry. - Create `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts`: Admin-facing DTOs. - Create `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts`: Admin endpoints under `/qqbot/napcat/webui`. - Create `src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts`: account authorization, target resolution, and Gateway client orchestration. - Create `src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts`: internal Gateway HTTP client. - Create `src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts`: MySQL audit entity. - Modify `src/modules/qqbot/napcat/qqbot-napcat.module.ts`: register controller, service, client, and audit entity. - Modify `sql/qqbot-init.sql` and `sql/refactor-v3/01-seed-core.sql`: add `QqBot:Account:WebUI` hidden route/menu and row-action permission. - Modify `sql/refactor-v3/99-verify.sql`: assert the new permission and audit table exist. - Create `dockerfile.gateway`: production image entry for `dist/apps/napcat-webui-gateway/main`. - Modify `Jenkinsfile`: build, push, and deploy API image plus Gateway image. - Modify `k8s/prod/api.yaml`: add Gateway Deployment/Service and API env for Gateway base URL/public base URL/internal secret. - Modify `README.md` and `API.md`: document Gateway env, route, and validation commands. ### Admin repository: `D:\MyFiles\KT\Vue\kt-template-admin` - Modify `apps/web-antdv-next/src/router/routes/modules/qqbot.ts`: add hidden second-level WebUI route. - Modify `apps/web-antdv-next/src/api/qqbot/napcat.ts`: add WebUI session contracts and callers. - Modify `apps/web-antdv-next/src/views/qqbot/account/list.tsx`: add WebUI row action. - Create `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.tsx`: remote console page. - Create `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.scss`: layout and theme-aware console styles. - Create `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/useNapcatWebuiGatewaySession.ts`: route-bound session lifecycle. - Modify `apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts`: keep WebUI session code out of account list. - Create `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx`: page lifecycle tests. - Modify `apps/web-antdv-next/src/api/qqbot/napcat.spec.ts`: WebUI session caller tests. --- ### Task 1: Add Contracts, Permission Seeds, and Red Tests **Files:** - Create: `test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts` - Modify: `sql/qqbot-init.sql` - Modify: `sql/refactor-v3/01-seed-core.sql` - Modify: `sql/refactor-v3/99-verify.sql` - [ ] **Step 1: Write the failing structural test** Create `test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts`: ```ts import { readFileSync } from 'fs'; import { resolve } from 'path'; const repoRoot = resolve(__dirname, '../../../..'); const read = (path: string) => readFileSync(resolve(repoRoot, path), 'utf8'); describe('NapCat WebUI Gateway contract seeds', () => { it('registers a dedicated Admin permission for full NapCat WebUI access', () => { const coreSeed = read('sql/refactor-v3/01-seed-core.sql'); const qqbotSeed = read('sql/qqbot-init.sql'); expect(coreSeed).toContain('QqBot:Account:WebUI'); expect(coreSeed).toContain('QqBotAccountNapcatWebui'); expect(qqbotSeed).toContain('QqBot:Account:WebUI'); expect(qqbotSeed).toContain('QqBotAccountNapcatWebui'); }); it('verifies the gateway audit table during full schema checks', () => { const verifySql = read('sql/refactor-v3/99-verify.sql'); expect(verifySql).toContain('qqbot_napcat_webui_gateway_audit'); expect(verifySql).toContain('QqBot:Account:WebUI'); }); }); ``` - [ ] **Step 2: Run the new test and verify RED** Run: ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts --runInBand ``` Expected: FAIL because `QqBot:Account:WebUI` and `qqbot_napcat_webui_gateway_audit` are not present. - [ ] **Step 3: Add SQL seed rows** Add these rows next to existing QQBot account rows in both `sql/qqbot-init.sql` and `sql/refactor-v3/01-seed-core.sql`: ```sql (2041700000000100412, 2041700000000100400, 'QqBotAccountNapcatWebui', '/qqbot/account/:accountId/napcat-webui', '/qqbot/account/napcat-webui/index', NULL, 'QqBot:Account:WebUI', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"NapCat WebUI"}', 1, 0), (2041700000000120407, 2041700000000100402, 'QqBotAccountWebUI', NULL, NULL, NULL, 'QqBot:Account:WebUI', 'button', '{"title":"NapCat WebUI"}', 1, 0), ``` Keep IDs unique and do not modify unrelated menu rows. - [ ] **Step 4: Add schema verification SQL** In `sql/refactor-v3/99-verify.sql`, add assertions using the existing verify style: ```sql SELECT 'qqbot_napcat_webui_gateway_audit table exists' AS check_name, COUNT(*) AS matched FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'qqbot_napcat_webui_gateway_audit'; SELECT 'QqBot Account WebUI permission exists' AS check_name, COUNT(*) AS matched FROM admin_menu WHERE auth_code = 'QqBot:Account:WebUI'; ``` The current menu table is `admin_menu`, and the permission column is `auth_code`; use those exact names in the verification SQL. - [ ] **Step 5: Run the contract test and verify GREEN** Run: ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts --runInBand ``` Expected: PASS. - [ ] **Step 6: Commit Task 1** ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api add test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts sql/qqbot-init.sql sql/refactor-v3/01-seed-core.sql sql/refactor-v3/99-verify.sql git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat WebUI权限契约" ``` --- ### Task 2: Build API Session Endpoints and Audit Entity **Files:** - Create: `test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts` - Create: `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts` - Create: `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts` - Create: `src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts` - Create: `src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts` - Create: `src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts` - Modify: `src/modules/qqbot/napcat/qqbot-napcat.module.ts` - Modify: `src/modules/qqbot/napcat/infrastructure/persistence/index.ts` - [ ] **Step 1: Write the failing API service test** Create `test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts`: ```ts import { QqbotNapcatWebuiGatewayService } from '../../../../src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service'; const account = { id: 'account-1', name: 'Mirror', selfId: '1914728559', }; const redactedWebuiToken = ['redacted', 'webui', 'token'].join('-'); const container = { id: 'container-1', name: 'kt-qqbot-napcat-1', webuiPort: 6100, webuiStatus: 'online', webuiToken: redactedWebuiToken, }; describe('QqbotNapcatWebuiGatewayService', () => { it('creates a safe Admin session response without leaking NapCat secrets', async () => { const service = new QqbotNapcatWebuiGatewayService( { findById: jest.fn().mockResolvedValue(account) } as any, { findPrimaryContainerByAccountId: jest.fn().mockResolvedValue(container) } as any, { createSession: jest.fn().mockResolvedValue({ expiresAt: 1782268000000, iframeUrl: '/napcat-webui/session/session-1/bootstrap?ticket=ticket-1', sessionId: 'session-1', }), heartbeat: jest.fn(), revoke: jest.fn(), } as any, { record: jest.fn() } as any, ); const result = await service.createSession({ accountId: 'account-1', adminUserId: '2041700000000000002', clientIp: '127.0.0.1', userAgent: 'vitest', }); expect(result).toMatchObject({ account: { id: 'account-1', selfId: '1914728559' }, container: { id: 'container-1', name: 'kt-qqbot-napcat-1', webuiStatus: 'online', }, iframeUrl: '/napcat-webui/session/session-1/bootstrap?ticket=ticket-1', sessionId: 'session-1', }); expect(JSON.stringify(result)).not.toContain('secret-token'); expect(JSON.stringify(result)).not.toContain('6100'); }); it('rejects accounts without an online WebUI target before calling Gateway', async () => { const gatewayClient = { createSession: jest.fn() }; const service = new QqbotNapcatWebuiGatewayService( { findById: jest.fn().mockResolvedValue(account) } as any, { findPrimaryContainerByAccountId: jest .fn() .mockResolvedValue({ ...container, webuiStatus: 'offline' }), } as any, gatewayClient as any, { record: jest.fn() } as any, ); await expect( service.createSession({ accountId: 'account-1', adminUserId: '2041700000000000002', clientIp: '127.0.0.1', userAgent: 'vitest', }), ).rejects.toThrow('NapCat WebUI 不在线'); expect(gatewayClient.createSession).not.toHaveBeenCalled(); }); }); ``` - [ ] **Step 2: Run the service test and verify RED** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts --runInBand ``` Expected: FAIL because the service and DTOs do not exist. - [ ] **Step 3: Create DTOs** Create `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts`: ```ts import { ApiProperty } from '@nestjs/swagger'; export class QqbotNapcatWebuiSessionCreateDto { @ApiProperty({ description: 'QQBot account id selected from the account list' }) accountId!: string; } export class QqbotNapcatWebuiSessionResponseDto { account!: { id: string; name?: string; selfId: string; }; container!: { id: string; name: string; webuiStatus: string; }; expiresAt!: number; iframeUrl!: string; sessionId!: string; } ``` - [ ] **Step 4: Create the audit entity** Create `src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts`: ```ts import { Column, Entity, PrimaryColumn } from 'typeorm'; import { KtCreateDateColumn, KtDateTime } from '@/common'; @Entity('qqbot_napcat_webui_gateway_audit') export class NapcatWebuiGatewayAudit { @PrimaryColumn({ length: 32 }) id!: string; @Column({ length: 64, name: 'session_id' }) sessionId!: string; @Column({ length: 32, name: 'admin_user_id' }) adminUserId!: string; @Column({ length: 32, name: 'account_id' }) accountId!: string; @Column({ length: 32, name: 'self_id' }) selfId!: string; @Column({ length: 32, name: 'container_id' }) containerId!: string; @Column({ length: 64, name: 'event_type' }) eventType!: string; @Column({ length: 128, name: 'client_ip', nullable: true }) clientIp?: null | string; @Column({ length: 512, name: 'user_agent', nullable: true }) userAgent?: null | string; @Column({ name: 'detail_json', nullable: true, type: 'json' }) detailJson?: null | Record; @KtCreateDateColumn() createTime!: KtDateTime; } ``` Use the existing ID generation helper used by nearby services when recording rows. - [ ] **Step 5: Create the Gateway internal client** Create `src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts` with methods: ```ts export interface CreateGatewaySessionInput { accountId: string; adminUserId: string; clientIp?: string; containerId: string; containerName: string; selfId: string; upstreamBaseUrl: string; userAgent?: string; webuiToken: string; } export interface CreateGatewaySessionResult { expiresAt: number; iframeUrl: string; sessionId: string; } export class QqbotNapcatWebuiGatewayClient { /** * Creates a Gateway session through the internal service endpoint. * @param input Account, container, and server-side WebUI credential material resolved by API. * @returns The browser-safe iframe URL and session lifetime returned by Gateway. */ async createSession( input: CreateGatewaySessionInput, ): Promise { // Implement with axios or Node fetch using NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL // and NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET. Do not log webuiToken. throw new Error('NapCat WebUI Gateway client not implemented'); } /** * Forwards a route-bound heartbeat to Gateway. * @param sessionId Gateway session id returned to Admin. * @returns Updated session lifetime. */ async heartbeat(sessionId: string) { throw new Error('NapCat WebUI Gateway heartbeat not implemented'); } /** * Revokes an active Gateway session. * @param sessionId Gateway session id returned to Admin. */ async revoke(sessionId: string) { throw new Error('NapCat WebUI Gateway revoke not implemented'); } } ``` Replace the throwing bodies in the implementation step with bounded HTTP calls and sanitized errors. - [ ] **Step 6: Create the API service** Create `src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts` with these public methods: ```ts export interface CreateAdminWebuiSessionInput { accountId: string; adminUserId: string; clientIp?: string; userAgent?: string; } export class QqbotNapcatWebuiGatewayService { /** * Creates a browser-safe NapCat WebUI Gateway session for one QQBot account. * @param input Admin user id, selected account id, and client evidence for audit. * @returns Safe session metadata for Admin route iframe loading. */ async createSession(input: CreateAdminWebuiSessionInput) { const account = await this.accountService.findById(input.accountId); if (!account) throw new Error('QQBot 账号不存在'); const container = await this.napcatRuntimeService.findPrimaryContainerByAccountId( input.accountId, ); if (!container) throw new Error('账号未绑定 NapCat 容器'); if (container.webuiStatus === 'offline') { throw new Error('NapCat WebUI 不在线'); } if (!container.webuiToken || !container.webuiPort) { throw new Error('NapCat WebUI 配置不完整'); } const gateway = await this.gatewayClient.createSession({ accountId: account.id, adminUserId: input.adminUserId, clientIp: input.clientIp, containerId: container.id, containerName: container.name, selfId: account.selfId, upstreamBaseUrl: this.buildContainerWebuiUrl(container.webuiPort), userAgent: input.userAgent, webuiToken: container.webuiToken, }); return { account: { id: account.id, name: account.name, selfId: account.selfId }, container: { id: container.id, name: container.name, webuiStatus: container.webuiStatus || 'unknown', }, ...gateway, }; } } ``` Add `findPrimaryContainerByAccountId(accountId: string)` to `QqbotNapcatContainerService` with JSDoc and a focused unit test, then use that method from `QqbotNapcatWebuiGatewayService`. - [ ] **Step 7: Create the API controller** Create `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts`: ```ts import { Body, Controller, HttpCode, HttpStatus, Param, Post, Req, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; import { vbenSuccess } from '@/common'; import { QqbotNapcatWebuiGatewayService } from '../application/qqbot-napcat-webui-gateway.service'; import { QqbotNapcatWebuiSessionCreateDto } from './qqbot-napcat-webui-gateway.dto'; @ApiTags('QQBot - NapCat WebUI Gateway') @Controller('qqbot/napcat/webui') @UseGuards(JwtAuthGuard) export class QqbotNapcatWebuiGatewayController { /** * Initializes the Admin-facing NapCat WebUI Gateway controller. * @param service Gateway session application service. */ constructor(private readonly service: QqbotNapcatWebuiGatewayService) {} /** * Creates one route-bound NapCat WebUI session. * @param body Selected QQBot account id from Admin. * @param req Authenticated request carrying Admin user and client evidence. */ @Post('session') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '创建 NapCat WebUI Gateway 会话' }) async createSession(@Body() body: QqbotNapcatWebuiSessionCreateDto, @Req() req: any) { return vbenSuccess( await this.service.createSession({ accountId: body.accountId, adminUserId: `${req.user?.sub || req.user?.id || ''}`, clientIp: req.ip, userAgent: req.headers?.['user-agent'], }), ); } @Post('session/:sessionId/heartbeat') @HttpCode(HttpStatus.OK) async heartbeat(@Param('sessionId') sessionId: string) { return vbenSuccess(await this.service.heartbeat(sessionId)); } @Post('session/:sessionId/revoke') @HttpCode(HttpStatus.OK) async revoke(@Param('sessionId') sessionId: string) { return vbenSuccess(await this.service.revoke(sessionId)); } } ``` - [ ] **Step 8: Register providers and entities** Modify `src/modules/qqbot/napcat/qqbot-napcat.module.ts` to include: ```ts import { QqbotNapcatWebuiGatewayController } from './webui-gateway/contract/qqbot-napcat-webui-gateway.controller'; import { QqbotNapcatWebuiGatewayService } from './webui-gateway/application/qqbot-napcat-webui-gateway.service'; import { QqbotNapcatWebuiGatewayClient } from './webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client'; ``` Add the controller to `QQBOT_NAPCAT_CONTROLLERS` and the service/client to `QQBOT_NAPCAT_PROVIDERS`. Modify `src/modules/qqbot/napcat/infrastructure/persistence/index.ts` to include `NapcatWebuiGatewayAudit` in `NAPCAT_RUNTIME_ENTITIES` and add `qqbot_napcat_webui_gateway_audit` to `NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables`. - [ ] **Step 9: Run API tests and typecheck** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts --runInBand pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck ``` Expected: tests PASS and typecheck PASS. - [ ] **Step 10: Commit Task 2** ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api add src/modules/qqbot/napcat/webui-gateway src/modules/qqbot/napcat/qqbot-napcat.module.ts src/modules/qqbot/napcat/infrastructure/persistence/index.ts test/modules/qqbot/napcat-webui-gateway git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat WebUI会话接口" ``` --- ### Task 3: Build Gateway App, Session Store, and Bootstrap Tickets **Files:** - Modify: `package.json` - Modify: `pnpm-lock.yaml` - Create: `test/apps/napcat-webui-gateway/session-store.spec.ts` - Create: `src/apps/napcat-webui-gateway/main.ts` - Create: `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts` - Create: `src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts` - Create: `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts` - Create: `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts` - Create: `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts` - Create: `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts` - Create: `src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts` - [ ] **Step 1: Add dependencies** Run: ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api add @nestjs-modules/ioredis ioredis http-proxy-middleware ``` Expected: `package.json` and `pnpm-lock.yaml` update. Keep `ws` unchanged because it already exists. Do not add `connect-redis` because Gateway sessions are domain sessions, not Express login sessions. - [ ] **Step 2: Add package scripts** Modify `package.json` scripts: ```json { "start:gateway:prod": "cross-env NODE_ENV=production node dist/apps/napcat-webui-gateway/main", "start:gateway:dev": "ts-node -r tsconfig-paths/register src/apps/napcat-webui-gateway/main.ts" } ``` The API repo already depends on `ts-node` and `tsconfig-paths`, so the dev script uses the direct TypeScript entrypoint and the production script keeps the compiled `dist/apps/napcat-webui-gateway/main` entrypoint. - [ ] **Step 3: Write the failing session lifecycle test** Create `test/apps/napcat-webui-gateway/session-store.spec.ts`: ```ts import { NapcatWebuiGatewaySessionService } from '../../../src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service'; import type { NapcatWebuiGatewaySessionStore } from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types'; class MemoryStore implements NapcatWebuiGatewaySessionStore { readonly sessions = new Map(); async create(session: any) { this.sessions.set(session.sessionId, session); return session; } async find(sessionId: string) { return this.sessions.get(sessionId); } async findActiveByUserAndAccount(adminUserId: string, accountId: string) { return [...this.sessions.values()].find( (item) => item.adminUserId === adminUserId && item.accountId === accountId && item.status !== 'revoked', ); } async update(sessionId: string, patch: any) { const current = this.sessions.get(sessionId); const next = { ...current, ...patch }; this.sessions.set(sessionId, next); return next; } } describe('NapcatWebuiGatewaySessionService', () => { it('revokes an older same-user same-account session when creating a new one', async () => { const store = new MemoryStore(); const service = new NapcatWebuiGatewaySessionService(store as any, { now: () => 1000, ttlMs: () => 60000, } as any); const first = await service.create({ accountId: 'account-1', adminUserId: 'admin-1', containerId: 'container-1', containerName: 'container', selfId: '1914728559', upstreamBaseUrl: 'http://127.0.0.1:6100', webuiToken: ['redacted', 'webui', 'token'].join('-'), }); const second = await service.create({ accountId: 'account-1', adminUserId: 'admin-1', containerId: 'container-1', containerName: 'container', selfId: '1914728559', upstreamBaseUrl: 'http://127.0.0.1:6100', webuiToken: ['redacted', 'webui', 'token'].join('-'), }); expect(first.sessionId).not.toBe(second.sessionId); expect(await store.find(first.sessionId)).toMatchObject({ status: 'revoked', }); expect(await store.find(second.sessionId)).toMatchObject({ status: 'created', }); }); it('extends active sessions on heartbeat and rejects revoked sessions', async () => { const store = new MemoryStore(); let now = 1000; const service = new NapcatWebuiGatewaySessionService(store as any, { now: () => now, ttlMs: () => 60000, } as any); const session = await service.create({ accountId: 'account-1', adminUserId: 'admin-1', containerId: 'container-1', containerName: 'container', selfId: '1914728559', upstreamBaseUrl: 'http://127.0.0.1:6100', webuiToken: ['redacted', 'webui', 'token'].join('-'), }); now = 5000; await service.markActive(session.sessionId); const heartbeat = await service.heartbeat(session.sessionId); expect(heartbeat).toMatchObject({ sessionId: session.sessionId, status: 'active', }); expect(heartbeat.expiresAt).toBe(65000); await service.revoke(session.sessionId); await expect(service.heartbeat(session.sessionId)).rejects.toThrow( 'Gateway session is not active', ); }); }); ``` - [ ] **Step 4: Run the test and verify RED** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/apps/napcat-webui-gateway/session-store.spec.ts --runInBand ``` Expected: FAIL because Gateway app types and service do not exist. - [ ] **Step 5: Create Gateway domain types** Create `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts`: ```ts export type NapcatWebuiGatewaySessionStatus = | 'active' | 'created' | 'expired' | 'failed' | 'revoked'; export interface NapcatWebuiGatewaySession { accountId: string; activeAt?: number; adminUserId: string; clientIp?: string; containerId: string; containerName: string; createdAt: number; expiresAt: number; lastSeenAt?: number; revokedAt?: number; selfId: string; sessionId: string; status: NapcatWebuiGatewaySessionStatus; upstreamBaseUrl: string; userAgent?: string; webuiToken: string; } export interface NapcatWebuiGatewaySessionStore { create(session: NapcatWebuiGatewaySession): Promise; find(sessionId: string): Promise; findActiveByUserAndAccount( adminUserId: string, accountId: string, ): Promise; update( sessionId: string, patch: Partial, ): Promise; } ``` - [ ] **Step 6: Implement session service** Create `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts` with `create`, `markActive`, `heartbeat`, `revoke`, `requireBootstrapSession`, and `requireProxySession`. Every method needs JSDoc. Use `crypto.randomUUID()` for `sessionId`. The `create()` implementation must: ```ts const older = await this.store.findActiveByUserAndAccount( input.adminUserId, input.accountId, ); if (older) { await this.store.update(older.sessionId, { revokedAt: now, status: 'revoked', }); } ``` 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** Create `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`. Inject Redis through `@nestjs-modules/ioredis`: ```ts import { Injectable } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import type Redis from 'ioredis'; @Injectable() export class NapcatWebuiGatewayRedisStore implements NapcatWebuiGatewaySessionStore { /** * Initializes the Redis-backed Gateway session store. * @param redis Shared Gateway Redis client managed by Nest RedisModule. */ constructor(@InjectRedis() private readonly redis: Redis) {} } ``` Use Redis `set(key, value, 'PX', ttlMs)`, `get`, and a secondary index key: ```text napcat:webui:session:{sessionId} napcat:webui:user-account:{adminUserId}:{accountId} ``` Create `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts`. Ticket keys: ```text napcat:webui:ticket:{ticket} ``` Ticket TTL must be 60 seconds or less. Redemption deletes the ticket key before returning the session id. - [ ] **Step 8: Add Gateway module and internal controller** Create `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts` and `presentation/internal-session.controller.ts`. The module must use the community Redis module rather than a custom Redis provider: ```ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisModule } from '@nestjs-modules/ioredis'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), RedisModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ type: 'single', url: config.get('NAPCAT_WEBUI_GATEWAY_REDIS_URL') || `redis://${config.get('NAPCAT_WEBUI_GATEWAY_REDIS_HOST') || '127.0.0.1'}:${config.get('NAPCAT_WEBUI_GATEWAY_REDIS_PORT') || 6379}`, }), }), ], }) export class NapcatWebuiGatewayModule {} ``` Internal controller paths: ```text POST /internal/sessions POST /internal/sessions/:sessionId/heartbeat POST /internal/sessions/:sessionId/revoke GET /internal/health ``` Check the `x-kt-gateway-secret` header against `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` before accepting mutating internal calls. - [ ] **Step 9: Add Gateway bootstrap** Create `src/apps/napcat-webui-gateway/main.ts`: ```ts import { NestFactory } from '@nestjs/core'; import { Logger } from 'nestjs-pino'; import { json, urlencoded } from 'express'; import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module'; /** * Starts the standalone NapCat WebUI Gateway process. */ async function bootstrap() { const app = await NestFactory.create(NapcatWebuiGatewayModule, { bufferLogs: true, }); app.useLogger(app.get(Logger)); app.use(json({ limit: '50mb' })); app.use(urlencoded({ extended: true, limit: '50mb' })); await app.listen(Number(process.env.NAPCAT_WEBUI_GATEWAY_PORT || 48086)); } bootstrap(); ``` - [ ] **Step 10: Run Gateway session tests and typecheck** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/apps/napcat-webui-gateway/session-store.spec.ts --runInBand pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck ``` Expected: tests PASS and typecheck PASS. - [ ] **Step 11: Commit Task 3** ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api add package.json pnpm-lock.yaml src/apps/napcat-webui-gateway test/apps/napcat-webui-gateway git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat WebUI Gateway会话服务" ``` --- ### Task 4: Add Gateway Credential Exchange and Proxy **Files:** - Create: `test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts` - Create: `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts` - Create: `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts` - Create: `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts` - Modify: `src/apps/napcat-webui-gateway/main.ts` - Modify: `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts` - [ ] **Step 1: Write proxy rewrite tests** Create `test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts`: ```ts import { buildGatewayCookiePathRewrite, rewriteNapcatLocationHeader, sanitizeGatewayProxyPath, } from '../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service'; describe('NapCat WebUI Gateway proxy rewriting', () => { it('rejects upstream URL injection and path traversal', () => { expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow( 'Invalid NapCat WebUI proxy path', ); expect(() => sanitizeGatewayProxyPath('../api/auth/login')).toThrow( 'Invalid NapCat WebUI proxy path', ); expect(sanitizeGatewayProxyPath('api/QQLogin/CheckLoginStatus')).toBe( '/api/QQLogin/CheckLoginStatus', ); }); it('rewrites redirects under the session proxy prefix', () => { expect( rewriteNapcatLocationHeader('/webui/login', { sessionId: 'session-1', }), ).toBe('/napcat-webui/session/session-1/webui/webui/login'); }); it('delegates cookie path rewriting to http-proxy-middleware options', () => { expect(buildGatewayCookiePathRewrite({ sessionId: 'session-1' })).toEqual({ '*': '/napcat-webui/session/session-1', }); }); }); ``` - [ ] **Step 2: Run proxy test and verify RED** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts --runInBand ``` Expected: FAIL because proxy helpers do not exist. - [ ] **Step 3: Implement credential client** Create `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts`. Use the same NapCat WebUI contract as `NapcatWebuiHttpClient`: hash `webuiToken + ".napcat"`, POST `/api/auth/login`, and cache Credential per session until session revoke/expire. Do not log `webuiToken`, hash, or Credential. - [ ] **Step 4: Implement proxy helpers** Create pure helper exports in `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`. Keep cookie rewriting as a `http-proxy-middleware` option instead of rewriting `Set-Cookie` by hand: ```ts export function sanitizeGatewayProxyPath(rawPath: string): string { const decoded = decodeURIComponent(rawPath || ''); if (/^https?:\/\//i.test(decoded) || decoded.includes('..')) { throw new Error('Invalid NapCat WebUI proxy path'); } return `/${decoded.replace(/^\/+/, '')}`; } export function rewriteNapcatLocationHeader( location: string, input: { sessionId: string }, ) { if (/^https?:\/\//i.test(location)) return location; return `/napcat-webui/session/${input.sessionId}/webui/${location.replace(/^\/+/, '')}`; } export function buildGatewayCookiePathRewrite(input: { sessionId: string }) { return { '*': `/napcat-webui/session/${input.sessionId}`, }; } ``` - [ ] **Step 5: Implement proxy service** Use `createProxyMiddleware` from `http-proxy-middleware` with: ```ts { changeOrigin: true, cookiePathRewrite: buildGatewayCookiePathRewrite({ sessionId }), on: { proxyReq: handleProxyReq, proxyReqWs: handleProxyReqWs, proxyRes: handleProxyRes, }, pathRewrite: (_path, req) => sanitizeGatewayProxyPath(req.params[0] || ''), secure: false, ws: true, selfHandleResponse: false, } ``` Before proxying: - resolve an active session by `sessionId` through `sessionService.requireProxySession(sessionId)`; - reject non-active, stale, terminal, expired, or missing sessions with 410; - redeem Credential through `NapcatWebuiCredentialClient`; - add `Authorization: Bearer ${credential}` upstream; - never forward API/Admin cookies upstream; - never allow the browser to override `target`. Do not mark sessions active from the proxy path; bootstrap activates the session before redirecting into the proxied WebUI. For WebSocket upgrade, do not tunnel frames through MQTT and do not hand-roll a WebSocket bridge. Expose a method on `NapcatWebuiProxyService` and bind HPM's upgrade handler from `main.ts`: ```ts /** * Binds NapCat WebUI WebSocket upgrades to the same proxy middleware. * @param server HTTP server created by Nest's Express adapter. */ bindWebSocketUpgrade(server: import('http').Server) { server.on('upgrade', (req, socket, head) => { if (!req.url?.startsWith('/napcat-webui/session/')) return; this.proxy.upgrade(req, socket, head); }); } ``` Modify `src/apps/napcat-webui-gateway/main.ts` after `app.listen()`: ```ts const server = app.getHttpServer(); app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(server); ``` - [ ] **Step 6: Implement public controller** Create `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`: ```text GET /napcat-webui/session/:sessionId/bootstrap ALL /napcat-webui/session/:sessionId/webui/* ``` Bootstrap must redeem `ticket`, validate the redeemed session through `sessionService.requireBootstrapSession(sessionId)`, call `sessionService.markActive(sessionId)`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`. Proxy route delegates to `NapcatWebuiProxyService`. The proxy service remains responsible for path sanitization, active-only session validation, Credential injection, HPM `cookiePathRewrite`, HPM HTTP proxying, and HPM WebSocket upgrade handling. - [ ] **Step 7: Run tests and typecheck** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/apps/napcat-webui-gateway/session-store.spec.ts test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts --runInBand pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck ``` Expected: tests PASS and typecheck PASS. - [ ] **Step 8: Commit Task 4** ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api add src/apps/napcat-webui-gateway test/apps/napcat-webui-gateway git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 代理NapCat WebUI流量" ``` --- ### Task 5: Wire Build, Docker, K8s, and API Gateway Env **Files:** - Create: `test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts` - Create: `dockerfile.gateway` - Modify: `Jenkinsfile` - Modify: `k8s/prod/api.yaml` - Modify: `README.md` - Modify: `API.md` - [ ] **Step 1: Write deployment structural tests** Create `test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts`: ```ts import { readFileSync } from 'fs'; import { resolve } from 'path'; const root = resolve(__dirname, '../../../..'); const read = (path: string) => readFileSync(resolve(root, path), 'utf8'); describe('NapCat WebUI Gateway deployment wiring', () => { it('has a dedicated production dockerfile entry', () => { const dockerfile = read('dockerfile.gateway'); expect(dockerfile).toContain('dist/apps/napcat-webui-gateway/main'); expect(dockerfile).toContain('EXPOSE 48086'); }); it('deploys Gateway as a separate K8s Deployment and Service', () => { const manifest = read('k8s/prod/api.yaml'); expect(manifest).toContain('name: kt-napcat-webui-gateway'); expect(manifest).toContain('containerPort: 48086'); expect(manifest).toContain('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET'); expect(manifest).toContain('NAPCAT_WEBUI_GATEWAY_REDIS_HOST'); }); it('builds and pushes a separate Gateway image in Jenkins', () => { const jenkinsfile = read('Jenkinsfile'); expect(jenkinsfile).toContain('GATEWAY_IMAGE_NAME'); expect(jenkinsfile).toContain('dockerfile.gateway'); expect(jenkinsfile).toContain('kt-napcat-webui-gateway'); }); }); ``` - [ ] **Step 2: Run deployment test and verify RED** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand ``` Expected: FAIL because deployment files are not wired yet. - [ ] **Step 3: Add `dockerfile.gateway`** Create `dockerfile.gateway` by mirroring `dockerfile`, changing: ```dockerfile ENV APP_PORT=48086 ENV LOG_APP_NAME=kt-napcat-webui-gateway EXPOSE 48086 CMD ["node", "dist/apps/napcat-webui-gateway/main"] ``` Keep font and production dependency installation consistent with the API image. - [ ] **Step 4: Update Jenkins** Add parameter: ```groovy string(name: 'GATEWAY_IMAGE_NAME', defaultValue: 'kt-napcat-webui-gateway', description: 'NapCat WebUI Gateway 镜像名称') ``` In `Prepare`, compute: ```groovy env.GATEWAY_DOCKER_IMAGE = registry ? "${registry}/${params.GATEWAY_IMAGE_NAME}:${env.IMAGE_TAG_FINAL}" : "${params.GATEWAY_IMAGE_NAME}:${env.IMAGE_TAG_FINAL}" env.GATEWAY_DOCKER_IMAGE_LATEST = registry ? "${registry}/${params.GATEWAY_IMAGE_NAME}:latest" : "${params.GATEWAY_IMAGE_NAME}:latest" ``` In Docker Build, after API image build: ```groovy docker build -f dockerfile.gateway -t ${env.GATEWAY_DOCKER_IMAGE} . if [ '${env.GATEWAY_DOCKER_IMAGE}' != '${env.GATEWAY_DOCKER_IMAGE_LATEST}' ]; then docker tag ${env.GATEWAY_DOCKER_IMAGE} ${env.GATEWAY_DOCKER_IMAGE_LATEST} fi ``` In Docker Push, push both Gateway tags. In K8s Deploy, set the Gateway deployment image: ```groovy kubectl ${kubeConfigArg} ${namespaceArg} set image deployment/kt-napcat-webui-gateway gateway=${env.GATEWAY_DOCKER_IMAGE} ``` Check rollout for both API and Gateway deployments. - [ ] **Step 5: Update K8s manifest** Add Gateway Deployment and Service in `k8s/prod/api.yaml`: ```yaml --- apiVersion: apps/v1 kind: Deployment metadata: name: kt-napcat-webui-gateway namespace: kt-prod labels: app: kt-napcat-webui-gateway spec: replicas: 1 revisionHistoryLimit: 3 selector: matchLabels: app: kt-napcat-webui-gateway template: metadata: labels: app: kt-napcat-webui-gateway spec: containers: - name: gateway image: k3d-kt-registry.localhost:5000/kt-napcat-webui-gateway:latest imagePullPolicy: IfNotPresent ports: - name: http containerPort: 48086 env: - name: NODE_ENV value: production - name: TZ value: Asia/Shanghai - name: NAPCAT_WEBUI_GATEWAY_PORT value: "48086" - name: NAPCAT_WEBUI_GATEWAY_REDIS_HOST value: kt-qqbot-plugin-redis - name: NAPCAT_WEBUI_GATEWAY_REDIS_PORT value: "6379" - name: NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL value: /napcat-webui envFrom: - secretRef: name: kt-template-online-api-env readinessProbe: httpGet: path: /internal/health port: 48086 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 --- apiVersion: v1 kind: Service metadata: name: kt-napcat-webui-gateway namespace: kt-prod labels: app: kt-napcat-webui-gateway spec: type: NodePort selector: app: kt-napcat-webui-gateway ports: - name: http port: 48086 targetPort: 48086 nodePort: 30086 ``` Add API env: ```yaml - name: NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL value: http://kt-napcat-webui-gateway:48086 - name: NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL value: /napcat-webui ``` Ensure `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` is read from the existing production env secret, not committed. - [ ] **Step 6: Update docs** Add to `README.md` and `API.md`: ```text NapCat WebUI Gateway: - API creates route-bound sessions through /qqbot/napcat/webui/session. - Gateway listens on 48086 and proxies /napcat-webui/session/:sessionId/*. - Required env: NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL, NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL, NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET, NAPCAT_WEBUI_GATEWAY_REDIS_HOST, NAPCAT_WEBUI_GATEWAY_REDIS_PORT. - Browser responses must not contain webuiToken, Credential, Docker host ports, or NAS SSH paths. ``` - [ ] **Step 7: Run deployment tests and build checks** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run build ``` Expected: tests PASS, typecheck PASS, build PASS, and `dist/apps/napcat-webui-gateway/main.js` exists. - [ ] **Step 8: Commit Task 5** ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api add dockerfile.gateway Jenkinsfile k8s/prod/api.yaml README.md API.md test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 部署NapCat WebUI Gateway" ``` --- ### Task 6: Add Admin API Client, Route, and Account Action **Files:** - Modify: `apps/web-antdv-next/src/api/qqbot/napcat.ts` - Modify: `apps/web-antdv-next/src/api/qqbot/napcat.spec.ts` - Modify: `apps/web-antdv-next/src/router/routes/modules/qqbot.ts` - Modify: `apps/web-antdv-next/src/views/qqbot/account/list.tsx` - Modify: `apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts` - [ ] **Step 1: Add failing Admin API tests** Extend `apps/web-antdv-next/src/api/qqbot/napcat.spec.ts` with: ```ts it('calls NapCat WebUI Gateway session endpoints', async () => { await createQqbotNapcatWebuiSession('account-1'); await heartbeatQqbotNapcatWebuiSession('session-1'); await revokeQqbotNapcatWebuiSession('session-1'); expect(postMock).toHaveBeenCalledWith('/qqbot/napcat/webui/session', { accountId: 'account-1', }); expect(postMock).toHaveBeenCalledWith( '/qqbot/napcat/webui/session/session-1/heartbeat', ); expect(postMock).toHaveBeenCalledWith( '/qqbot/napcat/webui/session/session-1/revoke', ); }); ``` Import the new functions at the top of the spec. - [ ] **Step 2: Add failing boundary test** Extend `apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts`: ```ts it('keeps WebUI route lifecycle outside the account list page', () => { const source = readAccountSource('list.tsx'); expect(source).toContain('QqBotAccountNapcatWebui'); expect(source).not.toContain('createQqbotNapcatWebuiSession'); expect(source).not.toContain('heartbeatQqbotNapcatWebuiSession'); expect(source).not.toContain('iframe'); }); ``` - [ ] **Step 3: Run Admin tests and verify RED** ```powershell pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts ``` Expected: FAIL because functions and route action are missing. - [ ] **Step 4: Add Admin API functions** Modify `apps/web-antdv-next/src/api/qqbot/napcat.ts`: ```ts export namespace QqbotNapcatApi { export interface WebuiGatewaySession { account: { id: string; name?: string; selfId: string }; container: { id: string; name: string; webuiStatus: string }; expiresAt: number; iframeUrl: string; sessionId: string; } } export function createQqbotNapcatWebuiSession(accountId: string) { return requestClient.post( '/qqbot/napcat/webui/session', { accountId }, ); } export function heartbeatQqbotNapcatWebuiSession(sessionId: string) { return requestClient.post & { status: 'active' }>( `/qqbot/napcat/webui/session/${sessionId}/heartbeat`, ); } export function revokeQqbotNapcatWebuiSession(sessionId: string) { return requestClient.post( `/qqbot/napcat/webui/session/${sessionId}/revoke`, ); } ``` - [ ] **Step 5: Add hidden Admin route** Modify `apps/web-antdv-next/src/router/routes/modules/qqbot.ts`: ```ts { component: () => import('#/views/qqbot/account/napcat-webui'), meta: { activePath: '/qqbot/account', hideInMenu: true, title: 'NapCat WebUI', }, name: 'QqBotAccountNapcatWebui', path: '/qqbot/account/:accountId/napcat-webui', } ``` - [ ] **Step 6: Add account row action** Modify `apps/web-antdv-next/src/views/qqbot/account/list.tsx`: ```ts { disabled: (row) => !row.napcat?.containerName || getWebuiStatus(row) === 'offline', key: 'napcatWebui', label: 'WebUI', onClick: openNapcatWebui, permissionCodes: ['QqBot:Account:WebUI'], } ``` Add: ```ts function openNapcatWebui(row: QqbotApi.Account) { void router.push({ name: 'QqBotAccountNapcatWebui', params: { accountId: row.id }, }); } ``` - [ ] **Step 7: Run Admin API and boundary tests** ```powershell pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts ``` Expected: tests PASS. - [ ] **Step 8: Commit Task 6** ```powershell git -C D:\MyFiles\KT\Vue\kt-template-admin add apps/web-antdv-next/src/api/qqbot/napcat.ts apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/router/routes/modules/qqbot.ts apps/web-antdv-next/src/views/qqbot/account/list.tsx apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "feat: 增加NapCat WebUI入口" ``` --- ### Task 7: Build Admin Route Page and Lifecycle Composable **Files:** - Create: `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.tsx` - Create: `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.scss` - Create: `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/useNapcatWebuiGatewaySession.ts` - Create: `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx` - [ ] **Step 1: Write route lifecycle tests** Create `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx`: ```ts import { flushPromises, mount } from '@vue/test-utils'; import { describe, expect, it, vi } from 'vitest'; import NapcatWebuiPage from './index'; const createSession = vi.fn(); const heartbeat = vi.fn(); const revoke = vi.fn(); vi.mock('#/api/qqbot/napcat', () => ({ createQqbotNapcatWebuiSession: createSession, heartbeatQqbotNapcatWebuiSession: heartbeat, revokeQqbotNapcatWebuiSession: revoke, })); vi.mock('vue-router', () => ({ useRoute: () => ({ params: { accountId: 'account-1' } }), useRouter: () => ({ push: vi.fn() }), })); describe('NapcatWebuiPage', () => { it('creates a Gateway session on mount and revokes on unmount', async () => { createSession.mockResolvedValue({ account: { id: 'account-1', selfId: '1914728559' }, container: { id: 'container-1', name: 'kt-qqbot-napcat-1', webuiStatus: 'online', }, expiresAt: Date.now() + 60000, iframeUrl: '/napcat-webui/session/session-1/bootstrap?ticket=ticket-1', sessionId: 'session-1', }); heartbeat.mockResolvedValue({ expiresAt: Date.now() + 60000, sessionId: 'session-1', status: 'active', }); const wrapper = mount(NapcatWebuiPage); await flushPromises(); expect(createSession).toHaveBeenCalledWith('account-1'); expect(wrapper.find('iframe').attributes('src')).toContain( '/napcat-webui/session/session-1/bootstrap', ); wrapper.unmount(); await flushPromises(); expect(revoke).toHaveBeenCalledWith('session-1'); }); }); ``` - [ ] **Step 2: Run route page test and verify RED** ```powershell pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx ``` Expected: FAIL because the page does not exist. - [ ] **Step 3: Implement lifecycle composable** Create `useNapcatWebuiGatewaySession.ts` with: ```ts import { useIntervalFn } from '@vueuse/core'; import { onBeforeUnmount, ref } from 'vue'; import { createQqbotNapcatWebuiSession, heartbeatQqbotNapcatWebuiSession, revokeQqbotNapcatWebuiSession, type QqbotNapcatApi, } from '#/api/qqbot/napcat'; export type NapcatWebuiSessionState = 'error' | 'idle' | 'loading' | 'ready' | 'revoked'; /** * Owns one route-bound NapCat WebUI Gateway session. * * @param accountId QQBot account id from the current route. * @returns Reactive session state and lifecycle commands for the WebUI page. */ export function useNapcatWebuiGatewaySession(accountId: string) { const errorMessage = ref(''); const session = ref(); const state = ref('idle'); const { pause: pauseHeartbeat, resume: resumeHeartbeat } = useIntervalFn( () => { const sessionId = session.value?.sessionId; if (!sessionId) return; void heartbeatQqbotNapcatWebuiSession(sessionId).catch(() => { errorMessage.value = 'NapCat WebUI 会话心跳失败,请重新打开'; state.value = 'error'; pauseHeartbeat(); }); }, 20_000, { immediate: false }, ); /** * Creates a Gateway session and starts the route-bound heartbeat. */ async function open() { state.value = 'loading'; errorMessage.value = ''; try { session.value = await createQqbotNapcatWebuiSession(accountId); state.value = 'ready'; resumeHeartbeat(); } catch (error: any) { errorMessage.value = error?.message || 'NapCat WebUI 会话创建失败'; state.value = 'error'; pauseHeartbeat(); } } /** * Revokes the current Gateway session and stops heartbeat traffic. */ async function revoke() { pauseHeartbeat(); const sessionId = session.value?.sessionId; if (!sessionId) return; await revokeQqbotNapcatWebuiSession(sessionId).catch(() => undefined); state.value = 'revoked'; } onBeforeUnmount(() => { void revoke(); }); return { errorMessage, open, revoke, session, state }; } ``` - [ ] **Step 4: Implement route page** Create `index.tsx` using `Page`, `Button`, `Alert`, `Spin`, and `Typography` from existing UI libraries. The iframe must only render when `state === 'ready'` and `session.iframeUrl` exists. Use one stable root element. Include top controls: - back to account list; - reopen session; - close session; - display selfId and container name. - [ ] **Step 5: Add SCSS** Create `index.scss`: ```scss .qqbot-napcat-webui-page { display: flex; flex-direction: column; height: var(--vben-content-height); min-height: 0; overflow: hidden; &__bar { align-items: center; border-bottom: 1px solid hsl(var(--border)); display: flex; flex: 0 0 auto; gap: 12px; justify-content: space-between; padding: 10px 12px; } &__frame-wrap { background: hsl(var(--background)); flex: 1 1 auto; min-height: 0; } &__frame { border: 0; display: block; height: 100%; width: 100%; } } ``` - [ ] **Step 6: Run Admin tests and typecheck** ```powershell pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next run typecheck ``` Expected: tests PASS and typecheck PASS. - [ ] **Step 7: Commit Task 7** ```powershell git -C D:\MyFiles\KT\Vue\kt-template-admin add apps/web-antdv-next/src/views/qqbot/account/napcat-webui git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "feat: 增加NapCat WebUI二级页面" ``` --- ### Task 8: Local End-to-End Smoke **Files:** - No planned source changes. Validation fixes must be applied to the failing files from Tasks 1-7 and committed with the related repo. - [ ] **Step 1: Confirm repo types and package managers** ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api status --short --branch Get-Content D:\MyFiles\KT\Node\kt-template-online-api\.node-version Get-Content D:\MyFiles\KT\Node\kt-template-online-api\package.json | Select-String '"packageManager"' git -C D:\MyFiles\KT\Vue\kt-template-admin status --short --branch Get-Content D:\MyFiles\KT\Vue\kt-template-admin\.node-version Get-Content D:\MyFiles\KT\Vue\kt-template-admin\package.json | Select-String '"packageManager"' ``` Expected: both repos are Git; API uses pnpm 9.15.9; Admin uses pnpm 10.28.2. - [ ] **Step 2: Run focused API validation** ```powershell pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts test/apps/napcat-webui-gateway/session-store.spec.ts test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run build ``` Expected: focused tests PASS, typecheck PASS, build PASS. - [ ] **Step 3: Run focused Admin validation** ```powershell pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next run typecheck ``` Expected: focused tests PASS and typecheck PASS. - [ ] **Step 4: Start local API and Gateway** Use existing local env conventions. Start API and Gateway in bounded terminals: ```powershell Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoLogo','-Command','cd D:\MyFiles\KT\Node\kt-template-online-api; pnpm run start:dev *> .kt-workspace\logs\api-webui-gateway-api.log' Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoLogo','-Command','cd D:\MyFiles\KT\Node\kt-template-online-api; pnpm run start:gateway:dev *> .kt-workspace\logs\api-webui-gateway-service.log' ``` Expected: API listens on `48085`; Gateway listens on `48086`. - [ ] **Step 5: Call the API session endpoint against local service** Use an existing local Admin token. If a token is not available, log into the local Admin UI and copy the token from the tracked local workflow. Then call: ```powershell curl.exe -sS -X POST "http://127.0.0.1:48085/qqbot/napcat/webui/session" -H "authorization: Bearer " -H "content-type: application/json" --data "{\"accountId\":\"\"}" ``` Expected: JSON contains `sessionId`, `iframeUrl`, `account`, and `container`, and does not contain `webuiToken`, `Credential`, `6100`, or Docker/NAS host paths. - [ ] **Step 6: Open the Admin route locally** Start Admin for the route smoke: ```powershell Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoLogo','-Command','cd D:\MyFiles\KT\Vue\kt-template-admin; pnpm -F @vben/web-antdv-next run dev *> .kt-workspace\logs\admin-webui-gateway.log' ``` Open: ```text http://127.0.0.1:5999/#/qqbot/account//napcat-webui ``` Expected: page renders the route console, creates a session, loads iframe shell, and revokes when navigating back to `/qqbot/account`. - [ ] **Step 7: Clean local processes** Stop Node processes started from these project paths: ```powershell Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*kt-template-online-api*start:dev*' -or $_.CommandLine -like '*kt-template-online-api*start:gateway:dev*' -or $_.CommandLine -like '*kt-template-admin*web-antdv-next*dev*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } ``` - [ ] **Step 8: Commit local validation fixes** If validation required fixes, commit the touched repo separately: ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api status --short git -C D:\MyFiles\KT\Vue\kt-template-admin status --short ``` Commit only files related to this feature. --- ### Task 9: Documentation, Review, Push, Deploy, and Online Closure **Files:** - Modify: `D:\MyFiles\KT\TASKS.md` - [ ] **Step 1: Update `TASKS.md`** Add a recent record with: ```text 范围:API Gateway service、Admin WebUI 二级页面、K8s/Jenkins Gateway 发布。 关键词:/qqbot/account/:accountId/napcat-webui、kt-napcat-webui-gateway、route-bound session、heartbeat/revoke、完整 WebUI 操作、token/Credential 不下发浏览器。 验证:列出 API focused tests、Admin focused tests、typecheck/build、本地 iframe smoke、线上 Gateway health 与账号 1914728559 iframe smoke。 ``` - [ ] **Step 2: Run final local gates** ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api diff --check git -C D:\MyFiles\KT\Vue\kt-template-admin diff --check pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --project api --changed-files pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --project admin --changed-files pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run ``` Expected: diff checks PASS, global-review findings=[], cleanup dry-run deleted=[]. - [ ] **Step 3: Commit remaining docs** ```powershell git -C D:\MyFiles\KT add TASKS.md git -C D:\MyFiles\KT commit -m "docs: 记录NapCat WebUI Gateway实施" ``` - [ ] **Step 4: Push when explicitly requested** Push API and Admin only after user asks: ```powershell git -C D:\MyFiles\KT\Node\kt-template-online-api push origin main git -C D:\MyFiles\KT\Vue\kt-template-admin push origin main ``` - [ ] **Step 5: Observe Jenkins/K8s** Use deploy observation for API: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --commit --execute ``` Manually verify Gateway because the current deploy-observation script targets API deployment by default: ```powershell $script = @' set -eu KUBECONFIG_PATH='/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml' kubectl --kubeconfig "$KUBECONFIG_PATH" -n kt-prod get deployment kt-napcat-webui-gateway -o wide kubectl --kubeconfig "$KUBECONFIG_PATH" -n kt-prod get pod -l app=kt-napcat-webui-gateway kubectl --kubeconfig "$KUBECONFIG_PATH" -n kt-prod logs -l app=kt-napcat-webui-gateway --tail=80 '@ $script | ssh nas "tr -d '\015' | bash -s" ``` Expected: API and Gateway deployments Running/Ready, restartCount 0, Gateway image tag matches the Jenkins build. - [ ] **Step 6: Configure Caddy/Admin route if not already routed** On Tencent Cloud Caddy, follow the stabilized Caddy rule: backup `/opt/nas-gateway/caddy/Caddyfile`, add `/napcat-webui/*` reverse proxy to the Gateway NodePort or service route, run `caddy validate`, reload, then verify: ```powershell curl.exe -I https://admin.kwitsukasa.top/napcat-webui/ ``` Expected: public route reaches Gateway and does not expose upstream host details. - [ ] **Step 7: Online functional smoke** Open: ```text https://admin.kwitsukasa.top/#/qqbot/account ``` For account `1914728559`: 1. Click `WebUI`. 2. Confirm route becomes `/#/qqbot/account//napcat-webui`. 3. Confirm iframe loads the original NapCat WebUI shell. 4. Open a safe WebUI page such as login status or settings. 5. Confirm browser URL does not contain `webuiToken`, `Credential`, `6100`, Docker container IP, NAS host path, or SSH route. 6. Navigate back to account list. 7. Verify Gateway logs or audit table contain revoke or heartbeat-timeout cleanup. - [ ] **Step 8: Final report** Report: - API commit(s), Admin commit(s), root TASKS commit. - Tests and typecheck/build evidence. - Jenkins/K8s evidence. - Online smoke evidence. - Any remaining blocker with exact next command. --- ## Self-Review - Spec coverage: covered Admin row action, second-level route, route-bound session lifecycle, independent Gateway process, full WebUI operation, no token/credential/browser leak, Redis session state, MySQL audit, K8s/Jenkins deployment, and online smoke. - Placeholder scan: no forbidden placeholder words or cross-task shorthand remain in the plan body. - Type consistency: all plan tasks use `QqBot:Account:WebUI`, `/qqbot/napcat/webui/session`, `/napcat-webui/session/:sessionId`, `kt-napcat-webui-gateway`, `48086`, and `QqbotNapcatWebuiGatewayService`. ## Execution Handoff Plan complete and saved to `docs/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md`. Two execution options: 1. **Subagent-Driven (recommended)** - Dispatch a fresh subagent per task, review between tasks, fast iteration. 2. **Inline Execution** - Execute tasks in this session using the KT-local execution loop, batch execution with checkpoints. Which approach?