feat: 增加NapCat WebUI会话接口
This commit is contained in:
parent
b9ef48e5dd
commit
06eac5a01d
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
{
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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`',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user