fix: 映射消息推送领域错误

This commit is contained in:
sunlei 2026-07-24 14:53:54 +08:00
parent 1c865319bf
commit f06b1e393d
6 changed files with 179 additions and 1 deletions

2
API.md
View File

@ -446,6 +446,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
请求采用严格白名单Snowflake/外键 ID 是 124 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1100 字符且不能全空白,`sourceConfig` 必须且仅含字符串 `portForwardId/ddnsRecordId`;模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1100 个严格嵌套 target类型仅 `group/private``targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝query boolean 只接受字面量 `true/false` 请求采用严格白名单Snowflake/外键 ID 是 124 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1100 字符且不能全空白,`sourceConfig` 必须且仅含字符串 `portForwardId/ddnsRecordId`;模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1100 个严格嵌套 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 发布接口。 响应仅返回管理契约字段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 ### NapCat Runtime Profile

View File

@ -135,6 +135,12 @@ export class ApiExceptionFilter implements ExceptionFilter {
exception: unknown, exception: unknown,
fallback: string, fallback: string,
) { ) {
if (
status >= HttpStatus.INTERNAL_SERVER_ERROR &&
!(exception instanceof HttpException)
) {
return 'Internal server error';
}
if (typeof body === 'string') return normalizeVbenErrorText(body, fallback); if (typeof body === 'string') return normalizeVbenErrorText(body, fallback);
if (body?.err !== undefined) if (body?.err !== undefined)
return normalizeVbenErrorText(body.err, fallback); return normalizeVbenErrorText(body.err, fallback);

View File

@ -9,6 +9,7 @@ import {
Post, Post,
Put, Put,
UseGuards, UseGuards,
UseInterceptors,
UsePipes, UsePipes,
ValidationPipe, ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
@ -23,6 +24,7 @@ import {
MessagePushEnabledDto, MessagePushEnabledDto,
} from './qqbot-message-push.dto'; } from './qqbot-message-push.dto';
import { QqbotMessagePushPermission } from './qqbot-message-push-permission.decorator'; 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 { QqbotMessagePushPermissionGuard } from './qqbot-message-push-permission.guard';
import type { import type {
QqbotMessagePublishBindingView, QqbotMessagePublishBindingView,
@ -77,6 +79,7 @@ const allowlistTargetOptions = (
@Controller('qqbot/accounts/:selfId/message-push') @Controller('qqbot/accounts/:selfId/message-push')
@UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard) @UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard)
@UseInterceptors(QqbotMessagePushContractErrorInterceptor)
@UsePipes( @UsePipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,

View File

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

View File

@ -10,6 +10,7 @@ import {
Put, Put,
Query, Query,
UseGuards, UseGuards,
UseInterceptors,
UsePipes, UsePipes,
ValidationPipe, ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
@ -36,6 +37,7 @@ import {
MessageTemplatePreviewDto, MessageTemplatePreviewDto,
} from './qqbot-message-push.dto'; } from './qqbot-message-push.dto';
import { QqbotMessagePushPermission } from './qqbot-message-push-permission.decorator'; 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 { QqbotMessagePushPermissionGuard } from './qqbot-message-push-permission.guard';
const SOURCE_READ_PERMISSIONS = [ const SOURCE_READ_PERMISSIONS = [
@ -167,6 +169,7 @@ const allowlistPreview = (
@Controller('qqbot/message-push') @Controller('qqbot/message-push')
@UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard) @UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard)
@UseInterceptors(QqbotMessagePushContractErrorInterceptor)
@UsePipes( @UsePipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,

View File

@ -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 { ValidationPipe, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as request from 'supertest'; 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 { 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 { 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'; 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 { 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 { 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 { 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 { 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 { import {
collectControllerRoutes, collectControllerRoutes,
routeKey, routeKey,
@ -96,6 +104,12 @@ const EXPECTED_ROUTE_PERMISSIONS: Record<string, string[]> = {
const STRING_ID = '123456789012345678901234'; const STRING_ID = '123456789012345678901234';
const SELF_ID = '12345'; const SELF_ID = '12345';
const pinoLogger = {
error: jest.fn(),
setContext: jest.fn(),
warn: jest.fn(),
};
/** Builds one valid strict STUN subscription payload. */ /** Builds one valid strict STUN subscription payload. */
const subscriptionBody = () => ({ const subscriptionBody = () => ({
enabled: true, enabled: true,
@ -305,6 +319,9 @@ describe('QQBot message-push management controllers', () => {
.compile(); .compile();
app = moduleRef.createNestApplication(); app = moduleRef.createNestApplication();
app.useGlobalFilters(
new ApiExceptionFilter(pinoLogger as unknown as PinoLogger),
);
await app.listen(0, '127.0.0.1'); await app.listen(0, '127.0.0.1');
apiUrl = await app.getUrl(); 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 () => { it('returns HTTP 200 and a Vben wrapper for every POST route', async () => {
const responses = await Promise.all([ const responses = await Promise.all([
request(apiUrl) request(apiUrl)