From 91f04f528881c0e6dd27adbe377e3db8e7e08729 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 24 Jul 2026 14:40:57 +0800 Subject: [PATCH] fix: close receipt delivery workflows --- .../migration.sql | 17 ++ api/src/open-api/delivery-mode.ts | 8 + api/src/open-api/open-api.service.spec.ts | 34 +++- api/src/open-api/open-api.service.ts | 36 ++-- .../gateway-events.controller.spec.ts | 21 ++ .../send-chain/gateway-events.controller.ts | 5 +- api/src/send-chain/send-chain.service.spec.ts | 129 +++++++++++- api/src/send-chain/send-chain.service.ts | 31 ++- api/src/sms-config/sms-config.service.spec.ts | 31 +++ api/src/sms-config/sms-config.service.ts | 19 +- .../first-version-development-requirements.md | 5 + docs/system-functional-test-cases.md | 5 + docs/testing-progress.md | 19 ++ gateway/internal/inbound/protocol_log_test.go | 38 ++++ gateway/internal/inbound/server.go | 81 ++++++++ gateway/internal/inbound/server_test.go | 15 ++ src/api/adminApi.ts | 10 + src/apps/admin/AdminRechargeRecordsPage.tsx | 31 ++- .../admin/AdminSmsApplicationFormPage.tsx | 43 +++- src/components/ui/RechargeReceiptDialog.tsx | 108 ++++++++++ src/components/ui/index.ts | 1 + src/styles/global.css | 190 +++++++++++++++++- 22 files changed, 829 insertions(+), 48 deletions(-) create mode 100644 api/prisma/migrations/20260724143000_derive_application_delivery_modes/migration.sql create mode 100644 api/src/open-api/delivery-mode.ts create mode 100644 src/components/ui/RechargeReceiptDialog.tsx diff --git a/api/prisma/migrations/20260724143000_derive_application_delivery_modes/migration.sql b/api/prisma/migrations/20260724143000_derive_application_delivery_modes/migration.sql new file mode 100644 index 0000000..26b3fc9 --- /dev/null +++ b/api/prisma/migrations/20260724143000_derive_application_delivery_modes/migration.sql @@ -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"; diff --git a/api/src/open-api/delivery-mode.ts b/api/src/open-api/delivery-mode.ts new file mode 100644 index 0000000..6503236 --- /dev/null +++ b/api/src/open-api/delivery-mode.ts @@ -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'; +} diff --git a/api/src/open-api/open-api.service.spec.ts b/api/src/open-api/open-api.service.spec.ts index c4a17d3..18cc038 100644 --- a/api/src/open-api/open-api.service.spec.ts +++ b/api/src/open-api/open-api.service.spec.ts @@ -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() { diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts index 79ac516..ef476cc 100644 --- a/api/src/open-api/open-api.service.ts +++ b/api/src/open-api/open-api.service.ts @@ -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, '回调超时'), diff --git a/api/src/send-chain/gateway-events.controller.spec.ts b/api/src/send-chain/gateway-events.controller.spec.ts index fb1dede..ae2a585 100644 --- a/api/src/send-chain/gateway-events.controller.spec.ts +++ b/api/src/send-chain/gateway-events.controller.spec.ts @@ -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 () => { diff --git a/api/src/send-chain/gateway-events.controller.ts b/api/src/send-chain/gateway-events.controller.ts index 76d8bfc..43b8ff6 100644 --- a/api/src/send-chain/gateway-events.controller.ts +++ b/api/src/send-chain/gateway-events.controller.ts @@ -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'); diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 632634b..b89c999 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -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 diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index e99fa92..6308196 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -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, diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 6821729..2861213 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -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 = { diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 23e23c1..148e0cb 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -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; }); } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 2a5da31..cf90f1a 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1620,6 +1620,8 @@ - 最终入账前必须调用真实后端预检并重复展示企业名称、编码、唯一ID、操作方向、当前现金余额、本次变动、预计现金余额和备注;余额以PostgreSQL账户读取结果为准,不能用前端静态计算冒充资格检查。 - 人工充值请求必须使用当前会话操作者、8至128位幂等键和账户`updatedAt`版本。相同幂等键同一请求返回原订单/操作单和`replayed=true`;不同范围复用键或账户版本变化必须返回冲突并要求重新核对。 - RechargeOrder、TenantAccount余额增量、AccountTransaction和OperationLog必须在同一Serializable事务内原子完成;审计记录需包含前余额、变动金额、后余额、订单号、原因和幂等键。正数为充值,负数为冲正,金额精确到小数点后4位且不得为0。 +- 运营端充值记录必须提供可截图的账户充值回执。回执只能使用真实充值订单、企业和关联账务流水数据,展示系统真实Logo、入账状态、企业名称与编码、订单号、入账时间、前后余额、入账方式和备注;不得使用前端临时数据补齐缺失字段。 +- 回执的“本次充值金额”按实际精度显示:整数金额不显示小数部分,存在小数时仅保留有效小数位;前后余额继续遵循平台统一的四位金额精度。 ## 2026-07-22 UI/UX A7公共Dialog契约 @@ -1672,3 +1674,6 @@ 5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。 6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT` 和 `SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。 7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered` 并向企业应用投递一次最终回执;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。 +8. 长短信任一分片返回非成功终态时,系统必须通过该分片审计关联的提交记录识别当前发送尝试,不得仅以主记录保存的首片上游消息号判断;确认属于当前尝试后,整条短信立即进入失败/补发或退款终态,无需等待其余分片回执。 +9. 回执和上行投递方式不得由运营人员选择。企业应用开通CMPP接口即按CMPP投递,开通HTTP接口且对应Webhook地址非空即按HTTP投递,两者同时满足时双投;任一地址为空时只跳过该类HTTP事件。运营端企业应用HTTP参数页必须始终可编辑回执和上行Webhook地址,不因HTTP接口开关关闭而隐藏。 +10. Gateway向企业应用发送真实 `CMPP_DELIVER` 以及收到企业应用真实 `CMPP_DELIVER_RESP` 时,都必须各写一条通讯交互日志,分别使用“平台→企业应用”和“企业应用→平台”方向;下游投递记录继续承担排队、重试和ACK业务状态,不得以通讯日志替代。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index c315861..0bc9f2c 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -3408,6 +3408,7 @@ npm run verify:phase8 | TC-BILLING-011 | 分别准备 `余额+授信` 为正数、0 和负数的账户,使用相同短信费用发起发送。 | 和为正数时允许发送;和为 0 或负数时提示余额不足。判断公式为 `balanceCents + creditCents > 0`,与本次费用和套餐无关。 | | TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个消息在提交前失败并释放冻结;另准备一笔任务冻结转扣费时的批次级释放。 | 最终失败只生成一条 `refunded` 并计入“今日返还”,重复回执不重复退款;提交前失败生成 `released + relatedType=sms_message_record` 并计入“今日返还”;冻结转扣费的 `released + relatedType=sms_batch_task` 属于内部转换,不计入“今日返还”;客户端和运营端当日金额一致且保留三位小数。 | | TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;启动 API 定时扫描并模拟重复扫描。 | 两类短信都转为 timeout 并退款;任务进度刷新;同一短信只退款一次;定时扫描默认启用且每 5 分钟执行。 | +| TC-BILLING-014 | 在运营端充值记录中分别打开整数金额、含1至4位有效小数、负数冲正以及缺少可追溯余额的真实订单回执。 | 每行提供“查看回执”;弹窗左上只使用系统真实Logo;企业、订单号、时间、备注与数据库订单一致;可追溯订单的入账前余额等于入账后余额减本次变动;无快照时前后余额不得伪造;主金额整数不显示小数,非整数仅显示有效小数,余额仍显示四位精度;正数显示已入账,负数显示已冲正。 | | TC-SEC-006 | 安装 API 生产依赖并执行 `npm audit`;使用缺文件、多文件、超大文件、超量字段和正常单文件调用认证后的 multipart 上传接口。 | NestJS/Multer/Hono 已升级或锁定到修复版本,生产依赖 audit 为 0;接口只接受一个不超过 20MB 的文件,并限制字段、part、字段名、字段值和 header pair 数量;异常请求返回受控 4xx,正常文件仍写入真实 MinIO 和 `FileObject`。 | ### 17.5.1 报表对账细化 @@ -3797,3 +3798,7 @@ npm run verify:phase8 - `TC-RECEIPT-SHARED-010`:供应商账号、Gateway主机、端口、协议和CMPP版本均相同的两个物理通道连接中,回执从副连接进入、原连接存在唯一`gatewayMessageId + DestTerminalId`分片候选时,应写入原提交逻辑通道;账号或端点任一不同、或候选超过一条时不得自动匹配。 - `TC-RECEIPT-LONG-011`:两分片长短信仅收到第一片`DELIVRD`时,`SmsMessageRecord`保持`submitted`且不创建企业应用最终回执;第二片到达后两条分片审计均为`delivered`,主记录只聚合一次为`delivered`,重复回执不得重复投递、扣费或退款。 - `TC-PROTOCOL-LOG-012`:供应商长短信每个真实分片分别产生一条`平台→供应商通道/CMPP_SUBMIT`和一条`供应商通道→平台/CMPP_SUBMIT_RESP`;内部`submit-result`聚合回调不得额外落协议日志。 +- `TC-RECEIPT-LONG-013`:两分片长短信主记录保存首片上游消息号,第二片返回`YL:1014`等任意非成功状态且首片未回执;系统通过第二片审计识别当前提交尝试,整条短信进入失败/补发或退款终态并只投递一次最终失败回执,不再卡在`submitted`。 +- `TC-DELIVERY-AUTO-014`:分别配置仅CMPP、仅HTTP、CMPP+HTTP、两者均关闭四种应用状态;回执与上行分别只产生CMPP下游记录、HTTP Webhook事件、两者各一条、均不产生。修改历史手工投递模式不得改变自动计算结果。 +- `TC-HTTP-WEBHOOK-015`:运营端关闭HTTP接口后,回执和上行Webhook地址输入框仍显示且可保存;任一地址保存为空时删除对应有效端点,后续不推送该类HTTP事件,另一非空地址不受影响。 +- `TC-PROTOCOL-LOG-016`:在线企业应用收到回执或上行 `CMPP_DELIVER` 并返回 `CMPP_DELIVER_RESP`;通讯日志各出现一条“平台→企业应用/DELIVER”和“企业应用→平台/DELIVER_RESP”,结果、消息号、序列号和投递记录一致,下游投递记录仍独立展示发送、ACK和重试状态。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 1eafb2d..56def16 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2372,3 +2372,22 @@ git diff --check - Gateway重启后5条活动供应商通道有4条立即在线,“富泷物业-联通”首次鉴权失败并按数据库`nextReconnectAt=2026-07-24 12:59:31+08`自动慢重试;到13:00只读复核时5/5均为`connected/currentConnections=1/desiredConnections=1`,最近心跳持续刷新、`nextReconnectAt`与`lastError`清空。 - 公网首页、运营登录、客户端登录和API health均HTTP 200,公网CMPP 17890 TCP连接成功。真实浏览器加载运营端登录页,标题正确、页面无横向溢出、控制台0条业务error/warn;因图形验证码保护,本轮未绕过登录,登录后通讯日志页面仍需下一次人工登录结合真实短信复测。 - 发布后API/Gateway日志未出现panic、fatal、unhandled、exception、通讯遥测失败或`SMS message record not found`。本次未发送真实短信、未修改或回填`13127620092`历史业务数据;跨连接真实供应商回执和长短信两片最终聚合仍需下一次授权测试短信或自然业务回执验证。 + +## 2026-07-24 运营端账户充值回执(本地未提交、未部署) + +- 运营端充值记录每行新增“查看回执”操作,弹窗直接使用真实`RechargeOrder`、企业信息及订单关联的`balanceAfterCents`,据此计算入账前余额;历史记录缺少可追溯余额时显示`-`,不使用当前账户余额或前端假数据补齐。 +- 回执左上只展示系统现有`/logo/logo1.png`真实Logo;展示入账状态、本次金额、企业名称和编码、订单号、入账时间、前后余额、入账方式与备注,适合客户截图留存。 +- “本次充值金额”采用实际精度:整数不显示小数,存在小数时移除末尾无效零;前后余额继续显示平台统一四位精度。正数显示“已入账”,负数冲正显示“已冲正”。 +- 金额边界验证结果:`10000.0000 → 10,000`、`10000.2500 → 10,000.25`、`10000.0001 → 10,000.0001`、负数冲正`-123.4500 → 123.45`,符合主金额按实际精度展示口径。 +- 使用Node.js v24.14.0执行前端TypeScript和Vite生产构建通过,保留既有约1.93MB单chunk/579.51KB gzip警告;`git diff --check`通过。首次由系统旧Node执行时Vite不支持`??=`且错误返回0,已明确排除,未将其计为通过。 +- 浏览器加载真实本地前端/API后进入运营登录页,页面标题正确、控制台0条error/warn;由于当前浏览器无有效会话且存在图形验证码,本轮未绕过验证码,登录后的“查看回执”点击与视觉验收仍需人工登录后补测。 +- 本轮按要求保持未提交、未推送、未部署;预发布仍运行既有版本,不包含充值回执功能。 + +## 2026-07-24 长短信失败终态、自动双通道投递与企业侧通讯日志(发布前) + +- 预发布只读复核号码`18821203795`的消息`MSG-e4ded553-8f08-4f7d-85ae-06a30b163e86`:主记录保存首片上游消息号`736078096474128384`,第二片`736078096490905600`收到`undelivered/YL:1014`,首片无回执。原逻辑只按主记录首片消息号查提交记录,导致第二片虽写入分片审计,但被误判为非当前尝试,主记录卡在`submitted`且未建立最终下游投递。 +- 修复为优先通过`SmsMessageSegmentAudit.gatewayMessageId`取得该分片所属`submitRecordId/submitId/channelId`,再执行当前尝试判断。长短信任一分片明确失败即可沿既有补发、退款和最终回执链路使整条短信终态化,不等待缺失分片;供应商原始非成功码(包括`YL:1014`)保持原样,不增加码表。 +- HTTP和CMPP投递改为按接口能力自动派生:CMPP开通即建CMPP下游投递,HTTP开通且对应Webhook地址有效即建HTTP事件,两者同时开通时双投。运营端不再提供回执/上行投递方式选择,HTTP关闭时仍显示并允许维护两个Webhook地址,保存空地址会删除对应端点并停止该类HTTP推送。 +- Gateway补充企业侧真实`CMPP_DELIVER`发送成功/失败以及`CMPP_DELIVER_RESP`接收结果通讯日志,API白名单允许`platform_to_client + deliver_receipt/deliver_uplink`和`client_to_platform + deliver_resp`。通讯日志只描述真实协议报文;`CmppDownstreamDelivery`继续保存排队、发送、ACK、失败和重试业务状态,两者不合并。 +- 新增migration`20260724143000_derive_application_delivery_modes`,按当前CMPP/HTTP开通状态回填历史配置的派生模式,避免旧人工模式继续影响展示或参数复制。 +- 已完成定向回归:OpenAPI、短信配置和Gateway事件3 suites/67项通过;SendChain新增长短信非首片失败、仅HTTP投递和CMPP关闭3项通过;Gateway inbound全量通过并覆盖企业侧DELIVER/DELIVER_RESP日志。另一个会话的运营端账户充值回执源码和文档已一并纳入本发布分支,原工作区未覆盖。 diff --git a/gateway/internal/inbound/protocol_log_test.go b/gateway/internal/inbound/protocol_log_test.go index 722d887..6902855 100644 --- a/gateway/internal/inbound/protocol_log_test.go +++ b/gateway/internal/inbound/protocol_log_test.go @@ -4,8 +4,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "sync" "testing" "time" + + cmpp "github.com/bigwhite/gocmpp" ) func TestSubmitResponseProtocolLoggerEmitsActualPacketDirection(t *testing.T) { @@ -50,3 +53,38 @@ func TestSubmitResponseProtocolLoggerEmitsActualPacketDirection(t *testing.T) { t.Fatal("timed out waiting for protocol event") } } + +func TestDownstreamDeliverProtocolLoggerEmitsReceiptPacket(t *testing.T) { + events := make(chan protocolLogEvent, 1) + session := &downstreamSession{ + account: "607532", tenantID: "tenant-1", applicationID: "app-1", + messageID: "MSG-LONG-1", phoneNumber: "18821203795", mu: &sync.Mutex{}, + protocolLog: func(event protocolLogEvent) { events <- event }, + } + session.recordDownstreamProtocol( + &cmpp.Cmpp2DeliverReqPkt{ + MsgId: 736078096490905600, SrcTerminalId: "18821203795", RegisterDelivery: 1, + }, + "delivery-1", + 71, + 736078096490905600, + "success", + "", + nil, + ) + + select { + case event := <-events: + if event.Protocol != "cmpp" || event.Direction != "platform_to_client" || event.EventType != "deliver_receipt" { + t.Fatalf("unexpected protocol event: %+v", event) + } + if event.TenantID != "tenant-1" || event.ApplicationID != "app-1" || event.Account != "607532" { + t.Fatalf("unexpected application identifiers: %+v", event) + } + if event.MessageID != "MSG-LONG-1" || event.GatewayMessageID != "736078096490905600" || event.Phone != "18821203795" { + t.Fatalf("unexpected message identifiers: %+v", event) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for downstream deliver protocol event") + } +} diff --git a/gateway/internal/inbound/server.go b/gateway/internal/inbound/server.go index 26a0cb6..6ca4dec 100644 --- a/gateway/internal/inbound/server.go +++ b/gateway/internal/inbound/server.go @@ -174,6 +174,8 @@ type downstreamConnectionEvent struct { type downstreamSession struct { messageID string account string + tenantID string + applicationID string enterpriseCode string protocol string srcID string @@ -188,6 +190,7 @@ type downstreamSession struct { instanceID string report func(*downstreamSession, string, string) deliveryReport func(downstreamDeliveryLifecycleEvent) + protocolLog func(protocolLogEvent) } var downstreamRegistry = struct { @@ -244,6 +247,8 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger now := time.Now().UTC() session := &downstreamSession{ account: strings.TrimSpace(defaultString(auth.Account, account)), + tenantID: strings.TrimSpace(auth.TenantID), + applicationID: strings.TrimSpace(auth.ApplicationID), enterpriseCode: strings.TrimSpace(auth.EnterpriseCode), protocol: cmppVersionName(req.Version), srcID: strings.TrimSpace(auth.Account), @@ -256,6 +261,7 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger instanceID: s.gatewayInstanceID(), report: s.reportConnection, deliveryReport: s.reportDownstreamDelivery, + protocolLog: s.emitProtocolLog, } if !rememberAccount(session, auth.MaxConnections) { logger.Printf("cmpp inbound auth failed account=%s remote=%s err=connection limit exceeded max=%d", account, packet.Conn.Conn.RemoteAddr(), auth.MaxConnections) @@ -372,6 +378,8 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge rememberDownstream(downstreamSession{ messageID: acceptedMessage.MessageID, account: account, + tenantID: result.TenantID, + applicationID: result.ApplicationID, enterpriseCode: session.enterpriseCode, protocol: clientProtocol, srcID: strings.TrimSpace(req.srcID), @@ -386,6 +394,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge instanceID: s.gatewayInstanceID(), report: session.report, deliveryReport: session.deliveryReport, + protocolLog: session.protocolLog, }) } if current := findSessionByConn(packet.Conn); current != nil && current.report != nil { @@ -1282,12 +1291,14 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt) if err := session.conn.SendPkt(deliver, sequenceID); err != nil { removeDownstreamAck(tracker) + session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err) if session.report != nil { go session.report(session, "disconnected", err.Error()) } forgetDownstream(session) return DownstreamSendResult{}, err } + session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "success", "", nil) result := DownstreamSendResult{ Sent: true, ConnectionID: session.connectionID, SequenceID: strconv.FormatUint(uint64(sequenceID), 10), MessageID: strconv.FormatUint(messageID, 10), @@ -1306,6 +1317,56 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID return result, nil } +func (session *downstreamSession) recordDownstreamProtocol( + deliver cmpp.Packer, + deliveryID string, + sequenceID uint32, + messageID uint64, + status string, + resultCode string, + sendErr error, +) { + if session == nil || session.protocolLog == nil { + return + } + eventType, phone := downstreamDeliverMetadata(deliver) + detail := map[string]any{"sequenceId": sequenceID, "deliveryId": deliveryID} + if sendErr != nil { + detail["error"] = sendErr.Error() + } + session.protocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "platform_to_client", + EventType: eventType, + Status: status, + TenantID: session.tenantID, + ApplicationID: session.applicationID, + Account: session.account, + MessageID: session.messageID, + GatewayMessageID: strconv.FormatUint(messageID, 10), + Phone: defaultString(phone, session.phoneNumber), + ResultCode: resultCode, + Detail: detail, + }) +} + +func downstreamDeliverMetadata(deliver cmpp.Packer) (string, string) { + switch packet := deliver.(type) { + case *cmpp.Cmpp2DeliverReqPkt: + if packet.RegisterDelivery == 1 { + return "deliver_receipt", packet.SrcTerminalId + } + return "deliver_uplink", packet.SrcTerminalId + case *cmpp.Cmpp3DeliverReqPkt: + if packet.RegisterDelivery == 1 { + return "deliver_receipt", packet.SrcTerminalId + } + return "deliver_uplink", packet.SrcTerminalId + default: + return "deliver", "" + } +} + func downstreamDeliverMessageID(deliver cmpp.Packer) uint64 { switch packet := deliver.(type) { case *cmpp.Cmpp2DeliverReqPkt: @@ -1389,6 +1450,26 @@ func handleDownstreamAcknowledgement(conn *cmpp.Conn, sequenceID uint32, message SequenceID: sequenceID, MessageID: messageID, Result: result, ObservedAt: time.Now().UTC(), }) } + if tracker.session != nil && tracker.session.protocolLog != nil { + status := "success" + if result != 0 { + status = "failed" + } + tracker.session.protocolLog(protocolLogEvent{ + Protocol: "cmpp", + Direction: "client_to_platform", + EventType: "deliver_resp", + Status: status, + TenantID: tracker.session.tenantID, + ApplicationID: tracker.session.applicationID, + Account: tracker.session.account, + MessageID: tracker.session.messageID, + GatewayMessageID: strconv.FormatUint(messageID, 10), + Phone: tracker.session.phoneNumber, + ResultCode: strconv.FormatUint(uint64(result), 10), + Detail: map[string]any{"sequenceId": sequenceID, "deliveryId": tracker.deliveryID}, + }) + } } func downstreamAckTimeout() time.Duration { diff --git a/gateway/internal/inbound/server_test.go b/gateway/internal/inbound/server_test.go index 307280f..a44f977 100644 --- a/gateway/internal/inbound/server_test.go +++ b/gateway/internal/inbound/server_test.go @@ -912,10 +912,14 @@ func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) { defer resetDownstreamRegistry() events := make(chan downstreamDeliveryLifecycleEvent, 1) + protocolEvents := make(chan protocolLogEvent, 1) conn := &cmpp.Conn{} session := &downstreamSession{ conn: conn, connectionID: "conn-1", deliveryReport: func(event downstreamDeliveryLifecycleEvent) { events <- event }, + tenantID: "tenant-1", applicationID: "app-1", account: "607532", + messageID: "MSG-LONG-1", phoneNumber: "18821203795", + protocolLog: func(event protocolLogEvent) { protocolEvents <- event }, } registerDownstreamAck(session, "delivery-1", 37, 9016479179509871733, time.Now().Add(time.Second)) handleDownstreamAcknowledgement(conn, 37, 9016479179509871733, 0, log.Default()) @@ -928,6 +932,17 @@ func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) { case <-time.After(time.Second): t.Fatal("timed out waiting acknowledgement event") } + select { + case event := <-protocolEvents: + if event.Protocol != "cmpp" || event.Direction != "client_to_platform" || event.EventType != "deliver_resp" { + t.Fatalf("unexpected acknowledgement protocol event: %+v", event) + } + if event.MessageID != "MSG-LONG-1" || event.GatewayMessageID != "9016479179509871733" || event.ResultCode != "0" { + t.Fatalf("unexpected acknowledgement identifiers: %+v", event) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting acknowledgement protocol event") + } } func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testing.T) { diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index b935caa..3e3f3a6 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -1432,6 +1432,16 @@ export const adminApi = { request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), getApplicationHttpApiConfig: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/http-api`), updateApplicationHttpApiConfig: (applicationId: string, body: Partial & { ipAllowlist?: string[] }) => request(`/admin/enterprise-applications/${applicationId}/http-api`, { method: 'PUT', body: JSON.stringify(body) }), + listApplicationHttpWebhooks: (applicationId: string) => + request(`/admin/enterprise-applications/${applicationId}/http-api/webhooks`), + saveApplicationHttpWebhook: ( + applicationId: string, + eventType: 'receipt' | 'uplink', + body: { url: string; rotateSecret?: boolean; status?: string }, + ) => request(`/admin/enterprise-applications/${applicationId}/http-api/webhooks/${eventType}`, { + method: 'PUT', + body: JSON.stringify(body), + }), listChannels: () => request('/admin/channels'), listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => request>(withQuery('/admin/reports/reconciliation', query)), diff --git a/src/apps/admin/AdminRechargeRecordsPage.tsx b/src/apps/admin/AdminRechargeRecordsPage.tsx index bdf1bb5..d848aea 100644 --- a/src/apps/admin/AdminRechargeRecordsPage.tsx +++ b/src/apps/admin/AdminRechargeRecordsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { Plus, Search } from 'lucide-react'; -import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, Tag, type DateRangeValue } from '@/components/ui'; +import { Plus, ReceiptText, Search } from 'lucide-react'; +import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, RechargeReceiptDialog, Tag, type DateRangeValue } from '@/components/ui'; import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; import { formatCents } from '@/utils/currency'; @@ -24,6 +24,7 @@ export function AdminRechargeRecordsPage() { const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [dateRange, setDateRange] = useState({}); const [manualOpen, setManualOpen] = useState(false); + const [receiptRecord, setReceiptRecord] = useState(null); const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -67,6 +68,9 @@ export function AdminRechargeRecordsPage() { const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); const currentPage = Math.min(page, totalPages); const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const receiptTenant = receiptRecord + ? receiptRecord.tenant ?? tenants.find((tenant) => tenant.id === receiptRecord.tenantId) + : undefined; useEffect(() => { setPage(1); @@ -107,15 +111,16 @@ export function AdminRechargeRecordsPage() { 充值后余额 充值类型 备注 + 操作 {error ? ( - {error} + {error} ) : loading ? ( - 正在加载真实充值记录... + 正在加载真实充值记录... ) : filteredRows.length === 0 ? ( - 暂无真实充值记录 + 暂无真实充值记录 ) : visibleRows.map((record) => { const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId; return ( @@ -126,6 +131,16 @@ export function AdminRechargeRecordsPage() { {record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatCents(record.balanceAfterCents)}`} 人工充值 + + + ); })} @@ -157,6 +172,12 @@ export function AdminRechargeRecordsPage() { balanceCents: accounts.find((account) => account.tenantId === tenant.id)?.balanceCents ?? 0, }))} /> + setReceiptRecord(null)} + open={Boolean(receiptRecord)} + record={receiptRecord} + tenant={receiptTenant} + /> ); } diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 268e98f..77b0eef 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -15,13 +15,6 @@ const carrierMeta: Record = { telecom: { label: '电信', description: '电信号码只会进入电信通道组' }, }; -const deliveryModeOptions = [ - { label: '仅 CMPP', value: 'cmpp' }, - { label: '仅 HTTP', value: 'http' }, - { label: 'CMPP + HTTP 双投', value: 'both' }, - { label: '不投递', value: 'none' }, -]; - const httpCapabilityOptions: Array<{ key: keyof HttpApiConfig; label: string }> = [ { key: 'sendEnabled', label: '单条发送' }, { key: 'messageQueryEnabled', label: '状态查询' }, @@ -62,6 +55,8 @@ export function AdminSmsApplicationFormPage() { allowClientManualRetry: true, allowClientTest: true, }); const [httpIpAddress, setHttpIpAddress] = useState(''); + const [receiptWebhookUrl, setReceiptWebhookUrl] = useState(''); + const [uplinkWebhookUrl, setUplinkWebhookUrl] = useState(''); const [groups, setGroups] = useState([]); const [mobileGroupId, setMobileGroupId] = useState(''); const [unicomGroupId, setUnicomGroupId] = useState(''); @@ -128,10 +123,15 @@ export function AdminSmsApplicationFormPage() { useEffect(() => { if (!appId) return; let cancelled = false; - adminApi.getApplicationHttpApiConfig(appId).then((result) => { + Promise.all([ + adminApi.getApplicationHttpApiConfig(appId), + adminApi.listApplicationHttpWebhooks(appId), + ]).then(([result, webhooks]) => { if (cancelled) return; if (result.config) setHttpConfig(result.config); setHttpIpAddress(result.ipAllowlist.join('\n')); + setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? ''); + setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? ''); }).catch((failure: Error) => { if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败'); }); @@ -245,6 +245,10 @@ export function AdminSmsApplicationFormPage() { })), }); await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) }); + await Promise.all([ + adminApi.saveApplicationHttpWebhook(application.id, 'receipt', { url: receiptWebhookUrl.trim() }), + adminApi.saveApplicationHttpWebhook(application.id, 'uplink', { url: uplinkWebhookUrl.trim() }), + ]); goBack(); } catch (failure) { setError(failure instanceof Error ? failure.message : '短信应用保存失败'); @@ -403,8 +407,6 @@ export function AdminSmsApplicationFormPage() { setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} /> setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} /> setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} /> - setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} /> setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} /> setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
HTTP 安全与重试
@@ -413,7 +415,26 @@ export function AdminSmsApplicationFormPage() {
- ) :
HTTP 接口未开通,能力、鉴权和 Webhook 参数已收起。
} + ) :
HTTP 接口未开通,接口能力和鉴权参数已收起。
} +
+ setReceiptWebhookUrl(event.target.value)} + placeholder="https://example.com/webhooks/sms/receipt" + value={receiptWebhookUrl} + /> + setUplinkWebhookUrl(event.target.value)} + placeholder="https://example.com/webhooks/sms/uplink" + value={uplinkWebhookUrl} + /> +
+
投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。
+
+
diff --git a/src/components/ui/RechargeReceiptDialog.tsx b/src/components/ui/RechargeReceiptDialog.tsx new file mode 100644 index 0000000..0b66755 --- /dev/null +++ b/src/components/ui/RechargeReceiptDialog.tsx @@ -0,0 +1,108 @@ +import { CheckCircle2 } from 'lucide-react'; +import type { RechargeOrder, TenantOption } from '@/api/adminApi'; +import { formatCents } from '@/utils/currency'; +import { formatDateTime } from '@/utils/dateTime'; +import { Button } from './Button'; +import { Modal } from './Modal'; + +type RechargeReceiptDialogProps = { + open: boolean; + record: RechargeOrder | null; + tenant?: Pick; + onClose: () => void; +}; + +export function formatReceiptAmount(moneyUnits: number) { + return formatCents(Math.abs(moneyUnits)).replace(/\.?0+$/, ''); +} + +export function RechargeReceiptDialog({ + open, + record, + tenant, + onClose, +}: RechargeReceiptDialogProps) { + if (!record) return null; + + const isCorrection = record.amountCents < 0; + const balanceAfter = record.balanceAfterCents; + const balanceBefore = balanceAfter === null || balanceAfter === undefined + ? null + : balanceAfter - record.amountCents; + const enterpriseName = record.tenant?.name ?? tenant?.name ?? record.tenantId; + const enterpriseCode = record.tenant?.code ?? tenant?.code ?? '-'; + + return ( + 完成} + onClose={onClose} + open={open} + title="账户充值回执" + > +
+
+ 聆界短信服务平台 + + +
+ +
+ 本次{isCorrection ? '冲正' : '充值'}金额 + + {isCorrection ? '−' : '+'} + ¥ + {formatReceiptAmount(record.amountCents)} + +
+ +
+ 入账企业 + {enterpriseName} + 企业编号:{enterpriseCode} +
+ +
+
+
回执编号
+
{record.orderNo}
+
+
+
入账时间
+
{formatDateTime(record.paidAt ?? record.createdAt)}
+
+
+
入账前余额
+
{balanceBefore === null ? '-' : `¥${formatCents(balanceBefore)}`}
+
+
+
入账后余额
+
{balanceAfter === null || balanceAfter === undefined ? '-' : `¥${formatCents(balanceAfter)}`}
+
+
+
入账方式
+
平台运营端 · 人工{isCorrection ? '冲正' : '充值'}
+
+
+
交易状态
+
{isCorrection ? '冲正完成' : '充值完成'}
+
+
+ +
+ 备注 +

{record.remark || '无'}

+
+ + +
+
+ ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 35ab651..5ff27a1 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -10,6 +10,7 @@ export { RiskAction } from './RiskAction'; export { DeleteRiskAction } from './DeleteRiskAction'; export { ManualRechargeDialog } from './ManualRechargeDialog'; export type { ManualRechargeTarget } from './ManualRechargeDialog'; +export { RechargeReceiptDialog } from './RechargeReceiptDialog'; export { Input } from './Input'; export { Modal } from './Modal'; export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives'; diff --git a/src/styles/global.css b/src/styles/global.css index 9b4c14d..8a1e739 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -9702,7 +9702,7 @@ h3 { } .admin-recharge-table { - min-width: 1220px; + min-width: 1350px; } .admin-recharge-table th { @@ -9726,6 +9726,194 @@ h3 { font-variant-numeric: tabular-nums; } +.recharge-receipt { + background: + linear-gradient(135deg, rgba(217, 195, 160, 0.18), transparent 42%), + var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; + position: relative; +} + +.recharge-receipt::before { + background: linear-gradient(90deg, var(--color-brand), var(--color-accent-strong)); + content: ''; + height: 4px; + inset: 0 0 auto; + position: absolute; +} + +.recharge-receipt__header { + align-items: center; + display: flex; + justify-content: space-between; + min-height: 74px; + padding: var(--space-6) var(--space-7) var(--space-4); +} + +.recharge-receipt__logo { + display: block; + height: 38px; + max-width: 190px; + object-fit: contain; + object-position: left center; + width: auto; +} + +.recharge-receipt__header > span { + align-items: center; + border-radius: var(--radius-full); + display: inline-flex; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + gap: var(--space-2); + padding: 7px 12px; +} + +.recharge-receipt__header .is-posted { + background: var(--color-success-soft); + color: var(--color-success); +} + +.recharge-receipt__header .is-correction { + background: var(--color-warning-soft); + color: var(--color-warning); +} + +.recharge-receipt__amount { + border-bottom: 1px solid var(--color-border); + display: grid; + justify-items: center; + padding: var(--space-5) var(--space-7) var(--space-8); +} + +.recharge-receipt__amount > span, +.recharge-receipt__enterprise > span, +.recharge-receipt__remark > span { + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.recharge-receipt__amount strong { + color: var(--color-text-strong); + font-size: clamp(36px, 7vw, 52px); + font-variant-numeric: tabular-nums; + letter-spacing: -0.035em; + line-height: 1.15; + margin-top: var(--space-2); +} + +.recharge-receipt__amount small { + font-size: 0.58em; + font-weight: var(--font-weight-semibold); + margin: 0 var(--space-1); +} + +.recharge-receipt__enterprise { + display: grid; + gap: var(--space-1); + padding: var(--space-6) var(--space-7); +} + +.recharge-receipt__enterprise strong { + color: var(--color-text-strong); + font-size: var(--font-size-xl); +} + +.recharge-receipt__enterprise small { + color: var(--color-text-muted); +} + +.recharge-receipt__details { + background: var(--color-bg-subtle); + border-bottom: 1px solid var(--color-border); + border-top: 1px solid var(--color-border); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + padding: var(--space-2) var(--space-7); +} + +.recharge-receipt__details > div { + display: grid; + gap: var(--space-1); + min-width: 0; + padding: var(--space-4) 0; +} + +.recharge-receipt__details > div:nth-child(even) { + padding-left: var(--space-6); +} + +.recharge-receipt__details dt { + color: var(--color-text-muted); + font-size: var(--font-size-xs); +} + +.recharge-receipt__details dd { + color: var(--color-text-strong); + font-variant-numeric: tabular-nums; + font-weight: var(--font-weight-medium); + margin: 0; + overflow-wrap: anywhere; +} + +.recharge-receipt__remark { + display: grid; + gap: var(--space-2); + padding: var(--space-5) var(--space-7); +} + +.recharge-receipt__remark p { + color: var(--color-text-strong); + line-height: var(--line-height-loose); + margin: 0; + overflow-wrap: anywhere; +} + +.recharge-receipt__note { + background: var(--color-bg-subtle); + border-top: 1px dashed var(--color-border-strong); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + padding: var(--space-4) var(--space-7); + text-align: center; +} + +@media (max-width: 560px) { + .recharge-receipt__header, + .recharge-receipt__enterprise, + .recharge-receipt__remark { + padding-left: var(--space-5); + padding-right: var(--space-5); + } + + .recharge-receipt__logo { + height: 32px; + max-width: 150px; + } + + .recharge-receipt__details { + grid-template-columns: 1fr; + padding-left: var(--space-5); + padding-right: var(--space-5); + } + + .recharge-receipt__details > div { + border-bottom: 1px solid var(--color-border); + } + + .recharge-receipt__details > div:last-child { + border-bottom: 0; + } + + .recharge-receipt__details > div:nth-child(even) { + padding-left: 0; + } +} + .manual-recharge-review, .manual-recharge-result { display: grid;