fix: close receipt delivery workflows
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export type DeliveryMode = 'cmpp' | 'http' | 'both' | 'none';
|
||||
|
||||
export function automaticDeliveryMode(cmppEnabled: boolean, httpEnabled: boolean): DeliveryMode {
|
||||
if (cmppEnabled && httpEnabled) return 'both';
|
||||
if (cmppEnabled) return 'cmpp';
|
||||
if (httpEnabled) return 'http';
|
||||
return 'none';
|
||||
}
|
||||
@@ -61,7 +61,7 @@ describe('OpenApiService', () => {
|
||||
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) }));
|
||||
});
|
||||
|
||||
it('creates an HTTP webhook event only for an enabled HTTP delivery mode', async () => {
|
||||
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
|
||||
const prisma = {
|
||||
smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) },
|
||||
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
|
||||
@@ -74,9 +74,9 @@ describe('OpenApiService', () => {
|
||||
expect(prisma.httpWebhookDelivery.create).toHaveBeenCalledWith({ data: { eventId: 'event-row-1', endpointId: 'endpoint-1' } });
|
||||
});
|
||||
|
||||
it('defaults a newly enabled HTTP interface to all six capabilities and HTTP webhook delivery', async () => {
|
||||
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
|
||||
const prisma = {
|
||||
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
|
||||
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', interfaceEnabled: true, httpConfig: null, httpIpAllowlist: [] }) },
|
||||
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
|
||||
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
|
||||
$transaction: jest.fn((operations) => Promise.all(operations)),
|
||||
@@ -94,11 +94,35 @@ describe('OpenApiService', () => {
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
receiptDeliveryMode: 'both',
|
||||
uplinkDeliveryMode: 'both',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('removes a webhook endpoint when an operator saves a blank address', async () => {
|
||||
const prisma = {
|
||||
smsApplication: {
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'app-1',
|
||||
name: '应用A',
|
||||
interfaceEnabled: true,
|
||||
httpConfig: { enabled: true, requireHttps: true },
|
||||
httpIpAllowlist: [],
|
||||
}),
|
||||
},
|
||||
httpWebhookEndpoint: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
|
||||
await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' }))
|
||||
.resolves.toEqual(expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true }));
|
||||
expect(prisma.httpWebhookEndpoint.deleteMany).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', eventType: 'receipt' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function auth() {
|
||||
|
||||
@@ -11,9 +11,9 @@ 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 DELIVERY_MODES = ['cmpp', 'http', 'both', 'none'] as const;
|
||||
const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400];
|
||||
|
||||
export type HttpConfigInput = {
|
||||
@@ -75,7 +75,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) {
|
||||
const application = await this.requireApplication(applicationId, tenantId);
|
||||
const data = normalizeConfig(input, application.httpConfig);
|
||||
const data = normalizeConfig(input, application.httpConfig, application.interfaceEnabled !== false);
|
||||
const ipAllowlist = normalizeIpAllowlist(input.ipAllowlist);
|
||||
const [config] = await this.prisma.$transaction([
|
||||
this.prisma.smsApplicationHttpConfig.upsert({
|
||||
@@ -144,6 +144,18 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
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;
|
||||
@@ -299,9 +311,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!data.applicationId) return null;
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true } });
|
||||
const config = application?.httpConfig;
|
||||
const mode = data.eventType === 'receipt' ? config?.receiptDeliveryMode : config?.uplinkDeliveryMode;
|
||||
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
|
||||
if (!config?.enabled || !enabled || !['http', 'both'].includes(mode ?? '')) return null;
|
||||
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 event = await this.prisma.httpWebhookEvent.create({
|
||||
@@ -410,7 +421,11 @@ function normalizeOpenApiFailure(error: unknown) {
|
||||
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) {
|
||||
function normalizeConfig(
|
||||
input: HttpConfigInput,
|
||||
existing: { enabled?: boolean } | null | undefined,
|
||||
cmppEnabled: boolean,
|
||||
) {
|
||||
const enabling = input.enabled === true && existing?.enabled !== true;
|
||||
const effective = enabling ? {
|
||||
sendEnabled: true,
|
||||
@@ -419,13 +434,10 @@ function normalizeConfig(input: HttpConfigInput, existing?: { enabled?: boolean
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
...input,
|
||||
} : input;
|
||||
for (const mode of [effective.receiptDeliveryMode, effective.uplinkDeliveryMode]) {
|
||||
if (mode !== undefined && !DELIVERY_MODES.includes(mode as typeof DELIVERY_MODES[number])) throw new BadRequestException('投递模式仅支持 cmpp、http、both、none');
|
||||
}
|
||||
const httpEnabled = effective.enabled ?? existing?.enabled ?? false;
|
||||
const deliveryMode = automaticDeliveryMode(cmppEnabled, httpEnabled);
|
||||
return {
|
||||
enabled: effective.enabled,
|
||||
sendEnabled: effective.sendEnabled,
|
||||
@@ -440,8 +452,8 @@ function normalizeConfig(input: HttpConfigInput, existing?: { enabled?: boolean
|
||||
uplinkRetentionDays: bounded(effective.uplinkRetentionDays, 1, 365, '上行保留天数'),
|
||||
maxQueryRangeDays: bounded(effective.maxQueryRangeDays, 1, 90, '查询跨度'),
|
||||
maxPageSize: bounded(effective.maxPageSize, 10, 500, '分页上限'),
|
||||
receiptDeliveryMode: effective.receiptDeliveryMode,
|
||||
uplinkDeliveryMode: effective.uplinkDeliveryMode,
|
||||
receiptDeliveryMode: deliveryMode,
|
||||
uplinkDeliveryMode: deliveryMode,
|
||||
webhookRetryEnabled: effective.webhookRetryEnabled,
|
||||
webhookMaxAttempts: bounded(effective.webhookMaxAttempts, 1, 7, '回调重试次数'),
|
||||
webhookTimeoutSeconds: bounded(effective.webhookTimeoutSeconds, 1, 30, '回调超时'),
|
||||
|
||||
Reference in New Issue
Block a user