import { OpenApiRecovery } from './open-api.recovery'; import { HTTP_REQUEST_CONTEXT } from '../send-chain/send-chain.contracts'; import { BadRequestException, ConflictException, ForbiddenException, forwardRef, HttpException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, Optional, UnprocessableEntityException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Queue, Worker } from 'bullmq'; import { 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'; import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; import { parseOpenApiDate, pinnedWebhookLookup, publicOpenApiFailure, webhookJobId } from './open-api.protocol'; import { automaticDeliveryMode } from './delivery-mode'; export const OPEN_API_WEBHOOK_TRANSPORT = Symbol('open-api-webhook-transport'); export type OpenApiWebhookTransport = ( url: string, body: string, headers: Record, timeoutMs: number, requireHttps: boolean, ) => Promise<{ status: number; body: string }>; const WEBHOOK_QUEUE = 'http-webhook-delivery'; 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 }>; private recovery?: OpenApiRecovery; private recoveryTimer?: ReturnType; constructor( private readonly prisma: PrismaService, @Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService, @Optional() private readonly protocolLogs?: ProtocolLogsService, @Optional() @Inject(OPEN_API_WEBHOOK_TRANSPORT) private readonly webhookTransport?: OpenApiWebhookTransport, ) {} onModuleInit() { const connection = bullmqConnection(); this.queue = new Queue(WEBHOOK_QUEUE, { connection }); // The isolated Gateway callback process only enqueues customer callbacks. // Delivery remains owned by the main API process so callback DB/HTTP capacity // cannot be consumed by slow customer webhook endpoints. if (process.env.CMPP_PROCESS_ROLE === 'callback') return; this.recovery = new OpenApiRecovery(this.prisma, this.sendChain, this.queue); this.recoveryTimer = setInterval(() => void this.recovery?.tick(), 15_000); this.recoveryTimer.unref?.(); this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10, }); } async onModuleDestroy() { if (this.recoveryTimer) clearInterval(this.recoveryTimer); await this.recovery?.close(); 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, publicOrigin: httpApiPublicOrigin(), config: application.httpConfig, ipAllowlist: application.httpIpAllowlist.map((item) => item.ipCidr), }; } async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) { const application = await this.requireApplication(applicationId, tenantId); const data = normalizeConfig(input, application.httpConfig, application.interfaceEnabled !== false); 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, publicOrigin: httpApiPublicOrigin(), 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 expiresAt = data.expiresAt ? new Date(data.expiresAt) : undefined; if (expiresAt && expiresAt.getTime() <= Date.now()) throw new BadRequestException('凭据过期时间必须晚于当前时间'); 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, 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'); if (!String(data.url ?? '').trim()) { await this.prisma.httpWebhookEndpoint.deleteMany({ where: { applicationId, eventType } }); return { applicationId, eventType, url: '', secretLast4: '', status: 'inactive', updatedAt: new Date(), deleted: true, }; } 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; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }, ) { if (!auth.config.sendEnabled) throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' }); if ( typeof input.mobile !== 'string' || typeof input.content !== 'string' || (input.clientMessageId != null && (typeof input.clientMessageId !== 'string' || Array.from(input.clientMessageId).length > 128)) ) { throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' }); } const mobile = input.mobile.trim(); const content = String(input.content ?? ''); if (!/^1\d{10}$/.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 (['failed', 'requires_review'].includes(existing.status) && existing.responseBody && existing.httpStatus) throw new HttpException(existing.responseBody as Record, existing.httpStatus); if ( existing.status === 'requires_review' || (existing.createdAt && Date.now() - existing.createdAt.getTime() > 600_000) ) throw new ConflictException({ code: 'REQUEST_REQUIRES_REVIEW', message: '请求结果待核对,请提供requestId联系支持,勿更换幂等键重发', requestId: existing.requestId, }); 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 && ['failed', 'requires_review'].includes(raced.status) && raced.responseBody && raced.httpStatus) throw new HttpException(raced.responseBody as Record, raced.httpStatus); throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); } throw error; } try { await this.sendChain.createHttpBatchTask({ [HTTP_REQUEST_CONTEXT]: { id: request.id, requestId }, tenantId: auth.application.tenantId, applicationId: auth.application.id, content, phones: [mobile], sourceIp: auth.sourceIp, userAgent: meta.userAgent, clientMessageId: input.clientMessageId, }); const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } }); if (frozen?.status === 'completed' && frozen.responseBody) { void this.recovery?.tick(); return frozen.responseBody; } if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus) throw new HttpException(frozen.responseBody as Record, frozen.httpStatus); throw new Error('HTTP acceptance snapshot was not committed'); } catch (error) { const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } }); if (frozen?.status === 'completed' && frozen.responseBody) return frozen.responseBody; if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus) throw new HttpException(frozen.responseBody as Record, frozen.httpStatus); 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: failure.httpStatus >= 500 ? 'requires_review' : 'failed', httpStatus: failure.httpStatus, businessCode: failure.code, responseBody: failure.responseBody, durationMs: Date.now() - startedAt, completedAt: new Date(), }, }); throw new HttpException(failure.responseBody as Record, failure.httpStatus); } } 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) { if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); for (const value of Object.values(query)) { if (value !== undefined && typeof value !== 'string') throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' }); } if (query.mobile !== undefined && !/^1\d{10}$/.test(query.mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: 'mobile必须为1开头的11位手机号' }); if (query.accessNumber !== undefined && !/^\d{1,21}$/.test(query.accessNumber)) throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'accessNumber必须为1至21位数字接入号' }); const endTime = query.endTime !== undefined ? parseOpenApiDate(query.endTime) : new Date(); const startTime = query.startTime !== undefined ? parseOpenApiDate(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}天`, }); if ( query.limit !== undefined && (!/^\d+$/.test(query.limit) || !Number.isSafeInteger(Number(query.limit)) || Number(query.limit) < 1) ) throw new BadRequestException({ code: 'LIMIT_INVALID', message: 'limit必须为正整数' }); const limit = Math.min(Number(query.limit ?? 50), 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, 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, tenantId: auth.application.tenantId, matchStatus: 'matched', }, select: { id: true, messageId: true, phoneNumber: true, destId: true, content: true, receivedAt: true, tenantId: true, applicationId: true, }, }); 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; }, transaction?: Prisma.TransactionClient, ) { const db = transaction ?? this.prisma; if (!data.applicationId) return null; const application = await db.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true }, }); const config = application?.httpConfig; const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled; if (!config?.enabled || !enabled) return null; const endpoint = await db.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } }, }); if (!endpoint || endpoint.status !== 'active') return null; const eventId = data.eventType === 'receipt' && data.messageRecordId ? `evt_receipt_${data.messageRecordId}` : data.eventType === 'uplink' && data.uplinkMessageId ? `evt_uplink_${data.uplinkMessageId}` : `evt_${randomUUID()}`; const persist = async (tx: Prisma.TransactionClient) => { const event = await tx.httpWebhookEvent.upsert({ where: { eventId }, update: {}, create: { eventId, tenantId: data.tenantId, applicationId: data.applicationId!, eventType: data.eventType, messageRecordId: data.messageRecordId, messageId: data.messageId, uplinkMessageId: data.uplinkMessageId, payload: data.payload as Prisma.InputJsonValue, }, }); return tx.httpWebhookDelivery.upsert({ where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } }, update: {}, create: { eventId: event.id, endpointId: endpoint.id, recoveryVersion: 1 }, }); }; const delivery = transaction ? await persist(transaction) : await this.prisma.$transaction(persist); if (transaction) return delivery; if (delivery.status !== 'pending' || delivery.recoveryVersion !== 1) return delivery; await this.queue?.add( 'deliver', { deliveryId: delivery.id }, { jobId: webhookJobId(delivery.id, 1), 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投递记录不存在'); const reset = await this.prisma.httpWebhookDelivery.updateMany({ where: { id: delivery.id, status: { in: ['pending', 'retrying', 'failed'] }, attemptCount: delivery.attemptCount, OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }], }, data: { status: 'pending', nextRetryAt: null, lastError: null, recoveryVersion: 1, leaseToken: null, leaseUntil: null, }, }); if (!reset.count) throw new ConflictException('回调正在投递或已成功,不能重投'); await this.queue?.add( 'deliver', { deliveryId }, { jobId: webhookJobId(deliveryId, 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 || !['pending', 'retrying', 'delivering'].includes(delivery.status)) return; if (delivery.nextRetryAt && delivery.nextRetryAt.getTime() > Date.now()) return; const config = await this.prisma.smsApplicationHttpConfig.findUnique({ where: { applicationId: delivery.event.applicationId }, }); if ( !config?.enabled || delivery.endpoint.status !== 'active' || !(delivery.event.eventType === 'receipt' ? config.receiptWebhookEnabled : config.uplinkWebhookEnabled) ) return; const leaseToken = randomUUID(); const claimed = await this.prisma.httpWebhookDelivery.updateMany({ where: { id: deliveryId, status: delivery.status, attemptCount: delivery.attemptCount, OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }], }, data: { status: 'delivering', leaseToken, leaseUntil: new Date(Date.now() + config.webhookTimeoutSeconds * 1000 + 60_000), }, }); if (!claimed.count) 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 (this.webhookTransport ?? 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); this.protocolLogs?.record({ protocol: 'http', direction: 'platform_to_client', eventType: `${delivery.event.eventType}_webhook`, status: success ? 'success' : retryable ? 'retrying' : 'failed', tenantId: delivery.event.tenantId, applicationId: delivery.event.applicationId, messageId: delivery.event.messageId, requestId: delivery.event.eventId, resultCode: responseStatus ?? 'NETWORK_ERROR', durationMs: Date.now() - startedAt, payloadBytes: Buffer.byteLength(body, 'utf8'), retryCount: attemptNo - 1, detail: { deliveryId, attemptNo, error: errorMessage }, }); const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length); const willRetry = !success && config.webhookRetryEnabled && retryable && attemptNo < maxAttempts; const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!; const nextRetryAt = willRetry ? new Date(Date.now() + delaySeconds * 1000) : null; await this.prisma.$transaction(async (tx) => { const updated = await tx.httpWebhookDelivery.updateMany({ where: { id: deliveryId, leaseToken }, data: { status: success ? 'delivered' : willRetry ? 'retrying' : 'failed', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: success ? null : (errorMessage ?? 'HTTP ' + responseStatus), deliveredAt: success ? new Date() : null, nextRetryAt, leaseToken: null, leaseUntil: null, }, }); if (!updated.count) return; await tx.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 (willRetry) await this.queue?.add( 'deliver', { deliveryId }, { jobId: webhookJobId(deliveryId, attemptNo + 1), delay: delaySeconds * 1000, removeOnComplete: 1000, removeOnFail: 1000, }, ); } 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 httpApiPublicOrigin() { const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, ''); if (!configured) return undefined; const url = new URL(configured); const insecureHttpExplicitlyAllowed = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:'; if ( (url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) || url.username || url.password || url.pathname !== '/' || url.search || url.hash ) { // This value is copied into customer integration parameters, so fail closed instead of // publishing an insecure or path-dependent endpoint unless an isolated test environment // has explicitly opted into plain HTTP. throw new Error( 'HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN', ); } return url.origin; } function normalizeOpenApiFailure(error: unknown) { const failure = publicOpenApiFailure(error); return { httpStatus: failure.status, code: failure.code, responseBody: { code: failure.code, message: failure.message } as Prisma.InputJsonValue, }; } function normalizeConfig( input: HttpConfigInput, existing: { enabled?: boolean } | null | undefined, cmppEnabled: boolean, ) { const enabling = input.enabled === true && existing?.enabled !== true; const effective = enabling ? { sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true, uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true, ...input, } : input; const httpEnabled = effective.enabled ?? existing?.enabled ?? false; const deliveryMode = automaticDeliveryMode(cmppEnabled, httpEnabled); return { enabled: effective.enabled, sendEnabled: effective.sendEnabled, messageQueryEnabled: effective.messageQueryEnabled, receiptWebhookEnabled: effective.receiptWebhookEnabled, uplinkWebhookEnabled: effective.uplinkWebhookEnabled, uplinkQueryEnabled: effective.uplinkQueryEnabled, credentialSelfServiceEnabled: effective.credentialSelfServiceEnabled, qpsLimit: bounded(effective.qpsLimit, 1, 1000, 'QPS'), timestampToleranceSeconds: bounded(effective.timestampToleranceSeconds, 60, 900, '时间戳容差'), maxCredentialCount: bounded(effective.maxCredentialCount, 1, 10, '凭据数'), uplinkRetentionDays: bounded(effective.uplinkRetentionDays, 1, 365, '上行保留天数'), maxQueryRangeDays: bounded(effective.maxQueryRangeDays, 1, 90, '查询跨度'), maxPageSize: bounded(effective.maxPageSize, 10, 500, '分页上限'), receiptDeliveryMode: deliveryMode, uplinkDeliveryMode: deliveryMode, webhookRetryEnabled: effective.webhookRetryEnabled, webhookMaxAttempts: bounded(effective.webhookMaxAttempts, 1, 7, '回调重试次数'), webhookTimeoutSeconds: bounded(effective.webhookTimeoutSeconds, 1, 30, '回调超时'), requireHttps: effective.requireHttps, allowClientManualRetry: effective.allowClientManualRetry, allowClientTest: effective.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(); } export 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 hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname; let addresses: Array<{ address: string }>; try { addresses = isIP(hostname) ? [{ address: hostname }] : await lookup(hostname, { all: true }); } catch { throw new BadRequestException('Webhook域名未解析到可用地址'); } 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, 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: pinnedWebhookLookup(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 canonical = isIP(address) === 6 ? new URL(`http://[${address}]`).hostname.slice(1, -1) : address; const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/i.exec(canonical); if (mapped) { const high = parseInt(mapped[1], 16), low = parseInt(mapped[2], 16); return isPrivateAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`); } const normalized = canonical.toLowerCase(); 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 { if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error(); const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string') throw new Error(); const [date, id] = parsed; const receivedAt = parseOpenApiDate(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, }; }