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": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
+2 -1
View File
@@ -43,6 +43,7 @@
"overrides": {
"exceljs": {
"uuid": "11.1.1"
}
},
"find-my-way": "9.7.0"
}
}
@@ -0,0 +1,14 @@
ALTER TABLE "CmppConnectionState"
ADD COLUMN "lastReconnectAttemptAt" TIMESTAMP(3),
ADD COLUMN "nextReconnectAt" TIMESTAMP(3),
ADD COLUMN "lastErrorCategory" TEXT;
CREATE INDEX "CmppConnectionState_status_nextReconnectAt_idx"
ON "CmppConnectionState"("status", "nextReconnectAt");
-- Rollback:
-- DROP INDEX "CmppConnectionState_status_nextReconnectAt_idx";
-- ALTER TABLE "CmppConnectionState"
-- DROP COLUMN "lastErrorCategory",
-- DROP COLUMN "nextReconnectAt",
-- DROP COLUMN "lastReconnectAttemptAt";
@@ -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)
customerUnitPrice BigInt @default(0)
queuePriority String @default("normal")
maxPhonesPerTask Int @default(1000000)
maxPhonesPerTask Int @default(10000)
templateMismatchMode String @default("reject")
downstreamReceiptRetryEnabled Boolean @default(true)
downstreamUplinkRetryEnabled Boolean @default(true)
@@ -785,6 +785,9 @@ model CmppConnectionState {
lastDisconnectedAt DateTime?
lastHeartbeatAt DateTime?
reconnectCount Int @default(0)
lastReconnectAttemptAt DateTime?
nextReconnectAt DateTime?
lastErrorCategory String?
lastError String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@ -797,6 +800,7 @@ model CmppConnectionState {
@@index([tenantId, status])
@@index([applicationId, status])
@@index([channelId, status])
@@index([status, nextReconnectAt])
}
model CmppDownstreamConnection {
@@ -1436,7 +1440,9 @@ model DailyReconciliationReport {
tenantName String
applicationId String
applicationName String
submittedUnits Int @default(0)
sentUnits Int @default(0)
unknownUnits Int @default(0)
successUnits Int @default(0)
failedUnits Int @default(0)
generatedAt DateTime @default(now())
@@ -1458,7 +1464,9 @@ model DailyProfitReport {
tenantName String?
applicationId String?
channelId String?
submittedUnits Int @default(0)
sentUnits Int @default(0)
unknownUnits Int @default(0)
successUnits Int @default(0)
failedUnits Int @default(0)
revenueCents BigInt @default(0)
@@ -1488,7 +1496,9 @@ model DailyQualityReport {
channelId String?
signatureId String?
drainageInfoId String?
submittedUnits Int @default(0)
sentUnits Int @default(0)
unknownUnits Int @default(0)
successUnits Int @default(0)
failedUnits Int @default(0)
successRateBps Int @default(0)
+234 -6
View File
@@ -1,6 +1,7 @@
import { ChannelsService } from './channels.service';
const mockQueueAdd = jest.fn().mockResolvedValue(undefined);
const mockJobRemove = jest.fn().mockResolvedValue(undefined);
const mockQueueAdd = jest.fn().mockImplementation(async () => ({ id: 'job-1', remove: mockJobRemove }));
const mockQueueClose = jest.fn().mockResolvedValue(undefined);
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
@@ -8,6 +9,8 @@ const mockFetch = jest.fn().mockResolvedValue({
text: jest.fn().mockResolvedValue(''),
});
const mockRedisXadd = jest.fn().mockResolvedValue('1710000000000-0');
const mockRedisSet = jest.fn().mockResolvedValue('OK');
const mockRedisEval = jest.fn().mockResolvedValue(1);
const mockRedisDisconnect = jest.fn();
jest.mock('bullmq', () => ({
@@ -19,6 +22,8 @@ jest.mock('bullmq', () => ({
jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
xadd: mockRedisXadd,
set: mockRedisSet,
eval: mockRedisEval,
disconnect: mockRedisDisconnect,
})));
@@ -70,7 +75,7 @@ function createPrismaMock() {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data })),
},
channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: {
@@ -272,8 +277,11 @@ describe('ChannelsService', () => {
beforeEach(() => {
mockQueueAdd.mockClear();
mockQueueClose.mockClear();
mockJobRemove.mockClear();
mockFetch.mockClear();
mockRedisXadd.mockClear();
mockRedisSet.mockClear();
mockRedisEval.mockClear();
mockRedisDisconnect.mockClear();
global.fetch = mockFetch as never;
});
@@ -300,12 +308,60 @@ describe('ChannelsService', () => {
channelId: 'channel-1',
reason: 'gateway_restarted',
channel: expect.objectContaining({ rateLimitPerSecond: 100 }),
}), { jobId: 'channel-1:primary:connect' });
}), expect.objectContaining({
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
removeOnComplete: 1000,
removeOnFail: 1000,
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
body: expect.stringContaining('"reason":"gateway_restarted"'),
}));
});
it('uses the direct Gateway control path when the Redis marker queue is unavailable', async () => {
const prisma = createPrismaMock();
mockQueueAdd.mockRejectedValueOnce(new Error('redis unavailable'));
const service = new ChannelsService(prisma as never);
await expect(service.createChannel({
code: 'CMPP-DIRECT',
name: '直连控制测试',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
status: 'active',
})).resolves.toEqual(expect.objectContaining({ id: 'channel-1' }));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST',
}));
});
it('reuses a supplier state created concurrently by another API instance', async () => {
const prisma = createPrismaMock();
prisma.cmppConnectionState.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'state-concurrent', applicationId: null });
prisma.cmppConnectionState.create.mockRejectedValueOnce({ code: 'P2002' });
const service = new ChannelsService(prisma as never);
await expect(service.createChannel({
code: 'CMPP-CONCURRENT',
name: '并发状态测试',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
status: 'active',
})).resolves.toEqual(expect.objectContaining({ id: 'channel-1' }));
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
where: { id: 'state-concurrent' },
data: expect.objectContaining({ status: 'connecting' }),
});
});
it('creates CMPP channels and route rules with first-version defaults', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
@@ -333,7 +389,7 @@ describe('ChannelsService', () => {
rateLimitPerSecond: 750,
sendRegion: '全国',
status: 'active',
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4 }),
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4, serviceId: 'SMS' }),
}),
});
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
@@ -351,7 +407,11 @@ describe('ChannelsService', () => {
connectionId: 'channel-1:primary',
reason: 'channel_created',
channel: expect.objectContaining({ cmppVersion: '2.0' }),
}), { jobId: 'channel-1:primary:connect' });
}), expect.objectContaining({
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
removeOnComplete: 1000,
removeOnFail: 1000,
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"messageType":"ConnectChannel"'),
@@ -372,6 +432,29 @@ describe('ChannelsService', () => {
});
});
it('defaults new channels to port 7890, CMPP and SMS while rejecting protocol overrides', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createChannel({
code: 'CMPP-DEFAULT',
name: '默认通道',
gatewayHost: '127.0.0.1',
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
protocol: 'HTTP',
});
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
data: expect.objectContaining({
gatewayPort: 7890,
protocol: 'CMPP',
config: expect.objectContaining({ serviceId: 'SMS' }),
}),
});
});
it('preserves explicit CMPP 3.0 and rejects unsupported CMPP versions', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
@@ -417,6 +500,7 @@ describe('ChannelsService', () => {
await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000');
await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20');
await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow('serviceId must contain 1 to 10 ASCII characters');
});
it('updates CMPP channel configuration without requiring password changes', async () => {
@@ -730,11 +814,155 @@ describe('ChannelsService', () => {
messageType: 'ConnectChannel',
channelId: 'channel-1',
reason: 'channel_enabled',
}), { jobId: 'channel-1:primary:connect' });
}), expect.objectContaining({
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
removeOnComplete: 1000,
removeOnFail: 1000,
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"reason":"channel_enabled"'),
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"reason":"channel_disabled"'),
}));
});
it('reconnects active failed channels and disconnects inactive live channels during reconciliation', async () => {
const prisma = createPrismaMock();
prisma.smsChannel.findMany.mockResolvedValue([
{
...(await prisma.smsChannel.findUnique()),
id: 'channel-active',
status: 'active',
connectionStates: [{
id: 'state-active',
connectionId: 'channel-active:primary',
status: 'failed',
currentConnections: 0,
desiredConnections: 1,
lastHeartbeatAt: null,
nextReconnectAt: new Date('2026-07-23T10:00:00.000Z'),
}],
},
{
...(await prisma.smsChannel.findUnique()),
id: 'channel-disabled',
status: 'disabled',
connectionStates: [{
id: 'state-disabled',
connectionId: 'channel-disabled:primary',
status: 'connected',
currentConnections: 1,
desiredConnections: 1,
lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'),
nextReconnectAt: null,
}],
},
]);
const service = new ChannelsService(prisma as never);
const result = await service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'));
expect(result).toEqual({ scanned: 2, reconnectRequested: 1, disconnectRequested: 1 });
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
body: expect.stringContaining('"reason":"automatic_reconnect"'),
}));
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({
body: expect.stringContaining('"reason":"inactive_channel_reconcile"'),
}));
expect(mockRedisSet).toHaveBeenCalledTimes(2);
expect(mockRedisEval).toHaveBeenCalledTimes(2);
});
it('does not reconnect a fresh healthy supplier connection', async () => {
const prisma = createPrismaMock();
prisma.smsChannel.findMany.mockResolvedValue([{
...(await prisma.smsChannel.findUnique()),
status: 'active',
config: { desiredConnections: 1, heartbeatIntervalSeconds: 30, heartbeatMissThreshold: 3 },
connectionStates: [{
connectionId: 'channel-1:primary',
status: 'connected',
currentConnections: 1,
desiredConnections: 1,
lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'),
nextReconnectAt: null,
}],
}]);
const service = new ChannelsService(prisma as never);
await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')))
.resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 });
expect(mockFetch).not.toHaveBeenCalled();
expect(mockRedisSet).not.toHaveBeenCalled();
});
it('skips duplicate reconciliation when another API instance owns the Redis lease', async () => {
const prisma = createPrismaMock();
prisma.smsChannel.findMany.mockResolvedValue([{
...(await prisma.smsChannel.findUnique()),
status: 'active',
connectionStates: [],
}]);
mockRedisSet.mockResolvedValueOnce(null);
const service = new ChannelsService(prisma as never);
await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')))
.resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 });
expect(mockFetch).not.toHaveBeenCalled();
expect(mockRedisEval).not.toHaveBeenCalled();
});
it('stores supplier heartbeat as a connected state and heartbeat audit event', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.upsertConnectionState({
channelId: 'channel-1',
connectionId: 'channel-1:primary',
status: 'heartbeat',
currentConnections: 1,
lastHeartbeatAt: '2026-07-23T11:00:00.000Z',
});
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
data: expect.objectContaining({
status: 'connected',
lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'),
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'cmpp_connection.heartbeat' }),
});
});
it('updates every supplier heartbeat without flooding operation logs', async () => {
const prisma = createPrismaMock();
prisma.cmppConnectionState.findFirst.mockResolvedValue({
id: 'state-1',
applicationId: null,
lastHeartbeatAt: new Date('2026-07-23T10:59:30.000Z'),
});
const service = new ChannelsService(prisma as never);
await service.upsertConnectionState({
channelId: 'channel-1',
connectionId: 'channel-1:primary',
status: 'heartbeat',
currentConnections: 1,
lastHeartbeatAt: '2026-07-23T11:00:00.000Z',
});
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
where: { id: 'state-1' },
data: expect.objectContaining({
status: 'connected',
lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'),
}),
});
expect(prisma.operationLog.create).not.toHaveBeenCalled();
});
it('copies channels with report field configuration and report materials', async () => {
+343 -19
View File
@@ -13,7 +13,7 @@ export interface CreateChannelDto {
sendRegion?: string;
protocol?: string;
gatewayHost: string;
gatewayPort: number;
gatewayPort?: number;
enterpriseCode?: string;
account: string;
passwordCipher: string;
@@ -24,6 +24,8 @@ export interface CreateChannelDto {
status?: string;
desiredConnections?: number;
windowSize?: number;
heartbeatIntervalSeconds?: number;
heartbeatMissThreshold?: number;
config?: Record<string, unknown>;
}
@@ -151,6 +153,9 @@ export interface UpsertConnectionStateDto {
lastDisconnectedAt?: string;
lastHeartbeatAt?: string;
reconnectCount?: number;
lastReconnectAttemptAt?: string;
nextReconnectAt?: string;
lastErrorCategory?: string;
lastError?: string;
}
@@ -182,6 +187,11 @@ const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000;
const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000;
const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000;
const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3;
const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000;
const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
const DEFAULT_CMPP_VERSION = '2.0';
@@ -193,6 +203,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
private redis?: IORedis;
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
private gatewayStartupReconnectTimer?: ReturnType<typeof setTimeout>;
private gatewayReconcileTimer?: ReturnType<typeof setInterval>;
constructor(private readonly prisma: PrismaService) {}
@@ -209,6 +220,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
void this.reconnectActiveChannelsAfterGatewayRestart();
}, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS));
this.gatewayStartupReconnectTimer.unref?.();
if (process.env.GATEWAY_CONNECTION_RECONCILER_DISABLED !== 'true') {
this.gatewayReconcileTimer = setInterval(() => {
void this.reconcileGatewayConnections().catch((error) => {
this.logger.error(`Failed to reconcile supplier connections: ${error instanceof Error ? error.message : String(error)}`);
});
}, getPositiveIntegerEnv('GATEWAY_CONNECTION_RECONCILE_INTERVAL_MS', DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS));
this.gatewayReconcileTimer.unref?.();
}
}
async onModuleDestroy() {
@@ -218,6 +237,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
if (this.gatewayStartupReconnectTimer) {
clearTimeout(this.gatewayStartupReconnectTimer);
}
if (this.gatewayReconcileTimer) {
clearInterval(this.gatewayReconcileTimer);
}
await this.gatewayConnectionQueue?.close();
await this.gatewaySubmitQueue?.close();
this.redis?.disconnect();
@@ -232,19 +254,26 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async createChannel(data: CreateChannelDto) {
assertMoneyUnits(data.unitPrice ?? 0, '通道单价');
const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => {
const value = data[field as keyof CreateChannelDto];
return value === undefined || value === null || value === '';
});
if (missingFields.length > 0) {
throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`);
}
const gatewayPort = Number(data.gatewayPort);
const gatewayPort = Number(data.gatewayPort ?? 7890);
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const cmppVersion = normalizeCmppVersion(data.cmppVersion);
const config = normalizeChannelRuntimeConfig(undefined, data.config, data.desiredConnections, data.windowSize);
const config = normalizeChannelRuntimeConfig(
undefined,
data.config,
data.desiredConnections,
data.windowSize,
data.heartbeatIntervalSeconds,
data.heartbeatMissThreshold,
);
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
const channel = await this.prisma.smsChannel.create({
data: {
@@ -252,7 +281,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
name: data.name,
carrier: data.carrier,
sendRegion: data.sendRegion ?? '全国',
protocol: data.protocol ?? 'CMPP',
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
@@ -285,8 +314,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined
? normalizeChannelRuntimeConfig(channel.config, data.config, data.desiredConnections, data.windowSize)
const config = data.config !== undefined
|| data.desiredConnections !== undefined
|| data.windowSize !== undefined
|| data.heartbeatIntervalSeconds !== undefined
|| data.heartbeatMissThreshold !== undefined
? normalizeChannelRuntimeConfig(
channel.config,
data.config,
data.desiredConnections,
data.windowSize,
data.heartbeatIntervalSeconds,
data.heartbeatMissThreshold,
)
: undefined;
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined
@@ -298,7 +338,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
name: data.name,
carrier: data.carrier,
sendRegion: data.sendRegion,
protocol: data.protocol,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
@@ -334,6 +374,26 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
} as Prisma.InputJsonValue,
},
});
const connectionConfigChanged = [
'gatewayHost',
'gatewayPort',
'account',
'passwordCipher',
'cmppVersion',
'rateLimitPerSecond',
'desiredConnections',
'windowSize',
'heartbeatIntervalSeconds',
'heartbeatMissThreshold',
].some((key) => data[key as keyof UpdateChannelDto] !== undefined)
|| Boolean(data.config && ['desiredConnections', 'windowSize', 'heartbeatIntervalSeconds', 'heartbeatMissThreshold']
.some((key) => key in data.config!));
const updatedStatus = data.status ?? channel.status;
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
await this.requestChannelConnection(updated, 'channel_updated');
} else if (updatedStatus !== 'active' && channel.status === 'active') {
await this.requestChannelDisconnection(updated, 'channel_disabled');
}
return updated;
}
@@ -358,6 +418,12 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
});
if (data.status === 'active') {
await this.requestChannelConnection(updated, 'channel_enabled', data.operatorId);
} else if (channel.status === 'active' || data.status === 'deleted') {
await this.requestChannelDisconnection(
updated,
data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled',
data.operatorId,
);
}
return updated;
}
@@ -615,7 +681,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}
async upsertConnectionState(data: UpsertConnectionStateDto) {
const status = normalizeGatewayConnectionStatus(data.status);
const rawStatus = data.status;
const status = normalizeGatewayConnectionStatus(rawStatus);
if (data.applicationId) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
if (!application) {
@@ -636,6 +703,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
reconnectCount: data.reconnectCount ?? 0,
lastReconnectAttemptAt: data.lastReconnectAttemptAt ? new Date(data.lastReconnectAttemptAt) : undefined,
nextReconnectAt: data.nextReconnectAt ? new Date(data.nextReconnectAt) : status === 'connected' ? null : undefined,
lastErrorCategory: status === 'connected' ? null : data.lastErrorCategory,
lastError: status === 'connected' ? null : data.lastError,
};
const existing = await this.prisma.cmppConnectionState.findFirst({
@@ -645,19 +715,47 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
connectionId: data.connectionId,
},
});
const state = existing
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload })
: await this.prisma.cmppConnectionState.create({
let state;
if (existing) {
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload });
} else {
try {
state = await this.prisma.cmppConnectionState.create({
data: {
channelId: data.channelId,
connectionId: data.connectionId,
...payload,
},
});
} catch (error) {
if ((error as { code?: string }).code !== 'P2002') {
throw error;
}
const concurrent = await this.prisma.cmppConnectionState.findFirst({
where: {
applicationId: data.applicationId ?? null,
channelId: data.channelId,
connectionId: data.connectionId,
},
});
if (!concurrent) {
throw error;
}
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data: payload });
}
}
const action = normalizeConnectionAction(
['heartbeat', 'active_test'].includes(rawStatus.toLowerCase()) ? rawStatus : status,
);
const heartbeatObservedAt = data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : new Date();
const shouldWriteAudit = action !== 'heartbeat'
|| !existing?.lastHeartbeatAt
|| heartbeatObservedAt.getTime() - existing.lastHeartbeatAt.getTime() >= HEARTBEAT_AUDIT_INTERVAL_MS;
if (shouldWriteAudit) {
await this.prisma.operationLog.create({
data: {
tenantId: data.tenantId,
action: `cmpp_connection.${normalizeConnectionAction(status)}`,
action: `cmpp_connection.${action}`,
resource: 'cmpp_connection',
resourceId: `${data.channelId}:${data.connectionId}`,
detail: {
@@ -669,6 +767,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
} as Prisma.InputJsonValue,
},
});
}
return state;
}
@@ -705,6 +804,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
status: 'failed',
currentConnections: 0,
lastDisconnectedAt: now,
nextReconnectAt: now,
lastErrorCategory: 'timeout',
lastError,
},
});
@@ -1245,7 +1346,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
rateLimitPerSecond: number;
config?: Prisma.JsonValue | null;
},
reason: 'channel_created' | 'channel_enabled' | 'gateway_restarted',
reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect',
operatorId?: string,
) {
const desiredConnections = getDesiredConnections(channel.config);
@@ -1263,16 +1364,34 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
desiredConnections,
currentConnections: 0,
lastError: null,
lastReconnectAttemptAt: new Date(),
nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)),
};
const state = existing
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data })
: await this.prisma.cmppConnectionState.create({
let state;
if (existing) {
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data });
} else {
try {
state = await this.prisma.cmppConnectionState.create({
data: {
channelId: channel.id,
connectionId,
...data,
},
});
} catch (error) {
if ((error as { code?: string }).code !== 'P2002') {
throw error;
}
const concurrent = await this.prisma.cmppConnectionState.findFirst({
where: { applicationId: null, channelId: channel.id, connectionId },
});
if (!concurrent) {
throw error;
}
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data });
}
}
await this.prisma.operationLog.create({
data: {
userId: operatorId,
@@ -1306,10 +1425,36 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
srcId: channel.srcId,
cmppVersion: channel.cmppVersion,
rateLimitPerSecond: channel.rateLimitPerSecond,
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
'heartbeatIntervalSeconds',
),
heartbeatMissThreshold: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatMissThreshold'),
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
),
},
};
await this.getGatewayConnectionQueue().add('connect-channel', command, { jobId: `${connectionId}:connect` });
const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, {
jobId: `gateway-connect-${channel.id}-${command.traceId}`,
removeOnComplete: 1000,
removeOnFail: 1000,
}).catch((error) => {
this.logger.warn(`Gateway connect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
});
try {
await this.notifyGatewayConnect(command);
} finally {
if (queuedJob) {
await queuedJob.remove().catch((error) => {
this.logger.warn(`Failed to remove delivered Gateway connect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
});
}
}
return state;
}
@@ -1328,6 +1473,134 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
});
}
async reconcileGatewayConnections(now = new Date()) {
const channels = await this.prisma.smsChannel.findMany({
where: { status: { in: ['active', 'disabled', 'deleted'] } },
include: {
connectionStates: {
where: { applicationId: null },
},
},
take: 200,
});
let reconnectRequested = 0;
let disconnectRequested = 0;
for (const channel of channels) {
const state = channel.connectionStates.find((item) => item.connectionId === defaultChannelConnectionId(channel.id));
if (channel.status !== 'active') {
if (state && (state.currentConnections > 0 || ['connected', 'connecting', 'reconnecting'].includes(state.status))) {
await this.withGatewayReconcileLock(channel.id, async () => {
await this.requestChannelDisconnection(channel, 'inactive_channel_reconcile');
disconnectRequested++;
});
}
continue;
}
const desiredConnections = getDesiredConnections(channel.config);
const heartbeatIntervalSeconds = getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
'heartbeatIntervalSeconds',
);
const heartbeatMissThreshold = getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatMissThreshold'),
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
);
const heartbeatCutoff = new Date(now.getTime() - heartbeatIntervalSeconds * (heartbeatMissThreshold + 1) * 1000);
const connectedAndFresh = state?.status === 'connected'
&& state.currentConnections >= desiredConnections
&& Boolean(state.lastHeartbeatAt && state.lastHeartbeatAt > heartbeatCutoff);
const retryDue = !state?.nextReconnectAt || state.nextReconnectAt <= now;
if (!connectedAndFresh && retryDue) {
await this.withGatewayReconcileLock(channel.id, async () => {
await this.requestChannelConnection(channel, 'automatic_reconnect');
reconnectRequested++;
});
}
}
return { scanned: channels.length, reconnectRequested, disconnectRequested };
}
private async withGatewayReconcileLock(channelId: string, action: () => Promise<void>) {
const redis = this.getRedis();
const key = `cmpp:gateway:reconcile:${channelId}`;
const token = randomUUID();
const acquired = await redis.set(key, token, 'PX', DEFAULT_CONNECTING_TIMEOUT_MS, 'NX');
if (acquired !== 'OK') {
return;
}
try {
await action();
} finally {
await redis.eval(
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
1,
key,
token,
);
}
}
private async requestChannelDisconnection(
channel: { id: string },
reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile',
operatorId?: string,
) {
const connectionId = defaultChannelConnectionId(channel.id);
const now = new Date();
await this.prisma.cmppConnectionState.updateMany({
where: {
applicationId: null,
channelId: channel.id,
connectionId,
},
data: {
status: 'disconnected',
currentConnections: 0,
lastDisconnectedAt: now,
nextReconnectAt: null,
lastErrorCategory: null,
lastError: null,
},
});
await this.prisma.operationLog.create({
data: {
userId: operatorId,
action: 'cmpp_connection.disconnect_requested',
resource: 'cmpp_connection',
resourceId: `${channel.id}:${connectionId}`,
detail: { reason } as Prisma.InputJsonValue,
},
});
const command = {
schemaVersion: 'v1',
messageType: 'DisconnectChannel',
traceId: randomUUID(),
channelId: channel.id,
connectionId,
createdAt: now.toISOString(),
reason,
};
const queuedJob = await this.getGatewayConnectionQueue().add('disconnect-channel', command, {
jobId: `gateway-disconnect-${channel.id}-${command.traceId}`,
removeOnComplete: 1000,
removeOnFail: 1000,
}).catch((error) => {
this.logger.warn(`Gateway disconnect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
});
try {
await this.notifyGatewayDisconnect(command);
} finally {
if (queuedJob) {
await queuedJob.remove().catch((error) => {
this.logger.warn(`Failed to remove delivered Gateway disconnect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
});
}
}
}
private getGatewayConnectionQueue() {
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
return this.gatewayConnectionQueue;
@@ -1366,6 +1639,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
});
} catch (error) {
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -1375,6 +1649,25 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`);
}
}
private async notifyGatewayDisconnect(command: Record<string, unknown>) {
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
let response: { ok: boolean; status: number; text: () => Promise<string> };
try {
response = await fetch(`${baseUrl}/connections/disconnect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
});
} catch (error) {
throw new BadRequestException(`Gateway disconnect request failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (!response.ok) {
const responseText = await response.text();
throw new BadRequestException(`Gateway disconnect request failed: ${response.status} ${responseText}`);
}
}
}
function normalizeTestPhones(data: TestChannelDto) {
@@ -1481,6 +1774,16 @@ function buildChannelTestSubmitCommand({
cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
'heartbeatIntervalSeconds',
),
heartbeatMissThreshold: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatMissThreshold'),
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
),
},
retry: { attempt: 0, maxAttempts: 1 },
};
@@ -1531,7 +1834,7 @@ function normalizeCmppVersion(version?: string) {
function normalizeGatewayConnectionStatus(status: string) {
const normalized = status.toLowerCase();
if (['online', 'open', 'connected'].includes(normalized)) {
if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) {
return 'connected';
}
if (['connecting', 'connect_requested'].includes(normalized)) {
@@ -1568,6 +1871,8 @@ function normalizeChannelRuntimeConfig(
incomingConfig?: Record<string, unknown> | null,
desiredConnections?: number,
windowSize?: number,
heartbeatIntervalSeconds?: number,
heartbeatMissThreshold?: number,
) {
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? existingConfig as Record<string, unknown>
@@ -1578,10 +1883,29 @@ function normalizeChannelRuntimeConfig(
const base = { ...existing, ...incoming };
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
'heartbeatIntervalSeconds',
);
base.heartbeatMissThreshold = getPositiveRuntimeInteger(
heartbeatMissThreshold ?? base.heartbeatMissThreshold,
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
);
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
base.serviceId = normalizeCmppServiceId(base.serviceId);
return base;
}
function normalizeCmppServiceId(value: unknown) {
const normalized = String(value ?? 'SMS').trim() || 'SMS';
if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) {
throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters');
}
return normalized;
}
function normalizeChannelRateLimit(value: unknown) {
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
if (normalized > 2000) {
@@ -145,6 +145,25 @@ describe('DictionariesService', () => {
}));
});
it('excludes soft-deleted security entries even when no status or deleted is requested', async () => {
const prisma = createPrismaMock();
const service = new DictionariesService(prisma as never);
await service.listSensitiveWords();
await service.listGlobalBlacklist({ status: 'deleted' });
await service.listEnterpriseBlacklist();
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: { not: 'deleted' } }),
}));
expect(prisma.globalBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: { not: 'deleted' } }),
}));
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: { not: 'deleted' } }),
}));
});
it('paginates carrier rules with a real database count', async () => {
const prisma = createPrismaMock();
prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]);
+7 -3
View File
@@ -9,6 +9,10 @@ export interface CreatePhoneSegmentDto {
city?: string;
}
function visibleDictionaryStatus(status?: string): string | Prisma.StringFilter {
return status && status !== 'all' && status !== 'deleted' ? status : { not: 'deleted' };
}
export interface PhoneSegmentListQuery {
keyword?: string;
page?: number;
@@ -152,7 +156,7 @@ export class DictionariesService {
listSensitiveWords(query: DictionaryListQuery = {}) {
return this.prisma.sensitiveWord.findMany({
where: {
status: query.status && query.status !== 'all' ? query.status : undefined,
status: visibleDictionaryStatus(query.status),
OR: query.keyword ? [
{ word: { contains: query.keyword } },
{ level: { contains: query.keyword } },
@@ -184,7 +188,7 @@ export class DictionariesService {
listGlobalBlacklist(query: DictionaryListQuery = {}) {
return this.prisma.globalBlacklist.findMany({
where: {
status: query.status && query.status !== 'all' ? query.status : undefined,
status: visibleDictionaryStatus(query.status),
OR: query.keyword ? [
{ phoneNumber: { contains: query.keyword } },
{ reason: { contains: query.keyword } },
@@ -221,7 +225,7 @@ export class DictionariesService {
where: {
tenantId: query.tenantId,
applicationId: query.applicationId,
status: query.status && query.status !== 'all' ? query.status : undefined,
status: visibleDictionaryStatus(query.status),
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
+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 () => {
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{
id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A',
sentUnits: 12, successUnits: 10, generatedAt: new Date('2026-07-15T00:00:00Z'),
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, generatedAt: new Date('2026-07-15T00:00:00Z'),
}]);
const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' });
expect(exported.fileName).toContain('对账单-');
expect(exported.content).toContain('"示例,企业"');
expect(exported.content).toContain('提交条数');
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }),
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
+53 -15
View File
@@ -74,19 +74,19 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async exportReconciliation(query: ReportListQuery) {
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] });
return csvExport('对账单', ['发送日期', '企业', '企业应用', '发送条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.sentUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)]));
return csvExport('对账单', ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)]));
}
async exportProfit(query: ReportListQuery) {
const { dimensionType, where } = profitWhere(query);
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
}
async exportQuality(query: ReportListQuery) {
const { dimensionType, where } = qualityWhere(query);
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] });
return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '发送条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)]));
return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)]));
}
async refreshRollingWindow(now = new Date()) {
@@ -119,7 +119,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DailyReconciliationReport" (
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
"sentUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
)
SELECT
CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)),
@@ -129,8 +129,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
application.id,
application.name,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected'
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END), 0)::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
@@ -157,7 +164,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
"sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
"generatedAt", "updatedAt"
)
SELECT
@@ -171,8 +178,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
application.id,
NULL,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected'
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(billing.revenue), 0)::bigint,
COALESCE(SUM(billing.refund), 0)::bigint,
COALESCE(SUM(costs.cost), 0)::bigint,
@@ -202,7 +216,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
"sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
"generatedAt", "updatedAt"
)
SELECT
@@ -216,18 +230,30 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
NULL,
channel.id,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(message."billingUnits"), 0)::integer,
COALESCE(SUM(CASE WHEN NOT EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" IN ('delivered', 'undelivered')
) AND submit."submitStatus" NOT IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN EXISTS (
COALESCE(SUM(CASE WHEN NOT EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) AND (EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) OR submit."submitStatus" IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer,
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint,
COALESCE(SUM(submit."costAmountCents"), 0)::bigint,
@@ -280,8 +306,15 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
tenant.name AS tenant_name,
application.id AS application_id,
message."billingUnits" AS billing_units,
CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END AS sent_units,
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END AS success_units,
CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END AS failed_units,
CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END AS failed_units,
CASE WHEN COALESCE(message.status, '') <> 'rejected'
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END AS unknown_units,
CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms
@@ -299,7 +332,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
INSERT INTO "DailyQualityReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
"sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
)
SELECT
CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)),
@@ -314,9 +347,11 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
CASE WHEN ${dimensionTypeSql} = 'signature' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
CASE WHEN ${dimensionTypeSql} = 'drainage' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
SUM(base.billing_units)::integer,
SUM(base.sent_units)::integer,
SUM(base.unknown_units)::integer,
SUM(base.success_units)::integer,
SUM(base.failed_units)::integer,
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
CASE WHEN SUM(base.sent_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.sent_units))::integer END,
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
@@ -334,7 +369,8 @@ function qualityByChannelSql(day: BusinessDay) {
channel.name AS dimension_name,
message."billingUnits" AS billing_units,
CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_units,
CASE WHEN failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units,
CASE WHEN receipt."deliveredAt" IS NULL AND failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units,
CASE WHEN receipt."deliveredAt" IS NULL AND failed_receipt."failedAt" IS NULL THEN message."billingUnits" ELSE 0 END AS unknown_units,
CASE WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms
FROM "SmsSubmitRecord" submit
@@ -364,7 +400,7 @@ function qualityByChannelSql(day: BusinessDay) {
INSERT INTO "DailyQualityReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
"sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
)
SELECT
CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)),
@@ -374,6 +410,8 @@ function qualityByChannelSql(day: BusinessDay) {
MAX(base.dimension_name),
NULL, NULL, NULL, base.dimension_id, NULL, NULL,
SUM(base.billing_units)::integer,
SUM(base.billing_units)::integer,
SUM(base.unknown_units)::integer,
SUM(base.success_units)::integer,
SUM(base.failed_units)::integer,
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
+2
View File
@@ -2847,6 +2847,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1),
windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16),
heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30),
heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3),
},
retry: { attempt, maxAttempts: 1 },
};
@@ -324,6 +324,7 @@ describe('SmsConfigService', () => {
interfaceType: 'cmpp20',
queuePriority: 'priority',
dailyLimit: 100000,
maxPhonesPerTask: 10000,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
+1 -1
View File
@@ -388,7 +388,7 @@ export class SmsConfigService {
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
customerUnitPrice: data.customerUnitPrice ?? 0,
queuePriority,
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
maxPhonesPerTask: data.maxPhonesPerTask ?? 10000,
templateMismatchMode: data.templateMismatchMode ?? 'reject',
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
@@ -1643,3 +1643,22 @@
- 号码基础校验只判断空值、字符、长度、数量上限、重复及多号码完整性;号段识别用于运营商和地区快照及路由提示,未知号段不得据此拒绝,必须继续按全国或三网兼容通道处理。
- 预发布历史数据库仍为`SQL_ASCII`时,数据迁移不得使用可能把多字节UTF-8字符拆成单字节处理的正则字符类。中文签名修复必须同时校验主键和原始字节序列,并从迁移前备份恢复完整UTF-8;长期生产数据库必须规划迁移到`UTF8`编码。
- 首次开通HTTP接口时,后端默认开启单条发送、状态查询、回执回调、上行查询、上行回调和客户端自助密钥六项能力,回执/上行投递默认为HTTP Webhook。参数复制必须包含应用名称、AppID、六项能力、基础地址、文档、QPS、白名单和真实投递方式。
## 2026-07-23 供应商连接主动心跳与自动重连要求
1. 供应商出站CMPP通道只要状态为`active`,首次连接失败、运行中断链、心跳超时或Gateway重启后都必须持续自动恢复到`desiredConnections`;不得依赖下一条短信触发惰性重连。
2. Gateway必须主动向供应商发送`CMPP_ACTIVE_TEST`并按`Sequence_Id`确认`CMPP_ACTIVE_TEST_RESP`。默认30秒一次,连续3次未响应判定断链;间隔和阈值允许按通道配置。短信提交、回执响应和心跳写包必须串行,断链后旧读写循环必须退出。
3. 网络类失败按5秒、15秒、30秒、60秒、2分钟、5分钟逐级退避并增加抖动,达到上限后持续重试;鉴权或凭据类失败仍持续重试,但默认降为5分钟一次。重连成功后清零失败次数和下次重连时间。
4. API每30秒协调数据库期望状态与Gateway真实状态,以Redis租约避免多实例重复下发;连接数不足、心跳陈旧、失败且到期或状态缺失的活动通道需要重新下发连接意图。
5. 手动停用或逻辑删除通道必须向Gateway发送`DisconnectChannel`,关闭连接池、主动心跳和重连监督器;协调任务还必须清理数据库状态与通道状态不一致的存量连接。重新启用或修改地址、端口、账号、密码、版本、窗口、连接数、心跳配置时立即使用新配置连接。
6. `CmppConnectionState`必须记录重连次数、最近重连尝试、下次重连时间、错误分类和最近心跳。运营端允许配置心跳参数并展示重连次数、下次时间和最近错误。
7. 多API实例并发恢复同一供应商通道时,数据库必须以`channelId + connectionId`的部分唯一索引约束`applicationId IS NULL`状态行;唯一冲突复用并更新既有状态,不得生成重复连接状态。
## 2026-07-23 通道、应用、详单与报表口径补充
- 新建上游短信通道默认端口为 `7890`;当前仅支持 CMPP,管理端和 API 均不得接受 HTTP/SGIP 作为通道协议。
- 通道业务代码写入真实通道运行配置 `config.serviceId`,默认 `SMS`,限制为 1~10 个 ASCII 字符,并用于 Gateway CMPP `Service_Id`
- 新建企业应用的“每任务最大号码数”默认 `10000`,超过上限仍由后端整任务拒绝。
- 安全控制的企业黑名单、全局黑名单和敏感词接口不得返回逻辑删除记录。
- 所有日报表按 `SmsMessageRecord.billingUnits` 统计长短信分片条数,输出提交、发送、未知、成功、失败五项;平台拦截(`status=rejected`)计入提交但不计入发送,并保证 `发送=未知+成功+失败`
- 通道维度只存在已经路由到通道的记录,因此该维度的提交数等于发送数;应用、签名、引流信息和对账维度的提交数包含平台拦截。
- 二级添加、编辑页面必须继续高亮其所属侧边菜单。
+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-HTTP-PARAM-002 | 首次开通HTTP后查看并复制参数 | 六项能力默认开启,回执/上行为HTTP Webhook;复制文本含AppID和“客户端自助密钥”,与真实API/DB一致 |
| TC-HTTP-PARAM-003 | 升级前已开通HTTP且Webhook能力开启,投递模式仍为cmpp | migration将对应回执/上行模式回填为http,参数复制不再显示CMPP长连接 |
## 2026-07-23 供应商连接主动心跳与自动重连用例
| 用例ID | 场景 | 验收标准 |
| --- | --- | --- |
| TC-CMPP-UP-RECONNECT-001 | 首次连接时供应商端口不可达,随后恢复 | 首次状态为failed并记录错误/下次重连;无需人工操作即建立连接,currentConnections恢复到期望值 |
| TC-CMPP-UP-RECONNECT-002 | 已连接socket被供应商关闭 | Gateway结束旧读循环、唤醒在途提交、进入reconnecting并按退避重新登录,不发生nil客户端panic |
| TC-CMPP-UP-HEARTBEAT-001 | 空闲连接正常响应ACTIVE_TEST | 平台按通道间隔主动发送请求,按Sequence_Id清除待响应项并更新lastHeartbeatAt,短信和心跳包不交叉写坏 |
| TC-CMPP-UP-HEARTBEAT-002 | 连续心跳无响应 | 达到阈值后关闭旧连接、分类为heartbeat_timeout并自动重连;单次迟到或其他Sequence响应不能误清除全部待响应项 |
| TC-CMPP-UP-RECONNECT-003 | 鉴权失败 | 通道保持failed且5分钟慢速持续重试,不刷屏、不高频触发供应商锁定;修改正确凭据后立即重连 |
| TC-CMPP-UP-RECONNECT-004 | desiredConnections大于1且部分断开 | 只补足缺失连接,未恢复到期望值前显示reconnecting,不超过配置连接数 |
| TC-CMPP-UP-DISCONNECT-001 | 手动停用或删除通道 | API发送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 为最新。
- 5 条 active 上游通道在重启后的真实结果为 3 条 `connected/currentConnections=1``CH-1784797581833` 及其复制通道 `CH-1784797581833-COPY-MRXBBARL` 被上游明确返回 `connect response status: auth failed`。发布前数据库显示 5/5 connected 属于重启前状态,真实重连暴露了这两条通道的凭据/上游鉴权问题;本次长短信代码未修改上游通道鉴权,未擅自改密或停用通道,需由运营确认凭据后另行恢复。
- 外部首页、运营登录页、客户端登录页和 API health 均返回 HTTP 200,公网 `8.160.169.106:17890` TCP 连接成功。部署后 API/Gateway 未出现 Prisma、panic、fatal、Unhandled 或 Exception 程序错误,Nginx 仅有历史响应缓冲警告和本次正常重启 notice。未发送真实短信、未创建生产测试短信记录;由于目标测试正文的 `【深圳市合正物业服务有限公司】` 尚未配置为该应用的审核通过签名,仍需先完成签名/模板配置,再由用户进行真实企业 CMPP 长短信复测。
## 2026-07-23 供应商CMPP主动心跳与自动重连(本地未提交)
- 现状根因:活动通道仅在创建、启用和API启动时连接一次;Gateway在首次失败后删除连接池,运行中断链只上报状态且不重新拨号。供应商出站连接只响应对端`ACTIVE_TEST`,不主动检测静默半开连接。停用/删除只修改数据库状态,没有关闭Gateway连接;修改连接参数也不会立即替换旧池。
- Gateway新增每通道连接监督器、主动`ACTIVE_TEST`、按Sequence响应跟踪、连续未响应断链、网络退避和鉴权慢速重试。断链读循环立即退出,短信、回执响应和心跳共用串行写锁;连接监督器持续补足期望连接数。新增`DisconnectChannel`控制接口,停用/删除会取消监督器并关闭连接池。
- API新增30秒供应商连接协调任务,以Redis租约避免多实例重复下发;活动通道连接不足、心跳陈旧或失败到期时重新下发,非活动通道存在活动/连接中状态时强制断开。修改连接相关配置立即重建连接,BullMQ连接任务使用每次尝试唯一ID并保留受控历史。
- Prisma新增`lastReconnectAttemptAt``nextReconnectAt``lastErrorCategory``status + nextReconnectAt`索引,migration为`20260723220000_add_upstream_reconnect_schedule`;另以`20260723225000_enforce_supplier_connection_state_identity`清理潜在重复供应商状态并增加`applicationId IS NULL`部分唯一索引,P2002并发创建会复用既有状态。回滚需先停止新版API/Gateway,先删除部分唯一索引,再删除调度索引和三列后启动旧版本;回滚只丢失调度可观测字段,不影响短信、提交和回执记录。
- 运营端通道表单新增心跳间隔和失败阈值,连接日志展示重连次数和下次重连时间。默认30秒/3次,可按供应商通道覆盖。
- 当前验证:Gateway定向及`go test ./...`通过,并含真实CMPP服务端闭环“首次拒绝连接→端口恢复→自动重连→平台主动心跳收到响应”;合并并行工作区后API通道定向38项、API全量24 suites / 304项均以`--no-cache`通过,API TypeScript build、前端TypeScript/Vite build、Prisma generate/validate通过。本地升级前供应商重复状态组为0,两条自动重连migration均已应用;当前合并工作区66条migration全部应用且status最新。一次将`send-chain.service.spec.ts`与通道套件联合运行在184.8秒超时且无汇总,随后在Redis PONG环境取得明确全量通过汇总;Gateway race检测因本机CGO未启用而未执行。
- 本地真实协调验证使用临时通道贯通Prisma/PostgreSQL、Redis租约、BullMQ和HTTP控制请求,确认failed活动通道写为connecting并发送`ConnectChannel/automatic_reconnect`,改为disabled后写为disconnected/0连接并发送`DisconnectChannel/inactive_channel_reconcile`,Redis租约释放。首次执行还暴露BullMQ禁止含冒号的jobId,修正为合法唯一ID后复测通过。协调器同时命中两条既有本地陈旧活动通道;已依据验证前操作日志精确恢复二者为原`failed/0/connect_timeout`状态,删除3条本轮日志和2个本轮队列任务,临时通道、状态和日志也已清理。
- `npm run verify:phase8`中的Gateway和契约阶段通过,但BullMQ性能阶段两次分别为284.16和376.52 TPS,低于500 TPS门槛,因此完整phase8仍记为环境性能失败而非通过;本轮功能正确性不依赖降低该门槛。
- 本地前端预览可加载且无框架错误覆盖层,但访问运营端通道页因本地API未启动跳转登录页,验证码接口返回502;按安全规则未绕过验证码、未提交浏览器中已有凭据,因此新增心跳字段和连接日志的登录后页面交互验收仍未完成。预览进程及浏览器测试页已关闭。
- 本轮未提交、未push、未部署,也未修改预发布通道或发送真实短信。生产/预发布仍运行旧版本,不具备本节新增自动重连能力。
## 2026-07-23 通道、应用、详单与报表整改(未提交、未部署)
- 已实现:新建通道默认端口 7890、协议强制 CMPP、业务代码 `serviceId` 默认 SMS;短信测试弹窗白色顶部栏。
- 已实现:短信详情展示发送号码,分片审计改为响应式卡片;短信记录列表压缩间距并限制长内容为两行。
- 已实现:企业应用任务号码默认上限 10000;列表操作换行、客户连接状态改名、未开通参数按钮禁用并区分颜色、CMPP 参数弹窗移除冗余摘要框。
- 已实现:安全控制三个真实 API 默认和显式 deleted 查询均排除逻辑删除数据;添加/编辑二级路由保持所属菜单选中。
- 已实现:三张日报表及导出增加提交数、未知数,使用 `billingUnits` 统计长短信分片,并从发送数中排除平台拦截。
- 已实现:企业认证审核隐藏申请单号、短信审核隐藏任务编号、模板审核隐藏审核编号。
- 自动化验证:`prisma validate` 通过;相关 4 个 Jest 套件 108/108 通过;API build 通过;前端 TypeScript/Vite build 通过;`git diff --check` 通过。
- 真实本地链路验证:本地 PostgreSQL 已应用并核对全部 66 条 migration`prisma migrate status` 返回 schema up to date;使用真实登录/API 验证安全控制三个列表即使显式传入 `status=deleted` 也不返回已删除数据;重新生成 2026-06-30 至 2026-07-03 报表后,利润、对账、质量报表均满足 `发送数 = 未知数 + 成功数 + 失败数``提交数 >= 发送数`,三个真实报表 API 均返回新增字段。
- 浏览器验收:使用真实本地 API/PostgreSQL/Redis 页面完成 1440×900、1366×768、768×1024、390×844、375×667 验收。短信记录详情展示发送号码且弹窗无横向溢出;企业应用操作区换行、客户连接状态表头、接口参数按钮状态色及 CMPP 参数精简均生效;新增应用默认每任务 10000 个号码;三个报表新增提交数和未知数;三个审核页面不再展示内部编号;新增应用深链保持“企业应用”菜单选中。
- 验收边界:本地数据库暂无包含分片审计明细的短信记录,因此已验证真实空状态和各视口无横向溢出,分片卡片的有数据视觉仍需在存在真实分片记录的环境补验。当前修改保持未提交、未推送、未部署。
## 2026-07-24 合并发布前门禁与 TPS 隔离复测
- 本轮按用户授权合并供应商 CMPP 主动心跳/自动重连、通道与应用整改、详单与报表口径及全部并行工作区代码。`origin/main` 与本地基线提交 `2f781ebb8af656bdb2a0395fa742538961febf21` 一致,无远端新提交需要合并;`api/tsconfig.build.tsbuildinfo``outputs/` 按发布规则排除。
- 发布前 API 全量 24 suites / 304 tests、Gateway `go test ./...``go vet ./...`、API build、前端 TypeScript/Vite build、Prisma generate/validate/status、`git diff --check`均通过;本地 PostgreSQL 共 66 条 migration 且 schema up to date。
- 依赖审计发现新公告:前端 `react-router-dom/react-router 7.17.0` 存在中危开放重定向等问题,升级至 7.18.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 结果待部署后补记。
+76
View File
@@ -14,6 +14,7 @@ import (
)
type ConnectFunc func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error)
type DisconnectFunc func(context.Context, DisconnectChannelCommand) (ConnectionStateCallback, error)
type SubmitFunc func(context.Context, queue.SubmitCommand) (queue.SubmitResult, error)
type ConnectChannelCommand struct {
@@ -37,6 +38,19 @@ type ChannelConfig struct {
SrcID string `json:"srcId"`
CMPPVersion string `json:"cmppVersion"`
RateLimitPerSecond int `json:"rateLimitPerSecond"`
WindowSize int `json:"windowSize,omitempty"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"`
HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"`
}
type DisconnectChannelCommand struct {
SchemaVersion string `json:"schemaVersion"`
MessageType string `json:"messageType"`
TraceID string `json:"traceId"`
ChannelID string `json:"channelId"`
ConnectionID string `json:"connectionId"`
CreatedAt string `json:"createdAt"`
Reason string `json:"reason"`
}
type ConnectionStateCallback struct {
@@ -49,6 +63,9 @@ type ConnectionStateCallback struct {
LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"`
ReconnectCount int `json:"reconnectCount,omitempty"`
LastReconnectAttemptAt string `json:"lastReconnectAttemptAt,omitempty"`
NextReconnectAt string `json:"nextReconnectAt,omitempty"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
LastError string `json:"lastError,omitempty"`
}
@@ -56,6 +73,7 @@ type Server struct {
APIBaseURL string
HTTPClient *http.Client
Connect ConnectFunc
Disconnect DisconnectFunc
Submit SubmitFunc
Upstream *upstream.Manager
Limiter ratelimit.Limiter
@@ -78,10 +96,14 @@ func Register(mux *http.ServeMux, server Server) {
if server.Connect == nil {
server.Connect = server.connectChannel
}
if server.Disconnect == nil {
server.Disconnect = server.disconnectChannel
}
if server.Submit == nil {
server.Submit = server.Upstream.Submit
}
mux.HandleFunc("/connections/connect", server.handleConnectChannel)
mux.HandleFunc("/connections/disconnect", server.handleDisconnectChannel)
mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit)
mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt)
mux.HandleFunc("/downstream/uplink", server.handleDownstreamUplink)
@@ -122,6 +144,29 @@ func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(status)
}
func (s Server) handleDisconnectChannel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var command DisconnectChannelCommand
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
http.Error(w, fmt.Sprintf("invalid disconnect command: %v", err), http.StatusBadRequest)
return
}
if command.MessageType != string(queue.MessageTypeDisconnectChannel) || command.ChannelID == "" || command.ConnectionID == "" {
http.Error(w, "messageType DisconnectChannel, channelId and connectionId are required", http.StatusBadRequest)
return
}
status, err := s.Disconnect(r.Context(), command)
if err != nil {
http.Error(w, fmt.Sprintf("failed to disconnect upstream pool: %v", err), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
}
func (s Server) handleUpstreamSubmit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -274,6 +319,9 @@ func (s Server) connectChannel(ctx context.Context, command ConnectChannelComman
SrcID: command.Channel.SrcID,
CMPPVersion: command.Channel.CMPPVersion,
RateLimitPerSecond: command.Channel.RateLimitPerSecond,
WindowSize: command.Channel.WindowSize,
HeartbeatIntervalSeconds: command.Channel.HeartbeatIntervalSeconds,
HeartbeatMissThreshold: command.Channel.HeartbeatMissThreshold,
},
})
if err != nil {
@@ -289,6 +337,34 @@ func (s Server) connectChannel(ctx context.Context, command ConnectChannelComman
LastDisconnectedAt: state.LastDisconnectedAt,
LastHeartbeatAt: state.LastHeartbeatAt,
ReconnectCount: state.ReconnectCount,
LastReconnectAttemptAt: state.LastReconnectAttemptAt,
NextReconnectAt: state.NextReconnectAt,
LastErrorCategory: state.LastErrorCategory,
LastError: state.LastError,
}, nil
}
func (s Server) disconnectChannel(ctx context.Context, command DisconnectChannelCommand) (ConnectionStateCallback, error) {
state, err := s.Upstream.DisconnectChannel(ctx, queue.DisconnectChannelCommand{
SchemaVersion: command.SchemaVersion,
MessageType: queue.MessageTypeDisconnectChannel,
TraceID: command.TraceID,
ChannelID: command.ChannelID,
ConnectionID: command.ConnectionID,
CreatedAt: time.Now().UTC(),
Reason: command.Reason,
})
if err != nil {
return ConnectionStateCallback{}, err
}
return ConnectionStateCallback{
ChannelID: state.ChannelID,
ConnectionID: state.ConnectionID,
Status: state.Status,
DesiredConnections: state.DesiredConnections,
CurrentConnections: state.CurrentConnections,
LastDisconnectedAt: state.LastDisconnectedAt,
ReconnectCount: state.ReconnectCount,
LastError: state.LastError,
}, 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) {
handler := handlerWithServer(Server{
RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) {
+16
View File
@@ -12,6 +12,7 @@ const (
MessageTypeReceiptEvent MessageType = "ReceiptEvent"
MessageTypeUplinkEvent MessageType = "UplinkEvent"
MessageTypeConnectChannel MessageType = "ConnectChannel"
MessageTypeDisconnectChannel MessageType = "DisconnectChannel"
)
type Envelope struct {
@@ -67,6 +68,8 @@ type UpstreamConfig struct {
CMPPVersion string `json:"cmppVersion"`
DesiredConnections int `json:"desiredConnections,omitempty"`
WindowSize int `json:"windowSize,omitempty"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"`
HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"`
}
type Retry struct {
@@ -138,4 +141,17 @@ type ConnectChannelConfig struct {
SrcID string `json:"srcId"`
CMPPVersion string `json:"cmppVersion"`
RateLimitPerSecond int `json:"rateLimitPerSecond"`
WindowSize int `json:"windowSize,omitempty"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds,omitempty"`
HeartbeatMissThreshold int `json:"heartbeatMissThreshold,omitempty"`
}
type DisconnectChannelCommand struct {
SchemaVersion string `json:"schemaVersion"`
MessageType MessageType `json:"messageType"`
TraceID string `json:"traceId"`
ChannelID string `json:"channelId"`
ConnectionID string `json:"connectionId"`
CreatedAt time.Time `json:"createdAt"`
Reason string `json:"reason"`
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
)
func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
@@ -18,6 +19,8 @@ func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
reported = state
return nil
},
reconnectSignal: make(chan struct{}, 1),
stopCh: make(chan struct{}),
},
}
conn.pool.conns = []*connection{conn}
@@ -45,6 +48,60 @@ func TestHandleConnectionLossNotifiesPendingSubmitters(t *testing.T) {
if reported.Status != "disconnected" || reported.CurrentConnections != 0 {
t.Fatalf("unexpected reported state: %+v", reported)
}
if conn.pool.reconnectCount != 1 || conn.pool.nextReconnectAt.IsZero() {
t.Fatalf("expected connection loss to schedule reconnect, got count=%d next=%v", conn.pool.reconnectCount, conn.pool.nextReconnectAt)
}
conn.pool.close()
}
func TestHeartbeatTimeoutClosesConnectionAndSchedulesReconnect(t *testing.T) {
pool := &connectionPool{
channelID: "channel-1",
connectionID: "channel-1:primary",
config: normalizeUpstreamConfig(queueUpstreamConfigForTest()),
reconnectSignal: make(chan struct{}, 1),
stopCh: make(chan struct{}),
}
conn := &connection{
pool: pool,
pending: make(map[uint32]chan submitPartResponse),
heartbeatPending: map[uint32]time.Time{1: time.Now(), 2: time.Now(), 3: time.Now()},
}
pool.conns = []*connection{conn}
if conn.sendHeartbeat() {
t.Fatal("expected heartbeat timeout to stop heartbeat loop")
}
if !conn.closed {
t.Fatal("expected heartbeat timeout to close the connection")
}
if pool.reconnectCount != 1 || pool.lastErrorCategory != "heartbeat_timeout" {
t.Fatalf("unexpected reconnect state: count=%d category=%s", pool.reconnectCount, pool.lastErrorCategory)
}
pool.close()
}
func TestReconnectDelayUsesCappedBackoffAndSlowAuthenticationRetry(t *testing.T) {
if got := reconnectDelay(1, "network"); got < 4*time.Second || got > 6*time.Second {
t.Fatalf("first reconnect delay = %v", got)
}
if got := reconnectDelay(6, "network"); got < 4*time.Minute || got > 6*time.Minute {
t.Fatalf("capped reconnect delay = %v", got)
}
if got := reconnectDelay(1, "authentication"); got != 5*time.Minute {
t.Fatalf("authentication reconnect delay = %v", got)
}
}
func TestHeartbeatResponseClearsOnlyMatchingRequest(t *testing.T) {
conn := &connection{heartbeatPending: map[uint32]time.Time{7: time.Now(), 8: time.Now()}}
conn.handleHeartbeatResponse(7)
if _, exists := conn.heartbeatPending[7]; exists {
t.Fatal("expected matching heartbeat request to be cleared")
}
if _, exists := conn.heartbeatPending[8]; !exists {
t.Fatal("expected unrelated heartbeat request to remain pending")
}
}
func TestTemporaryReadTimeoutDetection(t *testing.T) {
+354 -15
View File
@@ -23,6 +23,11 @@ const (
defaultSubmitTimeout = 10 * time.Second
defaultHTTPTimeout = 10 * time.Second
defaultWindowSize = 16
defaultHeartbeatInterval = 30 * time.Second
defaultHeartbeatMissThreshold = 3
defaultReconnectInitialDelay = 5 * time.Second
defaultReconnectMaximumDelay = 5 * time.Minute
defaultAuthReconnectDelay = 5 * time.Minute
)
type Manager struct {
@@ -43,6 +48,9 @@ type ConnectionState struct {
LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"`
ReconnectCount int `json:"reconnectCount,omitempty"`
LastReconnectAttemptAt string `json:"lastReconnectAttemptAt,omitempty"`
NextReconnectAt string `json:"nextReconnectAt,omitempty"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
LastError string `json:"lastError,omitempty"`
}
@@ -92,7 +100,9 @@ func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChann
PasswordCipher: command.Channel.PasswordCipher,
CMPPVersion: command.Channel.CMPPVersion,
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 {
@@ -103,16 +113,47 @@ func (m *Manager) ConnectChannel(ctx context.Context, command queue.ConnectChann
}
m.mu.Unlock()
pool.startSupervisor()
if err := pool.ensureConnected(); err != nil {
if pool.stopped() {
return pool.snapshotState("disconnected", nil), nil
}
pool.scheduleReconnect(err)
_ = pool.reportState(ctx, "failed", err)
m.mu.Lock()
delete(m.conns, command.ChannelID)
m.mu.Unlock()
return pool.snapshotState("failed", err), nil
}
if pool.stopped() {
return pool.snapshotState("disconnected", nil), nil
}
pool.resetReconnectState()
return pool.snapshotState("connected", nil), nil
}
func (m *Manager) DisconnectChannel(ctx context.Context, command queue.DisconnectChannelCommand) (ConnectionState, error) {
if command.ChannelID == "" || command.ConnectionID == "" {
return ConnectionState{}, fmt.Errorf("channelId and connectionId are required")
}
m.mu.Lock()
m.ensureDefaultsLocked()
pool := m.conns[command.ChannelID]
delete(m.conns, command.ChannelID)
m.mu.Unlock()
if pool != nil {
pool.close()
state := pool.snapshotState("disconnected", nil)
_ = pool.reportState(ctx, "disconnected", nil)
return state, nil
}
return ConnectionState{
ChannelID: command.ChannelID,
ConnectionID: command.ConnectionID,
Status: "disconnected",
DesiredConnections: 0,
CurrentConnections: 0,
LastDisconnectedAt: time.Now().UTC().Format(time.RFC3339Nano),
}, nil
}
func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -127,10 +168,12 @@ func (m *Manager) connectionFor(cmd queue.SubmitCommand) (*connectionPool, error
pool = m.newConnectionPool(cmd.ChannelID, defaultChannelConnectionID(cmd.ChannelID), normalizeUpstreamConfig(cmd.Upstream))
m.conns[cmd.ChannelID] = pool
}
pool.startSupervisor()
if err := pool.ensureConnected(); err != nil {
delete(m.conns, cmd.ChannelID)
pool.scheduleReconnect(err)
return nil, err
}
pool.resetReconnectState()
return pool, nil
}
@@ -161,6 +204,8 @@ func (m *Manager) newConnectionPool(channelID string, connectionID string, confi
reporter: func(ctx context.Context, state ConnectionState) error {
return m.post(ctx, "/admin/gateway/connections", state)
},
reconnectSignal: make(chan struct{}, 1),
stopCh: make(chan struct{}),
}
}
@@ -173,8 +218,17 @@ type connectionPool struct {
reporter func(context.Context, ConnectionState) error
mu sync.Mutex
connectMu sync.Mutex
conns []*connection
next int
reconnectSignal chan struct{}
stopCh chan struct{}
stopOnce sync.Once
supervisorOnce sync.Once
reconnectCount int
lastReconnectAttemptAt time.Time
nextReconnectAt time.Time
lastErrorCategory string
}
func (p *connectionPool) matches(config queue.UpstreamConfig) bool {
@@ -182,6 +236,8 @@ func (p *connectionPool) matches(config queue.UpstreamConfig) bool {
}
func (p *connectionPool) ensureConnected() error {
p.connectMu.Lock()
defer p.connectMu.Unlock()
desired := p.config.DesiredConnections
if desired <= 0 {
desired = 1
@@ -189,6 +245,16 @@ func (p *connectionPool) ensureConnected() error {
connectedAny := false
for {
p.mu.Lock()
active := p.conns[:0]
for _, existing := range p.conns {
existing.mu.Lock()
usable := existing.client != nil && !existing.closed
existing.mu.Unlock()
if usable {
active = append(active, existing)
}
}
p.conns = active
if len(p.conns) >= desired {
p.mu.Unlock()
break
@@ -205,13 +271,12 @@ func (p *connectionPool) ensureConnected() error {
pending: make(map[uint32]chan submitPartResponse),
tracker: make(map[uint64]queue.SubmitCommand),
longUplink: make(map[string]*longUplinkAssembly),
heartbeatPending: make(map[uint32]time.Time),
}
p.mu.Unlock()
connected, err := conn.ensureConnected()
if err != nil {
conn.close()
p.close()
return err
}
@@ -226,6 +291,118 @@ func (p *connectionPool) ensureConnected() error {
return nil
}
func (p *connectionPool) startSupervisor() {
p.supervisorOnce.Do(func() {
go p.superviseReconnects()
})
}
func (p *connectionPool) stopped() bool {
select {
case <-p.stopCh:
return true
default:
return false
}
}
func (p *connectionPool) signalReconnect() {
select {
case <-p.stopCh:
return
default:
}
select {
case p.reconnectSignal <- struct{}{}:
default:
}
}
func (p *connectionPool) scheduleReconnect(stateErr error) {
select {
case <-p.stopCh:
return
default:
}
p.mu.Lock()
now := time.Now().UTC()
if !p.nextReconnectAt.IsZero() && p.nextReconnectAt.After(now) {
p.mu.Unlock()
return
}
p.reconnectCount++
p.lastReconnectAttemptAt = now
p.lastErrorCategory = connectionErrorCategory(stateErr)
delay := reconnectDelay(p.reconnectCount, p.lastErrorCategory)
p.nextReconnectAt = p.lastReconnectAttemptAt.Add(delay)
p.mu.Unlock()
p.signalReconnect()
}
func (p *connectionPool) resetReconnectState() {
p.mu.Lock()
p.reconnectCount = 0
p.lastReconnectAttemptAt = time.Time{}
p.nextReconnectAt = time.Time{}
p.lastErrorCategory = ""
p.mu.Unlock()
p.signalReconnect()
}
func (p *connectionPool) superviseReconnects() {
for {
select {
case <-p.stopCh:
return
case <-p.reconnectSignal:
}
for {
p.mu.Lock()
next := p.nextReconnectAt
p.mu.Unlock()
if next.IsZero() {
break
}
timer := time.NewTimer(time.Until(next))
select {
case <-p.stopCh:
if !timer.Stop() {
<-timer.C
}
return
case <-p.reconnectSignal:
if !timer.Stop() {
<-timer.C
}
continue
case <-timer.C:
}
_ = p.reportState(context.Background(), "reconnecting", nil)
p.mu.Lock()
p.lastReconnectAttemptAt = time.Now().UTC()
p.mu.Unlock()
if err := p.ensureConnected(); err != nil {
select {
case <-p.stopCh:
return
default:
}
p.scheduleReconnect(err)
_ = p.reportState(context.Background(), "failed", err)
continue
}
select {
case <-p.stopCh:
return
default:
}
p.resetReconnectState()
_ = p.reportState(context.Background(), "connected", nil)
break
}
}
}
func (p *connectionPool) submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, error) {
parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content)
if err != nil {
@@ -314,6 +491,11 @@ func (p *connectionPool) tryAcquireConnection() (*connection, func()) {
}
func (p *connectionPool) close() {
p.connectMu.Lock()
defer p.connectMu.Unlock()
p.stopOnce.Do(func() {
close(p.stopCh)
})
p.mu.Lock()
conns := p.conns
p.conns = nil
@@ -339,6 +521,16 @@ func (p *connectionPool) snapshotState(status string, stateErr error) Connection
DesiredConnections: p.config.DesiredConnections,
CurrentConnections: p.countActiveConnections(),
}
p.mu.Lock()
state.ReconnectCount = p.reconnectCount
state.LastErrorCategory = p.lastErrorCategory
if !p.lastReconnectAttemptAt.IsZero() {
state.LastReconnectAttemptAt = p.lastReconnectAttemptAt.Format(time.RFC3339Nano)
}
if !p.nextReconnectAt.IsZero() {
state.NextReconnectAt = p.nextReconnectAt.Format(time.RFC3339Nano)
}
p.mu.Unlock()
if state.DesiredConnections <= 0 {
state.DesiredConnections = 1
}
@@ -346,6 +538,8 @@ func (p *connectionPool) snapshotState(status string, stateErr error) Connection
case "connected":
state.LastConnectedAt = now
state.LastHeartbeatAt = now
case "heartbeat":
state.LastHeartbeatAt = now
case "disconnected", "failed":
state.LastDisconnectedAt = now
}
@@ -387,6 +581,8 @@ type connection struct {
longUplink map[string]*longUplinkAssembly
readOnce sync.Once
closed bool
heartbeatCancel context.CancelFunc
heartbeatPending map[uint32]time.Time
}
type submitPartResponse struct {
@@ -415,7 +611,11 @@ func (c *connection) ensureConnected() (bool, error) {
}
c.client = client
c.closed = false
c.heartbeatPending = make(map[uint32]time.Time)
heartbeatCtx, cancelHeartbeat := context.WithCancel(context.Background())
c.heartbeatCancel = cancelHeartbeat
go c.readLoop()
go c.heartbeatLoop(heartbeatCtx)
return true, nil
}
@@ -423,8 +623,17 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
rspCh := make(chan submitPartResponse, 1)
pkt := c.submitRequestPacket(cmd, part)
c.mu.Lock()
client := c.client
closed := c.closed
c.mu.Unlock()
if closed || client == nil {
err := fmt.Errorf("supplier connection is not available")
result := submitResult(cmd, 0, "", "timeout", "CONNECTION_LOST", err.Error())
return 0, "", result, err
}
c.sendMu.Lock()
seq, err := c.client.SendReqPkt(pkt)
seq, err := client.SendReqPkt(pkt)
c.sendMu.Unlock()
if err != nil {
c.close()
@@ -576,10 +785,17 @@ func (c *connection) releaseWindow() {
func (c *connection) readLoop() {
for {
pkt, err := c.client.RecvAndUnpackPkt(time.Second)
c.mu.Lock()
client := c.client
closed := c.closed
c.mu.Unlock()
if closed || client == nil {
return
}
pkt, err := client.RecvAndUnpackPkt(time.Second)
if err != nil {
c.mu.Lock()
closed := c.closed
closed = c.closed
c.mu.Unlock()
if closed {
return
@@ -588,7 +804,7 @@ func (c *connection) readLoop() {
continue
}
c.handleConnectionLoss(err)
continue
return
}
switch p := pkt.(type) {
case *cmpp.Cmpp2SubmitRspPkt:
@@ -606,17 +822,86 @@ func (c *connection) readLoop() {
ch <- submitPartResponse{seqID: p.SeqId, msgID: p.MsgId, result: p.Result}
}
case *cmpp.Cmpp2DeliverReqPkt:
_ = c.client.SendRspPkt(&cmpp.Cmpp2DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId)
_ = c.sendResponse(client, &cmpp.Cmpp2DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId)
c.handleDeliver(deliverPacketFromCMPP2(p))
case *cmpp.Cmpp3DeliverReqPkt:
_ = c.client.SendRspPkt(&cmpp.Cmpp3DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId)
_ = c.sendResponse(client, &cmpp.Cmpp3DeliverRspPkt{MsgId: p.MsgId, Result: 0}, p.SeqId)
c.handleDeliver(deliverPacketFromCMPP3(p))
case *cmpp.CmppActiveTestReqPkt:
_ = c.client.SendRspPkt(&cmpp.CmppActiveTestRspPkt{}, p.SeqId)
_ = c.sendResponse(client, &cmpp.CmppActiveTestRspPkt{}, p.SeqId)
_ = c.pool.reportState(context.Background(), "heartbeat", nil)
case *cmpp.CmppActiveTestRspPkt:
c.handleHeartbeatResponse(p.SeqId)
_ = c.pool.reportState(context.Background(), "heartbeat", nil)
}
}
}
func (c *connection) sendResponse(client *cmpp.Client, packet cmpp.Packer, sequenceID uint32) error {
c.sendMu.Lock()
defer c.sendMu.Unlock()
return client.SendRspPkt(packet, sequenceID)
}
func (c *connection) heartbeatLoop(ctx context.Context) {
interval := time.Duration(c.config.HeartbeatIntervalSeconds) * time.Second
if interval <= 0 {
interval = defaultHeartbeatInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !c.sendHeartbeat() {
return
}
}
}
}
func (c *connection) sendHeartbeat() bool {
threshold := c.config.HeartbeatMissThreshold
if threshold <= 0 {
threshold = defaultHeartbeatMissThreshold
}
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return false
}
if len(c.heartbeatPending) >= threshold {
c.mu.Unlock()
c.handleConnectionLoss(fmt.Errorf("heartbeat timeout after %d unanswered ACTIVE_TEST requests", threshold))
return false
}
if c.client == nil {
c.mu.Unlock()
return false
}
c.sendMu.Lock()
seq, err := c.client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{})
c.sendMu.Unlock()
if err != nil {
c.mu.Unlock()
c.handleConnectionLoss(fmt.Errorf("send ACTIVE_TEST: %w", err))
return false
}
if !c.closed {
c.heartbeatPending[seq] = time.Now().UTC()
}
c.mu.Unlock()
return true
}
func (c *connection) handleHeartbeatResponse(sequenceID uint32) {
c.mu.Lock()
delete(c.heartbeatPending, sequenceID)
c.mu.Unlock()
}
type deliverPacket struct {
seqID uint32
msgID uint64
@@ -757,8 +1042,13 @@ func (c *connection) handleConnectionLoss(err error) {
return
}
c.closed = true
if c.heartbeatCancel != nil {
c.heartbeatCancel()
c.heartbeatCancel = nil
}
pending := c.pending
c.pending = make(map[uint32]chan submitPartResponse)
c.heartbeatPending = make(map[uint32]time.Time)
if c.client != nil {
c.client.Disconnect()
c.client = nil
@@ -774,8 +1064,9 @@ func (c *connection) handleConnectionLoss(err error) {
if c.pool != nil {
status := "disconnected"
if c.pool.countActiveConnections() > 0 {
status = "connected"
status = "reconnecting"
}
c.pool.scheduleReconnect(err)
_ = c.pool.reportState(context.Background(), status, err)
}
}
@@ -864,9 +1155,57 @@ func normalizeUpstreamConfig(config queue.UpstreamConfig) queue.UpstreamConfig {
if config.WindowSize <= 0 {
config.WindowSize = defaultWindowSize
}
if config.HeartbeatIntervalSeconds <= 0 {
config.HeartbeatIntervalSeconds = int(defaultHeartbeatInterval / time.Second)
}
if config.HeartbeatMissThreshold <= 0 {
config.HeartbeatMissThreshold = defaultHeartbeatMissThreshold
}
return config
}
func connectionErrorCategory(err error) string {
if err == nil {
return ""
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "auth"), strings.Contains(message, "password"), strings.Contains(message, "credential"):
return "authentication"
case strings.Contains(message, "heartbeat"):
return "heartbeat_timeout"
case strings.Contains(message, "timeout"):
return "timeout"
default:
return "network"
}
}
func reconnectDelay(attempt int, category string) time.Duration {
if category == "authentication" {
return defaultAuthReconnectDelay
}
if attempt < 1 {
attempt = 1
}
delays := []time.Duration{
defaultReconnectInitialDelay,
15 * time.Second,
30 * time.Second,
time.Minute,
2 * time.Minute,
defaultReconnectMaximumDelay,
}
delay := delays[min(attempt-1, len(delays)-1)]
// Deterministic ±10% jitter prevents a large set of channels from retrying together.
offsetPercent := (attempt*37)%21 - 10
delay += time.Duration(int64(delay) * int64(offsetPercent) / 100)
if delay < time.Second {
return time.Second
}
return delay
}
func isTemporaryReadTimeout(err error) bool {
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
+36
View File
@@ -1,6 +1,7 @@
package upstream
import (
"context"
"testing"
"cmpp-platform/gateway/internal/queue"
@@ -39,6 +40,38 @@ func TestConnectionPoolAcquiresAcrossConnections(t *testing.T) {
releaseSecond()
}
func TestManagerDisconnectChannelRemovesPoolAndStopsReconnects(t *testing.T) {
pool := &connectionPool{
channelID: "channel-1",
connectionID: "channel-1:primary",
config: normalizeUpstreamConfig(queueUpstreamConfigForTest()),
reconnectSignal: make(chan struct{}, 1),
stopCh: make(chan struct{}),
}
manager := &Manager{conns: map[string]*connectionPool{"channel-1": pool}}
state, err := manager.DisconnectChannel(context.Background(), queue.DisconnectChannelCommand{
MessageType: queue.MessageTypeDisconnectChannel,
ChannelID: "channel-1",
ConnectionID: "channel-1:primary",
})
if err != nil {
t.Fatalf("disconnect channel: %v", err)
}
if state.Status != "disconnected" || state.CurrentConnections != 0 {
t.Fatalf("unexpected state: %+v", state)
}
if _, exists := manager.conns["channel-1"]; exists {
t.Fatal("expected channel pool to be removed")
}
select {
case <-pool.stopCh:
default:
t.Fatal("expected reconnect supervisor to be stopped")
}
}
func TestNormalizeUpstreamConfigDefaults(t *testing.T) {
config := normalizeUpstreamConfig(queueUpstreamConfigForTest())
if config.DesiredConnections != 1 {
@@ -47,6 +80,9 @@ func TestNormalizeUpstreamConfigDefaults(t *testing.T) {
if config.WindowSize != defaultWindowSize {
t.Fatalf("WindowSize = %d, want %d", config.WindowSize, defaultWindowSize)
}
if config.HeartbeatIntervalSeconds != 30 || config.HeartbeatMissThreshold != 3 {
t.Fatalf("unexpected heartbeat defaults: interval=%d threshold=%d", config.HeartbeatIntervalSeconds, config.HeartbeatMissThreshold)
}
}
func queueUpstreamConfigForTest() queue.UpstreamConfig {
@@ -0,0 +1,119 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"cmpp-platform/gateway/internal/queue"
cmpp "github.com/bigwhite/gocmpp"
)
func TestFailedSupplierConnectionReconnectsWhenEndpointRecovers(t *testing.T) {
address := reserveSupplierAddress(t)
states := make(chan ConnectionState, 16)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var state ConnectionState
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
states <- state
w.WriteHeader(http.StatusNoContent)
}))
defer api.Close()
host, portText, err := net.SplitHostPort(address)
if err != nil {
t.Fatalf("split address: %v", err)
}
var port int
if _, err := fmt.Sscanf(portText, "%d", &port); err != nil {
t.Fatalf("parse port: %v", err)
}
manager := &Manager{APIBaseURL: api.URL}
command := queue.ConnectChannelCommand{
SchemaVersion: queue.SchemaVersion,
MessageType: queue.MessageTypeConnectChannel,
ChannelID: "channel-reconnect",
ConnectionID: "channel-reconnect:primary",
DesiredConnections: 1,
Channel: queue.ConnectChannelConfig{
GatewayHost: host,
GatewayPort: port,
Account: "sp",
PasswordCipher: "secret",
CMPPVersion: "3.0",
HeartbeatIntervalSeconds: 1,
HeartbeatMissThreshold: 3,
},
}
initial, err := manager.ConnectChannel(context.Background(), command)
if err != nil {
t.Fatalf("initial connect command: %v", err)
}
if initial.Status != "failed" {
t.Fatalf("initial state = %s, want failed", initial.Status)
}
go func() {
_ = cmpp.ListenAndServe(address, cmpp.V30, time.Hour, 3, nil,
cmpp.HandlerFunc(func(response *cmpp.Response, packet *cmpp.Packet, _ *log.Logger) (bool, error) {
if _, ok := packet.Packer.(*cmpp.CmppConnReqPkt); ok {
response.Packer.(*cmpp.Cmpp3ConnRspPkt).Version = 0x30
}
return false, nil
}),
)
}()
time.Sleep(100 * time.Millisecond)
manager.mu.Lock()
pool := manager.conns[command.ChannelID]
manager.mu.Unlock()
pool.mu.Lock()
pool.nextReconnectAt = time.Now()
pool.mu.Unlock()
pool.signalReconnect()
deadline := time.After(4 * time.Second)
connected := false
for {
select {
case state := <-states:
if state.Status == "connected" && state.CurrentConnections == 1 {
connected = true
}
if connected && state.Status == "heartbeat" && state.LastHeartbeatAt != "" {
_, _ = manager.DisconnectChannel(context.Background(), queue.DisconnectChannelCommand{
MessageType: queue.MessageTypeDisconnectChannel,
ChannelID: command.ChannelID,
ConnectionID: command.ConnectionID,
})
return
}
case <-deadline:
t.Fatal("timed out waiting for automatic supplier reconnection and active heartbeat")
}
}
}
func reserveSupplierAddress(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve address: %v", err)
}
address := listener.Addr().String()
if err := listener.Close(); err != nil {
t.Fatalf("close reserved listener: %v", err)
}
return address
}
+8 -8
View File
@@ -16,7 +16,7 @@
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.17.0",
"react-router-dom": "^7.18.1",
"vite": "^8.0.16",
"zustand": "^5.0.14"
},
@@ -1168,9 +1168,9 @@
}
},
"node_modules/react-router": {
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -1190,12 +1190,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.17.0"
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
+1 -1
View File
@@ -34,7 +34,7 @@
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.17.0",
"react-router-dom": "^7.18.1",
"vite": "^8.0.16",
"zustand": "^5.0.14"
},
+12 -3
View File
@@ -186,7 +186,7 @@ export type AdminChannel = {
rateLimitPerSecond: number;
unitPrice: number;
status: string;
config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; [key: string]: unknown } | null;
config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; [key: string]: unknown } | null;
connectionStates?: CmppConnectionState[];
};
@@ -983,7 +983,9 @@ export type DailyReconciliationReport = {
tenantName: string;
applicationId: string;
applicationName: string;
submittedUnits: number;
sentUnits: number;
unknownUnits: number;
successUnits: number;
failedUnits: number;
generatedAt: string;
@@ -1000,7 +1002,9 @@ export type DailyProfitReport = {
tenantName?: string | null;
applicationId?: string | null;
channelId?: string | null;
submittedUnits: number;
sentUnits: number;
unknownUnits: number;
successUnits: number;
failedUnits: number;
revenueCents: number;
@@ -1024,7 +1028,9 @@ export type DailyQualityReport = {
channelId?: string | null;
signatureId?: string | null;
drainageInfoId?: string | null;
submittedUnits: number;
sentUnits: number;
unknownUnits: number;
successUnits: number;
failedUnits: number;
successRateBps: number;
@@ -1104,6 +1110,9 @@ export type CmppConnectionState = {
lastDisconnectedAt?: string | null;
lastHeartbeatAt?: string | null;
reconnectCount: number;
lastReconnectAttemptAt?: string | null;
nextReconnectAt?: string | null;
lastErrorCategory?: string | null;
lastError?: string | null;
updatedAt: string;
channel?: AdminChannel;
@@ -1403,9 +1412,9 @@ export const adminApi = {
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)),
exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
requestBlob(withQuery('/admin/reports/quality/export', query)),
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) =>
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) =>
request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
method: 'POST',
+25 -10
View File
@@ -25,12 +25,15 @@ type SmsChannel = {
failureCount: number;
gatewayHost: string;
gatewayPort: string;
businessCode: string;
corpCode: string;
account: string;
accessNo: string;
cmppVersion: '2.0' | '3.0';
desiredConnections: number;
windowSize: number;
heartbeatIntervalSeconds: number;
heartbeatMissThreshold: number;
extensionDigits: number;
rateLimitPerSecond: number;
passwordCipher?: string;
@@ -83,12 +86,6 @@ const statusOptions = [
{ label: '连接失败', value: 'failed' },
];
const protocolOptions = [
{ label: 'CMPP', value: 'CMPP' },
{ label: 'HTTP', value: 'HTTP' },
{ label: 'SGIP', value: 'SGIP' },
];
const cmppVersionOptions = [
{ label: 'CMPP 2.0', value: '2.0' },
{ label: 'CMPP 3.0', value: '3.0' },
@@ -161,12 +158,15 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
failureCount: 0,
gatewayHost: channel.gatewayHost,
gatewayPort: String(channel.gatewayPort),
businessCode: String(channel.config?.serviceId ?? 'SMS'),
corpCode: channel.enterpriseCode ?? channel.code,
account: channel.account,
accessNo: channel.srcId,
cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0',
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
windowSize: Number(channel.config?.windowSize ?? 16),
heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30),
heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3),
extensionDigits: Number(channel.config?.extensionDigits ?? 0),
rateLimitPerSecond: channel.rateLimitPerSecond,
};
@@ -183,6 +183,7 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: Number(channel.gatewayPort),
protocol: 'CMPP',
enterpriseCode: channel.corpCode,
account: channel.account,
passwordCipher: passwordCipher || undefined,
@@ -192,7 +193,9 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
unitPrice: Math.round(channel.unitPrice),
desiredConnections: channel.desiredConnections,
windowSize: channel.windowSize,
config: { extensionDigits: channel.extensionDigits },
heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds,
heartbeatMissThreshold: channel.heartbeatMissThreshold,
config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode },
};
}
@@ -221,9 +224,9 @@ function ChannelFormModal({
const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
const [unitPriceError, setUnitPriceError] = useState('');
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
const [protocol, setProtocol] = useState('CMPP');
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '17890');
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890');
const [businessCode, setBusinessCode] = useState(channel?.businessCode ?? 'SMS');
const [corpCode, setCorpCode] = useState(channel?.corpCode ?? '');
const [account, setAccount] = useState(channel?.account ?? '');
const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0');
@@ -233,6 +236,8 @@ function ChannelFormModal({
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
function submit() {
if (!isValidMoneyInput(unitPrice)) {
@@ -255,12 +260,15 @@ function ChannelFormModal({
failureCount: channel?.failureCount ?? 0,
gatewayHost,
gatewayPort,
businessCode: businessCode.trim() || 'SMS',
corpCode,
account,
accessNo,
cmppVersion,
desiredConnections: Number(desiredConnections) || 1,
windowSize: Number(windowSize) || 16,
heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30,
heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3,
extensionDigits: Number(extensionDigits),
rateLimitPerSecond: Number(flowLimit),
passwordCipher: password || undefined,
@@ -302,11 +310,12 @@ function ChannelFormModal({
<section>
<h3></h3>
<div className="sms-channel-form-grid">
<Select label="* 协议选择" onChange={(event) => setProtocol(event.target.value)} options={protocolOptions} value={protocol} />
<Input disabled label="* 协议选择" value="CMPP" />
<div className="sms-channel-inline-field">
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
</div>
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} />
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
@@ -326,6 +335,8 @@ function ChannelFormModal({
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
</div>
</section>
</div>
@@ -721,6 +732,10 @@ export function AdminChannelsPage() {
<span></span>
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
</div>
<div>
<span></span>
<strong>{connection.reconnectCount} / {formatDateTime(connection.nextReconnectAt)}</strong>
</div>
{connection.lastError ? <p>{connection.lastError}</p> : null}
</article>
))}
@@ -153,12 +153,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState('');
const paramsText = formatCmppParams(app, params);
const host = params?.gatewayHost ?? app.cmppParams.host;
const port = params?.gatewayPort ?? app.cmppParams.port;
const password = params?.passwordCipher ?? app.cmppParams.password;
const srcId = params?.srcId ?? app.cmppParams.accessNumber;
const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled;
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
async function copyParams() {
try {
@@ -185,19 +179,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
title={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
>
<div className="cmpp-param-detail">
<div className="cmpp-param-grid">
<div><span></span><strong>{interfaceEnabled ? '开通' : '关闭'}</strong></div>
<div><span></span><strong>{interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}</strong></div>
<div><span>CMPP网关地址</span><strong>{host}</strong></div>
<div><span>CMPP网关端口</span><strong>{port}</strong></div>
<div><span></span><strong>{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}</strong></div>
<div><span></span><strong>{params?.account ?? app.cmppParams.account}</strong></div>
<div><span></span><strong>{password}</strong></div>
<div><span></span><strong>{srcId}</strong></div>
<div><span></span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
<div><span></span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} </strong></div>
<div><span></span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
</div>
<pre className="cmpp-param-copy">{paramsText}</pre>
{copyError ? <p className="form-error">{copyError}</p> : null}
</div>
@@ -391,21 +372,21 @@ export function AdminEnterpriseApplicationsPage() {
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)}` },
{
key: 'cmppStatus',
title: 'CMPP状态',
width: '230px',
title: '客户连接状态',
width: '250px',
render: (record) => (
<div className="cmpp-status-cell">
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
</Tag>
<button onClick={() => setConnectionApp(record)} type="button">
<button disabled={!record.cmppParams.interfaceEnabled} onClick={() => setConnectionApp(record)} type="button">
{record.cmppConnections.filter((item) => item.state === 'open').length}
</button>
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
<button className={`cmpp-status-cell__params ${record.cmppParams.interfaceEnabled ? 'is-enabled' : 'is-disabled'}`} disabled={!record.cmppParams.interfaceEnabled} onClick={() => { void openParams(record); }} type="button">
<Settings2 size={13} />
CMPP参数
</button>
<button className="cmpp-status-cell__params" disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
<button className={`cmpp-status-cell__params ${record.httpEnabled ? 'is-enabled' : 'is-disabled'}`} disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
</div>
),
},
@@ -416,7 +397,7 @@ export function AdminEnterpriseApplicationsPage() {
align: 'right',
width: '190px',
render: (record) => (
<div className="table-actions">
<div className="table-actions enterprise-app-actions">
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
{record.enabled ? '停用' : '启用'}
+1 -2
View File
@@ -104,7 +104,6 @@ export function AdminEnterpriseAuditPage() {
}
const columns: Array<TableColumn<EnterpriseAuditRecord>> = [
{ key: 'id', title: '申请单号', render: (record) => <span className="muted">{record.id}</span> },
{ key: 'companyName', title: '企业名称', render: (record) => <strong>{record.companyName}</strong> },
{ key: 'creditCode', title: '统一社会信用代码', render: (record) => record.creditCode },
{ key: 'contactName', title: '联系人', render: (record) => record.contactName },
@@ -173,7 +172,7 @@ export function AdminEnterpriseAuditPage() {
onClose={() => setDetailRecord(null)}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{detailRecord.id}</p></div>}
title={<div className="template-modal-title"><h2></h2><p>{detailRecord.companyName}</p></div>}
>
<div className="enterprise-audit-detail">
<section>
@@ -114,7 +114,7 @@ export function AdminEnterpriseBlacklistPage() {
<Select
label="状态"
onChange={(event) => setStatus(event.target.value)}
options={[{ label: '全部状态', value: 'all' }, { label: '生效中', value: 'active' }, { label: '已删除', value: 'deleted' }]}
options={[{ label: '全部状态', value: 'all' }, { label: '生效中', value: 'active' }]}
value={status}
/>
<div className="admin-security-filter__actions">
+5 -5
View File
@@ -74,12 +74,12 @@ export function AdminProfitReportsPage() {
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={11}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={11}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={11}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
{error ? <tr><td className="ui-table__empty" colSpan={13}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={13}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={13}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
</tbody>
</table>
</div>
+5 -5
View File
@@ -81,12 +81,12 @@ export function AdminQualityReportsPage() {
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th>{dimensionLabels[dimension]}</th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th>{dimensionLabels[dimension]}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={8}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={8}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={8}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
{error ? <tr><td className="ui-table__empty" colSpan={10}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={10}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={10}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
</tbody>
</table>
</div>
@@ -80,12 +80,12 @@ export function AdminReconciliationReportsPage() {
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={7}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
{error ? <tr><td className="ui-table__empty" colSpan={9}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={9}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={9}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
</tbody>
</table>
</div>
+25 -2
View File
@@ -48,7 +48,7 @@ export function AdminSmsApplicationFormPage() {
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10000');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
@@ -73,6 +73,29 @@ export function AdminSmsApplicationFormPage() {
let cancelled = false;
async function loadForm() {
try {
if (!isEdit) {
setAppName('');
setScene('行业通知');
setDailyLimit('100000');
setCustomerUnitPrice('0.0300');
setQueuePriority('normal');
setCmppAccount('');
setApplicationExtension('');
setAccessNumberFillEnabled(false);
setAccessNumberFillPrefix('');
setPasswordCipher(generateApplicationPassword());
setInterfaceEnabled(true);
setInterfaceType('cmpp20');
setCmppMaxConnections('1');
setPhoneDailyLimit('10000');
setMismatchPolicy('manual_review');
setDownstreamReceiptRetryEnabled(true);
setDownstreamUplinkRetryEnabled(true);
setIpAddress('');
setMobileGroupId('');
setUnicomGroupId('');
setTelecomGroupId('');
}
const [groupItems, application, routeRules] = await Promise.all([
adminApi.listChannelGroups(),
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
@@ -278,7 +301,7 @@ export function AdminSmsApplicationFormPage() {
<span></span>
</div>
</div>
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10000" required value={phoneDailyLimit} />
<Select
label="不符合模板的短信"
onChange={(event) => setMismatchPolicy(event.target.value)}
+1 -3
View File
@@ -102,7 +102,6 @@ export function AdminSmsAuditPage() {
width: '54px',
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
},
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') },
@@ -149,7 +148,7 @@ export function AdminSmsAuditPage() {
]}
value={status}
/>
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号、内容或审核原因" value={keyword} />
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容或审核原因" value={keyword} />
<Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
<div className="audit-filter-actions">
<Button icon={<Search size={17} />} onClick={loadData}></Button>
@@ -183,7 +182,6 @@ export function AdminSmsAuditPage() {
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
<div className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
<div><span></span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
+30 -23
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatCents } from '@/utils/currency';
const statusLabelMap: Record<string, string> = {
@@ -166,13 +166,11 @@ function downloadCsv(records: SmsMessageRecord[]) {
function SendDetailModal({
record,
segmentAudits,
segmentColumns,
segmentLoading,
onClose,
}: {
record: SmsMessageRecord;
segmentAudits: SmsMessageSegmentAudit[];
segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>>;
segmentLoading: boolean;
onClose: () => void;
}) {
@@ -204,6 +202,10 @@ function SendDetailModal({
<span></span>
<strong>{getTime(record.queuedAt)}</strong>
</div>
<div>
<span></span>
<strong>{record.phoneNumber || '-'}</strong>
</div>
<div>
<span></span>
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
@@ -272,13 +274,31 @@ function SendDetailModal({
<section>
<h3></h3>
<Table
columns={segmentColumns}
data={segmentAudits}
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
pagination={false}
rowKey="id"
/>
{segmentLoading ? <div className="ui-table__empty">...</div> : segmentAudits.length === 0 ? (
<div className="ui-table__empty"></div>
) : (
<div className="admin-sms-segment-list">
{segmentAudits.map((segment) => (
<article className="admin-sms-segment-card" key={segment.id}>
<header>
<strong> {segment.segmentIndex}/{segment.segmentTotal}</strong>
<div>
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
</div>
</header>
<dl>
<div><dt></dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
<div><dt> ID</dt><dd>{segment.submitId}</dd></div>
<div><dt> MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
<div><dt></dt><dd>{segment.compensationType ?? '-'}</dd></div>
<div><dt></dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
</dl>
</article>
))}
</div>
)}
</section>
</div>
</Modal>
@@ -378,18 +398,6 @@ export function AdminSmsRecordsPage() {
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
{ key: 'submitId', title: '提交ID', width: '190px', render: (record) => <strong className="admin-task-id">{record.submitId}</strong> },
{ key: 'channel', title: '通道', width: '150px', render: (record) => record.channel?.name ?? record.channelId ?? '-' },
{ key: 'sequenceId', title: 'Sequence', width: '110px', render: (record) => record.sequenceId ?? '-' },
{ key: 'gatewayMessageId', title: 'MsgId', width: '180px', render: (record) => record.gatewayMessageId ?? '-' },
{ key: 'submitStatus', title: '提交状态', width: '110px', render: (record) => <Tag tone={record.submitStatus === 'accepted' ? 'success' : record.submitStatus === 'queued' ? 'info' : 'danger'}>{record.submitStatus}</Tag> },
{ key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? <Tag tone={record.receiptStatus === 'delivered' ? 'success' : record.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{record.receiptStatus}</Tag> : '-' },
{ key: 'compensation', title: '补偿', width: '120px', render: (record) => record.compensationType ?? '-' },
{ key: 'error', title: '错误', render: (record) => record.errorMessage ?? record.errorCode ?? '-' },
];
function resetFilters() {
setEnterprise('all');
setApplication('all');
@@ -485,7 +493,6 @@ export function AdminSmsRecordsPage() {
onClose={() => setSelectedRecord(null)}
record={selectedRecord}
segmentAudits={segmentAudits}
segmentColumns={segmentColumns}
segmentLoading={segmentLoading}
/>
) : null}
+1 -2
View File
@@ -36,7 +36,6 @@ export function AdminTemplateAuditPage() {
const columns = useMemo<Array<TableColumn<SmsTemplateAudit>>>(
() => [
{ key: 'id', title: '审核编号', render: (record) => record.id },
{ key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId },
{ key: 'content', title: '短信模板内容', render: (record) => record.content },
@@ -83,7 +82,7 @@ export function AdminTemplateAuditPage() {
</div>
<div className="surface audit-filter-card">
<div className="audit-filter-grid audit-filter-grid--template">
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用模板内容或审核编号" value={keyword} />
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用模板内容" value={keyword} />
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<div className="audit-filter-actions">
<Button icon={<Search size={17} />}></Button>
+19 -3
View File
@@ -12,7 +12,7 @@ import {
PanelLeftOpen,
X,
} from 'lucide-react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { portalSessionApi } from '@/api/adminApi';
import {
clearSession,
@@ -337,13 +337,20 @@ export function AppShell({
const Icon = item.icon;
return (
<NavLink key={item.to} onClick={() => setMobileNavOpen(false)} to={item.to} end title={item.pending ? `${item.label}(待开发)` : item.label}>
<Link
aria-current={isShellNavItemActive(location.pathname, item.to) ? 'page' : undefined}
className={isShellNavItemActive(location.pathname, item.to) ? 'active' : undefined}
key={item.to}
onClick={() => setMobileNavOpen(false)}
to={item.to}
title={item.pending ? `${item.label}(待开发)` : item.label}
>
<Icon size={17} strokeWidth={2.1} />
<span className="side-nav-label">
<span className="side-nav-label-text">{item.label}</span>
{item.pending ? <span className="dev-status-badge side-nav-pending-badge"></span> : null}
</span>
</NavLink>
</Link>
);
})}
</div>
@@ -498,6 +505,15 @@ export function AppShell({
);
}
function isShellNavItemActive(pathname: string, itemPath: string) {
if (pathname === itemPath) return true;
if (itemPath === '/admin' || itemPath === '/client') return false;
if (pathname.startsWith(`${itemPath}/`)) return true;
if (itemPath === '/admin/enterprise-applications' && /^\/admin\/customers\/[^/]+\/sms-apps\//.test(pathname)) return true;
if (itemPath === '/admin/customer-enterprises' && /^\/admin\/customers\/[^/]+\/edit$/.test(pathname)) return true;
return false;
}
function formatCountdown(seconds: number) {
const minutes = Math.floor(seconds / 60);
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
+92 -13
View File
@@ -3167,11 +3167,32 @@ h3 {
color: var(--color-text-inverse);
}
.cmpp-status-cell button:disabled,
.cmpp-status-cell button.is-disabled {
background: var(--color-surface-muted);
border-color: var(--color-border);
color: var(--color-text-muted);
cursor: not-allowed;
opacity: 0.72;
}
.cmpp-status-cell button.is-enabled {
background: var(--color-success-soft);
border-color: color-mix(in srgb, var(--color-success) 30%, var(--color-border));
color: var(--color-success);
}
.cmpp-status-cell__params {
gap: 4px;
min-width: 58px !important;
}
.enterprise-app-actions {
display: grid;
grid-template-columns: repeat(2, max-content);
justify-content: end;
}
.cmpp-connection-detail {
display: grid;
gap: var(--space-5);
@@ -8061,9 +8082,9 @@ h3 {
.sms-test-title span {
align-items: center;
background: rgba(255, 255, 255, 0.18);
background: var(--color-selected-soft);
border-radius: var(--radius-md);
color: white;
color: var(--color-selected);
display: inline-flex;
height: 58px;
justify-content: center;
@@ -8072,7 +8093,7 @@ h3 {
.sms-test-title h2,
.sms-test-title p {
color: white;
color: var(--color-text-strong);
margin: 0;
}
@@ -8088,12 +8109,12 @@ h3 {
}
.ui-modal__header:has(.sms-test-title) {
background: var(--color-selected);
border-bottom: 0;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.ui-modal__header:has(.sms-test-title) .ui-button {
color: white;
color: var(--color-text-muted);
}
.sms-test-modal {
@@ -9007,8 +9028,8 @@ h3 {
.admin-sms-record-list {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
gap: var(--space-2);
padding: var(--space-3);
}
.admin-sms-record-card {
@@ -9016,8 +9037,8 @@ h3 {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: grid;
gap: var(--space-4);
padding: var(--space-5);
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
}
.admin-sms-record-card:hover {
@@ -9041,7 +9062,12 @@ h3 {
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
max-width: none;
padding: var(--space-4);
display: -webkit-box;
line-height: 1.55;
overflow: hidden;
padding: var(--space-2) var(--space-3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.admin-sms-record-card__meta {
@@ -9069,7 +9095,7 @@ h3 {
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
padding-top: var(--space-3);
padding-top: var(--space-2);
}
.admin-sms-record-table {
@@ -9258,12 +9284,65 @@ h3 {
overflow-wrap: anywhere;
}
.admin-sms-segment-list {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-sms-segment-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-3);
min-width: 0;
padding: var(--space-4);
}
.admin-sms-segment-card header {
align-items: center;
display: flex;
gap: var(--space-3);
justify-content: space-between;
}
.admin-sms-segment-card header > div {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.admin-sms-segment-card dl {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
}
.admin-sms-segment-card dl > div {
min-width: 0;
}
.admin-sms-segment-card dt {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
margin-bottom: var(--space-1);
}
.admin-sms-segment-card dd {
color: var(--color-text-strong);
margin: 0;
overflow-wrap: anywhere;
}
@media (max-width: 900px) {
.admin-sms-record-card > header,
.admin-sms-record-card__meta,
.admin-sms-detail-overview,
.admin-sms-detail-status-grid,
.admin-sms-route-list dl {
.admin-sms-route-list dl,
.admin-sms-segment-list,
.admin-sms-segment-card dl {
grid-template-columns: 1fr;
}