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`。
|
请求采用严格白名单: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 发布接口。
|
响应仅返回管理契约字段: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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user