fix: close receipt delivery workflows

This commit is contained in:
hectorzhao
2026-07-24 14:40:57 +08:00
parent 2ee39056eb
commit 91f04f5288
22 changed files with 829 additions and 48 deletions
@@ -0,0 +1,17 @@
UPDATE "SmsApplicationHttpConfig" config
SET
"receiptDeliveryMode" = CASE
WHEN application."interfaceEnabled" AND config.enabled THEN 'both'
WHEN application."interfaceEnabled" THEN 'cmpp'
WHEN config.enabled THEN 'http'
ELSE 'none'
END,
"uplinkDeliveryMode" = CASE
WHEN application."interfaceEnabled" AND config.enabled THEN 'both'
WHEN application."interfaceEnabled" THEN 'cmpp'
WHEN config.enabled THEN 'http'
ELSE 'none'
END,
"updatedAt" = CURRENT_TIMESTAMP
FROM "SmsApplication" application
WHERE application.id = config."applicationId";
+8
View File
@@ -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';
}
+29 -5
View File
@@ -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() {
+24 -12
View File
@@ -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, '回调超时'),
@@ -61,6 +61,27 @@ describe('GatewayEventsController protocol logging', () => {
status: 'success',
messageId: 'MSG-1',
})).toEqual({ accepted: true });
expect(controller.protocolLog({
protocol: 'cmpp',
direction: 'platform_to_client',
eventType: 'deliver_receipt',
status: 'success',
messageId: 'MSG-1',
})).toEqual({ accepted: true });
expect(controller.protocolLog({
protocol: 'cmpp',
direction: 'platform_to_client',
eventType: 'deliver_uplink',
status: 'success',
messageId: 'MSG-1',
})).toEqual({ accepted: true });
expect(controller.protocolLog({
protocol: 'cmpp',
direction: 'client_to_platform',
eventType: 'deliver_resp',
status: 'success',
messageId: 'MSG-1',
})).toEqual({ accepted: true });
});
it('enriches an enterprise Submit packet with identifiers returned by the real service', async () => {
@@ -51,7 +51,10 @@ export class GatewayEventsController {
&& body.eventType === 'submit_resp'
) || (
body.direction === 'platform_to_client'
&& body.eventType === 'submit_resp'
&& ['submit_resp', 'deliver_receipt', 'deliver_uplink'].includes(body.eventType)
) || (
body.direction === 'client_to_platform'
&& body.eventType === 'deliver_resp'
);
if (body.protocol !== 'cmpp' || !allowedPacket || !['success', 'failed'].includes(body.status)) {
throw new BadRequestException('Unsupported Gateway protocol log event');
+123 -6
View File
@@ -322,7 +322,10 @@ function createPrismaMock() {
return prisma;
}
function createService(prisma = createPrismaMock()) {
function createService(
prisma = createPrismaMock(),
openApi?: { queueWebhookEvent: jest.Mock },
) {
const billing = {
estimateSmsCost: jest.fn().mockReturnValue({
billingUnitsPerMessage: 1,
@@ -347,7 +350,7 @@ function createService(prisma = createPrismaMock()) {
reviewReason: '企业应用已配置模板不匹配进入人工审核',
}),
} as unknown as RiskReviewService;
const service = new SendChainService(prisma as never, billing, riskReview);
const service = new SendChainService(prisma as never, billing, riskReview, openApi as never);
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
return { service, prisma, billing, riskReview };
@@ -780,7 +783,6 @@ describe('SendChainService', () => {
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
@@ -788,7 +790,7 @@ describe('SendChainService', () => {
})).rejects.toThrow('CMPP interface is disabled for this application');
});
it('records and acknowledges Gateway submit with a failure receipt when the application interface was disabled after bind', async () => {
it('does not create a CMPP downstream delivery when the application interface was disabled after bind', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
@@ -801,6 +803,15 @@ describe('SendChainService', () => {
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
interfaceEnabled: false,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
httpConfig: { enabled: false },
});
await expect(service.submitInboundMessage({
account: '100001',
@@ -813,9 +824,37 @@ describe('SendChainService', () => {
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }),
});
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageRecordId: 'record-1', deliveryType: 'receipt', status: 'pending' }),
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
it('queues an HTTP webhook but not CMPP delivery for an HTTP-only application', async () => {
const prisma = createPrismaMock();
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ queued: true }) };
const { service } = createService(prisma, openApi);
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
interfaceEnabled: false,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
httpConfig: { enabled: true },
});
await service['queueAndTryDownstreamDelivery']({
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: 'MSG-1',
deliveryType: 'receipt',
payload: { receiptStatus: 'delivered' },
});
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({
applicationId: 'app-1',
eventType: 'receipt',
}));
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
it('splits every destination in one inbound CMPP Submit into an independent real message record', async () => {
@@ -2199,6 +2238,84 @@ describe('SendChainService', () => {
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
});
it('marks a long message failed when a non-primary segment returns an explicit failure', async () => {
const { service, prisma, billing } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue({
id: 'record-long',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-LONG-FAIL',
submitId: 'SUB-LONG-FAIL',
phoneNumber: '18821203795',
channelId: 'channel-1',
gatewayMessageId: 'GW-SEG-1',
status: 'submitted',
billingUnits: 2,
amountCents: 6,
unitPrice: 3,
});
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
id: 'segment-2',
messageRecordId: 'record-long',
submitRecordId: 'submit-long',
submitId: 'SUB-LONG-FAIL',
channelId: 'channel-1',
gatewayMessageId: 'GW-SEG-2',
segmentIndex: 2,
segmentTotal: 2,
});
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'YL:1014', deliveredAt: new Date() },
]);
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'billing-charged', billingStatus: 'charged' });
prisma.channelRouteRule.findFirst.mockResolvedValue({
id: 'route-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
carrier: 'mobile',
group: {
id: 'group-1',
carrier: 'mobile',
status: 'active',
retryEnabled: false,
retryTimeLimitHours: 72,
retryTimeLimitMinutes: 4320,
items: [],
},
});
await service.handleReceipt({
messageId: 'MSG-LONG-FAIL',
channelId: 'channel-1',
gatewayMessageId: 'GW-SEG-2',
phoneNumber: '18821203795',
receiptStatus: 'undelivered',
rawStatus: 'YL:1014',
});
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
where: { id: 'record-long' },
data: expect.objectContaining({
status: 'failed',
receiptStatus: 'undelivered',
receiptRawStatus: 'YL:1014',
}),
});
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
messageRecordId: 'record-long',
deliveryType: 'receipt',
status: 'pending',
}),
});
});
it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findUnique
+26 -5
View File
@@ -1868,7 +1868,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
const application = await this.prisma.smsApplication.findUnique({
where: { id: data.applicationId },
select: { cmppAccount: true, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, httpConfig: true },
select: {
cmppAccount: true,
interfaceEnabled: true,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
httpConfig: true,
},
});
try {
await this.openApi?.queueWebhookEvent({
@@ -1883,10 +1889,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} catch (error) {
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
}
const deliveryMode = data.deliveryType === 'receipt'
? application?.httpConfig?.receiptDeliveryMode ?? 'cmpp'
: application?.httpConfig?.uplinkDeliveryMode ?? 'cmpp';
if (!['cmpp', 'both'].includes(deliveryMode)) {
if (application?.interfaceEnabled !== true) {
return null;
}
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
@@ -3833,6 +3836,24 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
: null;
if (exactMessage) {
const segmentAudit = data.gatewayMessageId
? await this.smsMessageSegmentAuditDelegate().findFirst({
where: {
messageRecordId: exactMessage.id,
gatewayMessageId: data.gatewayMessageId,
},
orderBy: { updatedAt: 'desc' },
})
: null;
if (segmentAudit) {
return {
message: exactMessage,
messageId: exactMessage.messageId,
submitRecordId: segmentAudit.submitRecordId ?? undefined,
submitId: segmentAudit.submitId,
channelId: segmentAudit.channelId ?? data.channelId,
};
}
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
where: {
messageRecordId: exactMessage.id,
@@ -455,6 +455,37 @@ describe('SmsConfigService', () => {
}));
});
it('derives both downstream delivery modes when CMPP is enabled alongside HTTP', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
interfaceEnabled: false,
httpConfig: { enabled: true },
});
const tx = {
smsApplication: {
update: jest.fn().mockResolvedValue({ id: 'app-1', interfaceEnabled: true }),
},
smsApplicationHttpConfig: {
update: jest.fn().mockResolvedValue({}),
},
};
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
const service = new SmsConfigService(prisma as never);
await service.updateApplication('app-1', { interfaceEnabled: true });
expect(tx.smsApplicationHttpConfig.update).toHaveBeenCalledWith({
where: { applicationId: 'app-1' },
data: {
receiptDeliveryMode: 'both',
uplinkDeliveryMode: 'both',
},
});
});
it('replaces application carrier channel-group routes with carrier validation', async () => {
const prisma = createPrismaMock();
const tx = {
+17 -2
View File
@@ -4,6 +4,7 @@ import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
export interface CreateSmsApplicationDto {
tenantId: string;
@@ -401,7 +402,10 @@ export class SmsConfigService {
}
async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: { httpConfig: true },
});
if (!application) {
throw new NotFoundException('Application not found');
}
@@ -435,7 +439,7 @@ export class SmsConfigService {
if (data.ipAllowlist) {
await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } });
}
return tx.smsApplication.update({
const updated = await tx.smsApplication.update({
where: { id: applicationId },
data: {
name: data.name,
@@ -466,6 +470,17 @@ export class SmsConfigService {
},
include: { tenant: true, ipAllowlist: true },
});
if (data.interfaceEnabled !== undefined && application.httpConfig) {
const deliveryMode = automaticDeliveryMode(data.interfaceEnabled, application.httpConfig.enabled);
await tx.smsApplicationHttpConfig.update({
where: { applicationId },
data: {
receiptDeliveryMode: deliveryMode,
uplinkDeliveryMode: deliveryMode,
},
});
}
return updated;
});
}