feat: harden CMPP delivery and platform workflows

This commit is contained in:
hectorzhao
2026-07-20 18:07:29 +08:00
parent 80fb5a8f53
commit f02c33cbb7
61 changed files with 1834 additions and 281 deletions
+5 -2
View File
@@ -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 });
}
+35
View File
@@ -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;
}
+7 -7
View File
@@ -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' }) }));
});
+2 -4
View File
@@ -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,