feat: harden CMPP delivery and platform workflows
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Query, Req, UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiBody, ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { OpenApiAuthGuard } from './open-api-auth.guard';
|
||||
import { OpenApiService } from './open-api.service';
|
||||
import type { OpenApiRequestLike } from './open-api.types';
|
||||
import { OpenApiExceptionFilter } from './open-api-exception.filter';
|
||||
import { OpenApiSendMessageDto, OpenApiSendMessageResponseDto } from './open-api.dto';
|
||||
|
||||
@ApiTags('client-open-api-v1')
|
||||
@ApiHeader({ name: 'X-App-Key', required: true })
|
||||
@@ -21,7 +22,9 @@ export class OpenApiController {
|
||||
@HttpCode(202)
|
||||
@ApiHeader({ name: 'Idempotency-Key', required: true })
|
||||
@ApiOperation({ summary: '发送单条短信' })
|
||||
sendMessage(@Req() request: OpenApiRequestLike, @Body() body: { mobile?: string; content?: string; templateId?: string; clientMessageId?: string }, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) {
|
||||
@ApiBody({ type: OpenApiSendMessageDto })
|
||||
@ApiResponse({ status: 202, type: OpenApiSendMessageResponseDto })
|
||||
sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) {
|
||||
return this.service.sendMessage(request.openApiAuth!, body, { idempotencyKey, bodyHash: createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(body ?? {}))).digest('hex'), userAgent });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class OpenApiSendMessageDto {
|
||||
@ApiProperty({ example: '13800138000', description: '中国大陆手机号' })
|
||||
mobile!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '【示例签名】您的验证码是123456,5分钟内有效。',
|
||||
description: '完整短信正文;后端自动识别已审核签名、模板及变量值,不接受内部签名或模板 ID',
|
||||
})
|
||||
content!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'order-20260720-0001', maxLength: 128 })
|
||||
clientMessageId?: string;
|
||||
}
|
||||
|
||||
export class OpenApiSendMessageResponseDto {
|
||||
@ApiProperty({ example: 'ACCEPTED' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: 'req_7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' })
|
||||
requestId!: string;
|
||||
|
||||
@ApiProperty({ example: 'MSG-7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' })
|
||||
messageId!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'order-20260720-0001', nullable: true })
|
||||
clientMessageId!: string | null;
|
||||
|
||||
@ApiProperty({ example: 'queued' })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-07-20T08:00:00.000Z' })
|
||||
acceptedAt!: string;
|
||||
}
|
||||
@@ -15,22 +15,22 @@ describe('OpenApiService', () => {
|
||||
const prisma = {
|
||||
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) },
|
||||
};
|
||||
const sendChain = { createBatchTask: jest.fn() };
|
||||
const sendChain = { createHttpBatchTask: jest.fn() };
|
||||
const service = new OpenApiService(prisma as never, sendChain as never);
|
||||
const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' });
|
||||
expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' });
|
||||
expect(sendChain.createBatchTask).not.toHaveBeenCalled();
|
||||
expect(sendChain.createHttpBatchTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects reuse of an idempotency key with a different body', async () => {
|
||||
const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) } };
|
||||
const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn() } as never);
|
||||
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
|
||||
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'new' })).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('replays the same persisted business rejection', async () => {
|
||||
const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'failed', httpStatus: 422, responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' } }) } };
|
||||
const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn() } as never);
|
||||
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
|
||||
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
@@ -43,10 +43,10 @@ describe('OpenApiService', () => {
|
||||
},
|
||||
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
};
|
||||
const sendChain = { createBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) };
|
||||
const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) };
|
||||
const service = new OpenApiService(prisma as never, sendChain as never);
|
||||
const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' });
|
||||
expect(sendChain.createBatchTask).toHaveBeenCalledWith(expect.objectContaining({ sourceType: 'api', phones: ['18821203795'], clientMessageId: 'client-1' }));
|
||||
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }));
|
||||
expect(result).toEqual(expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }));
|
||||
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }) }));
|
||||
});
|
||||
@@ -56,7 +56,7 @@ describe('OpenApiService', () => {
|
||||
openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}) },
|
||||
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never);
|
||||
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never);
|
||||
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '未匹配模板' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' })).rejects.toMatchObject({ status: 422 });
|
||||
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) }));
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
return { ...endpoint, ...(secret ? { secret, secretShownOnce: true } : {}) };
|
||||
}
|
||||
|
||||
async sendMessage(auth: OpenApiAuthContext, input: { mobile?: string; content?: string; templateId?: string; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }) {
|
||||
async sendMessage(auth: OpenApiAuthContext, input: { mobile?: string; content?: string; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }) {
|
||||
if (!auth.config.sendEnabled) throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
|
||||
const mobile = String(input.mobile ?? '').trim();
|
||||
const content = String(input.content ?? '');
|
||||
@@ -188,13 +188,11 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const task = await this.sendChain.createBatchTask({
|
||||
const task = await this.sendChain.createHttpBatchTask({
|
||||
tenantId: auth.application.tenantId,
|
||||
applicationId: auth.application.id,
|
||||
templateId: input.templateId,
|
||||
content,
|
||||
phones: [mobile],
|
||||
sourceType: 'api',
|
||||
sourceIp: auth.sourceIp,
|
||||
userAgent: meta.userAgent,
|
||||
clientMessageId: input.clientMessageId,
|
||||
|
||||
Reference in New Issue
Block a user