fix: 映射消息推送领域错误
This commit is contained in:
parent
1c865319bf
commit
f06b1e393d
2
API.md
2
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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<string, string[]> = {
|
||||
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user