66 KiB
QQBot NapCat WebUI Gateway Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
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/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.md - Chinese spec:
docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.zh-CN.md http-proxy-middlewaresupports Express proxy middleware and WebSocket upgrades throughws: trueand upgrade handling: https://github.com/chimurai/http-proxy-middlewarehttp-proxy-middlewareWebSocket recipe documentsws: true, manualserver.on('upgrade', proxy.upgrade), multiple targets, and path rewriting: https://github.com/chimurai/http-proxy-middleware/blob/master/recipes/websocket.md@nestjs-modules/ioredisprovides NestRedisModule.forRootand@InjectRedis()overioredis: https://github.com/nest-modules/ioredisconnect-redisis 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: https://github.com/tj/connect-redis- VueUse
useIntervalFnwrapssetIntervalwith pause/resume controls and is already available in Admin: https://vueuse.org/shared/useintervalfn/
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.jsonandpnpm-lock.yaml: add@nestjs-modules/ioredis,ioredis, andhttp-proxy-middleware, plus gateway start scripts. - Create
src/apps/napcat-webui-gateway/main.ts: standalone Nest bootstrap on port48086. - 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.sqlandsql/refactor-v3/01-seed-core.sql: addQqBot:Account:WebUIhidden 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 fordist/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.mdandAPI.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:
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:
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:
(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:
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:
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
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:
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
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:
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:
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<string, unknown>;
@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:
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<CreateGatewaySessionResult> {
// 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:
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:
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:
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
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
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:
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:
{
"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:
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<string, any>();
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
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:
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<NapcatWebuiGatewaySession>;
find(sessionId: string): Promise<NapcatWebuiGatewaySession | undefined>;
findActiveByUserAndAccount(
adminUserId: string,
accountId: string,
): Promise<NapcatWebuiGatewaySession | undefined>;
update(
sessionId: string,
patch: Partial<NapcatWebuiGatewaySession>,
): Promise<NapcatWebuiGatewaySession>;
}
- Step 6: Implement session service
Create src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts with create, markActive, heartbeat, revoke, and requireProxySession. Every method needs JSDoc. Use crypto.randomUUID() for sessionId.
The create() implementation must:
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 reject revoked, expired, failed, and missing 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:
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:
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:
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:
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<string>('NAPCAT_WEBUI_GATEWAY_REDIS_URL') ||
`redis://${config.get<string>('NAPCAT_WEBUI_GATEWAY_REDIS_HOST') || '127.0.0.1'}:${config.get<number>('NAPCAT_WEBUI_GATEWAY_REDIS_PORT') || 6379}`,
}),
}),
],
})
export class NapcatWebuiGatewayModule {}
Internal controller paths:
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:
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
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
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:
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
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:
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:
{
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 session by
sessionId; - reject non-active/non-created 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.
On first successful upstream response, call sessionService.markActive(sessionId).
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:
/**
* 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():
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:
GET /napcat-webui/session/:sessionId/bootstrap
ALL /napcat-webui/session/:sessionId/webui/*
Bootstrap must redeem ticket, 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, session validation, Credential injection, HPM cookiePathRewrite, HPM HTTP proxying, and HPM WebSocket upgrade handling.
- Step 7: Run tests and typecheck
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
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:
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
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:
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:
string(name: 'GATEWAY_IMAGE_NAME', defaultValue: 'kt-napcat-webui-gateway', description: 'NapCat WebUI Gateway 镜像名称')
In Prepare, compute:
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:
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:
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:
---
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:
- 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:
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
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
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:
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:
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
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:
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<QqbotNapcatApi.WebuiGatewaySession>(
'/qqbot/napcat/webui/session',
{ accountId },
);
}
export function heartbeatQqbotNapcatWebuiSession(sessionId: string) {
return requestClient.post<Pick<QqbotNapcatApi.WebuiGatewaySession, 'expiresAt' | 'sessionId'> & { status: 'active' }>(
`/qqbot/napcat/webui/session/${sessionId}/heartbeat`,
);
}
export function revokeQqbotNapcatWebuiSession(sessionId: string) {
return requestClient.post<boolean>(
`/qqbot/napcat/webui/session/${sessionId}/revoke`,
);
}
- Step 5: Add hidden Admin route
Modify apps/web-antdv-next/src/router/routes/modules/qqbot.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:
{
disabled: (row) => !row.napcat?.containerName || getWebuiStatus(row) === 'offline',
key: 'napcatWebui',
label: 'WebUI',
onClick: openNapcatWebui,
permissionCodes: ['QqBot:Account:WebUI'],
}
Add:
function openNapcatWebui(row: QqbotApi.Account) {
void router.push({
name: 'QqBotAccountNapcatWebui',
params: { accountId: row.id },
});
}
- Step 7: Run Admin API and boundary tests
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
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:
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
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:
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<QqbotNapcatApi.WebuiGatewaySession>();
const state = ref<NapcatWebuiSessionState>('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:
.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
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
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
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
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
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:
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:
curl.exe -sS -X POST "http://127.0.0.1:48085/qqbot/napcat/webui/session" -H "authorization: Bearer <local-admin-token>" -H "content-type: application/json" --data "{\"accountId\":\"<local-account-id>\"}"
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:
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:
http://127.0.0.1:5999/#/qqbot/account/<local-account-id>/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:
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:
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:
范围: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
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 <comma-separated-api-files>
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --project admin --changed-files <comma-separated-admin-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
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:
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:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --commit <api-commit> --execute
Manually verify Gateway because the current deploy-observation script targets API deployment by default:
$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:
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:
https://admin.kwitsukasa.top/#/qqbot/account
For account 1914728559:
- Click
WebUI. - Confirm route becomes
/#/qqbot/account/<account-id>/napcat-webui. - Confirm iframe loads the original NapCat WebUI shell.
- Open a safe WebUI page such as login status or settings.
- Confirm browser URL does not contain
webuiToken,Credential,6100, Docker container IP, NAS host path, or SSH route. - Navigate back to account list.
- 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, andQqbotNapcatWebuiGatewayService.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md.
Two execution options:
- Subagent-Driven (recommended) - Dispatch a fresh subagent per task, review between tasks, fast iteration.
- Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?