feat: add HTTP API and complete client workflows
This commit is contained in:
@@ -11,6 +11,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { OperationsModule } from './operations/operations.module';
|
||||
import { OpenApiModule } from './open-api/open-api.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RiskReviewModule } from './risk-review/risk-review.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
@@ -42,6 +43,7 @@ import { UsersModule } from './users/users.module';
|
||||
ReportMaterialsModule,
|
||||
SendChainModule,
|
||||
OperationsModule,
|
||||
OpenApiModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [RequestContextMiddleware, SessionValidationMiddleware],
|
||||
|
||||
+10
-1
@@ -2,9 +2,10 @@ import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { OpenApiModule } from './open-api/open-api.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
@@ -15,6 +16,14 @@ async function bootstrap() {
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const clientDocument = SwaggerModule.createDocument(app, new DocumentBuilder()
|
||||
.setTitle('CMPP短信平台 HTTP 客户接口')
|
||||
.setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
|
||||
.setVersion('1.0.0')
|
||||
.build(), { include: [OpenApiModule] });
|
||||
clientDocument.paths = Object.fromEntries(Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')));
|
||||
SwaggerModule.setup('api/client-docs', app, clientDocument);
|
||||
|
||||
const port = Number(process.env.API_PORT ?? 3000);
|
||||
await app.listen(port);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { HttpConfigInput, OpenApiService } from './open-api.service';
|
||||
|
||||
@ApiTags('admin-http-open-api')
|
||||
@Controller('admin/enterprise-applications/:applicationId/http-api')
|
||||
export class AdminOpenApiController {
|
||||
constructor(private readonly service: OpenApiService) {}
|
||||
|
||||
@Get() getConfig(@Param('applicationId') applicationId: string) { return this.service.getConfig(applicationId); }
|
||||
@Put() @RequireRecentAuthentication() updateConfig(@Param('applicationId') applicationId: string, @Body() body: HttpConfigInput) { return this.service.updateConfig(applicationId, body); }
|
||||
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string) { return this.service.listCredentials(applicationId); }
|
||||
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }) { return this.service.createCredential(applicationId, body); }
|
||||
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string) { return this.service.revokeCredential(applicationId, credentialId); }
|
||||
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string) { return this.service.getWebhookEndpoints(applicationId); }
|
||||
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body); }
|
||||
@Get('requests') listRequests(@Param('applicationId') applicationId: string) { return this.service.listRequestLogs(applicationId); }
|
||||
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string) { return this.service.listWebhookDeliveries(applicationId); }
|
||||
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { OpenApiService } from './open-api.service';
|
||||
|
||||
@ApiTags('client-http-open-api-management')
|
||||
@Controller('client/applications/:applicationId/http-api')
|
||||
export class ClientOpenApiController {
|
||||
constructor(private readonly service: OpenApiService) {}
|
||||
|
||||
@Get() getConfig(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getConfig(applicationId, tenantId); }
|
||||
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listCredentials(applicationId, tenantId); }
|
||||
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @TenantId() tenantId?: string) { return this.service.createCredential(applicationId, body, tenantId, true); }
|
||||
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @TenantId() tenantId?: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
|
||||
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
|
||||
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @TenantId() tenantId?: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
|
||||
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listRequestLogs(applicationId, tenantId); }
|
||||
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
|
||||
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @TenantId() tenantId?: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, UnauthorizedException } from '@nestjs/common';
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import IORedis from 'ioredis';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { decryptSecret } from './open-api.crypto';
|
||||
import type { OpenApiRequestLike } from './open-api.types';
|
||||
|
||||
@Injectable()
|
||||
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
private redis?: IORedis;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
|
||||
const accessKey = header(request, 'x-app-key');
|
||||
const timestampText = header(request, 'x-timestamp');
|
||||
const nonce = header(request, 'x-nonce');
|
||||
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
|
||||
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
|
||||
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
|
||||
}
|
||||
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
|
||||
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
|
||||
}
|
||||
const credential = await this.prisma.httpApiCredential.findUnique({
|
||||
where: { accessKey },
|
||||
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
|
||||
});
|
||||
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
|
||||
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
|
||||
}
|
||||
const config = credential.application.httpConfig;
|
||||
if (!config?.enabled || credential.application.status !== 'active') {
|
||||
throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' });
|
||||
}
|
||||
const timestamp = Number(timestampText);
|
||||
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
|
||||
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
|
||||
}
|
||||
const sourceIp = requestIp(request);
|
||||
if (credential.application.httpIpAllowlist.length > 0 && (!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))) {
|
||||
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
|
||||
}
|
||||
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
|
||||
const bodyHash = createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(request.body ?? {}))).digest('hex');
|
||||
const signatureSource = [request.method.toUpperCase(), path, timestampText, nonce, bodyHash].join('\n');
|
||||
const expected = createHmac('sha256', decryptSecret(credential.secretEncrypted)).update(signatureSource).digest('hex');
|
||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
|
||||
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
|
||||
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
|
||||
}
|
||||
const redis = this.getRedis();
|
||||
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
|
||||
if (nonceAccepted !== 'OK') {
|
||||
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
|
||||
}
|
||||
const second = Math.floor(Date.now() / 1000);
|
||||
const qpsKey = `openapi:qps:${credential.applicationId}:${second}`;
|
||||
const currentQps = await redis.incr(qpsKey);
|
||||
if (currentQps === 1) await redis.expire(qpsKey, 2);
|
||||
if (currentQps > config.qpsLimit) {
|
||||
throw new HttpException({ code: 'QPS_LIMIT_EXCEEDED', message: 'HTTP接口QPS超限' }, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
request.openApiAuth = {
|
||||
application: credential.application,
|
||||
config,
|
||||
credentialId: credential.id,
|
||||
accessKey,
|
||||
sourceIp,
|
||||
};
|
||||
await this.prisma.httpApiCredential.update({ where: { id: credential.id }, data: { lastUsedAt: new Date(), lastUsedIp: sourceIp } });
|
||||
return true;
|
||||
}
|
||||
|
||||
onModuleDestroy() { this.redis?.disconnect(); }
|
||||
|
||||
private getRedis() {
|
||||
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
|
||||
return this.redis;
|
||||
}
|
||||
}
|
||||
|
||||
function header(request: OpenApiRequestLike, name: string) {
|
||||
const value = request.headers[name];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function requestIp(request: OpenApiRequestLike) {
|
||||
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
|
||||
return (forwarded ?? request.socket?.remoteAddress)?.replace(/^::ffff:/, '');
|
||||
}
|
||||
|
||||
function ipMatches(ip: string, rule: string) {
|
||||
const normalized = rule.trim();
|
||||
if (!normalized.includes('/')) return ip === normalized;
|
||||
const [network, bitsText] = normalized.split('/');
|
||||
if (isIP(ip) !== 4 || isIP(network) !== 4) return false;
|
||||
const bits = Number(bitsText);
|
||||
if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false;
|
||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
||||
return (ipv4(ip) & mask) === (ipv4(network) & mask);
|
||||
}
|
||||
|
||||
function ipv4(value: string) {
|
||||
return value.split('.').reduce((result, part) => ((result << 8) | Number(part)) >>> 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
@Catch()
|
||||
export class OpenApiExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<{ status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } } }>();
|
||||
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
const value = exception instanceof HttpException ? exception.getResponse() : {};
|
||||
const object = typeof value === 'object' && value ? value as Record<string, unknown> : {};
|
||||
const rawMessage = object.message ?? (exception instanceof Error ? exception.message : 'Internal server error');
|
||||
const detail = Array.isArray(rawMessage) ? rawMessage.join(';') : String(rawMessage);
|
||||
response.status(status).type('application/problem+json').send({
|
||||
type: `https://cmpp-platform.local/problems/${String(object.code ?? 'REQUEST_FAILED').toLowerCase()}`,
|
||||
title: String(object.error ?? HttpStatus[status] ?? 'Request failed'),
|
||||
status,
|
||||
code: String(object.code ?? 'REQUEST_FAILED'),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Query, Req, UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { ApiHeader, ApiOperation, 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';
|
||||
|
||||
@ApiTags('client-open-api-v1')
|
||||
@ApiHeader({ name: 'X-App-Key', required: true })
|
||||
@ApiHeader({ name: 'X-Timestamp', required: true })
|
||||
@ApiHeader({ name: 'X-Nonce', required: true })
|
||||
@ApiHeader({ name: 'X-Signature', required: true })
|
||||
@UseGuards(OpenApiAuthGuard)
|
||||
@UseFilters(OpenApiExceptionFilter)
|
||||
@Controller('openapi/v1/sms')
|
||||
export class OpenApiController {
|
||||
constructor(private readonly service: OpenApiService) {}
|
||||
|
||||
@Post('messages')
|
||||
@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) {
|
||||
return this.service.sendMessage(request.openApiAuth!, body, { idempotencyKey, bodyHash: createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(body ?? {}))).digest('hex'), userAgent });
|
||||
}
|
||||
|
||||
@Get('messages/:messageId')
|
||||
@ApiOperation({ summary: '查询短信状态' })
|
||||
getMessage(@Req() request: OpenApiRequestLike, @Param('messageId') messageId: string) {
|
||||
return this.service.getMessage(request.openApiAuth!, messageId);
|
||||
}
|
||||
|
||||
@Get('uplinks')
|
||||
@ApiOperation({ summary: '游标分页查询上行短信' })
|
||||
listUplinks(@Req() request: OpenApiRequestLike, @Query() query: Record<string, string | undefined>) {
|
||||
return this.service.listUplinks(request.openApiAuth!, query);
|
||||
}
|
||||
|
||||
@Get('uplinks/:uplinkId')
|
||||
@ApiOperation({ summary: '查询上行短信详情' })
|
||||
getUplink(@Req() request: OpenApiRequestLike, @Param('uplinkId') uplinkId: string) {
|
||||
return this.service.getUplink(request.openApiAuth!, uplinkId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
function encryptionKey() {
|
||||
const masterKey = process.env.HTTP_API_MASTER_KEY;
|
||||
if (!masterKey || masterKey.length < 32) {
|
||||
throw new Error('HTTP_API_MASTER_KEY must be configured with at least 32 characters');
|
||||
}
|
||||
return createHash('sha256').update(masterKey).digest();
|
||||
}
|
||||
|
||||
export function encryptSecret(value: string) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
return `${iv.toString('base64url')}.${cipher.getAuthTag().toString('base64url')}.${ciphertext.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(value: string) {
|
||||
const [iv, tag, ciphertext] = value.split('.');
|
||||
if (!iv || !tag || !ciphertext) throw new Error('Invalid encrypted secret');
|
||||
const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(iv, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(ciphertext, 'base64url')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SendChainModule } from '../send-chain/send-chain.module';
|
||||
import { AdminOpenApiController } from './admin-open-api.controller';
|
||||
import { ClientOpenApiController } from './client-open-api.controller';
|
||||
import { OpenApiAuthGuard } from './open-api-auth.guard';
|
||||
import { OpenApiController } from './open-api.controller';
|
||||
import { OpenApiService } from './open-api.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, forwardRef(() => SendChainModule)],
|
||||
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
|
||||
providers: [OpenApiService, OpenApiAuthGuard],
|
||||
exports: [OpenApiService],
|
||||
})
|
||||
export class OpenApiModule {}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { decryptSecret, encryptSecret } from './open-api.crypto';
|
||||
import { OpenApiService } from './open-api.service';
|
||||
|
||||
describe('OpenApiService', () => {
|
||||
beforeAll(() => { process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters'; });
|
||||
|
||||
it('encrypts secrets with authenticated encryption', () => {
|
||||
const encrypted = encryptSecret('customer-secret');
|
||||
expect(encrypted).not.toContain('customer-secret');
|
||||
expect(decryptSecret(encrypted)).toBe('customer-secret');
|
||||
});
|
||||
|
||||
it('replays a completed request for the same idempotency key and body', async () => {
|
||||
const prisma = {
|
||||
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) },
|
||||
};
|
||||
const sendChain = { createBatchTask: 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();
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('uses the real send chain and persists the accepted response', async () => {
|
||||
const prisma = {
|
||||
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 sendChain = { createBatchTask: 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(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' }) }));
|
||||
});
|
||||
|
||||
it('persists a 422 result when the real send chain rejects the business request', async () => {
|
||||
const prisma = {
|
||||
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);
|
||||
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' }) }));
|
||||
});
|
||||
|
||||
it('creates an HTTP webhook event only for an enabled HTTP delivery mode', async () => {
|
||||
const prisma = {
|
||||
smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) },
|
||||
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
|
||||
httpWebhookEvent: { create: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
|
||||
httpWebhookDelivery: { create: jest.fn().mockResolvedValue({ id: 'delivery-1' }) },
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
await service.queueWebhookEvent({ tenantId: 'tenant-1', applicationId: 'app-1', eventType: 'receipt', messageId: 'MSG-1', payload: { receiptStatus: 'delivered' } });
|
||||
expect(prisma.httpWebhookEvent.create).toHaveBeenCalled();
|
||||
expect(prisma.httpWebhookDelivery.create).toHaveBeenCalledWith({ data: { eventId: 'event-row-1', endpointId: 'endpoint-1' } });
|
||||
});
|
||||
});
|
||||
|
||||
function auth() {
|
||||
return {
|
||||
application: { id: 'app-1', tenantId: 'tenant-1', status: 'active' },
|
||||
config: { sendEnabled: true },
|
||||
credentialId: 'credential-1',
|
||||
accessKey: 'ak_test',
|
||||
sourceIp: '203.0.113.10',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, forwardRef, HttpException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SendChainService } from '../send-chain/send-chain.service';
|
||||
import { decryptSecret, encryptSecret } from './open-api.crypto';
|
||||
import type { OpenApiAuthContext } from './open-api.types';
|
||||
|
||||
const WEBHOOK_QUEUE = 'http-webhook-delivery';
|
||||
const DELIVERY_MODES = ['cmpp', 'http', 'both', 'none'] as const;
|
||||
const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400];
|
||||
|
||||
export type HttpConfigInput = {
|
||||
enabled?: boolean;
|
||||
sendEnabled?: boolean;
|
||||
messageQueryEnabled?: boolean;
|
||||
receiptWebhookEnabled?: boolean;
|
||||
uplinkWebhookEnabled?: boolean;
|
||||
uplinkQueryEnabled?: boolean;
|
||||
credentialSelfServiceEnabled?: boolean;
|
||||
qpsLimit?: number;
|
||||
timestampToleranceSeconds?: number;
|
||||
maxCredentialCount?: number;
|
||||
uplinkRetentionDays?: number;
|
||||
maxQueryRangeDays?: number;
|
||||
maxPageSize?: number;
|
||||
receiptDeliveryMode?: string;
|
||||
uplinkDeliveryMode?: string;
|
||||
webhookRetryEnabled?: boolean;
|
||||
webhookMaxAttempts?: number;
|
||||
webhookTimeoutSeconds?: number;
|
||||
requireHttps?: boolean;
|
||||
allowClientManualRetry?: boolean;
|
||||
allowClientTest?: boolean;
|
||||
ipAllowlist?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
private queue?: Queue<{ deliveryId: string }>;
|
||||
private worker?: Worker<{ deliveryId: string }>;
|
||||
|
||||
constructor(private readonly prisma: PrismaService, @Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const connection = bullmqConnection();
|
||||
this.queue = new Queue(WEBHOOK_QUEUE, { connection });
|
||||
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10 });
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.worker?.close();
|
||||
await this.queue?.close();
|
||||
}
|
||||
|
||||
async getConfig(applicationId: string, tenantId?: string) {
|
||||
const application = await this.requireApplication(applicationId, tenantId);
|
||||
return {
|
||||
applicationId,
|
||||
applicationName: application.name,
|
||||
config: application.httpConfig,
|
||||
ipAllowlist: application.httpIpAllowlist.map((item) => item.ipCidr),
|
||||
};
|
||||
}
|
||||
|
||||
async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) {
|
||||
await this.requireApplication(applicationId, tenantId);
|
||||
const data = normalizeConfig(input);
|
||||
const ipAllowlist = normalizeIpAllowlist(input.ipAllowlist);
|
||||
const [config] = await this.prisma.$transaction([
|
||||
this.prisma.smsApplicationHttpConfig.upsert({
|
||||
where: { applicationId },
|
||||
create: { applicationId, ...data },
|
||||
update: data,
|
||||
}),
|
||||
this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }),
|
||||
...(ipAllowlist.length > 0 ? [this.prisma.smsApplicationHttpIpAllowlist.createMany({ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })) })] : []),
|
||||
]);
|
||||
return { applicationId, config, ipAllowlist };
|
||||
}
|
||||
|
||||
async listCredentials(applicationId: string, tenantId?: string) {
|
||||
await this.requireApplication(applicationId, tenantId);
|
||||
return this.prisma.httpApiCredential.findMany({
|
||||
where: { applicationId },
|
||||
select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, lastUsedAt: true, lastUsedIp: true, createdAt: true, revokedAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createCredential(applicationId: string, data: { name?: string; expiresAt?: string; createdById?: string }, tenantId?: string, selfService = false) {
|
||||
const application = await this.requireApplication(applicationId, tenantId);
|
||||
const config = application.httpConfig;
|
||||
if (!config?.enabled) throw new BadRequestException('请先开通该应用的HTTP接口');
|
||||
if (selfService && !config.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理');
|
||||
const activeCount = await this.prisma.httpApiCredential.count({ where: { applicationId, status: 'active' } });
|
||||
if (activeCount >= config.maxCredentialCount) throw new BadRequestException(`有效凭据最多允许 ${config.maxCredentialCount} 个`);
|
||||
const secret = randomBytes(32).toString('base64url');
|
||||
const credential = await this.prisma.httpApiCredential.create({
|
||||
data: {
|
||||
applicationId,
|
||||
name: String(data.name ?? '默认凭据').trim().slice(0, 100) || '默认凭据',
|
||||
accessKey: `ak_${randomBytes(18).toString('base64url')}`,
|
||||
secretEncrypted: encryptSecret(secret),
|
||||
secretLast4: secret.slice(-4),
|
||||
expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
|
||||
createdById: data.createdById,
|
||||
},
|
||||
select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, createdAt: true },
|
||||
});
|
||||
return { ...credential, secret, secretShownOnce: true };
|
||||
}
|
||||
|
||||
async revokeCredential(applicationId: string, credentialId: string, tenantId?: string) {
|
||||
const application = await this.requireApplication(applicationId, tenantId);
|
||||
if (tenantId && !application.httpConfig?.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理');
|
||||
const result = await this.prisma.httpApiCredential.updateMany({
|
||||
where: { id: credentialId, applicationId, status: 'active' },
|
||||
data: { status: 'revoked', revokedAt: new Date() },
|
||||
});
|
||||
if (result.count !== 1) throw new NotFoundException('有效访问凭据不存在');
|
||||
return { id: credentialId, status: 'revoked' };
|
||||
}
|
||||
|
||||
async getWebhookEndpoints(applicationId: string, tenantId?: string) {
|
||||
await this.requireApplication(applicationId, tenantId);
|
||||
return this.prisma.httpWebhookEndpoint.findMany({
|
||||
where: { applicationId },
|
||||
select: { id: true, eventType: true, url: true, secretLast4: true, status: true, lastTestAt: true, lastTestStatus: true, updatedAt: true },
|
||||
orderBy: { eventType: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async upsertWebhookEndpoint(applicationId: string, eventType: string, data: { url: string; rotateSecret?: boolean; status?: string }, tenantId?: string) {
|
||||
const application = await this.requireApplication(applicationId, tenantId);
|
||||
if (!['receipt', 'uplink'].includes(eventType)) throw new BadRequestException('eventType only supports receipt or uplink');
|
||||
const url = await validateWebhookUrl(data.url, application.httpConfig?.requireHttps ?? true);
|
||||
const existing = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId, eventType } } });
|
||||
const secret = !existing || data.rotateSecret ? randomBytes(32).toString('base64url') : undefined;
|
||||
const endpoint = await this.prisma.httpWebhookEndpoint.upsert({
|
||||
where: { applicationId_eventType: { applicationId, eventType } },
|
||||
create: { applicationId, eventType, url, status: data.status ?? 'active', secretEncrypted: encryptSecret(secret!), secretLast4: secret!.slice(-4) },
|
||||
update: { url, status: data.status ?? existing?.status ?? 'active', ...(secret ? { secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4) } : {}) },
|
||||
select: { id: true, eventType: true, url: true, secretLast4: true, status: true, updatedAt: true },
|
||||
});
|
||||
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 }) {
|
||||
if (!auth.config.sendEnabled) throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
|
||||
const mobile = String(input.mobile ?? '').trim();
|
||||
const content = String(input.content ?? '');
|
||||
if (!/^1[3-9]\d{9}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
|
||||
if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' });
|
||||
const idempotencyKey = String(meta.idempotencyKey ?? '').trim();
|
||||
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'Idempotency-Key 必填且长度为8至128位' });
|
||||
const existing = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } });
|
||||
if (existing) {
|
||||
if (existing.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' });
|
||||
if (existing.status === 'completed' && existing.responseBody) return existing.responseBody;
|
||||
if (existing.status === 'failed' && existing.responseBody && existing.httpStatus) throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus);
|
||||
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
|
||||
}
|
||||
if (input.clientMessageId) {
|
||||
const duplicateClientMessage = await this.prisma.smsMessageRecord.findFirst({ where: { applicationId: auth.application.id, clientMessageId: input.clientMessageId }, select: { messageId: true } });
|
||||
if (duplicateClientMessage) throw new ConflictException({ code: 'CLIENT_MESSAGE_ID_CONFLICT', message: `clientMessageId已关联短信 ${duplicateClientMessage.messageId}` });
|
||||
}
|
||||
const requestId = `req_${randomUUID()}`;
|
||||
const startedAt = Date.now();
|
||||
let request;
|
||||
try {
|
||||
request = await this.prisma.openApiRequest.create({
|
||||
data: { tenantId: auth.application.tenantId, applicationId: auth.application.id, credentialId: auth.credentialId, requestId, idempotencyKey, bodyHash: meta.bodyHash, clientMessageId: input.clientMessageId, sourceIp: auth.sourceIp, userAgent: meta.userAgent },
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'P2002') {
|
||||
const raced = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } });
|
||||
if (raced?.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' });
|
||||
if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody;
|
||||
if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus) throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus);
|
||||
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const task = await this.sendChain.createBatchTask({
|
||||
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,
|
||||
});
|
||||
const message = task.messages?.[0];
|
||||
if (task.status === 'rejected' || message?.status === 'rejected') {
|
||||
throw new UnprocessableEntityException({ code: 'SEND_REJECTED', message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验' });
|
||||
}
|
||||
const response = { code: 'ACCEPTED', requestId, messageId: message?.messageId, clientMessageId: input.clientMessageId ?? null, status: message?.status ?? task.status, acceptedAt: new Date().toISOString() };
|
||||
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'completed', httpStatus: 202, businessCode: 'ACCEPTED', responseBody: response, messageRecordId: message?.id, durationMs: Date.now() - startedAt, completedAt: new Date() } });
|
||||
return response;
|
||||
} catch (error) {
|
||||
let outwardError = error;
|
||||
if (error instanceof HttpException && error.getStatus() === 400) {
|
||||
const response = error.getResponse();
|
||||
const message = typeof response === 'object' && response && 'message' in response ? (response as { message: unknown }).message : error.message;
|
||||
outwardError = new UnprocessableEntityException({ code: 'SEND_REJECTED', message });
|
||||
}
|
||||
const failure = normalizeOpenApiFailure(outwardError);
|
||||
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'failed', httpStatus: failure.httpStatus, businessCode: failure.code, responseBody: failure.responseBody, durationMs: Date.now() - startedAt, completedAt: new Date() } });
|
||||
throw outwardError;
|
||||
}
|
||||
}
|
||||
|
||||
async getMessage(auth: OpenApiAuthContext, messageId: string) {
|
||||
if (!auth.config.messageQueryEnabled) throw new ForbiddenException({ code: 'MESSAGE_QUERY_NOT_ENABLED', message: '该应用未开通短信状态查询' });
|
||||
const message = await this.prisma.smsMessageRecord.findFirst({
|
||||
where: { applicationId: auth.application.id, OR: [{ messageId }, { clientMessageId: messageId }] },
|
||||
select: { messageId: true, clientMessageId: true, phoneNumber: true, status: true, submitStatus: true, receiptStatus: true, errorCode: true, errorMessage: true, queuedAt: true, submittedAt: true, deliveredAt: true, updatedAt: true },
|
||||
});
|
||||
if (!message) throw new NotFoundException({ code: 'MESSAGE_NOT_FOUND', message: '短信记录不存在' });
|
||||
return message;
|
||||
}
|
||||
|
||||
async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) {
|
||||
if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
|
||||
const endTime = query.endTime ? new Date(query.endTime) : new Date();
|
||||
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
|
||||
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime) throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' });
|
||||
if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000) throw new BadRequestException({ code: 'TIME_RANGE_TOO_LARGE', message: `单次查询不能超过${auth.config.maxQueryRangeDays}天` });
|
||||
const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize);
|
||||
const cursor = decodeCursor(query.cursor);
|
||||
const rows = await this.prisma.smsUplinkMessage.findMany({
|
||||
where: {
|
||||
applicationId: auth.application.id,
|
||||
matchStatus: 'matched',
|
||||
receivedAt: { gte: startTime, lte: endTime },
|
||||
phoneNumber: query.mobile,
|
||||
destId: query.accessNumber,
|
||||
content: query.keyword ? { contains: query.keyword } : undefined,
|
||||
...(cursor ? { OR: [{ receivedAt: { lt: cursor.receivedAt } }, { receivedAt: cursor.receivedAt, id: { lt: cursor.id } }] } : {}),
|
||||
},
|
||||
select: { id: true, messageId: true, phoneNumber: true, destId: true, content: true, matchStatus: true, matchReason: true, receivedAt: true },
|
||||
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1,
|
||||
});
|
||||
const hasMore = rows.length > limit;
|
||||
const items = rows.slice(0, limit);
|
||||
const last = items.at(-1);
|
||||
return { items, nextCursor: hasMore && last ? encodeCursor(last.receivedAt, last.id) : null };
|
||||
}
|
||||
|
||||
async getUplink(auth: OpenApiAuthContext, uplinkId: string) {
|
||||
if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
|
||||
const row = await this.prisma.smsUplinkMessage.findFirst({ where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' } });
|
||||
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
|
||||
return row;
|
||||
}
|
||||
|
||||
async queueWebhookEvent(data: { tenantId: string; applicationId?: string | null; messageRecordId?: string | null; messageId?: string | null; uplinkMessageId?: string | null; eventType: 'receipt' | 'uplink'; payload: Record<string, unknown> }) {
|
||||
if (!data.applicationId) return null;
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true } });
|
||||
const config = application?.httpConfig;
|
||||
const mode = data.eventType === 'receipt' ? config?.receiptDeliveryMode : config?.uplinkDeliveryMode;
|
||||
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
|
||||
if (!config?.enabled || !enabled || !['http', 'both'].includes(mode ?? '')) return null;
|
||||
const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } } });
|
||||
if (!endpoint || endpoint.status !== 'active') return null;
|
||||
const event = await this.prisma.httpWebhookEvent.create({
|
||||
data: { eventId: `evt_${randomUUID()}`, tenantId: data.tenantId, applicationId: data.applicationId, eventType: data.eventType, messageRecordId: data.messageRecordId, messageId: data.messageId, uplinkMessageId: data.uplinkMessageId, payload: data.payload as Prisma.InputJsonValue },
|
||||
});
|
||||
const delivery = await this.prisma.httpWebhookDelivery.create({ data: { eventId: event.id, endpointId: endpoint.id } });
|
||||
await this.queue?.add('deliver', { deliveryId: delivery.id }, { jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 });
|
||||
return delivery;
|
||||
}
|
||||
|
||||
async listRequestLogs(applicationId: string, tenantId?: string) {
|
||||
await this.requireApplication(applicationId, tenantId);
|
||||
return this.prisma.openApiRequest.findMany({ where: { applicationId }, select: { id: true, requestId: true, clientMessageId: true, sourceIp: true, httpStatus: true, businessCode: true, status: true, durationMs: true, createdAt: true, completedAt: true }, orderBy: { createdAt: 'desc' }, take: 100 });
|
||||
}
|
||||
|
||||
async listWebhookDeliveries(applicationId: string, tenantId?: string) {
|
||||
await this.requireApplication(applicationId, tenantId);
|
||||
return this.prisma.httpWebhookDelivery.findMany({ where: { event: { applicationId } }, include: { event: true, endpoint: { select: { eventType: true, url: true } }, attempts: { orderBy: { attemptNo: 'desc' }, take: 5 } }, orderBy: { createdAt: 'desc' }, take: 100 });
|
||||
}
|
||||
|
||||
async retryWebhookDelivery(applicationId: string, deliveryId: string, tenantId?: string) {
|
||||
const application = await this.requireApplication(applicationId, tenantId);
|
||||
if (tenantId && !application.httpConfig?.allowClientManualRetry) throw new ForbiddenException('该应用未开通客户端手动重投');
|
||||
const delivery = await this.prisma.httpWebhookDelivery.findFirst({ where: { id: deliveryId, event: { applicationId } } });
|
||||
if (!delivery) throw new NotFoundException('Webhook投递记录不存在');
|
||||
await this.prisma.httpWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'pending', nextRetryAt: null, lastError: null } });
|
||||
await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 });
|
||||
return { id: deliveryId, status: 'pending' };
|
||||
}
|
||||
|
||||
private async deliverWebhook(deliveryId: string) {
|
||||
const delivery = await this.prisma.httpWebhookDelivery.findUnique({ where: { id: deliveryId }, include: { event: true, endpoint: true } });
|
||||
if (!delivery || delivery.status === 'delivered') return;
|
||||
const config = await this.prisma.smsApplicationHttpConfig.findUnique({ where: { applicationId: delivery.event.applicationId } });
|
||||
if (!config) return;
|
||||
const attemptNo = delivery.attemptCount + 1;
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const body = JSON.stringify({ eventId: delivery.event.eventId, eventType: delivery.event.eventType, occurredAt: delivery.event.createdAt.toISOString(), data: delivery.event.payload });
|
||||
const signature = createHmac('sha256', decryptSecret(delivery.endpoint.secretEncrypted)).update(`${timestamp}\n${body}`).digest('hex');
|
||||
const startedAt = Date.now();
|
||||
let responseStatus: number | undefined;
|
||||
let responseSummary: string | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
try {
|
||||
const response = await postWebhook(delivery.endpoint.url, body, {
|
||||
'content-type': 'application/json',
|
||||
'x-event-id': delivery.event.eventId,
|
||||
'x-event-type': delivery.event.eventType,
|
||||
'x-timestamp': timestamp,
|
||||
'x-signature': `sha256=${signature}`,
|
||||
}, config.webhookTimeoutSeconds * 1000, config.requireHttps);
|
||||
responseStatus = response.status;
|
||||
responseSummary = response.body;
|
||||
} catch (error) { errorMessage = error instanceof Error ? error.message : 'Webhook request failed'; }
|
||||
const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300;
|
||||
const retryable = errorMessage !== undefined || responseStatus === 408 || responseStatus === 429 || (responseStatus !== undefined && responseStatus >= 500);
|
||||
await this.prisma.httpWebhookAttempt.create({ data: { deliveryId, attemptNo, responseStatus, responseSummary, errorMessage, durationMs: Date.now() - startedAt, requestHeaders: { 'x-event-id': delivery.event.eventId, 'x-event-type': delivery.event.eventType, 'x-timestamp': timestamp, 'x-signature': 'sha256=***' } } });
|
||||
if (success) {
|
||||
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'delivered', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: null, deliveredAt: new Date(), nextRetryAt: null } });
|
||||
return;
|
||||
}
|
||||
const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length);
|
||||
if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) {
|
||||
const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
|
||||
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000);
|
||||
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'retrying', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt } });
|
||||
await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:${attemptNo + 1}`, delay: delaySeconds * 1000, removeOnComplete: 1000, removeOnFail: 1000 });
|
||||
return;
|
||||
}
|
||||
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'failed', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt: null } });
|
||||
}
|
||||
|
||||
private async requireApplication(applicationId: string, tenantId?: string) {
|
||||
const application = await this.prisma.smsApplication.findFirst({ where: { id: applicationId, tenantId }, include: { httpConfig: true, httpIpAllowlist: true } });
|
||||
if (!application) throw new NotFoundException('企业应用不存在');
|
||||
return application;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpenApiFailure(error: unknown) {
|
||||
if (error instanceof HttpException) {
|
||||
const value = error.getResponse();
|
||||
const object = typeof value === 'object' && value ? value as Record<string, unknown> : {};
|
||||
const rawMessage = object.message ?? error.message;
|
||||
return {
|
||||
httpStatus: error.getStatus(),
|
||||
code: String(object.code ?? 'SEND_REJECTED'),
|
||||
responseBody: { code: String(object.code ?? 'SEND_REJECTED'), message: Array.isArray(rawMessage) ? rawMessage.join(';') : String(rawMessage) } as Prisma.InputJsonValue,
|
||||
};
|
||||
}
|
||||
return { httpStatus: 500, code: 'INTERNAL_ERROR', responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue };
|
||||
}
|
||||
|
||||
function normalizeConfig(input: HttpConfigInput) {
|
||||
for (const mode of [input.receiptDeliveryMode, input.uplinkDeliveryMode]) {
|
||||
if (mode !== undefined && !DELIVERY_MODES.includes(mode as typeof DELIVERY_MODES[number])) throw new BadRequestException('投递模式仅支持 cmpp、http、both、none');
|
||||
}
|
||||
return {
|
||||
enabled: input.enabled,
|
||||
sendEnabled: input.sendEnabled,
|
||||
messageQueryEnabled: input.messageQueryEnabled,
|
||||
receiptWebhookEnabled: input.receiptWebhookEnabled,
|
||||
uplinkWebhookEnabled: input.uplinkWebhookEnabled,
|
||||
uplinkQueryEnabled: input.uplinkQueryEnabled,
|
||||
credentialSelfServiceEnabled: input.credentialSelfServiceEnabled,
|
||||
qpsLimit: bounded(input.qpsLimit, 1, 1000, 'QPS'),
|
||||
timestampToleranceSeconds: bounded(input.timestampToleranceSeconds, 60, 900, '时间戳容差'),
|
||||
maxCredentialCount: bounded(input.maxCredentialCount, 1, 10, '凭据数'),
|
||||
uplinkRetentionDays: bounded(input.uplinkRetentionDays, 1, 365, '上行保留天数'),
|
||||
maxQueryRangeDays: bounded(input.maxQueryRangeDays, 1, 90, '查询跨度'),
|
||||
maxPageSize: bounded(input.maxPageSize, 10, 500, '分页上限'),
|
||||
receiptDeliveryMode: input.receiptDeliveryMode,
|
||||
uplinkDeliveryMode: input.uplinkDeliveryMode,
|
||||
webhookRetryEnabled: input.webhookRetryEnabled,
|
||||
webhookMaxAttempts: bounded(input.webhookMaxAttempts, 1, 7, '回调重试次数'),
|
||||
webhookTimeoutSeconds: bounded(input.webhookTimeoutSeconds, 1, 30, '回调超时'),
|
||||
requireHttps: input.requireHttps,
|
||||
allowClientManualRetry: input.allowClientManualRetry,
|
||||
allowClientTest: input.allowClientTest,
|
||||
};
|
||||
}
|
||||
|
||||
function bounded(value: number | undefined, min: number, max: number, label: string) {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isInteger(value) || value < min || value > max) throw new BadRequestException(`${label}必须在${min}至${max}之间`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIpAllowlist(values?: string[]) {
|
||||
return [...new Set((values ?? []).map((item) => item.trim()).filter(Boolean).map((item) => {
|
||||
const [ip, prefix] = item.split('/');
|
||||
const version = isIP(ip);
|
||||
if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`);
|
||||
if (prefix !== undefined) {
|
||||
const bits = Number(prefix);
|
||||
const max = version === 4 ? 32 : 128;
|
||||
if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException(`CIDR格式非法:${item}`);
|
||||
}
|
||||
return item;
|
||||
}))];
|
||||
}
|
||||
|
||||
async function validateWebhookUrl(value: string, requireHttps: boolean) {
|
||||
return (await resolveWebhookTarget(value, requireHttps)).url.toString();
|
||||
}
|
||||
|
||||
async function resolveWebhookTarget(value: string, requireHttps: boolean) {
|
||||
let url: URL;
|
||||
try { url = new URL(String(value ?? '').trim()); } catch { throw new BadRequestException('Webhook URL格式非法'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS');
|
||||
if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS');
|
||||
if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码');
|
||||
const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true });
|
||||
if (addresses.some(({ address }) => isPrivateAddress(address))) throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址');
|
||||
const selected = addresses[0];
|
||||
if (!selected) throw new BadRequestException('Webhook域名未解析到可用地址');
|
||||
return { url, address: selected.address, family: isIP(selected.address) };
|
||||
}
|
||||
|
||||
async function postWebhook(urlText: string, body: string, headers: Record<string, string>, timeoutMs: number, requireHttps: boolean) {
|
||||
const target = await resolveWebhookTarget(urlText, requireHttps);
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const requestFn = target.url.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const request = requestFn(target.url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
|
||||
lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
|
||||
}, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
if (size < 1000) {
|
||||
const buffer = Buffer.from(chunk);
|
||||
chunks.push(buffer.subarray(0, 1000 - size));
|
||||
size += buffer.length;
|
||||
}
|
||||
});
|
||||
response.on('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
|
||||
});
|
||||
request.setTimeout(timeoutMs, () => request.destroy(new Error('Webhook request timed out')));
|
||||
request.on('error', reject);
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
function isPrivateAddress(address: string) {
|
||||
const normalized = address.replace(/^::ffff:/, '');
|
||||
if (normalized === '::1' || normalized === '::' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) return true;
|
||||
if (isIP(normalized) !== 4) return false;
|
||||
const [a, b] = normalized.split('.').map(Number);
|
||||
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
|
||||
}
|
||||
|
||||
function encodeCursor(receivedAt: Date, id: string) { return Buffer.from(JSON.stringify([receivedAt.toISOString(), id])).toString('base64url'); }
|
||||
function decodeCursor(value?: string) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const [date, id] = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as [string, string];
|
||||
const receivedAt = new Date(date);
|
||||
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
|
||||
return { receivedAt, id };
|
||||
} catch { throw new BadRequestException({ code: 'CURSOR_INVALID', message: 'cursor格式非法' }); }
|
||||
}
|
||||
|
||||
function bullmqConnection() {
|
||||
const url = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return { host: url.hostname, port: Number(url.port || 6379), username: url.username || undefined, password: url.password || undefined, db: Number(url.pathname.slice(1) || 0), maxRetriesPerRequest: null as null };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SmsApplication, SmsApplicationHttpConfig } from '@prisma/client';
|
||||
|
||||
export type OpenApiAuthContext = {
|
||||
application: SmsApplication;
|
||||
config: SmsApplicationHttpConfig;
|
||||
credentialId: string;
|
||||
accessKey: string;
|
||||
sourceIp?: string;
|
||||
};
|
||||
|
||||
export type OpenApiRequestLike = {
|
||||
method: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
body?: unknown;
|
||||
rawBody?: Buffer;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
socket?: { remoteAddress?: string };
|
||||
openApiAuth?: OpenApiAuthContext;
|
||||
};
|
||||
@@ -31,8 +31,8 @@ export class ClientOperationsController {
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) {
|
||||
return this.operations.listUplinkMessages({ tenantId, channelId });
|
||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string) {
|
||||
return this.operations.listUplinkMessages({ tenantId, channelId, applicationId, phoneNumber, keyword, startTime, endTime });
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('OperationsService', () => {
|
||||
await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' });
|
||||
|
||||
expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', channelId: 'channel-1' },
|
||||
where: { tenantId: 'tenant-1', channelId: 'channel-1', applicationId: undefined, phoneNumber: undefined, content: undefined, receivedAt: undefined },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
@@ -246,6 +246,7 @@ describe('OperationsService', () => {
|
||||
},
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -96,9 +96,16 @@ export class OperationsService {
|
||||
});
|
||||
}
|
||||
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string }) {
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) {
|
||||
return this.prisma.smsUplinkMessage.findMany({
|
||||
where: { tenantId: query.tenantId, channelId: query.channelId },
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
channelId: query.channelId,
|
||||
applicationId: query.applicationId,
|
||||
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
|
||||
content: query.keyword ? { contains: query.keyword } : undefined,
|
||||
receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
@@ -114,6 +121,7 @@ export class OperationsService {
|
||||
},
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import { BillingModule } from '../billing/billing.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { RiskReviewModule } from '../risk-review/risk-review.module';
|
||||
import { SmsConfigModule } from '../sms-config/sms-config.module';
|
||||
import { OpenApiModule } from '../open-api/open-api.module';
|
||||
import { AdminSendChainController } from './admin-send-chain.controller';
|
||||
import { ClientSendChainController } from './client-send-chain.controller';
|
||||
import { GatewayEventsController } from './gateway-events.controller';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, BillingModule, forwardRef(() => RiskReviewModule), SmsConfigModule],
|
||||
imports: [PrismaModule, BillingModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)],
|
||||
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
|
||||
providers: [SendChainService],
|
||||
exports: [SendChainService],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
@@ -9,6 +9,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { OpenApiService } from '../open-api/open-api.service';
|
||||
|
||||
export interface CreateBatchTaskDto {
|
||||
tenantId: string;
|
||||
@@ -24,6 +25,7 @@ export interface CreateBatchTaskDto {
|
||||
sourceIp?: string;
|
||||
userAgent?: string;
|
||||
sourceType?: 'client' | 'api' | 'cmpp';
|
||||
clientMessageId?: string;
|
||||
}
|
||||
|
||||
export interface GatewayInboundAuthDto {
|
||||
@@ -258,6 +260,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
@Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -379,6 +382,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
signatureId: messageClassification.signatureId,
|
||||
drainageInfoId: messageClassification.drainageInfoId,
|
||||
messageId: `MSG-${randomUUID()}`,
|
||||
clientMessageId: data.clientMessageId,
|
||||
phoneNumber: phone,
|
||||
content: data.content,
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
@@ -965,6 +969,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
destId: data.destId,
|
||||
content: data.content,
|
||||
receivedAt: record.receivedAt.toISOString(),
|
||||
uplinkMessageId: record.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1571,8 +1576,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
select: { cmppAccount: true, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true },
|
||||
select: { cmppAccount: true, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, httpConfig: true },
|
||||
});
|
||||
try {
|
||||
await this.openApi?.queueWebhookEvent({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
|
||||
eventType: data.deliveryType,
|
||||
payload: data.payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
const deliveryMode = data.deliveryType === 'receipt'
|
||||
? application?.httpConfig?.receiptDeliveryMode ?? 'cmpp'
|
||||
: application?.httpConfig?.uplinkDeliveryMode ?? 'cmpp';
|
||||
if (!['cmpp', 'both'].includes(deliveryMode)) {
|
||||
return null;
|
||||
}
|
||||
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.create({
|
||||
data: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SmsConfigService,
|
||||
UpdateSmsTemplateDto,
|
||||
UpdateSmsDrainageInfoDto,
|
||||
UpdateSmsSignatureDto,
|
||||
} from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@@ -36,12 +37,12 @@ export class ClientSmsConfigController {
|
||||
|
||||
@Get('applications/:id/report-fields')
|
||||
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, reportType));
|
||||
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
|
||||
}
|
||||
|
||||
@Get('report-fields/common')
|
||||
getCommonReportFields(@Query('reportType') reportType: 'signature' | 'drainage' = 'drainage') {
|
||||
return this.smsConfig.getApplicationReportFields(undefined, reportType);
|
||||
return this.smsConfig.getClientApplicationReportFields(undefined, reportType);
|
||||
}
|
||||
|
||||
@Post('applications/:id/secret/reset')
|
||||
@@ -58,12 +59,24 @@ export class ClientSmsConfigController {
|
||||
|
||||
@Get('signatures')
|
||||
listSignatures(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listSignatures(tenantId);
|
||||
return this.smsConfig.listClientSignatures(tenantId);
|
||||
}
|
||||
|
||||
@Get('signatures-workspace')
|
||||
getSignatureWorkspace(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.getClientSignatureWorkspace(tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures')
|
||||
createSignature(@Body() body: CreateSmsSignatureDto) {
|
||||
return this.smsConfig.createSignature(body);
|
||||
async createSignature(@Body() body: CreateSmsSignatureDto, @TenantId() tenantId?: string) {
|
||||
const signature = await this.smsConfig.createSignature({ ...body, tenantId: tenantId ?? body.tenantId });
|
||||
return this.smsConfig.getClientSignatureView(signature.id, tenantId ?? body.tenantId);
|
||||
}
|
||||
|
||||
@Put('signatures/:id')
|
||||
async updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/materials')
|
||||
@@ -73,32 +86,38 @@ export class ClientSmsConfigController {
|
||||
|
||||
@Get('drainage-infos')
|
||||
listDrainageInfos(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listDrainageInfos({ tenantId });
|
||||
return this.smsConfig.listClientDrainageInfos(tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/drainage-infos')
|
||||
createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
|
||||
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
|
||||
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
|
||||
}
|
||||
|
||||
@Put('drainage-infos/:id')
|
||||
updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
|
||||
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
|
||||
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/status')
|
||||
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
|
||||
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
|
||||
if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
|
||||
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/submit')
|
||||
submitSignature(@Param('id') signatureId: string) {
|
||||
return this.smsConfig.submitSignature(signatureId);
|
||||
async submitSignature(@Param('id') signatureId: string, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.submitSignature(signatureId, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/status')
|
||||
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeSignatureStatus(signatureId, body);
|
||||
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
|
||||
@@ -65,6 +65,7 @@ function createPrismaMock() {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
smsSignature: {
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'sig-1',
|
||||
tenantId: 'tenant-1',
|
||||
@@ -224,7 +225,7 @@ describe('SmsConfigService', () => {
|
||||
]);
|
||||
expect(prisma.smsApplication.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' } }),
|
||||
include: { tenant: true, ipAllowlist: true },
|
||||
include: { tenant: true, ipAllowlist: true, httpConfig: true },
|
||||
}));
|
||||
expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take');
|
||||
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -661,6 +662,86 @@ describe('SmsConfigService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes channel sources from client report-field responses', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.channelRouteRule.findMany.mockResolvedValue([{
|
||||
id: 'route-1', priority: 10,
|
||||
group: {
|
||||
id: 'group-1', name: '内部通道组',
|
||||
items: [{ channel: { id: 'channel-secret', code: 'SECRET-CH', name: '内部通道', reportFields: [{ status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }],
|
||||
},
|
||||
}] as never);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
const fields = await service.getClientApplicationReportFields('app-1', 'signature');
|
||||
|
||||
expect(fields).toEqual([expect.objectContaining({ id: 'field-1', code: 'license', required: true })]);
|
||||
expect(JSON.stringify(fields)).not.toContain('channel-secret');
|
||||
expect(JSON.stringify(fields)).not.toContain('内部通道');
|
||||
expect(fields[0]).not.toHaveProperty('channels');
|
||||
});
|
||||
|
||||
it('returns client signatures without report tasks, channels, or internal requirement snapshots', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findMany.mockResolvedValue([{
|
||||
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: '通知',
|
||||
auditStatus: 'rejected', rejectReason: '请补充资料',
|
||||
drainageInfo: { signatureReportValues: { license: 'file-1' }, reportRequirements: [{ channelId: 'channel-secret', channelName: '内部通道' }] },
|
||||
createdAt: new Date('2026-07-16T01:00:00Z'), updatedAt: new Date('2026-07-16T02:00:00Z'),
|
||||
application: { id: 'app-1', name: '应用A', status: 'active' },
|
||||
materials: [{ id: 'material-1', fileObjectId: 'file-1', materialType: 'license', title: '营业执照', description: null, createdAt: new Date() }],
|
||||
drainageItems: [{ id: 'drainage-1', siteName: '官网', url: 'https://example.com', remark: null, reportValues: { owner: '企业A' }, auditStatus: 'pending', rejectReason: null, submittedAt: new Date(), reviewedAt: null, createdAt: new Date(), updatedAt: new Date() }],
|
||||
_count: { reportMaterials: 2 },
|
||||
reportTasks: [{ channelId: 'channel-secret' }],
|
||||
}] as never);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
const result = await service.listClientSignatures('tenant-1');
|
||||
const serialized = JSON.stringify(result);
|
||||
|
||||
expect(result[0]).toEqual(expect.objectContaining({
|
||||
id: 'sig-1',
|
||||
submittedMaterialCount: 3,
|
||||
reportValues: { license: 'file-1' },
|
||||
drainageInfo: { links: [expect.objectContaining({ id: 'drainage-1', siteName: '官网' })] },
|
||||
}));
|
||||
expect(serialized).not.toContain('channel-secret');
|
||||
expect(serialized).not.toContain('内部通道');
|
||||
expect(result[0]).not.toHaveProperty('reportTasks');
|
||||
expect(result[0]).not.toHaveProperty('reportStatus');
|
||||
});
|
||||
|
||||
it('returns real client signature workspace counts from database grouping', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findMany.mockResolvedValue([]);
|
||||
prisma.smsSignature.groupBy.mockResolvedValue([
|
||||
{ auditStatus: 'pending', _count: { _all: 2 } },
|
||||
{ auditStatus: 'approved', _count: { _all: 5 } },
|
||||
{ auditStatus: 'rejected', _count: { _all: 1 } },
|
||||
] as never);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.getClientSignatureWorkspace('tenant-1')).resolves.toEqual({
|
||||
items: [],
|
||||
summary: { total: 8, pending: 2, approved: 5, rejected: 1, draft: 0 },
|
||||
});
|
||||
expect(prisma.smsSignature.groupBy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
}));
|
||||
});
|
||||
|
||||
it('selects client drainage information without internal tasks or channels', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsDrainageInfo.findMany.mockResolvedValue([{ id: 'drainage-1', siteName: '官网' }] as never);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listClientDrainageInfos('tenant-1')).resolves.toEqual([{ id: 'drainage-1', siteName: '官网' }]);
|
||||
const query = prisma.smsDrainageInfo.findMany.mock.calls[0][0];
|
||||
expect(query.where).toEqual({ id: undefined, tenantId: 'tenant-1', auditStatus: { not: 'deleted' } });
|
||||
expect(query.select).not.toHaveProperty('reportTasks');
|
||||
expect(JSON.stringify(query.select)).not.toContain('channel');
|
||||
});
|
||||
|
||||
it('requires common signature fields even when a signature is not bound to an application', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.commonReportField.findMany.mockResolvedValue([{
|
||||
|
||||
@@ -180,6 +180,7 @@ export class SmsConfigService {
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
@@ -221,6 +222,7 @@ export class SmsConfigService {
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
@@ -350,6 +352,11 @@ export class SmsConfigService {
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
async getClientApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') {
|
||||
const fields = await this.getApplicationReportFields(applicationId, reportType);
|
||||
return fields.map(({ channels: _channels, commonReportTypes: _commonReportTypes, ...field }) => field);
|
||||
}
|
||||
|
||||
async createApplication(data: CreateSmsApplicationDto) {
|
||||
const secret = normalizeApplicationPassword(data.passwordCipher);
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
@@ -808,6 +815,128 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async listClientSignatures(tenantId?: string, signatureId?: string) {
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: { id: signatureId, tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
name: true,
|
||||
purpose: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
drainageInfo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
materials: {
|
||||
select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true },
|
||||
},
|
||||
drainageItems: {
|
||||
where: { auditStatus: { not: 'deleted' } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { reportMaterials: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return signatures.map((signature) => {
|
||||
const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
return {
|
||||
id: signature.id,
|
||||
tenantId: signature.tenantId,
|
||||
applicationId: signature.applicationId,
|
||||
name: signature.name,
|
||||
purpose: signature.purpose,
|
||||
auditStatus: signature.auditStatus,
|
||||
rejectReason: signature.rejectReason,
|
||||
createdAt: signature.createdAt,
|
||||
updatedAt: signature.updatedAt,
|
||||
application: signature.application,
|
||||
materials: signature.materials,
|
||||
submittedMaterialCount: signature.materials.length + signature._count.reportMaterials,
|
||||
reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {},
|
||||
drainageInfo: {
|
||||
links: signature.drainageItems.map((item) => ({
|
||||
...item,
|
||||
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getClientSignatureView(signatureId: string, tenantId?: string) {
|
||||
const [signature] = await this.listClientSignatures(tenantId, signatureId);
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
return signature;
|
||||
}
|
||||
|
||||
async getClientSignatureWorkspace(tenantId?: string) {
|
||||
const [items, statusCounts] = await Promise.all([
|
||||
this.listClientSignatures(tenantId),
|
||||
this.prisma.smsSignature.groupBy({
|
||||
by: ['auditStatus'],
|
||||
where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 };
|
||||
for (const item of statusCounts) {
|
||||
const count = item._count._all;
|
||||
summary.total += count;
|
||||
if (item.auditStatus in summary && item.auditStatus !== 'total') {
|
||||
summary[item.auditStatus as keyof Omit<typeof summary, 'total'>] = count;
|
||||
}
|
||||
}
|
||||
return { items, summary };
|
||||
}
|
||||
|
||||
async listClientDrainageInfos(tenantId?: string, itemId?: string) {
|
||||
return this.prisma.smsDrainageInfo.findMany({
|
||||
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
signatureId: true,
|
||||
applicationId: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
signature: { select: { id: true, name: true, auditStatus: true } },
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getClientDrainageInfoView(itemId: string, tenantId?: string) {
|
||||
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
|
||||
await this.validateSignatureReportValues(data.applicationId, data.drainageInfo);
|
||||
const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo);
|
||||
@@ -835,9 +964,9 @@ export class SmsConfigService {
|
||||
return signature;
|
||||
}
|
||||
|
||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) {
|
||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
await this.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo);
|
||||
@@ -852,6 +981,7 @@ export class SmsConfigService {
|
||||
name: data.name,
|
||||
purpose: data.purpose,
|
||||
auditStatus: data.auditStatus,
|
||||
rejectReason: data.auditStatus === 'pending' ? null : undefined,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
@@ -863,6 +993,24 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found');
|
||||
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
|
||||
throw new BadRequestException('当前审核状态不允许修改签名');
|
||||
}
|
||||
const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId);
|
||||
await this.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action: 'client_update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
listDrainageInfos(query: DrainageInfoListQuery = {}) {
|
||||
return this.prisma.smsDrainageInfo.findMany({
|
||||
where: {
|
||||
@@ -1098,9 +1246,9 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async submitSignature(signatureId: string) {
|
||||
async submitSignature(signatureId: string, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
|
||||
@@ -1285,9 +1433,9 @@ export class SmsConfigService {
|
||||
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
async changeSignatureStatus(signatureId: string, data: StatusChangeDto) {
|
||||
async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
|
||||
Reference in New Issue
Block a user