fix: reassemble inbound CMPP long messages
This commit is contained in:
+61
@@ -0,0 +1,61 @@
|
||||
CREATE TABLE "CmppInboundLongMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"groupKey" TEXT NOT NULL,
|
||||
"account" TEXT NOT NULL,
|
||||
"srcId" TEXT,
|
||||
"phoneNumbers" JSONB NOT NULL,
|
||||
"concatReference" INTEGER NOT NULL,
|
||||
"segmentTotal" INTEGER NOT NULL,
|
||||
"msgFmt" INTEGER NOT NULL,
|
||||
"messageId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'collecting',
|
||||
"response" JSONB,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CmppInboundLongMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "CmppInboundLongMessageSegment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"segmentIndex" INTEGER NOT NULL,
|
||||
"sequenceId" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"contentHash" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CmppInboundLongMessageSegment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "CmppInboundLongMessage_messageId_key"
|
||||
ON "CmppInboundLongMessage"("messageId");
|
||||
|
||||
CREATE INDEX "CmppInboundLongMessage_groupKey_status_createdAt_idx"
|
||||
ON "CmppInboundLongMessage"("groupKey", "status", "createdAt");
|
||||
|
||||
CREATE INDEX "CmppInboundLongMessage_applicationId_status_expiresAt_idx"
|
||||
ON "CmppInboundLongMessage"("applicationId", "status", "expiresAt");
|
||||
|
||||
CREATE UNIQUE INDEX "CmppInboundLongMessageSegment_groupId_segmentIndex_key"
|
||||
ON "CmppInboundLongMessageSegment"("groupId", "segmentIndex");
|
||||
|
||||
CREATE INDEX "CmppInboundLongMessageSegment_sequenceId_idx"
|
||||
ON "CmppInboundLongMessageSegment"("sequenceId");
|
||||
|
||||
ALTER TABLE "CmppInboundLongMessage"
|
||||
ADD CONSTRAINT "CmppInboundLongMessage_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "CmppInboundLongMessage"
|
||||
ADD CONSTRAINT "CmppInboundLongMessage_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "CmppInboundLongMessageSegment"
|
||||
ADD CONSTRAINT "CmppInboundLongMessageSegment_groupId_fkey"
|
||||
FOREIGN KEY ("groupId") REFERENCES "CmppInboundLongMessage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -47,6 +47,7 @@ model Tenant {
|
||||
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
|
||||
openApiRequests OpenApiRequest[]
|
||||
httpWebhookEvents HttpWebhookEvent[]
|
||||
cmppInboundLongMessages CmppInboundLongMessage[]
|
||||
}
|
||||
|
||||
model EnterpriseCertification {
|
||||
@@ -400,6 +401,7 @@ model SmsApplication {
|
||||
httpWebhookEndpoints HttpWebhookEndpoint[]
|
||||
httpWebhookEvents HttpWebhookEvent[]
|
||||
dailyUsages SmsApplicationDailyUsage[]
|
||||
inboundLongMessages CmppInboundLongMessage[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
}
|
||||
@@ -1541,6 +1543,49 @@ model SmsMessageSegmentAudit {
|
||||
@@index([submitStatus, receiptStatus])
|
||||
}
|
||||
|
||||
model CmppInboundLongMessage {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String
|
||||
groupKey String
|
||||
account String
|
||||
srcId String?
|
||||
phoneNumbers Json
|
||||
concatReference Int
|
||||
segmentTotal Int
|
||||
msgFmt Int
|
||||
messageId String @unique
|
||||
status String @default("collecting")
|
||||
response Json?
|
||||
expiresAt DateTime
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
segments CmppInboundLongMessageSegment[]
|
||||
|
||||
@@index([groupKey, status, createdAt])
|
||||
@@index([applicationId, status, expiresAt])
|
||||
}
|
||||
|
||||
model CmppInboundLongMessageSegment {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
segmentIndex Int
|
||||
sequenceId String?
|
||||
content String
|
||||
contentHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group CmppInboundLongMessage @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, segmentIndex])
|
||||
@@index([sequenceId])
|
||||
}
|
||||
|
||||
model SmsReceiptRecord {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
@@ -58,7 +59,7 @@ function createPrismaMock() {
|
||||
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
|
||||
},
|
||||
};
|
||||
return {
|
||||
const prisma = {
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||
},
|
||||
@@ -160,6 +161,16 @@ function createPrismaMock() {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
cmppInboundLongMessage: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
cmppInboundLongMessageSegment: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }),
|
||||
findMany: jest.fn().mockImplementation(({ where }) => Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId })))),
|
||||
@@ -301,8 +312,13 @@ function createPrismaMock() {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ dailyLimit: 100000, usedCount: 2 }]),
|
||||
$transaction: jest.fn((operations) => Promise.all(operations)),
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
prisma.$transaction.mockImplementation((operations: any) => typeof operations === 'function'
|
||||
? operations(prisma)
|
||||
: Promise.all(operations));
|
||||
return prisma;
|
||||
}
|
||||
|
||||
function createService(prisma = createPrismaMock()) {
|
||||
@@ -850,6 +866,349 @@ describe('SendChainService', () => {
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
const segments: Array<Record<string, any>> = [];
|
||||
const group = {
|
||||
id: 'long-group-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupKey: 'group-key',
|
||||
account: '100001',
|
||||
srcId: null,
|
||||
phoneNumbers: ['13800000001'],
|
||||
concatReference: 16,
|
||||
segmentTotal: 2,
|
||||
msgFmt: 8,
|
||||
messageId: 'MSG-LONG-1',
|
||||
status: 'collecting',
|
||||
response: null,
|
||||
expiresAt: new Date(Date.now() + 300_000),
|
||||
createdAt: new Date(),
|
||||
segments,
|
||||
};
|
||||
prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve(
|
||||
segments.length ? { ...group, segments: [...segments] } : null,
|
||||
));
|
||||
prisma.cmppInboundLongMessage.create.mockResolvedValue(group);
|
||||
prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => {
|
||||
const segment = { id: `segment-${data.segmentIndex}`, ...data };
|
||||
segments.push(segment);
|
||||
return Promise.resolve(segment);
|
||||
});
|
||||
prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve(
|
||||
[...segments].sort((a, b) => a.segmentIndex - b.segmentIndex),
|
||||
));
|
||||
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
|
||||
Object.assign(group, data);
|
||||
return Promise.resolve({ ...group });
|
||||
});
|
||||
|
||||
const first = await service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '【签名】第一片',
|
||||
sequenceId: 101,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 16, total: 2, index: 1, format: 8 },
|
||||
});
|
||||
expect(first).toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
fragmentPending: true,
|
||||
messageId: 'MSG-LONG-1',
|
||||
receivedSegments: 1,
|
||||
}));
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
|
||||
const second = await service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '第二片正文',
|
||||
sequenceId: 102,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 16, total: 2, index: 2, format: 8 },
|
||||
});
|
||||
expect(second).toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
messageId: 'MSG-LONG-1',
|
||||
messageRecordId: 'record-1',
|
||||
}));
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ content: '【签名】第一片第二片正文', phoneTotal: 1 }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
content: '【签名】第一片第二片正文',
|
||||
cmppSubmitSequenceId: '101',
|
||||
cmppSubmitGroupMessageId: 'MSG-LONG-1',
|
||||
}),
|
||||
});
|
||||
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: '【签名】第一片第二片正文',
|
||||
phoneCount: 1,
|
||||
}));
|
||||
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'long-group-1' },
|
||||
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts out-of-order and duplicate CMPP long-message fragments but rejects conflicting duplicates', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
const segments: Array<Record<string, any>> = [];
|
||||
const group = {
|
||||
id: 'long-group-2',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupKey: 'group-key-2',
|
||||
account: '100001',
|
||||
srcId: null,
|
||||
phoneNumbers: ['13800000001'],
|
||||
concatReference: 17,
|
||||
segmentTotal: 2,
|
||||
msgFmt: 8,
|
||||
messageId: 'MSG-LONG-2',
|
||||
status: 'collecting',
|
||||
response: null,
|
||||
expiresAt: new Date(Date.now() + 300_000),
|
||||
createdAt: new Date(),
|
||||
segments,
|
||||
};
|
||||
prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve(
|
||||
segments.length ? { ...group, segments: [...segments] } : null,
|
||||
));
|
||||
prisma.cmppInboundLongMessage.create.mockResolvedValue(group);
|
||||
prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => {
|
||||
const segment = { id: `segment-${data.segmentIndex}`, ...data };
|
||||
segments.push(segment);
|
||||
return Promise.resolve(segment);
|
||||
});
|
||||
prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve(
|
||||
[...segments].sort((a, b) => a.segmentIndex - b.segmentIndex),
|
||||
));
|
||||
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
|
||||
Object.assign(group, data);
|
||||
return Promise.resolve({ ...group });
|
||||
});
|
||||
|
||||
const secondFragment = {
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '第二片正文',
|
||||
sequenceId: 202,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 17, total: 2, index: 2, format: 8 },
|
||||
};
|
||||
await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({
|
||||
fragmentPending: true,
|
||||
receivedSegments: 1,
|
||||
}));
|
||||
await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({
|
||||
fragmentPending: true,
|
||||
receivedSegments: 1,
|
||||
}));
|
||||
expect(prisma.cmppInboundLongMessageSegment.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
...secondFragment,
|
||||
content: '冲突的第二片',
|
||||
})).rejects.toThrow('fragment 2 conflicts');
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '【签名】第一片',
|
||||
sequenceId: 201,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 17, total: 2, index: 1, format: 8 },
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
messageId: 'MSG-LONG-2',
|
||||
messageRecordId: 'record-1',
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
content: '【签名】第一片第二片正文',
|
||||
cmppSubmitSequenceId: '201',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('resumes a persistently complete CMPP long message after processing is interrupted by a restart', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([]);
|
||||
const staleAt = new Date(Date.now() - 60_000);
|
||||
const group = {
|
||||
id: 'long-group-restart',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupKey: 'group-key-restart',
|
||||
account: '100001',
|
||||
srcId: null,
|
||||
phoneNumbers: ['13800000001'],
|
||||
concatReference: 18,
|
||||
segmentTotal: 2,
|
||||
msgFmt: 8,
|
||||
messageId: 'MSG-LONG-RESTART',
|
||||
status: 'processing',
|
||||
response: null,
|
||||
expiresAt: new Date(Date.now() + 300_000),
|
||||
createdAt: staleAt,
|
||||
updatedAt: staleAt,
|
||||
segments: [
|
||||
{
|
||||
id: 'segment-restart-1',
|
||||
groupId: 'long-group-restart',
|
||||
segmentIndex: 1,
|
||||
sequenceId: '301',
|
||||
content: '【签名】第一片',
|
||||
contentHash: createHash('sha256').update('【签名】第一片').digest('hex'),
|
||||
},
|
||||
{
|
||||
id: 'segment-restart-2',
|
||||
groupId: 'long-group-restart',
|
||||
segmentIndex: 2,
|
||||
sequenceId: '302',
|
||||
content: '第二片正文',
|
||||
contentHash: createHash('sha256').update('第二片正文').digest('hex'),
|
||||
},
|
||||
],
|
||||
};
|
||||
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue(group);
|
||||
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
|
||||
Object.assign(group, data, { updatedAt: new Date() });
|
||||
return Promise.resolve({ ...group });
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '第二片正文',
|
||||
sequenceId: 302,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 18, total: 2, index: 2, format: 8 },
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
messageId: 'MSG-LONG-RESTART',
|
||||
messageRecordId: 'record-1',
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
content: '【签名】第一片第二片正文',
|
||||
cmppSubmitSequenceId: '301',
|
||||
cmppSubmitGroupMessageId: 'MSG-LONG-RESTART',
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'long-group-restart' },
|
||||
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers the persisted SMS result after a restart without creating a duplicate main record', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const staleAt = new Date(Date.now() - 60_000);
|
||||
const group = {
|
||||
id: 'long-group-after-record',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupKey: 'group-key-after-record',
|
||||
account: '100001',
|
||||
srcId: null,
|
||||
phoneNumbers: ['13800000001'],
|
||||
concatReference: 19,
|
||||
segmentTotal: 2,
|
||||
msgFmt: 8,
|
||||
messageId: 'MSG-LONG-AFTER-RECORD',
|
||||
status: 'processing',
|
||||
response: null,
|
||||
expiresAt: new Date(Date.now() + 300_000),
|
||||
createdAt: staleAt,
|
||||
updatedAt: staleAt,
|
||||
segments: [
|
||||
{
|
||||
id: 'segment-after-record-1',
|
||||
groupId: 'long-group-after-record',
|
||||
segmentIndex: 1,
|
||||
sequenceId: '401',
|
||||
content: '【签名】第一片',
|
||||
contentHash: createHash('sha256').update('【签名】第一片').digest('hex'),
|
||||
},
|
||||
{
|
||||
id: 'segment-after-record-2',
|
||||
groupId: 'long-group-after-record',
|
||||
segmentIndex: 2,
|
||||
sequenceId: '402',
|
||||
content: '第二片正文',
|
||||
contentHash: createHash('sha256').update('第二片正文').digest('hex'),
|
||||
},
|
||||
],
|
||||
};
|
||||
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue(group);
|
||||
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
|
||||
Object.assign(group, data, { updatedAt: new Date() });
|
||||
return Promise.resolve({ ...group });
|
||||
});
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{
|
||||
id: 'persisted-record-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
batchTaskId: 'persisted-task-1',
|
||||
messageId: 'MSG-LONG-AFTER-RECORD',
|
||||
phoneNumber: '13800000001',
|
||||
status: 'failed',
|
||||
errorCode: 'SIGNATURE',
|
||||
}]);
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '第二片正文',
|
||||
sequenceId: 402,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 19, total: 2, index: 2, format: 8 },
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
messageId: 'MSG-LONG-AFTER-RECORD',
|
||||
messageRecordId: 'persisted-record-1',
|
||||
taskId: 'persisted-task-1',
|
||||
}));
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'long-group-after-record' },
|
||||
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
|
||||
});
|
||||
});
|
||||
|
||||
it('expires incomplete or interrupted CMPP long-message groups without creating SMS records', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const now = new Date('2026-07-23T12:00:00.000Z');
|
||||
prisma.cmppInboundLongMessage.updateMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
await expect(service.expireInboundLongMessages(now)).resolves.toEqual({ count: 2 });
|
||||
expect(prisma.cmppInboundLongMessage.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: { in: ['collecting', 'processing'] },
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: {
|
||||
status: 'expired',
|
||||
completedAt: now,
|
||||
},
|
||||
});
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the whole CMPP Submit synchronously while keeping per-destination audit records when the daily limit is exceeded', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
|
||||
|
||||
@@ -48,6 +48,12 @@ export interface GatewayInboundSubmitDto {
|
||||
destId?: string;
|
||||
sequenceId?: number;
|
||||
remoteIp?: string;
|
||||
longMessage?: {
|
||||
reference: number;
|
||||
total: number;
|
||||
index: number;
|
||||
format: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GatewayInboundSingleSubmitResult {
|
||||
@@ -260,6 +266,9 @@ const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000;
|
||||
const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000;
|
||||
const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
const DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS = 60_000;
|
||||
const INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS = 10_000;
|
||||
const DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS = 30;
|
||||
const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||
priority: 1,
|
||||
@@ -279,6 +288,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
private scheduledDispatchInitialTimer?: ReturnType<typeof setTimeout>;
|
||||
private scheduledDispatchIntervalTimer?: ReturnType<typeof setInterval>;
|
||||
private scheduledDispatchScanRunning = false;
|
||||
private inboundLongMessageInitialTimer?: ReturnType<typeof setTimeout>;
|
||||
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -312,6 +323,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
this.scheduledDispatchIntervalTimer.unref?.();
|
||||
}
|
||||
if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') {
|
||||
this.inboundLongMessageInitialTimer = setTimeout(
|
||||
() => void this.expireInboundLongMessages().catch((error) => {
|
||||
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
|
||||
}),
|
||||
INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
|
||||
);
|
||||
this.inboundLongMessageInitialTimer.unref?.();
|
||||
this.inboundLongMessageIntervalTimer = setInterval(
|
||||
() => void this.expireInboundLongMessages().catch((error) => {
|
||||
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
|
||||
}),
|
||||
positiveInteger(
|
||||
process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
|
||||
DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
|
||||
),
|
||||
);
|
||||
this.inboundLongMessageIntervalTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
@@ -319,6 +349,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
|
||||
if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer);
|
||||
if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer);
|
||||
if (this.inboundLongMessageInitialTimer) clearTimeout(this.inboundLongMessageInitialTimer);
|
||||
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
|
||||
await this.worker?.close();
|
||||
await this.sendQueue?.close();
|
||||
await this.gatewayQueue?.close();
|
||||
@@ -2027,28 +2059,174 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const dailyQuota = await this.tryReserveDailySendQuota(application.id, phoneNumbers.length);
|
||||
if (data.longMessage) {
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
validateInboundApplicationSrcId(data.srcId, application);
|
||||
const collection = await this.collectInboundLongMessageFragment(data, application, phoneNumbers);
|
||||
if (collection.response) {
|
||||
return collection.response;
|
||||
}
|
||||
if (!collection.complete) {
|
||||
return {
|
||||
accepted: true,
|
||||
messageId: collection.messageId,
|
||||
status: 'fragment_pending',
|
||||
fragmentPending: true,
|
||||
receivedSegments: collection.receivedSegments,
|
||||
segmentTotal: data.longMessage.total,
|
||||
phoneCount: phoneNumbers.length,
|
||||
messages: phoneNumbers.map((phoneNumber) => ({
|
||||
phoneNumber,
|
||||
messageId: collection.messageId,
|
||||
status: 'fragment_pending',
|
||||
})),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await this.recoverCompletedInboundLongMessageResponse(
|
||||
collection.messageId,
|
||||
phoneNumbers,
|
||||
) ?? await this.submitCompleteInboundMessage({
|
||||
...data,
|
||||
content: collection.content,
|
||||
sequenceId: collection.sequenceId,
|
||||
longMessage: undefined,
|
||||
}, phoneNumbers, application, collection.messageId);
|
||||
await this.prisma.cmppInboundLongMessage.update({
|
||||
where: { id: collection.groupId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
response: JSON.parse(JSON.stringify(response)) as Prisma.InputJsonValue,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
await this.prisma.cmppInboundLongMessage.update({
|
||||
where: { id: collection.groupId },
|
||||
data: {
|
||||
status: 'rejected',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return this.submitCompleteInboundMessage(data, phoneNumbers, application);
|
||||
}
|
||||
|
||||
private async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
|
||||
const existing = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
cmppSubmitGroupMessageId: messageId,
|
||||
phoneNumber: { in: phoneNumbers },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
batchTaskId: true,
|
||||
messageId: true,
|
||||
phoneNumber: true,
|
||||
status: true,
|
||||
errorCode: true,
|
||||
},
|
||||
});
|
||||
const byPhone = new Map(existing.map((item) => [item.phoneNumber, item]));
|
||||
const ordered = phoneNumbers.map((phoneNumber) => byPhone.get(phoneNumber));
|
||||
if (ordered.some((item) => !item)) {
|
||||
return null;
|
||||
}
|
||||
const messages = ordered.map((item, index) => ({
|
||||
phoneNumber: phoneNumbers[index],
|
||||
messageId: item!.messageId,
|
||||
messageRecordId: item!.id,
|
||||
taskId: item!.batchTaskId ?? '',
|
||||
status: item!.status,
|
||||
}));
|
||||
const first = ordered[0]!;
|
||||
const dailyLimitRejected = ordered.every((item) => item!.errorCode === 'DAILY_LIMIT');
|
||||
return {
|
||||
accepted: !dailyLimitRejected,
|
||||
tenantId: first.tenantId ?? '',
|
||||
applicationId: first.applicationId ?? '',
|
||||
taskId: first.batchTaskId ?? '',
|
||||
messageId: first.messageId,
|
||||
messageRecordId: first.id,
|
||||
status: dailyLimitRejected ? 'rejected' : 'accepted',
|
||||
result: dailyLimitRejected ? 8 : undefined,
|
||||
phoneCount: messages.length,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
private async submitCompleteInboundMessage(
|
||||
data: GatewayInboundSubmitDto,
|
||||
phoneNumbers: string[],
|
||||
application: Awaited<ReturnType<SendChainService['findInboundApplication']>>,
|
||||
requestedGroupMessageId?: string,
|
||||
) {
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const persisted = requestedGroupMessageId
|
||||
? await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
cmppSubmitGroupMessageId: requestedGroupMessageId,
|
||||
phoneNumber: { in: phoneNumbers },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
batchTaskId: true,
|
||||
messageId: true,
|
||||
phoneNumber: true,
|
||||
status: true,
|
||||
errorCode: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
|
||||
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => !persistedByPhone.has(phoneNumber)).length;
|
||||
const dailyQuota = missingPhoneCount > 0
|
||||
? await this.tryReserveDailySendQuota(application.id, missingPhoneCount)
|
||||
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
|
||||
const dailyLimitRejection = dailyQuota.reserved
|
||||
? undefined
|
||||
: {
|
||||
code: 'DAILY_LIMIT',
|
||||
reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${phoneNumbers.length}条超出剩余配额`,
|
||||
reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${missingPhoneCount}条超出剩余配额`,
|
||||
};
|
||||
|
||||
const submitGroupMessageId = `MSG-${randomUUID()}`;
|
||||
const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`;
|
||||
const submissions = phoneNumbers.map((phoneNumber, index) => ({
|
||||
phoneNumber,
|
||||
messageId: index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`,
|
||||
persisted: persistedByPhone.get(phoneNumber),
|
||||
messageId: persistedByPhone.get(phoneNumber)?.messageId
|
||||
?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`),
|
||||
}));
|
||||
const results: GatewayInboundSingleSubmitResult[] = [];
|
||||
const concurrency = 10;
|
||||
for (let offset = 0; offset < submissions.length; offset += concurrency) {
|
||||
const batch = submissions.slice(offset, offset + concurrency);
|
||||
results.push(...await Promise.all(batch.map((submission) => this.submitInboundSingleMessage({
|
||||
...data,
|
||||
phoneNumber: submission.phoneNumber,
|
||||
phoneNumbers: undefined,
|
||||
}, submission.messageId, submitGroupMessageId, dailyLimitRejection))));
|
||||
results.push(...await Promise.all(batch.map((submission) => submission.persisted
|
||||
? Promise.resolve({
|
||||
accepted: submission.persisted.errorCode !== 'DAILY_LIMIT',
|
||||
tenantId: submission.persisted.tenantId ?? application.tenantId,
|
||||
applicationId: submission.persisted.applicationId ?? application.id,
|
||||
taskId: submission.persisted.batchTaskId ?? '',
|
||||
messageId: submission.persisted.messageId,
|
||||
messageRecordId: submission.persisted.id,
|
||||
status: submission.persisted.status,
|
||||
})
|
||||
: this.submitInboundSingleMessage({
|
||||
...data,
|
||||
phoneNumber: submission.phoneNumber,
|
||||
phoneNumbers: undefined,
|
||||
}, submission.messageId, submitGroupMessageId, dailyLimitRejection))));
|
||||
}
|
||||
const first = results[0];
|
||||
return {
|
||||
@@ -2065,6 +2243,173 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
private async collectInboundLongMessageFragment(
|
||||
data: GatewayInboundSubmitDto,
|
||||
application: NonNullable<Awaited<ReturnType<SendChainService['findInboundApplication']>>>,
|
||||
phoneNumbers: string[],
|
||||
) {
|
||||
const fragment = data.longMessage;
|
||||
if (!fragment || !Number.isInteger(fragment.reference) || fragment.reference < 0 || fragment.reference > 65535
|
||||
|| !Number.isInteger(fragment.total) || fragment.total < 2 || fragment.total > 255
|
||||
|| !Number.isInteger(fragment.index) || fragment.index < 1 || fragment.index > fragment.total
|
||||
|| !Number.isInteger(fragment.format) || fragment.format < 0 || fragment.format > 255) {
|
||||
throw new BadRequestException('CMPP long message fragment metadata is invalid');
|
||||
}
|
||||
const groupKey = createHash('sha256').update(JSON.stringify({
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
srcId: data.srcId?.trim() ?? '',
|
||||
phoneNumbers,
|
||||
reference: fragment.reference,
|
||||
total: fragment.total,
|
||||
format: fragment.format,
|
||||
})).digest('hex');
|
||||
const contentHash = createHash('sha256').update(data.content).digest('hex');
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + positiveInteger(
|
||||
process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS,
|
||||
300,
|
||||
) * 1000);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${groupKey}, 0))`;
|
||||
await tx.cmppInboundLongMessage.updateMany({
|
||||
where: {
|
||||
groupKey,
|
||||
status: { in: ['collecting', 'processing'] },
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: { status: 'expired', completedAt: now },
|
||||
});
|
||||
|
||||
const recent = await tx.cmppInboundLongMessage.findFirst({
|
||||
where: {
|
||||
groupKey,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const matchingRecentSegment = recent?.segments.find((item) => item.segmentIndex === fragment.index);
|
||||
if (recent && ['completed', 'rejected'].includes(recent.status)
|
||||
&& matchingRecentSegment?.contentHash === contentHash
|
||||
&& matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId))) {
|
||||
return {
|
||||
complete: recent.status === 'completed',
|
||||
groupId: recent.id,
|
||||
messageId: recent.messageId,
|
||||
receivedSegments: recent.segments.length,
|
||||
response: recent.response as any,
|
||||
content: recent.segments.map((item) => item.content).join(''),
|
||||
sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId),
|
||||
};
|
||||
}
|
||||
|
||||
let group = recent && ['collecting', 'processing'].includes(recent.status) ? recent : null;
|
||||
if (!group) {
|
||||
group = await tx.cmppInboundLongMessage.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
groupKey,
|
||||
account: data.account,
|
||||
srcId: data.srcId?.trim() || null,
|
||||
phoneNumbers,
|
||||
concatReference: fragment.reference,
|
||||
segmentTotal: fragment.total,
|
||||
msgFmt: fragment.format,
|
||||
messageId: `MSG-${randomUUID()}`,
|
||||
expiresAt,
|
||||
},
|
||||
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
|
||||
});
|
||||
}
|
||||
if (group.status === 'processing') {
|
||||
const processingStaleMs = positiveInteger(
|
||||
process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
|
||||
DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
|
||||
) * 1000;
|
||||
const complete = group.segments.length === fragment.total
|
||||
&& group.segments.every((item, index) => item.segmentIndex === index + 1);
|
||||
if (complete && now.getTime() - group.updatedAt.getTime() >= processingStaleMs) {
|
||||
await tx.cmppInboundLongMessage.update({
|
||||
where: { id: group.id },
|
||||
data: { status: 'processing', expiresAt },
|
||||
});
|
||||
return {
|
||||
complete: true,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: group.segments.length,
|
||||
response: null,
|
||||
content: group.segments.map((item) => item.content).join(''),
|
||||
sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
complete: false,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: group.segments.length,
|
||||
response: group.response as any,
|
||||
content: '',
|
||||
sequenceId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
|
||||
if (existing && (existing.contentHash !== contentHash
|
||||
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) {
|
||||
throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`);
|
||||
}
|
||||
if (!existing) {
|
||||
await tx.cmppInboundLongMessageSegment.create({
|
||||
data: {
|
||||
groupId: group.id,
|
||||
segmentIndex: fragment.index,
|
||||
sequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
content: data.content,
|
||||
contentHash,
|
||||
},
|
||||
});
|
||||
}
|
||||
const segments = await tx.cmppInboundLongMessageSegment.findMany({
|
||||
where: { groupId: group.id },
|
||||
orderBy: { segmentIndex: 'asc' },
|
||||
});
|
||||
const complete = segments.length === fragment.total
|
||||
&& segments.every((item, index) => item.segmentIndex === index + 1);
|
||||
if (complete) {
|
||||
await tx.cmppInboundLongMessage.update({
|
||||
where: { id: group.id },
|
||||
data: { status: 'processing', expiresAt },
|
||||
});
|
||||
}
|
||||
return {
|
||||
complete,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: segments.length,
|
||||
response: null,
|
||||
content: complete ? segments.map((item) => item.content).join('') : '',
|
||||
sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async expireInboundLongMessages(now = new Date()) {
|
||||
return this.prisma.cmppInboundLongMessage.updateMany({
|
||||
where: {
|
||||
status: { in: ['collecting', 'processing'] },
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: {
|
||||
status: 'expired',
|
||||
completedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async submitInboundSingleMessage(
|
||||
data: GatewayInboundSubmitDto & { phoneNumber: string },
|
||||
messageId: string,
|
||||
@@ -3782,6 +4127,12 @@ function positiveInteger(value: string | undefined, fallback: number) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseOptionalSequenceId(value: string | null | undefined) {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
|
||||
}
|
||||
|
||||
function shanghaiDateKey(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
6. 一个企业应用可以分别绑定移动、联通、电信通道组;可以只绑定其中一类或两类,但不能一个都不绑定。
|
||||
7. 发送时先按运营商识别结果分流:移动短信走移动通道组,联通短信走联通通道组,电信短信走电信通道组;识别不出的号码走移动通道组。
|
||||
8. 运营商识别以可配置的号码前缀正则表达式为准,通常匹配手机号前 3 到 4 位;手机号段库只提供省份/城市识别,其 carrier 字段仅作后台校验或提示,不参与发送运营商判定。
|
||||
- 预发布运营商规则按公开码号资料覆盖中国移动、中国联通、中国电信及其移动转售号段;中国广电 `192` 号段按当前业务约定归入中国移动路由。
|
||||
- 号码前缀表示原始码号分配关系;携号转网号码无法仅凭前缀识别当前签约运营商,如后续要求按实时在网运营商路由,必须接入可信的携号转网/HLR 查询能力。
|
||||
9. 路由规则只能表达应用到通道组的绑定关系,不能直接绑定单个通道,也不在规则层配置省份;省份和全国路由在通道组内部处理。
|
||||
10. 发送服务必须使用手机号段库识别手机号省份和城市;无法识别省份时走对应运营商的全国通道组路由。
|
||||
11. 通道组内必须先匹配省网路由;省网未匹配时走同一运营商通道组内的全国通道。省网发送失败后,当前版本立即跳到该通道组第一个全国通道补发,不再尝试同省第二省网通道。
|
||||
@@ -254,6 +256,7 @@
|
||||
11. Gateway 必须支持下游客户上行接入场景:收到运营商上行后,按接入号、手机号、应用、时间窗口匹配并向客户连接推送 Deliver,上行同时入库。
|
||||
12. 下游客户连接与上游通道连接必须隔离管理:客户侧账号密码不能用于连接上游通道,上游通道账号密码也不能作为客户接入凭据。
|
||||
13. Gateway 必须为客户侧 CMPP2.0/3.0 Submit 记录可检索日志:收包时记录协议版本、账号、客户 IP、sequenceId、号码、srcId、编码、分片序号和内容长度;响应时记录 result、平台 messageId、CMPP Msg_Id、耗时和失败阶段。NestJS 拒绝 Submit 时,Gateway 日志必须保留 API 返回的真实业务原因,不能只记录 HTTP 状态码;短信正文不得明文写入 Gateway 日志,仅记录字符数和哈希。
|
||||
14. 客户已按 CMPP 标准 UDH 拆分的下游长短信必须先重组再进入业务发送链。Gateway 应识别 8 位 `05 00 03` 和 16 位 `06 08 04` 拼接头,去除 UDH 后按 `MsgFmt` 解码正文,并将引用号、总片数、片序号和编码传给 NestJS;NestJS 必须在 PostgreSQL 持久化分组和分片,支持乱序、同片幂等、冲突片拒绝、进程重启恢复和超时终止。分片未齐全前不得创建内部批次、`SmsMessageRecord`、计费或路由;齐全后只创建一条完整正文主记录,并使用第一片 `Sequence_Id` 建立客户消息映射。每个合法分片仍应分别取得一个 `CMPP_SUBMIT_RESP`,但不能把 UDH 字节作为正文、不能按分片生成多条短信记录。
|
||||
|
||||
#### 4.8.3 回执、上行与幂等
|
||||
|
||||
|
||||
@@ -1011,6 +1011,21 @@
|
||||
- 放入联通通道组的三网通道不能被移动号码选中。
|
||||
- route/trace 标明实际命中的三网通道。
|
||||
|
||||
### TC-SEND-016A 预发布运营商号段规则完整性
|
||||
|
||||
- 优先级:P0
|
||||
- 前置条件:运营商区分规则已按当前公开码号资料同步。
|
||||
- 步骤:
|
||||
1. 读取真实 `GET /api/admin/dictionaries/phone-carrier-rules?page=1&pageSize=100`。
|
||||
2. 使用移动、联通、电信基础号段及 `162/165/167/170/171` 等移动转售号段生成代表号码。
|
||||
3. 使用 `190/191/192/193/195/196/197/198/199` 生成代表号码。
|
||||
4. 对每个代表号码按发送服务相同的优先级和 JavaScript 正则逐条匹配。
|
||||
- 预期结果:
|
||||
- 每个代表号码唯一命中一条 active 规则,不得重叠或漏配。
|
||||
- `190/191/193/199` 归电信,`196` 归联通,`195/197/198` 归移动。
|
||||
- 中国广电 `192` 按当前业务约定唯一归入移动。
|
||||
- 测试结论仅代表原始号段分配规则,不把携号转网号码误宣称为实时运营商识别。
|
||||
|
||||
### TC-SEND-018 失败补发停止条件
|
||||
|
||||
- 优先级:P0
|
||||
@@ -1205,6 +1220,25 @@
|
||||
- CMPP 内部批次的详情、短信明细和取消请求均返回不可见/不存在,不泄露内部任务。
|
||||
- CMPP 短信仍完整出现在短信记录、提交、回执和账务链路。
|
||||
|
||||
### TC-GW-006A 下游 CMPP UDH 长短信持久化重组
|
||||
|
||||
- 优先级:P0
|
||||
- 前置条件:企业应用可正常 bind;已配置审核通过的完整签名和模板;NestJS、PostgreSQL、Redis 与 Go Gateway 使用真实本地或预发布链路。
|
||||
- 步骤:
|
||||
1. 使用 CMPP2.0 和 CMPP3.0 分别提交一条两片长短信,分片正文使用 UCS2,首片携带签名;覆盖 8 位 `05 00 03` 和 16 位 `06 08 04` UDH。
|
||||
2. 第一片提交后查询 `CmppInboundLongMessage`、`CmppInboundLongMessageSegment`、`SmsBatchTask` 和 `SmsMessageRecord`。
|
||||
3. 乱序提交第二片,再重复提交内容和 `Sequence_Id` 完全相同的分片。
|
||||
4. 使用相同引用号和片序号提交内容不同或 `Sequence_Id` 不同的冲突片。
|
||||
5. 在全部分片已持久化、业务处理尚未完成时重启 API,再重放任一已存分片。
|
||||
6. 只提交部分分片并等待超过 `CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS`,执行超时扫描。
|
||||
- 预期结果:
|
||||
- Gateway 去除 UDH 后才按 `MsgFmt` 解码,NestJS 收到的每片正文不含 `05 00 03`/`06 08 04` 控制字节;每个合法分片均返回 `SUBMIT_RESP status=0`。
|
||||
- 分片未齐全时只存在一条 collecting 分组和已收分片,API 返回稳定的分组 `messageId`,不得创建内部批次、短信主记录、计费或路由。
|
||||
- 分片齐全后按 `segmentIndex` 唯一排序拼接,只创建一条完整正文 `SmsMessageRecord` 和一个内部批次;正文签名/模板识别针对拼接后的完整内容执行,`cmppSubmitSequenceId` 使用第一片 `Sequence_Id`。
|
||||
- 乱序可完成;完全相同的重复片幂等复用原结果;冲突片返回明确 4xx/非零 SubmitResp,数据库唯一约束禁止同组同片序号出现两行。
|
||||
- API 重启后从 PostgreSQL 恢复分组、分片、原 `messageId` 和第一片 `Sequence_Id`,不得重复创建主记录。
|
||||
- 超时未齐分组转为 expired,保留分片审计但不创建短信主记录;后续相同引用号的新消息可建立新分组。
|
||||
|
||||
### TC-SEND-039 CMPP 模板不匹配短窗口聚合人工审核
|
||||
|
||||
- 优先级:P0
|
||||
|
||||
@@ -2244,3 +2244,21 @@ git diff --check
|
||||
- 发布后以字节级扫描复核49条签名,非法UTF-8为0;两个目标ID均精确恢复为hex `e38090e888aae5a4a9e4bfa1e681afe4bfa1e8afbae7bd91e38091`(`【航天信息信诺网】`)。生产Prisma真实执行签名、报备任务、报备记录、待报备资料四类关联查询均成功,分别返回49、34、82、4条,不再触发P2039/22021。
|
||||
- API/Gateway health、外部首页、运营登录页、客户端登录页和外部API health均返回HTTP 200;Redis PONG,`gateway.submit.commands`为`pending=0、lag=0`,7个通道TPS权威配置存在。两个active上游通道均为`connected/currentConnections=1`;disabled通道的一条`connected/1`仍是2026-07-09历史状态残留,本次启动没有把它作为活动通道恢复。
|
||||
- 部署后API日志新增P2039/22021为0,Nginx中企业签名及相关报备接口新增5xx为0。应用内Browser因Chrome标签被另一Codex会话占用且控制连接超时,未完成登录态页面交互验收;本次以真实NestJS所用Prisma关联查询、PostgreSQL字节扫描和Nginx/API日志作为后端修复证据,不虚报浏览器交互通过。未发送短信,未审核、删除、充值、改密或修改其他生产业务数据。
|
||||
|
||||
## 2026-07-23 预发布运营商区分规则同步(配置变更,未提交、未部署代码)
|
||||
|
||||
- 在线证据以华为云2026年2月《消息&短信》号码规则为主,并以工信部关于`190/197/196/192`公众移动通信网网号核发信息交叉核对。规则覆盖三大基础运营商、移动转售号段及必要的上网卡/物联网/卫星前缀;按用户明确要求将中国广电`192`归入中国移动路由。
|
||||
- 变更前预发布`PhoneCarrierRule`有30条,存在`190`重复、移动`195`仅覆盖`1951—1952`、电信`191/193`等缺失。完整PostgreSQL及原规则CSV已备份至`/opt/cmpp-platform/backups/config/20260723-164357-phone-carrier-rules`;数据库备份SHA-256为`239c4c669d55e8ccf6bfc5458e92fab507e1af8cb928e7f4ace5cd7f53c1e6f1`,规则CSV SHA-256为`8e437138f0e1a526519b01c6ddc4e95ce58e1a76fb623dc2a3ab0f6616630e86`,两者权限600且gzip校验通过。
|
||||
- 使用单个PostgreSQL事务锁定并原子替换规则,最终30条全部active:移动12条、联通9条、电信9条。`19200000000`唯一命中`mobile / ^19[2578]`,备注明确“含中国广电192,按业务要求归中国移动”。
|
||||
- 真实管理员验证码登录成功(201),`GET /api/admin/dictionaries/phone-carrier-rules?page=1&pageSize=100`返回200和30条;对68个基础、转售及新号段代表号码按发送服务同款JavaScript正则验证,全部唯一命中、0个错配,随后真实登出成功。首次验证误把凭据文件的`password=unchanged`当作密码产生一次401,未锁定账号、未修改规则,修正为已授权密码后通过。
|
||||
- 本次只修改预发布字典配置,不改代码、不重启服务、不发送短信,也不回写历史短信运营商。号段规则反映原始码号分配;携号转网后的当前签约运营商无法只靠前缀判断,若业务要求实时识别需另接MNP/HLR能力。
|
||||
|
||||
## 2026-07-23 下游 CMPP UDH 长短信持久化重组修复(发布前验证)
|
||||
|
||||
- 根因确认不是近期回归,而是既有下游入站链路从未实现重组:Gateway 将每个 CMPP Submit 的完整 `MsgContent`(包含 UDH)直接按 UCS2/GB18030 解码并逐片调用 NestJS,NestJS 因而把两片当成两条独立短信,控制字节污染首部签名匹配。此前台账通过的是“平台完整正文向上游拆分”和“上游 Deliver 长上行重组”,未覆盖“企业客户端已拆分的下游 Submit 重组”。
|
||||
- Gateway 新增标准 8 位 `05 00 03`、16 位 `06 08 04` UDH 解析,校验 `PkTotal/PkNumber` 与 UDH 总片数/片序号一致,先剥离 UDH 再按 `MsgFmt` 解码,并向 NestJS 传递引用号、总片数、片序号和编码。真实 CMPP2.0 TCP 回归确认两片均获得成功 SubmitResp,API 收到的片正文不含 UDH。
|
||||
- NestJS/Prisma 新增 `CmppInboundLongMessage`、`CmppInboundLongMessageSegment` 及 migration `20260723120000_add_cmpp_inbound_long_message_reassembly`。分组键包含应用、账号、Src_Id、目标号码、引用号、总片数和编码;使用 PostgreSQL advisory transaction lock 与同组同片唯一索引保证并发幂等。分片齐全前不创建批次/短信,齐全后按片序合并并只创建一条完整正文记录;持久化稳定 `messageId` 和第一片 `Sequence_Id`,支持乱序、重复片、冲突拒绝、进程重启恢复及超时转 expired。
|
||||
- 本地真实 PostgreSQL 16 已应用 62 条 migration,schema 最新。事务验证成功写入2片并按序拼成 `【测试】第一片第二片正文`,同组同片重复索引被唯一约束拒绝,验证事务最终回滚为0条残留。真实本地 NestJS API + PostgreSQL + Redis 调用两次入站接口后,分组为 completed、持久化2片、只创建1条主记录,完整正文和第一片 `Sequence_Id` 均正确;未启动 Go Gateway 上游连接,未发送真实短信。
|
||||
- 新增长短信相关 API 回归5项(合并、乱序/重复/冲突、处理中断恢复、主记录已落库后的幂等恢复、超时终止)和 Gateway 回归3项(8位UDH、16位UDH、真实CMPP2.0两片转发)。API发送链83/83、API全量24 suites/295项、Gateway `go test ./...`、API build、前端build、Prisma generate/validate/status均通过。`verify:phase8`首次与本地API并发时BullMQ为438.93 TPS而失败;关闭仅由本轮启动的API后单测为909.56 TPS,完整重跑为872.43 TPS并通过。前端仅保留既有约1.92MB单chunk告警。
|
||||
- 预发布两次测试正文使用的 `【深圳市合正物业服务有限公司】` 在该应用签名库中不存在;本次修复能消除UDH污染并完整重组,但不会绕过签名审核。部署后复测前需先按正常产品流程为应用配置并审核该签名/模板,或改用应用已有的审核通过签名。数据库升级只新增两张重组表和外键/索引;如必须回滚,应先停止新版本 API/Gateway,再删除子表和父表,未完成分片审计会丢失,既有短信主记录不受影响。
|
||||
- 发布前功能代码、迁移、回归测试和文档已完成并获用户授权提交、推送和部署;本节先保留发布前验证证据,实际提交、备份、migration、服务重启和发布后验收结果在部署完成后追加记录。
|
||||
|
||||
@@ -44,14 +44,22 @@ type authRequest struct {
|
||||
}
|
||||
|
||||
type submitRequest struct {
|
||||
Account string `json:"account"`
|
||||
PhoneNumber string `json:"phoneNumber,omitempty"`
|
||||
PhoneNumbers []string `json:"phoneNumbers,omitempty"`
|
||||
Content string `json:"content"`
|
||||
SrcID string `json:"srcId,omitempty"`
|
||||
DestID string `json:"destId,omitempty"`
|
||||
SequenceID uint32 `json:"sequenceId,omitempty"`
|
||||
RemoteIP string `json:"remoteIp,omitempty"`
|
||||
Account string `json:"account"`
|
||||
PhoneNumber string `json:"phoneNumber,omitempty"`
|
||||
PhoneNumbers []string `json:"phoneNumbers,omitempty"`
|
||||
Content string `json:"content"`
|
||||
SrcID string `json:"srcId,omitempty"`
|
||||
DestID string `json:"destId,omitempty"`
|
||||
SequenceID uint32 `json:"sequenceId,omitempty"`
|
||||
RemoteIP string `json:"remoteIp,omitempty"`
|
||||
LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"`
|
||||
}
|
||||
|
||||
type inboundLongMessageFragment struct {
|
||||
Reference int `json:"reference"`
|
||||
Total int `json:"total"`
|
||||
Index int `json:"index"`
|
||||
Format int `json:"format"`
|
||||
}
|
||||
|
||||
type submitResponseMessage struct {
|
||||
@@ -291,7 +299,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
|
||||
clientProtocol, req.protocol, account, enterpriseCode, remote, req.sequenceID, phone, strings.TrimSpace(req.srcID), req.msgFmt,
|
||||
req.pkNumber, req.pkTotal, len(req.destTerminalIDs), len(req.msgContent),
|
||||
)
|
||||
content, err := decodeContent(req.msgFmt, req.msgContent)
|
||||
content, longMessage, err := decodeInboundSubmitContent(req)
|
||||
if err != nil {
|
||||
logger.Printf(
|
||||
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=9 stage=decode reason=%q",
|
||||
@@ -311,6 +319,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
|
||||
DestID: phone,
|
||||
SequenceID: req.sequenceID,
|
||||
RemoteIP: remoteIP(remote),
|
||||
LongMessage: longMessage,
|
||||
})
|
||||
if err != nil || !result.Accepted {
|
||||
reason := "api returned accepted=false"
|
||||
@@ -465,6 +474,7 @@ type inboundSubmitPacket struct {
|
||||
protocol string
|
||||
pkTotal uint8
|
||||
pkNumber uint8
|
||||
tpUdhi uint8
|
||||
msgFmt uint8
|
||||
msgSrc string
|
||||
srcID string
|
||||
@@ -477,13 +487,13 @@ func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) {
|
||||
switch req := packet.(type) {
|
||||
case *cmpp.Cmpp2SubmitReqPkt:
|
||||
return inboundSubmitPacket{
|
||||
protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, msgFmt: req.MsgFmt,
|
||||
protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt,
|
||||
msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId,
|
||||
msgContent: req.MsgContent, sequenceID: req.SeqId,
|
||||
}, true
|
||||
case *cmpp.Cmpp3SubmitReqPkt:
|
||||
return inboundSubmitPacket{
|
||||
protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, msgFmt: req.MsgFmt,
|
||||
protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt,
|
||||
msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId,
|
||||
msgContent: req.MsgContent, sequenceID: req.SeqId,
|
||||
}, true
|
||||
@@ -697,6 +707,60 @@ func decodeContent(format uint8, content string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeInboundSubmitContent(req inboundSubmitPacket) (string, *inboundLongMessageFragment, error) {
|
||||
raw := []byte(req.msgContent)
|
||||
if req.tpUdhi == 0 && req.pkTotal <= 1 {
|
||||
content, err := decodeContent(req.msgFmt, req.msgContent)
|
||||
return content, nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return "", nil, errors.New("UDH message content is empty")
|
||||
}
|
||||
|
||||
headerLength := int(raw[0]) + 1
|
||||
if headerLength > len(raw) {
|
||||
return "", nil, fmt.Errorf("UDH length %d exceeds message content length %d", headerLength, len(raw))
|
||||
}
|
||||
|
||||
var reference, total, index int
|
||||
switch {
|
||||
case len(raw) >= 6 && raw[0] == 0x05 && raw[1] == 0x00 && raw[2] == 0x03:
|
||||
reference = int(raw[3])
|
||||
total = int(raw[4])
|
||||
index = int(raw[5])
|
||||
case len(raw) >= 7 && raw[0] == 0x06 && raw[1] == 0x08 && raw[2] == 0x04:
|
||||
reference = int(raw[3])<<8 | int(raw[4])
|
||||
total = int(raw[5])
|
||||
index = int(raw[6])
|
||||
default:
|
||||
if req.pkTotal > 1 {
|
||||
return "", nil, errors.New("concatenated CMPP submit is missing a supported 8-bit or 16-bit UDH")
|
||||
}
|
||||
content, err := decodeContent(req.msgFmt, string(raw[headerLength:]))
|
||||
return content, nil, err
|
||||
}
|
||||
if total < 2 || index < 1 || index > total {
|
||||
return "", nil, fmt.Errorf("invalid concatenated UDH total/index %d/%d", index, total)
|
||||
}
|
||||
if req.pkTotal > 0 && int(req.pkTotal) != total {
|
||||
return "", nil, fmt.Errorf("PkTotal %d does not match UDH total %d", req.pkTotal, total)
|
||||
}
|
||||
if req.pkNumber > 0 && int(req.pkNumber) != index {
|
||||
return "", nil, fmt.Errorf("PkNumber %d does not match UDH index %d", req.pkNumber, index)
|
||||
}
|
||||
|
||||
content, err := decodeContent(req.msgFmt, string(raw[headerLength:]))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return content, &inboundLongMessageFragment{
|
||||
Reference: reference,
|
||||
Total: total,
|
||||
Index: index,
|
||||
Format: int(req.msgFmt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func apiBaseURL(value string) string {
|
||||
if value == "" {
|
||||
return "http://127.0.0.1:3000/api"
|
||||
@@ -733,6 +797,13 @@ func rememberDownstream(session downstreamSession) {
|
||||
}
|
||||
session.touchPresence("connected", true, false)
|
||||
downstreamRegistry.Lock()
|
||||
if existing := downstreamRegistry.byMessageID[session.messageID]; existing != nil && existing.conn == session.conn {
|
||||
// A downstream long message returns one SUBMIT_RESP per fragment but is
|
||||
// persisted as one platform message. Keep the first fragment Msg_Id so
|
||||
// online delivery and restart recovery (which persists the first
|
||||
// Sequence_Id) address the same client-side message.
|
||||
session.gatewayMsgID = existing.gatewayMsgID
|
||||
}
|
||||
downstreamRegistry.byMessageID[session.messageID] = &session
|
||||
downstreamRegistry.byConn[session.conn] = &session
|
||||
if session.account != "" {
|
||||
|
||||
@@ -235,6 +235,144 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInboundLongMessageStripsConcatUDHBeforeUCS2Decode(t *testing.T) {
|
||||
payload, err := cmpputils.Utf8ToUcs2("【深圳市合正物业服务有限公司】第一片正文")
|
||||
if err != nil {
|
||||
t.Fatalf("encode content: %v", err)
|
||||
}
|
||||
raw := append([]byte{0x05, 0x00, 0x03, 0x10, 0x02, 0x01}, []byte(payload)...)
|
||||
|
||||
content, fragment, err := decodeInboundSubmitContent(inboundSubmitPacket{
|
||||
pkTotal: 2,
|
||||
pkNumber: 1,
|
||||
tpUdhi: 1,
|
||||
msgFmt: 8,
|
||||
msgContent: string(raw),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("decode long message: %v", err)
|
||||
}
|
||||
if content != "【深圳市合正物业服务有限公司】第一片正文" {
|
||||
t.Fatalf("decoded content = %q", content)
|
||||
}
|
||||
if fragment == nil || fragment.Reference != 0x10 || fragment.Total != 2 || fragment.Index != 1 {
|
||||
t.Fatalf("unexpected fragment metadata: %+v", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInboundLongMessageSupports16BitConcatReference(t *testing.T) {
|
||||
payload, err := cmpputils.Utf8ToUcs2("第二片正文")
|
||||
if err != nil {
|
||||
t.Fatalf("encode content: %v", err)
|
||||
}
|
||||
raw := append([]byte{0x06, 0x08, 0x04, 0x12, 0x34, 0x02, 0x02}, []byte(payload)...)
|
||||
|
||||
content, fragment, err := decodeInboundSubmitContent(inboundSubmitPacket{
|
||||
pkTotal: 2,
|
||||
pkNumber: 2,
|
||||
tpUdhi: 1,
|
||||
msgFmt: 8,
|
||||
msgContent: string(raw),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("decode long message: %v", err)
|
||||
}
|
||||
if content != "第二片正文" {
|
||||
t.Fatalf("decoded content = %q", content)
|
||||
}
|
||||
if fragment == nil || fragment.Reference != 0x1234 || fragment.Total != 2 || fragment.Index != 2 {
|
||||
t.Fatalf("unexpected fragment metadata: %+v", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundServerForwardsLongMessageFragmentsWithoutUDHAndAcknowledgesEachSubmit(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
account := "100001"
|
||||
password := "secret-hash"
|
||||
var mu sync.Mutex
|
||||
submits := make([]submitRequest, 0, 2)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/gateway/events/inbound/authenticate":
|
||||
_ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: account})
|
||||
case "/api/gateway/events/inbound/submit":
|
||||
var submit submitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&submit); err != nil {
|
||||
t.Fatalf("decode submit: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
submits = append(submits, submit)
|
||||
count := len(submits)
|
||||
mu.Unlock()
|
||||
status := "fragment_pending"
|
||||
if count == 2 {
|
||||
status = "accepted"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"accepted": true, "messageId": "MSG-LONG-1", "status": status,
|
||||
})
|
||||
case "/api/gateway/events/downstream/pending":
|
||||
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
|
||||
case "/api/gateway/events/inbound/connection":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Fatalf("unexpected api path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
addr := reserveTCPAddr(t)
|
||||
go func() { _ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe() }()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
client := cmpp.NewClient(cmpp.V20)
|
||||
defer client.Disconnect()
|
||||
if err := client.Connect(addr, account, password, 2*time.Second); err != nil {
|
||||
t.Fatalf("connect CMPP2 inbound: %v", err)
|
||||
}
|
||||
parts := []string{"【签名】第一片", "第二片正文"}
|
||||
var firstResponseMsgID uint64
|
||||
for index, text := range parts {
|
||||
payload, _ := cmpputils.Utf8ToUcs2(text)
|
||||
raw := append([]byte{0x05, 0x00, 0x03, 0x22, 0x02, byte(index + 1)}, []byte(payload)...)
|
||||
if _, err := client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{
|
||||
PkTotal: 2, PkNumber: uint8(index + 1), TpUdhi: 1, RegisteredDelivery: 1, MsgLevel: 1,
|
||||
ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696",
|
||||
MsgFmt: 8, MsgSrc: account, FeeType: "02", FeeCode: "0", SrcId: "10690000",
|
||||
DestUsrTl: 1, DestTerminalId: []string{"13500002696"}, MsgLength: uint8(len(raw)), MsgContent: string(raw),
|
||||
}); err != nil {
|
||||
t.Fatalf("send long-message fragment %d: %v", index+1, err)
|
||||
}
|
||||
if rsp := recvSubmitRsp20(t, client); rsp.Result != 0 || rsp.MsgId == 0 {
|
||||
t.Fatalf("unexpected fragment %d response: %+v", index+1, rsp)
|
||||
} else if index == 0 {
|
||||
firstResponseMsgID = rsp.MsgId
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(submits) != 2 {
|
||||
t.Fatalf("submit API calls = %d, want 2", len(submits))
|
||||
}
|
||||
for index, submit := range submits {
|
||||
if submit.Content != parts[index] {
|
||||
t.Fatalf("fragment %d content = %q", index+1, submit.Content)
|
||||
}
|
||||
if submit.LongMessage == nil || submit.LongMessage.Reference != 0x22 ||
|
||||
submit.LongMessage.Total != 2 || submit.LongMessage.Index != index+1 || submit.LongMessage.Format != 8 {
|
||||
t.Fatalf("fragment %d metadata = %+v", index+1, submit.LongMessage)
|
||||
}
|
||||
}
|
||||
downstreamRegistry.RLock()
|
||||
session := downstreamRegistry.byMessageID["MSG-LONG-1"]
|
||||
downstreamRegistry.RUnlock()
|
||||
if session == nil || session.gatewayMsgID != firstResponseMsgID {
|
||||
t.Fatalf("stored long-message Msg_Id = %v, want first fragment Msg_Id %v", session, firstResponseMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitResponsePrecedesQueuedFailureReceipt(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
Reference in New Issue
Block a user