From 06eac5a01d446c6beaeb43014b77ffa7f07c1d53 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 11:39:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0NapCat=20WebUI?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/qqbot-init.sql | 18 + sql/refactor-v3/00-full-schema.sql | 17 + .../qqbot-napcat-container.service.ts | 9 + .../infrastructure/persistence/index.ts | 4 + .../qqbot/napcat/qqbot-napcat.module.ts | 10 + .../qqbot-napcat-webui-gateway.service.ts | 250 ++++++++++++ .../qqbot-napcat-webui-gateway.controller.ts | 85 ++++ .../qqbot-napcat-webui-gateway.dto.ts | 49 +++ .../napcat-webui-gateway-audit.entity.ts | 55 +++ .../qqbot-napcat-webui-gateway.client.ts | 157 ++++++++ .../api-session.service.spec.ts | 374 ++++++++++++++++++ 11 files changed, 1028 insertions(+) create mode 100644 src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts create mode 100644 src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts create mode 100644 src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts create mode 100644 src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts create mode 100644 src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts create mode 100644 test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 20a1859..10f7d9d 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -255,6 +255,24 @@ CREATE TABLE IF NOT EXISTS `napcat_risk_mode` ( KEY `idx_napcat_risk_mode_mode` (`risk_mode`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `qqbot_napcat_webui_gateway_audit` ( + `id` bigint NOT NULL, + `session_id` varchar(64) NOT NULL, + `admin_user_id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `self_id` varchar(32) NOT NULL, + `container_id` bigint NOT NULL, + `event_type` varchar(64) NOT NULL, + `client_ip` varchar(128) DEFAULT NULL, + `user_agent` varchar(512) DEFAULT NULL, + `detail_json` json DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_napcat_webui_gateway_audit_session` (`session_id`), + KEY `idx_napcat_webui_gateway_audit_account_event` (`account_id`, `event_type`), + KEY `idx_napcat_webui_gateway_audit_admin_time` (`admin_user_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `qqbot_config` ( `id` bigint NOT NULL, `config_key` varchar(120) NOT NULL, diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index f4acf30..69cf956 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -1125,3 +1125,20 @@ CREATE TABLE IF NOT EXISTS napcat_risk_mode ( UNIQUE KEY uk_napcat_risk_mode_account (account_id), KEY idx_napcat_risk_mode_mode (risk_mode) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qqbot_napcat_webui_gateway_audit ( + id BIGINT NOT NULL PRIMARY KEY, + session_id VARCHAR(64) NOT NULL, + admin_user_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + self_id VARCHAR(32) NOT NULL, + container_id BIGINT NOT NULL, + event_type VARCHAR(64) NOT NULL, + client_ip VARCHAR(128) NULL, + user_agent VARCHAR(512) NULL, + detail_json JSON NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_napcat_webui_gateway_audit_session (session_id), + KEY idx_napcat_webui_gateway_audit_account_event (account_id, event_type), + KEY idx_napcat_webui_gateway_audit_admin_time (admin_user_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index 432fac0..67db6c9 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -1205,6 +1205,15 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true return container ? this.toRuntime(container) : null; } + /** + * Finds the primary bound NapCat container runtime for an account. + * @param accountId - QQBot account id whose primary NapCat binding should be resolved. + * @returns Primary runtime with WebUI token selected, or null when no active binding exists. + */ + async findPrimaryContainerByAccountId(accountId: string) { + return this.getPrimaryRuntime(accountId); + } + /** * 查询 NapCat 登录运行态数据。 * @param containerId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。 diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/index.ts b/src/modules/qqbot/napcat/infrastructure/persistence/index.ts index bedba2d..1aecd4c 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/index.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/index.ts @@ -9,6 +9,7 @@ import { NapcatRiskMode } from './napcat-risk-mode.entity'; import { NapcatRuntimeCleanup } from './napcat-runtime-cleanup.entity'; import { NapcatRuntimeProfile } from './napcat-runtime-profile.entity'; import { NapcatSessionBehaviorProfile } from './napcat-session-behavior-profile.entity'; +import { NapcatWebuiGatewayAudit } from '../../webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity'; export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = { tables: [ @@ -23,6 +24,7 @@ export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = { 'napcat_session_behavior_profile', 'napcat_login_event', 'napcat_risk_mode', + 'qqbot_napcat_webui_gateway_audit', ], } as const; @@ -38,6 +40,7 @@ export const NAPCAT_RUNTIME_ENTITIES = [ NapcatSessionBehaviorProfile, NapcatLoginEvent, NapcatRiskMode, + NapcatWebuiGatewayAudit, ]; export { @@ -46,4 +49,5 @@ export { NapcatRiskMode, NapcatRuntimeProfile, NapcatSessionBehaviorProfile, + NapcatWebuiGatewayAudit, }; diff --git a/src/modules/qqbot/napcat/qqbot-napcat.module.ts b/src/modules/qqbot/napcat/qqbot-napcat.module.ts index 3310737..51f9bfa 100644 --- a/src/modules/qqbot/napcat/qqbot-napcat.module.ts +++ b/src/modules/qqbot/napcat/qqbot-napcat.module.ts @@ -13,6 +13,12 @@ import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtim import { NapcatSessionBehaviorService } from './application/runtime/napcat-session-behavior.service'; import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller'; import { QqbotNapcatRuntimeController } from './contract/qqbot-napcat-runtime.controller'; +import { + NapcatWebuiGatewayAuditService, + QqbotNapcatWebuiGatewayService, +} from './webui-gateway/application/qqbot-napcat-webui-gateway.service'; +import { QqbotNapcatWebuiGatewayController } from './webui-gateway/contract/qqbot-napcat-webui-gateway.controller'; +import { QqbotNapcatWebuiGatewayClient } from './webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client'; import { NapcatRuntimeProfileInspectionScriptService } from './infrastructure/integration/container/napcat-runtime-profile-inspection-script.service'; import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service'; import { NapcatDeviceIdentityService } from './infrastructure/integration/device/napcat-device-identity.service'; @@ -24,6 +30,7 @@ export const QQBOT_NAPCAT_ENTITIES = [...NAPCAT_RUNTIME_ENTITIES]; export const QQBOT_NAPCAT_CONTROLLERS = [ QqbotNapcatLoginController, QqbotNapcatRuntimeController, + QqbotNapcatWebuiGatewayController, ]; export const QQBOT_NAPCAT_PROVIDERS = [ @@ -34,8 +41,11 @@ export const QQBOT_NAPCAT_PROVIDERS = [ NapcatRuntimeProfileInspectionScriptService, NapcatRuntimeProfileService, NapcatSessionBehaviorService, + NapcatWebuiGatewayAuditService, QqbotNapcatAccountRuntimeService, QqbotNapcatContainerService, + QqbotNapcatWebuiGatewayClient, + QqbotNapcatWebuiGatewayService, QqbotNapcatLoginService, QqbotNapcatWatchdogService, { diff --git a/src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts b/src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts new file mode 100644 index 0000000..68ee42f --- /dev/null +++ b/src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts @@ -0,0 +1,250 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; +import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; +import type { + QqbotNapcatRuntime, + QqbotNapcatWebuiStatus, +} from '@/modules/qqbot/core/contract/qqbot.types'; +import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service'; +import { NapcatWebuiGatewayAudit } from '../infrastructure/persistence/napcat-webui-gateway-audit.entity'; +import { + QqbotNapcatWebuiGatewayClient, + type QqbotNapcatWebuiGatewayLifecycleResult, +} from '../infrastructure/qqbot-napcat-webui-gateway.client'; +import type { QqbotNapcatWebuiSessionResponseDto } from '../contract/qqbot-napcat-webui-gateway.dto'; + +const SENSITIVE_DETAIL_KEY_PATTERN = + /^(baseUrl|captcha|captchaTicket|credential|dockerIp|headers|internalSecret|nasPath|password|qrPayload|qrcode|rawHeaders|secret|targetBaseUrl|ticket|token|upstreamBaseUrl|webuiPort|webuiToken)$/i; + +export type QqbotNapcatWebuiGatewaySessionCreateInput = { + accountId: string; + adminUserId: string; + clientIp?: string | null; + userAgent?: string | null; +}; + +export type NapcatWebuiGatewayAuditRecordInput = { + accountId: string; + adminUserId: string; + clientIp?: string | null; + containerId: string; + detailJson?: null | Record; + eventType: string; + selfId: string; + sessionId: string; + userAgent?: string | null; +}; + +@Injectable() +export class NapcatWebuiGatewayAuditService { + /** + * Creates the audit recorder for browser Gateway session lifecycle evidence. + * @param auditRepository - TypeORM repository for sanitized WebUI Gateway audit rows. + */ + constructor( + @InjectRepository(NapcatWebuiGatewayAudit) + private readonly auditRepository: Repository, + ) {} + + /** + * Persists one sanitized Gateway audit event. + * @param input - Event identity, actor, client evidence, and already-safe detail fields. + * @returns Saved audit entity. + */ + async record(input: NapcatWebuiGatewayAuditRecordInput) { + const entity = this.auditRepository.create({ + accountId: input.accountId, + adminUserId: input.adminUserId, + clientIp: this.toNullableText(input.clientIp, 128), + containerId: input.containerId, + detailJson: this.sanitizeDetail(input.detailJson), + eventType: input.eventType, + selfId: input.selfId, + sessionId: input.sessionId, + userAgent: this.toNullableText(input.userAgent, 512), + }); + + return this.auditRepository.save(entity); + } + + /** + * Removes known secret-bearing keys from nested audit detail objects. + * @param detail - Candidate detail payload supplied by the application service. + * @returns Sanitized detail object or null when no detail is supplied. + */ + private sanitizeDetail(detail?: null | Record) { + if (!detail) return null; + return this.sanitizeValue(detail) as Record; + } + + /** + * Recursively sanitizes arrays and objects without preserving sensitive keys. + * @param value - Arbitrary audit detail value. + * @returns Value safe to serialize into the audit table. + */ + private sanitizeValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.sanitizeValue(item)); + } + if (!value || typeof value !== 'object') return value; + + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => !SENSITIVE_DETAIL_KEY_PATTERN.test(key)) + .map(([key, item]) => [key, this.sanitizeValue(item)]), + ); + } + + /** + * Converts optional client evidence into a bounded nullable column value. + * @param value - Raw IP or user-agent value from the request. + * @param limit - Database column length limit. + * @returns Trimmed string or null for empty input. + */ + private toNullableText(value: null | string | undefined, limit: number) { + const text = String(value || '').trim(); + return text ? text.slice(0, limit) : null; + } +} + +@Injectable() +export class QqbotNapcatWebuiGatewayService { + /** + * Creates the Admin-facing WebUI Gateway application service. + * @param accountService - QQBot account reader used to validate and serialize account identity. + * @param containerService - NapCat runtime resolver that supplies server-only WebUI target data. + * @param gatewayClient - Internal Gateway client used for session lifecycle requests. + * @param auditService - Sanitized audit recorder for Admin session events. + */ + constructor( + private readonly accountService: QqbotAccountService, + private readonly containerService: QqbotNapcatContainerService, + private readonly gatewayClient: QqbotNapcatWebuiGatewayClient, + private readonly auditService: NapcatWebuiGatewayAuditService, + ) {} + + /** + * Creates a browser-safe WebUI Gateway session for one QQBot account. + * @param input - Admin actor, client evidence, and target QQBot account id. + * @returns Browser-safe session response without WebUI token, upstream URL, or port. + */ + async createSession( + input: QqbotNapcatWebuiGatewaySessionCreateInput, + ): Promise { + const account = await this.accountService.findById(input.accountId); + if (!account) { + throwVbenError('QQBot 账号不存在'); + } + + const runtime = await this.containerService.findPrimaryContainerByAccountId( + account.id, + ); + if (!runtime?.id) { + throwVbenError('账号未绑定 NapCat 容器'); + } + if (runtime.sourceContainerOnline !== true) { + throwVbenError('NapCat WebUI 不在线'); + } + + const target = this.toGatewayTarget(runtime); + const gatewaySession = await this.gatewayClient.createSession({ + accountId: account.id, + accountName: account.name, + containerId: runtime.id, + containerName: runtime.name, + selfId: account.selfId, + ...target, + }); + const webuiStatus = this.toWebuiStatus(runtime); + + await this.auditService.record({ + accountId: account.id, + adminUserId: input.adminUserId, + clientIp: input.clientIp, + containerId: runtime.id, + detailJson: { + accountName: account.name, + containerName: runtime.name, + iframeUrl: gatewaySession.iframeUrl, + webuiStatus, + }, + eventType: 'session.create', + selfId: account.selfId, + sessionId: gatewaySession.sessionId, + userAgent: input.userAgent, + }); + + return { + account: { + id: account.id, + name: account.name, + selfId: account.selfId, + }, + container: { + id: runtime.id, + name: runtime.name, + webuiStatus, + }, + expiresAt: gatewaySession.expiresAt, + iframeUrl: gatewaySession.iframeUrl, + sessionId: gatewaySession.sessionId, + }; + } + + /** + * Forwards a Gateway heartbeat for an existing Admin WebUI session. + * @param sessionId - Gateway session id returned by `createSession()`. + * @returns Gateway lifecycle response. + */ + heartbeat( + sessionId: string, + ): Promise { + return this.gatewayClient.heartbeat(sessionId); + } + + /** + * Revokes an existing Admin WebUI Gateway session. + * @param sessionId - Gateway session id returned by `createSession()`. + * @returns Gateway lifecycle response. + */ + revoke(sessionId: string): Promise { + return this.gatewayClient.revoke(sessionId); + } + + /** + * Converts private NapCat runtime fields into the Gateway client payload. + * @param runtime - Primary NapCat runtime containing upstream URL, port, and WebUI token. + * @returns Internal-only Gateway target metadata. + */ + private toGatewayTarget(runtime: QqbotNapcatRuntime) { + const targetBaseUrl = String(runtime.baseUrl || '').trim(); + const webuiToken = String(runtime.webuiToken || '').trim(); + const webuiPort = Number(runtime.webuiPort); + + if (!targetBaseUrl || !webuiToken || !Number.isFinite(webuiPort)) { + throwVbenError('NapCat WebUI 配置不完整'); + } + if (webuiPort <= 0) { + throwVbenError('NapCat WebUI 配置不完整'); + } + + return { + targetBaseUrl, + webuiPort, + webuiToken, + }; + } + + /** + * Maps runtime evidence to the browser-safe WebUI status field. + * @param runtime - Primary NapCat runtime with container online evidence. + * @returns Browser-safe WebUI status string. + */ + private toWebuiStatus( + runtime: QqbotNapcatRuntime, + ): QqbotNapcatWebuiStatus { + return runtime.sourceContainerOnline ? 'online' : 'offline'; + } +} diff --git a/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts b/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts new file mode 100644 index 0000000..c9605d2 --- /dev/null +++ b/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts @@ -0,0 +1,85 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentAdminUser, vbenSuccess } from '@/common'; +import type { AdminRequest } from '@/modules/admin/contract/admin.types'; +import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; +import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity'; +import { QqbotNapcatWebuiGatewayService } from '../application/qqbot-napcat-webui-gateway.service'; +import { + QqbotNapcatWebuiSessionCreateDto, + QqbotNapcatWebuiSessionResponseDto, +} from './qqbot-napcat-webui-gateway.dto'; + +@ApiTags('QQBot - NapCat WebUI Gateway') +@Controller('qqbot/napcat/webui') +@UseGuards(JwtAuthGuard) +export class QqbotNapcatWebuiGatewayController { + /** + * Creates the Admin-authenticated WebUI Gateway controller. + * @param gatewayService - Application service that creates, heartbeats, and revokes Gateway sessions. + */ + constructor( + private readonly gatewayService: QqbotNapcatWebuiGatewayService, + ) {} + + /** + * Creates a browser-safe Gateway session for an account-bound NapCat WebUI. + * @param body - Request body containing the QQBot account id. + * @param user - Authenticated Admin user from JwtAuthGuard. + * @param req - Express request used only for IP and user-agent audit evidence. + * @returns Vben response containing safe session metadata for the Admin page. + */ + @Post('session') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '创建 NapCat WebUI Gateway 会话' }) + @ApiOkResponse({ type: QqbotNapcatWebuiSessionResponseDto }) + async createSession( + @Body() body: QqbotNapcatWebuiSessionCreateDto, + @CurrentAdminUser() user: AdminUser, + @Req() req: AdminRequest, + ) { + const userAgent = req.headers['user-agent']; + + return vbenSuccess( + await this.gatewayService.createSession({ + accountId: body.accountId, + adminUserId: user.id, + clientIp: req.ip, + userAgent: Array.isArray(userAgent) ? userAgent.join(', ') : userAgent, + }), + ); + } + + /** + * Refreshes the Gateway heartbeat for one active WebUI session. + * @param sessionId - Gateway session id returned by the create-session endpoint. + * @returns Vben response containing Gateway lifecycle state. + */ + @Post('session/:sessionId/heartbeat') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '刷新 NapCat WebUI Gateway 会话心跳' }) + async heartbeat(@Param('sessionId') sessionId: string) { + return vbenSuccess(await this.gatewayService.heartbeat(sessionId)); + } + + /** + * Revokes one active WebUI Gateway session. + * @param sessionId - Gateway session id returned by the create-session endpoint. + * @returns Vben response containing Gateway lifecycle state. + */ + @Post('session/:sessionId/revoke') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '撤销 NapCat WebUI Gateway 会话' }) + async revoke(@Param('sessionId') sessionId: string) { + return vbenSuccess(await this.gatewayService.revoke(sessionId)); + } +} diff --git a/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts b/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts new file mode 100644 index 0000000..59bb03d --- /dev/null +++ b/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty } from '@nestjs/swagger'; +import type { QqbotNapcatWebuiStatus } from '@/modules/qqbot/core/contract/qqbot.types'; + +export class QqbotNapcatWebuiSessionCreateDto { + @ApiProperty({ description: 'QQBot account id bound to the NapCat WebUI.' }) + accountId: string; +} + +export class QqbotNapcatWebuiSessionAccountDto { + @ApiProperty({ description: 'QQBot account id.' }) + id: string; + + @ApiProperty({ description: 'QQBot account display name.' }) + name: string; + + @ApiProperty({ description: 'QQ self id for the account.' }) + selfId: string; +} + +export class QqbotNapcatWebuiSessionContainerDto { + @ApiProperty({ description: 'NapCat container id.' }) + id: string; + + @ApiProperty({ description: 'NapCat container name.' }) + name: string; + + @ApiProperty({ + description: 'Browser-safe WebUI availability status.', + enum: ['offline', 'online', 'unknown'], + }) + webuiStatus: QqbotNapcatWebuiStatus; +} + +export class QqbotNapcatWebuiSessionResponseDto { + @ApiProperty({ type: QqbotNapcatWebuiSessionAccountDto }) + account: QqbotNapcatWebuiSessionAccountDto; + + @ApiProperty({ type: QqbotNapcatWebuiSessionContainerDto }) + container: QqbotNapcatWebuiSessionContainerDto; + + @ApiProperty({ description: 'Gateway session expiry time in ISO format.' }) + expiresAt: string; + + @ApiProperty({ description: 'Browser-safe iframe URL served by Gateway.' }) + iframeUrl: string; + + @ApiProperty({ description: 'Gateway session id used for lifecycle calls.' }) + sessionId: string; +} diff --git a/src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts b/src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts new file mode 100644 index 0000000..f9ec628 --- /dev/null +++ b/src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts @@ -0,0 +1,55 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime } from '@/common'; + +@Entity('qqbot_napcat_webui_gateway_audit') +@Index('idx_napcat_webui_gateway_audit_session', ['sessionId']) +@Index('idx_napcat_webui_gateway_audit_account_event', [ + 'accountId', + 'eventType', +]) +@Index('idx_napcat_webui_gateway_audit_admin_time', [ + 'adminUserId', + 'createTime', +]) +export class NapcatWebuiGatewayAudit { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 64, name: 'session_id', type: 'varchar' }) + sessionId: string; + + @Column({ name: 'admin_user_id', type: 'bigint' }) + adminUserId: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ length: 32, name: 'self_id' }) + selfId: string; + + @Column({ name: 'container_id', type: 'bigint' }) + containerId: string; + + @Column({ length: 64, name: 'event_type' }) + eventType: string; + + @Column({ default: null, length: 128, name: 'client_ip', nullable: true }) + clientIp: null | string; + + @Column({ default: null, length: 512, name: 'user_agent', nullable: true }) + userAgent: null | string; + + @Column({ default: null, name: 'detail_json', nullable: true, type: 'json' }) + detailJson: null | Record; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + /** + * Assigns a Snowflake id before persisting an Admin WebUI gateway audit row. + */ + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts b/src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts new file mode 100644 index 0000000..7bc0781 --- /dev/null +++ b/src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts @@ -0,0 +1,157 @@ +import axios, { type AxiosRequestConfig } from 'axios'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { throwVbenError } from '@/common'; + +const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:48086'; +const DEFAULT_GATEWAY_TIMEOUT_MS = 5000; + +export type QqbotNapcatWebuiGatewayCreateSessionRequest = { + accountId: string; + accountName: string; + containerId: string; + containerName: string; + selfId: string; + targetBaseUrl: string; + webuiPort: number; + webuiToken: string; +}; + +export type QqbotNapcatWebuiGatewaySessionResult = { + expiresAt: string; + iframeUrl: string; + sessionId: string; +}; + +export type QqbotNapcatWebuiGatewayLifecycleResult = Record; + +type GatewayResponseBody = T | { data: T }; + +@Injectable() +export class QqbotNapcatWebuiGatewayClient { + /** + * Creates the internal Gateway client backed by bounded axios requests. + * @param configService - Nest config source for Gateway base URL, internal secret, and timeout. + */ + constructor(private readonly configService: ConfigService) {} + + /** + * Creates a proxied NapCat WebUI session through the internal Gateway. + * @param input - Server-only target metadata, including WebUI token and upstream endpoint. + * @returns Browser-safe Gateway session metadata. + */ + createSession(input: QqbotNapcatWebuiGatewayCreateSessionRequest) { + return this.post( + '/internal/sessions', + input, + ); + } + + /** + * Refreshes one Gateway session heartbeat without exposing internal target data. + * @param sessionId - Gateway session id previously returned to Admin. + * @returns Gateway lifecycle response body. + */ + heartbeat(sessionId: string) { + return this.post( + `/internal/sessions/${encodeURIComponent(sessionId)}/heartbeat`, + ); + } + + /** + * Revokes one Gateway session without exposing internal target data. + * @param sessionId - Gateway session id previously returned to Admin. + * @returns Gateway lifecycle response body. + */ + revoke(sessionId: string) { + return this.post( + `/internal/sessions/${encodeURIComponent(sessionId)}/revoke`, + ); + } + + /** + * Sends one bounded POST request to the internal Gateway and strips raw axios errors. + * @param path - Internal Gateway path starting with `/internal`. + * @param data - Optional JSON payload sent only server-to-server. + * @returns Unwrapped Gateway response data. + */ + private async post(path: string, data?: unknown): Promise { + const config: AxiosRequestConfig = { + data, + headers: this.getHeaders(), + method: 'POST', + timeout: this.getTimeoutMs(), + url: this.buildUrl(path), + }; + + try { + const response = await axios.request>(config); + return this.unwrapGatewayBody(response.data); + } catch { + throwVbenError( + 'NapCat WebUI Gateway 请求失败', + HttpStatus.BAD_GATEWAY, + ); + } + } + + /** + * Builds the complete Gateway URL from a configured base URL and fixed internal path. + * @param path - Internal Gateway path supplied by the service method. + * @returns Absolute Gateway URL without duplicate slashes. + */ + private buildUrl(path: string) { + return `${this.getBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`; + } + + /** + * Reads and normalizes the internal Gateway base URL. + * @returns Configured base URL or the local Gateway default. + */ + private getBaseUrl() { + const configured = this.configService.get( + 'NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL', + ); + return (configured || DEFAULT_GATEWAY_BASE_URL).replace(/\/+$/, ''); + } + + /** + * Reads the optional Gateway shared secret and maps it to the internal header. + * @returns Header map when a secret is configured, otherwise undefined. + */ + private getHeaders() { + const secret = ( + this.configService.get('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET') || + '' + ).trim(); + + return secret ? { 'x-kt-gateway-secret': secret } : undefined; + } + + /** + * Reads and validates the Gateway request timeout. + * @returns Positive timeout in milliseconds. + */ + private getTimeoutMs() { + const configured = Number( + this.configService.get('NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS') || '', + ); + + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_GATEWAY_TIMEOUT_MS; + } + + /** + * Accepts both raw Gateway bodies and Vben-like `{ data }` wrappers. + * @param body - Axios response body returned by the internal Gateway. + * @returns The unwrapped data payload expected by API callers. + */ + private unwrapGatewayBody(body: GatewayResponseBody): T { + if (body && typeof body === 'object' && 'data' in body) { + return (body as { data: T }).data; + } + + return body as T; + } +} diff --git a/test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts b/test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts new file mode 100644 index 0000000..4be5112 --- /dev/null +++ b/test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts @@ -0,0 +1,374 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { HttpException } from '@nestjs/common'; +import { getMetadataArgsStorage } from 'typeorm'; +import axios from 'axios'; +import { + QqbotNapcatWebuiGatewayService, + NapcatWebuiGatewayAuditService, +} from '../../../../src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service'; +import { QqbotNapcatWebuiGatewayClient } from '../../../../src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client'; +import { NapcatWebuiGatewayAudit } from '../../../../src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity'; +import { + NAPCAT_RUNTIME_DOMAIN_CONTRACT, + NAPCAT_RUNTIME_ENTITIES, +} from '../../../../src/modules/qqbot/napcat/infrastructure/persistence'; + +jest.mock('axios'); + +const repoRoot = resolve(__dirname, '../../../..'); +const requestMock = axios.request as jest.Mock; +const INTERNAL_SECRET_FIXTURE = ['internal', 'secret'].join('-'); +const WEBUI_TOKEN_FIXTURE = ['webui', 'token', 'fixture'].join('-'); + +type MockRepository = { + create: jest.Mock]>; + rows: T[]; + save: jest.Mock, [T]>; +}; + +/** + * Reads a source or SQL file from the API repository root. + * @param relativePath - Repository-relative file path. + * @returns File text used by schema and contract assertions. + */ +const readSource = (relativePath: string) => { + return readFileSync(resolve(repoRoot, relativePath), 'utf8'); +}; + +/** + * Creates the minimal TypeORM repository mock needed by audit recording tests. + * @returns Repository-like object that stores saved rows for assertions. + */ +const createRepository = () => { + const rows: T[] = []; + const repository: MockRepository = { + create: jest.fn((input: Partial) => ({ ...input }) as T), + rows, + save: jest.fn(async (input: T) => { + rows.push(input); + return input; + }), + }; + + return repository; +}; + +/** + * Extracts a readable message from Vben-style HTTP exceptions. + * @param error - Error thrown by the service under test. + * @returns Vben `msg` or regular error message. + */ +const getThrownMessage = (error: unknown) => { + if (error instanceof HttpException) { + const response = error.getResponse() as { msg?: string }; + return response.msg; + } + return error instanceof Error ? error.message : String(error); +}; + +describe('QqbotNapcatWebuiGatewayService', () => { + beforeEach(() => { + requestMock.mockReset(); + }); + + it('creates a browser-safe Gateway session and records sanitized audit', async () => { + const account = { + id: '1001', + name: '主机器人', + selfId: '1914728559', + }; + const runtime = { + baseUrl: 'http://172.18.0.23:6099', + id: '2001', + name: 'kt-qqbot-napcat-1914728559', + sourceContainerOnline: true, + webuiPort: 6099, + webuiToken: WEBUI_TOKEN_FIXTURE, + }; + const gatewayResult = { + expiresAt: '2026-06-24T10:00:00.000Z', + iframeUrl: '/napcat-webui-gateway/session/sess_1/', + sessionId: 'sess_1', + }; + const accountService = { + findById: jest.fn(async () => account), + }; + const containerService = { + findPrimaryContainerByAccountId: jest.fn(async () => runtime), + }; + const client = { + createSession: jest.fn(async () => gatewayResult), + }; + const auditRepository = createRepository(); + const audit = new NapcatWebuiGatewayAuditService( + auditRepository as never, + ); + const service = new QqbotNapcatWebuiGatewayService( + accountService as never, + containerService as never, + client as unknown as QqbotNapcatWebuiGatewayClient, + audit, + ); + + const response = await service.createSession({ + accountId: '1001', + adminUserId: '3001', + clientIp: '127.0.0.1', + userAgent: 'jest-agent', + }); + const serialized = JSON.stringify(response); + const savedAudit = auditRepository.rows[0]; + const serializedAudit = JSON.stringify(savedAudit); + + expect(response).toEqual({ + account: { + id: '1001', + name: '主机器人', + selfId: '1914728559', + }, + container: { + id: '2001', + name: 'kt-qqbot-napcat-1914728559', + webuiStatus: 'online', + }, + expiresAt: '2026-06-24T10:00:00.000Z', + iframeUrl: '/napcat-webui-gateway/session/sess_1/', + sessionId: 'sess_1', + }); + expect(client.createSession).toHaveBeenCalledWith({ + accountId: '1001', + accountName: '主机器人', + containerId: '2001', + containerName: 'kt-qqbot-napcat-1914728559', + selfId: '1914728559', + targetBaseUrl: 'http://172.18.0.23:6099', + webuiPort: 6099, + webuiToken: WEBUI_TOKEN_FIXTURE, + }); + expect(serialized).not.toContain(WEBUI_TOKEN_FIXTURE); + expect(serialized).not.toContain('Credential'); + expect(serialized).not.toContain('172.18.0.23'); + expect(serialized).not.toContain('6099'); + expect(serialized).not.toContain(INTERNAL_SECRET_FIXTURE); + + expect(auditRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: '1001', + adminUserId: '3001', + clientIp: '127.0.0.1', + containerId: '2001', + eventType: 'session.create', + selfId: '1914728559', + sessionId: 'sess_1', + userAgent: 'jest-agent', + }), + ); + expect(savedAudit.detailJson).toEqual({ + accountName: '主机器人', + containerName: 'kt-qqbot-napcat-1914728559', + iframeUrl: '/napcat-webui-gateway/session/sess_1/', + webuiStatus: 'online', + }); + expect(serializedAudit).not.toContain(WEBUI_TOKEN_FIXTURE); + expect(serializedAudit).not.toContain('Credential'); + expect(serializedAudit).not.toContain('172.18.0.23'); + expect(serializedAudit).not.toContain('6099'); + expect(serializedAudit).not.toContain(INTERNAL_SECRET_FIXTURE); + }); + + it('rejects an offline WebUI target before calling Gateway', async () => { + const accountService = { + findById: jest.fn(async () => ({ + id: '1001', + name: '主机器人', + selfId: '1914728559', + })), + }; + const containerService = { + findPrimaryContainerByAccountId: jest.fn(async () => ({ + baseUrl: 'http://172.18.0.23:6099', + id: '2001', + name: 'kt-qqbot-napcat-1914728559', + sourceContainerOnline: false, + webuiPort: 6099, + webuiToken: WEBUI_TOKEN_FIXTURE, + })), + }; + const client = { + createSession: jest.fn(), + }; + const auditRepository = createRepository(); + const service = new QqbotNapcatWebuiGatewayService( + accountService as never, + containerService as never, + client as unknown as QqbotNapcatWebuiGatewayClient, + new NapcatWebuiGatewayAuditService(auditRepository as never), + ); + + let thrown: unknown; + + try { + await service.createSession({ + accountId: '1001', + adminUserId: '3001', + }); + } catch (error) { + thrown = error; + } + + expect(getThrownMessage(thrown)).toBe('NapCat WebUI 不在线'); + expect(client.createSession).not.toHaveBeenCalled(); + expect(auditRepository.save).not.toHaveBeenCalled(); + }); + + it('forwards heartbeat and revoke to the Gateway client', async () => { + const client = { + heartbeat: jest.fn(async () => ({ ok: true, status: 'active' })), + revoke: jest.fn(async () => ({ ok: true, status: 'revoked' })), + }; + const service = new QqbotNapcatWebuiGatewayService( + { findById: jest.fn() } as never, + { findPrimaryContainerByAccountId: jest.fn() } as never, + client as unknown as QqbotNapcatWebuiGatewayClient, + new NapcatWebuiGatewayAuditService( + createRepository() as never, + ), + ); + + await expect(service.heartbeat('sess_1')).resolves.toEqual({ + ok: true, + status: 'active', + }); + await expect(service.revoke('sess_1')).resolves.toEqual({ + ok: true, + status: 'revoked', + }); + expect(client.heartbeat).toHaveBeenCalledWith('sess_1'); + expect(client.revoke).toHaveBeenCalledWith('sess_1'); + }); +}); + +describe('QqbotNapcatWebuiGatewayClient', () => { + beforeEach(() => { + requestMock.mockReset(); + }); + + it('posts internal session requests with the secret header and unwraps Gateway data', async () => { + requestMock.mockResolvedValue({ + data: { + data: { + expiresAt: '2026-06-24T10:00:00.000Z', + iframeUrl: '/napcat-webui-gateway/session/sess_1/', + sessionId: 'sess_1', + }, + }, + }); + const configService = { + get: jest.fn((key: string) => { + return { + NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086', + NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: INTERNAL_SECRET_FIXTURE, + NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234', + }[key]; + }), + }; + const client = new QqbotNapcatWebuiGatewayClient(configService as never); + + const result = await client.createSession({ + accountId: '1001', + accountName: '主机器人', + containerId: '2001', + containerName: 'kt-qqbot-napcat-1914728559', + selfId: '1914728559', + targetBaseUrl: 'http://172.18.0.23:6099', + webuiPort: 6099, + webuiToken: WEBUI_TOKEN_FIXTURE, + }); + + expect(requestMock).toHaveBeenCalledWith({ + data: expect.objectContaining({ + targetBaseUrl: 'http://172.18.0.23:6099', + webuiToken: WEBUI_TOKEN_FIXTURE, + }), + headers: { + 'x-kt-gateway-secret': INTERNAL_SECRET_FIXTURE, + }, + method: 'POST', + timeout: 1234, + url: 'http://127.0.0.1:48086/internal/sessions', + }); + expect(result).toEqual({ + expiresAt: '2026-06-24T10:00:00.000Z', + iframeUrl: '/napcat-webui-gateway/session/sess_1/', + sessionId: 'sess_1', + }); + }); + + it('sanitizes Gateway client errors before returning them to Admin', async () => { + requestMock.mockRejectedValue( + new Error( + `connect http://172.18.0.23:6099 token=${WEBUI_TOKEN_FIXTURE} secret=${INTERNAL_SECRET_FIXTURE} Credential=abc`, + ), + ); + const configService = { + get: jest.fn((key: string) => { + return { + NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086', + NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: INTERNAL_SECRET_FIXTURE, + NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234', + }[key]; + }), + }; + const client = new QqbotNapcatWebuiGatewayClient(configService as never); + let thrown: unknown; + + try { + await client.createSession({ + accountId: '1001', + accountName: '主机器人', + containerId: '2001', + containerName: 'kt-qqbot-napcat-1914728559', + selfId: '1914728559', + targetBaseUrl: 'http://172.18.0.23:6099', + webuiPort: 6099, + webuiToken: WEBUI_TOKEN_FIXTURE, + }); + } catch (error) { + thrown = error; + } + + const message = getThrownMessage(thrown) || ''; + + expect(message).toBe('NapCat WebUI Gateway 请求失败'); + expect(JSON.stringify(thrown)).not.toContain(WEBUI_TOKEN_FIXTURE); + expect(JSON.stringify(thrown)).not.toContain(INTERNAL_SECRET_FIXTURE); + expect(JSON.stringify(thrown)).not.toContain('172.18.0.23'); + expect(JSON.stringify(thrown)).not.toContain('6099'); + expect(JSON.stringify(thrown)).not.toContain('Credential'); + }); +}); + +describe('NapCat WebUI Gateway audit schema contract', () => { + it('keeps entity, domain contract, and SQL schema aligned', () => { + const refactorSchema = readSource('sql/refactor-v3/00-full-schema.sql'); + const qqbotInitSql = readSource('sql/qqbot-init.sql'); + const tableName = getMetadataArgsStorage().tables.find( + (table) => table.target === NapcatWebuiGatewayAudit, + )?.name; + + expect(tableName).toBe('qqbot_napcat_webui_gateway_audit'); + expect(NAPCAT_RUNTIME_ENTITIES).toEqual( + expect.arrayContaining([NapcatWebuiGatewayAudit]), + ); + expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual( + expect.arrayContaining(['qqbot_napcat_webui_gateway_audit']), + ); + expect(refactorSchema).toContain( + 'CREATE TABLE IF NOT EXISTS qqbot_napcat_webui_gateway_audit', + ); + expect(qqbotInitSql).toContain( + 'CREATE TABLE IF NOT EXISTS `qqbot_napcat_webui_gateway_audit`', + ); + }); +});