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' }) })); 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 = { const prisma = {
smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) },
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) }, 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' } }); 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 = { 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)) }, smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() }, smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
$transaction: jest.fn((operations) => Promise.all(operations)), $transaction: jest.fn((operations) => Promise.all(operations)),
@@ -94,11 +94,35 @@ describe('OpenApiService', () => {
uplinkWebhookEnabled: true, uplinkWebhookEnabled: true,
uplinkQueryEnabled: true, uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true, credentialSelfServiceEnabled: true,
receiptDeliveryMode: 'http', receiptDeliveryMode: 'both',
uplinkDeliveryMode: 'http', 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() { 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 { decryptSecret, encryptSecret } from './open-api.crypto';
import type { OpenApiAuthContext } from './open-api.types'; import type { OpenApiAuthContext } from './open-api.types';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { automaticDeliveryMode } from './delivery-mode';
const WEBHOOK_QUEUE = 'http-webhook-delivery'; 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]; const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400];
export type HttpConfigInput = { export type HttpConfigInput = {
@@ -75,7 +75,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) { async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) {
const application = await this.requireApplication(applicationId, tenantId); 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 ipAllowlist = normalizeIpAllowlist(input.ipAllowlist);
const [config] = await this.prisma.$transaction([ const [config] = await this.prisma.$transaction([
this.prisma.smsApplicationHttpConfig.upsert({ 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) { async upsertWebhookEndpoint(applicationId: string, eventType: string, data: { url: string; rotateSecret?: boolean; status?: string }, tenantId?: string) {
const application = await this.requireApplication(applicationId, tenantId); const application = await this.requireApplication(applicationId, tenantId);
if (!['receipt', 'uplink'].includes(eventType)) throw new BadRequestException('eventType only supports receipt or uplink'); 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 url = await validateWebhookUrl(data.url, application.httpConfig?.requireHttps ?? true);
const existing = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId, eventType } } }); const existing = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId, eventType } } });
const secret = !existing || data.rotateSecret ? randomBytes(32).toString('base64url') : undefined; 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; if (!data.applicationId) return null;
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true } }); const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true } });
const config = application?.httpConfig; const config = application?.httpConfig;
const mode = data.eventType === 'receipt' ? config?.receiptDeliveryMode : config?.uplinkDeliveryMode;
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled; 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 } } }); const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } } });
if (!endpoint || endpoint.status !== 'active') return null; if (!endpoint || endpoint.status !== 'active') return null;
const event = await this.prisma.httpWebhookEvent.create({ 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 }; 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 enabling = input.enabled === true && existing?.enabled !== true;
const effective = enabling ? { const effective = enabling ? {
sendEnabled: true, sendEnabled: true,
@@ -419,13 +434,10 @@ function normalizeConfig(input: HttpConfigInput, existing?: { enabled?: boolean
uplinkWebhookEnabled: true, uplinkWebhookEnabled: true,
uplinkQueryEnabled: true, uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true, credentialSelfServiceEnabled: true,
receiptDeliveryMode: 'http',
uplinkDeliveryMode: 'http',
...input, ...input,
} : input; } : input;
for (const mode of [effective.receiptDeliveryMode, effective.uplinkDeliveryMode]) { const httpEnabled = effective.enabled ?? existing?.enabled ?? false;
if (mode !== undefined && !DELIVERY_MODES.includes(mode as typeof DELIVERY_MODES[number])) throw new BadRequestException('投递模式仅支持 cmpp、http、both、none'); const deliveryMode = automaticDeliveryMode(cmppEnabled, httpEnabled);
}
return { return {
enabled: effective.enabled, enabled: effective.enabled,
sendEnabled: effective.sendEnabled, sendEnabled: effective.sendEnabled,
@@ -440,8 +452,8 @@ function normalizeConfig(input: HttpConfigInput, existing?: { enabled?: boolean
uplinkRetentionDays: bounded(effective.uplinkRetentionDays, 1, 365, '上行保留天数'), uplinkRetentionDays: bounded(effective.uplinkRetentionDays, 1, 365, '上行保留天数'),
maxQueryRangeDays: bounded(effective.maxQueryRangeDays, 1, 90, '查询跨度'), maxQueryRangeDays: bounded(effective.maxQueryRangeDays, 1, 90, '查询跨度'),
maxPageSize: bounded(effective.maxPageSize, 10, 500, '分页上限'), maxPageSize: bounded(effective.maxPageSize, 10, 500, '分页上限'),
receiptDeliveryMode: effective.receiptDeliveryMode, receiptDeliveryMode: deliveryMode,
uplinkDeliveryMode: effective.uplinkDeliveryMode, uplinkDeliveryMode: deliveryMode,
webhookRetryEnabled: effective.webhookRetryEnabled, webhookRetryEnabled: effective.webhookRetryEnabled,
webhookMaxAttempts: bounded(effective.webhookMaxAttempts, 1, 7, '回调重试次数'), webhookMaxAttempts: bounded(effective.webhookMaxAttempts, 1, 7, '回调重试次数'),
webhookTimeoutSeconds: bounded(effective.webhookTimeoutSeconds, 1, 30, '回调超时'), webhookTimeoutSeconds: bounded(effective.webhookTimeoutSeconds, 1, 30, '回调超时'),
@@ -61,6 +61,27 @@ describe('GatewayEventsController protocol logging', () => {
status: 'success', status: 'success',
messageId: 'MSG-1', messageId: 'MSG-1',
})).toEqual({ accepted: true }); })).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 () => { 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.eventType === 'submit_resp'
) || ( ) || (
body.direction === 'platform_to_client' 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)) { if (body.protocol !== 'cmpp' || !allowedPacket || !['success', 'failed'].includes(body.status)) {
throw new BadRequestException('Unsupported Gateway protocol log event'); throw new BadRequestException('Unsupported Gateway protocol log event');
+123 -6
View File
@@ -322,7 +322,10 @@ function createPrismaMock() {
return prisma; return prisma;
} }
function createService(prisma = createPrismaMock()) { function createService(
prisma = createPrismaMock(),
openApi?: { queueWebhookEvent: jest.Mock },
) {
const billing = { const billing = {
estimateSmsCost: jest.fn().mockReturnValue({ estimateSmsCost: jest.fn().mockReturnValue({
billingUnitsPerMessage: 1, billingUnitsPerMessage: 1,
@@ -347,7 +350,7 @@ function createService(prisma = createPrismaMock()) {
reviewReason: '企业应用已配置模板不匹配进入人工审核', reviewReason: '企业应用已配置模板不匹配进入人工审核',
}), }),
} as unknown as RiskReviewService; } 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['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined); service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
return { service, prisma, billing, riskReview }; return { service, prisma, billing, riskReview };
@@ -780,7 +783,6 @@ describe('SendChainService', () => {
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
}); });
await expect(service.authenticateInboundApplication({ await expect(service.authenticateInboundApplication({
account: '100001', account: '100001',
password: 'secret-hash', password: 'secret-hash',
@@ -788,7 +790,7 @@ describe('SendChainService', () => {
})).rejects.toThrow('CMPP interface is disabled for this application'); })).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(); const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({ prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1', id: 'app-1',
@@ -801,6 +803,15 @@ describe('SendChainService', () => {
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, 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({ await expect(service.submitInboundMessage({
account: '100001', account: '100001',
@@ -813,9 +824,37 @@ describe('SendChainService', () => {
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }), data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }),
}); });
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({ expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
data: expect.objectContaining({ messageRecordId: 'record-1', deliveryType: 'receipt', status: 'pending' }), });
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 () => { 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); 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 () => { it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.smsReceiptRecord.findUnique prisma.smsReceiptRecord.findUnique
+26 -5
View File
@@ -1868,7 +1868,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
const application = await this.prisma.smsApplication.findUnique({ const application = await this.prisma.smsApplication.findUnique({
where: { id: data.applicationId }, 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 { try {
await this.openApi?.queueWebhookEvent({ await this.openApi?.queueWebhookEvent({
@@ -1883,10 +1889,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} catch (error) { } catch (error) {
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(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' if (application?.interfaceEnabled !== true) {
? application?.httpConfig?.receiptDeliveryMode ?? 'cmpp'
: application?.httpConfig?.uplinkDeliveryMode ?? 'cmpp';
if (!['cmpp', 'both'].includes(deliveryMode)) {
return null; return null;
} }
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload }; 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 } }) ? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
: null; : null;
if (exactMessage) { 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({ const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
where: { where: {
messageRecordId: exactMessage.id, 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 () => { it('replaces application carrier channel-group routes with carrier validation', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const tx = { const tx = {
+17 -2
View File
@@ -4,6 +4,7 @@ import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist'; import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money'; import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
export interface CreateSmsApplicationDto { export interface CreateSmsApplicationDto {
tenantId: string; tenantId: string;
@@ -401,7 +402,10 @@ export class SmsConfigService {
} }
async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) { 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) { if (!application) {
throw new NotFoundException('Application not found'); throw new NotFoundException('Application not found');
} }
@@ -435,7 +439,7 @@ export class SmsConfigService {
if (data.ipAllowlist) { if (data.ipAllowlist) {
await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } }); await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } });
} }
return tx.smsApplication.update({ const updated = await tx.smsApplication.update({
where: { id: applicationId }, where: { id: applicationId },
data: { data: {
name: data.name, name: data.name,
@@ -466,6 +470,17 @@ export class SmsConfigService {
}, },
include: { tenant: true, ipAllowlist: true }, 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;
}); });
} }
@@ -1620,6 +1620,8 @@
- 最终入账前必须调用真实后端预检并重复展示企业名称、编码、唯一ID、操作方向、当前现金余额、本次变动、预计现金余额和备注;余额以PostgreSQL账户读取结果为准,不能用前端静态计算冒充资格检查。 - 最终入账前必须调用真实后端预检并重复展示企业名称、编码、唯一ID、操作方向、当前现金余额、本次变动、预计现金余额和备注;余额以PostgreSQL账户读取结果为准,不能用前端静态计算冒充资格检查。
- 人工充值请求必须使用当前会话操作者、8至128位幂等键和账户`updatedAt`版本。相同幂等键同一请求返回原订单/操作单和`replayed=true`;不同范围复用键或账户版本变化必须返回冲突并要求重新核对。 - 人工充值请求必须使用当前会话操作者、8至128位幂等键和账户`updatedAt`版本。相同幂等键同一请求返回原订单/操作单和`replayed=true`;不同范围复用键或账户版本变化必须返回冲突并要求重新核对。
- RechargeOrder、TenantAccount余额增量、AccountTransaction和OperationLog必须在同一Serializable事务内原子完成;审计记录需包含前余额、变动金额、后余额、订单号、原因和幂等键。正数为充值,负数为冲正,金额精确到小数点后4位且不得为0。 - RechargeOrder、TenantAccount余额增量、AccountTransaction和OperationLog必须在同一Serializable事务内原子完成;审计记录需包含前余额、变动金额、后余额、订单号、原因和幂等键。正数为充值,负数为冲正,金额精确到小数点后4位且不得为0。
- 运营端充值记录必须提供可截图的账户充值回执。回执只能使用真实充值订单、企业和关联账务流水数据,展示系统真实Logo、入账状态、企业名称与编码、订单号、入账时间、前后余额、入账方式和备注;不得使用前端临时数据补齐缺失字段。
- 回执的“本次充值金额”按实际精度显示:整数金额不显示小数部分,存在小数时仅保留有效小数位;前后余额继续遵循平台统一的四位金额精度。
## 2026-07-22 UI/UX A7公共Dialog契约 ## 2026-07-22 UI/UX A7公共Dialog契约
@@ -1672,3 +1674,6 @@
5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。 5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。
6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT``SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。 6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT``SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。
7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered` 并向企业应用投递一次最终回执;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。 7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered` 并向企业应用投递一次最终回执;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。
8. 长短信任一分片返回非成功终态时,系统必须通过该分片审计关联的提交记录识别当前发送尝试,不得仅以主记录保存的首片上游消息号判断;确认属于当前尝试后,整条短信立即进入失败/补发或退款终态,无需等待其余分片回执。
9. 回执和上行投递方式不得由运营人员选择。企业应用开通CMPP接口即按CMPP投递,开通HTTP接口且对应Webhook地址非空即按HTTP投递,两者同时满足时双投;任一地址为空时只跳过该类HTTP事件。运营端企业应用HTTP参数页必须始终可编辑回执和上行Webhook地址,不因HTTP接口开关关闭而隐藏。
10. Gateway向企业应用发送真实 `CMPP_DELIVER` 以及收到企业应用真实 `CMPP_DELIVER_RESP` 时,都必须各写一条通讯交互日志,分别使用“平台→企业应用”和“企业应用→平台”方向;下游投递记录继续承担排队、重试和ACK业务状态,不得以通讯日志替代。
+5
View File
@@ -3408,6 +3408,7 @@ npm run verify:phase8
| TC-BILLING-011 | 分别准备 `余额+授信` 为正数、0 和负数的账户,使用相同短信费用发起发送。 | 和为正数时允许发送;和为 0 或负数时提示余额不足。判断公式为 `balanceCents + creditCents > 0`,与本次费用和套餐无关。 | | TC-BILLING-011 | 分别准备 `余额+授信` 为正数、0 和负数的账户,使用相同短信费用发起发送。 | 和为正数时允许发送;和为 0 或负数时提示余额不足。判断公式为 `balanceCents + creditCents > 0`,与本次费用和套餐无关。 |
| TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个消息在提交前失败并释放冻结;另准备一笔任务冻结转扣费时的批次级释放。 | 最终失败只生成一条 `refunded` 并计入“今日返还”,重复回执不重复退款;提交前失败生成 `released + relatedType=sms_message_record` 并计入“今日返还”;冻结转扣费的 `released + relatedType=sms_batch_task` 属于内部转换,不计入“今日返还”;客户端和运营端当日金额一致且保留三位小数。 | | 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-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`。 | | TC-SEC-006 | 安装 API 生产依赖并执行 `npm audit`;使用缺文件、多文件、超大文件、超量字段和正常单文件调用认证后的 multipart 上传接口。 | NestJS/Multer/Hono 已升级或锁定到修复版本,生产依赖 audit 为 0;接口只接受一个不超过 20MB 的文件,并限制字段、part、字段名、字段值和 header pair 数量;异常请求返回受控 4xx,正常文件仍写入真实 MinIO 和 `FileObject`。 |
### 17.5.1 报表对账细化 ### 17.5.1 报表对账细化
@@ -3797,3 +3798,7 @@ npm run verify:phase8
- `TC-RECEIPT-SHARED-010`:供应商账号、Gateway主机、端口、协议和CMPP版本均相同的两个物理通道连接中,回执从副连接进入、原连接存在唯一`gatewayMessageId + DestTerminalId`分片候选时,应写入原提交逻辑通道;账号或端点任一不同、或候选超过一条时不得自动匹配。 - `TC-RECEIPT-SHARED-010`:供应商账号、Gateway主机、端口、协议和CMPP版本均相同的两个物理通道连接中,回执从副连接进入、原连接存在唯一`gatewayMessageId + DestTerminalId`分片候选时,应写入原提交逻辑通道;账号或端点任一不同、或候选超过一条时不得自动匹配。
- `TC-RECEIPT-LONG-011`:两分片长短信仅收到第一片`DELIVRD`时,`SmsMessageRecord`保持`submitted`且不创建企业应用最终回执;第二片到达后两条分片审计均为`delivered`,主记录只聚合一次为`delivered`,重复回执不得重复投递、扣费或退款。 - `TC-RECEIPT-LONG-011`:两分片长短信仅收到第一片`DELIVRD`时,`SmsMessageRecord`保持`submitted`且不创建企业应用最终回执;第二片到达后两条分片审计均为`delivered`,主记录只聚合一次为`delivered`,重复回执不得重复投递、扣费或退款。
- `TC-PROTOCOL-LOG-012`:供应商长短信每个真实分片分别产生一条`平台→供应商通道/CMPP_SUBMIT`和一条`供应商通道→平台/CMPP_SUBMIT_RESP`;内部`submit-result`聚合回调不得额外落协议日志。 - `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和重试状态。
+19
View File
@@ -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`清空。 - 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 health均HTTP 200,公网CMPP 17890 TCP连接成功。真实浏览器加载运营端登录页,标题正确、页面无横向溢出、控制台0条业务error/warn;因图形验证码保护,本轮未绕过登录,登录后通讯日志页面仍需下一次人工登录结合真实短信复测。
- 发布后API/Gateway日志未出现panic、fatal、unhandled、exception、通讯遥测失败或`SMS message record not found`。本次未发送真实短信、未修改或回填`13127620092`历史业务数据;跨连接真实供应商回执和长短信两片最终聚合仍需下一次授权测试短信或自然业务回执验证。 - 发布后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日志。另一个会话的运营端账户充值回执源码和文档已一并纳入本发布分支,原工作区未覆盖。
@@ -4,8 +4,11 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
cmpp "github.com/bigwhite/gocmpp"
) )
func TestSubmitResponseProtocolLoggerEmitsActualPacketDirection(t *testing.T) { func TestSubmitResponseProtocolLoggerEmitsActualPacketDirection(t *testing.T) {
@@ -50,3 +53,38 @@ func TestSubmitResponseProtocolLoggerEmitsActualPacketDirection(t *testing.T) {
t.Fatal("timed out waiting for protocol event") 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")
}
}
+81
View File
@@ -174,6 +174,8 @@ type downstreamConnectionEvent struct {
type downstreamSession struct { type downstreamSession struct {
messageID string messageID string
account string account string
tenantID string
applicationID string
enterpriseCode string enterpriseCode string
protocol string protocol string
srcID string srcID string
@@ -188,6 +190,7 @@ type downstreamSession struct {
instanceID string instanceID string
report func(*downstreamSession, string, string) report func(*downstreamSession, string, string)
deliveryReport func(downstreamDeliveryLifecycleEvent) deliveryReport func(downstreamDeliveryLifecycleEvent)
protocolLog func(protocolLogEvent)
} }
var downstreamRegistry = struct { var downstreamRegistry = struct {
@@ -244,6 +247,8 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
now := time.Now().UTC() now := time.Now().UTC()
session := &downstreamSession{ session := &downstreamSession{
account: strings.TrimSpace(defaultString(auth.Account, account)), account: strings.TrimSpace(defaultString(auth.Account, account)),
tenantID: strings.TrimSpace(auth.TenantID),
applicationID: strings.TrimSpace(auth.ApplicationID),
enterpriseCode: strings.TrimSpace(auth.EnterpriseCode), enterpriseCode: strings.TrimSpace(auth.EnterpriseCode),
protocol: cmppVersionName(req.Version), protocol: cmppVersionName(req.Version),
srcID: strings.TrimSpace(auth.Account), srcID: strings.TrimSpace(auth.Account),
@@ -256,6 +261,7 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
instanceID: s.gatewayInstanceID(), instanceID: s.gatewayInstanceID(),
report: s.reportConnection, report: s.reportConnection,
deliveryReport: s.reportDownstreamDelivery, deliveryReport: s.reportDownstreamDelivery,
protocolLog: s.emitProtocolLog,
} }
if !rememberAccount(session, auth.MaxConnections) { 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) 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{ rememberDownstream(downstreamSession{
messageID: acceptedMessage.MessageID, messageID: acceptedMessage.MessageID,
account: account, account: account,
tenantID: result.TenantID,
applicationID: result.ApplicationID,
enterpriseCode: session.enterpriseCode, enterpriseCode: session.enterpriseCode,
protocol: clientProtocol, protocol: clientProtocol,
srcID: strings.TrimSpace(req.srcID), srcID: strings.TrimSpace(req.srcID),
@@ -386,6 +394,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
instanceID: s.gatewayInstanceID(), instanceID: s.gatewayInstanceID(),
report: session.report, report: session.report,
deliveryReport: session.deliveryReport, deliveryReport: session.deliveryReport,
protocolLog: session.protocolLog,
}) })
} }
if current := findSessionByConn(packet.Conn); current != nil && current.report != nil { 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) tracker := registerDownstreamAck(session, deliveryID, sequenceID, messageID, ackDeadlineAt)
if err := session.conn.SendPkt(deliver, sequenceID); err != nil { if err := session.conn.SendPkt(deliver, sequenceID); err != nil {
removeDownstreamAck(tracker) removeDownstreamAck(tracker)
session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err)
if session.report != nil { if session.report != nil {
go session.report(session, "disconnected", err.Error()) go session.report(session, "disconnected", err.Error())
} }
forgetDownstream(session) forgetDownstream(session)
return DownstreamSendResult{}, err return DownstreamSendResult{}, err
} }
session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "success", "", nil)
result := DownstreamSendResult{ result := DownstreamSendResult{
Sent: true, ConnectionID: session.connectionID, Sent: true, ConnectionID: session.connectionID,
SequenceID: strconv.FormatUint(uint64(sequenceID), 10), MessageID: strconv.FormatUint(messageID, 10), 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 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 { func downstreamDeliverMessageID(deliver cmpp.Packer) uint64 {
switch packet := deliver.(type) { switch packet := deliver.(type) {
case *cmpp.Cmpp2DeliverReqPkt: 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(), 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 { func downstreamAckTimeout() time.Duration {
+15
View File
@@ -912,10 +912,14 @@ func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) {
defer resetDownstreamRegistry() defer resetDownstreamRegistry()
events := make(chan downstreamDeliveryLifecycleEvent, 1) events := make(chan downstreamDeliveryLifecycleEvent, 1)
protocolEvents := make(chan protocolLogEvent, 1)
conn := &cmpp.Conn{} conn := &cmpp.Conn{}
session := &downstreamSession{ session := &downstreamSession{
conn: conn, connectionID: "conn-1", conn: conn, connectionID: "conn-1",
deliveryReport: func(event downstreamDeliveryLifecycleEvent) { events <- event }, 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)) registerDownstreamAck(session, "delivery-1", 37, 9016479179509871733, time.Now().Add(time.Second))
handleDownstreamAcknowledgement(conn, 37, 9016479179509871733, 0, log.Default()) handleDownstreamAcknowledgement(conn, 37, 9016479179509871733, 0, log.Default())
@@ -928,6 +932,17 @@ func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) {
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("timed out waiting acknowledgement event") 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) { func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testing.T) {
+10
View File
@@ -1432,6 +1432,16 @@ export const adminApi = {
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`), request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
getApplicationHttpApiConfig: (applicationId: string) => request<HttpApiConfigResponse>(`/admin/enterprise-applications/${applicationId}/http-api`), getApplicationHttpApiConfig: (applicationId: string) => request<HttpApiConfigResponse>(`/admin/enterprise-applications/${applicationId}/http-api`),
updateApplicationHttpApiConfig: (applicationId: string, body: Partial<HttpApiConfig> & { ipAllowlist?: string[] }) => request<HttpApiConfigResponse>(`/admin/enterprise-applications/${applicationId}/http-api`, { method: 'PUT', body: JSON.stringify(body) }), updateApplicationHttpApiConfig: (applicationId: string, body: Partial<HttpApiConfig> & { ipAllowlist?: string[] }) => request<HttpApiConfigResponse>(`/admin/enterprise-applications/${applicationId}/http-api`, { method: 'PUT', body: JSON.stringify(body) }),
listApplicationHttpWebhooks: (applicationId: string) =>
request<HttpWebhookEndpoint[]>(`/admin/enterprise-applications/${applicationId}/http-api/webhooks`),
saveApplicationHttpWebhook: (
applicationId: string,
eventType: 'receipt' | 'uplink',
body: { url: string; rotateSecret?: boolean; status?: string },
) => request<HttpWebhookEndpoint>(`/admin/enterprise-applications/${applicationId}/http-api/webhooks/${eventType}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listChannels: () => request<AdminChannel[]>('/admin/channels'), listChannels: () => request<AdminChannel[]>('/admin/channels'),
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)), request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
+26 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react'; import { Plus, ReceiptText, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, Tag, type DateRangeValue } from '@/components/ui'; 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 { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { formatCents } from '@/utils/currency'; import { formatCents } from '@/utils/currency';
@@ -24,6 +24,7 @@ export function AdminRechargeRecordsPage() {
const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [manualOpen, setManualOpen] = useState(false); const [manualOpen, setManualOpen] = useState(false);
const [receiptRecord, setReceiptRecord] = useState<RechargeOrder | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -67,6 +68,9 @@ export function AdminRechargeRecordsPage() {
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const currentPage = Math.min(page, totalPages); const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const receiptTenant = receiptRecord
? receiptRecord.tenant ?? tenants.find((tenant) => tenant.id === receiptRecord.tenantId)
: undefined;
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
@@ -107,15 +111,16 @@ export function AdminRechargeRecordsPage() {
<th style={{ width: '140px' }}></th> <th style={{ width: '140px' }}></th>
<th style={{ width: '120px' }}></th> <th style={{ width: '120px' }}></th>
<th style={{ width: '300px' }}></th> <th style={{ width: '300px' }}></th>
<th style={{ width: '130px' }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{error ? ( {error ? (
<tr><td className="ui-table__empty" colSpan={6}>{error}</td></tr> <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
) : loading ? ( ) : loading ? (
<tr><td className="ui-table__empty" colSpan={6}>...</td></tr> <tr><td className="ui-table__empty" colSpan={7}>...</td></tr>
) : filteredRows.length === 0 ? ( ) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={6}></td></tr> <tr><td className="ui-table__empty" colSpan={7}></td></tr>
) : visibleRows.map((record) => { ) : visibleRows.map((record) => {
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId; const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
return ( return (
@@ -126,6 +131,16 @@ export function AdminRechargeRecordsPage() {
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatCents(record.balanceAfterCents)}`}</td> <td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatCents(record.balanceAfterCents)}`}</td>
<td><Tag tone="warning"></Tag></td> <td><Tag tone="warning"></Tag></td>
<td><RemarkCell value={record.remark ?? undefined} /></td> <td><RemarkCell value={record.remark ?? undefined} /></td>
<td>
<Button
icon={<ReceiptText size={15} />}
onClick={() => setReceiptRecord(record)}
size="sm"
variant="ghost"
>
</Button>
</td>
</tr> </tr>
); );
})} })}
@@ -157,6 +172,12 @@ export function AdminRechargeRecordsPage() {
balanceCents: accounts.find((account) => account.tenantId === tenant.id)?.balanceCents ?? 0, balanceCents: accounts.find((account) => account.tenantId === tenant.id)?.balanceCents ?? 0,
}))} }))}
/> />
<RechargeReceiptDialog
onClose={() => setReceiptRecord(null)}
open={Boolean(receiptRecord)}
record={receiptRecord}
tenant={receiptTenant}
/>
</section> </section>
); );
} }
+32 -11
View File
@@ -15,13 +15,6 @@ const carrierMeta: Record<Carrier, { label: string; description: string }> = {
telecom: { label: '电信', description: '电信号码只会进入电信通道组' }, 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 }> = [ const httpCapabilityOptions: Array<{ key: keyof HttpApiConfig; label: string }> = [
{ key: 'sendEnabled', label: '单条发送' }, { key: 'sendEnabled', label: '单条发送' },
{ key: 'messageQueryEnabled', label: '状态查询' }, { key: 'messageQueryEnabled', label: '状态查询' },
@@ -62,6 +55,8 @@ export function AdminSmsApplicationFormPage() {
allowClientManualRetry: true, allowClientTest: true, allowClientManualRetry: true, allowClientTest: true,
}); });
const [httpIpAddress, setHttpIpAddress] = useState(''); const [httpIpAddress, setHttpIpAddress] = useState('');
const [receiptWebhookUrl, setReceiptWebhookUrl] = useState('');
const [uplinkWebhookUrl, setUplinkWebhookUrl] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]); const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [mobileGroupId, setMobileGroupId] = useState(''); const [mobileGroupId, setMobileGroupId] = useState('');
const [unicomGroupId, setUnicomGroupId] = useState(''); const [unicomGroupId, setUnicomGroupId] = useState('');
@@ -128,10 +123,15 @@ export function AdminSmsApplicationFormPage() {
useEffect(() => { useEffect(() => {
if (!appId) return; if (!appId) return;
let cancelled = false; let cancelled = false;
adminApi.getApplicationHttpApiConfig(appId).then((result) => { Promise.all([
adminApi.getApplicationHttpApiConfig(appId),
adminApi.listApplicationHttpWebhooks(appId),
]).then(([result, webhooks]) => {
if (cancelled) return; if (cancelled) return;
if (result.config) setHttpConfig(result.config); if (result.config) setHttpConfig(result.config);
setHttpIpAddress(result.ipAllowlist.join('\n')); setHttpIpAddress(result.ipAllowlist.join('\n'));
setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
}).catch((failure: Error) => { }).catch((failure: Error) => {
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败'); if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
}); });
@@ -245,6 +245,10 @@ export function AdminSmsApplicationFormPage() {
})), })),
}); });
await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) }); 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(); goBack();
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '短信应用保存失败'); setError(failure instanceof Error ? failure.message : '短信应用保存失败');
@@ -403,8 +407,6 @@ export function AdminSmsApplicationFormPage() {
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} /> <Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} /> <Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} /> <Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
<Select label="回执投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, receiptDeliveryMode: event.target.value as HttpApiConfig['receiptDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.receiptDeliveryMode} />
<Select label="上行投递方式" onChange={(event) => setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} />
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} /> <Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} /> <Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
<div className="admin-app-form-row admin-app-form-row--wide"><span>HTTP </span><div className="radio-row"> <div className="admin-app-form-row admin-app-form-row--wide"><span>HTTP </span><div className="radio-row">
@@ -413,7 +415,26 @@ export function AdminSmsApplicationFormPage() {
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" /></label> <label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" /></label>
</div></div> </div></div>
</div> </div>
) : <div className="admin-app-protocol-empty">HTTP Webhook </div>} ) : <div className="admin-app-protocol-empty">HTTP </div>}
<div className="admin-app-form-grid admin-app-protocol-body">
<Input
hint="留空不推送;HTTP接口开通后按该地址推送状态回执。"
label="HTTP 回执地址"
onChange={(event) => setReceiptWebhookUrl(event.target.value)}
placeholder="https://example.com/webhooks/sms/receipt"
value={receiptWebhookUrl}
/>
<Input
hint="留空不推送;HTTP接口开通后按该地址推送上行短信。"
label="HTTP 上行地址"
onChange={(event) => setUplinkWebhookUrl(event.target.value)}
placeholder="https://example.com/webhooks/sms/uplink"
value={uplinkWebhookUrl}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<div className="admin-app-form-tip"><Info size={17} /><span>CMPP开通则走CMPPHTTP开通且地址非空则走HTTP</span></div>
</div>
</div>
</section> </section>
<section className="ui-detail-section"> <section className="ui-detail-section">
+108
View File
@@ -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<TenantOption, 'name' | 'code'>;
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 (
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open={open}
title="账户充值回执"
>
<article className="recharge-receipt">
<header className="recharge-receipt__header">
<img
alt="聆界短信服务平台"
className="recharge-receipt__logo"
src="/logo/logo1.png"
/>
<span className={isCorrection ? 'is-correction' : 'is-posted'}>
<CheckCircle2 aria-hidden="true" size={16} />
{isCorrection ? '已冲正' : '已入账'}
</span>
</header>
<section className="recharge-receipt__amount">
<span>{isCorrection ? '冲正' : '充值'}</span>
<strong>
{isCorrection ? '' : '+'}
<small>¥</small>
{formatReceiptAmount(record.amountCents)}
</strong>
</section>
<section className="recharge-receipt__enterprise">
<span></span>
<strong>{enterpriseName}</strong>
<small>{enterpriseCode}</small>
</section>
<dl className="recharge-receipt__details">
<div>
<dt></dt>
<dd>{record.orderNo}</dd>
</div>
<div>
<dt></dt>
<dd>{formatDateTime(record.paidAt ?? record.createdAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{balanceBefore === null ? '-' : `¥${formatCents(balanceBefore)}`}</dd>
</div>
<div>
<dt></dt>
<dd>{balanceAfter === null || balanceAfter === undefined ? '-' : `¥${formatCents(balanceAfter)}`}</dd>
</div>
<div>
<dt></dt>
<dd> · {isCorrection ? '冲正' : '充值'}</dd>
</div>
<div>
<dt></dt>
<dd>{isCorrection ? '冲正完成' : '充值完成'}</dd>
</div>
</dl>
<section className="recharge-receipt__remark">
<span></span>
<p>{record.remark || '无'}</p>
</section>
<footer className="recharge-receipt__note">
</footer>
</article>
</Modal>
);
}
+1
View File
@@ -10,6 +10,7 @@ export { RiskAction } from './RiskAction';
export { DeleteRiskAction } from './DeleteRiskAction'; export { DeleteRiskAction } from './DeleteRiskAction';
export { ManualRechargeDialog } from './ManualRechargeDialog'; export { ManualRechargeDialog } from './ManualRechargeDialog';
export type { ManualRechargeTarget } from './ManualRechargeDialog'; export type { ManualRechargeTarget } from './ManualRechargeDialog';
export { RechargeReceiptDialog } from './RechargeReceiptDialog';
export { Input } from './Input'; export { Input } from './Input';
export { Modal } from './Modal'; export { Modal } from './Modal';
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives'; export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
+189 -1
View File
@@ -9702,7 +9702,7 @@ h3 {
} }
.admin-recharge-table { .admin-recharge-table {
min-width: 1220px; min-width: 1350px;
} }
.admin-recharge-table th { .admin-recharge-table th {
@@ -9726,6 +9726,194 @@ h3 {
font-variant-numeric: tabular-nums; 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-review,
.manual-recharge-result { .manual-recharge-result {
display: grid; display: grid;