From f06b1e393d69b872445c6a9ecdc02e09f2059a3d Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 14:53:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=98=A0=E5=B0=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E9=A2=86=E5=9F=9F=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 + src/common/filters/api-exception.filter.ts | 6 + .../qqbot-account-message-push.controller.ts | 3 + ...message-push-contract-error.interceptor.ts | 59 ++++++++++ .../qqbot-message-push.controller.ts | 3 + .../qqbot-message-push.controller.spec.ts | 107 +++++++++++++++++- 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/modules/qqbot/core/contract/message-push/qqbot-message-push-contract-error.interceptor.ts diff --git a/API.md b/API.md index d21ed6b..d9b3aaa 100644 --- a/API.md +++ b/API.md @@ -446,6 +446,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 请求采用严格白名单:Snowflake/外键 ID 是 1–24 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1–100 字符且不能全空白,`sourceConfig` 必须且仅含字符串 `portForwardId/ddnsRecordId`;模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1–100 个严格嵌套 target,类型仅 `group/private`,`targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝,query boolean 只接受字面量 `true/false`。 +管理边界将 `SystemMessageContractError` 仅转换为 Vben 安全错误,响应只包含其稳定 `code`,不会暴露原始消息、实体或 provider 对象:`unknown_message_source`、`mapping_not_found`、`ddns_not_found` 返回 HTTP 404;重复、禁用、不可用、已取代、映射不匹配、DDNS 未同步、错误协议/管理状态及 OneBot 可用性或拒绝状态返回 HTTP 409;其余来源、目标、模板、长度和契约校验错误返回 HTTP 400。非该领域错误维持 HTTP 500,且不返回其内部细节。 + 响应仅返回管理契约字段:source definition/field/variable 白名单;STUN 的 port-forward/DDNS 候选白名单;subscription、template、preview、binding/target 和 target option 视图。不会返回 adapter、entity/repository、`activeKey`、digest、软删除字段、账号内部 ID、事件 payload/delivery/lease/retry 状态、凭据、access token、Provider/OneBot/MQTT 原始对象。系统事件只能通过 Nest 内部 Outbox stager 暂存,不存在 publish、event、delivery、fan-out、retry 或 worker HTTP 发布接口。 ### NapCat Runtime Profile diff --git a/src/common/filters/api-exception.filter.ts b/src/common/filters/api-exception.filter.ts index c15ed04..8b8cc9d 100644 --- a/src/common/filters/api-exception.filter.ts +++ b/src/common/filters/api-exception.filter.ts @@ -135,6 +135,12 @@ export class ApiExceptionFilter implements ExceptionFilter { exception: unknown, fallback: string, ) { + if ( + status >= HttpStatus.INTERNAL_SERVER_ERROR && + !(exception instanceof HttpException) + ) { + return 'Internal server error'; + } if (typeof body === 'string') return normalizeVbenErrorText(body, fallback); if (body?.err !== undefined) return normalizeVbenErrorText(body.err, fallback); diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller.ts b/src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller.ts index c23f676..eb9d323 100644 --- a/src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller.ts +++ b/src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller.ts @@ -9,6 +9,7 @@ import { Post, Put, UseGuards, + UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; @@ -23,6 +24,7 @@ import { MessagePushEnabledDto, } from './qqbot-message-push.dto'; import { QqbotMessagePushPermission } from './qqbot-message-push-permission.decorator'; +import { QqbotMessagePushContractErrorInterceptor } from './qqbot-message-push-contract-error.interceptor'; import { QqbotMessagePushPermissionGuard } from './qqbot-message-push-permission.guard'; import type { QqbotMessagePublishBindingView, @@ -77,6 +79,7 @@ const allowlistTargetOptions = ( @Controller('qqbot/accounts/:selfId/message-push') @UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard) +@UseInterceptors(QqbotMessagePushContractErrorInterceptor) @UsePipes( new ValidationPipe({ forbidNonWhitelisted: true, diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push-contract-error.interceptor.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push-contract-error.interceptor.ts new file mode 100644 index 0000000..0ccddf1 --- /dev/null +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push-contract-error.interceptor.ts @@ -0,0 +1,59 @@ +import { + CallHandler, + ExecutionContext, + HttpStatus, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { throwVbenError } from '@/common'; +import { catchError, type Observable } from 'rxjs'; +import { SystemMessageContractError } from './qqbot-message-push.types'; + +const NOT_FOUND_CODES = new Set([ + 'ddns_not_found', + 'mapping_not_found', + 'unknown_message_source', +]); + +const CONFLICT_CODE_PATTERN = + /(?:^|_)(?:duplicate|disabled|unavailable|superseded|mismatch|not_synced)(?:_|$)|^(?:mapping_not_managed|mapping_not_udp|ddns_not_ipv4|ddns_source_type_invalid|onebot_)/; + +/** + * Converts safe system-message domain failures at the management HTTP boundary. + * It intentionally leaves every unrelated error untouched for the global exception filter. + */ +@Injectable() +export class QqbotMessagePushContractErrorInterceptor implements NestInterceptor { + /** + * Translates synchronous and asynchronous system-message contract errors into Vben HTTP errors. + * @param _context - Current Nest execution context; this boundary does not inspect request state. + * @param next - The controller handler stream to observe for domain failures. + * @returns The original handler stream or a Vben-safe mapped HTTP exception. + */ + intercept( + _context: ExecutionContext, + next: CallHandler, + ): Observable { + return next.handle().pipe( + catchError((error: unknown) => { + if (!(error instanceof SystemMessageContractError)) throw error; + return throwVbenError( + error.code, + this.resolveStatus(error.code), + error.code, + ); + }), + ); + } + + /** + * Classifies a stable domain code using the management API's documented 4xx semantics. + * @param code - Non-sensitive system-message contract code. + * @returns HTTP 404 for absent resources, 409 for mutable-state conflicts, otherwise 400. + */ + private resolveStatus(code: string): HttpStatus { + if (NOT_FOUND_CODES.has(code)) return HttpStatus.NOT_FOUND; + if (CONFLICT_CODE_PATTERN.test(code)) return HttpStatus.CONFLICT; + return HttpStatus.BAD_REQUEST; + } +} diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts index e0f5d86..5f649e2 100644 --- a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts @@ -10,6 +10,7 @@ import { Put, Query, UseGuards, + UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; @@ -36,6 +37,7 @@ import { MessageTemplatePreviewDto, } from './qqbot-message-push.dto'; import { QqbotMessagePushPermission } from './qqbot-message-push-permission.decorator'; +import { QqbotMessagePushContractErrorInterceptor } from './qqbot-message-push-contract-error.interceptor'; import { QqbotMessagePushPermissionGuard } from './qqbot-message-push-permission.guard'; const SOURCE_READ_PERMISSIONS = [ @@ -167,6 +169,7 @@ const allowlistPreview = ( @Controller('qqbot/message-push') @UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard) +@UseInterceptors(QqbotMessagePushContractErrorInterceptor) @UsePipes( new ValidationPipe({ forbidNonWhitelisted: true, diff --git a/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts index 273e36e..cef022e 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts @@ -1,7 +1,13 @@ -import { GUARDS_METADATA, PIPES_METADATA } from '@nestjs/common/constants'; +import { + GUARDS_METADATA, + INTERCEPTORS_METADATA, + PIPES_METADATA, +} from '@nestjs/common/constants'; import { ValidationPipe, type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; +import { PinoLogger } from 'nestjs-pino'; +import { ApiExceptionFilter } from '../../../../src/common/filters/api-exception.filter'; import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard'; import { QqbotAccountMessagePushService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service'; import { QqbotMessageSubscriptionService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service'; @@ -11,7 +17,9 @@ import { SystemMessageSourceRegistry } from '../../../../src/modules/qqbot/core/ import { QqbotAccountMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller'; import { QqbotMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller'; import { QqbotMessagePushPermissionGuard } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard'; +import { QqbotMessagePushContractErrorInterceptor } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-contract-error.interceptor'; import { QQBOT_MESSAGE_PUSH_PERMISSION } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator'; +import { SystemMessageContractError } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types'; import { collectControllerRoutes, routeKey, @@ -96,6 +104,12 @@ const EXPECTED_ROUTE_PERMISSIONS: Record = { const STRING_ID = '123456789012345678901234'; const SELF_ID = '12345'; +const pinoLogger = { + error: jest.fn(), + setContext: jest.fn(), + warn: jest.fn(), +}; + /** Builds one valid strict STUN subscription payload. */ const subscriptionBody = () => ({ enabled: true, @@ -305,6 +319,9 @@ describe('QQBot message-push management controllers', () => { .compile(); app = moduleRef.createNestApplication(); + app.useGlobalFilters( + new ApiExceptionFilter(pinoLogger as unknown as PinoLogger), + ); await app.listen(0, '127.0.0.1'); apiUrl = await app.getUrl(); }); @@ -457,6 +474,94 @@ describe('QQBot message-push management controllers', () => { } }); + it('shares one contract-error boundary across both message-push controllers', () => { + for (const ControllerClass of [ + QqbotMessagePushController, + QqbotAccountMessagePushController, + ]) { + expect( + Reflect.getMetadata(INTERCEPTORS_METADATA, ControllerClass), + ).toEqual([QqbotMessagePushContractErrorInterceptor]); + } + }); + + it('maps a synchronous unknown source registry error to a safe HTTP 404', async () => { + registry.get.mockImplementationOnce(() => { + throw new SystemMessageContractError('unknown_message_source'); + }); + + const response = await request(apiUrl) + .get('/qqbot/message-push/sources/missing-source') + .expect(404); + + expect(response.body).toEqual({ + code: 404, + err: 'unknown_message_source', + msg: 'unknown_message_source', + }); + }); + + it('maps template contract errors to a safe HTTP 400', async () => { + templates.preview.mockImplementationOnce(() => { + throw new SystemMessageContractError('template_invalid'); + }); + + const response = await request(apiUrl) + .post('/qqbot/message-push/templates/preview') + .send({ + content: 'Endpoint: ${{endpoint}}', + sourceKey: 'network.stun.mapping-port-changed', + }) + .expect(400); + + expect(response.body).toEqual({ + code: 400, + err: 'template_invalid', + msg: 'template_invalid', + }); + }); + + it.each(['account_unavailable', 'ddns_not_synced'])( + 'maps async account binding contract error %s to a safe HTTP 409', + async (code) => { + bindings.createBinding.mockRejectedValueOnce( + new SystemMessageContractError(code), + ); + + const response = await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(bindingBody()) + .expect(409); + + expect(response.body).toEqual({ + code: 409, + err: code, + msg: code, + }); + }, + ); + + it('leaves ordinary failures at HTTP 500 without leaking their detail', async () => { + templates.preview.mockImplementationOnce(() => { + throw new Error('database password must-not-leak'); + }); + + const response = await request(apiUrl) + .post('/qqbot/message-push/templates/preview') + .send({ + content: 'Endpoint: ${{endpoint}}', + sourceKey: 'network.stun.mapping-port-changed', + }) + .expect(500); + + expect(response.body).toEqual({ + code: 500, + err: 'Internal server error', + msg: 'Internal server error', + }); + expect(JSON.stringify(response.body)).not.toContain('must-not-leak'); + }); + it('returns HTTP 200 and a Vben wrapper for every POST route', async () => { const responses = await Promise.all([ request(apiUrl)