feat: 增加NapCat WebUI会话接口

This commit is contained in:
sunlei 2026-06-24 11:39:03 +08:00
parent b9ef48e5dd
commit 06eac5a01d
11 changed files with 1028 additions and 0 deletions

View File

@ -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,

View File

@ -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;

View File

@ -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 IDNapCat

View File

@ -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,
};

View File

@ -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,
{

View File

@ -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<string, unknown>;
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<NapcatWebuiGatewayAudit>,
) {}
/**
* 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<string, unknown>) {
if (!detail) return null;
return this.sanitizeValue(detail) as Record<string, unknown>;
}
/**
* 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<string, unknown>)
.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<QqbotNapcatWebuiSessionResponseDto> {
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<QqbotNapcatWebuiGatewayLifecycleResult> {
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<QqbotNapcatWebuiGatewayLifecycleResult> {
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';
}
}

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
/**
* Assigns a Snowflake id before persisting an Admin WebUI gateway audit row.
*/
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -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<string, unknown>;
type GatewayResponseBody<T> = 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<QqbotNapcatWebuiGatewaySessionResult>(
'/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<QqbotNapcatWebuiGatewayLifecycleResult>(
`/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<QqbotNapcatWebuiGatewayLifecycleResult>(
`/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<T>(path: string, data?: unknown): Promise<T> {
const config: AxiosRequestConfig = {
data,
headers: this.getHeaders(),
method: 'POST',
timeout: this.getTimeoutMs(),
url: this.buildUrl(path),
};
try {
const response = await axios.request<GatewayResponseBody<T>>(config);
return this.unwrapGatewayBody<T>(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<string>(
'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<string>('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<string>('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<T>(body: GatewayResponseBody<T>): T {
if (body && typeof body === 'object' && 'data' in body) {
return (body as { data: T }).data;
}
return body as T;
}
}

View File

@ -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<T extends object> = {
create: jest.Mock<T, [Partial<T>]>;
rows: T[];
save: jest.Mock<Promise<T>, [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 = <T extends object>() => {
const rows: T[] = [];
const repository: MockRepository<T> = {
create: jest.fn((input: Partial<T>) => ({ ...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<NapcatWebuiGatewayAudit>();
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<NapcatWebuiGatewayAudit>();
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<NapcatWebuiGatewayAudit>() 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`',
);
});
});