1040 lines
41 KiB
TypeScript
1040 lines
41 KiB
TypeScript
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<string, string>,
|
|
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<typeof setInterval>;
|
|
|
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, string | undefined>) {
|
|
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<string, unknown>;
|
|
},
|
|
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<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: 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,
|
|
};
|
|
}
|