577 lines
34 KiB
TypeScript
577 lines
34 KiB
TypeScript
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 { 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';
|
||
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
|
||
import { automaticDeliveryMode } from './delivery-mode';
|
||
|
||
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 }>;
|
||
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
@Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService,
|
||
@Optional() private readonly protocolLogs?: ProtocolLogsService,
|
||
) {}
|
||
|
||
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,
|
||
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 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');
|
||
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短信发送' });
|
||
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.createHttpBatchTask({
|
||
tenantId: auth.application.tenantId,
|
||
applicationId: auth.application.id,
|
||
content,
|
||
phones: [mobile],
|
||
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() } });
|
||
this.protocolLogs?.record({
|
||
protocol: 'http',
|
||
direction: 'client_to_platform',
|
||
eventType: 'send_request',
|
||
status: 'accepted',
|
||
tenantId: auth.application.tenantId,
|
||
applicationId: auth.application.id,
|
||
messageId: message?.messageId,
|
||
requestId,
|
||
phone: mobile,
|
||
resultCode: 'ACCEPTED',
|
||
durationMs: Date.now() - startedAt,
|
||
payloadBytes: Buffer.byteLength(content, 'utf8'),
|
||
detail: { clientMessageId: input.clientMessageId },
|
||
});
|
||
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() } });
|
||
this.protocolLogs?.record({
|
||
protocol: 'http',
|
||
direction: 'client_to_platform',
|
||
eventType: 'send_request',
|
||
status: 'failed',
|
||
tenantId: auth.application.tenantId,
|
||
applicationId: auth.application.id,
|
||
requestId,
|
||
phone: mobile,
|
||
resultCode: failure.code,
|
||
durationMs: Date.now() - startedAt,
|
||
payloadBytes: Buffer.byteLength(content, 'utf8'),
|
||
});
|
||
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 enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
|
||
if (!config?.enabled || !enabled) 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 eventId = data.eventType === 'receipt' && data.messageRecordId
|
||
? `evt_receipt_${data.messageRecordId}`
|
||
: data.eventType === 'uplink' && data.uplinkMessageId
|
||
? `evt_uplink_${data.uplinkMessageId}`
|
||
: `evt_${randomUUID()}`;
|
||
const event = await this.prisma.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 },
|
||
});
|
||
const delivery = await this.prisma.httpWebhookDelivery.upsert({
|
||
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
|
||
update: {},
|
||
create: { eventId: event.id, endpointId: endpoint.id },
|
||
});
|
||
if (delivery.status === 'delivered') return delivery;
|
||
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=***' } } });
|
||
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 },
|
||
});
|
||
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 httpApiPublicOrigin() {
|
||
const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, '');
|
||
if (!configured) return undefined;
|
||
const url = new URL(configured);
|
||
if (url.protocol !== 'https:' || 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 when deployment config is wrong.
|
||
throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址');
|
||
}
|
||
return url.origin;
|
||
}
|
||
|
||
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,
|
||
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();
|
||
}
|
||
|
||
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 };
|
||
}
|