feat: improve channel resilience and operations

This commit is contained in:
hectorzhao
2026-07-24 08:17:33 +08:00
parent 2f781ebb8a
commit afd3c96070
43 changed files with 1969 additions and 299 deletions
+3 -3
View File
@@ -4870,9 +4870,9 @@
} }
}, },
"node_modules/find-my-way": { "node_modules/find-my-way": {
"version": "9.6.0", "version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+2 -1
View File
@@ -43,6 +43,7 @@
"overrides": { "overrides": {
"exceljs": { "exceljs": {
"uuid": "11.1.1" "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";
@@ -0,0 +1,2 @@
ALTER TABLE "SmsApplication"
ALTER COLUMN "maxPhonesPerTask" SET DEFAULT 10000;
@@ -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);
@@ -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";
+11 -1
View File
@@ -370,7 +370,7 @@ model SmsApplication {
dailyLimit Int @default(100000) dailyLimit Int @default(100000)
customerUnitPrice BigInt @default(0) customerUnitPrice BigInt @default(0)
queuePriority String @default("normal") queuePriority String @default("normal")
maxPhonesPerTask Int @default(1000000) maxPhonesPerTask Int @default(10000)
templateMismatchMode String @default("reject") templateMismatchMode String @default("reject")
downstreamReceiptRetryEnabled Boolean @default(true) downstreamReceiptRetryEnabled Boolean @default(true)
downstreamUplinkRetryEnabled Boolean @default(true) downstreamUplinkRetryEnabled Boolean @default(true)
@@ -785,6 +785,9 @@ model CmppConnectionState {
lastDisconnectedAt DateTime? lastDisconnectedAt DateTime?
lastHeartbeatAt DateTime? lastHeartbeatAt DateTime?
reconnectCount Int @default(0) reconnectCount Int @default(0)
lastReconnectAttemptAt DateTime?
nextReconnectAt DateTime?
lastErrorCategory String?
lastError String? lastError String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -797,6 +800,7 @@ model CmppConnectionState {
@@index([tenantId, status]) @@index([tenantId, status])
@@index([applicationId, status]) @@index([applicationId, status])
@@index([channelId, status]) @@index([channelId, status])
@@index([status, nextReconnectAt])
} }
model CmppDownstreamConnection { model CmppDownstreamConnection {
@@ -1436,7 +1440,9 @@ model DailyReconciliationReport {
tenantName String tenantName String
applicationId String applicationId String
applicationName String applicationName String
submittedUnits Int @default(0)
sentUnits Int @default(0) sentUnits Int @default(0)
unknownUnits Int @default(0)
successUnits Int @default(0) successUnits Int @default(0)
failedUnits Int @default(0) failedUnits Int @default(0)
generatedAt DateTime @default(now()) generatedAt DateTime @default(now())
@@ -1458,7 +1464,9 @@ model DailyProfitReport {
tenantName String? tenantName String?
applicationId String? applicationId String?
channelId String? channelId String?
submittedUnits Int @default(0)
sentUnits Int @default(0) sentUnits Int @default(0)
unknownUnits Int @default(0)
successUnits Int @default(0) successUnits Int @default(0)
failedUnits Int @default(0) failedUnits Int @default(0)
revenueCents BigInt @default(0) revenueCents BigInt @default(0)
@@ -1488,7 +1496,9 @@ model DailyQualityReport {
channelId String? channelId String?
signatureId String? signatureId String?
drainageInfoId String? drainageInfoId String?
submittedUnits Int @default(0)
sentUnits Int @default(0) sentUnits Int @default(0)
unknownUnits Int @default(0)
successUnits Int @default(0) successUnits Int @default(0)
failedUnits Int @default(0) failedUnits Int @default(0)
successRateBps Int @default(0) successRateBps Int @default(0)
+234 -6
View File
@@ -1,6 +1,7 @@
import { ChannelsService } from './channels.service'; 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 mockQueueClose = jest.fn().mockResolvedValue(undefined);
const mockFetch = jest.fn().mockResolvedValue({ const mockFetch = jest.fn().mockResolvedValue({
ok: true, ok: true,
@@ -8,6 +9,8 @@ const mockFetch = jest.fn().mockResolvedValue({
text: jest.fn().mockResolvedValue(''), text: jest.fn().mockResolvedValue(''),
}); });
const mockRedisXadd = jest.fn().mockResolvedValue('1710000000000-0'); const mockRedisXadd = jest.fn().mockResolvedValue('1710000000000-0');
const mockRedisSet = jest.fn().mockResolvedValue('OK');
const mockRedisEval = jest.fn().mockResolvedValue(1);
const mockRedisDisconnect = jest.fn(); const mockRedisDisconnect = jest.fn();
jest.mock('bullmq', () => ({ jest.mock('bullmq', () => ({
@@ -19,6 +22,8 @@ jest.mock('bullmq', () => ({
jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({ jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
xadd: mockRedisXadd, xadd: mockRedisXadd,
set: mockRedisSet,
eval: mockRedisEval,
disconnect: mockRedisDisconnect, disconnect: mockRedisDisconnect,
}))); })));
@@ -70,7 +75,7 @@ function createPrismaMock() {
findMany: jest.fn(), findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
findUnique: jest.fn().mockResolvedValue(channel), 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() }, channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: { smsChannelGroup: {
@@ -272,8 +277,11 @@ describe('ChannelsService', () => {
beforeEach(() => { beforeEach(() => {
mockQueueAdd.mockClear(); mockQueueAdd.mockClear();
mockQueueClose.mockClear(); mockQueueClose.mockClear();
mockJobRemove.mockClear();
mockFetch.mockClear(); mockFetch.mockClear();
mockRedisXadd.mockClear(); mockRedisXadd.mockClear();
mockRedisSet.mockClear();
mockRedisEval.mockClear();
mockRedisDisconnect.mockClear(); mockRedisDisconnect.mockClear();
global.fetch = mockFetch as never; global.fetch = mockFetch as never;
}); });
@@ -300,12 +308,60 @@ describe('ChannelsService', () => {
channelId: 'channel-1', channelId: 'channel-1',
reason: 'gateway_restarted', reason: 'gateway_restarted',
channel: expect.objectContaining({ rateLimitPerSecond: 100 }), 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({ expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
body: expect.stringContaining('"reason":"gateway_restarted"'), 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 () => { it('creates CMPP channels and route rules with first-version defaults', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never); const service = new ChannelsService(prisma as never);
@@ -333,7 +389,7 @@ describe('ChannelsService', () => {
rateLimitPerSecond: 750, rateLimitPerSecond: 750,
sendRegion: '全国', sendRegion: '全国',
status: 'active', 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({ expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
@@ -351,7 +407,11 @@ describe('ChannelsService', () => {
connectionId: 'channel-1:primary', connectionId: 'channel-1:primary',
reason: 'channel_created', reason: 'channel_created',
channel: expect.objectContaining({ cmppVersion: '2.0' }), 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({ expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST', method: 'POST',
body: expect.stringContaining('"messageType":"ConnectChannel"'), 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 () => { it('preserves explicit CMPP 3.0 and rejects unsupported CMPP versions', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never); 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, 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: { 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 () => { it('updates CMPP channel configuration without requiring password changes', async () => {
@@ -730,11 +814,155 @@ describe('ChannelsService', () => {
messageType: 'ConnectChannel', messageType: 'ConnectChannel',
channelId: 'channel-1', channelId: 'channel-1',
reason: 'channel_enabled', 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({ expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST', method: 'POST',
body: expect.stringContaining('"reason":"channel_enabled"'), 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 () => { it('copies channels with report field configuration and report materials', async () => {
+367 -43
View File
@@ -13,7 +13,7 @@ export interface CreateChannelDto {
sendRegion?: string; sendRegion?: string;
protocol?: string; protocol?: string;
gatewayHost: string; gatewayHost: string;
gatewayPort: number; gatewayPort?: number;
enterpriseCode?: string; enterpriseCode?: string;
account: string; account: string;
passwordCipher: string; passwordCipher: string;
@@ -24,6 +24,8 @@ export interface CreateChannelDto {
status?: string; status?: string;
desiredConnections?: number; desiredConnections?: number;
windowSize?: number; windowSize?: number;
heartbeatIntervalSeconds?: number;
heartbeatMissThreshold?: number;
config?: Record<string, unknown>; config?: Record<string, unknown>;
} }
@@ -151,6 +153,9 @@ export interface UpsertConnectionStateDto {
lastDisconnectedAt?: string; lastDisconnectedAt?: string;
lastHeartbeatAt?: string; lastHeartbeatAt?: string;
reconnectCount?: number; reconnectCount?: number;
lastReconnectAttemptAt?: string;
nextReconnectAt?: string;
lastErrorCategory?: string;
lastError?: string; lastError?: string;
} }
@@ -182,6 +187,11 @@ const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000; const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000; const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_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 CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
const DEFAULT_CMPP_VERSION = '2.0'; const DEFAULT_CMPP_VERSION = '2.0';
@@ -193,6 +203,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
private redis?: IORedis; private redis?: IORedis;
private connectionTimeoutTimer?: ReturnType<typeof setInterval>; private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
private gatewayStartupReconnectTimer?: ReturnType<typeof setTimeout>; private gatewayStartupReconnectTimer?: ReturnType<typeof setTimeout>;
private gatewayReconcileTimer?: ReturnType<typeof setInterval>;
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -209,6 +220,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
void this.reconnectActiveChannelsAfterGatewayRestart(); void this.reconnectActiveChannelsAfterGatewayRestart();
}, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS)); }, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS));
this.gatewayStartupReconnectTimer.unref?.(); 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() { async onModuleDestroy() {
@@ -218,6 +237,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
if (this.gatewayStartupReconnectTimer) { if (this.gatewayStartupReconnectTimer) {
clearTimeout(this.gatewayStartupReconnectTimer); clearTimeout(this.gatewayStartupReconnectTimer);
} }
if (this.gatewayReconcileTimer) {
clearInterval(this.gatewayReconcileTimer);
}
await this.gatewayConnectionQueue?.close(); await this.gatewayConnectionQueue?.close();
await this.gatewaySubmitQueue?.close(); await this.gatewaySubmitQueue?.close();
this.redis?.disconnect(); this.redis?.disconnect();
@@ -232,19 +254,26 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async createChannel(data: CreateChannelDto) { async createChannel(data: CreateChannelDto) {
assertMoneyUnits(data.unitPrice ?? 0, '通道单价'); 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]; const value = data[field as keyof CreateChannelDto];
return value === undefined || value === null || value === ''; return value === undefined || value === null || value === '';
}); });
if (missingFields.length > 0) { if (missingFields.length > 0) {
throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`); 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) { if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
} }
const cmppVersion = normalizeCmppVersion(data.cmppVersion); 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 rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
const channel = await this.prisma.smsChannel.create({ const channel = await this.prisma.smsChannel.create({
data: { data: {
@@ -252,7 +281,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
name: data.name, name: data.name,
carrier: data.carrier, carrier: data.carrier,
sendRegion: data.sendRegion ?? '全国', sendRegion: data.sendRegion ?? '全国',
protocol: data.protocol ?? 'CMPP', protocol: 'CMPP',
gatewayHost: data.gatewayHost, gatewayHost: data.gatewayHost,
gatewayPort, gatewayPort,
enterpriseCode: data.enterpriseCode, 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'); throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
} }
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined const config = data.config !== undefined
? normalizeChannelRuntimeConfig(channel.config, data.config, data.desiredConnections, data.windowSize) || 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; : undefined;
const rateLimitPerSecond = data.rateLimitPerSecond === undefined const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined ? undefined
@@ -298,7 +338,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
name: data.name, name: data.name,
carrier: data.carrier, carrier: data.carrier,
sendRegion: data.sendRegion, sendRegion: data.sendRegion,
protocol: data.protocol, protocol: 'CMPP',
gatewayHost: data.gatewayHost, gatewayHost: data.gatewayHost,
gatewayPort, gatewayPort,
enterpriseCode: data.enterpriseCode, enterpriseCode: data.enterpriseCode,
@@ -334,6 +374,26 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
} as Prisma.InputJsonValue, } 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; return updated;
} }
@@ -358,6 +418,12 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}); });
if (data.status === 'active') { if (data.status === 'active') {
await this.requestChannelConnection(updated, 'channel_enabled', data.operatorId); 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; return updated;
} }
@@ -615,7 +681,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
} }
async upsertConnectionState(data: UpsertConnectionStateDto) { async upsertConnectionState(data: UpsertConnectionStateDto) {
const status = normalizeGatewayConnectionStatus(data.status); const rawStatus = data.status;
const status = normalizeGatewayConnectionStatus(rawStatus);
if (data.applicationId) { if (data.applicationId) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
if (!application) { if (!application) {
@@ -636,6 +703,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined, lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined, lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
reconnectCount: data.reconnectCount ?? 0, 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, lastError: status === 'connected' ? null : data.lastError,
}; };
const existing = await this.prisma.cmppConnectionState.findFirst({ const existing = await this.prisma.cmppConnectionState.findFirst({
@@ -645,30 +715,59 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
connectionId: data.connectionId, connectionId: data.connectionId,
}, },
}); });
const state = existing let state;
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload }) if (existing) {
: await this.prisma.cmppConnectionState.create({ 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: { data: {
channelId: data.channelId, tenantId: data.tenantId,
connectionId: data.connectionId, action: `cmpp_connection.${action}`,
...payload, 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; return state;
} }
@@ -705,6 +804,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
status: 'failed', status: 'failed',
currentConnections: 0, currentConnections: 0,
lastDisconnectedAt: now, lastDisconnectedAt: now,
nextReconnectAt: now,
lastErrorCategory: 'timeout',
lastError, lastError,
}, },
}); });
@@ -1245,7 +1346,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
rateLimitPerSecond: number; rateLimitPerSecond: number;
config?: Prisma.JsonValue | null; config?: Prisma.JsonValue | null;
}, },
reason: 'channel_created' | 'channel_enabled' | 'gateway_restarted', reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect',
operatorId?: string, operatorId?: string,
) { ) {
const desiredConnections = getDesiredConnections(channel.config); const desiredConnections = getDesiredConnections(channel.config);
@@ -1263,16 +1364,34 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
desiredConnections, desiredConnections,
currentConnections: 0, currentConnections: 0,
lastError: null, lastError: null,
lastReconnectAttemptAt: new Date(),
nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)),
}; };
const state = existing let state;
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data }) if (existing) {
: await this.prisma.cmppConnectionState.create({ state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data });
data: { } else {
channelId: channel.id, try {
connectionId, state = await this.prisma.cmppConnectionState.create({
...data, 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({ await this.prisma.operationLog.create({
data: { data: {
userId: operatorId, userId: operatorId,
@@ -1306,10 +1425,36 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
srcId: channel.srcId, srcId: channel.srcId,
cmppVersion: channel.cmppVersion, cmppVersion: channel.cmppVersion,
rateLimitPerSecond: channel.rateLimitPerSecond, 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` }); const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, {
await this.notifyGatewayConnect(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; 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() { private getGatewayConnectionQueue() {
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() }); this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
return this.gatewayConnectionQueue; return this.gatewayConnectionQueue;
@@ -1366,6 +1639,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command), body: JSON.stringify(command),
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
}); });
} catch (error) { } catch (error) {
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(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}`); 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) { function normalizeTestPhones(data: TestChannelDto) {
@@ -1481,6 +1774,16 @@ function buildChannelTestSubmitCommand({
cmppVersion: channel.cmppVersion, cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'), desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'), 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 }, retry: { attempt: 0, maxAttempts: 1 },
}; };
@@ -1531,7 +1834,7 @@ function normalizeCmppVersion(version?: string) {
function normalizeGatewayConnectionStatus(status: string) { function normalizeGatewayConnectionStatus(status: string) {
const normalized = status.toLowerCase(); const normalized = status.toLowerCase();
if (['online', 'open', 'connected'].includes(normalized)) { if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) {
return 'connected'; return 'connected';
} }
if (['connecting', 'connect_requested'].includes(normalized)) { if (['connecting', 'connect_requested'].includes(normalized)) {
@@ -1568,6 +1871,8 @@ function normalizeChannelRuntimeConfig(
incomingConfig?: Record<string, unknown> | null, incomingConfig?: Record<string, unknown> | null,
desiredConnections?: number, desiredConnections?: number,
windowSize?: number, windowSize?: number,
heartbeatIntervalSeconds?: number,
heartbeatMissThreshold?: number,
) { ) {
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? existingConfig as Record<string, unknown> ? existingConfig as Record<string, unknown>
@@ -1578,10 +1883,29 @@ function normalizeChannelRuntimeConfig(
const base = { ...existing, ...incoming }; const base = { ...existing, ...incoming };
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections'); base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize'); 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.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
base.serviceId = normalizeCmppServiceId(base.serviceId);
return base; 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) { function normalizeChannelRateLimit(value: unknown) {
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond'); const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
if (normalized > 2000) { 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 () => { it('paginates carrier rules with a real database count', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]); prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]);
+7 -3
View File
@@ -9,6 +9,10 @@ export interface CreatePhoneSegmentDto {
city?: string; city?: string;
} }
function visibleDictionaryStatus(status?: string): string | Prisma.StringFilter {
return status && status !== 'all' && status !== 'deleted' ? status : { not: 'deleted' };
}
export interface PhoneSegmentListQuery { export interface PhoneSegmentListQuery {
keyword?: string; keyword?: string;
page?: number; page?: number;
@@ -152,7 +156,7 @@ export class DictionariesService {
listSensitiveWords(query: DictionaryListQuery = {}) { listSensitiveWords(query: DictionaryListQuery = {}) {
return this.prisma.sensitiveWord.findMany({ return this.prisma.sensitiveWord.findMany({
where: { where: {
status: query.status && query.status !== 'all' ? query.status : undefined, status: visibleDictionaryStatus(query.status),
OR: query.keyword ? [ OR: query.keyword ? [
{ word: { contains: query.keyword } }, { word: { contains: query.keyword } },
{ level: { contains: query.keyword } }, { level: { contains: query.keyword } },
@@ -184,7 +188,7 @@ export class DictionariesService {
listGlobalBlacklist(query: DictionaryListQuery = {}) { listGlobalBlacklist(query: DictionaryListQuery = {}) {
return this.prisma.globalBlacklist.findMany({ return this.prisma.globalBlacklist.findMany({
where: { where: {
status: query.status && query.status !== 'all' ? query.status : undefined, status: visibleDictionaryStatus(query.status),
OR: query.keyword ? [ OR: query.keyword ? [
{ phoneNumber: { contains: query.keyword } }, { phoneNumber: { contains: query.keyword } },
{ reason: { contains: query.keyword } }, { reason: { contains: query.keyword } },
@@ -221,7 +225,7 @@ export class DictionariesService {
where: { where: {
tenantId: query.tenantId, tenantId: query.tenantId,
applicationId: query.applicationId, applicationId: query.applicationId,
status: query.status && query.status !== 'all' ? query.status : undefined, status: visibleDictionaryStatus(query.status),
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
+2 -1
View File
@@ -82,11 +82,12 @@ describe('ReportsService', () => {
it('exports complete filtered report data as escaped CSV instead of the current page', async () => { it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{ prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{
id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A', 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' }); const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' });
expect(exported.fileName).toContain('对账单-'); expect(exported.fileName).toContain('对账单-');
expect(exported.content).toContain('"示例,企业"'); expect(exported.content).toContain('"示例,企业"');
expect(exported.content).toContain('提交条数');
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }), where: expect.objectContaining({ tenantId: 'tenant-1' }),
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
+53 -15
View File
@@ -74,19 +74,19 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async exportReconciliation(query: ReportListQuery) { async exportReconciliation(query: ReportListQuery) {
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] }); 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) { async exportProfit(query: ReportListQuery) {
const { dimensionType, where } = profitWhere(query); const { dimensionType, where } = profitWhere(query);
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] }); 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) { async exportQuality(query: ReportListQuery) {
const { dimensionType, where } = qualityWhere(query); const { dimensionType, where } = qualityWhere(query);
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] }); 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()) { async refreshRollingWindow(now = new Date()) {
@@ -119,7 +119,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
await tx.$executeRaw(Prisma.sql` await tx.$executeRaw(Prisma.sql`
INSERT INTO "DailyReconciliationReport" ( INSERT INTO "DailyReconciliationReport" (
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName", "id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
"sentUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt" "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
) )
SELECT SELECT
CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)), CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)),
@@ -129,8 +129,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
application.id, application.id,
application.name, application.name,
COALESCE(SUM(message."billingUnits"), 0)::integer, 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 = '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,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message FROM "SmsMessageRecord" message
@@ -157,7 +164,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
INSERT INTO "DailyProfitReport" ( INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "tenantId", "tenantName", "applicationId", "channelId",
"sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps", "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
"generatedAt", "updatedAt" "generatedAt", "updatedAt"
) )
SELECT SELECT
@@ -171,8 +178,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
application.id, application.id,
NULL, NULL,
COALESCE(SUM(message."billingUnits"), 0)::integer, 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 = '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.revenue), 0)::bigint,
COALESCE(SUM(billing.refund), 0)::bigint, COALESCE(SUM(billing.refund), 0)::bigint,
COALESCE(SUM(costs.cost), 0)::bigint, COALESCE(SUM(costs.cost), 0)::bigint,
@@ -202,7 +216,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
INSERT INTO "DailyProfitReport" ( INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "tenantId", "tenantName", "applicationId", "channelId",
"sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps", "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
"generatedAt", "updatedAt" "generatedAt", "updatedAt"
) )
SELECT SELECT
@@ -216,18 +230,30 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
NULL, NULL,
channel.id, channel.id,
COALESCE(SUM(message."billingUnits"), 0)::integer, 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 ( COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId" AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered' AND receipt."receiptStatus" = 'delivered'
) THEN message."billingUnits" ELSE 0 END), 0)::integer, ) 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 SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId" AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered' 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.revenue ELSE 0 END), 0)::bigint,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund 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, COALESCE(SUM(submit."costAmountCents"), 0)::bigint,
@@ -280,8 +306,15 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
tenant.name AS tenant_name, tenant.name AS tenant_name,
application.id AS application_id, application.id AS application_id,
message."billingUnits" AS billing_units, 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 = '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') CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt" AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms 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" ( INSERT INTO "DailyQualityReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId", "tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
"sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
) )
SELECT SELECT
CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)), 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} = '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, 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.billing_units)::integer,
SUM(base.sent_units)::integer,
SUM(base.unknown_units)::integer,
SUM(base.success_units)::integer, SUM(base.success_units)::integer,
SUM(base.failed_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, 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,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
@@ -334,7 +369,8 @@ function qualityByChannelSql(day: BusinessDay) {
channel.name AS dimension_name, channel.name AS dimension_name,
message."billingUnits" AS billing_units, message."billingUnits" AS billing_units,
CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_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") 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 THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms
FROM "SmsSubmitRecord" submit FROM "SmsSubmitRecord" submit
@@ -364,7 +400,7 @@ function qualityByChannelSql(day: BusinessDay) {
INSERT INTO "DailyQualityReport" ( INSERT INTO "DailyQualityReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId", "tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
"sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" "submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
) )
SELECT SELECT
CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)), CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)),
@@ -374,6 +410,8 @@ function qualityByChannelSql(day: BusinessDay) {
MAX(base.dimension_name), MAX(base.dimension_name),
NULL, NULL, NULL, base.dimension_id, NULL, NULL, NULL, NULL, NULL, base.dimension_id, NULL, NULL,
SUM(base.billing_units)::integer, SUM(base.billing_units)::integer,
SUM(base.billing_units)::integer,
SUM(base.unknown_units)::integer,
SUM(base.success_units)::integer, SUM(base.success_units)::integer,
SUM(base.failed_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.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
+2
View File
@@ -2847,6 +2847,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
cmppVersion: channel.cmppVersion, cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1), desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1),
windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16), windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16),
heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30),
heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3),
}, },
retry: { attempt, maxAttempts: 1 }, retry: { attempt, maxAttempts: 1 },
}; };
@@ -324,6 +324,7 @@ describe('SmsConfigService', () => {
interfaceType: 'cmpp20', interfaceType: 'cmpp20',
queuePriority: 'priority', queuePriority: 'priority',
dailyLimit: 100000, dailyLimit: 100000,
maxPhonesPerTask: 10000,
downstreamReceiptRetryEnabled: true, downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true, downstreamUplinkRetryEnabled: true,
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
+1 -1
View File
@@ -388,7 +388,7 @@ export class SmsConfigService {
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
customerUnitPrice: data.customerUnitPrice ?? 0, customerUnitPrice: data.customerUnitPrice ?? 0,
queuePriority, queuePriority,
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000, maxPhonesPerTask: data.maxPhonesPerTask ?? 10000,
templateMismatchMode: data.templateMismatchMode ?? 'reject', templateMismatchMode: data.templateMismatchMode ?? 'reject',
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true, downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true, downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
@@ -1643,3 +1643,22 @@
- 号码基础校验只判断空值、字符、长度、数量上限、重复及多号码完整性;号段识别用于运营商和地区快照及路由提示,未知号段不得据此拒绝,必须继续按全国或三网兼容通道处理。 - 号码基础校验只判断空值、字符、长度、数量上限、重复及多号码完整性;号段识别用于运营商和地区快照及路由提示,未知号段不得据此拒绝,必须继续按全国或三网兼容通道处理。
- 预发布历史数据库仍为`SQL_ASCII`时,数据迁移不得使用可能把多字节UTF-8字符拆成单字节处理的正则字符类。中文签名修复必须同时校验主键和原始字节序列,并从迁移前备份恢复完整UTF-8;长期生产数据库必须规划迁移到`UTF8`编码。 - 预发布历史数据库仍为`SQL_ASCII`时,数据迁移不得使用可能把多字节UTF-8字符拆成单字节处理的正则字符类。中文签名修复必须同时校验主键和原始字节序列,并从迁移前备份恢复完整UTF-8;长期生产数据库必须规划迁移到`UTF8`编码。
- 首次开通HTTP接口时,后端默认开启单条发送、状态查询、回执回调、上行查询、上行回调和客户端自助密钥六项能力,回执/上行投递默认为HTTP Webhook。参数复制必须包含应用名称、AppID、六项能力、基础地址、文档、QPS、白名单和真实投递方式。 - 首次开通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`)计入提交但不计入发送,并保证 `发送=未知+成功+失败`
- 通道维度只存在已经路由到通道的记录,因此该维度的提交数等于发送数;应用、签名、引流信息和对账维度的提交数包含平台拦截。
- 二级添加、编辑页面必须继续高亮其所属侧边菜单。
+103
View File
@@ -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 testsGateway 与前后端构建通过。二轮 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、迁移、服务和健康检查通过。
+25
View File
@@ -3758,3 +3758,28 @@ npm run verify:phase8
| TC-SIGN-UTF8-003 | 目标签名在补偿migration前已被人工修改 | 原始hex不匹配时不得覆盖;重复执行修复SQL结果不变且不产生非法字节 | | TC-SIGN-UTF8-003 | 目标签名在补偿migration前已被人工修改 | 原始hex不匹配时不得覆盖;重复执行修复SQL结果不变且不产生非法字节 |
| TC-HTTP-PARAM-002 | 首次开通HTTP后查看并复制参数 | 六项能力默认开启,回执/上行为HTTP Webhook;复制文本含AppID和“客户端自助密钥”,与真实API/DB一致 | | TC-HTTP-PARAM-002 | 首次开通HTTP后查看并复制参数 | 六项能力默认开启,回执/上行为HTTP Webhook;复制文本含AppID和“客户端自助密钥”,与真实API/DB一致 |
| TC-HTTP-PARAM-003 | 升级前已开通HTTP且Webhook能力开启,投递模式仍为cmpp | migration将对应回执/上行模式回填为http,参数复制不再显示CMPP长连接 | | 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发送DisconnectChannelGateway关闭全部连接并停止心跳/重连;等待多个协调周期后仍为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` 和选中样式。
+33
View File
@@ -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 返回 okRedis PONG`gateway.submit.commands``pending=0、lag=0`10 个 `rate:gateway:channel:config:*` TPS 权威配置键存在,Prisma migration status 为最新。 - systemd 日志确认 21:07:16 先停止、启动 `cmpp-gateway`,随后停止、启动 `cmpp-api`。Gateway、API、Nginx、PostgreSQL、Redis 和 MinIO 实际运行,`12026/17890/8090/3000/6379/5432/9000` 均监听;本地 API/Gateway health 返回 okRedis 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 属于重启前状态,真实重连暴露了这两条通道的凭据/上游鉴权问题;本次长短信代码未修改上游通道鉴权,未擅自改密或停用通道,需由运营确认凭据后另行恢复。 - 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 长短信复测。 - 外部首页、运营登录页、客户端登录页和 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.1API 的 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、负载 0Gateway/API/Nginx/PostgreSQL/MinIO 正常,Redis `PONG`,发送 Worker 并发 50`gateway.submit.commands``pending=0、lag=0`。实际发布、备份、迁移、服务重启和预发布 TPS 结果待部署后补记。
+106 -30
View File
@@ -14,6 +14,7 @@ import (
) )
type ConnectFunc func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) 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 SubmitFunc func(context.Context, queue.SubmitCommand) (queue.SubmitResult, error)
type ConnectChannelCommand struct { type ConnectChannelCommand struct {
@@ -28,34 +29,51 @@ type ConnectChannelCommand struct {
} }
type ChannelConfig struct { type ChannelConfig struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
GatewayHost string `json:"gatewayHost"` GatewayHost string `json:"gatewayHost"`
GatewayPort int `json:"gatewayPort"` GatewayPort int `json:"gatewayPort"`
Account string `json:"account"` Account string `json:"account"`
PasswordCipher string `json:"passwordCipher"` PasswordCipher string `json:"passwordCipher"`
SrcID string `json:"srcId"` SrcID string `json:"srcId"`
CMPPVersion string `json:"cmppVersion"` CMPPVersion string `json:"cmppVersion"`
RateLimitPerSecond int `json:"rateLimitPerSecond"` 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 { type ConnectionStateCallback struct {
ChannelID string `json:"channelId"` ChannelID string `json:"channelId"`
ConnectionID string `json:"connectionId"` ConnectionID string `json:"connectionId"`
Status string `json:"status"` Status string `json:"status"`
DesiredConnections int `json:"desiredConnections"` DesiredConnections int `json:"desiredConnections"`
CurrentConnections int `json:"currentConnections"` CurrentConnections int `json:"currentConnections"`
LastConnectedAt string `json:"lastConnectedAt,omitempty"` LastConnectedAt string `json:"lastConnectedAt,omitempty"`
LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"` LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"`
ReconnectCount int `json:"reconnectCount,omitempty"` ReconnectCount int `json:"reconnectCount,omitempty"`
LastError string `json:"lastError,omitempty"` LastReconnectAttemptAt string `json:"lastReconnectAttemptAt,omitempty"`
NextReconnectAt string `json:"nextReconnectAt,omitempty"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
LastError string `json:"lastError,omitempty"`
} }
type Server struct { type Server struct {
APIBaseURL string APIBaseURL string
HTTPClient *http.Client HTTPClient *http.Client
Connect ConnectFunc Connect ConnectFunc
Disconnect DisconnectFunc
Submit SubmitFunc Submit SubmitFunc
Upstream *upstream.Manager Upstream *upstream.Manager
Limiter ratelimit.Limiter Limiter ratelimit.Limiter
@@ -78,10 +96,14 @@ func Register(mux *http.ServeMux, server Server) {
if server.Connect == nil { if server.Connect == nil {
server.Connect = server.connectChannel server.Connect = server.connectChannel
} }
if server.Disconnect == nil {
server.Disconnect = server.disconnectChannel
}
if server.Submit == nil { if server.Submit == nil {
server.Submit = server.Upstream.Submit server.Submit = server.Upstream.Submit
} }
mux.HandleFunc("/connections/connect", server.handleConnectChannel) mux.HandleFunc("/connections/connect", server.handleConnectChannel)
mux.HandleFunc("/connections/disconnect", server.handleDisconnectChannel)
mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit) mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit)
mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt) mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt)
mux.HandleFunc("/downstream/uplink", server.handleDownstreamUplink) 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) _ = 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) { func (s Server) handleUpstreamSubmit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -265,29 +310,60 @@ func (s Server) connectChannel(ctx context.Context, command ConnectChannelComman
Reason: command.Reason, Reason: command.Reason,
DesiredConnections: command.DesiredConnections, DesiredConnections: command.DesiredConnections,
Channel: queue.ConnectChannelConfig{ Channel: queue.ConnectChannelConfig{
Code: command.Channel.Code, Code: command.Channel.Code,
Name: command.Channel.Name, Name: command.Channel.Name,
GatewayHost: command.Channel.GatewayHost, GatewayHost: command.Channel.GatewayHost,
GatewayPort: command.Channel.GatewayPort, GatewayPort: command.Channel.GatewayPort,
Account: command.Channel.Account, Account: command.Channel.Account,
PasswordCipher: command.Channel.PasswordCipher, PasswordCipher: command.Channel.PasswordCipher,
SrcID: command.Channel.SrcID, SrcID: command.Channel.SrcID,
CMPPVersion: command.Channel.CMPPVersion, CMPPVersion: command.Channel.CMPPVersion,
RateLimitPerSecond: command.Channel.RateLimitPerSecond, RateLimitPerSecond: command.Channel.RateLimitPerSecond,
WindowSize: command.Channel.WindowSize,
HeartbeatIntervalSeconds: command.Channel.HeartbeatIntervalSeconds,
HeartbeatMissThreshold: command.Channel.HeartbeatMissThreshold,
}, },
}) })
if err != nil { if err != nil {
return ConnectionStateCallback{}, err 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{ return ConnectionStateCallback{
ChannelID: state.ChannelID, ChannelID: state.ChannelID,
ConnectionID: state.ConnectionID, ConnectionID: state.ConnectionID,
Status: state.Status, Status: state.Status,
DesiredConnections: state.DesiredConnections, DesiredConnections: state.DesiredConnections,
CurrentConnections: state.CurrentConnections, CurrentConnections: state.CurrentConnections,
LastConnectedAt: state.LastConnectedAt,
LastDisconnectedAt: state.LastDisconnectedAt, LastDisconnectedAt: state.LastDisconnectedAt,
LastHeartbeatAt: state.LastHeartbeatAt,
ReconnectCount: state.ReconnectCount, ReconnectCount: state.ReconnectCount,
LastError: state.LastError, LastError: state.LastError,
}, nil }, nil
+33
View File
@@ -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) { func TestRecoveryCandidatesEndpointReturnsView(t *testing.T) {
handler := handlerWithServer(Server{ handler := handlerWithServer(Server{
RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) { RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) {
+43 -27
View File
@@ -7,11 +7,12 @@ const SchemaVersion = "v1"
type MessageType string type MessageType string
const ( const (
MessageTypeSubmitCommand MessageType = "SubmitCommand" MessageTypeSubmitCommand MessageType = "SubmitCommand"
MessageTypeSubmitResult MessageType = "SubmitResult" MessageTypeSubmitResult MessageType = "SubmitResult"
MessageTypeReceiptEvent MessageType = "ReceiptEvent" MessageTypeReceiptEvent MessageType = "ReceiptEvent"
MessageTypeUplinkEvent MessageType = "UplinkEvent" MessageTypeUplinkEvent MessageType = "UplinkEvent"
MessageTypeConnectChannel MessageType = "ConnectChannel" MessageTypeConnectChannel MessageType = "ConnectChannel"
MessageTypeDisconnectChannel MessageType = "DisconnectChannel"
) )
type Envelope struct { type Envelope struct {
@@ -60,13 +61,15 @@ type CMPP struct {
} }
type UpstreamConfig struct { type UpstreamConfig struct {
GatewayHost string `json:"gatewayHost"` GatewayHost string `json:"gatewayHost"`
GatewayPort int `json:"gatewayPort"` GatewayPort int `json:"gatewayPort"`
Account string `json:"account"` Account string `json:"account"`
PasswordCipher string `json:"passwordCipher"` PasswordCipher string `json:"passwordCipher"`
CMPPVersion string `json:"cmppVersion"` CMPPVersion string `json:"cmppVersion"`
DesiredConnections int `json:"desiredConnections,omitempty"` DesiredConnections int `json:"desiredConnections,omitempty"`
WindowSize int `json:"windowSize,omitempty"` WindowSize int `json:"windowSize,omitempty"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"`
HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"`
} }
type Retry struct { type Retry struct {
@@ -76,12 +79,12 @@ type Retry struct {
type SubmitResult struct { type SubmitResult struct {
Envelope Envelope
SequenceID uint32 `json:"sequenceId"` SequenceID uint32 `json:"sequenceId"`
GatewayMessageID string `json:"gatewayMessageId"` GatewayMessageID string `json:"gatewayMessageId"`
SubmitStatus string `json:"submitStatus"` SubmitStatus string `json:"submitStatus"`
ErrorCode string `json:"errorCode,omitempty"` ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"`
SubmittedAt time.Time `json:"submittedAt"` SubmittedAt time.Time `json:"submittedAt"`
Segments []SubmitSegmentResult `json:"segments,omitempty"` Segments []SubmitSegmentResult `json:"segments,omitempty"`
} }
@@ -129,13 +132,26 @@ type ConnectChannelCommand struct {
} }
type ConnectChannelConfig struct { type ConnectChannelConfig struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
GatewayHost string `json:"gatewayHost"` GatewayHost string `json:"gatewayHost"`
GatewayPort int `json:"gatewayPort"` GatewayPort int `json:"gatewayPort"`
Account string `json:"account"` Account string `json:"account"`
PasswordCipher string `json:"passwordCipher"` PasswordCipher string `json:"passwordCipher"`
SrcID string `json:"srcId"` SrcID string `json:"srcId"`
CMPPVersion string `json:"cmppVersion"` CMPPVersion string `json:"cmppVersion"`
RateLimitPerSecond int `json:"rateLimitPerSecond"` 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" "context"
"errors" "errors"
"testing" "testing"
"time"
) )
func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) { func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
@@ -18,6 +19,8 @@ func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
reported = state reported = state
return nil return nil
}, },
reconnectSignal: make(chan struct{}, 1),
stopCh: make(chan struct{}),
}, },
} }
conn.pool.conns = []*connection{conn} conn.pool.conns = []*connection{conn}
@@ -45,6 +48,60 @@ func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
if reported.Status != "disconnected" || reported.CurrentConnections != 0 { if reported.Status != "disconnected" || reported.CurrentConnections != 0 {
t.Fatalf("unexpected reported state: %+v", reported) 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) { func TestTemporaryReadTimeoutDetection(t *testing.T) {
+396 -57
View File
@@ -19,10 +19,15 @@ import (
) )
const ( const (
defaultConnectTimeout = 5 * time.Second defaultConnectTimeout = 5 * time.Second
defaultSubmitTimeout = 10 * time.Second defaultSubmitTimeout = 10 * time.Second
defaultHTTPTimeout = 10 * time.Second defaultHTTPTimeout = 10 * time.Second
defaultWindowSize = 16 defaultWindowSize = 16
defaultHeartbeatInterval = 30 * time.Second
defaultHeartbeatMissThreshold = 3
defaultReconnectInitialDelay = 5 * time.Second
defaultReconnectMaximumDelay = 5 * time.Minute
defaultAuthReconnectDelay = 5 * time.Minute
) )
type Manager struct { type Manager struct {
@@ -34,16 +39,19 @@ type Manager struct {
} }
type ConnectionState struct { type ConnectionState struct {
ChannelID string `json:"channelId"` ChannelID string `json:"channelId"`
ConnectionID string `json:"connectionId"` ConnectionID string `json:"connectionId"`
Status string `json:"status"` Status string `json:"status"`
DesiredConnections int `json:"desiredConnections"` DesiredConnections int `json:"desiredConnections"`
CurrentConnections int `json:"currentConnections"` CurrentConnections int `json:"currentConnections"`
LastConnectedAt string `json:"lastConnectedAt,omitempty"` LastConnectedAt string `json:"lastConnectedAt,omitempty"`
LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"` LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"`
ReconnectCount int `json:"reconnectCount,omitempty"` ReconnectCount int `json:"reconnectCount,omitempty"`
LastError string `json:"lastError,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) { 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() m.ensureDefaultsLocked()
pool := m.conns[command.ChannelID] pool := m.conns[command.ChannelID]
config := normalizeUpstreamConfig(queue.UpstreamConfig{ config := normalizeUpstreamConfig(queue.UpstreamConfig{
GatewayHost: command.Channel.GatewayHost, GatewayHost: command.Channel.GatewayHost,
GatewayPort: command.Channel.GatewayPort, GatewayPort: command.Channel.GatewayPort,
Account: command.Channel.Account, Account: command.Channel.Account,
PasswordCipher: command.Channel.PasswordCipher, PasswordCipher: command.Channel.PasswordCipher,
CMPPVersion: command.Channel.CMPPVersion, CMPPVersion: command.Channel.CMPPVersion,
DesiredConnections: command.DesiredConnections, DesiredConnections: command.DesiredConnections,
WindowSize: 16, WindowSize: command.Channel.WindowSize,
HeartbeatIntervalSeconds: command.Channel.HeartbeatIntervalSeconds,
HeartbeatMissThreshold: command.Channel.HeartbeatMissThreshold,
}) })
if pool == nil || !pool.matches(config) { if pool == nil || !pool.matches(config) {
if pool != nil { if pool != nil {
@@ -103,16 +113,47 @@ func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChann
} }
m.mu.Unlock() m.mu.Unlock()
pool.startSupervisor()
if err := pool.ensureConnected(); err != nil { if err := pool.ensureConnected(); err != nil {
if pool.stopped() {
return pool.snapshotState("disconnected", nil), nil
}
pool.scheduleReconnect(err)
_ = pool.reportState(ctx, "failed", err) _ = pool.reportState(ctx, "failed", err)
m.mu.Lock()
delete(m.conns, command.ChannelID)
m.mu.Unlock()
return pool.snapshotState("failed", err), nil return pool.snapshotState("failed", err), nil
} }
if pool.stopped() {
return pool.snapshotState("disconnected", nil), nil
}
pool.resetReconnectState()
return pool.snapshotState("connected", nil), nil 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) { func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() 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)) pool = m.newConnectionPool(cmd.ChannelID, defaultChannelConnectionID(cmd.ChannelID), normalizeUpstreamConfig(cmd.Upstream))
m.conns[cmd.ChannelID] = pool m.conns[cmd.ChannelID] = pool
} }
pool.startSupervisor()
if err := pool.ensureConnected(); err != nil { if err := pool.ensureConnected(); err != nil {
delete(m.conns, cmd.ChannelID) pool.scheduleReconnect(err)
return nil, err return nil, err
} }
pool.resetReconnectState()
return pool, nil return pool, nil
} }
@@ -161,6 +204,8 @@ func (m *Manager) newConnectionPool(channelID string, connectionID string, confi
reporter: func(ctx context.Context, state ConnectionState) error { reporter: func(ctx context.Context, state ConnectionState) error {
return m.post(ctx, "/admin/gateway/connections", state) 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 httpClient *http.Client
reporter func(context.Context, ConnectionState) error reporter func(context.Context, ConnectionState) error
mu sync.Mutex mu sync.Mutex
conns []*connection connectMu sync.Mutex
next int 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 { 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 { func (p *connectionPool) ensureConnected() error {
p.connectMu.Lock()
defer p.connectMu.Unlock()
desired := p.config.DesiredConnections desired := p.config.DesiredConnections
if desired <= 0 { if desired <= 0 {
desired = 1 desired = 1
@@ -189,29 +245,38 @@ func (p *connectionPool) ensureConnected() error {
connectedAny := false connectedAny := false
for { for {
p.mu.Lock() 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 { if len(p.conns) >= desired {
p.mu.Unlock() p.mu.Unlock()
break break
} }
index := len(p.conns) index := len(p.conns)
conn := &connection{ conn := &connection{
channelID: p.channelID, channelID: p.channelID,
config: p.config, config: p.config,
index: index, index: index,
pool: p, pool: p,
apiBaseURL: p.apiBaseURL, apiBaseURL: p.apiBaseURL,
httpClient: p.httpClient, httpClient: p.httpClient,
window: make(chan struct{}, p.config.WindowSize), window: make(chan struct{}, p.config.WindowSize),
pending: make(map[uint32]chan submitPartResponse), pending: make(map[uint32]chan submitPartResponse),
tracker: make(map[uint64]queue.SubmitCommand), tracker: make(map[uint64]queue.SubmitCommand),
longUplink: make(map[string]*longUplinkAssembly), longUplink: make(map[string]*longUplinkAssembly),
heartbeatPending: make(map[uint32]time.Time),
} }
p.mu.Unlock() p.mu.Unlock()
connected, err := conn.ensureConnected() connected, err := conn.ensureConnected()
if err != nil { if err != nil {
conn.close()
p.close()
return err return err
} }
@@ -226,6 +291,118 @@ func (p *connectionPool) ensureConnected() error {
return nil 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) { func (p *connectionPool) submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) {
parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content) parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content)
if err != nil { if err != nil {
@@ -314,6 +491,11 @@ func (p *connectionPool) tryAcquireConnection() (*connection, func()) {
} }
func (p *connectionPool) close() { func (p *connectionPool) close() {
p.connectMu.Lock()
defer p.connectMu.Unlock()
p.stopOnce.Do(func() {
close(p.stopCh)
})
p.mu.Lock() p.mu.Lock()
conns := p.conns conns := p.conns
p.conns = nil p.conns = nil
@@ -339,6 +521,16 @@ func (p *connectionPool) snapshotState(status string, stateErr error) Connection
DesiredConnections: p.config.DesiredConnections, DesiredConnections: p.config.DesiredConnections,
CurrentConnections: p.countActiveConnections(), 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 { if state.DesiredConnections <= 0 {
state.DesiredConnections = 1 state.DesiredConnections = 1
} }
@@ -346,6 +538,8 @@ func (p *connectionPool) snapshotState(status string, stateErr error) Connection
case "connected": case "connected":
state.LastConnectedAt = now state.LastConnectedAt = now
state.LastHeartbeatAt = now state.LastHeartbeatAt = now
case "heartbeat":
state.LastHeartbeatAt = now
case "disconnected", "failed": case "disconnected", "failed":
state.LastDisconnectedAt = now state.LastDisconnectedAt = now
} }
@@ -378,15 +572,17 @@ type connection struct {
apiBaseURL string apiBaseURL string
httpClient *http.Client httpClient *http.Client
mu sync.Mutex mu sync.Mutex
sendMu sync.Mutex sendMu sync.Mutex
client *cmpp.Client client *cmpp.Client
window chan struct{} window chan struct{}
pending map[uint32]chan submitPartResponse pending map[uint32]chan submitPartResponse
tracker map[uint64]queue.SubmitCommand tracker map[uint64]queue.SubmitCommand
longUplink map[string]*longUplinkAssembly longUplink map[string]*longUplinkAssembly
readOnce sync.Once readOnce sync.Once
closed bool closed bool
heartbeatCancel context.CancelFunc
heartbeatPending map[uint32]time.Time
} }
type submitPartResponse struct { type submitPartResponse struct {
@@ -415,7 +611,11 @@ func (c *connection) ensureConnected() (bool, error) {
} }
c.client = client c.client = client
c.closed = false c.closed = false
c.heartbeatPending = make(map[uint32]time.Time)
heartbeatCtx, cancelHeartbeat := context.WithCancel(context.Background())
c.heartbeatCancel = cancelHeartbeat
go c.readLoop() go c.readLoop()
go c.heartbeatLoop(heartbeatCtx)
return true, nil return true, nil
} }
@@ -423,8 +623,17 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
rspCh := make(chan submitPartResponse, 1) rspCh := make(chan submitPartResponse, 1)
pkt := c.submitRequestPacket(cmd, part) 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() c.sendMu.Lock()
seq, err := c.client.SendReqPkt(pkt) seq, err := client.SendReqPkt(pkt)
c.sendMu.Unlock() c.sendMu.Unlock()
if err != nil { if err != nil {
c.close() c.close()
@@ -576,10 +785,17 @@ func (c *connection) releaseWindow() {
func (c *connection) readLoop() { func (c *connection) readLoop() {
for { 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 { if err != nil {
c.mu.Lock() c.mu.Lock()
closed := c.closed closed = c.closed
c.mu.Unlock() c.mu.Unlock()
if closed { if closed {
return return
@@ -588,7 +804,7 @@ func (c *connection) readLoop() {
continue continue
} }
c.handleConnectionLoss(err) c.handleConnectionLoss(err)
continue return
} }
switch p := pkt.(type) { switch p := pkt.(type) {
case *cmpp.Cmpp2SubmitRspPkt: case *cmpp.Cmpp2SubmitRspPkt:
@@ -606,17 +822,86 @@ func (c *connection) readLoop() {
ch <- submitPartResponse{seqID: p.SeqId, msgID: p.MsgId, result: p.Result} ch <- submitPartResponse{seqID: p.SeqId, msgID: p.MsgId, result: p.Result}
} }
case *cmpp.Cmpp2DeliverReqPkt: 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)) c.handleDeliver(deliverPacketFromCMPP2(p))
case *cmpp.Cmpp3DeliverReqPkt: 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)) c.handleDeliver(deliverPacketFromCMPP3(p))
case *cmpp.CmppActiveTestReqPkt: 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 { type deliverPacket struct {
seqID uint32 seqID uint32
msgID uint64 msgID uint64
@@ -757,8 +1042,13 @@ func (c *connection) handleConnectionLoss(err error) {
return return
} }
c.closed = true c.closed = true
if c.heartbeatCancel != nil {
c.heartbeatCancel()
c.heartbeatCancel = nil
}
pending := c.pending pending := c.pending
c.pending = make(map[uint32]chan submitPartResponse) c.pending = make(map[uint32]chan submitPartResponse)
c.heartbeatPending = make(map[uint32]time.Time)
if c.client != nil { if c.client != nil {
c.client.Disconnect() c.client.Disconnect()
c.client = nil c.client = nil
@@ -774,8 +1064,9 @@ func (c *connection) handleConnectionLoss(err error) {
if c.pool != nil { if c.pool != nil {
status := "disconnected" status := "disconnected"
if c.pool.countActiveConnections() > 0 { if c.pool.countActiveConnections() > 0 {
status = "connected" status = "reconnecting"
} }
c.pool.scheduleReconnect(err)
_ = c.pool.reportState(context.Background(), status, err) _ = c.pool.reportState(context.Background(), status, err)
} }
} }
@@ -864,9 +1155,57 @@ func normalizeUpstreamConfig(config queue.UpstreamConfig) queue.UpstreamConfig {
if config.WindowSize <= 0 { if config.WindowSize <= 0 {
config.WindowSize = defaultWindowSize config.WindowSize = defaultWindowSize
} }
if config.HeartbeatIntervalSeconds <= 0 {
config.HeartbeatIntervalSeconds = int(defaultHeartbeatInterval / time.Second)
}
if config.HeartbeatMissThreshold <= 0 {
config.HeartbeatMissThreshold = defaultHeartbeatMissThreshold
}
return config 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 { func isTemporaryReadTimeout(err error) bool {
var netErr net.Error var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout() return errors.As(err, &netErr) && netErr.Timeout()
+36
View File
@@ -1,6 +1,7 @@
package upstream package upstream
import ( import (
"context"
"testing" "testing"
"cmpp-platform/gateway/internal/queue" "cmpp-platform/gateway/internal/queue"
@@ -39,6 +40,38 @@ func TestConnectionPoolAcquiresAcrossConnections(t *testing.T) {
releaseSecond() 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) { func TestNormalizeUpstreamConfigDefaults(t *testing.T) {
config := normalizeUpstreamConfig(queueUpstreamConfigForTest()) config := normalizeUpstreamConfig(queueUpstreamConfigForTest())
if config.DesiredConnections != 1 { if config.DesiredConnections != 1 {
@@ -47,6 +80,9 @@ func TestNormalizeUpstreamConfigDefaults(t *testing.T) {
if config.WindowSize != defaultWindowSize { if config.WindowSize != defaultWindowSize {
t.Fatalf("WindowSize = %d, want %d", 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 { 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
}
+8 -8
View File
@@ -16,7 +16,7 @@
"lucide-react": "^1.18.0", "lucide-react": "^1.18.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.17.0", "react-router-dom": "^7.18.1",
"vite": "^8.0.16", "vite": "^8.0.16",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
@@ -1168,9 +1168,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.17.0", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -1190,12 +1190,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.17.0", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.17.0" "react-router": "7.18.1"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+1 -1
View File
@@ -34,7 +34,7 @@
"lucide-react": "^1.18.0", "lucide-react": "^1.18.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.17.0", "react-router-dom": "^7.18.1",
"vite": "^8.0.16", "vite": "^8.0.16",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
+12 -3
View File
@@ -186,7 +186,7 @@ export type AdminChannel = {
rateLimitPerSecond: number; rateLimitPerSecond: number;
unitPrice: number; unitPrice: number;
status: string; 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[]; connectionStates?: CmppConnectionState[];
}; };
@@ -983,7 +983,9 @@ export type DailyReconciliationReport = {
tenantName: string; tenantName: string;
applicationId: string; applicationId: string;
applicationName: string; applicationName: string;
submittedUnits: number;
sentUnits: number; sentUnits: number;
unknownUnits: number;
successUnits: number; successUnits: number;
failedUnits: number; failedUnits: number;
generatedAt: string; generatedAt: string;
@@ -1000,7 +1002,9 @@ export type DailyProfitReport = {
tenantName?: string | null; tenantName?: string | null;
applicationId?: string | null; applicationId?: string | null;
channelId?: string | null; channelId?: string | null;
submittedUnits: number;
sentUnits: number; sentUnits: number;
unknownUnits: number;
successUnits: number; successUnits: number;
failedUnits: number; failedUnits: number;
revenueCents: number; revenueCents: number;
@@ -1024,7 +1028,9 @@ export type DailyQualityReport = {
channelId?: string | null; channelId?: string | null;
signatureId?: string | null; signatureId?: string | null;
drainageInfoId?: string | null; drainageInfoId?: string | null;
submittedUnits: number;
sentUnits: number; sentUnits: number;
unknownUnits: number;
successUnits: number; successUnits: number;
failedUnits: number; failedUnits: number;
successRateBps: number; successRateBps: number;
@@ -1104,6 +1110,9 @@ export type CmppConnectionState = {
lastDisconnectedAt?: string | null; lastDisconnectedAt?: string | null;
lastHeartbeatAt?: string | null; lastHeartbeatAt?: string | null;
reconnectCount: number; reconnectCount: number;
lastReconnectAttemptAt?: string | null;
nextReconnectAt?: string | null;
lastErrorCategory?: string | null;
lastError?: string | null; lastError?: string | null;
updatedAt: string; updatedAt: string;
channel?: AdminChannel; channel?: AdminChannel;
@@ -1403,9 +1412,9 @@ export const adminApi = {
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)), 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 } = {}) => exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
requestBlob(withQuery('/admin/reports/quality/export', query)), 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) }), 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) }), request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, { copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
method: 'POST', method: 'POST',
+25 -10
View File
@@ -25,12 +25,15 @@ type SmsChannel = {
failureCount: number; failureCount: number;
gatewayHost: string; gatewayHost: string;
gatewayPort: string; gatewayPort: string;
businessCode: string;
corpCode: string; corpCode: string;
account: string; account: string;
accessNo: string; accessNo: string;
cmppVersion: '2.0' | '3.0'; cmppVersion: '2.0' | '3.0';
desiredConnections: number; desiredConnections: number;
windowSize: number; windowSize: number;
heartbeatIntervalSeconds: number;
heartbeatMissThreshold: number;
extensionDigits: number; extensionDigits: number;
rateLimitPerSecond: number; rateLimitPerSecond: number;
passwordCipher?: string; passwordCipher?: string;
@@ -83,12 +86,6 @@ const statusOptions = [
{ label: '连接失败', value: 'failed' }, { label: '连接失败', value: 'failed' },
]; ];
const protocolOptions = [
{ label: 'CMPP', value: 'CMPP' },
{ label: 'HTTP', value: 'HTTP' },
{ label: 'SGIP', value: 'SGIP' },
];
const cmppVersionOptions = [ const cmppVersionOptions = [
{ label: 'CMPP 2.0', value: '2.0' }, { label: 'CMPP 2.0', value: '2.0' },
{ label: 'CMPP 3.0', value: '3.0' }, { label: 'CMPP 3.0', value: '3.0' },
@@ -161,12 +158,15 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
failureCount: 0, failureCount: 0,
gatewayHost: channel.gatewayHost, gatewayHost: channel.gatewayHost,
gatewayPort: String(channel.gatewayPort), gatewayPort: String(channel.gatewayPort),
businessCode: String(channel.config?.serviceId ?? 'SMS'),
corpCode: channel.enterpriseCode ?? channel.code, corpCode: channel.enterpriseCode ?? channel.code,
account: channel.account, account: channel.account,
accessNo: channel.srcId, accessNo: channel.srcId,
cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0', cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0',
desiredConnections: Number(channel.config?.desiredConnections ?? 1), desiredConnections: Number(channel.config?.desiredConnections ?? 1),
windowSize: Number(channel.config?.windowSize ?? 16), windowSize: Number(channel.config?.windowSize ?? 16),
heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30),
heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3),
extensionDigits: Number(channel.config?.extensionDigits ?? 0), extensionDigits: Number(channel.config?.extensionDigits ?? 0),
rateLimitPerSecond: channel.rateLimitPerSecond, rateLimitPerSecond: channel.rateLimitPerSecond,
}; };
@@ -183,6 +183,7 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
sendRegion: channel.sendRegion, sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost, gatewayHost: channel.gatewayHost,
gatewayPort: Number(channel.gatewayPort), gatewayPort: Number(channel.gatewayPort),
protocol: 'CMPP',
enterpriseCode: channel.corpCode, enterpriseCode: channel.corpCode,
account: channel.account, account: channel.account,
passwordCipher: passwordCipher || undefined, passwordCipher: passwordCipher || undefined,
@@ -192,7 +193,9 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
unitPrice: Math.round(channel.unitPrice), unitPrice: Math.round(channel.unitPrice),
desiredConnections: channel.desiredConnections, desiredConnections: channel.desiredConnections,
windowSize: channel.windowSize, 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 [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
const [unitPriceError, setUnitPriceError] = useState(''); const [unitPriceError, setUnitPriceError] = useState('');
const [region, setRegion] = useState(channel?.sendRegion ?? '全国'); const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
const [protocol, setProtocol] = useState('CMPP');
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? ''); 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 [corpCode, setCorpCode] = useState(channel?.corpCode ?? '');
const [account, setAccount] = useState(channel?.account ?? ''); const [account, setAccount] = useState(channel?.account ?? '');
const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0'); 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 [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1)); const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16)); 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() { function submit() {
if (!isValidMoneyInput(unitPrice)) { if (!isValidMoneyInput(unitPrice)) {
@@ -255,12 +260,15 @@ function ChannelFormModal({
failureCount: channel?.failureCount ?? 0, failureCount: channel?.failureCount ?? 0,
gatewayHost, gatewayHost,
gatewayPort, gatewayPort,
businessCode: businessCode.trim() || 'SMS',
corpCode, corpCode,
account, account,
accessNo, accessNo,
cmppVersion, cmppVersion,
desiredConnections: Number(desiredConnections) || 1, desiredConnections: Number(desiredConnections) || 1,
windowSize: Number(windowSize) || 16, windowSize: Number(windowSize) || 16,
heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30,
heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3,
extensionDigits: Number(extensionDigits), extensionDigits: Number(extensionDigits),
rateLimitPerSecond: Number(flowLimit), rateLimitPerSecond: Number(flowLimit),
passwordCipher: password || undefined, passwordCipher: password || undefined,
@@ -302,11 +310,12 @@ function ChannelFormModal({
<section> <section>
<h3></h3> <h3></h3>
<div className="sms-channel-form-grid"> <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"> <div className="sms-channel-inline-field">
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} /> <Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} /> <Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
</div> </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) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} /> <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} /> <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="* 通道流速" 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) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} /> <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> </div>
</section> </section>
</div> </div>
@@ -721,6 +732,10 @@ export function AdminChannelsPage() {
<span></span> <span></span>
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong> <strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
</div> </div>
<div>
<span></span>
<strong>{connection.reconnectCount} / {formatDateTime(connection.nextReconnectAt)}</strong>
</div>
{connection.lastError ? <p>{connection.lastError}</p> : null} {connection.lastError ? <p>{connection.lastError}</p> : null}
</article> </article>
))} ))}
@@ -153,12 +153,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(''); const [copyError, setCopyError] = useState('');
const paramsText = formatCmppParams(app, params); 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() { async function copyParams() {
try { 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>} 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-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> <pre className="cmpp-param-copy">{paramsText}</pre>
{copyError ? <p className="form-error">{copyError}</p> : null} {copyError ? <p className="form-error">{copyError}</p> : null}
</div> </div>
@@ -391,21 +372,21 @@ export function AdminEnterpriseApplicationsPage() {
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)}` }, { key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)}` },
{ {
key: 'cmppStatus', key: 'cmppStatus',
title: 'CMPP状态', title: '客户连接状态',
width: '230px', width: '250px',
render: (record) => ( render: (record) => (
<div className="cmpp-status-cell"> <div className="cmpp-status-cell">
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}> <Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'} {record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
</Tag> </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} {record.cmppConnections.filter((item) => item.state === 'open').length}
</button> </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} /> <Settings2 size={13} />
CMPP参数 CMPP参数
</button> </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> </div>
), ),
}, },
@@ -416,7 +397,7 @@ export function AdminEnterpriseApplicationsPage() {
align: 'right', align: 'right',
width: '190px', width: '190px',
render: (record) => ( 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 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'}> <Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
{record.enabled ? '停用' : '启用'} {record.enabled ? '停用' : '启用'}
+1 -2
View File
@@ -104,7 +104,6 @@ export function AdminEnterpriseAuditPage() {
} }
const columns: Array<TableColumn<EnterpriseAuditRecord>> = [ 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: 'companyName', title: '企业名称', render: (record) => <strong>{record.companyName}</strong> },
{ key: 'creditCode', title: '统一社会信用代码', render: (record) => record.creditCode }, { key: 'creditCode', title: '统一社会信用代码', render: (record) => record.creditCode },
{ key: 'contactName', title: '联系人', render: (record) => record.contactName }, { key: 'contactName', title: '联系人', render: (record) => record.contactName },
@@ -173,7 +172,7 @@ export function AdminEnterpriseAuditPage() {
onClose={() => setDetailRecord(null)} onClose={() => setDetailRecord(null)}
open open
size="xl" 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"> <div className="enterprise-audit-detail">
<section> <section>
@@ -114,7 +114,7 @@ export function AdminEnterpriseBlacklistPage() {
<Select <Select
label="状态" label="状态"
onChange={(event) => setStatus(event.target.value)} 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} value={status}
/> />
<div className="admin-security-filter__actions"> <div className="admin-security-filter__actions">
+5 -5
View File
@@ -74,12 +74,12 @@ export function AdminProfitReportsPage() {
<div className="surface"> <div className="surface">
<div className="ui-table-wrap"> <div className="ui-table-wrap">
<table className="ui-table"> <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> <tbody>
{error ? <tr><td className="ui-table__empty" colSpan={11}>{error}</td></tr> {error ? <tr><td className="ui-table__empty" colSpan={13}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={11}>...</td></tr> : loading ? <tr><td className="ui-table__empty" colSpan={13}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={11}></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.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>)} : 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> </tbody>
</table> </table>
</div> </div>
+5 -5
View File
@@ -81,12 +81,12 @@ export function AdminQualityReportsPage() {
<div className="surface"> <div className="surface">
<div className="ui-table-wrap"> <div className="ui-table-wrap">
<table className="ui-table"> <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> <tbody>
{error ? <tr><td className="ui-table__empty" colSpan={8}>{error}</td></tr> {error ? <tr><td className="ui-table__empty" colSpan={10}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={8}>...</td></tr> : loading ? <tr><td className="ui-table__empty" colSpan={10}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={8}></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.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>)} : 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> </tbody>
</table> </table>
</div> </div>
@@ -80,12 +80,12 @@ export function AdminReconciliationReportsPage() {
<div className="surface"> <div className="surface">
<div className="ui-table-wrap"> <div className="ui-table-wrap">
<table className="ui-table"> <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> <tbody>
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr> {error ? <tr><td className="ui-table__empty" colSpan={9}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={7}>...</td></tr> : loading ? <tr><td className="ui-table__empty" colSpan={9}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}></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.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>)} : 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> </tbody>
</table> </table>
</div> </div>
+25 -2
View File
@@ -48,7 +48,7 @@ export function AdminSmsApplicationFormPage() {
const [interfaceEnabled, setInterfaceEnabled] = useState(true); const [interfaceEnabled, setInterfaceEnabled] = useState(true);
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20'); const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
const [cmppMaxConnections, setCmppMaxConnections] = useState('1'); const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10'); const [phoneDailyLimit, setPhoneDailyLimit] = useState('10000');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review'); const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true); const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true); const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
@@ -73,6 +73,29 @@ export function AdminSmsApplicationFormPage() {
let cancelled = false; let cancelled = false;
async function loadForm() { async function loadForm() {
try { 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([ const [groupItems, application, routeRules] = await Promise.all([
adminApi.listChannelGroups(), adminApi.listChannelGroups(),
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null), isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
@@ -278,7 +301,7 @@ export function AdminSmsApplicationFormPage() {
<span></span> <span></span>
</div> </div>
</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 <Select
label="不符合模板的短信" label="不符合模板的短信"
onChange={(event) => setMismatchPolicy(event.target.value)} onChange={(event) => setMismatchPolicy(event.target.value)}
+1 -3
View File
@@ -102,7 +102,6 @@ export function AdminSmsAuditPage() {
width: '54px', 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" />, 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: '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: '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') }, { key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') },
@@ -149,7 +148,7 @@ export function AdminSmsAuditPage() {
]} ]}
value={status} 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} /> <Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
<div className="audit-filter-actions"> <div className="audit-filter-actions">
<Button icon={<Search size={17} />} onClick={loadData}></Button> <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="审核任务更多信息"> {detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
<div className="detail-grid"> <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>{formatDateTime(detailTarget.createdAt)}</strong></div>
<div><span></span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div> <div><span></span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div> <div><span></span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
+30 -23
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react'; import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi'; 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'; import { formatCents } from '@/utils/currency';
const statusLabelMap: Record<string, string> = { const statusLabelMap: Record<string, string> = {
@@ -166,13 +166,11 @@ function downloadCsv(records: SmsMessageRecord[]) {
function SendDetailModal({ function SendDetailModal({
record, record,
segmentAudits, segmentAudits,
segmentColumns,
segmentLoading, segmentLoading,
onClose, onClose,
}: { }: {
record: SmsMessageRecord; record: SmsMessageRecord;
segmentAudits: SmsMessageSegmentAudit[]; segmentAudits: SmsMessageSegmentAudit[];
segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>>;
segmentLoading: boolean; segmentLoading: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
@@ -204,6 +202,10 @@ function SendDetailModal({
<span></span> <span></span>
<strong>{getTime(record.queuedAt)}</strong> <strong>{getTime(record.queuedAt)}</strong>
</div> </div>
<div>
<span></span>
<strong>{record.phoneNumber || '-'}</strong>
</div>
<div> <div>
<span></span> <span></span>
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong> <strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
@@ -272,13 +274,31 @@ function SendDetailModal({
<section> <section>
<h3></h3> <h3></h3>
<Table {segmentLoading ? <div className="ui-table__empty">...</div> : segmentAudits.length === 0 ? (
columns={segmentColumns} <div className="ui-table__empty"></div>
data={segmentAudits} ) : (
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'} <div className="admin-sms-segment-list">
pagination={false} {segmentAudits.map((segment) => (
rowKey="id" <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> </section>
</div> </div>
</Modal> </Modal>
@@ -378,18 +398,6 @@ export function AdminSmsRecordsPage() {
const currentPage = Math.min(page, totalPages); const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const 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() { function resetFilters() {
setEnterprise('all'); setEnterprise('all');
setApplication('all'); setApplication('all');
@@ -485,7 +493,6 @@ export function AdminSmsRecordsPage() {
onClose={() => setSelectedRecord(null)} onClose={() => setSelectedRecord(null)}
record={selectedRecord} record={selectedRecord}
segmentAudits={segmentAudits} segmentAudits={segmentAudits}
segmentColumns={segmentColumns}
segmentLoading={segmentLoading} segmentLoading={segmentLoading}
/> />
) : null} ) : null}
+1 -2
View File
@@ -36,7 +36,6 @@ export function AdminTemplateAuditPage() {
const columns = useMemo<Array<TableColumn<SmsTemplateAudit>>>( const columns = useMemo<Array<TableColumn<SmsTemplateAudit>>>(
() => [ () => [
{ key: 'id', title: '审核编号', render: (record) => record.id },
{ key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId }, { key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId }, { key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId },
{ key: 'content', title: '短信模板内容', render: (record) => record.content }, { key: 'content', title: '短信模板内容', render: (record) => record.content },
@@ -83,7 +82,7 @@ export function AdminTemplateAuditPage() {
</div> </div>
<div className="surface audit-filter-card"> <div className="surface audit-filter-card">
<div className="audit-filter-grid audit-filter-grid--template"> <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} /> <Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<div className="audit-filter-actions"> <div className="audit-filter-actions">
<Button icon={<Search size={17} />}></Button> <Button icon={<Search size={17} />}></Button>
+19 -3
View File
@@ -12,7 +12,7 @@ import {
PanelLeftOpen, PanelLeftOpen,
X, X,
} from 'lucide-react'; } 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 { portalSessionApi } from '@/api/adminApi';
import { import {
clearSession, clearSession,
@@ -337,13 +337,20 @@ export function AppShell({
const Icon = item.icon; const Icon = item.icon;
return ( 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} /> <Icon size={17} strokeWidth={2.1} />
<span className="side-nav-label"> <span className="side-nav-label">
<span className="side-nav-label-text">{item.label}</span> <span className="side-nav-label-text">{item.label}</span>
{item.pending ? <span className="dev-status-badge side-nav-pending-badge"></span> : null} {item.pending ? <span className="dev-status-badge side-nav-pending-badge"></span> : null}
</span> </span>
</NavLink> </Link>
); );
})} })}
</div> </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) { function formatCountdown(seconds: number) {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
+92 -13
View File
@@ -3167,11 +3167,32 @@ h3 {
color: var(--color-text-inverse); 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 { .cmpp-status-cell__params {
gap: 4px; gap: 4px;
min-width: 58px !important; min-width: 58px !important;
} }
.enterprise-app-actions {
display: grid;
grid-template-columns: repeat(2, max-content);
justify-content: end;
}
.cmpp-connection-detail { .cmpp-connection-detail {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
@@ -8061,9 +8082,9 @@ h3 {
.sms-test-title span { .sms-test-title span {
align-items: center; align-items: center;
background: rgba(255, 255, 255, 0.18); background: var(--color-selected-soft);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: white; color: var(--color-selected);
display: inline-flex; display: inline-flex;
height: 58px; height: 58px;
justify-content: center; justify-content: center;
@@ -8072,7 +8093,7 @@ h3 {
.sms-test-title h2, .sms-test-title h2,
.sms-test-title p { .sms-test-title p {
color: white; color: var(--color-text-strong);
margin: 0; margin: 0;
} }
@@ -8088,12 +8109,12 @@ h3 {
} }
.ui-modal__header:has(.sms-test-title) { .ui-modal__header:has(.sms-test-title) {
background: var(--color-selected); background: var(--color-surface);
border-bottom: 0; border-bottom: 1px solid var(--color-border);
} }
.ui-modal__header:has(.sms-test-title) .ui-button { .ui-modal__header:has(.sms-test-title) .ui-button {
color: white; color: var(--color-text-muted);
} }
.sms-test-modal { .sms-test-modal {
@@ -9007,8 +9028,8 @@ h3 {
.admin-sms-record-list { .admin-sms-record-list {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-2);
padding: var(--space-5); padding: var(--space-3);
} }
.admin-sms-record-card { .admin-sms-record-card {
@@ -9016,8 +9037,8 @@ h3 {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-3);
padding: var(--space-5); padding: var(--space-3) var(--space-4);
} }
.admin-sms-record-card:hover { .admin-sms-record-card:hover {
@@ -9041,7 +9062,12 @@ h3 {
background: var(--color-bg-subtle); background: var(--color-bg-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
max-width: none; 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 { .admin-sms-record-card__meta {
@@ -9069,7 +9095,7 @@ h3 {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding-top: var(--space-3); padding-top: var(--space-2);
} }
.admin-sms-record-table { .admin-sms-record-table {
@@ -9258,12 +9284,65 @@ h3 {
overflow-wrap: anywhere; 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) { @media (max-width: 900px) {
.admin-sms-record-card > header, .admin-sms-record-card > header,
.admin-sms-record-card__meta, .admin-sms-record-card__meta,
.admin-sms-detail-overview, .admin-sms-detail-overview,
.admin-sms-detail-status-grid, .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; grid-template-columns: 1fr;
} }