feat: improve channel resilience and operations
This commit is contained in:
Generated
+3
-3
@@ -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": {
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@
|
||||
"overrides": {
|
||||
"exceljs": {
|
||||
"uuid": "11.1.1"
|
||||
}
|
||||
},
|
||||
"find-my-way": "9.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SmsApplication"
|
||||
ALTER COLUMN "maxPhonesPerTask" SET DEFAULT 10000;
|
||||
+23
@@ -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);
|
||||
+21
@@ -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";
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -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<typeof setInterval>;
|
||||
private gatewayStartupReconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
private gatewayReconcileTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
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<void>) {
|
||||
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<string, unknown>) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
|
||||
let response: { ok: boolean; status: number; text: () => Promise<string> };
|
||||
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<string, unknown> | null,
|
||||
desiredConnections?: number,
|
||||
windowSize?: number,
|
||||
heartbeatIntervalSeconds?: number,
|
||||
heartbeatMissThreshold?: number,
|
||||
) {
|
||||
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||
? existingConfig as Record<string, unknown>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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' }] },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)计入提交但不计入发送,并保证 `发送=未知+成功+失败`。
|
||||
- 通道维度只存在已经路由到通道的记录,因此该维度的提交数等于发送数;应用、签名、引流信息和对账维度的提交数包含平台拦截。
|
||||
- 二级添加、编辑页面必须继续高亮其所属侧边菜单。
|
||||
|
||||
@@ -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、迁移、服务和健康检查通过。
|
||||
@@ -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` 和选中样式。
|
||||
|
||||
@@ -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 结果待部署后补记。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+8
-8
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
+12
-3
@@ -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<PagedResponse<DailyQualityReport> & { 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<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
|
||||
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) =>
|
||||
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
|
||||
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) =>
|
||||
request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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({
|
||||
<section>
|
||||
<h3>参数配置</h3>
|
||||
<div className="sms-channel-form-grid">
|
||||
<Select label="* 协议选择" onChange={(event) => setProtocol(event.target.value)} options={protocolOptions} value={protocol} />
|
||||
<Input disabled label="* 协议选择" value="CMPP" />
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
|
||||
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
|
||||
</div>
|
||||
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} />
|
||||
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
||||
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
||||
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
|
||||
@@ -326,6 +335,8 @@ function ChannelFormModal({
|
||||
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
||||
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
|
||||
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -721,6 +732,10 @@ export function AdminChannelsPage() {
|
||||
<span>最近心跳</span>
|
||||
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动重连</span>
|
||||
<strong>{connection.reconnectCount} 次 / {formatDateTime(connection.nextReconnectAt)}</strong>
|
||||
</div>
|
||||
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -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={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-param-detail">
|
||||
<div className="cmpp-param-grid">
|
||||
<div><span>短信接口</span><strong>{interfaceEnabled ? '开通' : '关闭'}</strong></div>
|
||||
<div><span>接口类型</span><strong>{interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}</strong></div>
|
||||
<div><span>CMPP网关地址</span><strong>{host}</strong></div>
|
||||
<div><span>CMPP网关端口</span><strong>{port}</strong></div>
|
||||
<div><span>企业代码</span><strong>{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}</strong></div>
|
||||
<div><span>接口账号</span><strong>{params?.account ?? app.cmppParams.account}</strong></div>
|
||||
<div><span>接口密码</span><strong>{password}</strong></div>
|
||||
<div><span>接入号</span><strong>{srcId}</strong></div>
|
||||
<div><span>最大连接数</span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
|
||||
<div><span>心跳间隔</span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒</strong></div>
|
||||
<div><span>协议版本</span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
|
||||
</div>
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</div>
|
||||
@@ -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) => (
|
||||
<div className="cmpp-status-cell">
|
||||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||
</Tag>
|
||||
<button onClick={() => setConnectionApp(record)} type="button">
|
||||
<button disabled={!record.cmppParams.interfaceEnabled} onClick={() => setConnectionApp(record)} type="button">
|
||||
{record.cmppConnections.filter((item) => item.state === 'open').length}
|
||||
</button>
|
||||
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
|
||||
<button className={`cmpp-status-cell__params ${record.cmppParams.interfaceEnabled ? 'is-enabled' : 'is-disabled'}`} disabled={!record.cmppParams.interfaceEnabled} onClick={() => { void openParams(record); }} type="button">
|
||||
<Settings2 size={13} />
|
||||
CMPP参数
|
||||
</button>
|
||||
<button className="cmpp-status-cell__params" disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
|
||||
<button className={`cmpp-status-cell__params ${record.httpEnabled ? 'is-enabled' : 'is-disabled'}`} disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -416,7 +397,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
align: 'right',
|
||||
width: '190px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<div className="table-actions enterprise-app-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
|
||||
{record.enabled ? '停用' : '启用'}
|
||||
|
||||
@@ -104,7 +104,6 @@ export function AdminEnterpriseAuditPage() {
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<EnterpriseAuditRecord>> = [
|
||||
{ key: 'id', title: '申请单号', render: (record) => <span className="muted">{record.id}</span> },
|
||||
{ key: 'companyName', title: '企业名称', render: (record) => <strong>{record.companyName}</strong> },
|
||||
{ key: 'creditCode', title: '统一社会信用代码', render: (record) => record.creditCode },
|
||||
{ key: 'contactName', title: '联系人', render: (record) => record.contactName },
|
||||
@@ -173,7 +172,7 @@ export function AdminEnterpriseAuditPage() {
|
||||
onClose={() => setDetailRecord(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>企业认证详情</h2><p>{detailRecord.id}</p></div>}
|
||||
title={<div className="template-modal-title"><h2>企业认证详情</h2><p>{detailRecord.companyName}</p></div>}
|
||||
>
|
||||
<div className="enterprise-audit-detail">
|
||||
<section>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '生效中', value: 'active' }, { label: '已删除', value: 'deleted' }]}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '生效中', value: 'active' }]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
|
||||
@@ -74,12 +74,12 @@ export function AdminProfitReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th>发送</th><th>成功</th><th>失败</th><th>净消费</th><th>返还</th><th>成本</th><th>利润</th><th>利润率</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th>提交</th><th>发送</th><th>未知</th><th>成功</th><th>失败</th><th>净消费</th><th>返还</th><th>成本</th><th>利润</th><th>利润率</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={11}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={11}>正在加载真实利润数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={11}>暂无已生成的利润报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={13}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={13}>正在加载真实利润数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={13}>暂无已生成的利润报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -81,12 +81,12 @@ export function AdminQualityReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>{dimensionLabels[dimension]}</th><th>发送条数</th><th>成功条数</th><th>失败条数</th><th>成功率</th><th>平均到达时长</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>{dimensionLabels[dimension]}</th><th>提交条数</th><th>发送条数</th><th>未知条数</th><th>成功条数</th><th>失败条数</th><th>成功率</th><th>平均到达时长</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={8}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={8}>正在加载真实发送质量数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={8}>暂无已生成的发送质量报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={10}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={10}>正在加载真实发送质量数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={10}>暂无已生成的发送质量报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -80,12 +80,12 @@ export function AdminReconciliationReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>企业</th><th>企业应用</th><th>发送条数</th><th>成功条数</th><th>失败条数</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>企业</th><th>企业应用</th><th>提交条数</th><th>发送条数</th><th>未知条数</th><th>成功条数</th><th>失败条数</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={7}>正在加载真实对账数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}>暂无已生成的对账单</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={9}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={9}>正在加载真实对账数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={9}>暂无已生成的对账单</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
|
||||
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
|
||||
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10000');
|
||||
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
||||
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
|
||||
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
|
||||
@@ -73,6 +73,29 @@ export function AdminSmsApplicationFormPage() {
|
||||
let cancelled = false;
|
||||
async function loadForm() {
|
||||
try {
|
||||
if (!isEdit) {
|
||||
setAppName('');
|
||||
setScene('行业通知');
|
||||
setDailyLimit('100000');
|
||||
setCustomerUnitPrice('0.0300');
|
||||
setQueuePriority('normal');
|
||||
setCmppAccount('');
|
||||
setApplicationExtension('');
|
||||
setAccessNumberFillEnabled(false);
|
||||
setAccessNumberFillPrefix('');
|
||||
setPasswordCipher(generateApplicationPassword());
|
||||
setInterfaceEnabled(true);
|
||||
setInterfaceType('cmpp20');
|
||||
setCmppMaxConnections('1');
|
||||
setPhoneDailyLimit('10000');
|
||||
setMismatchPolicy('manual_review');
|
||||
setDownstreamReceiptRetryEnabled(true);
|
||||
setDownstreamUplinkRetryEnabled(true);
|
||||
setIpAddress('');
|
||||
setMobileGroupId('');
|
||||
setUnicomGroupId('');
|
||||
setTelecomGroupId('');
|
||||
}
|
||||
const [groupItems, application, routeRules] = await Promise.all([
|
||||
adminApi.listChannelGroups(),
|
||||
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
|
||||
@@ -278,7 +301,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
<span>优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
||||
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10000" required value={phoneDailyLimit} />
|
||||
<Select
|
||||
label="不符合模板的短信"
|
||||
onChange={(event) => setMismatchPolicy(event.target.value)}
|
||||
|
||||
@@ -102,7 +102,6 @@ export function AdminSmsAuditPage() {
|
||||
width: '54px',
|
||||
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
|
||||
},
|
||||
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
|
||||
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
|
||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') },
|
||||
@@ -149,7 +148,7 @@ export function AdminSmsAuditPage() {
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号、内容或审核原因" value={keyword} />
|
||||
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容或审核原因" value={keyword} />
|
||||
<Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />} onClick={loadData}>查询</Button>
|
||||
@@ -183,7 +182,6 @@ export function AdminSmsAuditPage() {
|
||||
|
||||
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}>关闭</Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
|
||||
<div className="detail-grid">
|
||||
<div><span>任务编号</span><strong>{detailTarget.taskNo}</strong></div>
|
||||
<div><span>提交时间</span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
|
||||
<div><span>审核人</span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
|
||||
<div><span>审核时间</span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -166,13 +166,11 @@ function downloadCsv(records: SmsMessageRecord[]) {
|
||||
function SendDetailModal({
|
||||
record,
|
||||
segmentAudits,
|
||||
segmentColumns,
|
||||
segmentLoading,
|
||||
onClose,
|
||||
}: {
|
||||
record: SmsMessageRecord;
|
||||
segmentAudits: SmsMessageSegmentAudit[];
|
||||
segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>>;
|
||||
segmentLoading: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -204,6 +202,10 @@ function SendDetailModal({
|
||||
<span>提交时间</span>
|
||||
<strong>{getTime(record.queuedAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送号码</span>
|
||||
<strong>{record.phoneNumber || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>号码归属</span>
|
||||
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
|
||||
@@ -272,13 +274,31 @@ function SendDetailModal({
|
||||
|
||||
<section>
|
||||
<h3>分片补偿审计</h3>
|
||||
<Table
|
||||
columns={segmentColumns}
|
||||
data={segmentAudits}
|
||||
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
/>
|
||||
{segmentLoading ? <div className="ui-table__empty">加载中...</div> : segmentAudits.length === 0 ? (
|
||||
<div className="ui-table__empty">暂无分片审计</div>
|
||||
) : (
|
||||
<div className="admin-sms-segment-list">
|
||||
{segmentAudits.map((segment) => (
|
||||
<article className="admin-sms-segment-card" key={segment.id}>
|
||||
<header>
|
||||
<strong>分片 {segment.segmentIndex}/{segment.segmentTotal}</strong>
|
||||
<div>
|
||||
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
|
||||
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
|
||||
</div>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>通道</dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
|
||||
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
|
||||
<div><dt>提交 ID</dt><dd>{segment.submitId}</dd></div>
|
||||
<div><dt>网关 MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
|
||||
<div><dt>补偿方式</dt><dd>{segment.compensationType ?? '-'}</dd></div>
|
||||
<div><dt>错误信息</dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -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<TableColumn<SmsMessageSegmentAudit>> = [
|
||||
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
|
||||
{ key: 'submitId', title: '提交ID', width: '190px', render: (record) => <strong className="admin-task-id">{record.submitId}</strong> },
|
||||
{ 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) => <Tag tone={record.submitStatus === 'accepted' ? 'success' : record.submitStatus === 'queued' ? 'info' : 'danger'}>{record.submitStatus}</Tag> },
|
||||
{ key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? <Tag tone={record.receiptStatus === 'delivered' ? 'success' : record.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{record.receiptStatus}</Tag> : '-' },
|
||||
{ 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}
|
||||
|
||||
@@ -36,7 +36,6 @@ export function AdminTemplateAuditPage() {
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SmsTemplateAudit>>>(
|
||||
() => [
|
||||
{ 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() {
|
||||
</div>
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--template">
|
||||
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用、模板内容或审核编号" value={keyword} />
|
||||
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用或模板内容" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PanelLeftOpen,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { portalSessionApi } from '@/api/adminApi';
|
||||
import {
|
||||
clearSession,
|
||||
@@ -337,13 +337,20 @@ export function AppShell({
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<NavLink key={item.to} onClick={() => setMobileNavOpen(false)} to={item.to} end title={item.pending ? `${item.label}(待开发)` : item.label}>
|
||||
<Link
|
||||
aria-current={isShellNavItemActive(location.pathname, item.to) ? 'page' : undefined}
|
||||
className={isShellNavItemActive(location.pathname, item.to) ? 'active' : undefined}
|
||||
key={item.to}
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
to={item.to}
|
||||
title={item.pending ? `${item.label}(待开发)` : item.label}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2.1} />
|
||||
<span className="side-nav-label">
|
||||
<span className="side-nav-label-text">{item.label}</span>
|
||||
{item.pending ? <span className="dev-status-badge side-nav-pending-badge">待开发</span> : null}
|
||||
</span>
|
||||
</NavLink>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -498,6 +505,15 @@ export function AppShell({
|
||||
);
|
||||
}
|
||||
|
||||
function isShellNavItemActive(pathname: string, itemPath: string) {
|
||||
if (pathname === itemPath) return true;
|
||||
if (itemPath === '/admin' || itemPath === '/client') return false;
|
||||
if (pathname.startsWith(`${itemPath}/`)) return true;
|
||||
if (itemPath === '/admin/enterprise-applications' && /^\/admin\/customers\/[^/]+\/sms-apps\//.test(pathname)) return true;
|
||||
if (itemPath === '/admin/customer-enterprises' && /^\/admin\/customers\/[^/]+\/edit$/.test(pathname)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatCountdown(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
|
||||
+92
-13
@@ -3167,11 +3167,32 @@ h3 {
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.cmpp-status-cell button:disabled,
|
||||
.cmpp-status-cell button.is-disabled {
|
||||
background: var(--color-surface-muted);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.cmpp-status-cell button.is-enabled {
|
||||
background: var(--color-success-soft);
|
||||
border-color: color-mix(in srgb, var(--color-success) 30%, var(--color-border));
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.cmpp-status-cell__params {
|
||||
gap: 4px;
|
||||
min-width: 58px !important;
|
||||
}
|
||||
|
||||
.enterprise-app-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.cmpp-connection-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
@@ -8061,9 +8082,9 @@ h3 {
|
||||
|
||||
.sms-test-title span {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
background: var(--color-selected-soft);
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
color: var(--color-selected);
|
||||
display: inline-flex;
|
||||
height: 58px;
|
||||
justify-content: center;
|
||||
@@ -8072,7 +8093,7 @@ h3 {
|
||||
|
||||
.sms-test-title h2,
|
||||
.sms-test-title p {
|
||||
color: white;
|
||||
color: var(--color-text-strong);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -8088,12 +8109,12 @@ h3 {
|
||||
}
|
||||
|
||||
.ui-modal__header:has(.sms-test-title) {
|
||||
background: var(--color-selected);
|
||||
border-bottom: 0;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-modal__header:has(.sms-test-title) .ui-button {
|
||||
color: white;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sms-test-modal {
|
||||
@@ -9007,8 +9028,8 @@ h3 {
|
||||
|
||||
.admin-sms-record-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-sms-record-card {
|
||||
@@ -9016,8 +9037,8 @@ h3 {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-record-card:hover {
|
||||
@@ -9041,7 +9062,12 @@ h3 {
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
max-width: none;
|
||||
padding: var(--space-4);
|
||||
display: -webkit-box;
|
||||
line-height: 1.55;
|
||||
overflow: hidden;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.admin-sms-record-card__meta {
|
||||
@@ -9069,7 +9095,7 @@ h3 {
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-sms-record-table {
|
||||
@@ -9258,12 +9284,65 @@ h3 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-sms-segment-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-sms-segment-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card header > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dl {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dl > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dd {
|
||||
color: var(--color-text-strong);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-sms-record-card > header,
|
||||
.admin-sms-record-card__meta,
|
||||
.admin-sms-detail-overview,
|
||||
.admin-sms-detail-status-grid,
|
||||
.admin-sms-route-list dl {
|
||||
.admin-sms-route-list dl,
|
||||
.admin-sms-segment-list,
|
||||
.admin-sms-segment-card dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user