fix: reassemble inbound CMPP long messages

This commit is contained in:
hectorzhao
2026-07-23 21:04:35 +08:00
parent f186aee00b
commit b29576fcd1
9 changed files with 1102 additions and 22 deletions
@@ -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;
+45
View File
@@ -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?
+361 -2
View File
@@ -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 }]);
+356 -5
View File
@@ -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,24 +2059,170 @@ 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({
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,
@@ -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` 解码正文,并将引用号、总片数、片序号和编码传给 NestJSNestJS 必须在 PostgreSQL 持久化分组和分片,支持乱序、同片幂等、冲突片拒绝、进程重启恢复和超时终止。分片未齐全前不得创建内部批次、`SmsMessageRecord`、计费或路由;齐全后只创建一条完整正文主记录,并使用第一片 `Sequence_Id` 建立客户消息映射。每个合法分片仍应分别取得一个 `CMPP_SUBMIT_RESP`,但不能把 UDH 字节作为正文、不能按分片生成多条短信记录。
#### 4.8.3 回执、上行与幂等
+34
View File
@@ -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
+18
View File
@@ -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 200Redis 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 条 migrationschema 最新。事务验证成功写入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、服务重启和发布后验收结果在部署完成后追加记录。
+74 -3
View File
@@ -52,6 +52,14 @@ type submitRequest struct {
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 != "" {
+138
View File
@@ -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()