From afd3c960709b1660c18d028499c811321983cb22 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 24 Jul 2026 08:17:33 +0800 Subject: [PATCH] feat: improve channel resilience and operations --- api/package-lock.json | 6 +- api/package.json | 3 +- .../migration.sql | 14 + .../migration.sql | 2 + .../migration.sql | 23 + .../migration.sql | 21 + api/prisma/schema.prisma | 12 +- api/src/channels/channels.service.spec.ts | 240 +++++++++- api/src/channels/channels.service.ts | 410 ++++++++++++++-- .../dictionaries/dictionaries.service.spec.ts | 19 + api/src/dictionaries/dictionaries.service.ts | 10 +- api/src/reports/reports.service.spec.ts | 3 +- api/src/reports/reports.service.ts | 68 ++- api/src/send-chain/send-chain.service.ts | 2 + api/src/sms-config/sms-config.service.spec.ts | 1 + api/src/sms-config/sms-config.service.ts | 2 +- .../first-version-development-requirements.md | 19 + docs/project-daily-log.md | 103 ++++ docs/system-functional-test-cases.md | 25 + docs/testing-progress.md | 33 ++ gateway/internal/control/server.go | 136 ++++-- gateway/internal/control/server_test.go | 33 ++ gateway/internal/queue/messages.go | 70 +-- .../internal/upstream/connection_loss_test.go | 57 +++ gateway/internal/upstream/manager.go | 453 +++++++++++++++--- gateway/internal/upstream/pool_test.go | 36 ++ .../upstream/reconnect_integration_test.go | 119 +++++ package-lock.json | 16 +- package.json | 2 +- src/api/adminApi.ts | 15 +- src/apps/admin/AdminChannelsPage.tsx | 35 +- .../admin/AdminEnterpriseApplicationsPage.tsx | 31 +- src/apps/admin/AdminEnterpriseAuditPage.tsx | 3 +- .../admin/AdminEnterpriseBlacklistPage.tsx | 2 +- src/apps/admin/AdminProfitReportsPage.tsx | 10 +- src/apps/admin/AdminQualityReportsPage.tsx | 10 +- .../admin/AdminReconciliationReportsPage.tsx | 10 +- .../admin/AdminSmsApplicationFormPage.tsx | 27 +- src/apps/admin/AdminSmsAuditPage.tsx | 4 +- src/apps/admin/AdminSmsRecordsPage.tsx | 53 +- src/apps/admin/AdminTemplateAuditPage.tsx | 3 +- src/layouts/AppShell.tsx | 22 +- src/styles/global.css | 105 +++- 43 files changed, 1969 insertions(+), 299 deletions(-) create mode 100644 api/prisma/migrations/20260723220000_add_upstream_reconnect_schedule/migration.sql create mode 100644 api/prisma/migrations/20260723223000_default_application_task_phone_limit/migration.sql create mode 100644 api/prisma/migrations/20260723224000_add_report_submission_unknown_units/migration.sql create mode 100644 api/prisma/migrations/20260723225000_enforce_supplier_connection_state_identity/migration.sql create mode 100644 docs/project-daily-log.md create mode 100644 gateway/internal/upstream/reconnect_integration_test.go diff --git a/api/package-lock.json b/api/package-lock.json index 7133a5e..9b58d01 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -4870,9 +4870,9 @@ } }, "node_modules/find-my-way": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", - "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "devOptional": true, "license": "MIT", "dependencies": { diff --git a/api/package.json b/api/package.json index b77f07f..df610ee 100644 --- a/api/package.json +++ b/api/package.json @@ -43,6 +43,7 @@ "overrides": { "exceljs": { "uuid": "11.1.1" - } + }, + "find-my-way": "9.7.0" } } diff --git a/api/prisma/migrations/20260723220000_add_upstream_reconnect_schedule/migration.sql b/api/prisma/migrations/20260723220000_add_upstream_reconnect_schedule/migration.sql new file mode 100644 index 0000000..dfe5397 --- /dev/null +++ b/api/prisma/migrations/20260723220000_add_upstream_reconnect_schedule/migration.sql @@ -0,0 +1,14 @@ +ALTER TABLE "CmppConnectionState" + ADD COLUMN "lastReconnectAttemptAt" TIMESTAMP(3), + ADD COLUMN "nextReconnectAt" TIMESTAMP(3), + ADD COLUMN "lastErrorCategory" TEXT; + +CREATE INDEX "CmppConnectionState_status_nextReconnectAt_idx" + ON "CmppConnectionState"("status", "nextReconnectAt"); + +-- Rollback: +-- DROP INDEX "CmppConnectionState_status_nextReconnectAt_idx"; +-- ALTER TABLE "CmppConnectionState" +-- DROP COLUMN "lastErrorCategory", +-- DROP COLUMN "nextReconnectAt", +-- DROP COLUMN "lastReconnectAttemptAt"; diff --git a/api/prisma/migrations/20260723223000_default_application_task_phone_limit/migration.sql b/api/prisma/migrations/20260723223000_default_application_task_phone_limit/migration.sql new file mode 100644 index 0000000..1364bc9 --- /dev/null +++ b/api/prisma/migrations/20260723223000_default_application_task_phone_limit/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "SmsApplication" +ALTER COLUMN "maxPhonesPerTask" SET DEFAULT 10000; diff --git a/api/prisma/migrations/20260723224000_add_report_submission_unknown_units/migration.sql b/api/prisma/migrations/20260723224000_add_report_submission_unknown_units/migration.sql new file mode 100644 index 0000000..ec88e08 --- /dev/null +++ b/api/prisma/migrations/20260723224000_add_report_submission_unknown_units/migration.sql @@ -0,0 +1,23 @@ +ALTER TABLE "DailyReconciliationReport" + ADD COLUMN "submittedUnits" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "unknownUnits" INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE "DailyProfitReport" + ADD COLUMN "submittedUnits" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "unknownUnits" INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE "DailyQualityReport" + ADD COLUMN "submittedUnits" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "unknownUnits" INTEGER NOT NULL DEFAULT 0; + +UPDATE "DailyReconciliationReport" +SET "submittedUnits" = "sentUnits", + "unknownUnits" = GREATEST("sentUnits" - "successUnits" - "failedUnits", 0); + +UPDATE "DailyProfitReport" +SET "submittedUnits" = "sentUnits", + "unknownUnits" = GREATEST("sentUnits" - "successUnits" - "failedUnits", 0); + +UPDATE "DailyQualityReport" +SET "submittedUnits" = "sentUnits", + "unknownUnits" = GREATEST("sentUnits" - "successUnits" - "failedUnits", 0); diff --git a/api/prisma/migrations/20260723225000_enforce_supplier_connection_state_identity/migration.sql b/api/prisma/migrations/20260723225000_enforce_supplier_connection_state_identity/migration.sql new file mode 100644 index 0000000..bce3ced --- /dev/null +++ b/api/prisma/migrations/20260723225000_enforce_supplier_connection_state_identity/migration.sql @@ -0,0 +1,21 @@ +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY "channelId", "connectionId" + ORDER BY "updatedAt" DESC, "createdAt" DESC, id DESC + ) AS row_no + FROM "CmppConnectionState" + WHERE "applicationId" IS NULL +) +DELETE FROM "CmppConnectionState" state +USING ranked +WHERE state.id = ranked.id + AND ranked.row_no > 1; + +CREATE UNIQUE INDEX "CmppConnectionState_supplier_channel_connection_key" + ON "CmppConnectionState"("channelId", "connectionId") + WHERE "applicationId" IS NULL; + +-- Rollback: +-- DROP INDEX "CmppConnectionState_supplier_channel_connection_key"; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index fa4bf90..fbec9b0 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -370,7 +370,7 @@ model SmsApplication { dailyLimit Int @default(100000) customerUnitPrice BigInt @default(0) queuePriority String @default("normal") - maxPhonesPerTask Int @default(1000000) + maxPhonesPerTask Int @default(10000) templateMismatchMode String @default("reject") downstreamReceiptRetryEnabled Boolean @default(true) downstreamUplinkRetryEnabled Boolean @default(true) @@ -785,6 +785,9 @@ model CmppConnectionState { lastDisconnectedAt DateTime? lastHeartbeatAt DateTime? reconnectCount Int @default(0) + lastReconnectAttemptAt DateTime? + nextReconnectAt DateTime? + lastErrorCategory String? lastError String? updatedAt DateTime @updatedAt createdAt DateTime @default(now()) @@ -797,6 +800,7 @@ model CmppConnectionState { @@index([tenantId, status]) @@index([applicationId, status]) @@index([channelId, status]) + @@index([status, nextReconnectAt]) } model CmppDownstreamConnection { @@ -1436,7 +1440,9 @@ model DailyReconciliationReport { tenantName String applicationId String applicationName String + submittedUnits Int @default(0) sentUnits Int @default(0) + unknownUnits Int @default(0) successUnits Int @default(0) failedUnits Int @default(0) generatedAt DateTime @default(now()) @@ -1458,7 +1464,9 @@ model DailyProfitReport { tenantName String? applicationId String? channelId String? + submittedUnits Int @default(0) sentUnits Int @default(0) + unknownUnits Int @default(0) successUnits Int @default(0) failedUnits Int @default(0) revenueCents BigInt @default(0) @@ -1488,7 +1496,9 @@ model DailyQualityReport { channelId String? signatureId String? drainageInfoId String? + submittedUnits Int @default(0) sentUnits Int @default(0) + unknownUnits Int @default(0) successUnits Int @default(0) failedUnits Int @default(0) successRateBps Int @default(0) diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 4d1c585..ae5fc75 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -1,6 +1,7 @@ import { ChannelsService } from './channels.service'; -const mockQueueAdd = jest.fn().mockResolvedValue(undefined); +const mockJobRemove = jest.fn().mockResolvedValue(undefined); +const mockQueueAdd = jest.fn().mockImplementation(async () => ({ id: 'job-1', remove: mockJobRemove })); const mockQueueClose = jest.fn().mockResolvedValue(undefined); const mockFetch = jest.fn().mockResolvedValue({ ok: true, @@ -8,6 +9,8 @@ const mockFetch = jest.fn().mockResolvedValue({ text: jest.fn().mockResolvedValue(''), }); const mockRedisXadd = jest.fn().mockResolvedValue('1710000000000-0'); +const mockRedisSet = jest.fn().mockResolvedValue('OK'); +const mockRedisEval = jest.fn().mockResolvedValue(1); const mockRedisDisconnect = jest.fn(); jest.mock('bullmq', () => ({ @@ -19,6 +22,8 @@ jest.mock('bullmq', () => ({ jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({ xadd: mockRedisXadd, + set: mockRedisSet, + eval: mockRedisEval, disconnect: mockRedisDisconnect, }))); @@ -70,7 +75,7 @@ function createPrismaMock() { findMany: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), findUnique: jest.fn().mockResolvedValue(channel), - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data })), }, channelHealthMetric: { findMany: jest.fn() }, smsChannelGroup: { @@ -272,8 +277,11 @@ describe('ChannelsService', () => { beforeEach(() => { mockQueueAdd.mockClear(); mockQueueClose.mockClear(); + mockJobRemove.mockClear(); mockFetch.mockClear(); mockRedisXadd.mockClear(); + mockRedisSet.mockClear(); + mockRedisEval.mockClear(); mockRedisDisconnect.mockClear(); global.fetch = mockFetch as never; }); @@ -300,12 +308,60 @@ describe('ChannelsService', () => { channelId: 'channel-1', reason: 'gateway_restarted', channel: expect.objectContaining({ rateLimitPerSecond: 100 }), - }), { jobId: 'channel-1:primary:connect' }); + }), expect.objectContaining({ + jobId: expect.stringMatching(/^gateway-connect-channel-1-/), + removeOnComplete: 1000, + removeOnFail: 1000, + })); expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ body: expect.stringContaining('"reason":"gateway_restarted"'), })); }); + it('uses the direct Gateway control path when the Redis marker queue is unavailable', async () => { + const prisma = createPrismaMock(); + mockQueueAdd.mockRejectedValueOnce(new Error('redis unavailable')); + const service = new ChannelsService(prisma as never); + + await expect(service.createChannel({ + code: 'CMPP-DIRECT', + name: '直连控制测试', + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + status: 'active', + })).resolves.toEqual(expect.objectContaining({ id: 'channel-1' })); + expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ + method: 'POST', + })); + }); + + it('reuses a supplier state created concurrently by another API instance', async () => { + const prisma = createPrismaMock(); + prisma.cmppConnectionState.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'state-concurrent', applicationId: null }); + prisma.cmppConnectionState.create.mockRejectedValueOnce({ code: 'P2002' }); + const service = new ChannelsService(prisma as never); + + await expect(service.createChannel({ + code: 'CMPP-CONCURRENT', + name: '并发状态测试', + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + status: 'active', + })).resolves.toEqual(expect.objectContaining({ id: 'channel-1' })); + expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ + where: { id: 'state-concurrent' }, + data: expect.objectContaining({ status: 'connecting' }), + }); + }); + it('creates CMPP channels and route rules with first-version defaults', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); @@ -333,7 +389,7 @@ describe('ChannelsService', () => { rateLimitPerSecond: 750, sendRegion: '全国', status: 'active', - config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4 }), + config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4, serviceId: 'SMS' }), }), }); expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ @@ -351,7 +407,11 @@ describe('ChannelsService', () => { connectionId: 'channel-1:primary', reason: 'channel_created', channel: expect.objectContaining({ cmppVersion: '2.0' }), - }), { jobId: 'channel-1:primary:connect' }); + }), expect.objectContaining({ + jobId: expect.stringMatching(/^gateway-connect-channel-1-/), + removeOnComplete: 1000, + removeOnFail: 1000, + })); expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"messageType":"ConnectChannel"'), @@ -372,6 +432,29 @@ describe('ChannelsService', () => { }); }); + it('defaults new channels to port 7890, CMPP and SMS while rejecting protocol overrides', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await service.createChannel({ + code: 'CMPP-DEFAULT', + name: '默认通道', + gatewayHost: '127.0.0.1', + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + protocol: 'HTTP', + }); + + expect(prisma.smsChannel.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + gatewayPort: 7890, + protocol: 'CMPP', + config: expect.objectContaining({ serviceId: 'SMS' }), + }), + }); + }); + it('preserves explicit CMPP 3.0 and rejects unsupported CMPP versions', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); @@ -417,6 +500,7 @@ describe('ChannelsService', () => { await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000'); await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20'); + await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow('serviceId must contain 1 to 10 ASCII characters'); }); it('updates CMPP channel configuration without requiring password changes', async () => { @@ -730,11 +814,155 @@ describe('ChannelsService', () => { messageType: 'ConnectChannel', channelId: 'channel-1', reason: 'channel_enabled', - }), { jobId: 'channel-1:primary:connect' }); + }), expect.objectContaining({ + jobId: expect.stringMatching(/^gateway-connect-channel-1-/), + removeOnComplete: 1000, + removeOnFail: 1000, + })); expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"reason":"channel_enabled"'), })); + expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"reason":"channel_disabled"'), + })); + }); + + it('reconnects active failed channels and disconnects inactive live channels during reconciliation', async () => { + const prisma = createPrismaMock(); + prisma.smsChannel.findMany.mockResolvedValue([ + { + ...(await prisma.smsChannel.findUnique()), + id: 'channel-active', + status: 'active', + connectionStates: [{ + id: 'state-active', + connectionId: 'channel-active:primary', + status: 'failed', + currentConnections: 0, + desiredConnections: 1, + lastHeartbeatAt: null, + nextReconnectAt: new Date('2026-07-23T10:00:00.000Z'), + }], + }, + { + ...(await prisma.smsChannel.findUnique()), + id: 'channel-disabled', + status: 'disabled', + connectionStates: [{ + id: 'state-disabled', + connectionId: 'channel-disabled:primary', + status: 'connected', + currentConnections: 1, + desiredConnections: 1, + lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'), + nextReconnectAt: null, + }], + }, + ]); + const service = new ChannelsService(prisma as never); + + const result = await service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')); + + expect(result).toEqual({ scanned: 2, reconnectRequested: 1, disconnectRequested: 1 }); + expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ + body: expect.stringContaining('"reason":"automatic_reconnect"'), + })); + expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({ + body: expect.stringContaining('"reason":"inactive_channel_reconcile"'), + })); + expect(mockRedisSet).toHaveBeenCalledTimes(2); + expect(mockRedisEval).toHaveBeenCalledTimes(2); + }); + + it('does not reconnect a fresh healthy supplier connection', async () => { + const prisma = createPrismaMock(); + prisma.smsChannel.findMany.mockResolvedValue([{ + ...(await prisma.smsChannel.findUnique()), + status: 'active', + config: { desiredConnections: 1, heartbeatIntervalSeconds: 30, heartbeatMissThreshold: 3 }, + connectionStates: [{ + connectionId: 'channel-1:primary', + status: 'connected', + currentConnections: 1, + desiredConnections: 1, + lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'), + nextReconnectAt: null, + }], + }]); + const service = new ChannelsService(prisma as never); + + await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'))) + .resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 }); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockRedisSet).not.toHaveBeenCalled(); + }); + + it('skips duplicate reconciliation when another API instance owns the Redis lease', async () => { + const prisma = createPrismaMock(); + prisma.smsChannel.findMany.mockResolvedValue([{ + ...(await prisma.smsChannel.findUnique()), + status: 'active', + connectionStates: [], + }]); + mockRedisSet.mockResolvedValueOnce(null); + const service = new ChannelsService(prisma as never); + + await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'))) + .resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 }); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockRedisEval).not.toHaveBeenCalled(); + }); + + it('stores supplier heartbeat as a connected state and heartbeat audit event', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await service.upsertConnectionState({ + channelId: 'channel-1', + connectionId: 'channel-1:primary', + status: 'heartbeat', + currentConnections: 1, + lastHeartbeatAt: '2026-07-23T11:00:00.000Z', + }); + + expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + status: 'connected', + lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'), + }), + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: 'cmpp_connection.heartbeat' }), + }); + }); + + it('updates every supplier heartbeat without flooding operation logs', async () => { + const prisma = createPrismaMock(); + prisma.cmppConnectionState.findFirst.mockResolvedValue({ + id: 'state-1', + applicationId: null, + lastHeartbeatAt: new Date('2026-07-23T10:59:30.000Z'), + }); + const service = new ChannelsService(prisma as never); + + await service.upsertConnectionState({ + channelId: 'channel-1', + connectionId: 'channel-1:primary', + status: 'heartbeat', + currentConnections: 1, + lastHeartbeatAt: '2026-07-23T11:00:00.000Z', + }); + + expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ + where: { id: 'state-1' }, + data: expect.objectContaining({ + status: 'connected', + lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'), + }), + }); + expect(prisma.operationLog.create).not.toHaveBeenCalled(); }); it('copies channels with report field configuration and report materials', async () => { diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 24acfa0..32b271a 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -13,7 +13,7 @@ export interface CreateChannelDto { sendRegion?: string; protocol?: string; gatewayHost: string; - gatewayPort: number; + gatewayPort?: number; enterpriseCode?: string; account: string; passwordCipher: string; @@ -24,6 +24,8 @@ export interface CreateChannelDto { status?: string; desiredConnections?: number; windowSize?: number; + heartbeatIntervalSeconds?: number; + heartbeatMissThreshold?: number; config?: Record; } @@ -151,6 +153,9 @@ export interface UpsertConnectionStateDto { lastDisconnectedAt?: string; lastHeartbeatAt?: string; reconnectCount?: number; + lastReconnectAttemptAt?: string; + nextReconnectAt?: string; + lastErrorCategory?: string; lastError?: string; } @@ -182,6 +187,11 @@ const DEFAULT_CHANNEL_CONNECTION_ID = 'primary'; const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000; const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000; const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000; +const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000; +const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000; +const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30; +const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3; +const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000; const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out'; const DEFAULT_CMPP_VERSION = '2.0'; @@ -193,6 +203,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { private redis?: IORedis; private connectionTimeoutTimer?: ReturnType; private gatewayStartupReconnectTimer?: ReturnType; + private gatewayReconcileTimer?: ReturnType; constructor(private readonly prisma: PrismaService) {} @@ -209,6 +220,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { void this.reconnectActiveChannelsAfterGatewayRestart(); }, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS)); this.gatewayStartupReconnectTimer.unref?.(); + if (process.env.GATEWAY_CONNECTION_RECONCILER_DISABLED !== 'true') { + this.gatewayReconcileTimer = setInterval(() => { + void this.reconcileGatewayConnections().catch((error) => { + this.logger.error(`Failed to reconcile supplier connections: ${error instanceof Error ? error.message : String(error)}`); + }); + }, getPositiveIntegerEnv('GATEWAY_CONNECTION_RECONCILE_INTERVAL_MS', DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS)); + this.gatewayReconcileTimer.unref?.(); + } } async onModuleDestroy() { @@ -218,6 +237,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { if (this.gatewayStartupReconnectTimer) { clearTimeout(this.gatewayStartupReconnectTimer); } + if (this.gatewayReconcileTimer) { + clearInterval(this.gatewayReconcileTimer); + } await this.gatewayConnectionQueue?.close(); await this.gatewaySubmitQueue?.close(); this.redis?.disconnect(); @@ -232,19 +254,26 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { async createChannel(data: CreateChannelDto) { assertMoneyUnits(data.unitPrice ?? 0, '通道单价'); - const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => { + const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => { const value = data[field as keyof CreateChannelDto]; return value === undefined || value === null || value === ''; }); if (missingFields.length > 0) { throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`); } - const gatewayPort = Number(data.gatewayPort); + const gatewayPort = Number(data.gatewayPort ?? 7890); if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) { throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); } const cmppVersion = normalizeCmppVersion(data.cmppVersion); - const config = normalizeChannelRuntimeConfig(undefined, data.config, data.desiredConnections, data.windowSize); + const config = normalizeChannelRuntimeConfig( + undefined, + data.config, + data.desiredConnections, + data.windowSize, + data.heartbeatIntervalSeconds, + data.heartbeatMissThreshold, + ); const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond); const channel = await this.prisma.smsChannel.create({ data: { @@ -252,7 +281,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { name: data.name, carrier: data.carrier, sendRegion: data.sendRegion ?? '全国', - protocol: data.protocol ?? 'CMPP', + protocol: 'CMPP', gatewayHost: data.gatewayHost, gatewayPort, enterpriseCode: data.enterpriseCode, @@ -285,8 +314,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); } const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); - const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined - ? normalizeChannelRuntimeConfig(channel.config, data.config, data.desiredConnections, data.windowSize) + const config = data.config !== undefined + || data.desiredConnections !== undefined + || data.windowSize !== undefined + || data.heartbeatIntervalSeconds !== undefined + || data.heartbeatMissThreshold !== undefined + ? normalizeChannelRuntimeConfig( + channel.config, + data.config, + data.desiredConnections, + data.windowSize, + data.heartbeatIntervalSeconds, + data.heartbeatMissThreshold, + ) : undefined; const rateLimitPerSecond = data.rateLimitPerSecond === undefined ? undefined @@ -298,7 +338,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { name: data.name, carrier: data.carrier, sendRegion: data.sendRegion, - protocol: data.protocol, + protocol: 'CMPP', gatewayHost: data.gatewayHost, gatewayPort, enterpriseCode: data.enterpriseCode, @@ -334,6 +374,26 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { } as Prisma.InputJsonValue, }, }); + const connectionConfigChanged = [ + 'gatewayHost', + 'gatewayPort', + 'account', + 'passwordCipher', + 'cmppVersion', + 'rateLimitPerSecond', + 'desiredConnections', + 'windowSize', + 'heartbeatIntervalSeconds', + 'heartbeatMissThreshold', + ].some((key) => data[key as keyof UpdateChannelDto] !== undefined) + || Boolean(data.config && ['desiredConnections', 'windowSize', 'heartbeatIntervalSeconds', 'heartbeatMissThreshold'] + .some((key) => key in data.config!)); + const updatedStatus = data.status ?? channel.status; + if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) { + await this.requestChannelConnection(updated, 'channel_updated'); + } else if (updatedStatus !== 'active' && channel.status === 'active') { + await this.requestChannelDisconnection(updated, 'channel_disabled'); + } return updated; } @@ -358,6 +418,12 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { }); if (data.status === 'active') { await this.requestChannelConnection(updated, 'channel_enabled', data.operatorId); + } else if (channel.status === 'active' || data.status === 'deleted') { + await this.requestChannelDisconnection( + updated, + data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled', + data.operatorId, + ); } return updated; } @@ -615,7 +681,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { } async upsertConnectionState(data: UpsertConnectionStateDto) { - const status = normalizeGatewayConnectionStatus(data.status); + const rawStatus = data.status; + const status = normalizeGatewayConnectionStatus(rawStatus); if (data.applicationId) { const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); if (!application) { @@ -636,6 +703,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined, lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined, reconnectCount: data.reconnectCount ?? 0, + lastReconnectAttemptAt: data.lastReconnectAttemptAt ? new Date(data.lastReconnectAttemptAt) : undefined, + nextReconnectAt: data.nextReconnectAt ? new Date(data.nextReconnectAt) : status === 'connected' ? null : undefined, + lastErrorCategory: status === 'connected' ? null : data.lastErrorCategory, lastError: status === 'connected' ? null : data.lastError, }; const existing = await this.prisma.cmppConnectionState.findFirst({ @@ -645,30 +715,59 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { connectionId: data.connectionId, }, }); - const state = existing - ? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload }) - : await this.prisma.cmppConnectionState.create({ + let state; + if (existing) { + state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload }); + } else { + try { + state = await this.prisma.cmppConnectionState.create({ + data: { + channelId: data.channelId, + connectionId: data.connectionId, + ...payload, + }, + }); + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') { + throw error; + } + const concurrent = await this.prisma.cmppConnectionState.findFirst({ + where: { + applicationId: data.applicationId ?? null, + channelId: data.channelId, + connectionId: data.connectionId, + }, + }); + if (!concurrent) { + throw error; + } + state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data: payload }); + } + } + const action = normalizeConnectionAction( + ['heartbeat', 'active_test'].includes(rawStatus.toLowerCase()) ? rawStatus : status, + ); + const heartbeatObservedAt = data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : new Date(); + const shouldWriteAudit = action !== 'heartbeat' + || !existing?.lastHeartbeatAt + || heartbeatObservedAt.getTime() - existing.lastHeartbeatAt.getTime() >= HEARTBEAT_AUDIT_INTERVAL_MS; + if (shouldWriteAudit) { + await this.prisma.operationLog.create({ data: { - channelId: data.channelId, - connectionId: data.connectionId, - ...payload, + tenantId: data.tenantId, + action: `cmpp_connection.${action}`, + resource: 'cmpp_connection', + resourceId: `${data.channelId}:${data.connectionId}`, + detail: { + status, + applicationId: state.applicationId, + desiredConnections: state.desiredConnections, + currentConnections: state.currentConnections, + lastError: state.lastError, + } as Prisma.InputJsonValue, }, }); - await this.prisma.operationLog.create({ - data: { - tenantId: data.tenantId, - action: `cmpp_connection.${normalizeConnectionAction(status)}`, - resource: 'cmpp_connection', - resourceId: `${data.channelId}:${data.connectionId}`, - detail: { - status, - applicationId: state.applicationId, - desiredConnections: state.desiredConnections, - currentConnections: state.currentConnections, - lastError: state.lastError, - } as Prisma.InputJsonValue, - }, - }); + } return state; } @@ -705,6 +804,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { status: 'failed', currentConnections: 0, lastDisconnectedAt: now, + nextReconnectAt: now, + lastErrorCategory: 'timeout', lastError, }, }); @@ -1245,7 +1346,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { rateLimitPerSecond: number; config?: Prisma.JsonValue | null; }, - reason: 'channel_created' | 'channel_enabled' | 'gateway_restarted', + reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect', operatorId?: string, ) { const desiredConnections = getDesiredConnections(channel.config); @@ -1263,16 +1364,34 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { desiredConnections, currentConnections: 0, lastError: null, + lastReconnectAttemptAt: new Date(), + nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)), }; - const state = existing - ? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data }) - : await this.prisma.cmppConnectionState.create({ - data: { - channelId: channel.id, - connectionId, - ...data, - }, - }); + let state; + if (existing) { + state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data }); + } else { + try { + state = await this.prisma.cmppConnectionState.create({ + data: { + channelId: channel.id, + connectionId, + ...data, + }, + }); + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') { + throw error; + } + const concurrent = await this.prisma.cmppConnectionState.findFirst({ + where: { applicationId: null, channelId: channel.id, connectionId }, + }); + if (!concurrent) { + throw error; + } + state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data }); + } + } await this.prisma.operationLog.create({ data: { userId: operatorId, @@ -1306,10 +1425,36 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { srcId: channel.srcId, cmppVersion: channel.cmppVersion, rateLimitPerSecond: channel.rateLimitPerSecond, + windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'), + heartbeatIntervalSeconds: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatIntervalSeconds'), + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + 'heartbeatIntervalSeconds', + ), + heartbeatMissThreshold: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatMissThreshold'), + DEFAULT_HEARTBEAT_MISS_THRESHOLD, + 'heartbeatMissThreshold', + ), }, }; - await this.getGatewayConnectionQueue().add('connect-channel', command, { jobId: `${connectionId}:connect` }); - await this.notifyGatewayConnect(command); + const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, { + jobId: `gateway-connect-${channel.id}-${command.traceId}`, + removeOnComplete: 1000, + removeOnFail: 1000, + }).catch((error) => { + this.logger.warn(`Gateway connect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + }); + try { + await this.notifyGatewayConnect(command); + } finally { + if (queuedJob) { + await queuedJob.remove().catch((error) => { + this.logger.warn(`Failed to remove delivered Gateway connect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } return state; } @@ -1328,6 +1473,134 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { }); } + async reconcileGatewayConnections(now = new Date()) { + const channels = await this.prisma.smsChannel.findMany({ + where: { status: { in: ['active', 'disabled', 'deleted'] } }, + include: { + connectionStates: { + where: { applicationId: null }, + }, + }, + take: 200, + }); + let reconnectRequested = 0; + let disconnectRequested = 0; + for (const channel of channels) { + const state = channel.connectionStates.find((item) => item.connectionId === defaultChannelConnectionId(channel.id)); + if (channel.status !== 'active') { + if (state && (state.currentConnections > 0 || ['connected', 'connecting', 'reconnecting'].includes(state.status))) { + await this.withGatewayReconcileLock(channel.id, async () => { + await this.requestChannelDisconnection(channel, 'inactive_channel_reconcile'); + disconnectRequested++; + }); + } + continue; + } + const desiredConnections = getDesiredConnections(channel.config); + const heartbeatIntervalSeconds = getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatIntervalSeconds'), + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + 'heartbeatIntervalSeconds', + ); + const heartbeatMissThreshold = getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatMissThreshold'), + DEFAULT_HEARTBEAT_MISS_THRESHOLD, + 'heartbeatMissThreshold', + ); + const heartbeatCutoff = new Date(now.getTime() - heartbeatIntervalSeconds * (heartbeatMissThreshold + 1) * 1000); + const connectedAndFresh = state?.status === 'connected' + && state.currentConnections >= desiredConnections + && Boolean(state.lastHeartbeatAt && state.lastHeartbeatAt > heartbeatCutoff); + const retryDue = !state?.nextReconnectAt || state.nextReconnectAt <= now; + if (!connectedAndFresh && retryDue) { + await this.withGatewayReconcileLock(channel.id, async () => { + await this.requestChannelConnection(channel, 'automatic_reconnect'); + reconnectRequested++; + }); + } + } + return { scanned: channels.length, reconnectRequested, disconnectRequested }; + } + + private async withGatewayReconcileLock(channelId: string, action: () => Promise) { + const redis = this.getRedis(); + const key = `cmpp:gateway:reconcile:${channelId}`; + const token = randomUUID(); + const acquired = await redis.set(key, token, 'PX', DEFAULT_CONNECTING_TIMEOUT_MS, 'NX'); + if (acquired !== 'OK') { + return; + } + try { + await action(); + } finally { + await redis.eval( + 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end', + 1, + key, + token, + ); + } + } + + private async requestChannelDisconnection( + channel: { id: string }, + reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile', + operatorId?: string, + ) { + const connectionId = defaultChannelConnectionId(channel.id); + const now = new Date(); + await this.prisma.cmppConnectionState.updateMany({ + where: { + applicationId: null, + channelId: channel.id, + connectionId, + }, + data: { + status: 'disconnected', + currentConnections: 0, + lastDisconnectedAt: now, + nextReconnectAt: null, + lastErrorCategory: null, + lastError: null, + }, + }); + await this.prisma.operationLog.create({ + data: { + userId: operatorId, + action: 'cmpp_connection.disconnect_requested', + resource: 'cmpp_connection', + resourceId: `${channel.id}:${connectionId}`, + detail: { reason } as Prisma.InputJsonValue, + }, + }); + const command = { + schemaVersion: 'v1', + messageType: 'DisconnectChannel', + traceId: randomUUID(), + channelId: channel.id, + connectionId, + createdAt: now.toISOString(), + reason, + }; + const queuedJob = await this.getGatewayConnectionQueue().add('disconnect-channel', command, { + jobId: `gateway-disconnect-${channel.id}-${command.traceId}`, + removeOnComplete: 1000, + removeOnFail: 1000, + }).catch((error) => { + this.logger.warn(`Gateway disconnect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + }); + try { + await this.notifyGatewayDisconnect(command); + } finally { + if (queuedJob) { + await queuedJob.remove().catch((error) => { + this.logger.warn(`Failed to remove delivered Gateway disconnect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } + } + private getGatewayConnectionQueue() { this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() }); return this.gatewayConnectionQueue; @@ -1366,6 +1639,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command), + signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)), }); } catch (error) { throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`); @@ -1375,6 +1649,25 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`); } } + + private async notifyGatewayDisconnect(command: Record) { + const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, ''); + let response: { ok: boolean; status: number; text: () => Promise }; + try { + response = await fetch(`${baseUrl}/connections/disconnect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)), + }); + } catch (error) { + throw new BadRequestException(`Gateway disconnect request failed: ${error instanceof Error ? error.message : String(error)}`); + } + if (!response.ok) { + const responseText = await response.text(); + throw new BadRequestException(`Gateway disconnect request failed: ${response.status} ${responseText}`); + } + } } function normalizeTestPhones(data: TestChannelDto) { @@ -1481,6 +1774,16 @@ function buildChannelTestSubmitCommand({ cmppVersion: channel.cmppVersion, desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'), windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'), + heartbeatIntervalSeconds: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatIntervalSeconds'), + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + 'heartbeatIntervalSeconds', + ), + heartbeatMissThreshold: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'heartbeatMissThreshold'), + DEFAULT_HEARTBEAT_MISS_THRESHOLD, + 'heartbeatMissThreshold', + ), }, retry: { attempt: 0, maxAttempts: 1 }, }; @@ -1531,7 +1834,7 @@ function normalizeCmppVersion(version?: string) { function normalizeGatewayConnectionStatus(status: string) { const normalized = status.toLowerCase(); - if (['online', 'open', 'connected'].includes(normalized)) { + if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) { return 'connected'; } if (['connecting', 'connect_requested'].includes(normalized)) { @@ -1568,6 +1871,8 @@ function normalizeChannelRuntimeConfig( incomingConfig?: Record | null, desiredConnections?: number, windowSize?: number, + heartbeatIntervalSeconds?: number, + heartbeatMissThreshold?: number, ) { const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) ? existingConfig as Record @@ -1578,10 +1883,29 @@ function normalizeChannelRuntimeConfig( const base = { ...existing, ...incoming }; base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections'); base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize'); + base.heartbeatIntervalSeconds = getPositiveRuntimeInteger( + heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds, + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + 'heartbeatIntervalSeconds', + ); + base.heartbeatMissThreshold = getPositiveRuntimeInteger( + heartbeatMissThreshold ?? base.heartbeatMissThreshold, + DEFAULT_HEARTBEAT_MISS_THRESHOLD, + 'heartbeatMissThreshold', + ); base.extensionDigits = normalizeExtensionDigits(base.extensionDigits); + base.serviceId = normalizeCmppServiceId(base.serviceId); return base; } +function normalizeCmppServiceId(value: unknown) { + const normalized = String(value ?? 'SMS').trim() || 'SMS'; + if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) { + throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters'); + } + return normalized; +} + function normalizeChannelRateLimit(value: unknown) { const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond'); if (normalized > 2000) { diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 925078b..7ba287e 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -145,6 +145,25 @@ describe('DictionariesService', () => { })); }); + it('excludes soft-deleted security entries even when no status or deleted is requested', async () => { + const prisma = createPrismaMock(); + const service = new DictionariesService(prisma as never); + + await service.listSensitiveWords(); + await service.listGlobalBlacklist({ status: 'deleted' }); + await service.listEnterpriseBlacklist(); + + expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ status: { not: 'deleted' } }), + })); + expect(prisma.globalBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ status: { not: 'deleted' } }), + })); + expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ status: { not: 'deleted' } }), + })); + }); + it('paginates carrier rules with a real database count', async () => { const prisma = createPrismaMock(); prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 45438ab..0e75ec0 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -9,6 +9,10 @@ export interface CreatePhoneSegmentDto { city?: string; } +function visibleDictionaryStatus(status?: string): string | Prisma.StringFilter { + return status && status !== 'all' && status !== 'deleted' ? status : { not: 'deleted' }; +} + export interface PhoneSegmentListQuery { keyword?: string; page?: number; @@ -152,7 +156,7 @@ export class DictionariesService { listSensitiveWords(query: DictionaryListQuery = {}) { return this.prisma.sensitiveWord.findMany({ where: { - status: query.status && query.status !== 'all' ? query.status : undefined, + status: visibleDictionaryStatus(query.status), OR: query.keyword ? [ { word: { contains: query.keyword } }, { level: { contains: query.keyword } }, @@ -184,7 +188,7 @@ export class DictionariesService { listGlobalBlacklist(query: DictionaryListQuery = {}) { return this.prisma.globalBlacklist.findMany({ where: { - status: query.status && query.status !== 'all' ? query.status : undefined, + status: visibleDictionaryStatus(query.status), OR: query.keyword ? [ { phoneNumber: { contains: query.keyword } }, { reason: { contains: query.keyword } }, @@ -221,7 +225,7 @@ export class DictionariesService { where: { tenantId: query.tenantId, applicationId: query.applicationId, - status: query.status && query.status !== 'all' ? query.status : undefined, + status: visibleDictionaryStatus(query.status), tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, diff --git a/api/src/reports/reports.service.spec.ts b/api/src/reports/reports.service.spec.ts index 1ff0bbf..1cc7644 100644 --- a/api/src/reports/reports.service.spec.ts +++ b/api/src/reports/reports.service.spec.ts @@ -82,11 +82,12 @@ describe('ReportsService', () => { it('exports complete filtered report data as escaped CSV instead of the current page', async () => { prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{ id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A', - sentUnits: 12, successUnits: 10, generatedAt: new Date('2026-07-15T00:00:00Z'), + submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, generatedAt: new Date('2026-07-15T00:00:00Z'), }]); const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' }); expect(exported.fileName).toContain('对账单-'); expect(exported.content).toContain('"示例,企业"'); + expect(exported.content).toContain('提交条数'); expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts index 6375e18..04896a5 100644 --- a/api/src/reports/reports.service.ts +++ b/api/src/reports/reports.service.ts @@ -74,19 +74,19 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { async exportReconciliation(query: ReportListQuery) { const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] }); - return csvExport('对账单', ['发送日期', '企业', '企业应用', '发送条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.sentUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)])); + return csvExport('对账单', ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)])); } async exportProfit(query: ReportListQuery) { const { dimensionType, where } = profitWhere(query); const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] }); - return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)])); + return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)])); } async exportQuality(query: ReportListQuery) { const { dimensionType, where } = qualityWhere(query); const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] }); - return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '发送条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)])); + return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)])); } async refreshRollingWindow(now = new Date()) { @@ -119,7 +119,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { await tx.$executeRaw(Prisma.sql` INSERT INTO "DailyReconciliationReport" ( "id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName", - "sentUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt" + "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt" ) SELECT CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)), @@ -129,8 +129,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { application.id, application.name, COALESCE(SUM(message."billingUnits"), 0)::integer, + COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' + AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false)) + AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false)) + THEN message."billingUnits" ELSE 0 END), 0)::integer, COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, - COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false)) + AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false)) + THEN message."billingUnits" ELSE 0 END), 0)::integer, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM "SmsMessageRecord" message @@ -157,7 +164,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { INSERT INTO "DailyProfitReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", - "sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps", + "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps", "generatedAt", "updatedAt" ) SELECT @@ -171,8 +178,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { application.id, NULL, COALESCE(SUM(message."billingUnits"), 0)::integer, + COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' + AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false)) + AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false)) + THEN message."billingUnits" ELSE 0 END), 0)::integer, COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, - COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false)) + AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false)) + THEN message."billingUnits" ELSE 0 END), 0)::integer, COALESCE(SUM(billing.revenue), 0)::bigint, COALESCE(SUM(billing.refund), 0)::bigint, COALESCE(SUM(costs.cost), 0)::bigint, @@ -202,7 +216,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { INSERT INTO "DailyProfitReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", - "sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps", + "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps", "generatedAt", "updatedAt" ) SELECT @@ -216,18 +230,30 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { NULL, channel.id, COALESCE(SUM(message."billingUnits"), 0)::integer, + COALESCE(SUM(message."billingUnits"), 0)::integer, + COALESCE(SUM(CASE WHEN NOT EXISTS ( + SELECT 1 FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" IN ('delivered', 'undelivered') + ) AND submit."submitStatus" NOT IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer, COALESCE(SUM(CASE WHEN EXISTS ( SELECT 1 FROM "SmsReceiptRecord" receipt WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'delivered' ) THEN message."billingUnits" ELSE 0 END), 0)::integer, - COALESCE(SUM(CASE WHEN EXISTS ( + COALESCE(SUM(CASE WHEN NOT EXISTS ( + SELECT 1 FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'delivered' + ) AND (EXISTS ( SELECT 1 FROM "SmsReceiptRecord" receipt WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'undelivered' - ) OR submit."submitStatus" IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer, + ) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer, COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint, COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint, COALESCE(SUM(submit."costAmountCents"), 0)::bigint, @@ -280,8 +306,15 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat tenant.name AS tenant_name, application.id AS application_id, message."billingUnits" AS billing_units, + CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END AS sent_units, CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END AS success_units, - CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END AS failed_units, + CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false)) + AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false)) + THEN message."billingUnits" ELSE 0 END AS failed_units, + CASE WHEN COALESCE(message.status, '') <> 'rejected' + AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false)) + AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false)) + THEN message."billingUnits" ELSE 0 END AS unknown_units, CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt" THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms @@ -299,7 +332,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat INSERT INTO "DailyQualityReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId", - "sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" + "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" ) SELECT CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)), @@ -314,9 +347,11 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat CASE WHEN ${dimensionTypeSql} = 'signature' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END, CASE WHEN ${dimensionTypeSql} = 'drainage' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END, SUM(base.billing_units)::integer, + SUM(base.sent_units)::integer, + SUM(base.unknown_units)::integer, SUM(base.success_units)::integer, SUM(base.failed_units)::integer, - CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END, + CASE WHEN SUM(base.sent_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.sent_units))::integer END, ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP @@ -334,7 +369,8 @@ function qualityByChannelSql(day: BusinessDay) { channel.name AS dimension_name, message."billingUnits" AS billing_units, CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_units, - CASE WHEN failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units, + CASE WHEN receipt."deliveredAt" IS NULL AND failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units, + CASE WHEN receipt."deliveredAt" IS NULL AND failed_receipt."failedAt" IS NULL THEN message."billingUnits" ELSE 0 END AS unknown_units, CASE WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms FROM "SmsSubmitRecord" submit @@ -364,7 +400,7 @@ function qualityByChannelSql(day: BusinessDay) { INSERT INTO "DailyQualityReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId", - "sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" + "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" ) SELECT CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)), @@ -374,6 +410,8 @@ function qualityByChannelSql(day: BusinessDay) { MAX(base.dimension_name), NULL, NULL, NULL, base.dimension_id, NULL, NULL, SUM(base.billing_units)::integer, + SUM(base.billing_units)::integer, + SUM(base.unknown_units)::integer, SUM(base.success_units)::integer, SUM(base.failed_units)::integer, CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END, diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 7187bcc..1db9397 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -2847,6 +2847,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { cmppVersion: channel.cmppVersion, desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1), windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16), + heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30), + heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3), }, retry: { attempt, maxAttempts: 1 }, }; diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 2de12b5..6821729 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -324,6 +324,7 @@ describe('SmsConfigService', () => { interfaceType: 'cmpp20', queuePriority: 'priority', dailyLimit: 100000, + maxPhonesPerTask: 10000, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 02565bc..23e23c1 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -388,7 +388,7 @@ export class SmsConfigService { dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), customerUnitPrice: data.customerUnitPrice ?? 0, queuePriority, - maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000, + maxPhonesPerTask: data.maxPhonesPerTask ?? 10000, templateMismatchMode: data.templateMismatchMode ?? 'reject', downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true, downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true, diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index abc94d7..6de82a3 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1643,3 +1643,22 @@ - 号码基础校验只判断空值、字符、长度、数量上限、重复及多号码完整性;号段识别用于运营商和地区快照及路由提示,未知号段不得据此拒绝,必须继续按全国或三网兼容通道处理。 - 预发布历史数据库仍为`SQL_ASCII`时,数据迁移不得使用可能把多字节UTF-8字符拆成单字节处理的正则字符类。中文签名修复必须同时校验主键和原始字节序列,并从迁移前备份恢复完整UTF-8;长期生产数据库必须规划迁移到`UTF8`编码。 - 首次开通HTTP接口时,后端默认开启单条发送、状态查询、回执回调、上行查询、上行回调和客户端自助密钥六项能力,回执/上行投递默认为HTTP Webhook。参数复制必须包含应用名称、AppID、六项能力、基础地址、文档、QPS、白名单和真实投递方式。 + +## 2026-07-23 供应商连接主动心跳与自动重连要求 + +1. 供应商出站CMPP通道只要状态为`active`,首次连接失败、运行中断链、心跳超时或Gateway重启后都必须持续自动恢复到`desiredConnections`;不得依赖下一条短信触发惰性重连。 +2. Gateway必须主动向供应商发送`CMPP_ACTIVE_TEST`并按`Sequence_Id`确认`CMPP_ACTIVE_TEST_RESP`。默认30秒一次,连续3次未响应判定断链;间隔和阈值允许按通道配置。短信提交、回执响应和心跳写包必须串行,断链后旧读写循环必须退出。 +3. 网络类失败按5秒、15秒、30秒、60秒、2分钟、5分钟逐级退避并增加抖动,达到上限后持续重试;鉴权或凭据类失败仍持续重试,但默认降为5分钟一次。重连成功后清零失败次数和下次重连时间。 +4. API每30秒协调数据库期望状态与Gateway真实状态,以Redis租约避免多实例重复下发;连接数不足、心跳陈旧、失败且到期或状态缺失的活动通道需要重新下发连接意图。 +5. 手动停用或逻辑删除通道必须向Gateway发送`DisconnectChannel`,关闭连接池、主动心跳和重连监督器;协调任务还必须清理数据库状态与通道状态不一致的存量连接。重新启用或修改地址、端口、账号、密码、版本、窗口、连接数、心跳配置时立即使用新配置连接。 +6. `CmppConnectionState`必须记录重连次数、最近重连尝试、下次重连时间、错误分类和最近心跳。运营端允许配置心跳参数并展示重连次数、下次时间和最近错误。 +7. 多API实例并发恢复同一供应商通道时,数据库必须以`channelId + connectionId`的部分唯一索引约束`applicationId IS NULL`状态行;唯一冲突复用并更新既有状态,不得生成重复连接状态。 +## 2026-07-23 通道、应用、详单与报表口径补充 + +- 新建上游短信通道默认端口为 `7890`;当前仅支持 CMPP,管理端和 API 均不得接受 HTTP/SGIP 作为通道协议。 +- 通道业务代码写入真实通道运行配置 `config.serviceId`,默认 `SMS`,限制为 1~10 个 ASCII 字符,并用于 Gateway CMPP `Service_Id`。 +- 新建企业应用的“每任务最大号码数”默认 `10000`,超过上限仍由后端整任务拒绝。 +- 安全控制的企业黑名单、全局黑名单和敏感词接口不得返回逻辑删除记录。 +- 所有日报表按 `SmsMessageRecord.billingUnits` 统计长短信分片条数,输出提交、发送、未知、成功、失败五项;平台拦截(`status=rejected`)计入提交但不计入发送,并保证 `发送=未知+成功+失败`。 +- 通道维度只存在已经路由到通道的记录,因此该维度的提交数等于发送数;应用、签名、引流信息和对账维度的提交数包含平台拦截。 +- 二级添加、编辑页面必须继续高亮其所属侧边菜单。 diff --git a/docs/project-daily-log.md b/docs/project-daily-log.md new file mode 100644 index 0000000..f50a337 --- /dev/null +++ b/docs/project-daily-log.md @@ -0,0 +1,103 @@ +# CMPP 平台项目日志 + +> 整理范围:本机 Codex 中工作目录为 `CMPP平台建设` 的全部可读取会话,时间为 2026-06-16 至 2026-07-22。仅列出发生实际项目工作的日期;无项目对话的日期不补写。内容同时参考 Git 提交、需求文档、系统功能测试用例和测试进度,以区分本地完成、已提交及已部署状态。 + +## 2026-06-16 + +项目从客户端纯前端原型复现起步,确定使用 React、TypeScript、Vite 和自研 UI 组件,不接数据库与后端。当天集中完成批量任务列表、任务详情、日期区间日历、定时发送控件、短信发送详情和上行短信页面,并将查询面板、分页、详情区块、时间选择等能力沉淀到全局组件库。主要问题是早期误把截图中的“发送详情”理解成统计看板,经用户指出后按查询列表重做;日期区间的 hover、选中态和内容换行也经过多轮细调。各批次均以 `npm run build` 验证,只有既有构建体积提示。 + +## 2026-06-17 + +继续补齐客户端基础配置和展示页面,完成短信应用、签名与引流信息、短信模板、彩信签名报备和彩信模板管理,并统一新增/编辑弹窗结构。短信模板支持变量插入、计费提示和内容预览;彩信模板支持多帧编辑、预览及三网审核状态。当天反复调整卡片内部间距、操作按钮、状态同行展示和宽弹窗底部区域,暴露出仅按截图逐处修改容易造成“过松—过紧”来回摆动的问题。为减少后续视觉漂移,新增全局 UI 设计规范,统一颜色、字号、间距、卡片、表格、弹窗和导航约定,构建持续通过。 + +## 2026-06-18 + +客户端侧补充彩信发送、彩信任务、彩信发送详情、上行彩信、用户管理、系统日志和企业认证;随后开始运营端原型,完成运营看板、企业管理及重型企业详情的第一轮实现。项目明确新原则:截图只提供字段、流程和状态参考,视觉必须复用自研控件与全局规范,不再像素级照搬。过程中曾出现两次改动不符合预期并讨论回滚,同时发现目录尚未纳入 Git,导致缺少安全恢复点;随后安装 Git 并建立版本管理意识。运营看板经过删减筛选、拆分签名排行和统一网格后定型,构建通过。 + +## 2026-06-19 + +运营端进入业务配置密集开发,完成短信和彩信应用配置、短信签名与引流资料、短信和彩信模板审核、企业认证审核、短信审核及号码列表弹窗。随后实现短信通道列表、新增编辑、发送测试、通道报备详情和状态维护,支持签名下展开引流信息。主要问题集中在复杂表格操作区被裁切、按钮层级混乱以及报备详情入口缺失;通过压缩信息列、重组发送质量面板、固定操作区并将报备入口单独突出解决。还纠正了彩信不应具有引流链接、模板审核应展示所属应用等业务差异。页面和弹窗均保持本地 mock 交互,构建及 1280×720 页面检查通过。 + +## 2026-06-20 + +围绕通道报备可配置能力继续完善原型。在通道报备详情中新增“个性化引流信息报备字段”弹窗,支持字段池搜索、添加、必填配置、通道字段映射、排序和删除,并实际验证字段数量变化、弹窗滚动与布局。随后新增独立彩信通道管理菜单和页面,具备通道搜索、运营商与状态筛选、发送质量、启停、编辑、删除以及表单必填校验。当天的难点是既要保留短信通道的复杂报备逻辑,又要避免把短信特有概念错误带入彩信;最终两类通道拆分建模,全部维持纯前端状态,浏览器检查无裁切和控制台错误,构建通过。 + +## 2026-06-21 + +完成短信通道组列表及添加/编辑页面,覆盖基础设置、省网分流、全国通道、通道选择和优先级配置;又补齐运营端短信和彩信任务进度、任务详情、短信记录、彩信记录和短信上行记录。任务列表统一采用“主信息行+模板内容独立行”,详情弹窗恢复计费规则归属。当天另行复盘单个任务耗时过长,确认超长会话、页面分散、频繁构建和浏览器验证共同增加执行时间,并整理新会话接手提示,明确目录、纯前端边界、自研控件和禁止接后端等约束。功能构建通过,但 Vite 大 chunk 警告仍存在。 + +## 2026-06-30 + +对早期原型进行结构化收口:拆分企业管理、应用、签名、引流和模板模块,补齐删除确认,并将短信签名、引流资料、短信模板和彩信模板的新增编辑功能对齐旧企业详情。随后审视“通道字段配置—客户上传—运营审核—生成报备任务—导出资料—导入回执—维护状态”全流程,发现已有能力点状分散,遂新增一级“报备任务”菜单及任务、记录二级页面,统一导出和双入口回执导入。还统一运营端页面标题与图标规范。项目完成首次正式 Git 提交与远端推送,形成可回滚基线。 + +## 2026-07-01 + +项目从纯前端原型转向真实平台建设,建立 Phase 0–8 技术评估、工程骨架、NestJS、Prisma/PostgreSQL、Redis/BullMQ、MinIO、CMPP Gateway、风控、计费、发送链和运营验收方案。当天新增首批 Prisma migration、人工充值、操作日志、真实环境 smoke,以及风险审核、计费、发送链、通道报备和运营查询测试。核心问题是大量页面仍依赖 mock、localStorage 或静态数组,与“所有功能必须真实后端”的新验收标准冲突;因此同步重写需求、测试计划、功能用例和缺口清单,并规定缺少真实依赖只能标记阻塞,不能算通过。多轮测试与构建完成并提交。 + +## 2026-07-02 + +集中清理真实后端缺口,将运营看板、系统日志、人工充值、通道状态、敏感词、黑名单、企业应用和剩余短信页面接入真实 NestJS API。补齐登录、用户、角色、权限和会话流程,完成运营端操作闭环,并将非彩信的纯 mock 菜单逐步真实化。当天遇到的主要问题包括 PostgreSQL、Redis、MinIO 未启动导致的环境阻塞,Prisma schema 与迁移不同步,以及前端虽能展示但增删改只改本地状态。通过新增服务测试、真实环境 smoke、API/前端构建和文档复测区分代码通过与环境未执行项;相关修复分批提交,CMPP 对外端口统一为 17890。 + +## 2026-07-03 + +完善真实发送路由与失败补发:通道组改为按单运营商组织,应用配置客户费率,通道组成员携带运营商、省份和优先级;发送时校验真实在线连接与最终通道报备状态,提交失败、超时或失败回执按规则切换备用全国通道,并确保迟到旧回执不能覆盖已送达结果,冻结、扣费、释放和退款保持幂等。同步补齐报备回执的真实文件上传、解析和菜单后端化。难点是旧模型允许单通道路由、运营商口径混用及失败状态重复计费,需同时调整 Prisma、服务和测试。定向测试、API/前端构建及真实 PostgreSQL migration 均通过。 + +## 2026-07-06 + +围绕企业管理和文件链路做回归修复:企业列表补充关键字段和真实分页,企业编辑页恢复完整资料,上传预览、下载、删除及人工充值入口接入真实 API;补充本地 MinIO 启动脚本,避免上传功能因对象存储不可用而假通过。应用级 CMPP 连接、签名与引流表单也建立后端基线,并修复通道连接与真实路由交互。当天主要问题是部分页面仍保留老原型的本地初始数据、上传仅显示文件名而未落 MinIO,以及弹窗复杂度造成操作项遮挡。最终通过相关服务测试、前端构建和页面审计收口,随后提交真实后端与通道连接修复。 + +## 2026-07-07 + +完成第一轮完整生产部署能力及 Gateway 下游 CMPP 入站。新增 ARM64、Node、Go、PostgreSQL、MinIO、本地对象存储和 API systemd 等部署兼容处理,解决国内网络下载、数据库密码特殊字符、已有 MinIO、Prisma health check 带 schema 参数等连续安装问题。Gateway 开始监听 17890,支持真实 CONNECT 鉴权、应用独立六位账号、IP 白名单和 Submit 解包,NestJS 复用模板、签名、风控、余额、运营商、路由与队列链路。另补充应用优先队列、手机号段 Tab 和通道组补发上限。部署过程多次因环境差异失败,最终以逐项修复和健康检查完成生产基线。 + +## 2026-07-08 + +围绕 Gateway 可靠性连续推进二十四个阶段,完成上游 SubmitCommand 独立消费、客户侧 Deliver 持久化与重投、普通上行匹配、长短信拆分和长上行重组、多连接窗口控制、配置入口、在途恢复、断线补偿、保守回执归因、死信治理、自动退避、批量重投、告警、Dashboard、在线 Presence、恢复候选视图、锁与状态审计、分片补偿及共享接入号上行认领。主要困难是跨 Redis Stream、PostgreSQL、Gateway 内存状态和客户 TCP 会话保持一致,任何环节都不能用“已入队”冒充已投递。最终相关测试、构建和生产恢复流程完成,形成可观察、可重试、可审计的链路。 + +## 2026-07-09 + +修复生产通道测试短信和 CMPP2.0 上游链路。通道测试从独立记录走真实发送、提交和回执闭环,发送详情展示原始回执码及本地时间;Gateway 按通道版本发送 CMPP2 包并正确解析 Deliver 回执。同步修复部署覆盖管理员密码、登录失败无提示、通道复制后错误启用、连接池状态回写、应用 CMPP 凭据及接口开关/接口类型。当天最关键的问题是配置显示成功但真实协议版本和凭据不一致,导致页面、数据库与 Gateway 状态互相矛盾。通过生产日志、真实记录、数据库和连接状态逐层核对后分批提交,未以单一页面状态作为验收依据。 + +## 2026-07-10 + +导入 2026 年 4 月手机号段数据,过滤异常后生产落库 516217 条,并将查询改为真实服务端分页和搜索。随后按瑕疵台账完成企业充值、通道配置、通道报备、通道组、用户会话失效、审核与企业配置五批修复。问题包括前端猜测总页数、Linux 脚本换行、已有管理员部署冲突、文件名中文编码、Dashboard 待审核统计取错表,以及启停、TPS、扩展位数等配置未真正进入 Gateway。每批均运行定向测试、API/前端构建和生产健康检查;真实受保护接口、PostgreSQL、Redis、MinIO、Nginx 与 Gateway 证据均完成核对。 + +## 2026-07-11 + +完成文档瑕疵二次闭环和 CMPP 协议关键修复。Gateway 根据 CONNECT 版本协商 CMPP2.0/2.1/3.0,记录解包失败和 Submit 业务日志;模板、报备或余额失败也先落真实批次、请求、短信及失败回执,避免客户收到成功却平台无记录。新增企业应用下游连接表,区分客户 bind 与上游通道连接;短信记录持久化运营商和省份并回填历史数据。还重做运营查询控件和手机号段分页。主要问题是固定 CMPP3 解包导致字段错位、上游连接状态被误当成客户在线状态。API 全量、Gateway 测试、迁移与生产部署通过。 + +## 2026-07-12 + +系统整改固定条数截断和错误总数口径:生产企业实际 105 条、应用 204 条,但旧页面只取前 100 条并把数组长度当总数;本次审计并移除各业务列表的 100/200/500 固定上限。客户端任务页同时隔离 CMPP 和通道测试内部批次,仅展示客户批量任务。CMPP 模板不匹配改为按应用、账号和内容哈希在短窗口聚合人工审核。报备字段库与企业签名、引流资料建立真实关联,按生效路由求通道字段合集和必填规则。难点是生产相关表暂无真实字段样本,因此只验证结构、接口和空态,未注入虚假数据冒充验收。 + +## 2026-07-13 + +修复签名审核与通道报备混为一谈的问题。发送路由不再被签名全局 `reportStatus` 一票否决,而是仅从真实已报备候选通道中选路;运营端分别展示签名审核状态、三网报备汇总和“部分通过 x/y”。同时优化企业模板、企业签名布局,补齐签名审核、引流信息独立审核、通道报备任务门禁及报备状态闭环。生产核查发现【安徽航天信息】一个通道通过、一个报备中,旧前端却统一显示审核中,正好暴露状态映射丢失。修复经服务测试、构建、备份、部署和生产资源校验通过;登录后截图仍受验证码会话限制,未虚报页面验收。 + +## 2026-07-14 + +集中治理报备追溯、会话安全、下游 ACK、计费和系统日志。运营列表支持组合搜索并保留历史报备来源;CMPP 入站接受完整黑括号签名及部分通道放行。服务端新增安全 Cookie 会话、自动锁定和失效校验。下游回执要求 `CMPP_DELIVER_RESP` 精确 Msg_Id 确认,修复 SubmitResp 尚未写出就先发 Deliver、Msg_Id=0 被误判送达等时序问题,并迁移纠正历史错误状态。计费统一以现金余额为主,补齐 72 小时无回执退款;OperationLog 增加分页索引、归档和保留策略。生产 Worker 配置缺失也被定位修复,全量测试、迁移和部署健康检查通过。 + +## 2026-07-15 + +当天完成多个生产批次:报表导出与通用 Select、签名及引流资料 Excel 批量导入、统一通道报备、TPS 配置口径、报表对账、利润与发送质量、接入号填充与扩展码、运营列表排序、批量驳回、模板签名自动填充,以及下游人工重投、告警和依赖安全治理。资料文件与内嵌图片进入 MinIO,映射、版本和任务快照落 PostgreSQL,导入不再自动生成报备任务。主要问题是 WPS 多行表头、图片解析、通道 TPS 与通道组字段归属混乱、Gateway 重启后通道状态丢失。各批次均测试、备份并部署,最终服务、端口、Redis Stream 和 51 条 migration 正常。 + +## 2026-07-16 + +完成 HTTP 开放接口和两端体验收口。新增 HMAC-SHA256 鉴权、nonce 防重放、应用 QPS、单条发送、状态与上行查询,以及使用 BullMQ 的回执/上行 Webhook;发送继续复用真实模板、风控、余额、计费和 Redis 链路,并增加 HTTPS/SSRF 防护。运营端和客户端完成移动端适配,企业应用拆分 CMPP/HTTP 配置,金额精度统一为四位小数和 BIGINT 最小单位,参数复制及未开通接口的 403 边界补齐。生产还发现 Gateway 重启留下陈旧客户连接占用名额,遂将清理前置。53 条迁移、测试、构建、服务、端口和流队列均验证正常并完成部署。 + +## 2026-07-18 + +根据平台 LG 生产验证定位 CMPP 多号码 Submit 只落第一条的严重缺陷:Gateway 虽记录 `dest_count=2` 并返回成功,却只把首号码传给 NestJS。修复改为每个目的号码分别创建短信记录、内部消息和回执,同时保留一个客户端 SubmitResp/原始 Msg_Id,并增加分组 ID 支持重启恢复;任一号码非法时整包落库前拒绝。并行会话另修复上传大小、信用代码格式、操作日志及运营商规则等手工验收瑕疵,刻意避开 send-chain 重叠区域。相关 API、Gateway、Prisma 和构建验证通过,但按要求未提交、未推送、未部署,生产仍运行旧代码。 + +## 2026-07-20 + +继续收口平台 LG 缺陷并做本地真实链路验证,补齐回执、报备材料、用户安全和公开 HTTP 接口等问题;API 全量达到 21 suites、237 tests,Gateway 与前后端构建通过。二轮 UI/UX 阶段 A 同时启动,读取完整走查报告和多视口证据,建立 45 项唯一整改台账,先处理客户端用户管理移动端操作不可达等 P0。随后将 7 月 18 日以来的多号码、投递和工作流修复汇总为 `f02c33cb`,完成备份、迁移和生产发布验证。过程中坚持不发送测试短信、不写生产业务数据;仅本地完成的 UI 阶段项仍保留待部署状态。 + +## 2026-07-21 + +生产只读复查发现陈旧下游连接 `lastHeartbeatAt=NULL` 未被清理,持续占用应用最大连接数,使新 bind 建立后被关闭且 Submit 无响应;经授权只清理确认无真实 TCP/Redis 会话的一条登记,并补充代码修复。另为历史回执错归属设计唯一匹配迁移,避免猜测回填。UI/UX 阶段完成双门户会话与 Cookie 隔离、深链恢复、安全上传与日志导出、审核资格预检、定时短信多实例幂等、人工重投恢复、报备生成、删除和人工充值治理。大量功能已通过真实 API、PostgreSQL/Redis 和构建验证,但当天大部分仍未提交、未部署,生产验证留待发布后执行。 + +## 2026-07-22 + +完成公共 Dialog 的焦点约束、背景 inert、滚动锁、关闭后焦点恢复和脏表单放弃确认,并收口安全上传与审核治理。工作区先汇总为 `0f223f7f` 发布预生产,期间发生中断窗口并完成复盘。随后新增应用北京时间自然日日发送上限、HTTP 参数默认值和签名完整黑括号规范;CMPP 超限由异步失败改为同步整包 `result=8` 拒绝。发布签名迁移时又暴露生产 PostgreSQL 为 SQL_ASCII,中文正则按字节误删 UTF-8,准确识别两条损坏数据后通过备份和补偿迁移恢复。最终相关提交均推送并发布,API 全量 290 项、Gateway、迁移、服务和健康检查通过。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index d36248b..95614d1 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -3758,3 +3758,28 @@ npm run verify:phase8 | TC-SIGN-UTF8-003 | 目标签名在补偿migration前已被人工修改 | 原始hex不匹配时不得覆盖;重复执行修复SQL结果不变且不产生非法字节 | | TC-HTTP-PARAM-002 | 首次开通HTTP后查看并复制参数 | 六项能力默认开启,回执/上行为HTTP Webhook;复制文本含AppID和“客户端自助密钥”,与真实API/DB一致 | | TC-HTTP-PARAM-003 | 升级前已开通HTTP且Webhook能力开启,投递模式仍为cmpp | migration将对应回执/上行模式回填为http,参数复制不再显示CMPP长连接 | + +## 2026-07-23 供应商连接主动心跳与自动重连用例 + +| 用例ID | 场景 | 验收标准 | +| --- | --- | --- | +| TC-CMPP-UP-RECONNECT-001 | 首次连接时供应商端口不可达,随后恢复 | 首次状态为failed并记录错误/下次重连;无需人工操作即建立连接,currentConnections恢复到期望值 | +| TC-CMPP-UP-RECONNECT-002 | 已连接socket被供应商关闭 | Gateway结束旧读循环、唤醒在途提交、进入reconnecting并按退避重新登录,不发生nil客户端panic | +| TC-CMPP-UP-HEARTBEAT-001 | 空闲连接正常响应ACTIVE_TEST | 平台按通道间隔主动发送请求,按Sequence_Id清除待响应项并更新lastHeartbeatAt,短信和心跳包不交叉写坏 | +| TC-CMPP-UP-HEARTBEAT-002 | 连续心跳无响应 | 达到阈值后关闭旧连接、分类为heartbeat_timeout并自动重连;单次迟到或其他Sequence响应不能误清除全部待响应项 | +| TC-CMPP-UP-RECONNECT-003 | 鉴权失败 | 通道保持failed且5分钟慢速持续重试,不刷屏、不高频触发供应商锁定;修改正确凭据后立即重连 | +| TC-CMPP-UP-RECONNECT-004 | desiredConnections大于1且部分断开 | 只补足缺失连接,未恢复到期望值前显示reconnecting,不超过配置连接数 | +| TC-CMPP-UP-DISCONNECT-001 | 手动停用或删除通道 | API发送DisconnectChannel,Gateway关闭全部连接并停止心跳/重连;等待多个协调周期后仍为0连接 | +| TC-CMPP-UP-DISCONNECT-002 | disabled/deleted通道存在历史connected状态 | API协调任务自动下发断开并把currentConnections归零,不恢复非active通道 | +| TC-CMPP-UP-CONFIG-001 | 修改地址、端口、凭据、版本、连接数、窗口或心跳配置 | 旧池被关闭,新配置立即生效,无需先手工停用再启用 | +| TC-CMPP-UP-RECONCILE-001 | API/Gateway/Redis依次重启及多API实例并行扫描 | 活动通道最终恢复,Redis租约保证同一协调周期每通道只有一个连接指令,BullMQ任务ID不冲突 | +| TC-CMPP-UP-RECONCILE-002 | 两个API实例同时发现缺少供应商状态行 | PostgreSQL部分唯一索引只允许一条`applicationId IS NULL + channelId + connectionId`记录;P2002一方复用赢家并继续更新 | +## 2026-07-23 通道与报表补充用例 + +- `TC-CHANNEL-DEFAULT-001`:新建通道不传端口、协议、业务代码时,API 分别落库 `7890`、`CMPP`、`config.serviceId=SMS`;传入 HTTP/SGIP 时仍落为 CMPP。 +- `TC-CHANNEL-SERVICE-002`:业务代码超过 10 字节或包含非 ASCII 字符时后端返回 400;合法值进入真实 Gateway Submit 的 `Service_Id`。 +- `TC-APP-LIMIT-003`:新建企业应用未指定每任务号码上限时落库 `10000`,超过上限的真实发送任务被后端整任务拒绝。 +- `TC-SECURITY-DELETED-004`:企业黑名单、全局黑名单、敏感词列表在默认、全部状态以及显式请求 deleted 时均不返回逻辑删除记录。 +- `TC-REPORT-SEGMENT-005`:构造含长短信分片、平台拦截、成功、失败和无终态记录的日报,验证 `提交=全部 billingUnits`、`发送=提交-平台拦截`、`发送=未知+成功+失败`,并验证三类 CSV 导出字段一致。 +- `TC-UI-DETAIL-006`:短信详情展示发送号码,分片审计使用无需横向滚动的响应式卡片;短信记录桌面行密度提升且长内容两行截断。 +- `TC-UI-NAV-007`:从企业应用、企业管理、通道组等列表进入新增/编辑页后,所属二级菜单保持 `aria-current=page` 和选中样式。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index d77a8e8..f3267a7 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2272,3 +2272,36 @@ git diff --check - systemd 日志确认 21:07:16 先停止、启动 `cmpp-gateway`,随后停止、启动 `cmpp-api`。Gateway、API、Nginx、PostgreSQL、Redis 和 MinIO 实际运行,`12026/17890/8090/3000/6379/5432/9000` 均监听;本地 API/Gateway health 返回 ok,Redis PONG,`gateway.submit.commands` 为 `pending=0、lag=0`,10 个 `rate:gateway:channel:config:*` TPS 权威配置键存在,Prisma migration status 为最新。 - 5 条 active 上游通道在重启后的真实结果为 3 条 `connected/currentConnections=1`;`CH-1784797581833` 及其复制通道 `CH-1784797581833-COPY-MRXBBARL` 被上游明确返回 `connect response status: auth failed`。发布前数据库显示 5/5 connected 属于重启前状态,真实重连暴露了这两条通道的凭据/上游鉴权问题;本次长短信代码未修改上游通道鉴权,未擅自改密或停用通道,需由运营确认凭据后另行恢复。 - 外部首页、运营登录页、客户端登录页和 API health 均返回 HTTP 200,公网 `8.160.169.106:17890` TCP 连接成功。部署后 API/Gateway 未出现 Prisma、panic、fatal、Unhandled 或 Exception 程序错误,Nginx 仅有历史响应缓冲警告和本次正常重启 notice。未发送真实短信、未创建生产测试短信记录;由于目标测试正文的 `【深圳市合正物业服务有限公司】` 尚未配置为该应用的审核通过签名,仍需先完成签名/模板配置,再由用户进行真实企业 CMPP 长短信复测。 + +## 2026-07-23 供应商CMPP主动心跳与自动重连(本地未提交) + +- 现状根因:活动通道仅在创建、启用和API启动时连接一次;Gateway在首次失败后删除连接池,运行中断链只上报状态且不重新拨号。供应商出站连接只响应对端`ACTIVE_TEST`,不主动检测静默半开连接。停用/删除只修改数据库状态,没有关闭Gateway连接;修改连接参数也不会立即替换旧池。 +- Gateway新增每通道连接监督器、主动`ACTIVE_TEST`、按Sequence响应跟踪、连续未响应断链、网络退避和鉴权慢速重试。断链读循环立即退出,短信、回执响应和心跳共用串行写锁;连接监督器持续补足期望连接数。新增`DisconnectChannel`控制接口,停用/删除会取消监督器并关闭连接池。 +- API新增30秒供应商连接协调任务,以Redis租约避免多实例重复下发;活动通道连接不足、心跳陈旧或失败到期时重新下发,非活动通道存在活动/连接中状态时强制断开。修改连接相关配置立即重建连接,BullMQ连接任务使用每次尝试唯一ID并保留受控历史。 +- Prisma新增`lastReconnectAttemptAt`、`nextReconnectAt`、`lastErrorCategory`和`status + nextReconnectAt`索引,migration为`20260723220000_add_upstream_reconnect_schedule`;另以`20260723225000_enforce_supplier_connection_state_identity`清理潜在重复供应商状态并增加`applicationId IS NULL`部分唯一索引,P2002并发创建会复用既有状态。回滚需先停止新版API/Gateway,先删除部分唯一索引,再删除调度索引和三列后启动旧版本;回滚只丢失调度可观测字段,不影响短信、提交和回执记录。 +- 运营端通道表单新增心跳间隔和失败阈值,连接日志展示重连次数和下次重连时间。默认30秒/3次,可按供应商通道覆盖。 +- 当前验证:Gateway定向及`go test ./...`通过,并含真实CMPP服务端闭环“首次拒绝连接→端口恢复→自动重连→平台主动心跳收到响应”;合并并行工作区后API通道定向38项、API全量24 suites / 304项均以`--no-cache`通过,API TypeScript build、前端TypeScript/Vite build、Prisma generate/validate通过。本地升级前供应商重复状态组为0,两条自动重连migration均已应用;当前合并工作区66条migration全部应用且status最新。一次将`send-chain.service.spec.ts`与通道套件联合运行在184.8秒超时且无汇总,随后在Redis PONG环境取得明确全量通过汇总;Gateway race检测因本机CGO未启用而未执行。 +- 本地真实协调验证使用临时通道贯通Prisma/PostgreSQL、Redis租约、BullMQ和HTTP控制请求,确认failed活动通道写为connecting并发送`ConnectChannel/automatic_reconnect`,改为disabled后写为disconnected/0连接并发送`DisconnectChannel/inactive_channel_reconcile`,Redis租约释放。首次执行还暴露BullMQ禁止含冒号的jobId,修正为合法唯一ID后复测通过。协调器同时命中两条既有本地陈旧活动通道;已依据验证前操作日志精确恢复二者为原`failed/0/connect_timeout`状态,删除3条本轮日志和2个本轮队列任务,临时通道、状态和日志也已清理。 +- `npm run verify:phase8`中的Gateway和契约阶段通过,但BullMQ性能阶段两次分别为284.16和376.52 TPS,低于500 TPS门槛,因此完整phase8仍记为环境性能失败而非通过;本轮功能正确性不依赖降低该门槛。 +- 本地前端预览可加载且无框架错误覆盖层,但访问运营端通道页因本地API未启动跳转登录页,验证码接口返回502;按安全规则未绕过验证码、未提交浏览器中已有凭据,因此新增心跳字段和连接日志的登录后页面交互验收仍未完成。预览进程及浏览器测试页已关闭。 +- 本轮未提交、未push、未部署,也未修改预发布通道或发送真实短信。生产/预发布仍运行旧版本,不具备本节新增自动重连能力。 +## 2026-07-23 通道、应用、详单与报表整改(未提交、未部署) + +- 已实现:新建通道默认端口 7890、协议强制 CMPP、业务代码 `serviceId` 默认 SMS;短信测试弹窗白色顶部栏。 +- 已实现:短信详情展示发送号码,分片审计改为响应式卡片;短信记录列表压缩间距并限制长内容为两行。 +- 已实现:企业应用任务号码默认上限 10000;列表操作换行、客户连接状态改名、未开通参数按钮禁用并区分颜色、CMPP 参数弹窗移除冗余摘要框。 +- 已实现:安全控制三个真实 API 默认和显式 deleted 查询均排除逻辑删除数据;添加/编辑二级路由保持所属菜单选中。 +- 已实现:三张日报表及导出增加提交数、未知数,使用 `billingUnits` 统计长短信分片,并从发送数中排除平台拦截。 +- 已实现:企业认证审核隐藏申请单号、短信审核隐藏任务编号、模板审核隐藏审核编号。 +- 自动化验证:`prisma validate` 通过;相关 4 个 Jest 套件 108/108 通过;API build 通过;前端 TypeScript/Vite build 通过;`git diff --check` 通过。 +- 真实本地链路验证:本地 PostgreSQL 已应用并核对全部 66 条 migration,`prisma migrate status` 返回 schema up to date;使用真实登录/API 验证安全控制三个列表即使显式传入 `status=deleted` 也不返回已删除数据;重新生成 2026-06-30 至 2026-07-03 报表后,利润、对账、质量报表均满足 `发送数 = 未知数 + 成功数 + 失败数`、`提交数 >= 发送数`,三个真实报表 API 均返回新增字段。 +- 浏览器验收:使用真实本地 API/PostgreSQL/Redis 页面完成 1440×900、1366×768、768×1024、390×844、375×667 验收。短信记录详情展示发送号码且弹窗无横向溢出;企业应用操作区换行、客户连接状态表头、接口参数按钮状态色及 CMPP 参数精简均生效;新增应用默认每任务 10000 个号码;三个报表新增提交数和未知数;三个审核页面不再展示内部编号;新增应用深链保持“企业应用”菜单选中。 +- 验收边界:本地数据库暂无包含分片审计明细的短信记录,因此已验证真实空状态和各视口无横向溢出,分片卡片的有数据视觉仍需在存在真实分片记录的环境补验。当前修改保持未提交、未推送、未部署。 + +## 2026-07-24 合并发布前门禁与 TPS 隔离复测 + +- 本轮按用户授权合并供应商 CMPP 主动心跳/自动重连、通道与应用整改、详单与报表口径及全部并行工作区代码。`origin/main` 与本地基线提交 `2f781ebb8af656bdb2a0395fa742538961febf21` 一致,无远端新提交需要合并;`api/tsconfig.build.tsbuildinfo` 和 `outputs/` 按发布规则排除。 +- 发布前 API 全量 24 suites / 304 tests、Gateway `go test ./...` 与 `go vet ./...`、API build、前端 TypeScript/Vite build、Prisma generate/validate/status、`git diff --check`均通过;本地 PostgreSQL 共 66 条 migration 且 schema up to date。 +- 依赖审计发现新公告:前端 `react-router-dom/react-router 7.17.0` 存在中危开放重定向等问题,升级至 7.18.1;API 的 Prisma CLI 间接依赖 `find-my-way 9.6.0` 存在高危 HTTP/2 DDoS 问题,以兼容覆盖固定为 9.7.0。升级后根项目与 API `npm audit` 均为 0 漏洞,构建和 Prisma 命令复测通过。 +- 本机 3000 端口存在其他会话自 2026-07-23 23:02 起运行的 API 进程,若直接使用共享 Redis 压测会与业务服务争用事件循环、CPU 和 Redis 连接。为保留该会话进程并消除队列干扰,本轮在独立临时 Redis 6389 上运行完整 `npm run verify:phase8`:15000 条消息入队 3679.70 TPS,提交结果与回执完整闭环 549.52 TPS,达到 500 TPS 门槛;临时 Redis 已停止。 +- 预发布部署前只读基线:`.deployed-commit=b29576fcd118bea04416be0c9fc1bc2a4213d830`,服务器 4 核、7499MB 内存、可用内存 6519MB、负载 0;Gateway/API/Nginx/PostgreSQL/MinIO 正常,Redis `PONG`,发送 Worker 并发 50,`gateway.submit.commands` 为 `pending=0、lag=0`。实际发布、备份、迁移、服务重启和预发布 TPS 结果待部署后补记。 diff --git a/gateway/internal/control/server.go b/gateway/internal/control/server.go index 5705331..a19d9a6 100644 --- a/gateway/internal/control/server.go +++ b/gateway/internal/control/server.go @@ -14,6 +14,7 @@ import ( ) type ConnectFunc func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) +type DisconnectFunc func(context.Context, DisconnectChannelCommand) (ConnectionStateCallback, error) type SubmitFunc func(context.Context, queue.SubmitCommand) (queue.SubmitResult, error) type ConnectChannelCommand struct { @@ -28,34 +29,51 @@ type ConnectChannelCommand struct { } type ChannelConfig struct { - Code string `json:"code"` - Name string `json:"name"` - GatewayHost string `json:"gatewayHost"` - GatewayPort int `json:"gatewayPort"` - Account string `json:"account"` - PasswordCipher string `json:"passwordCipher"` - SrcID string `json:"srcId"` - CMPPVersion string `json:"cmppVersion"` - RateLimitPerSecond int `json:"rateLimitPerSecond"` + Code string `json:"code"` + Name string `json:"name"` + GatewayHost string `json:"gatewayHost"` + GatewayPort int `json:"gatewayPort"` + Account string `json:"account"` + PasswordCipher string `json:"passwordCipher"` + SrcID string `json:"srcId"` + CMPPVersion string `json:"cmppVersion"` + RateLimitPerSecond int `json:"rateLimitPerSecond"` + WindowSize int `json:"windowSize,omitempty"` + HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"` + HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"` +} + +type DisconnectChannelCommand struct { + SchemaVersion string `json:"schemaVersion"` + MessageType string `json:"messageType"` + TraceID string `json:"traceId"` + ChannelID string `json:"channelId"` + ConnectionID string `json:"connectionId"` + CreatedAt string `json:"createdAt"` + Reason string `json:"reason"` } type ConnectionStateCallback struct { - ChannelID string `json:"channelId"` - ConnectionID string `json:"connectionId"` - Status string `json:"status"` - DesiredConnections int `json:"desiredConnections"` - CurrentConnections int `json:"currentConnections"` - LastConnectedAt string `json:"lastConnectedAt,omitempty"` - LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"` - LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` - ReconnectCount int `json:"reconnectCount,omitempty"` - LastError string `json:"lastError,omitempty"` + ChannelID string `json:"channelId"` + ConnectionID string `json:"connectionId"` + Status string `json:"status"` + DesiredConnections int `json:"desiredConnections"` + CurrentConnections int `json:"currentConnections"` + LastConnectedAt string `json:"lastConnectedAt,omitempty"` + LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"` + LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` + ReconnectCount int `json:"reconnectCount,omitempty"` + LastReconnectAttemptAt string `json:"lastReconnectAttemptAt,omitempty"` + NextReconnectAt string `json:"nextReconnectAt,omitempty"` + LastErrorCategory string `json:"lastErrorCategory,omitempty"` + LastError string `json:"lastError,omitempty"` } type Server struct { APIBaseURL string HTTPClient *http.Client Connect ConnectFunc + Disconnect DisconnectFunc Submit SubmitFunc Upstream *upstream.Manager Limiter ratelimit.Limiter @@ -78,10 +96,14 @@ func Register(mux *http.ServeMux, server Server) { if server.Connect == nil { server.Connect = server.connectChannel } + if server.Disconnect == nil { + server.Disconnect = server.disconnectChannel + } if server.Submit == nil { server.Submit = server.Upstream.Submit } mux.HandleFunc("/connections/connect", server.handleConnectChannel) + mux.HandleFunc("/connections/disconnect", server.handleDisconnectChannel) mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit) mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt) mux.HandleFunc("/downstream/uplink", server.handleDownstreamUplink) @@ -122,6 +144,29 @@ func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(status) } +func (s Server) handleDisconnectChannel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var command DisconnectChannelCommand + if err := json.NewDecoder(r.Body).Decode(&command); err != nil { + http.Error(w, fmt.Sprintf("invalid disconnect command: %v", err), http.StatusBadRequest) + return + } + if command.MessageType != string(queue.MessageTypeDisconnectChannel) || command.ChannelID == "" || command.ConnectionID == "" { + http.Error(w, "messageType DisconnectChannel, channelId and connectionId are required", http.StatusBadRequest) + return + } + status, err := s.Disconnect(r.Context(), command) + if err != nil { + http.Error(w, fmt.Sprintf("failed to disconnect upstream pool: %v", err), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(status) +} + func (s Server) handleUpstreamSubmit(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -265,29 +310,60 @@ func (s Server) connectChannel(ctx context.Context, command ConnectChannelComman Reason: command.Reason, DesiredConnections: command.DesiredConnections, Channel: queue.ConnectChannelConfig{ - Code: command.Channel.Code, - Name: command.Channel.Name, - GatewayHost: command.Channel.GatewayHost, - GatewayPort: command.Channel.GatewayPort, - Account: command.Channel.Account, - PasswordCipher: command.Channel.PasswordCipher, - SrcID: command.Channel.SrcID, - CMPPVersion: command.Channel.CMPPVersion, - RateLimitPerSecond: command.Channel.RateLimitPerSecond, + Code: command.Channel.Code, + Name: command.Channel.Name, + GatewayHost: command.Channel.GatewayHost, + GatewayPort: command.Channel.GatewayPort, + Account: command.Channel.Account, + PasswordCipher: command.Channel.PasswordCipher, + SrcID: command.Channel.SrcID, + CMPPVersion: command.Channel.CMPPVersion, + RateLimitPerSecond: command.Channel.RateLimitPerSecond, + WindowSize: command.Channel.WindowSize, + HeartbeatIntervalSeconds: command.Channel.HeartbeatIntervalSeconds, + HeartbeatMissThreshold: command.Channel.HeartbeatMissThreshold, }, }) if err != nil { return ConnectionStateCallback{}, err } + return ConnectionStateCallback{ + ChannelID: state.ChannelID, + ConnectionID: state.ConnectionID, + Status: state.Status, + DesiredConnections: state.DesiredConnections, + CurrentConnections: state.CurrentConnections, + LastConnectedAt: state.LastConnectedAt, + LastDisconnectedAt: state.LastDisconnectedAt, + LastHeartbeatAt: state.LastHeartbeatAt, + ReconnectCount: state.ReconnectCount, + LastReconnectAttemptAt: state.LastReconnectAttemptAt, + NextReconnectAt: state.NextReconnectAt, + LastErrorCategory: state.LastErrorCategory, + LastError: state.LastError, + }, nil +} + +func (s Server) disconnectChannel(ctx context.Context, command DisconnectChannelCommand) (ConnectionStateCallback, error) { + state, err := s.Upstream.DisconnectChannel(ctx, queue.DisconnectChannelCommand{ + SchemaVersion: command.SchemaVersion, + MessageType: queue.MessageTypeDisconnectChannel, + TraceID: command.TraceID, + ChannelID: command.ChannelID, + ConnectionID: command.ConnectionID, + CreatedAt: time.Now().UTC(), + Reason: command.Reason, + }) + if err != nil { + return ConnectionStateCallback{}, err + } return ConnectionStateCallback{ ChannelID: state.ChannelID, ConnectionID: state.ConnectionID, Status: state.Status, DesiredConnections: state.DesiredConnections, CurrentConnections: state.CurrentConnections, - LastConnectedAt: state.LastConnectedAt, LastDisconnectedAt: state.LastDisconnectedAt, - LastHeartbeatAt: state.LastHeartbeatAt, ReconnectCount: state.ReconnectCount, LastError: state.LastError, }, nil diff --git a/gateway/internal/control/server_test.go b/gateway/internal/control/server_test.go index 2cac159..5692ca4 100644 --- a/gateway/internal/control/server_test.go +++ b/gateway/internal/control/server_test.go @@ -140,6 +140,39 @@ func TestConnectChannelRejectsInvalidCommand(t *testing.T) { } } +func TestDisconnectChannelStopsSupplierPool(t *testing.T) { + var received DisconnectChannelCommand + handler := handlerWithServer(Server{ + Disconnect: func(_ context.Context, command DisconnectChannelCommand) (ConnectionStateCallback, error) { + received = command + return ConnectionStateCallback{ + ChannelID: command.ChannelID, + ConnectionID: command.ConnectionID, + Status: "disconnected", + CurrentConnections: 0, + }, nil + }, + }) + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/connections/disconnect", strings.NewReader(`{ + "schemaVersion":"v1", + "messageType":"DisconnectChannel", + "traceId":"trace-disconnect", + "channelId":"channel-1", + "connectionId":"channel-1:primary", + "reason":"channel_disabled" + }`)) + + handler.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String()) + } + if received.ChannelID != "channel-1" || received.Reason != "channel_disabled" { + t.Fatalf("unexpected disconnect command: %+v", received) + } +} + func TestRecoveryCandidatesEndpointReturnsView(t *testing.T) { handler := handlerWithServer(Server{ RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) { diff --git a/gateway/internal/queue/messages.go b/gateway/internal/queue/messages.go index cba4533..48eccb2 100644 --- a/gateway/internal/queue/messages.go +++ b/gateway/internal/queue/messages.go @@ -7,11 +7,12 @@ const SchemaVersion = "v1" type MessageType string const ( - MessageTypeSubmitCommand MessageType = "SubmitCommand" - MessageTypeSubmitResult MessageType = "SubmitResult" - MessageTypeReceiptEvent MessageType = "ReceiptEvent" - MessageTypeUplinkEvent MessageType = "UplinkEvent" - MessageTypeConnectChannel MessageType = "ConnectChannel" + MessageTypeSubmitCommand MessageType = "SubmitCommand" + MessageTypeSubmitResult MessageType = "SubmitResult" + MessageTypeReceiptEvent MessageType = "ReceiptEvent" + MessageTypeUplinkEvent MessageType = "UplinkEvent" + MessageTypeConnectChannel MessageType = "ConnectChannel" + MessageTypeDisconnectChannel MessageType = "DisconnectChannel" ) type Envelope struct { @@ -60,13 +61,15 @@ type CMPP struct { } type UpstreamConfig struct { - GatewayHost string `json:"gatewayHost"` - GatewayPort int `json:"gatewayPort"` - Account string `json:"account"` - PasswordCipher string `json:"passwordCipher"` - CMPPVersion string `json:"cmppVersion"` - DesiredConnections int `json:"desiredConnections,omitempty"` - WindowSize int `json:"windowSize,omitempty"` + GatewayHost string `json:"gatewayHost"` + GatewayPort int `json:"gatewayPort"` + Account string `json:"account"` + PasswordCipher string `json:"passwordCipher"` + CMPPVersion string `json:"cmppVersion"` + DesiredConnections int `json:"desiredConnections,omitempty"` + WindowSize int `json:"windowSize,omitempty"` + HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"` + HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"` } type Retry struct { @@ -76,12 +79,12 @@ type Retry struct { type SubmitResult struct { Envelope - SequenceID uint32 `json:"sequenceId"` - GatewayMessageID string `json:"gatewayMessageId"` - SubmitStatus string `json:"submitStatus"` - ErrorCode string `json:"errorCode,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` - SubmittedAt time.Time `json:"submittedAt"` + SequenceID uint32 `json:"sequenceId"` + GatewayMessageID string `json:"gatewayMessageId"` + SubmitStatus string `json:"submitStatus"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + SubmittedAt time.Time `json:"submittedAt"` Segments []SubmitSegmentResult `json:"segments,omitempty"` } @@ -129,13 +132,26 @@ type ConnectChannelCommand struct { } type ConnectChannelConfig struct { - Code string `json:"code"` - Name string `json:"name"` - GatewayHost string `json:"gatewayHost"` - GatewayPort int `json:"gatewayPort"` - Account string `json:"account"` - PasswordCipher string `json:"passwordCipher"` - SrcID string `json:"srcId"` - CMPPVersion string `json:"cmppVersion"` - RateLimitPerSecond int `json:"rateLimitPerSecond"` + Code string `json:"code"` + Name string `json:"name"` + GatewayHost string `json:"gatewayHost"` + GatewayPort int `json:"gatewayPort"` + Account string `json:"account"` + PasswordCipher string `json:"passwordCipher"` + SrcID string `json:"srcId"` + CMPPVersion string `json:"cmppVersion"` + RateLimitPerSecond int `json:"rateLimitPerSecond"` + WindowSize int `json:"windowSize,omitempty"` + HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"` + HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"` +} + +type DisconnectChannelCommand struct { + SchemaVersion string `json:"schemaVersion"` + MessageType MessageType `json:"messageType"` + TraceID string `json:"traceId"` + ChannelID string `json:"channelId"` + ConnectionID string `json:"connectionId"` + CreatedAt time.Time `json:"createdAt"` + Reason string `json:"reason"` } diff --git a/gateway/internal/upstream/connection_loss_test.go b/gateway/internal/upstream/connection_loss_test.go index c1dfd46..aa8218d 100644 --- a/gateway/internal/upstream/connection_loss_test.go +++ b/gateway/internal/upstream/connection_loss_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" ) func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) { @@ -18,6 +19,8 @@ func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) { reported = state return nil }, + reconnectSignal: make(chan struct{}, 1), + stopCh: make(chan struct{}), }, } conn.pool.conns = []*connection{conn} @@ -45,6 +48,60 @@ func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) { if reported.Status != "disconnected" || reported.CurrentConnections != 0 { t.Fatalf("unexpected reported state: %+v", reported) } + if conn.pool.reconnectCount != 1 || conn.pool.nextReconnectAt.IsZero() { + t.Fatalf("expected connection loss to schedule reconnect, got count=%d next=%v", conn.pool.reconnectCount, conn.pool.nextReconnectAt) + } + conn.pool.close() +} + +func TestHeartbeatTimeoutClosesConnectionAndSchedulesReconnect(t *testing.T) { + pool := &connectionPool{ + channelID: "channel-1", + connectionID: "channel-1:primary", + config: normalizeUpstreamConfig(queueUpstreamConfigForTest()), + reconnectSignal: make(chan struct{}, 1), + stopCh: make(chan struct{}), + } + conn := &connection{ + pool: pool, + pending: make(map[uint32]chan submitPartResponse), + heartbeatPending: map[uint32]time.Time{1: time.Now(), 2: time.Now(), 3: time.Now()}, + } + pool.conns = []*connection{conn} + + if conn.sendHeartbeat() { + t.Fatal("expected heartbeat timeout to stop heartbeat loop") + } + if !conn.closed { + t.Fatal("expected heartbeat timeout to close the connection") + } + if pool.reconnectCount != 1 || pool.lastErrorCategory != "heartbeat_timeout" { + t.Fatalf("unexpected reconnect state: count=%d category=%s", pool.reconnectCount, pool.lastErrorCategory) + } + pool.close() +} + +func TestReconnectDelayUsesCappedBackoffAndSlowAuthenticationRetry(t *testing.T) { + if got := reconnectDelay(1, "network"); got < 4*time.Second || got > 6*time.Second { + t.Fatalf("first reconnect delay = %v", got) + } + if got := reconnectDelay(6, "network"); got < 4*time.Minute || got > 6*time.Minute { + t.Fatalf("capped reconnect delay = %v", got) + } + if got := reconnectDelay(1, "authentication"); got != 5*time.Minute { + t.Fatalf("authentication reconnect delay = %v", got) + } +} + +func TestHeartbeatResponseClearsOnlyMatchingRequest(t *testing.T) { + conn := &connection{heartbeatPending: map[uint32]time.Time{7: time.Now(), 8: time.Now()}} + conn.handleHeartbeatResponse(7) + if _, exists := conn.heartbeatPending[7]; exists { + t.Fatal("expected matching heartbeat request to be cleared") + } + if _, exists := conn.heartbeatPending[8]; !exists { + t.Fatal("expected unrelated heartbeat request to remain pending") + } } func TestTemporaryReadTimeoutDetection(t *testing.T) { diff --git a/gateway/internal/upstream/manager.go b/gateway/internal/upstream/manager.go index 3c46ac6..c2110a1 100644 --- a/gateway/internal/upstream/manager.go +++ b/gateway/internal/upstream/manager.go @@ -19,10 +19,15 @@ import ( ) const ( - defaultConnectTimeout = 5 * time.Second - defaultSubmitTimeout = 10 * time.Second - defaultHTTPTimeout = 10 * time.Second - defaultWindowSize = 16 + defaultConnectTimeout = 5 * time.Second + defaultSubmitTimeout = 10 * time.Second + defaultHTTPTimeout = 10 * time.Second + defaultWindowSize = 16 + defaultHeartbeatInterval = 30 * time.Second + defaultHeartbeatMissThreshold = 3 + defaultReconnectInitialDelay = 5 * time.Second + defaultReconnectMaximumDelay = 5 * time.Minute + defaultAuthReconnectDelay = 5 * time.Minute ) type Manager struct { @@ -34,16 +39,19 @@ type Manager struct { } type ConnectionState struct { - ChannelID string `json:"channelId"` - ConnectionID string `json:"connectionId"` - Status string `json:"status"` - DesiredConnections int `json:"desiredConnections"` - CurrentConnections int `json:"currentConnections"` - LastConnectedAt string `json:"lastConnectedAt,omitempty"` - LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"` - LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` - ReconnectCount int `json:"reconnectCount,omitempty"` - LastError string `json:"lastError,omitempty"` + ChannelID string `json:"channelId"` + ConnectionID string `json:"connectionId"` + Status string `json:"status"` + DesiredConnections int `json:"desiredConnections"` + CurrentConnections int `json:"currentConnections"` + LastConnectedAt string `json:"lastConnectedAt,omitempty"` + LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"` + LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` + ReconnectCount int `json:"reconnectCount,omitempty"` + LastReconnectAttemptAt string `json:"lastReconnectAttemptAt,omitempty"` + NextReconnectAt string `json:"nextReconnectAt,omitempty"` + LastErrorCategory string `json:"lastErrorCategory,omitempty"` + LastError string `json:"lastError,omitempty"` } func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) { @@ -86,13 +94,15 @@ func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChann m.ensureDefaultsLocked() pool := m.conns[command.ChannelID] config := normalizeUpstreamConfig(queue.UpstreamConfig{ - GatewayHost: command.Channel.GatewayHost, - GatewayPort: command.Channel.GatewayPort, - Account: command.Channel.Account, - PasswordCipher: command.Channel.PasswordCipher, - CMPPVersion: command.Channel.CMPPVersion, - DesiredConnections: command.DesiredConnections, - WindowSize: 16, + GatewayHost: command.Channel.GatewayHost, + GatewayPort: command.Channel.GatewayPort, + Account: command.Channel.Account, + PasswordCipher: command.Channel.PasswordCipher, + CMPPVersion: command.Channel.CMPPVersion, + DesiredConnections: command.DesiredConnections, + WindowSize: command.Channel.WindowSize, + HeartbeatIntervalSeconds: command.Channel.HeartbeatIntervalSeconds, + HeartbeatMissThreshold: command.Channel.HeartbeatMissThreshold, }) if pool == nil || !pool.matches(config) { if pool != nil { @@ -103,16 +113,47 @@ func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChann } m.mu.Unlock() + pool.startSupervisor() if err := pool.ensureConnected(); err != nil { + if pool.stopped() { + return pool.snapshotState("disconnected", nil), nil + } + pool.scheduleReconnect(err) _ = pool.reportState(ctx, "failed", err) - m.mu.Lock() - delete(m.conns, command.ChannelID) - m.mu.Unlock() return pool.snapshotState("failed", err), nil } + if pool.stopped() { + return pool.snapshotState("disconnected", nil), nil + } + pool.resetReconnectState() return pool.snapshotState("connected", nil), nil } +func (m *Manager) DisconnectChannel(ctx context.Context, command queue.DisconnectChannelCommand) (ConnectionState, error) { + if command.ChannelID == "" || command.ConnectionID == "" { + return ConnectionState{}, fmt.Errorf("channelId and connectionId are required") + } + m.mu.Lock() + m.ensureDefaultsLocked() + pool := m.conns[command.ChannelID] + delete(m.conns, command.ChannelID) + m.mu.Unlock() + if pool != nil { + pool.close() + state := pool.snapshotState("disconnected", nil) + _ = pool.reportState(ctx, "disconnected", nil) + return state, nil + } + return ConnectionState{ + ChannelID: command.ChannelID, + ConnectionID: command.ConnectionID, + Status: "disconnected", + DesiredConnections: 0, + CurrentConnections: 0, + LastDisconnectedAt: time.Now().UTC().Format(time.RFC3339Nano), + }, nil +} + func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error) { m.mu.Lock() defer m.mu.Unlock() @@ -127,10 +168,12 @@ func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error pool = m.newConnectionPool(cmd.ChannelID, defaultChannelConnectionID(cmd.ChannelID), normalizeUpstreamConfig(cmd.Upstream)) m.conns[cmd.ChannelID] = pool } + pool.startSupervisor() if err := pool.ensureConnected(); err != nil { - delete(m.conns, cmd.ChannelID) + pool.scheduleReconnect(err) return nil, err } + pool.resetReconnectState() return pool, nil } @@ -161,6 +204,8 @@ func (m *Manager) newConnectionPool(channelID string, connectionID string, confi reporter: func(ctx context.Context, state ConnectionState) error { return m.post(ctx, "/admin/gateway/connections", state) }, + reconnectSignal: make(chan struct{}, 1), + stopCh: make(chan struct{}), } } @@ -172,9 +217,18 @@ type connectionPool struct { httpClient *http.Client reporter func(context.Context, ConnectionState) error - mu sync.Mutex - conns []*connection - next int + mu sync.Mutex + connectMu sync.Mutex + conns []*connection + next int + reconnectSignal chan struct{} + stopCh chan struct{} + stopOnce sync.Once + supervisorOnce sync.Once + reconnectCount int + lastReconnectAttemptAt time.Time + nextReconnectAt time.Time + lastErrorCategory string } func (p *connectionPool) matches(config queue.UpstreamConfig) bool { @@ -182,6 +236,8 @@ func (p *connectionPool) matches(config queue.UpstreamConfig) bool { } func (p *connectionPool) ensureConnected() error { + p.connectMu.Lock() + defer p.connectMu.Unlock() desired := p.config.DesiredConnections if desired <= 0 { desired = 1 @@ -189,29 +245,38 @@ func (p *connectionPool) ensureConnected() error { connectedAny := false for { p.mu.Lock() + active := p.conns[:0] + for _, existing := range p.conns { + existing.mu.Lock() + usable := existing.client != nil && !existing.closed + existing.mu.Unlock() + if usable { + active = append(active, existing) + } + } + p.conns = active if len(p.conns) >= desired { p.mu.Unlock() break } index := len(p.conns) conn := &connection{ - channelID: p.channelID, - config: p.config, - index: index, - pool: p, - apiBaseURL: p.apiBaseURL, - httpClient: p.httpClient, - window: make(chan struct{}, p.config.WindowSize), - pending: make(map[uint32]chan submitPartResponse), - tracker: make(map[uint64]queue.SubmitCommand), - longUplink: make(map[string]*longUplinkAssembly), + channelID: p.channelID, + config: p.config, + index: index, + pool: p, + apiBaseURL: p.apiBaseURL, + httpClient: p.httpClient, + window: make(chan struct{}, p.config.WindowSize), + pending: make(map[uint32]chan submitPartResponse), + tracker: make(map[uint64]queue.SubmitCommand), + longUplink: make(map[string]*longUplinkAssembly), + heartbeatPending: make(map[uint32]time.Time), } p.mu.Unlock() connected, err := conn.ensureConnected() if err != nil { - conn.close() - p.close() return err } @@ -226,6 +291,118 @@ func (p *connectionPool) ensureConnected() error { return nil } +func (p *connectionPool) startSupervisor() { + p.supervisorOnce.Do(func() { + go p.superviseReconnects() + }) +} + +func (p *connectionPool) stopped() bool { + select { + case <-p.stopCh: + return true + default: + return false + } +} + +func (p *connectionPool) signalReconnect() { + select { + case <-p.stopCh: + return + default: + } + select { + case p.reconnectSignal <- struct{}{}: + default: + } +} + +func (p *connectionPool) scheduleReconnect(stateErr error) { + select { + case <-p.stopCh: + return + default: + } + p.mu.Lock() + now := time.Now().UTC() + if !p.nextReconnectAt.IsZero() && p.nextReconnectAt.After(now) { + p.mu.Unlock() + return + } + p.reconnectCount++ + p.lastReconnectAttemptAt = now + p.lastErrorCategory = connectionErrorCategory(stateErr) + delay := reconnectDelay(p.reconnectCount, p.lastErrorCategory) + p.nextReconnectAt = p.lastReconnectAttemptAt.Add(delay) + p.mu.Unlock() + p.signalReconnect() +} + +func (p *connectionPool) resetReconnectState() { + p.mu.Lock() + p.reconnectCount = 0 + p.lastReconnectAttemptAt = time.Time{} + p.nextReconnectAt = time.Time{} + p.lastErrorCategory = "" + p.mu.Unlock() + p.signalReconnect() +} + +func (p *connectionPool) superviseReconnects() { + for { + select { + case <-p.stopCh: + return + case <-p.reconnectSignal: + } + for { + p.mu.Lock() + next := p.nextReconnectAt + p.mu.Unlock() + if next.IsZero() { + break + } + timer := time.NewTimer(time.Until(next)) + select { + case <-p.stopCh: + if !timer.Stop() { + <-timer.C + } + return + case <-p.reconnectSignal: + if !timer.Stop() { + <-timer.C + } + continue + case <-timer.C: + } + _ = p.reportState(context.Background(), "reconnecting", nil) + p.mu.Lock() + p.lastReconnectAttemptAt = time.Now().UTC() + p.mu.Unlock() + if err := p.ensureConnected(); err != nil { + select { + case <-p.stopCh: + return + default: + } + p.scheduleReconnect(err) + _ = p.reportState(context.Background(), "failed", err) + continue + } + select { + case <-p.stopCh: + return + default: + } + p.resetReconnectState() + _ = p.reportState(context.Background(), "connected", nil) + break + } + } +} + func (p *connectionPool) submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) { parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content) if err != nil { @@ -314,6 +491,11 @@ func (p *connectionPool) tryAcquireConnection() (*connection, func()) { } func (p *connectionPool) close() { + p.connectMu.Lock() + defer p.connectMu.Unlock() + p.stopOnce.Do(func() { + close(p.stopCh) + }) p.mu.Lock() conns := p.conns p.conns = nil @@ -339,6 +521,16 @@ func (p *connectionPool) snapshotState(status string, stateErr error) Connection DesiredConnections: p.config.DesiredConnections, CurrentConnections: p.countActiveConnections(), } + p.mu.Lock() + state.ReconnectCount = p.reconnectCount + state.LastErrorCategory = p.lastErrorCategory + if !p.lastReconnectAttemptAt.IsZero() { + state.LastReconnectAttemptAt = p.lastReconnectAttemptAt.Format(time.RFC3339Nano) + } + if !p.nextReconnectAt.IsZero() { + state.NextReconnectAt = p.nextReconnectAt.Format(time.RFC3339Nano) + } + p.mu.Unlock() if state.DesiredConnections <= 0 { state.DesiredConnections = 1 } @@ -346,6 +538,8 @@ func (p *connectionPool) snapshotState(status string, stateErr error) Connection case "connected": state.LastConnectedAt = now state.LastHeartbeatAt = now + case "heartbeat": + state.LastHeartbeatAt = now case "disconnected", "failed": state.LastDisconnectedAt = now } @@ -378,15 +572,17 @@ type connection struct { apiBaseURL string httpClient *http.Client - mu sync.Mutex - sendMu sync.Mutex - client *cmpp.Client - window chan struct{} - pending map[uint32]chan submitPartResponse - tracker map[uint64]queue.SubmitCommand - longUplink map[string]*longUplinkAssembly - readOnce sync.Once - closed bool + mu sync.Mutex + sendMu sync.Mutex + client *cmpp.Client + window chan struct{} + pending map[uint32]chan submitPartResponse + tracker map[uint64]queue.SubmitCommand + longUplink map[string]*longUplinkAssembly + readOnce sync.Once + closed bool + heartbeatCancel context.CancelFunc + heartbeatPending map[uint32]time.Time } type submitPartResponse struct { @@ -415,7 +611,11 @@ func (c *connection) ensureConnected() (bool, error) { } c.client = client c.closed = false + c.heartbeatPending = make(map[uint32]time.Time) + heartbeatCtx, cancelHeartbeat := context.WithCancel(context.Background()) + c.heartbeatCancel = cancelHeartbeat go c.readLoop() + go c.heartbeatLoop(heartbeatCtx) return true, nil } @@ -423,8 +623,17 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa rspCh := make(chan submitPartResponse, 1) pkt := c.submitRequestPacket(cmd, part) + c.mu.Lock() + client := c.client + closed := c.closed + c.mu.Unlock() + if closed || client == nil { + err := fmt.Errorf("supplier connection is not available") + result := submitResult(cmd, 0, "", "timeout", "CONNECTION_LOST", err.Error()) + return 0, "", result, err + } c.sendMu.Lock() - seq, err := c.client.SendReqPkt(pkt) + seq, err := client.SendReqPkt(pkt) c.sendMu.Unlock() if err != nil { c.close() @@ -576,10 +785,17 @@ func (c *connection) releaseWindow() { func (c *connection) readLoop() { for { - pkt, err := c.client.RecvAndUnpackPkt(time.Second) + c.mu.Lock() + client := c.client + closed := c.closed + c.mu.Unlock() + if closed || client == nil { + return + } + pkt, err := client.RecvAndUnpackPkt(time.Second) if err != nil { c.mu.Lock() - closed := c.closed + closed = c.closed c.mu.Unlock() if closed { return @@ -588,7 +804,7 @@ func (c *connection) readLoop() { continue } c.handleConnectionLoss(err) - continue + return } switch p := pkt.(type) { case *cmpp.Cmpp2SubmitRspPkt: @@ -606,17 +822,86 @@ func (c *connection) readLoop() { ch <- submitPartResponse{seqID: p.SeqId, msgID: p.MsgId, result: p.Result} } case *cmpp.Cmpp2DeliverReqPkt: - _ = c.client.SendRspPkt(&cmpp.Cmpp2DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) + _ = c.sendResponse(client, &cmpp.Cmpp2DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) c.handleDeliver(deliverPacketFromCMPP2(p)) case *cmpp.Cmpp3DeliverReqPkt: - _ = c.client.SendRspPkt(&cmpp.Cmpp3DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) + _ = c.sendResponse(client, &cmpp.Cmpp3DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId) c.handleDeliver(deliverPacketFromCMPP3(p)) case *cmpp.CmppActiveTestReqPkt: - _ = c.client.SendRspPkt(&cmpp.CmppActiveTestRspPkt{}, p.SeqId) + _ = c.sendResponse(client, &cmpp.CmppActiveTestRspPkt{}, p.SeqId) + _ = c.pool.reportState(context.Background(), "heartbeat", nil) + case *cmpp.CmppActiveTestRspPkt: + c.handleHeartbeatResponse(p.SeqId) + _ = c.pool.reportState(context.Background(), "heartbeat", nil) } } } +func (c *connection) sendResponse(client *cmpp.Client, packet cmpp.Packer, sequenceID uint32) error { + c.sendMu.Lock() + defer c.sendMu.Unlock() + return client.SendRspPkt(packet, sequenceID) +} + +func (c *connection) heartbeatLoop(ctx context.Context) { + interval := time.Duration(c.config.HeartbeatIntervalSeconds) * time.Second + if interval <= 0 { + interval = defaultHeartbeatInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !c.sendHeartbeat() { + return + } + } + } +} + +func (c *connection) sendHeartbeat() bool { + threshold := c.config.HeartbeatMissThreshold + if threshold <= 0 { + threshold = defaultHeartbeatMissThreshold + } + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return false + } + if len(c.heartbeatPending) >= threshold { + c.mu.Unlock() + c.handleConnectionLoss(fmt.Errorf("heartbeat timeout after %d unanswered ACTIVE_TEST requests", threshold)) + return false + } + if c.client == nil { + c.mu.Unlock() + return false + } + c.sendMu.Lock() + seq, err := c.client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{}) + c.sendMu.Unlock() + if err != nil { + c.mu.Unlock() + c.handleConnectionLoss(fmt.Errorf("send ACTIVE_TEST: %w", err)) + return false + } + if !c.closed { + c.heartbeatPending[seq] = time.Now().UTC() + } + c.mu.Unlock() + return true +} + +func (c *connection) handleHeartbeatResponse(sequenceID uint32) { + c.mu.Lock() + delete(c.heartbeatPending, sequenceID) + c.mu.Unlock() +} + type deliverPacket struct { seqID uint32 msgID uint64 @@ -757,8 +1042,13 @@ func (c *connection) handleConnectionLoss(err error) { return } c.closed = true + if c.heartbeatCancel != nil { + c.heartbeatCancel() + c.heartbeatCancel = nil + } pending := c.pending c.pending = make(map[uint32]chan submitPartResponse) + c.heartbeatPending = make(map[uint32]time.Time) if c.client != nil { c.client.Disconnect() c.client = nil @@ -774,8 +1064,9 @@ func (c *connection) handleConnectionLoss(err error) { if c.pool != nil { status := "disconnected" if c.pool.countActiveConnections() > 0 { - status = "connected" + status = "reconnecting" } + c.pool.scheduleReconnect(err) _ = c.pool.reportState(context.Background(), status, err) } } @@ -864,9 +1155,57 @@ func normalizeUpstreamConfig(config queue.UpstreamConfig) queue.UpstreamConfig { if config.WindowSize <= 0 { config.WindowSize = defaultWindowSize } + if config.HeartbeatIntervalSeconds <= 0 { + config.HeartbeatIntervalSeconds = int(defaultHeartbeatInterval / time.Second) + } + if config.HeartbeatMissThreshold <= 0 { + config.HeartbeatMissThreshold = defaultHeartbeatMissThreshold + } return config } +func connectionErrorCategory(err error) string { + if err == nil { + return "" + } + message := strings.ToLower(err.Error()) + switch { + case strings.Contains(message, "auth"), strings.Contains(message, "password"), strings.Contains(message, "credential"): + return "authentication" + case strings.Contains(message, "heartbeat"): + return "heartbeat_timeout" + case strings.Contains(message, "timeout"): + return "timeout" + default: + return "network" + } +} + +func reconnectDelay(attempt int, category string) time.Duration { + if category == "authentication" { + return defaultAuthReconnectDelay + } + if attempt < 1 { + attempt = 1 + } + delays := []time.Duration{ + defaultReconnectInitialDelay, + 15 * time.Second, + 30 * time.Second, + time.Minute, + 2 * time.Minute, + defaultReconnectMaximumDelay, + } + delay := delays[min(attempt-1, len(delays)-1)] + // Deterministic ±10% jitter prevents a large set of channels from retrying together. + offsetPercent := (attempt*37)%21 - 10 + delay += time.Duration(int64(delay) * int64(offsetPercent) / 100) + if delay < time.Second { + return time.Second + } + return delay +} + func isTemporaryReadTimeout(err error) bool { var netErr net.Error return errors.As(err, &netErr) && netErr.Timeout() diff --git a/gateway/internal/upstream/pool_test.go b/gateway/internal/upstream/pool_test.go index e884d80..77dc33c 100644 --- a/gateway/internal/upstream/pool_test.go +++ b/gateway/internal/upstream/pool_test.go @@ -1,6 +1,7 @@ package upstream import ( + "context" "testing" "cmpp-platform/gateway/internal/queue" @@ -39,6 +40,38 @@ func TestConnectionPoolAcquiresAcrossConnections(t *testing.T) { releaseSecond() } +func TestManagerDisconnectChannelRemovesPoolAndStopsReconnects(t *testing.T) { + pool := &connectionPool{ + channelID: "channel-1", + connectionID: "channel-1:primary", + config: normalizeUpstreamConfig(queueUpstreamConfigForTest()), + reconnectSignal: make(chan struct{}, 1), + stopCh: make(chan struct{}), + } + manager := &Manager{conns: map[string]*connectionPool{"channel-1": pool}} + + state, err := manager.DisconnectChannel(context.Background(), queue.DisconnectChannelCommand{ + MessageType: queue.MessageTypeDisconnectChannel, + ChannelID: "channel-1", + ConnectionID: "channel-1:primary", + }) + + if err != nil { + t.Fatalf("disconnect channel: %v", err) + } + if state.Status != "disconnected" || state.CurrentConnections != 0 { + t.Fatalf("unexpected state: %+v", state) + } + if _, exists := manager.conns["channel-1"]; exists { + t.Fatal("expected channel pool to be removed") + } + select { + case <-pool.stopCh: + default: + t.Fatal("expected reconnect supervisor to be stopped") + } +} + func TestNormalizeUpstreamConfigDefaults(t *testing.T) { config := normalizeUpstreamConfig(queueUpstreamConfigForTest()) if config.DesiredConnections != 1 { @@ -47,6 +80,9 @@ func TestNormalizeUpstreamConfigDefaults(t *testing.T) { if config.WindowSize != defaultWindowSize { t.Fatalf("WindowSize = %d, want %d", config.WindowSize, defaultWindowSize) } + if config.HeartbeatIntervalSeconds != 30 || config.HeartbeatMissThreshold != 3 { + t.Fatalf("unexpected heartbeat defaults: interval=%d threshold=%d", config.HeartbeatIntervalSeconds, config.HeartbeatMissThreshold) + } } func queueUpstreamConfigForTest() queue.UpstreamConfig { diff --git a/gateway/internal/upstream/reconnect_integration_test.go b/gateway/internal/upstream/reconnect_integration_test.go new file mode 100644 index 0000000..31c17cb --- /dev/null +++ b/gateway/internal/upstream/reconnect_integration_test.go @@ -0,0 +1,119 @@ +package upstream + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "cmpp-platform/gateway/internal/queue" + + cmpp "github.com/bigwhite/gocmpp" +) + +func TestFailedSupplierConnectionReconnectsWhenEndpointRecovers(t *testing.T) { + address := reserveSupplierAddress(t) + states := make(chan ConnectionState, 16) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var state ConnectionState + if err := json.NewDecoder(r.Body).Decode(&state); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + states <- state + w.WriteHeader(http.StatusNoContent) + })) + defer api.Close() + + host, portText, err := net.SplitHostPort(address) + if err != nil { + t.Fatalf("split address: %v", err) + } + var port int + if _, err := fmt.Sscanf(portText, "%d", &port); err != nil { + t.Fatalf("parse port: %v", err) + } + manager := &Manager{APIBaseURL: api.URL} + command := queue.ConnectChannelCommand{ + SchemaVersion: queue.SchemaVersion, + MessageType: queue.MessageTypeConnectChannel, + ChannelID: "channel-reconnect", + ConnectionID: "channel-reconnect:primary", + DesiredConnections: 1, + Channel: queue.ConnectChannelConfig{ + GatewayHost: host, + GatewayPort: port, + Account: "sp", + PasswordCipher: "secret", + CMPPVersion: "3.0", + HeartbeatIntervalSeconds: 1, + HeartbeatMissThreshold: 3, + }, + } + initial, err := manager.ConnectChannel(context.Background(), command) + if err != nil { + t.Fatalf("initial connect command: %v", err) + } + if initial.Status != "failed" { + t.Fatalf("initial state = %s, want failed", initial.Status) + } + + go func() { + _ = cmpp.ListenAndServe(address, cmpp.V30, time.Hour, 3, nil, + cmpp.HandlerFunc(func(response *cmpp.Response, packet *cmpp.Packet, _ *log.Logger) (bool, error) { + if _, ok := packet.Packer.(*cmpp.CmppConnReqPkt); ok { + response.Packer.(*cmpp.Cmpp3ConnRspPkt).Version = 0x30 + } + return false, nil + }), + ) + }() + time.Sleep(100 * time.Millisecond) + + manager.mu.Lock() + pool := manager.conns[command.ChannelID] + manager.mu.Unlock() + pool.mu.Lock() + pool.nextReconnectAt = time.Now() + pool.mu.Unlock() + pool.signalReconnect() + + deadline := time.After(4 * time.Second) + connected := false + for { + select { + case state := <-states: + if state.Status == "connected" && state.CurrentConnections == 1 { + connected = true + } + if connected && state.Status == "heartbeat" && state.LastHeartbeatAt != "" { + _, _ = manager.DisconnectChannel(context.Background(), queue.DisconnectChannelCommand{ + MessageType: queue.MessageTypeDisconnectChannel, + ChannelID: command.ChannelID, + ConnectionID: command.ConnectionID, + }) + return + } + case <-deadline: + t.Fatal("timed out waiting for automatic supplier reconnection and active heartbeat") + } + } +} + +func reserveSupplierAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve address: %v", err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close reserved listener: %v", err) + } + return address +} diff --git a/package-lock.json b/package-lock.json index 2e7a294..1cd563d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "lucide-react": "^1.18.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router-dom": "^7.17.0", + "react-router-dom": "^7.18.1", "vite": "^8.0.16", "zustand": "^5.0.14" }, @@ -1168,9 +1168,9 @@ } }, "node_modules/react-router": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", - "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -1190,12 +1190,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", - "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "license": "MIT", "dependencies": { - "react-router": "7.17.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" diff --git a/package.json b/package.json index 3f13ffd..557854c 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "lucide-react": "^1.18.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router-dom": "^7.17.0", + "react-router-dom": "^7.18.1", "vite": "^8.0.16", "zustand": "^5.0.14" }, diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index b024c20..0119dac 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -186,7 +186,7 @@ export type AdminChannel = { rateLimitPerSecond: number; unitPrice: number; status: string; - config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; [key: string]: unknown } | null; + config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; [key: string]: unknown } | null; connectionStates?: CmppConnectionState[]; }; @@ -983,7 +983,9 @@ export type DailyReconciliationReport = { tenantName: string; applicationId: string; applicationName: string; + submittedUnits: number; sentUnits: number; + unknownUnits: number; successUnits: number; failedUnits: number; generatedAt: string; @@ -1000,7 +1002,9 @@ export type DailyProfitReport = { tenantName?: string | null; applicationId?: string | null; channelId?: string | null; + submittedUnits: number; sentUnits: number; + unknownUnits: number; successUnits: number; failedUnits: number; revenueCents: number; @@ -1024,7 +1028,9 @@ export type DailyQualityReport = { channelId?: string | null; signatureId?: string | null; drainageInfoId?: string | null; + submittedUnits: number; sentUnits: number; + unknownUnits: number; successUnits: number; failedUnits: number; successRateBps: number; @@ -1104,6 +1110,9 @@ export type CmppConnectionState = { lastDisconnectedAt?: string | null; lastHeartbeatAt?: string | null; reconnectCount: number; + lastReconnectAttemptAt?: string | null; + nextReconnectAt?: string | null; + lastErrorCategory?: string | null; lastError?: string | null; updatedAt: string; channel?: AdminChannel; @@ -1403,9 +1412,9 @@ export const adminApi = { request & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)), exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => requestBlob(withQuery('/admin/reports/quality/export', query)), - createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) => + createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), - updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) => + updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) => request(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }), copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { method: 'POST', diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index c0262bb..a7e752d 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -25,12 +25,15 @@ type SmsChannel = { failureCount: number; gatewayHost: string; gatewayPort: string; + businessCode: string; corpCode: string; account: string; accessNo: string; cmppVersion: '2.0' | '3.0'; desiredConnections: number; windowSize: number; + heartbeatIntervalSeconds: number; + heartbeatMissThreshold: number; extensionDigits: number; rateLimitPerSecond: number; passwordCipher?: string; @@ -83,12 +86,6 @@ const statusOptions = [ { label: '连接失败', value: 'failed' }, ]; -const protocolOptions = [ - { label: 'CMPP', value: 'CMPP' }, - { label: 'HTTP', value: 'HTTP' }, - { label: 'SGIP', value: 'SGIP' }, -]; - const cmppVersionOptions = [ { label: 'CMPP 2.0', value: '2.0' }, { label: 'CMPP 3.0', value: '3.0' }, @@ -161,12 +158,15 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[] failureCount: 0, gatewayHost: channel.gatewayHost, gatewayPort: String(channel.gatewayPort), + businessCode: String(channel.config?.serviceId ?? 'SMS'), corpCode: channel.enterpriseCode ?? channel.code, account: channel.account, accessNo: channel.srcId, cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0', desiredConnections: Number(channel.config?.desiredConnections ?? 1), windowSize: Number(channel.config?.windowSize ?? 16), + heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30), + heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3), extensionDigits: Number(channel.config?.extensionDigits ?? 0), rateLimitPerSecond: channel.rateLimitPerSecond, }; @@ -183,6 +183,7 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) { sendRegion: channel.sendRegion, gatewayHost: channel.gatewayHost, gatewayPort: Number(channel.gatewayPort), + protocol: 'CMPP', enterpriseCode: channel.corpCode, account: channel.account, passwordCipher: passwordCipher || undefined, @@ -192,7 +193,9 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) { unitPrice: Math.round(channel.unitPrice), desiredConnections: channel.desiredConnections, windowSize: channel.windowSize, - config: { extensionDigits: channel.extensionDigits }, + heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds, + heartbeatMissThreshold: channel.heartbeatMissThreshold, + config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode }, }; } @@ -221,9 +224,9 @@ function ChannelFormModal({ const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300'); const [unitPriceError, setUnitPriceError] = useState(''); const [region, setRegion] = useState(channel?.sendRegion ?? '全国'); - const [protocol, setProtocol] = useState('CMPP'); const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? ''); - const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '17890'); + const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890'); + const [businessCode, setBusinessCode] = useState(channel?.businessCode ?? 'SMS'); const [corpCode, setCorpCode] = useState(channel?.corpCode ?? ''); const [account, setAccount] = useState(channel?.account ?? ''); const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0'); @@ -233,6 +236,8 @@ function ChannelFormModal({ const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100)); const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1)); const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16)); + const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30)); + const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3)); function submit() { if (!isValidMoneyInput(unitPrice)) { @@ -255,12 +260,15 @@ function ChannelFormModal({ failureCount: channel?.failureCount ?? 0, gatewayHost, gatewayPort, + businessCode: businessCode.trim() || 'SMS', corpCode, account, accessNo, cmppVersion, desiredConnections: Number(desiredConnections) || 1, windowSize: Number(windowSize) || 16, + heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30, + heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3, extensionDigits: Number(extensionDigits), rateLimitPerSecond: Number(flowLimit), passwordCipher: password || undefined, @@ -302,11 +310,12 @@ function ChannelFormModal({

参数配置

-
setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} /> setGatewayPort(event.target.value)} value={gatewayPort} />
+ setBusinessCode(event.target.value.toUpperCase())} value={businessCode} /> setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} /> setAccount(event.target.value)} placeholder="请输入网关账号" value={account} /> setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} /> setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} /> setWindowSize(event.target.value)} placeholder="16" value={windowSize} /> + setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} /> + setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
@@ -721,6 +732,10 @@ export function AdminChannelsPage() { 最近心跳 {formatDateTime(connection.lastHeartbeatAt)} +
+ 自动重连 + {connection.reconnectCount} 次 / {formatDateTime(connection.nextReconnectAt)} +
{connection.lastError ?

{connection.lastError}

: null} ))} diff --git a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx index 7268524..2604b58 100644 --- a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx +++ b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx @@ -153,12 +153,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli const [copied, setCopied] = useState(false); const [copyError, setCopyError] = useState(''); const paramsText = formatCmppParams(app, params); - const host = params?.gatewayHost ?? app.cmppParams.host; - const port = params?.gatewayPort ?? app.cmppParams.port; - const password = params?.passwordCipher ?? app.cmppParams.password; - const srcId = params?.srcId ?? app.cmppParams.accessNumber; - const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled; - const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType; async function copyParams() { try { @@ -185,19 +179,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli title={

CMPP连接参数

{app.enterprise} / {app.name}

} >
-
-
短信接口{interfaceEnabled ? '开通' : '关闭'}
-
接口类型{interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}
-
CMPP网关地址{host}
-
CMPP网关端口{port}
-
企业代码{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}
-
接口账号{params?.account ?? app.cmppParams.account}
-
接口密码{password}
-
接入号{srcId}
-
最大连接数{params?.maxConnections ?? app.cmppParams.maxConnections}
-
心跳间隔{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒
-
协议版本{params?.protocolVersion ?? app.cmppParams.protocolVersion}
-
{paramsText}
{copyError ?

{copyError}

: null}
@@ -391,21 +372,21 @@ export function AdminEnterpriseApplicationsPage() { { key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)} 元` }, { key: 'cmppStatus', - title: 'CMPP状态', - width: '230px', + title: '客户连接状态', + width: '250px', render: (record) => (
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'} - - - +
), }, @@ -416,7 +397,7 @@ export function AdminEnterpriseApplicationsPage() { align: 'right', width: '190px', render: (record) => ( -
+
@@ -183,7 +182,6 @@ export function AdminSmsAuditPage() { {detailTarget ? setDetailTarget(null)}>关闭} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
-
任务编号{detailTarget.taskNo}
提交时间{formatDateTime(detailTarget.createdAt)}
审核人{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}
审核时间{formatDateTime(detailTarget.reviewedAt)}
diff --git a/src/apps/admin/AdminSmsRecordsPage.tsx b/src/apps/admin/AdminSmsRecordsPage.tsx index 877ea7c..f1c62bb 100644 --- a/src/apps/admin/AdminSmsRecordsPage.tsx +++ b/src/apps/admin/AdminSmsRecordsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react'; import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi'; -import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui'; import { formatCents } from '@/utils/currency'; const statusLabelMap: Record = { @@ -166,13 +166,11 @@ function downloadCsv(records: SmsMessageRecord[]) { function SendDetailModal({ record, segmentAudits, - segmentColumns, segmentLoading, onClose, }: { record: SmsMessageRecord; segmentAudits: SmsMessageSegmentAudit[]; - segmentColumns: Array>; segmentLoading: boolean; onClose: () => void; }) { @@ -204,6 +202,10 @@ function SendDetailModal({ 提交时间 {getTime(record.queuedAt)}
+
+ 发送号码 + {record.phoneNumber || '-'} +
号码归属 {record.province ?? '-'} / {getCarrierLabel(record.carrier)} @@ -272,13 +274,31 @@ function SendDetailModal({

分片补偿审计

- + {segmentLoading ?
加载中...
: segmentAudits.length === 0 ? ( +
暂无分片审计
+ ) : ( +
+ {segmentAudits.map((segment) => ( +
+
+ 分片 {segment.segmentIndex}/{segment.segmentTotal} +
+ {segment.submitStatus} + {segment.receiptStatus ? {segment.receiptStatus} : null} +
+
+
+
通道
{segment.channel?.name ?? segment.channelId ?? '-'}
+
Sequence
{segment.sequenceId ?? '-'}
+
提交 ID
{segment.submitId}
+
网关 MsgId
{segment.gatewayMessageId ?? '-'}
+
补偿方式
{segment.compensationType ?? '-'}
+
错误信息
{segment.errorMessage ?? segment.errorCode ?? '-'}
+
+
+ ))} +
+ )} @@ -378,18 +398,6 @@ export function AdminSmsRecordsPage() { const currentPage = Math.min(page, totalPages); const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); - const segmentColumns: Array> = [ - { key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` }, - { key: 'submitId', title: '提交ID', width: '190px', render: (record) => {record.submitId} }, - { key: 'channel', title: '通道', width: '150px', render: (record) => record.channel?.name ?? record.channelId ?? '-' }, - { key: 'sequenceId', title: 'Sequence', width: '110px', render: (record) => record.sequenceId ?? '-' }, - { key: 'gatewayMessageId', title: 'MsgId', width: '180px', render: (record) => record.gatewayMessageId ?? '-' }, - { key: 'submitStatus', title: '提交状态', width: '110px', render: (record) => {record.submitStatus} }, - { key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? {record.receiptStatus} : '-' }, - { key: 'compensation', title: '补偿', width: '120px', render: (record) => record.compensationType ?? '-' }, - { key: 'error', title: '错误', render: (record) => record.errorMessage ?? record.errorCode ?? '-' }, - ]; - function resetFilters() { setEnterprise('all'); setApplication('all'); @@ -485,7 +493,6 @@ export function AdminSmsRecordsPage() { onClose={() => setSelectedRecord(null)} record={selectedRecord} segmentAudits={segmentAudits} - segmentColumns={segmentColumns} segmentLoading={segmentLoading} /> ) : null} diff --git a/src/apps/admin/AdminTemplateAuditPage.tsx b/src/apps/admin/AdminTemplateAuditPage.tsx index 6f6a5cb..13d6c24 100644 --- a/src/apps/admin/AdminTemplateAuditPage.tsx +++ b/src/apps/admin/AdminTemplateAuditPage.tsx @@ -36,7 +36,6 @@ export function AdminTemplateAuditPage() { const columns = useMemo>>( () => [ - { key: 'id', title: '审核编号', render: (record) => record.id }, { key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId }, { key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId }, { key: 'content', title: '短信模板内容', render: (record) => record.content }, @@ -83,7 +82,7 @@ export function AdminTemplateAuditPage() {
- setKeyword(event.target.value)} placeholder="搜索客户、应用、模板内容或审核编号" value={keyword} /> + setKeyword(event.target.value)} placeholder="搜索客户、应用或模板内容" value={keyword} />