fix: 修复 CMPP 协议字段容量与版本兼容性
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-20 15:49:37 +08:00
parent b24cd7c08d
commit 001d5f2cbd
37 changed files with 1933 additions and 295 deletions
@@ -0,0 +1,19 @@
-- Widen only; historical invalid values abort the entire transaction. Never narrow on rollback.
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '5min';
ALTER TABLE "UpstreamReceiptInbox" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "UpstreamReceiptInbox_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsReceiptRecord" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsReceiptRecord_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsUplinkMessage" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsUplinkMessage_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsSubmitRecord" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsSubmitRecord_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsMessageSegmentAudit" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsMessageSegmentAudit_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "CmppDownstreamDelivery" ALTER COLUMN "ackResult" TYPE BIGINT,
ADD CONSTRAINT "CmppDownstreamDelivery_ackResult_uint32_check" CHECK ("ackResult" BETWEEN 0 AND 4294967295);
ALTER TABLE "CmppDownstreamDeliveryAttempt" ALTER COLUMN "ackResult" TYPE BIGINT,
ADD CONSTRAINT "CmppDownstreamDeliveryAttempt_ackResult_uint32_check" CHECK ("ackResult" BETWEEN 0 AND 4294967295);
COMMIT;
+7 -7
View File
@@ -1939,7 +1939,7 @@ model SmsSubmitRecord {
sessionId String? sessionId String?
retryOfSubmitRecordId String? @unique retryOfSubmitRecordId String? @unique
submitId String @unique submitId String @unique
sequenceId Int? sequenceId BigInt?
gatewayMessageId String? gatewayMessageId String?
submitStatus String @default("queued") submitStatus String @default("queued")
resultEventId String? @unique resultEventId String? @unique
@@ -2090,7 +2090,7 @@ model SmsMessageSegmentAudit {
attempt Int @default(0) attempt Int @default(0)
segmentTotal Int @default(1) segmentTotal Int @default(1)
segmentIndex Int @default(1) segmentIndex Int @default(1)
sequenceId Int? sequenceId BigInt?
gatewayMessageId String? gatewayMessageId String?
submitStatus String @default("queued") submitStatus String @default("queued")
receiptStatus String? receiptStatus String?
@@ -2203,7 +2203,7 @@ model SmsReceiptRecord {
messageId String messageId String
gatewayMessageId String gatewayMessageId String
phoneNumber String? phoneNumber String?
sequenceId Int? sequenceId BigInt?
receiptStatus String receiptStatus String
rawStatus String rawStatus String
errorCode String? errorCode String?
@@ -2274,7 +2274,7 @@ model SmsUplinkMessage {
messageRecordId String? messageRecordId String?
messageId String? messageId String?
gatewayMessageId String? gatewayMessageId String?
sequenceId Int? sequenceId BigInt?
phoneNumber String phoneNumber String
destId String destId String
content String content String
@@ -2343,7 +2343,7 @@ model CmppDownstreamDelivery {
sentAt DateTime? sentAt DateTime?
acknowledgedAt DateTime? acknowledgedAt DateTime?
ackDeadlineAt DateTime? ackDeadlineAt DateTime?
ackResult Int? ackResult BigInt?
ackSequenceId String? ackSequenceId String?
ackMessageId String? ackMessageId String?
connectionId String? connectionId String?
@@ -2451,7 +2451,7 @@ model CmppDownstreamDeliveryAttempt {
sentAt DateTime? sentAt DateTime?
ackDeadlineAt DateTime? ackDeadlineAt DateTime?
acknowledgedAt DateTime? acknowledgedAt DateTime?
ackResult Int? ackResult BigInt?
failureType String? failureType String?
errorMessage String? errorMessage String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -2475,7 +2475,7 @@ model UpstreamReceiptInbox {
protocol String protocol String
protocolVersion String protocolVersion String
provisionalMessageId String? provisionalMessageId String?
sequenceId Int? sequenceId BigInt?
gatewayMessageId String gatewayMessageId String
phoneNumber String? phoneNumber String?
receiptStatus String receiptStatus String
+8 -1
View File
@@ -1,3 +1,5 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module'; import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module';
import { HomeModule } from './home-dashboard/home.module'; import { HomeModule } from './home-dashboard/home.module';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
@@ -67,7 +69,12 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
SendingMonitorModule, SendingMonitorModule,
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware], providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
RequestContextMiddleware,
SessionValidationMiddleware,
ManualOperationAuditMiddleware,
],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map } from 'rxjs/operators';
import { protocolFieldsToJson } from './protocol-uint32';
@Injectable()
export class ProtocolFieldsInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(map(protocolFieldsToJson));
}
}
+40
View File
@@ -0,0 +1,40 @@
import {
protocolFieldsToJson,
protocolUint32,
protocolUint32FromDb,
protocolUint32ToDb,
parseProtocolSequence,
} from './protocol-uint32';
describe('CMPP unsigned protocol fields', () => {
it.each([0, 2147483647, 2147483648, 4294967295])(
'round trips %s without changing the JSON number contract',
(value) => {
expect(protocolUint32FromDb(protocolUint32ToDb(value))).toBe(value);
expect(
JSON.parse(
JSON.stringify(protocolFieldsToJson({ rows: [{ sequenceId: BigInt(value), ackResult: BigInt(value) }] })),
),
).toEqual({ rows: [{ sequenceId: value, ackResult: value }] });
},
);
it.each([-1, 4294967296, 1.5, NaN, Infinity, '', '0', ' ', {}, true])('rejects invalid wire value %s', (value) => {
expect(() => protocolUint32(value)).toThrow();
expect(() => protocolUint32ToDb(value)).toThrow();
});
it('preserves optional historical nulls and unrelated serializers', () => {
expect(protocolUint32ToDb(null)).toBeUndefined();
expect(protocolUint32FromDb(null)).toBeUndefined();
const date = new Date();
expect(
protocolFieldsToJson({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' }),
).toEqual({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' });
expect(() => protocolUint32FromDb(4294967296n)).toThrow();
});
it('distinguishes text zero from missing or malformed historical sequences', () => {
for (const value of [null, undefined, '', ' ', '-1', '1.5', '1e2', '4294967296'])
expect(parseProtocolSequence(value)).toBeUndefined();
expect(parseProtocolSequence('0')).toBe(0);
expect(parseProtocolSequence('4294967295')).toBe(4294967295);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
/** Protocol integers are exact JS numbers on the wire and bigint in PostgreSQL. */
export function protocolUint32(value: unknown, field = 'sequenceId'): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
throw new BadRequestException(`${field} must be an unsigned 32-bit integer`);
}
return value;
}
export function protocolUint32ToDb(value: unknown, field = 'sequenceId'): bigint | undefined {
return value == null ? undefined : BigInt(protocolUint32(value, field));
}
export function protocolUint32FromDb(value: bigint | number | null | undefined): number | undefined {
if (value == null) return undefined;
return protocolUint32(typeof value === 'bigint' ? Number(value) : value);
}
/** Historical Submit sequence columns are text; blanks must never become zero. */
export function parseProtocolSequence(value: string | null | undefined): number | undefined {
if (value == null || !/^\d+$/.test(value)) return undefined;
const number = Number(value);
return Number.isInteger(number) && number <= 0xffffffff ? number : undefined;
}
/** Only protocol fields are converted, leaving money and dates to their existing serializers. */
export function protocolFieldsToJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(protocolFieldsToJson);
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
(key === 'sequenceId' || key === 'ackResult') && typeof item === 'bigint'
? protocolUint32FromDb(item)
: protocolFieldsToJson(item),
]),
);
}
+3
View File
@@ -1,3 +1,5 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller'; import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
@@ -21,6 +23,7 @@ import { SendChainService } from './send-chain/send-chain.service';
], ],
controllers: [DrainageSubmitGuardController, GatewayCallbackController], controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [ providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
BillingService, BillingService,
RiskReviewService, RiskReviewService,
PhoneFrequencyService, PhoneFrequencyService,
@@ -1,3 +1,4 @@
import { parseProtocolSequence } from '../common/protocol-uint32';
import type { PrismaService } from '../prisma/prisma.service'; import type { PrismaService } from '../prisma/prisma.service';
export type FinalReceiptMessage = { export type FinalReceiptMessage = {
@@ -44,29 +45,33 @@ async function resolveClientReceiptTargets(
}); });
if (group?.segments.length) { if (group?.segments.length) {
return group.segments.flatMap((segment) => { return group.segments.flatMap((segment) => {
const submitSequenceId = Number(segment.sequenceId); const submitSequenceId = parseProtocolSequence(segment.sequenceId);
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return []; if (submitSequenceId === undefined) return [];
return [{ return [
segmentIndex: segment.segmentIndex, {
segmentTotal: group.segmentTotal, segmentIndex: segment.segmentIndex,
submitSequenceId, segmentTotal: group.segmentTotal,
submitGroupMessageId: group.messageId, submitSequenceId,
registeredDelivery: segment.registeredDelivery, submitGroupMessageId: group.messageId,
}]; registeredDelivery: segment.registeredDelivery,
},
];
}); });
} }
} }
const submitSequenceId = Number(message.cmppSubmitSequenceId); const submitSequenceId = parseProtocolSequence(message.cmppSubmitSequenceId);
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return []; if (submitSequenceId === undefined) return [];
return [{ return [
segmentIndex: 1, {
segmentTotal: 1, segmentIndex: 1,
submitSequenceId, segmentTotal: 1,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId, submitSequenceId,
// Null means a historical CMPP record created before this field existed. submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
registeredDelivery: message.cmppRegisteredDelivery !== false, // Null means a historical CMPP record created before this field existed.
}]; registeredDelivery: message.cmppRegisteredDelivery !== false,
},
];
} }
/** /**
@@ -104,8 +109,7 @@ export async function queueFinalReceiptDeliveries(
propagateHttpQueueError: data.propagateHttpQueueError, propagateHttpQueueError: data.propagateHttpQueueError,
}); });
const targets = (await resolveClientReceiptTargets(prisma, message)) const targets = (await resolveClientReceiptTargets(prisma, message)).filter((target) => target.registeredDelivery);
.filter((target) => target.registeredDelivery);
for (const target of targets) { for (const target of targets) {
const isSingleFragment = target.segmentTotal === 1; const isSingleFragment = target.segmentTotal === 1;
await queue({ await queue({
@@ -0,0 +1,56 @@
import { Prisma } from '@prisma/client';
import { SendReceiptService } from './send-receipt.service';
describe('concurrent protocol receipt intake', () => {
function fixture() {
const duplicate = new Prisma.PrismaClientKnownRequestError('duplicate receiptKey', {
code: 'P2002',
clientVersion: 'test',
});
const prisma = {
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel' }) },
upstreamReceiptInbox: {
upsert: jest.fn().mockRejectedValue(duplicate),
findUnique: jest.fn().mockResolvedValue({ id: 'durable', status: 'matched' }),
},
};
return {
prisma,
duplicate,
service: new SendReceiptService(prisma as never, {} as never, undefined, {} as never, {}),
};
}
const event = {
channelId: 'channel',
sequenceId: 4294967295,
gatewayMessageId: '18446744073709551615',
receiptStatus: 'delivered' as const,
rawStatus: 'DELIVRD',
};
it('acknowledges only the same durable receipt after a unique-key race', async () => {
const { prisma, service } = fixture();
await expect(service.intakeReceipt(event)).resolves.toEqual({
accepted: true,
inboxId: 'durable',
status: 'matched',
});
expect(prisma.upstreamReceiptInbox.findUnique.mock.calls[0][0].where).toEqual(
prisma.upstreamReceiptInbox.upsert.mock.calls[0][0].where,
);
});
it('does not hide a missing conflicting row or an unrelated database outage', async () => {
const { prisma, duplicate, service } = fixture();
prisma.upstreamReceiptInbox.findUnique.mockResolvedValue(null);
await expect(service.intakeReceipt(event)).rejects.toBe(duplicate);
const outage = new Error('database unavailable');
prisma.upstreamReceiptInbox.upsert.mockRejectedValue(outage);
await expect(service.intakeReceipt(event)).rejects.toBe(outage);
});
it('rejects invalid sequence before looking up or writing any business records', async () => {
const { prisma, service } = fixture();
await expect(service.intakeReceipt({ ...event, sequenceId: -1 })).rejects.toThrow();
expect(prisma.smsChannel.findUnique).not.toHaveBeenCalled();
expect(prisma.upstreamReceiptInbox.upsert).not.toHaveBeenCalled();
});
});
+87 -52
View File
@@ -1,6 +1,16 @@
import { parseProtocolSequence } from '../common/protocol-uint32';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { CreateBatchTaskDto, GatewayControlDeliveryResult, GatewayDownstreamRecoveryStatusDto, GatewayDownstreamSentDto, GatewayInboundAuthDto, GatewayReceiptEventDto, GatewaySubmitResultDto, QueuePriority } from './send-chain.contracts'; import type {
CreateBatchTaskDto,
GatewayControlDeliveryResult,
GatewayDownstreamRecoveryStatusDto,
GatewayDownstreamSentDto,
GatewayInboundAuthDto,
GatewayReceiptEventDto,
GatewaySubmitResultDto,
QueuePriority,
} from './send-chain.contracts';
// R8 pure policies and deterministic key/status helpers. No database, queue or network access. // R8 pure policies and deterministic key/status helpers. No database, queue or network access.
@@ -70,6 +80,7 @@ export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */ /** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */
export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) { export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) {
void _drainage; // Kept in the signature for existing callers.
return undefined; return undefined;
} }
@@ -171,7 +182,7 @@ export function parseImportRows(content: string, delimiter?: ',' | '\t') {
return dataLines.map((line, index) => { return dataLines.map((line, index) => {
const cells = splitImportLine(line, firstDelimiter); const cells = splitImportLine(line, firstDelimiter);
const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = { const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = {
rowNumber: (hasHeader ? index + 2 : index + 1), rowNumber: hasHeader ? index + 2 : index + 1,
phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0], phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0],
variables: {}, variables: {},
}; };
@@ -194,7 +205,9 @@ export function cellByHeader(headers: string[], cells: string[], candidates: str
} }
export function normalizeCarrier(carrier?: string | null) { export function normalizeCarrier(carrier?: string | null) {
const value = String(carrier ?? '').trim().toLowerCase(); const value = String(carrier ?? '')
.trim()
.toLowerCase();
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
@@ -226,14 +239,20 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba
return fallback; return fallback;
} }
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string, channelCarriers?: string[] | null) { export function isCarrierCompatible(
channelCarrier: string | null | undefined,
targetCarrier: string,
channelCarriers?: string[] | null,
) {
if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier)); if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier));
const normalized = normalizeCarrier(channelCarrier); const normalized = normalizeCarrier(channelCarrier);
return normalized === 'all' || normalized === targetCarrier; return normalized === 'all' || normalized === targetCarrier;
} }
export function normalizeRegion(region?: string | null) { export function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); return String(region ?? '')
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
.trim();
} }
export function matchTemplateContent(templateContent: string, actualContent: string) { export function matchTemplateContent(templateContent: string, actualContent: string) {
@@ -281,7 +300,10 @@ export function isNationalChannel(item: { province?: string | null; channel: { s
return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国'; return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国';
} }
export function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) { export function isProvinceChannel(
item: { province?: string | null; channel: { sendRegion?: string | null } },
province?: string | null,
) {
if (!province) { if (!province) {
return false; return false;
} }
@@ -307,11 +329,13 @@ export function validateInboundApplicationSrcId(
} }
const fillPrefix = application.cmppAccessNumberFillEnabled const fillPrefix = application.cmppAccessNumberFillEnabled
? application.cmppAccessNumberFillPrefix?.trim() ?? '' ? (application.cmppAccessNumberFillPrefix?.trim() ?? '')
: ''; : '';
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`; const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
if (!submittedSrcId || submittedSrcId !== expectedSrcId) { if (!submittedSrcId || submittedSrcId !== expectedSrcId) {
throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`); throw new BadRequestException(
`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`,
);
} }
return submittedSrcId; return submittedSrcId;
} }
@@ -330,9 +354,7 @@ export function positiveInteger(value: string | undefined, fallback: number) {
} }
export function parseOptionalSequenceId(value: string | null | undefined) { export function parseOptionalSequenceId(value: string | null | undefined) {
if (!value) return undefined; return parseProtocolSequence(value);
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
} }
export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] { export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
@@ -344,13 +366,17 @@ export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['r
} }
export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) { export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) {
return createHash('sha256').update([ return createHash('sha256')
data.id, .update(
data.connectionId ?? '', [
data.sequenceId ?? '', data.id,
data.messageId ?? '', data.connectionId ?? '',
data.sequenceId ? '' : data.sentAt ?? '', data.sequenceId ?? '',
].join('\u0000')).digest('hex'); data.messageId ?? '',
data.sequenceId ? '' : (data.sentAt ?? ''),
].join('\u0000'),
)
.digest('hex');
} }
export function shanghaiDateKey(now = new Date()) { export function shanghaiDateKey(now = new Date()) {
@@ -378,12 +404,14 @@ export function bullmqConnection() {
export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) { export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
if (data.authSource && data.timestamp !== undefined) { if (data.authSource && data.timestamp !== undefined) {
const expected = createHash('md5') const expected = createHash('md5')
.update(Buffer.concat([ .update(
Buffer.from(octetString(data.account, 6), 'binary'), Buffer.concat([
Buffer.alloc(9), Buffer.from(octetString(data.account, 6), 'binary'),
Buffer.from(secretHash), Buffer.alloc(9),
Buffer.from(String(data.timestamp).padStart(10, '0')), Buffer.from(secretHash),
])) Buffer.from(String(data.timestamp).padStart(10, '0')),
]),
)
.digest('base64'); .digest('base64');
return expected === data.authSource; return expected === data.authSource;
} }
@@ -410,8 +438,9 @@ export function hasRecoveryAuditStateChanged(
if (!previous) { if (!previous) {
return true; return true;
} }
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'] return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'].some(
.some((key) => (previous[key] ?? null) !== (current[key] ?? null)); (key) => (previous[key] ?? null) !== (current[key] ?? null),
);
} }
export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) { export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
@@ -463,8 +492,9 @@ export function isChannelSendAvailable(channel: ChannelCandidate['channel']) {
if (channel.status !== 'active') { if (channel.status !== 'active') {
return false; return false;
} }
return (channel.connectionStates ?? []).some((connection) => return (channel.connectionStates ?? []).some(
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected', (connection) =>
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
); );
} }
@@ -483,11 +513,12 @@ export function selectChannelCandidate<T extends ChannelCandidate>(
routingKey?: string; routingKey?: string;
}, },
) { ) {
const eligible = items.filter((item) => const eligible = items.filter(
!options.excludedChannelIds.has(item.channelId) (item) =>
&& options.approvedChannelIds.has(item.channelId) !options.excludedChannelIds.has(item.channelId) &&
&& normalizeCarrier(item.carrier) === options.carrier options.approvedChannelIds.has(item.channelId) &&
&& isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers), normalizeCarrier(item.carrier) === options.carrier &&
isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
); );
const provinceCandidates = options.forceNational const provinceCandidates = options.forceNational
? [] ? []
@@ -537,11 +568,8 @@ export function aggregateReceiptSegmentState(
deliveredAt: Date, deliveredAt: Date,
) { ) {
if (audits.length === 0) { if (audits.length === 0) {
const status = data.receiptStatus === 'delivered' const status =
? 'delivered' data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
: data.receiptStatus === 'unknown'
? 'unknown'
: 'failed';
return { return {
terminal: true, terminal: true,
segmentTotal: 1, segmentTotal: 1,
@@ -576,7 +604,8 @@ export function aggregateReceiptSegmentState(
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered'); const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
if (delivered.length >= segmentTotal) { if (delivered.length >= segmentTotal) {
const latest = delivered.reduce((current, audit) => const latest = delivered.reduce((current, audit) =>
(audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current); (audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current,
);
return { return {
terminal: true, terminal: true,
segmentTotal, segmentTotal,
@@ -617,20 +646,26 @@ export function isSameUpstreamEndpointIdentity(
left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
) { ) {
return left.account.trim() === right.account.trim() return (
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() left.account.trim() === right.account.trim() &&
&& left.gatewayPort === right.gatewayPort left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() &&
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() left.gatewayPort === right.gatewayPort &&
&& left.cmppVersion.trim() === right.cmppVersion.trim(); left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() &&
left.cmppVersion.trim() === right.cmppVersion.trim()
);
} }
export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) { export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
return createHash('sha256').update([ return createHash('sha256')
channelId, .update(
data.gatewayMessageId, [
data.phoneNumber?.trim() ?? '', channelId,
data.receiptStatus, data.gatewayMessageId,
data.rawStatus.trim(), data.phoneNumber?.trim() ?? '',
data.errorCode ?? '', data.receiptStatus,
].join('\u0000')).digest('hex'); data.rawStatus.trim(),
data.errorCode ?? '',
].join('\u0000'),
)
.digest('hex');
} }
@@ -3287,7 +3287,7 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({ expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'submit-1' }, where: { id: 'submit-1' },
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }), data: expect.objectContaining({ sequenceId: 7n, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
}); });
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown', 'timeout'] } }, where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown', 'timeout'] } },
@@ -3736,7 +3736,7 @@ describe('SendChainService', () => {
}, },
data: { data: {
gatewayMessageId: 'GW-RECOVERED-1', gatewayMessageId: 'GW-RECOVERED-1',
sequenceId: 7, sequenceId: 7n,
}, },
}); });
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
@@ -4853,7 +4853,7 @@ describe('SendChainService', () => {
}, },
create: expect.objectContaining({ create: expect.objectContaining({
segmentTotal: 3, segmentTotal: 3,
sequenceId: 71, sequenceId: 71n,
gatewayMessageId: 'GW-SEG-1', gatewayMessageId: 'GW-SEG-1',
submitStatus: 'accepted', submitStatus: 'accepted',
}), }),
@@ -4862,7 +4862,7 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith( expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { id: 'submit-1', gatewayMessageId: null }, where: { id: 'submit-1', gatewayMessageId: null },
data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }), data: expect.objectContaining({ sequenceId: 71n, gatewayMessageId: 'GW-SEG-1' }),
}), }),
); );
}); });
@@ -5260,7 +5260,7 @@ describe('SendChainService', () => {
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ data: expect.objectContaining({
status: 'delivered', status: 'delivered',
ackResult: 0, ackResult: 0n,
deliveredAt: new Date('2026-07-14T03:40:18.060Z'), deliveredAt: new Date('2026-07-14T03:40:18.060Z'),
}), }),
}), }),
@@ -5270,7 +5270,7 @@ describe('SendChainService', () => {
update: expect.objectContaining({ update: expect.objectContaining({
status: 'acknowledged', status: 'acknowledged',
acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'), acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'),
ackResult: 0, ackResult: 0n,
}), }),
}), }),
); );
@@ -5290,7 +5290,7 @@ describe('SendChainService', () => {
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith( expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }), data: expect.objectContaining({ ackResult: 0n, ackMessageId: '0' }),
}), }),
); );
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith( expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(
@@ -1,3 +1,4 @@
import { protocolUint32ToDb } from '../common/protocol-uint32';
import { completionContext } from './completion-context'; import { completionContext } from './completion-context';
import { resolveUplinkMatch } from './uplink-matching'; import { resolveUplinkMatch } from './uplink-matching';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
@@ -33,6 +34,7 @@ export class SendDownstreamDeliveryService {
) {} ) {}
async handleUplink(data: GatewayUplinkEventDto) { async handleUplink(data: GatewayUplinkEventDto) {
protocolUint32ToDb(data.sequenceId);
if (!completionContext.getStore()) { if (!completionContext.getStore()) {
return this.prisma.$transaction( return this.prisma.$transaction(
(tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)), (tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)),
@@ -63,7 +65,7 @@ export class SendDownstreamDeliveryService {
channelId: data.channelId, channelId: data.channelId,
messageId: match.messageId, messageId: match.messageId,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
destId: data.destId, destId: data.destId,
content: data.content, content: data.content,
@@ -33,7 +33,11 @@ function normalizedFilter(filter: DownstreamRequeueFilter): DownstreamRequeueFil
}; };
} }
function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayableByDefault = true): Prisma.CmppDownstreamDeliveryWhereInput { function taskWhere(
filter: DownstreamRequeueFilter,
snapshotAt: Date,
replayableByDefault = true,
): Prisma.CmppDownstreamDeliveryWhereInput {
const normalized = normalizedFilter(filter); const normalized = normalizedFilter(filter);
const from = parseDateBoundary(normalized.createdAtFrom, false); const from = parseDateBoundary(normalized.createdAtFrom, false);
const to = parseDateBoundary(normalized.createdAtTo, true); const to = parseDateBoundary(normalized.createdAtTo, true);
@@ -41,16 +45,19 @@ function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayable
tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined, tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined,
applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : undefined, applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : undefined,
deliveryType: normalized.deliveryType !== 'all' ? normalized.deliveryType : undefined, deliveryType: normalized.deliveryType !== 'all' ? normalized.deliveryType : undefined,
status: normalized.status !== 'all' ? normalized.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined, status:
normalized.status !== 'all' ? normalized.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined,
createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt }, createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt },
OR: normalized.keyword ? [ OR: normalized.keyword
{ messageId: { contains: normalized.keyword } }, ? [
{ payload: { path: ['account'], string_contains: normalized.keyword } }, { messageId: { contains: normalized.keyword } },
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } }, { payload: { path: ['account'], string_contains: normalized.keyword } },
{ lastError: { contains: normalized.keyword } }, { payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
{ tenant: { name: { contains: normalized.keyword } } }, { lastError: { contains: normalized.keyword } },
{ application: { name: { contains: normalized.keyword } } }, { tenant: { name: { contains: normalized.keyword } } },
] : undefined, { application: { name: { contains: normalized.keyword } } },
]
: undefined,
}; };
} }
@@ -71,9 +78,19 @@ function verifyPreview(token: string, operatorId?: string) {
if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检'); if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检');
const expected = createHmac('sha256', previewSecret()).update(encoded).digest(); const expected = createHmac('sha256', previewSecret()).update(encoded).digest();
let actual: Buffer; let actual: Buffer;
try { actual = Buffer.from(supplied, 'base64url'); } catch { throw new BadRequestException('预检凭证无效,请重新预检'); } try {
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new BadRequestException('预检凭证无效,请重新预检'); actual = Buffer.from(supplied, 'base64url');
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { filter: DownstreamRequeueFilter; snapshotAt: string; operatorId?: string; expiresAt: number }; } catch {
throw new BadRequestException('预检凭证无效,请重新预检');
}
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
throw new BadRequestException('预检凭证无效,请重新预检');
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as {
filter: DownstreamRequeueFilter;
snapshotAt: string;
operatorId?: string;
expiresAt: number;
};
if (payload.expiresAt < Date.now()) throw new BadRequestException('预检凭证已过期,请重新预检'); if (payload.expiresAt < Date.now()) throw new BadRequestException('预检凭证已过期,请重新预检');
if ((payload.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致'); if ((payload.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致');
return payload; return payload;
@@ -85,21 +102,35 @@ function jsonFailures(value: unknown): Record<string, number> {
} }
export class SendDownstreamRequeueTaskService { export class SendDownstreamRequeueTaskService {
constructor(private readonly prisma: PrismaService, private readonly facade: RequeueFacade) {} constructor(
private readonly prisma: PrismaService,
private readonly facade: RequeueFacade,
) {}
async preview(filter: DownstreamRequeueFilter, operatorId?: string) { async preview(filter: DownstreamRequeueFilter, operatorId?: string) {
const snapshotAt = new Date(); const snapshotAt = new Date();
const normalized = normalizedFilter(filter); const normalized = normalizedFilter(filter);
const base = taskWhere(normalized, snapshotAt, false); const base = taskWhere(normalized, snapshotAt, false);
const replayableWhere = { AND: [base, { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput; const replayableWhere = {
AND: [base, { status: { in: REPLAYABLE_STATUSES } }],
} as Prisma.CmppDownstreamDeliveryWhereInput;
const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([ const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([
this.prisma.cmppDownstreamDelivery.count({ where: base }), this.prisma.cmppDownstreamDelivery.count({ where: base }),
this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }), this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.findFirst({ where: base, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }), this.prisma.cmppDownstreamDelivery.findFirst({
where: base,
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
}),
]); ]);
const tokenPayload = { filter: normalized, snapshotAt: snapshotAt.toISOString(), operatorId: operatorId || '', expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS }; const tokenPayload = {
filter: normalized,
snapshotAt: snapshotAt.toISOString(),
operatorId: operatorId || '',
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
};
return { return {
snapshotAt, snapshotAt,
previewToken: signPreview(tokenPayload), previewToken: signPreview(tokenPayload),
@@ -113,36 +144,82 @@ export class SendDownstreamRequeueTaskService {
}; };
} }
async create(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, createdById?: string) { async create(
data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
createdById?: string,
) {
const reason = data.reason?.trim(); const reason = data.reason?.trim();
if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字'); if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字');
const preview = verifyPreview(data.previewToken, createdById); const preview = verifyPreview(data.previewToken, createdById);
const filter = normalizedFilter(preview.filter); const filter = normalizedFilter(preview.filter);
const snapshotAt = new Date(preview.snapshotAt); const snapshotAt = new Date(preview.snapshotAt);
if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录'); if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录');
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({ where: { const activeTask = await this.prisma.downstreamRequeueTask.findFirst({
status: { in: ACTIVE_TASK_STATUSES }, where: {
...(filter.applicationId !== 'all' ? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] } : {}), status: { in: ACTIVE_TASK_STATUSES },
}, select: { taskNo: true } }); ...(filter.applicationId !== 'all'
? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] }
: {}),
},
select: { taskNo: true },
});
if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`); if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`);
const where = { AND: [taskWhere(filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput; const where = {
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ where, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], take: 100001, select: { id: true, applicationId: true, status: true } }); AND: [taskWhere(filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }],
} as Prisma.CmppDownstreamDeliveryWhereInput;
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({
where,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: 100001,
select: { id: true, applicationId: true, status: true },
});
if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录'); if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录');
if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围'); if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围');
const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10))); const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10)));
const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10))); const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10)));
const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`; const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000)
.toString()
.padStart(3, '0')}`;
const task = await this.prisma.$transaction(async (tx) => { const task = await this.prisma.$transaction(async (tx) => {
const created = await tx.downstreamRequeueTask.create({ data: { const created = await tx.downstreamRequeueTask.create({
taskNo, data: {
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null, taskNo,
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null, tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
filterSnapshot: filter as Prisma.InputJsonValue, applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
snapshotAt, reason, ratePerSecond, consecutiveFailureLimit: failureLimit, filterSnapshot: filter as Prisma.InputJsonValue,
totalCount: deliveries.length, createdById, snapshotAt,
} }); reason,
await tx.downstreamRequeueTaskItem.createMany({ data: deliveries.map((item) => ({ taskId: created.id, deliveryId: item.id, applicationId: item.applicationId, previousStatus: item.status })) }); ratePerSecond,
await tx.operationLog.create({ data: { userId: createdById, action: 'gateway.downstream_requeue_task_created', resource: 'downstream_requeue_task', resourceId: created.id, detail: { taskNo, reason, totalCount: deliveries.length, snapshotAt, filter, ratePerSecond, consecutiveFailureLimit: failureLimit } } }); consecutiveFailureLimit: failureLimit,
totalCount: deliveries.length,
createdById,
},
});
await tx.downstreamRequeueTaskItem.createMany({
data: deliveries.map((item) => ({
taskId: created.id,
deliveryId: item.id,
applicationId: item.applicationId,
previousStatus: item.status,
})),
});
await tx.operationLog.create({
data: {
userId: createdById,
action: 'gateway.downstream_requeue_task_created',
resource: 'downstream_requeue_task',
resourceId: created.id,
detail: {
taskNo,
reason,
totalCount: deliveries.length,
snapshotAt,
filter,
ratePerSecond,
consecutiveFailureLimit: failureLimit,
},
},
});
return created; return created;
}); });
return this.get(task.id); return this.get(task.id);
@@ -153,16 +230,37 @@ export class SendDownstreamRequeueTaskService {
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10))); const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10)));
const where = { status: query.status && query.status !== 'all' ? query.status : undefined }; const where = { status: query.status && query.status !== 'all' ? query.status : undefined };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTask.findMany({ where, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), this.prisma.downstreamRequeueTask.findMany({
where,
include: {
tenant: true,
application: true,
createdBy: { select: { id: true, displayName: true, username: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.downstreamRequeueTask.count({ where }), this.prisma.downstreamRequeueTask.count({ where }),
]); ]);
return { items, total, page, pageSize }; return { items, total, page, pageSize };
} }
async get(id: string) { async get(id: string) {
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id }, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } } }); const task = await this.prisma.downstreamRequeueTask.findUnique({
where: { id },
include: {
tenant: true,
application: true,
createdBy: { select: { id: true, displayName: true, username: true } },
},
});
if (!task) throw new NotFoundException('后台重投任务不存在'); if (!task) throw new NotFoundException('后台重投任务不存在');
const itemGroups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId: id }, _count: { _all: true } }); const itemGroups = await this.prisma.downstreamRequeueTaskItem.groupBy({
by: ['status'],
where: { taskId: id },
_count: { _all: true },
});
return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])) }; return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])) };
} }
@@ -175,14 +273,22 @@ export class SendDownstreamRequeueTaskService {
const where: Prisma.DownstreamRequeueTaskItemWhereInput = { const where: Prisma.DownstreamRequeueTaskItemWhereInput = {
taskId: id, taskId: id,
status: query.status && query.status !== 'all' ? query.status : undefined, status: query.status && query.status !== 'all' ? query.status : undefined,
OR: keyword ? [ OR: keyword
{ delivery: { messageId: { contains: keyword } } }, ? [
{ skipReason: { contains: keyword } }, { delivery: { messageId: { contains: keyword } } },
{ errorMessage: { contains: keyword } }, { skipReason: { contains: keyword } },
] : undefined, { errorMessage: { contains: keyword } },
]
: undefined,
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTaskItem.findMany({ where, include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } }, orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }), this.prisma.downstreamRequeueTaskItem.findMany({
where,
include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } },
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.downstreamRequeueTaskItem.count({ where }), this.prisma.downstreamRequeueTaskItem.count({ where }),
]); ]);
return { items, total, page, pageSize }; return { items, total, page, pageSize };
@@ -192,18 +298,47 @@ export class SendDownstreamRequeueTaskService {
if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作'); if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作');
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } }); const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } });
if (!task) throw new NotFoundException('后台重投任务不存在'); if (!task) throw new NotFoundException('后台重投任务不存在');
const allowed = action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused']; const allowed =
action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused'];
if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作'); if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作');
const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated'; const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated';
const updated = await this.prisma.downstreamRequeueTask.update({ where: { id }, data: { status, pausedAt: status === 'paused' ? new Date() : null, finishedAt: status === 'terminated' ? new Date() : undefined, scanLeaseOwner: null, scanLeaseUntil: null } }); const updated = await this.prisma.downstreamRequeueTask.update({
if (status === 'terminated') await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId: id, status: { in: ['queued', 'waiting_connection'] } }, data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() } }); where: { id },
await this.prisma.operationLog.create({ data: { userId: operatorId, action: `gateway.downstream_requeue_task_${action}`, resource: 'downstream_requeue_task', resourceId: id, detail: { taskNo: task.taskNo, previousStatus: task.status, status } } }); data: {
status,
pausedAt: status === 'paused' ? new Date() : null,
finishedAt: status === 'terminated' ? new Date() : undefined,
scanLeaseOwner: null,
scanLeaseUntil: null,
},
});
if (status === 'terminated')
await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { taskId: id, status: { in: ['queued', 'waiting_connection'] } },
data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() },
});
await this.prisma.operationLog.create({
data: {
userId: operatorId,
action: `gateway.downstream_requeue_task_${action}`,
resource: 'downstream_requeue_task',
resourceId: id,
detail: { taskNo: task.taskNo, previousStatus: task.status, status },
},
});
return updated; return updated;
} }
async runScan() { async runScan() {
await this.prisma.downstreamRequeueRateWindow.deleteMany({ where: { windowStartedAt: { lt: new Date(Date.now() - 5 * 60_000) } } }); await this.prisma.downstreamRequeueRateWindow.deleteMany({
const tasks = await this.prisma.downstreamRequeueTask.findMany({ where: { status: { in: ['queued', 'running'] } }, orderBy: { createdAt: 'asc' }, take: 3, select: { id: true } }); where: { windowStartedAt: { lt: new Date(Date.now() - 5 * 60_000) } },
});
const tasks = await this.prisma.downstreamRequeueTask.findMany({
where: { status: { in: ['queued', 'running'] } },
orderBy: { createdAt: 'asc' },
take: 3,
select: { id: true },
});
for (const task of tasks) await this.processTask(task.id); for (const task of tasks) await this.processTask(task.id);
} }
@@ -211,7 +346,11 @@ export class SendDownstreamRequeueTaskService {
const leaseOwner = randomUUID(); const leaseOwner = randomUUID();
const now = new Date(); const now = new Date();
const lease = await this.prisma.downstreamRequeueTask.updateMany({ const lease = await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, status: { in: ['queued', 'running'] }, OR: [{ scanLeaseUntil: null }, { scanLeaseUntil: { lt: now } }] }, where: {
id: taskId,
status: { in: ['queued', 'running'] },
OR: [{ scanLeaseUntil: null }, { scanLeaseUntil: { lt: now } }],
},
data: { scanLeaseOwner: leaseOwner, scanLeaseUntil: new Date(now.getTime() + SCAN_LEASE_MS) }, data: { scanLeaseOwner: leaseOwner, scanLeaseUntil: new Date(now.getTime() + SCAN_LEASE_MS) },
}); });
if (!lease.count) return; if (!lease.count) return;
@@ -220,7 +359,10 @@ export class SendDownstreamRequeueTaskService {
if (!task || !['queued', 'running'].includes(task.status)) return; if (!task || !['queued', 'running'].includes(task.status)) return;
// A process may die after the database claim but before the Gateway call. The lease makes that // A process may die after the database claim but before the Gateway call. The lease makes that
// ambiguous window visible and recoverable; every recovered item is revalidated before replay. // ambiguous window visible and recoverable; every recovered item is revalidated before replay.
await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId, status: 'processing', claimedAt: { lt: new Date(Date.now() - PROCESSING_LEASE_MS) } }, data: { status: 'queued', claimedAt: null, errorMessage: '执行进程中断,已回收并等待重新复核' } }); await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { taskId, status: 'processing', claimedAt: { lt: new Date(Date.now() - PROCESSING_LEASE_MS) } },
data: { status: 'queued', claimedAt: null, errorMessage: '执行进程中断,已回收并等待重新复核' },
});
let failures = await this.reconcileWaiting(taskId, jsonFailures(task.applicationFailures)); let failures = await this.reconcileWaiting(taskId, jsonFailures(task.applicationFailures));
const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit); const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (existingFailureEntry) { if (existingFailureEntry) {
@@ -228,19 +370,36 @@ export class SendDownstreamRequeueTaskService {
await this.refreshTask(taskId); await this.refreshTask(taskId);
return; return;
} }
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status: 'running', startedAt: task.startedAt ?? new Date() } }); await this.prisma.downstreamRequeueTask.update({
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'queued' }, orderBy: { createdAt: 'asc' }, take: Math.min(200, task.ratePerSecond * 3), select: { id: true, deliveryId: true, applicationId: true, previousStatus: true } }); where: { id: taskId },
data: { status: 'running', startedAt: task.startedAt ?? new Date() },
});
const items = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: 'queued' },
orderBy: { createdAt: 'asc' },
take: Math.min(200, task.ratePerSecond * 3),
select: { id: true, deliveryId: true, applicationId: true, previousStatus: true },
});
for (const item of items) { for (const item of items) {
const latestTask = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } }); const latestTask = await this.prisma.downstreamRequeueTask.findUnique({
where: { id: taskId },
select: { status: true },
});
if (!latestTask || !['queued', 'running'].includes(latestTask.status)) break; if (!latestTask || !['queued', 'running'].includes(latestTask.status)) break;
if (!(await this.consumeRate(item.applicationId, task.ratePerSecond))) continue; if (!(await this.consumeRate(item.applicationId, task.ratePerSecond))) continue;
const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'queued' }, data: { status: 'processing', claimedAt: new Date() } }); const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { id: item.id, status: 'queued' },
data: { status: 'processing', claimedAt: new Date() },
});
if (!claimed.count) continue; if (!claimed.count) continue;
const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus); const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus);
if (outcome === 'success') failures[item.applicationId] = 0; if (outcome === 'success') failures[item.applicationId] = 0;
if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1; if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
const maxFailures = Math.max(0, ...Object.values(failures)); const maxFailures = Math.max(0, ...Object.values(failures));
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } }); await this.prisma.downstreamRequeueTask.update({
where: { id: taskId },
data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures },
});
if ((failures[item.applicationId] ?? 0) >= task.consecutiveFailureLimit) { if ((failures[item.applicationId] ?? 0) >= task.consecutiveFailureLimit) {
await this.autoPause(task, item.applicationId, failures[item.applicationId]); await this.autoPause(task, item.applicationId, failures[item.applicationId]);
break; break;
@@ -248,16 +407,26 @@ export class SendDownstreamRequeueTaskService {
} }
failures = await this.reconcileWaiting(taskId, failures); failures = await this.reconcileWaiting(taskId, failures);
const maxFailures = Math.max(0, ...Object.values(failures)); const maxFailures = Math.max(0, ...Object.values(failures));
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, status: { in: ['queued', 'running'] } }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } }); await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, status: { in: ['queued', 'running'] } },
data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures },
});
const ackFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit); const ackFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]); if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]);
await this.refreshTask(taskId); await this.refreshTask(taskId);
} finally { } finally {
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, scanLeaseOwner: leaseOwner }, data: { scanLeaseOwner: null, scanLeaseUntil: null } }); await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, scanLeaseOwner: leaseOwner },
data: { scanLeaseOwner: null, scanLeaseUntil: null },
});
} }
} }
private async processItem(itemId: string, deliveryId: string, previousStatus: string): Promise<'success' | 'failed' | 'waiting' | 'skipped'> { private async processItem(
itemId: string,
deliveryId: string,
previousStatus: string,
): Promise<'success' | 'failed' | 'waiting' | 'skipped'> {
try { try {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { id: deliveryId }, where: { id: deliveryId },
@@ -271,32 +440,88 @@ export class SendDownstreamRequeueTaskService {
return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认'); return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认');
} }
if (!REPLAYABLE_STATUSES.includes(delivery.status)) { if (!REPLAYABLE_STATUSES.includes(delivery.status)) {
if (delivery.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_external_ack', skipReason: null } }); return 'waiting'; } if (delivery.status === 'awaiting_ack') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'waiting_external_ack', skipReason: null },
});
return 'waiting';
}
return this.finishItem(itemId, 'skipped', '执行前状态已变化'); return this.finishItem(itemId, 'skipped', '执行前状态已变化');
} }
if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled) return this.finishItem(itemId, 'skipped', '应用或投递能力已停用'); if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled)
if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType)) return this.finishItem(itemId, 'skipped', '投递数据不完整'); return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({ where: { deliveryId, id: { not: itemId }, status: { in: ['processing', 'waiting_ack', 'success'] } }, select: { id: true } }); if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType))
return this.finishItem(itemId, 'skipped', '投递数据不完整');
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({
where: { deliveryId, id: { not: itemId }, status: { in: ['processing', 'waiting_ack', 'success'] } },
select: { id: true },
});
if (activeOther) return this.finishItem(itemId, 'skipped', '已被其他任务处理'); if (activeOther) return this.finishItem(itemId, 'skipped', '已被其他任务处理');
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: delivery.applicationId, status: 'connected' } }); const connected = await this.prisma.cmppDownstreamConnection.count({
if (connected === 0) { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_connection', claimedAt: null, errorMessage: '客户当前离线,等待连接恢复' } }); return 'waiting'; } where: { applicationId: delivery.applicationId, status: 'connected' },
const result = await this.facade.requeueDownstreamDelivery(deliveryId) as { status?: string; lastError?: string | null }; });
if (result?.status === 'delivered') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'success', completedAt: new Date() } }); return 'success'; } if (connected === 0) {
if (result?.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_ack', completedAt: null } }); return 'waiting'; } await this.prisma.downstreamRequeueTaskItem.update({
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'failed', errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态', completedAt: new Date() } }); where: { id: itemId },
data: { status: 'waiting_connection', claimedAt: null, errorMessage: '客户当前离线,等待连接恢复' },
});
return 'waiting';
}
const result = (await this.facade.requeueDownstreamDelivery(deliveryId)) as {
status?: string;
lastError?: string | null;
};
if (result?.status === 'delivered') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'success', completedAt: new Date() },
});
return 'success';
}
if (result?.status === 'awaiting_ack') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'waiting_ack', completedAt: null },
});
return 'waiting';
}
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: {
status: 'failed',
errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态',
completedAt: new Date(),
},
});
return 'failed'; return 'failed';
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : '后台重投失败'; const message = error instanceof Error ? error.message : '后台重投失败';
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message) ? '执行前状态已变化' const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message)
: /payload|投递类型/.test(message) ? '投递数据不完整' ? '执行前状态已变化'
: /Submit|Msg_Id|Sequence/.test(message) ? '缺少原Submit映射,无法安全重投' : null; : /payload|投递类型/.test(message)
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: skipReason ? 'skipped' : 'failed', skipReason, errorMessage: skipReason ? null : message, completedAt: new Date() } }); ? '投递数据不完整'
: /Submit|Msg_Id|Sequence/.test(message)
? '缺少原Submit映射,无法安全重投'
: null;
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: {
status: skipReason ? 'skipped' : 'failed',
skipReason,
errorMessage: skipReason ? null : message,
completedAt: new Date(),
},
});
return skipReason ? 'skipped' : 'failed'; return skipReason ? 'skipped' : 'failed';
} }
} }
private async finishItem(itemId: string, status: 'skipped', reason: string): Promise<'skipped'> { private async finishItem(itemId: string, status: 'skipped', reason: string): Promise<'skipped'> {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status, skipReason: reason, completedAt: new Date() } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status, skipReason: reason, completedAt: new Date() },
});
return 'skipped'; return 'skipped';
} }
@@ -314,24 +539,61 @@ export class SendDownstreamRequeueTaskService {
} }
private async reconcileWaiting(taskId: string, currentFailures?: Record<string, number>) { private async reconcileWaiting(taskId: string, currentFailures?: Record<string, number>) {
const task = currentFailures ? null : await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { applicationFailures: true } }); const task = currentFailures
? null
: await this.prisma.downstreamRequeueTask.findUnique({
where: { id: taskId },
select: { applicationFailures: true },
});
const failures = currentFailures ?? jsonFailures(task?.applicationFailures); const failures = currentFailures ?? jsonFailures(task?.applicationFailures);
const connectionItems = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'waiting_connection' }, select: { id: true, applicationId: true } }); const connectionItems = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: 'waiting_connection' },
select: { id: true, applicationId: true },
});
for (const item of connectionItems) { for (const item of connectionItems) {
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: item.applicationId, status: 'connected' } }); const connected = await this.prisma.cmppDownstreamConnection.count({
if (connected > 0) await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'waiting_connection' }, data: { status: 'queued', errorMessage: null, claimedAt: null } }); where: { applicationId: item.applicationId, status: 'connected' },
});
if (connected > 0)
await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { id: item.id, status: 'waiting_connection' },
data: { status: 'queued', errorMessage: null, claimedAt: null },
});
} }
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } }, include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } }, take: 500 }); const items = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } },
include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } },
take: 500,
});
const now = new Date(); const now = new Date();
for (const item of items) { for (const item of items) {
if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0) { if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0n) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: item.status === 'waiting_external_ack' ? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now } : { status: 'success', completedAt: now } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data:
item.status === 'waiting_external_ack'
? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now }
: { status: 'success', completedAt: now },
});
if (item.status === 'waiting_ack') failures[item.applicationId] = 0; if (item.status === 'waiting_ack') failures[item.applicationId] = 0;
} else if (['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) || (item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)) { } else if (
['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) ||
(item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)
) {
if (item.status === 'waiting_external_ack') { if (item.status === 'waiting_external_ack') {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data: { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null },
});
} else { } else {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'failed', errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时', completedAt: now } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data: {
status: 'failed',
errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时',
completedAt: now,
},
});
failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1; failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
} }
} }
@@ -339,21 +601,70 @@ export class SendDownstreamRequeueTaskService {
return failures; return failures;
} }
private async autoPause(task: { id: string; taskNo: string; consecutiveFailureLimit: number }, applicationId: string, count: number) { private async autoPause(
task: { id: string; taskNo: string; consecutiveFailureLimit: number },
applicationId: string,
count: number,
) {
const pausedAt = new Date(); const pausedAt = new Date();
const message = `应用 ${applicationId} 连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停`; const message = `应用 ${applicationId} 连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停`;
const updated = await this.prisma.downstreamRequeueTask.updateMany({ where: { id: task.id, status: { in: ['queued', 'running'] } }, data: { status: 'paused', pausedAt, lastError: message } }); const updated = await this.prisma.downstreamRequeueTask.updateMany({
if (updated.count) await this.prisma.operationLog.create({ data: { action: 'gateway.downstream_requeue_task_auto_paused', resource: 'downstream_requeue_task', resourceId: task.id, detail: { taskNo: task.taskNo, applicationId, consecutiveFailures: count, failureLimit: task.consecutiveFailureLimit, pausedAt } } }); where: { id: task.id, status: { in: ['queued', 'running'] } },
data: { status: 'paused', pausedAt, lastError: message },
});
if (updated.count)
await this.prisma.operationLog.create({
data: {
action: 'gateway.downstream_requeue_task_auto_paused',
resource: 'downstream_requeue_task',
resourceId: task.id,
detail: {
taskNo: task.taskNo,
applicationId,
consecutiveFailures: count,
failureLimit: task.consecutiveFailureLimit,
pausedAt,
},
},
});
} }
private async refreshTask(taskId: string) { private async refreshTask(taskId: string) {
const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId }, _count: { _all: true } }); const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({
by: ['status'],
where: { taskId },
_count: { _all: true },
});
const counts = new Map(groups.map((item) => [item.status, item._count._all])); const counts = new Map(groups.map((item) => [item.status, item._count._all]));
const queued = counts.get('queued') ?? 0; const queued = counts.get('queued') ?? 0;
const active = (counts.get('processing') ?? 0) + (counts.get('waiting_connection') ?? 0) + (counts.get('waiting_ack') ?? 0) + (counts.get('waiting_external_ack') ?? 0); const active =
(counts.get('processing') ?? 0) +
(counts.get('waiting_connection') ?? 0) +
(counts.get('waiting_ack') ?? 0) +
(counts.get('waiting_external_ack') ?? 0);
const failed = counts.get('failed') ?? 0; const failed = counts.get('failed') ?? 0;
const current = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } }); const current = await this.prisma.downstreamRequeueTask.findUnique({
const status = current?.status === 'paused' || current?.status === 'terminated' ? current.status : queued + active === 0 ? (failed > 0 ? 'partial_completed' : 'completed') : 'running'; where: { id: taskId },
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status, successCount: counts.get('success') ?? 0, failedCount: failed, skippedCount: counts.get('skipped') ?? 0, waitingCount: active, ...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}) } }); select: { status: true },
});
const status =
current?.status === 'paused' || current?.status === 'terminated'
? current.status
: queued + active === 0
? failed > 0
? 'partial_completed'
: 'completed'
: 'running';
await this.prisma.downstreamRequeueTask.update({
where: { id: taskId },
data: {
status,
successCount: counts.get('success') ?? 0,
failedCount: failed,
skippedCount: counts.get('skipped') ?? 0,
waitingCount: active,
...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}),
},
});
} }
} }
@@ -1,15 +1,34 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; import { protocolUint32, protocolUint32ToDb } from '../common/protocol-uint32';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.service'; import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; import type {
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; GatewayPendingDeliveryQueryDto,
import type { SendSubmissionService } from './send-submission.service'; GatewayDownstreamSentDto,
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; GatewayDownstreamAcknowledgedDto,
GatewayDownstreamFailureType,
GatewayControlDeliveryResult,
GatewayDownstreamRecoveryStatusDto,
} from './send-chain.contracts';
import {
positiveInteger,
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
isObjectRecord,
asDateOrNull,
downstreamRetryDelayMs,
downstreamAckTimeoutMs,
downstreamMaxRetries,
downstreamControlFailureMessage,
downstreamDeliveryAttemptKey,
hasRecoveryAuditStateChanged,
normalizeRecoveryFailureCategory,
} from './send-chain.helpers';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
/** /**
* R10 downstreamState implementation. * R10 downstreamState implementation.
@@ -37,7 +56,11 @@ export class SendDownstreamStateService {
take: 500, take: 500,
}); });
for (const expired of expiredAcknowledgements) { for (const expired of expiredAcknowledgements) {
await this.facade.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout'); await this.facade.markDownstreamDeliveryFailed(
expired.id,
'CMPP_DELIVER_RESP timeout recovered after Gateway restart',
'ack_timeout',
);
} }
const now = new Date(); const now = new Date();
await this.prisma.cmppDownstreamDelivery.updateMany({ await this.prisma.cmppDownstreamDelivery.updateMany({
@@ -150,6 +173,7 @@ export class SendDownstreamStateService {
} }
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) { async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
protocolUint32(data.result, 'result');
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date(); const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
const acknowledgedMessageId = String(data.messageId ?? '').trim(); const acknowledgedMessageId = String(data.messageId ?? '').trim();
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } }); const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
@@ -166,7 +190,7 @@ export class SendDownstreamStateService {
sequenceId: data.sequenceId, sequenceId: data.sequenceId,
messageId: data.messageId, messageId: data.messageId,
acknowledgedAt, acknowledgedAt,
ackResult: data.result, ackResult: protocolUint32ToDb(data.result, 'result'),
ackDeadlineAt: null, ackDeadlineAt: null,
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected', failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
errorMessage: acknowledgementAccepted errorMessage: acknowledgementAccepted
@@ -184,7 +208,7 @@ export class SendDownstreamStateService {
messageId: data.messageId, messageId: data.messageId,
status: acknowledgementAccepted ? 'acknowledged' : 'rejected', status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
acknowledgedAt, acknowledgedAt,
ackResult: data.result, ackResult: protocolUint32ToDb(data.result, 'result'),
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected', failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
errorMessage: acknowledgementAccepted errorMessage: acknowledgementAccepted
? null ? null
@@ -201,7 +225,7 @@ export class SendDownstreamStateService {
acknowledgedAt, acknowledgedAt,
deliveredAt: acknowledgedAt, deliveredAt: acknowledgedAt,
ackDeadlineAt: null, ackDeadlineAt: null,
ackResult: data.result, ackResult: protocolUint32ToDb(data.result, 'result'),
ackSequenceId: data.sequenceId, ackSequenceId: data.sequenceId,
ackMessageId: data.messageId, ackMessageId: data.messageId,
connectionId: data.connectionId, connectionId: data.connectionId,
@@ -215,16 +239,24 @@ export class SendDownstreamStateService {
where: { id: data.id, status: { not: 'delivered' } }, where: { id: data.id, status: { not: 'delivered' } },
data: { data: {
acknowledgedAt, acknowledgedAt,
ackResult: data.result, ackResult: protocolUint32ToDb(data.result, 'result'),
ackSequenceId: data.sequenceId, ackSequenceId: data.sequenceId,
ackMessageId: data.messageId, ackMessageId: data.messageId,
connectionId: data.connectionId, connectionId: data.connectionId,
}, },
}); });
if (data.result === 0) { if (data.result === 0) {
return this.facade.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid'); return this.facade.markDownstreamDeliveryFailed(
data.id,
'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信',
'ack_invalid',
);
} }
return this.facade.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected'); return this.facade.markDownstreamDeliveryFailed(
data.id,
`downstream CMPP_DELIVER_RESP result=${data.result}`,
'ack_rejected',
);
} }
async markDownstreamDeliveryFailed( async markDownstreamDeliveryFailed(
@@ -261,7 +293,11 @@ export class SendDownstreamStateService {
return delivery; return delivery;
} }
const retryCount = (delivery.retryCount ?? 0) + 1; const retryCount = (delivery.retryCount ?? 0) + 1;
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost'; const acknowledgementFailure =
failureType === 'ack_timeout' ||
failureType === 'ack_rejected' ||
failureType === 'ack_invalid' ||
failureType === 'connection_lost';
const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false; const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout'; const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout';
const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries(); const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries();
@@ -330,12 +366,14 @@ export class SendDownstreamStateService {
if (!account) { if (!account) {
throw new BadRequestException('account is required'); throw new BadRequestException('account is required');
} }
const recoveryStatuses = (this.prisma as PrismaService & { const recoveryStatuses = (
gatewayDownstreamRecoveryStatus: { this.prisma as PrismaService & {
findUnique: (args: Record<string, unknown>) => Promise<any>; gatewayDownstreamRecoveryStatus: {
upsert: (args: Record<string, unknown>) => Promise<any>; findUnique: (args: Record<string, unknown>) => Promise<any>;
}; upsert: (args: Record<string, unknown>) => Promise<any>;
}).gatewayDownstreamRecoveryStatus; };
}
).gatewayDownstreamRecoveryStatus;
const previous = await recoveryStatuses.findUnique({ const previous = await recoveryStatuses.findUnique({
where: { account }, where: { account },
select: { select: {
@@ -499,7 +537,7 @@ export class SendDownstreamStateService {
}, },
}); });
try { try {
const result = await this.facade.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult; const result = (await this.facade.postGatewayControl(path, requestPayload)) as GatewayControlDeliveryResult;
if (result.sent || result.delivered) { if (result.sent || result.delivered) {
return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result }); return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
} }
@@ -517,10 +555,13 @@ export class SendDownstreamStateService {
} }
async recoverStaleDownstreamManualRequeues(now = new Date()) { async recoverStaleDownstreamManualRequeues(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger( const staleCutoff = new Date(
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, now.getTime() -
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, positiveInteger(
)); process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
),
);
const stale = await this.prisma.cmppDownstreamDelivery.findMany({ const stale = await this.prisma.cmppDownstreamDelivery.findMany({
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } }, where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
select: { id: true, updatedAt: true }, select: { id: true, updatedAt: true },
@@ -1,3 +1,4 @@
import { protocolUint32ToDb } from '../common/protocol-uint32';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
@@ -43,6 +44,7 @@ export class SendGatewayResultService {
) {} ) {}
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) { async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
protocolUint32ToDb(data.sequenceId);
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data); const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
const effectiveSubmitId = submitRecord.submitId; const effectiveSubmitId = submitRecord.submitId;
@@ -87,7 +89,7 @@ export class SendGatewayResultService {
gatewayMessageId: null, gatewayMessageId: null,
}, },
data: { data: {
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
submittedAt, submittedAt,
}, },
@@ -155,6 +157,8 @@ export class SendGatewayResultService {
} }
async handleSubmitResult(data: GatewaySubmitResultDto) { async handleSubmitResult(data: GatewaySubmitResultDto) {
protocolUint32ToDb(data.sequenceId);
for (const segment of data.segments ?? []) protocolUint32ToDb(segment.sequenceId);
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data); const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data);
if (data.eventId && submitRecord.resultEventId) { if (data.eventId && submitRecord.resultEventId) {
@@ -171,7 +175,7 @@ export class SendGatewayResultService {
await this.prisma.smsSubmitRecord.updateMany({ await this.prisma.smsSubmitRecord.updateMany({
where: { id: submitRecord.id }, where: { id: submitRecord.id },
data: { data: {
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
submitStatus: data.submitStatus, submitStatus: data.submitStatus,
errorCode: data.errorCode, errorCode: data.errorCode,
@@ -434,7 +438,7 @@ export class SendGatewayResultService {
channelId: data.channelId ?? message.channelId ?? null, channelId: data.channelId ?? message.channelId ?? null,
attempt, attempt,
segmentTotal, segmentTotal,
sequenceId: segment.sequenceId ?? data.sequenceId ?? null, sequenceId: protocolUint32ToDb(segment.sequenceId ?? data.sequenceId) ?? null,
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
submitStatus: status, submitStatus: status,
errorCode: segment.errorCode ?? data.errorCode ?? null, errorCode: segment.errorCode ?? data.errorCode ?? null,
@@ -452,7 +456,7 @@ export class SendGatewayResultService {
attempt, attempt,
segmentTotal, segmentTotal,
segmentIndex, segmentIndex,
sequenceId: segment.sequenceId ?? data.sequenceId ?? null, sequenceId: protocolUint32ToDb(segment.sequenceId ?? data.sequenceId) ?? null,
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
submitStatus: status, submitStatus: status,
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null, compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
+47 -33
View File
@@ -1,3 +1,4 @@
import { protocolUint32ToDb, protocolUint32FromDb } from '../common/protocol-uint32';
import { resolveReceiptAttempt } from './receipt-attempt-resolver'; import { resolveReceiptAttempt } from './receipt-attempt-resolver';
import { completionContext } from './completion-context'; import { completionContext } from './completion-context';
import { Logger, NotFoundException } from '@nestjs/common'; import { Logger, NotFoundException } from '@nestjs/common';
@@ -39,6 +40,7 @@ export class SendReceiptService {
) {} ) {}
async intakeReceipt(data: GatewayReceiptEventDto) { async intakeReceipt(data: GatewayReceiptEventDto) {
protocolUint32ToDb(data.sequenceId);
const channel = await this.prisma.smsChannel.findUnique({ const channel = await this.prisma.smsChannel.findUnique({
where: { id: data.channelId }, where: { id: data.channelId },
select: { select: {
@@ -55,34 +57,45 @@ export class SendReceiptService {
} }
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const receiptKey = receiptEventKey(data, data.channelId); const receiptKey = receiptEventKey(data, data.channelId);
const inbox = await this.prisma.upstreamReceiptInbox.upsert({ const inbox = await this.prisma.upstreamReceiptInbox
where: { receiptKey }, .upsert({
update: { where: { receiptKey },
incomingConnectionId: data.connectionId, update: {
}, incomingConnectionId: data.connectionId,
create: { },
receiptKey, create: {
incomingChannelId: data.channelId, receiptKey,
incomingConnectionId: data.connectionId, incomingChannelId: data.channelId,
upstreamAccount: channel.account, incomingConnectionId: data.connectionId,
upstreamHost: channel.gatewayHost, upstreamAccount: channel.account,
upstreamPort: channel.gatewayPort, upstreamHost: channel.gatewayHost,
protocol: channel.protocol, upstreamPort: channel.gatewayPort,
protocolVersion: channel.cmppVersion, protocol: channel.protocol,
provisionalMessageId: data.messageId, protocolVersion: channel.cmppVersion,
sequenceId: data.sequenceId, provisionalMessageId: data.messageId,
gatewayMessageId: data.gatewayMessageId, sequenceId: protocolUint32ToDb(data.sequenceId),
phoneNumber: data.phoneNumber?.trim() || null, gatewayMessageId: data.gatewayMessageId,
receiptStatus: data.receiptStatus, phoneNumber: data.phoneNumber?.trim() || null,
rawStatus: data.rawStatus, receiptStatus: data.receiptStatus,
errorCode: data.errorCode, rawStatus: data.rawStatus,
errorMessage: data.errorMessage, errorCode: data.errorCode,
deliveredAt, errorMessage: data.errorMessage,
gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null, deliveredAt,
status: 'pending', gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
nextRetryAt: new Date(), status: 'pending',
}, nextRetryAt: new Date(),
}); },
})
.catch(async (error: unknown) => {
// Prisma can emulate an upsert when the optional update is empty. Another
// callback may win the unique receiptKey insert; acknowledge only the
// exact durable fact, never swallow unrelated persistence failures.
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const existing = await this.prisma.upstreamReceiptInbox.findUnique({ where: { receiptKey } });
if (existing) return existing;
}
throw error;
});
if (['pending', 'retrying'].includes(inbox.status)) { if (['pending', 'retrying'].includes(inbox.status)) {
setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id)); setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id));
} }
@@ -143,7 +156,7 @@ export class SendReceiptService {
messageId: inbox.provisionalMessageId ?? undefined, messageId: inbox.provisionalMessageId ?? undefined,
channelId: inbox.incomingChannelId, channelId: inbox.incomingChannelId,
connectionId: inbox.incomingConnectionId ?? undefined, connectionId: inbox.incomingConnectionId ?? undefined,
sequenceId: inbox.sequenceId ?? undefined, sequenceId: protocolUint32FromDb(inbox.sequenceId),
gatewayMessageId: inbox.gatewayMessageId, gatewayMessageId: inbox.gatewayMessageId,
phoneNumber: inbox.phoneNumber ?? undefined, phoneNumber: inbox.phoneNumber ?? undefined,
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus), receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
@@ -223,6 +236,7 @@ export class SendReceiptService {
cmppVersion: string; cmppVersion: string;
}, },
) { ) {
protocolUint32ToDb(data.sequenceId);
const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity); const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
const logicalChannelId = resolved.channelId ?? data.channelId; const logicalChannelId = resolved.channelId ?? data.channelId;
const receiptKey = receiptEventKey(data, logicalChannelId); const receiptKey = receiptEventKey(data, logicalChannelId);
@@ -245,7 +259,7 @@ export class SendReceiptService {
}, },
data: { data: {
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
}, },
}); });
} }
@@ -262,7 +276,7 @@ export class SendReceiptService {
messageId: resolved.messageId, messageId: resolved.messageId,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber, phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
receiptStatus: data.receiptStatus, receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus, rawStatus: data.rawStatus,
errorCode: data.errorCode, errorCode: data.errorCode,
@@ -573,7 +587,7 @@ export class SendReceiptService {
update: { update: {
submitRecordId: submitRecord?.id ?? submitRecordId ?? null, submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
channelId: data.channelId ?? message.channelId ?? null, channelId: data.channelId ?? message.channelId ?? null,
sequenceId: data.sequenceId ?? null, sequenceId: protocolUint32ToDb(data.sequenceId) ?? null,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
receiptStatus: data.receiptStatus, receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus, rawStatus: data.rawStatus,
@@ -590,7 +604,7 @@ export class SendReceiptService {
attempt: 0, attempt: 0,
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
segmentIndex: 1, segmentIndex: 1,
sequenceId: data.sequenceId ?? null, sequenceId: protocolUint32ToDb(data.sequenceId) ?? null,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
submitStatus: submitRecord?.submitStatus ?? 'accepted', submitStatus: submitRecord?.submitStatus ?? 'accepted',
receiptStatus: data.receiptStatus, receiptStatus: data.receiptStatus,
@@ -0,0 +1,211 @@
# CMPP 协议字段兼容性整改方案
日期:2026-09-20。状态:六类整改已在 `b24cd7c` 基线上实施并完成本地定向验收;目标环境未迁移、未发布。实施证据及剩余边界见第 10 节。
## 1. 目标、范围与授权
解决平台内部字段容量和编解码规则小于 CMPP 协议允许范围的问题,避免合法回执、上行和提交结果被拒绝,或被错误解释。
本方案覆盖 CMPP 2.0/3.0 的 Gateway 编解码、Redis/HTTP 事件、后端参数、Prisma、PostgreSQL 和下游投递。初稿授权仅为文档审查;后续用户明确授权基于最新代码执行方案、修复并本地提交。实施范围为本地代码、隔离基础设施验证和文档,不包含推送、环境发布、预生产迁移、再次重投或业务配置变更。应急恢复独立记录,不作为本方案验收结果。
本方案是 [发送链路设计](phase-4-send-pipeline-redesign.md) 的字段兼容性补充,不替代其发送尝试归属、终态、补发、事务和账务设计;不替代 [计费方案](phase-5-billing-plan.md)、[测试计划](testing-plan.md) 和 [部署手册](production-deployment.md)。协议兼容整改不改变客户费率、路由、补发策略或退款规则。
实施前复读 [需求](first-version-development-requirements.md)、[系统测试用例](system-functional-test-cases.md)、[测试进度](testing-progress.md) 与当前代码;将本文新增用例纳入既有测试体系,避免形成两套验收标准。
## 2. 初稿审查基线与证据边界(历史记录)
- 本地审查基线:main / `c20c224`;存在大量其他会话未提交修改,不能将工作区整体作为本方案可提交内容。上述提交包含的长短信终态修复与本方案不是同一变更。
- 预生产只读核验:2026-09-20 14:56:46(北京时间),应用标记为 `1676cfe622f648bbfe09ac027e3db91737a7789d`。通过 `information_schema.columns` 核对实际字段类型,并核对部署目录源码/后端产物中的相关实现。该记录不是以后发布时可复用的现场结论,也不是对运行 Gateway 二进制逐项反编译验证。
- 已知线上故障:上游回执头序号 `2245918284``2245918288` 超过 PostgreSQL integer 上限,原业务回执入库受阻。完整应急恢复清单、处理结果和副作用由主任务独立记录,本方案不宣称恢复已完成。
- 其余发现来自协议原文、静态调用路径和内存边界核算;尚未用真实 TCP、真实后端和隔离 PostgreSQL 完成回归,不能写成线上已经发生同类故障。
- 本次未修改代码、数据库、Redis、业务配置或服务,未发送测试报文。
协议依据为中国移动规范原文的公开镜像:
1. [CMPP 2.0](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-2.0.pdf):第 7.3 节消息头及 SP/ISMG 消息定义。
2. [CMPP 3.0](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-v30.pdf):第 8.3 节消息头、第 8.4.1.2 节连接响应、第 8.4.3 节 Submit、第 8.4.5 节 Deliver/状态报告及 Deliver 响应。
协议中的字段字节数还须结合语义限制解释,不能把字段可表示范围当作所有业务值都有效。例如 Msg_Length 虽占 1 字节,仍受对应编码的内容长度规定约束。
## 3. 问题清单
| 编号 | 优先级 | 问题及证据 | 后果与当前确认程度 |
|---|---|---|---|
| CMPP-FIELD-01 | P1,首批必修 | 5 个 sequenceId 字段为有符号 integer;协议为 4 字节无符号整数 | 已发生回执入库故障;其他字段同类风险已确认,尚未逐条实测 |
| CMPP-FIELD-02 | P2 | 两张下游表的 ackResult 为 integerCMPP 3.0 DELIVER_RESP.Result 为 4 字节无符号整数,9 及以上可表示其他错误 | 大错误码可能入库失败,常见 0~9 不触发此容量问题;未确认线上命中 |
| CMPP-FIELD-03 | P1 | 2.0/3.0 共用固定 60 字节状态报告编解码,号码字段固定 21 字节 | 3.0 应为 32 字节号码字段、71 字节报告体;可能错读 SMSC_sequence,发出不符合 3.0 格式的报告 |
| CMPP-FIELD-04 | P1 | 下游目标选择过滤 sequenceId <= 0Gateway 用 0 表示缺失 | 0 可由无符号字段表示,已核对章节未将其保留;合法 0 序号无法正确生成或恢复通知 |
| CMPP-FIELD-05 | P1 | 3.0 CONNECT_RESP.Status 从 uint32 强转 uint8 | 256 会截为 0,客户端误判认证成功;这是本地状态误判,不代表上游真的授予权限 |
| CMPP-FIELD-06 | P2 | CMPP3_PACKET_MAX 固定 3335 | 合法的 99 个收件人、140 字节内容的 Submit 总长 3471,会在解码前被拒绝 |
### 3.1 数据库字段逐项清单
协议 uint32 表示范围为 `0..4294967295`,现有 integer 为 `-2147483648..2147483647`
| 表 | 字段 | 当前实际类型 | 对应业务 |
|---|---|---|---|
| UpstreamReceiptInbox | sequenceId | integer,可空 | 上游回执接收 |
| SmsReceiptRecord | sequenceId | integer,可空 | 业务回执记录 |
| SmsUplinkMessage | sequenceId | integer,可空 | 上行短信 |
| SmsSubmitRecord | sequenceId | integer,可空 | 向上游提交结果 |
| SmsMessageSegmentAudit | sequenceId | integer,可空 | 长短信分片提交/回执审计 |
| CmppDownstreamDelivery | ackResult | integer,可空 | 下游确认结果 |
| CmppDownstreamDeliveryAttempt | ackResult | integer,可空 | 每次下游投递确认结果 |
代码入口:[schema.prisma](../api/prisma/schema.prisma)、[回执处理](../api/src/send-chain/send-receipt.service.ts)、[提交结果](../api/src/send-chain/send-gateway-result.service.ts)、[上行处理](../api/src/send-chain/send-downstream-delivery.service.ts)、[下游状态](../api/src/send-chain/send-downstream-state.service.ts)。不得只修改收件箱,否则后续落库仍可能失败。
### 3.2 协议与调用路径证据
- [Gateway 事件结构](../gateway/internal/queue/messages.go) 的 SequenceID 已是 uint32Msg_Id 转为十进制字符串进入事件,序号超限发生在后端持久化边界。
- [receipt.go](../gateway/third_party/gocmpp/receipt.go) 的 CmppReceiptPktLen=60Pack/Unpack 均使用 21 字节号码。[上游处理](../gateway/internal/upstream/deliver.go) 对 2.0/3.0 共用此解析器;[下游投递](../gateway/internal/inbound/delivery.go) 共用此打包器。
- [下游目标选择](../api/src/send-chain/downstream-receipt-targets.ts) 过滤 <=0[下游投递](../gateway/internal/inbound/delivery.go) 使用 ==0/!=0 判定是否提供原 Submit 序号。
- [client.go](../gateway/third_party/gocmpp/client.go) 执行 `status = uint8(rsp.Status)` 后按是否为 0 判断成功;[上游连接](../gateway/internal/upstream/connection.go) 实际调用该客户端。
- [packet.go](../gateway/third_party/gocmpp/packet.go) 设置 3335 上限;[conn.go](../gateway/third_party/gocmpp/conn.go) 在收包时执行;[submit.go](../gateway/third_party/gocmpp/submit.go) 的 3.0 长度公式为 `12 + 129 + 32*N + 1 + 1 + MsgLength + 20`。N=99、MsgLength=140 得到 3471,且 99 满足规范“小于100”。
内存核算还确认:正确 71 字节回执中 SMSC_sequence 的起始偏移为 67;旧代码从偏移 56 读取。对普通 11 位号码,号码可能仍正确,但后续序号会读到补零。不能以手机号看起来正常证明整个报文正确。
## 4. 目标设计
### 4.1 uint32 存储和接口边界
建议统一采用:Gateway uint32 → JSON number → 后端校验后的 number → 持久化适配器 BigInt → Prisma BigInt / PostgreSQL bigint。
- 上述 7 字段统一升级;保留可空属性,已有 NULL 表示历史未采集,不转换成 0。
- 非空合法范围为 0~4294967295;拒绝负数、小数、NaN、无穷大和超上限值。必填协议头字段不应因内部接口可空而被静默省略。
- number 精确覆盖全部 uint32;进入 ORM 时显式转换为 BigInt,读取后检查范围再转 number,保持现有事件/API 的数值契约,避免把 Prisma BigInt 直接送入 JSON。
- 使用字段专用转换函数,覆盖查询条件、create/update/upsert、原始 SQL、批量查询、DTO、队列回放、审计、报表和导出。不能靠修改全局 BigInt.toJSON 掩盖边界。
- 数据库增加非空值范围约束。旧负值先列入异常清单,不擅自加 2^32“纠正”;须有原始报文佐证并单独处理。
- API、Worker、Callback 等所有读取这些字段的进程必须一起完成兼容验证。协议字段升级不得污染既有金额 BigInt 的单位和序列化规则。
`Msg_Id` 不套用上述方案:它是 uint64,完整上限为 18446744073709551615,超出 JavaScript 安全整数及 PostgreSQL 有符号 bigint。继续使用 Gateway uint64、跨服务十进制字符串和数据库 text;必要时校验十进制格式及 uint64 上限,不使用 Number/parseInt 中转。
### 4.2 CMPP 2.0/3.0 状态报告编解码
- 显式区分协议版本:2.0 的号码字段 21 字节、报告体 60 字节;3.0 分别为 32、71 字节。
- 收包按协商版本与报告体实际长度校验后解析;发送按下游连接协商版本打包。同步更新协议日志解析路径,避免业务解析正确但日志仍错位。
- 严格校验截断、额外字节、填充和字段边界;不能仅更改总长度常量,必须同时更改字段偏移与读写长度。
- 某些供应商可能在 3.0 连接上发送 60 字节历史格式。先盘点脱敏报文;如确有兼容需求,设计显式、可审计的兼容策略并测试,不凭长度默默切换,也不强制修改通道配置。本方案不默认启用降级。
- 不新增 SMSC_sequence、LinkID、供应商时间字段的业务存储需求;它们的原始报文保留和追踪另按既有审计方案执行。暂不落库不等于可以错读字段位置。
### 4.3 序号 0 与缺失的区别
- 用 null/undefined 或明确存在标志表示未提供;0 是合法已提供的数值。
- Gateway 可用可空 uint32 或等价带存在标志的结构,检查 JSON omitempty、默认值、恢复映射、幂等键和分片目标生成的全部路径。
- 不将空字符串经 Number 转换为 0;旧空字符串仍视为缺失。分别测试数字 0、字符串 "0"、空串、null 和未提供字段。
- 保留原始客户 Submit 序号及 Msg_Id 对应关系,不生成替代序号。回卷到 0 后,未完成请求不得发生映射碰撞。
### 4.4 连接响应状态与收包长度
- 3.0 CONNECT_RESP.Status 全程使用 uint322.0 uint8 可无损提升。只有原始值 0 表示成功,未知非零值仍失败,日志保留完整原值。
- 根据支持的命令、版本、合法收件人数及内容长度重新计算收包边界;既不能保留过小总上限,也不能取消上限或直接接受任意长度。
- 检查长度公式、实际编码长度、读缓冲区和内存分配顺序;拒绝长度头与正文不符、越界人数、畸形截断包及超大包。
- 不把扩大总包上限误写成扩大单条短信正文限制;编码长度与字符数量分别校验。
## 5. 已核对的非问题项与范围限制
- 本次检查的 Msg_Id/gatewayMessageId/ackMessageId 使用字符串存储,未发现数据库容量缩小。
- 客户原始序号、下游 ACK 序号已有 text 存储,没有此次 integer 上限问题,但仍须处理序号 0 的代码语义。
- 已查手机号、接入号、正文、原始状态码等数据库列为 text,没有 varchar 长度不足问题;协议封包处的字节长度校验仍需保留。
- 长短信 8/16 位引用号、分片数量和编码值可被现有 integer 容纳;语义校验仍须保留,不能只靠数据库类型。
- CONNECT 时间戳按 MMDDHHMMSS 编码,最大合法日历组合不超过 1231235959,不属于此次 integer 超限风险。
- 本次不是完整 CMPP 一致性认证;ISMG 间路由、所有扩展命令、二进制短信全场景及国际号码产品范围不在本次结论内。
## 6. 历史数据与应急恢复衔接
1. 实施前重新取得主任务最终恢复记录,核对原始事件摘要、业务匹配、发送尝试、扣退费、通知与客户 ACK,不直接继续任何旧脚本。
2. 迁移只扩大存储能力,不自动重投历史事件、不重新生成通知、不重算金额,也不回写短信成功状态。
3. 临时恢复时省略的原始序号,仅在存在原始事件且与记录唯一匹配时才可计划补录。补录审计字段不得重新触发补发、退款或下游推送;需要独立清单和恢复授权。
4. 事件匹配必须包含通道/供应商身份、Msg_Id、号码和发送尝试,不以号码单独认领,不能跨客户合并。
5. 原始事件可在确认耐久业务接管后按现有恢复流程收尾,但 Inbox matched 不能单独证明短信终态、账务或客户通知已经完成;还须核对收尾工作和最终结果。
6. 再次恢复时使用原业务幂等键,尊重已完成的最终决策,禁止为让客户“看到成功”覆盖真实失败。
## 7. 实施、迁移与发布顺序
建议同一维护窗口覆盖全部问题;如必须拆批,首批至少完成 FIELD-01~04 并独立闭环验证,FIELD-05/06 必须有明确后续计划。优先级是实施顺序,不代表本轮授权执行。
1. 固定当前分支、精确提交、工作区保护清单、线上版本;重新盘点受影响调用点和数据规模。将本文用例与需求、设计和测试进度同步。
2. 完成字段适配、编解码、序号存在性、状态和长度修复及测试。既有第三方库以仓库内 fork 修改留痕,不顺带升级整套依赖。
3. 在隔离 PostgreSQL 上演练旧 schema 升级,保留旧值、NULL、索引、约束及关联,测量迁移锁持有时间、磁盘/WAL 增量和失败回滚。
4. integer → bigint 可能引发表重写和强锁;按真实表规模选维护窗口。设置明确 lock_timeout/statement_timeout,超时退出而非无限等待,不在未知流量下直接执行。
5. 迁移、Prisma Client 与全部相关进程作为兼容整体交付。约束可按演练结果使用 NOT VALID 后校验,但上线不能留下未登记的未验证约束。
6. 授权发布后按 [部署手册](production-deployment.md) 使用标准 `npm run release -- ...`,依次 plan、有效测试证据、preflight、prepare、deploy、verify/status/report。每个目标环境独立核验,不用临时脚本替代应用发布。
7. 发布前保存独立数据库/应用恢复资产,确认主任务恢复不会与迁移同时处理同一批数据。需要暂停消费者时明确范围和时长,防止新旧进程交叉写入。
8. 发布后核对精确版本、实际列类型、迁移状态、全链路与队列;真实业务发送验收需另有明确环境和流量授权,不能借回归向真实客户发送测试短信。
回退限制:旧代码/旧 Prisma Client 可能无法读取新写入的大序号。应用回退不代表兼容,更不能把 bigint 直接缩回 integer。保留宽字段,优先前向修复;确需回退时必须证明旧版本兼容、未完成事件可接续。不能删除大值记录来让回退成功,已发送短信和客户已确认回执也无法用数据库快照撤销。
## 8. 验收用例
以下全部为新增待执行用例,不计入既有通过数。使用独立隔离数据;mock 只用于隔离,不替代真实 API/PG/Redis/Gateway 证据。
| 用例 | 场景 | 预期与证据 |
|---|---|---|
| CMPP-FIELD-T01 | 7 字段依次写入 0、2147483647、2147483648、4294967295 | 真实后端/PG精确保存及读回,JSON不丢精度 |
| CMPP-FIELD-T02 | -1、4294967296、小数、NaN、空串及缺失 | 按必填/可空规则拒绝或保留缺失;不误转0,不产生假成功 |
| CMPP-FIELD-T03 | 真实回执高序号,单条与批量回调入口 | 耐久接收、匹配、分片聚合、终态及通知闭环 |
| CMPP-FIELD-T04 | 上行高序号及重复事件 | 上行入库、应用归属及投递正确,无重复业务记录 |
| CMPP-FIELD-T05 | Submit/分片高序号返回 | 提交结果、分片记录及后续回执均可关联 |
| CMPP-FIELD-T06 | 3.0 DELIVER_RESP 的大非零 Result | 保存完整错误码,进入失败/重试路径,不判为成功 |
| CMPP-FIELD-T07 | uint64 Msg_Id 最大值跨 Gateway/Redis/API/PG/UI | 全链路字符串精确一致,无 Number 中转 |
| CMPP-FIELD-T08 | 2.0/3.0 标准60/71字节状态报告收发 | 逐字段、偏移、长度正确;实际TCP对端解析通过 |
| CMPP-FIELD-T09 | 3.0 32字节号码字段、最大SMSC_sequence;错版本/截断包 | 不错位,异常显式拒绝;兼容例外仅按批准策略执行 |
| CMPP-FIELD-T10 | 客户Submit序号0、4294967295及回卷 | 单条/长短信回执正常,原Msg_Id一致,不漏目标 |
| CMPP-FIELD-T11 | 序号0在Gateway重启、重连后恢复 | 使用持久化身份恢复;缺失序号不伪造为0 |
| CMPP-FIELD-T12 | CONNECT_RESP.Status=0、5、255、256、4294967295 | 只有0成功;全部非零失败并保留原码 |
| CMPP-FIELD-T13 | 3.0 Submit 99人×140字节、编码允许边界 | 3471字节合法包进入正常校验;100人按规范限制拒绝 |
| CMPP-FIELD-T14 | 巨大/过小Total_Length、错长度、截断包 | 有界读取与内存占用,明确失败,无崩溃或无界分配 |
| CMPP-FIELD-T15 | 旧值/NULL升级,迁移锁超时/中断 | 数据不变、失败可识别、恢复步骤真实演练 |
| CMPP-FIELD-T16 | 高序号重复、乱序、跨尝试、并发与故障接管 | 关联不串客户/尝试;不重复补发、扣退费或最终通知 |
| CMPP-FIELD-T17 | 真实下游ACK与断线重连 | 区分待发送、已发未确认和客户已确认,不将入队当送达 |
| CMPP-FIELD-T18 | 应急省略序号的历史记录与原始事件核对 | 只补审计不重开业务;证据不足保留待核查 |
自动检查按 [测试计划](testing-plan.md) 执行 API 定向/全量测试、类型检查、构建及质量门禁,Gateway `go test ./...``go vet ./...`。覆盖 vendored 协议库本身的测试:不能默认 Gateway 的 `./...` 会跨越嵌套 Go module。若影响前端接口或展示,补前端定向与必要回归、真实页面验收。保留未执行原因,不以编译通过替代协议互通。
停止条件:新增重复Submit、账务不一致、终态被覆盖、租户串联、无法恢复的协议解析异常、持续积压或迁移锁超时。停止放量并保留证据,不靠手工改最终状态掩盖失败。
## 9. 完成标准与交接
仅在以下事实全部具备后,才能声明目标环境整改完成:
- 7字段覆盖、版本化编解码、序号0、连接状态与收包上限已实现,并满足选定发布范围。
- 上述用例有对应证据,真实PG及TCP互通通过;历史数据兼容和故障恢复经过验证。
- 精确提交已通过标准发布,目标环境版本与实际schema一致。
- 原始事件、耐久接收、业务终态、补发尝试、账务和客户ACK分层对账,无未解释缺口;客户离线等外部阻塞单独列明。
- 更新需求、设计、系统测试用例和测试进度,分别报告本地修改、提交、推送、测试部署、预生产部署状态。
目标环境实施人接手时重新核对主任务最终恢复结果和现场,不从本方案推定任何待处理队列已排空或获得生产操作授权。
## 10. 2026-09-20 实施审查与本地验收
### 实施基线与规则补充
- 最新本地基线为 `b24cd7c`(模板拒收策略与运营页面),已包含 `c20c224` 的终态/发送尝试归属修复;已核对远端 main 仍为 `5e4d644`,本轮不推送。其他会话未提交文件及共享文档原有差异保留。
- 五个 `sequenceId` 与两个 `ackResult` 改为 nullable BigInt,迁移 `20260920160000_cmpp_protocol_uint32` 在一个事务内扩容并加入 0..4294967295 检查,锁等待 5 秒、单语句超时 5 分钟。负数历史值阻断并回滚,NULL 不回填。发布前需要按真实表规模重新评估重写/WAL/锁时间。
- `protocol-uint32.ts` 明确执行数值校验、BigInt 写入、校验后读回;API 与 callback 使用专用字段响应转换,仅处理 `sequenceId`/`ackResult`,金额 BigInt 和日期保持原有行为。协议输入不接受数字字符串、空串、浮点及越界;历史文本 Submit 序号单独解析,字符串 `"0"` 有效,空白/缺失无效。Msg_Id 保持十进制字符串。
- 版本化 `PackVersion`/`UnpackVersion` 严格检查 2.0/2.1 的 60 字节与 3.0 的 71 字节。上游、协议日志、下游均显式传版本;原 Pack/Unpack 仅作为 2.0 兼容调用保留,3.0 不自动接受 60 字节。目的号码字段为 21/32 字节,SMSC_sequence 最后 4 字节,超长字段不能截断。
- 原 Submit 序号使用可空指针区分缺失与 0;入站 JSON 不省略 0。长短信首段判断改为实际段位置。上游分配序号时避开在途 Submit/心跳,下游 ACK 注册冲突则保留原记录、返回可重试错误,不覆盖待确认回执。
- CONNECT_RESP 状态全程 uint32,非 0 一律失败并保留完整错误码。3.0 收包上限复用现有 Submit 最大长度常量 3491,覆盖 99 号码、140 字节的 3471,以及 ASCII 159 字节的 3490;规范原文为 ASCII <160,因此 160 字节拒绝。3491 只是保守的有界内存上限,不代表允许 160 字节内容。打包验证号码个数和数组、声明长度和实际内容一致;解包拒绝截断和多余字节。
- T16 并发真实验收复现一个相邻缺陷:可选 connectionId 未传时 Prisma 的空 update upsert 可能退化为先查后插,两个首次相同回执发生 P2002。本轮仅在该错误后按相同 receiptKey 回读已持久化记录再确认接收;没有对应记录或其他数据库错误仍抛出,避免假成功。
### 验收映射
| 用例 | 本地证据与结果 | 尚未覆盖的目标环境项 |
|---|---|---|
| T01T06 | 新脚本 `tools/testing/verify-protocol-fields.mjs` 用真实 Nest callback、PG、Redis 验证四个边界、七字段、单/批回执、Submit 分片、上行去重、大 ACK 非成功、非法输入无写入;通过 | 真实供应商/客户在线业务回归 |
| T07 | 最大 Msg_Id `18446744073709551615` 经过 Redis/HTTP/Prisma 保持字符串;Go TCP/回执往返不截断 | 线上供应商端到端互通 |
| T08T09 | vendored codec 与 upstream tests 验证 60/71 字节、32 字节号码、最大 SMSC、错误版本/截断;下游 2.0/3.0 TCP 回执验证通过 | 实际通道 3.0 是否存在非规范 60 字节回执需发布前抽样 |
| T10T11、T17 | 入站序号 0 JSON、缺失与 null 区分;新连接无原消息内存映射时恢复 Msg_Id2.0/3.0 真 TCP DELIVER/DELIVER_RESP Result=0;最大/0 序号冲突保护测试通过 | 实际客户端重连验证 |
| T12T14 | 真 TCP CONNECT 状态 0/5/255/256/429496729599 号码 3471/3490100 号码、超长内容、截断/多余内容、巨大/过短头拒绝;通过 | 目标环境日志、吞吐与异常隔离观察 |
| T15 | 全量 113 个已提交基线+本轮迁移在新隔离库成功;缩小结构演练末表非法值导致前面 DDL 整体回滚、5 秒锁超时回滚、NULL/0/2147483647 与七个索引保留 | 生产规模 WAL/耗时、发布切换与实际备份恢复未演练 |
| T16 | 同一大序号回执并发接收、重复批回调、相反迟到回执;另现有 receipt-finality/attempt-completion 的并发、跨尝试、故障事务恢复真实 PG 测试通过 | 整个 Gateway→Redis→API→客户单进程链路未联合运行;各边界分别验证 |
| T18 | 模拟已持久化但省略序号的历史回执,重复接收后仍 NULL,终态/通知数量不重新打开;通过 | 本轮不对线上应急历史数据做字段回填 |
验收脚本要求**新建空的本机 `cmpp_qa_*` 数据库**,应用基线及本轮迁移后构建 API,设置 `PROTOCOL_TEST_DATABASE_URL``PROTOCOL_TEST_REDIS_URL`。最高 Msg_Id 为固定边界 fixture,不能把多轮 fixture 混在同库当成新的发送尝试。脚本只连接本机,通道禁用、地址 127.0.0.1:1,不启动发送工作进程;产生合成业务/通知记录,不向真实运营商或客户发报文。
证据目录 `.local-data/protocol-fields-20260920/` 不入 Git。关键日志为 `real-chain-verified.log``migrate-verified.log``api-coverage-final.log``api-incremental.log``go-final.log``gocmpp-final.log``receipt-finality.log``attempt-completion.log`。最终迁移小样本为七表 21 行,扩容阶段约 106ms、WAL 81800 字节;不是线上容量承诺。保留首次 Redis 从错误工作目录读取旧 RDB 而启动失败、重复使用固定边界 fixture 库而匹配冲突、以及实际 P2002 并发失败日志,最终以专用 Redis 目录和全新库复验。
本地 Redis 为 5.0.14.1,实际 Stream 读写通过,但低于 BullMQ 推荐的 6.2;没有以此宣称发布环境队列版本验收。PG 驱动给出并发 query 弃用提示,当前测试通过,未修改驱动。API 响应结构保持数字/字符串不变,无前端代码或 CSS 改动;未重复浏览器验收。部署时必须一起发布 schema、API/callback/相关 worker 及 Gateway,旧 Prisma 客户端不能视为已支持超 31 位数据;不能通过缩回 integer 做应用回滚。
门禁:API 全量和增量覆盖率各 87 套 / 990 项通过,TypeScript 构建通过;Gateway 全量测试及根模块 vet 通过,vendored 模块测试通过;5 个队列契约、变更代码 lint(0 error32 个存量 any warning)、格式检查和 diff 检查通过。额外运行 vendored 模块完整 vet 发现两个原有 stdmethods 提示:`packetWriter.WriteByte`/`packetReader.ReadByte` 使用累积错误接口而非标准 io 签名;本轮未更名整个协议库,记录为既有待治理项,不宣称该额外检查通过。没有通过禁用检查隐藏问题。
测试在保留其他会话修改的当前工作区执行,提交仅纳入本轮文件和共享文档新增段落;这些本地结果不能冒充标准发布工具绑定精确提交的发布证据。隔离 PostgreSQL、Redis 已关闭,数据/日志保留。上线前仍应按标准工具从精确提交构建并生成对应验证证据。
@@ -2384,3 +2384,11 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
4. “避免影响消息分片数”固定选中,接口不可关闭;增删均保持计费单位与Gateway编码分片数,否则原文发送。只处理末尾精确指令,不修改正文、标点。重试换通道从原文计算,不叠加。 4. “避免影响消息分片数”固定选中,接口不可关闭;增删均保持计费单位与Gateway编码分片数,否则原文发送。只处理末尾精确指令,不修改正文、标点。重试换通道从原文计算,不叠加。
5. 短信列表显示提交通道的实际内容,详情保留原始内容及改写过消息的各次提交快照;客户端保留自身原文查看能力。现有计费、回执和报表单位不变。 5. 短信列表显示提交通道的实际内容,详情保留原始内容及改写过消息的各次提交快照;客户端保留自身原文查看能力。现有计费、回执和报表单位不变。
6. 设计及兼容边界见 [模板拒收策略方案](template-optout-policy-design-20260920.md)。本轮授权本地修改和提交,不推送或部署。 6. 设计及兼容边界见 [模板拒收策略方案](template-optout-policy-design-20260920.md)。本轮授权本地修改和提交,不推送或部署。
## 2026-09-20 CMPP 协议字段兼容性整改
- 协议 Sequence_Id、CMPP 3.0 ACK Result 完整支持 0..4294967295;七个历史 nullable 字段扩容并约束范围,前端/HTTP 仍接收数字,Msg_Id 仍为精确十进制字符串。金额、客户费用、路由与终态规则保持既有设计。
- 按已协商的 CMPP 2.0/2.1、3.0 编解码 60/71 字节回执;不得在 3.0 静默使用 2.0 布局。合法序号 0 可恢复下游原 Msg_Id,回绕不得覆盖在途记录;所有非零 CONNECT_RESP 状态失败,合法多号码大包可接收且异常长度有界拒绝。
- 同一回执并发首次接收只形成一个持久化事实。历史应急记录缺失序号不自动补填,不再次触发通知、补发或账务。
- 详细字段、迁移发布边界及验收依据:[CMPP 字段兼容性方案](cmpp-protocol-field-compatibility-remediation-20260920.md)。本轮授权并完成本地实现/验收/提交,环境上线另行执行标准发布。
+15
View File
@@ -5715,3 +5715,18 @@ TC-SQA-0114:真实隔离PG覆盖核心日期/日报/长短信/事务/分页
| TC-OPT-20260920-12 | 1600×1000/1366×768/390×844 | 进度条不换行;模板保存、通道缩减、原文详情、刷新跨路由正常,无控制台异常 | | TC-OPT-20260920-12 | 1600×1000/1366×768/390×844 | 进度条不换行;模板保存、通道缩减、原文详情、刷新跨路由正常,无控制台异常 |
代码级与真实本地API/PG验收分别见 testing-progress.md;无运营商真实发送授权,因此不将Outbox构造/回滚测试称为真实短信送达验收。 代码级与真实本地API/PG验收分别见 testing-progress.md;无运营商真实发送授权,因此不将Outbox构造/回滚测试称为真实短信送达验收。
## 2026-09-20 CMPP 协议字段兼容性用例登记
将 [整改方案第 8、10 节](cmpp-protocol-field-compatibility-remediation-20260920.md) 的 CMPP-FIELD-T01T18 纳入本表体系,ID 与预期不另行重定义。
| 用例组 | 执行入口 | 本轮结果与边界 |
|---|---|---|
| T01T07 | protocol-uint32.spec.ts、protocol-receipt-intake.spec.ts、tools/testing/verify-protocol-fields.mjs | 数字校验/七字段真实 PG/API/Redis、大小 Msg_Id、单批重复、高值 ACK;本地通过 |
| T08T14 | gateway/third_party/gocmpp/protocol_compatibility_test.go、inbound/upstream protocol_fields_test.go | 版本布局、32 字节号码、序号 0/回绕冲突、CONNECT 完整状态、大包及异常报文;本地 TCP/单元通过 |
| T15 | verify-protocol-fields.mjs 迁移演练 | 113 迁移新库成功;21 行小样本非法末表数据整体回滚、锁超时、NULL/旧值/索引保留通过;不是线上重写容量验证 |
| T16 | verify-protocol-fields.mjs、verify-receipt-finality.mjs、verify-attempt-completion.mjs | 高值并发去重/迟到相反回执、本机 PG 8 项终态与 11 项完成态回归通过;边界分别验证,未联合运行整套供应商到客户链路 |
| T17T18 | inbound/protocol_fields_test.go、verify-protocol-fields.mjs | 2.0/3.0 本机 TCP 重连零序号回执与 ACK,历史缺失序号不回填且终态/通知不重开;通过 |
后续上线须追加目标 schema/版本、真实供应商/客户互通、队列排空和账务对账证据。当前不标记目标环境完成,也不执行历史回执重投。
+11
View File
@@ -5199,3 +5199,14 @@ API全量81套/880项通过并达覆盖率门禁(语句67.73%、分支52.89%
- 浏览器:当前环境无Browser插件,按前端验收技能使用独立Playwright/Edge、真实API及会话,1600×1000、1366×768、390×844分别验证策略保存、固定勾选、四段条、刷新/跨路由、原文/尝试详情、通道弹窗减少运营商后保存及PG回读,无pageerror。发现并修复既有窄屏查询遮挡/弹窗运营商溢出,仅增加同页响应式规则,保持桌面布局;已查看截图核对。 - 浏览器:当前环境无Browser插件,按前端验收技能使用独立Playwright/Edge、真实API及会话,1600×1000、1366×768、390×844分别验证策略保存、固定勾选、四段条、刷新/跨路由、原文/尝试详情、通道弹窗减少运营商后保存及PG回读,无pageerror。发现并修复既有窄屏查询遮挡/弹窗运营商溢出,仅增加同页响应式规则,保持桌面布局;已查看截图核对。
- 证据:.local-data/template-optout-20260920/ 下 api-coverage-final.log、frontend-coverage-final.log、migrations-commit.log、browser-accepted.log、lint-accepted.log、format-accepted.log、api-build-accepted.log、build-accepted.log、bundle-accepted.log、go-test.log、go-vet.log及template/quality/detail/channel三尺寸截图。验证脚本不保存会话或密码,fixture.json仅含隔离业务ID。 - 证据:.local-data/template-optout-20260920/ 下 api-coverage-final.log、frontend-coverage-final.log、migrations-commit.log、browser-accepted.log、lint-accepted.log、format-accepted.log、api-build-accepted.log、build-accepted.log、bundle-accepted.log、go-test.log、go-vet.log及template/quality/detail/channel三尺寸截图。验证脚本不保存会话或密码,fixture.json仅含隔离业务ID。
- 边界/未执行:未操作测试或预生产配置;未推送、未部署、未执行运营商发送/实际回执推送/资金结算;冻结历史报表不重算。测试证明本地真实持久化和页面,不能冒称线上或实际送达验收。提交仅纳入本轮源文件、迁移和本轮文档增量,提交号以本轮Git提交为准。 - 边界/未执行:未操作测试或预生产配置;未推送、未部署、未执行运营商发送/实际回执推送/资金结算;冻结历史报表不重算。测试证明本地真实持久化和页面,不能冒称线上或实际送达验收。提交仅纳入本轮源文件、迁移和本轮文档增量,提交号以本轮Git提交为准。
## 2026-09-20 CMPP 字段兼容性方案实施与本地提交
- 用户授权:在另一个会话最新代码基础上审查侧边方案、执行修复并本地提交。核验 main 为 b24cd7c,远端 main 为 5e4d644,工作区有其他会话修改,已保护并仅暂存本轮文件/共享文档新增段落。不推送、不部署、不修改预生产数据/配置、不重投真实短信或回执。
- 实施 CMPP-FIELD-0106:七个 uint32 字段 BigInt+CHECK、显式协议数值适配与响应序列化、2.0/3.0 回执布局、序号 0 与在途回绕保护、CONNECT 非零完整状态、3.0 大包上限及计数/长度校验。并发实测发现 Prisma 空更新 upsert 首次竞态 P2002,增加同 receiptKey 已持久化确认,不掩盖其他失败。与最新终态/发送尝试归属代码兼容;仅清理受影响模块无用 import/格式,无业务规则扩展。
- 验收:API 全量及增量覆盖率均 87 套 / 990 项;API 构建、Gateway 全量 test/vet、嵌套 gocmpp test、5 个队列契约、变更代码 lint0 error/32 存量 warning)、格式与 diff 检查通过。额外嵌套库 vet 存在原有两条 WriteByte/ReadByte 标准接口签名提示,未隐瞒或跳过配置,详细登记于方案第 10 节。
- 真实本机 Nest callback + PostgreSQL + Redis0/2147483647/2147483648/4294967295 七字段、最大 uint64 字符串、并发/批量重复、上行去重、大非零 ACK、非法输入无写入、历史 NULL 不重开通过。另真实 PG 终态 8 项、完成态 11 项通过。CMPP 2.0/3.0 本机真 TCP 回执、零序号恢复/ACK、认证状态 256/max、99 号码 3471/3490 与异常长度拒绝通过。不是线上供应商/客户验收,也不是整套进程联调。
- 迁移:从已提交基线排除其他会话未提交 mobile-rule 迁移,新空库部署 113 迁移成功。七表 21 行小样本证明末表负值使此前 DDL 整体回滚,锁等待 5 秒失败回滚,旧值/NULL/7 索引保留;最终有效扩容约 106ms、WAL 81800 字节。生产规模、真实发布备份/恢复与 3.0 供应商非规范布局抽样待上线前补验,禁止把小样本当容量承诺。
- 证据:.local-data/protocol-fields-20260920/ 保留原失败和复验日志;最终 real-chain-verified.log、migrate-verified.log、api-coverage-final.log、api-incremental.log、go-final.log、gocmpp-final.log。专用 Redis 5.0.14.1 的真实 Stream 验证有效,但低于 BullMQ 推荐 6.2;PG 并发 query 弃用提示记为存量,未修改依赖。测试脚本要求全新本机 cmpp_qa_* 数据库,避免固定最大 Msg_Id fixture 跨轮冲突。
- 方案/需求/用例同步:docs/cmpp-protocol-field-compatibility-remediation-20260920.md 第 10 节完整映射 T01~T18。无前端/CSS改动、未执行浏览器回归;客户端 JSON 类型保持不变。业务状态与资金未通过迁移回填。后续所有相关进程须与 BigInt schema 配套,旧客户端不能读取高位值;应用回退不执行 bigint 缩回 integer。
@@ -80,6 +80,10 @@ func registerDownstreamAck(session *downstreamSession, deliveryID string, claimI
} }
key := downstreamAckKey(session.conn, sequenceID) key := downstreamAckKey(session.conn, sequenceID)
downstreamAckRegistry.Lock() downstreamAckRegistry.Lock()
if downstreamAckRegistry.items[key] != nil {
downstreamAckRegistry.Unlock()
return nil
}
downstreamAckRegistry.items[key] = tracker downstreamAckRegistry.items[key] = tracker
tracker.timer = time.AfterFunc(time.Until(deadline), func() { tracker.timer = time.AfterFunc(time.Until(deadline), func() {
timedOut := takeDownstreamAck(session.conn, sequenceID) timedOut := takeDownstreamAck(session.conn, sequenceID)
+29 -19
View File
@@ -15,19 +15,19 @@ import (
// arbitrary account session would acknowledge a message with the wrong Msg_Id. // arbitrary account session would acknowledge a message with the wrong Msg_Id.
type DownstreamReceipt struct { type DownstreamReceipt struct {
DeliveryID string `json:"deliveryId,omitempty"` DeliveryID string `json:"deliveryId,omitempty"`
ClaimID string `json:"claimId,omitempty"` ClaimID string `json:"claimId,omitempty"`
Account string `json:"account,omitempty"` Account string `json:"account,omitempty"`
ApplicationID string `json:"applicationId,omitempty"` ApplicationID string `json:"applicationId,omitempty"`
MessageID string `json:"messageId"` MessageID string `json:"messageId"`
GatewayMessageID string `json:"gatewayMessageId,omitempty"` GatewayMessageID string `json:"gatewayMessageId,omitempty"`
PhoneNumber string `json:"phoneNumber,omitempty"` PhoneNumber string `json:"phoneNumber,omitempty"`
ReceiptStatus string `json:"receiptStatus"` ReceiptStatus string `json:"receiptStatus"`
RawStatus string `json:"rawStatus,omitempty"` RawStatus string `json:"rawStatus,omitempty"`
ErrorCode string `json:"errorCode,omitempty"` ErrorCode string `json:"errorCode,omitempty"`
SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"` SubmitSequenceID *uint32 `json:"submitSequenceId,omitempty"`
SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"` SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"`
DeliveredAt string `json:"deliveredAt,omitempty"` DeliveredAt string `json:"deliveredAt,omitempty"`
} }
type DownstreamUplink struct { type DownstreamUplink struct {
@@ -108,7 +108,7 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
session = recoverReceiptSession(event) session = recoverReceiptSession(event)
} }
if session == nil { if session == nil {
if event.SubmitSequenceID == 0 { if event.SubmitSequenceID == nil {
return DownstreamSendResult{ return DownstreamSendResult{
Retryable: false, Retryable: false,
ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID", ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID",
@@ -158,7 +158,11 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber), DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber),
SmscSequence: uint32(time.Now().UnixNano() & 0xffffffff), SmscSequence: uint32(time.Now().UnixNano() & 0xffffffff),
} }
receiptBytes, err := receipt.Pack() version := cmpp.V30
if session.protocol == "cmpp20" || session.protocol == "cmpp21" {
version = cmpp.V20
}
receiptBytes, err := receipt.PackVersion(version)
if err != nil { if err != nil {
return DownstreamSendResult{}, err return DownstreamSendResult{}, err
} }
@@ -167,8 +171,8 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
} }
func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 { func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 {
if event.SubmitSequenceID != 0 { if event.SubmitSequenceID != nil {
return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID) return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), *event.SubmitSequenceID)
} }
if session == nil { if session == nil {
return 0 return 0
@@ -189,7 +193,7 @@ func findReceiptSession(messageID string, account string) *downstreamSession {
} }
func recoverReceiptSession(event DownstreamReceipt) *downstreamSession { func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
if event.MessageID == "" || event.SubmitSequenceID == 0 || event.Account == "" { if event.MessageID == "" || event.SubmitSequenceID == nil || event.Account == "" {
return nil return nil
} }
downstreamRegistry.RLock() downstreamRegistry.RLock()
@@ -200,7 +204,7 @@ func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
} }
recovered := *accountSession recovered := *accountSession
recovered.messageID = event.MessageID recovered.messageID = event.MessageID
recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID) recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), *event.SubmitSequenceID)
return &recovered return &recovered
} }
@@ -293,6 +297,12 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID
AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt), AckDeadlineAt: formatRFC3339Nano(ackDeadlineAt),
} }
tracker := registerDownstreamAck(session, deliveryID, claimID, sequenceID, messageID, ackDeadlineAt) tracker := registerDownstreamAck(session, deliveryID, claimID, sequenceID, messageID, ackDeadlineAt)
// A wrapped sequence cannot overwrite another unacknowledged delivery.
if deliveryID != "" && tracker == nil {
result.Retryable = true
result.ReasonCode = "SEQUENCE_IN_USE"
return result, nil
}
if err := session.conn.SendPkt(deliver, sequenceID); err != nil { if err := session.conn.SendPkt(deliver, sequenceID); err != nil {
removeDownstreamAck(tracker) removeDownstreamAck(tracker)
session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err) session.recordDownstreamProtocol(deliver, deliveryID, sequenceID, messageID, "failed", "SEND_FAILED", err)
@@ -0,0 +1,146 @@
package inbound
import (
"encoding/json"
cmpp "github.com/bigwhite/gocmpp"
"net"
"sync"
"testing"
"time"
)
func TestZeroSequenceReceiptRecoversAndAcknowledgesOverTCP(t *testing.T) {
for _, version := range []cmpp.Type{cmpp.V20, cmpp.V30} {
t.Run(version.String(), func(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
tcp, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
remote, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
server, client := cmpp.NewConn(remote, version), cmpp.NewConn(tcp, version)
server.SetState(cmpp.CONN_AUTHOK)
client.SetState(cmpp.CONN_AUTHOK)
defer server.Close()
defer client.Close()
acknowledged := make(chan downstreamDeliveryLifecycleEvent, 2)
session := &downstreamSession{account: "qa", conn: server, protocol: version.String(), connectionID: "reconnected", mu: &sync.Mutex{}, deliveryReport: func(e downstreamDeliveryLifecycleEvent) {
if e.Kind == "acknowledged" {
acknowledged <- e
}
}}
// Simulate a fresh connection with no in-memory original Submit mapping.
downstreamRegistry.Lock()
downstreamRegistry.byAccount["qa"] = session
downstreamRegistry.Unlock()
var event DownstreamReceipt
if err = json.Unmarshal([]byte(`{"deliveryId":"qa-zero","account":"qa","messageId":"original","submitSequenceId":0,"receiptStatus":"delivered","phoneNumber":"13800138000"}`), &event); err != nil {
t.Fatal(err)
}
result, err := PushReceiptWithResult(event)
if err != nil || !result.Sent {
t.Fatalf("zero cannot recover: %+v %v", result, err)
}
p, err := client.RecvAndUnpackPkt(time.Second)
if err != nil {
t.Fatal(err)
}
var content string
var seq uint32
var msgID uint64
switch pkt := p.(type) {
case *cmpp.Cmpp2DeliverReqPkt:
content, seq, msgID = pkt.MsgContent, pkt.SeqId, pkt.MsgId
case *cmpp.Cmpp3DeliverReqPkt:
content, seq, msgID = pkt.MsgContent, pkt.SeqId, pkt.MsgId
default:
t.Fatalf("bad packet %T", p)
}
var receipt cmpp.CmppReceiptPkt
if err = receipt.UnpackVersion([]byte(content), version); err != nil {
t.Fatal(err)
}
if receipt.MsgId != messageIDFrom("original", 0) || msgID != receipt.MsgId {
t.Fatalf("wrong original Msg_Id: %d", receipt.MsgId)
}
var response cmpp.Packer = &cmpp.Cmpp3DeliverRspPkt{MsgId: msgID, Result: 0}
if version == cmpp.V20 {
response = &cmpp.Cmpp2DeliverRspPkt{MsgId: msgID, Result: 0}
}
if err = client.SendPkt(response, seq); err != nil {
t.Fatal(err)
}
p, err = server.RecvAndUnpackPkt(time.Second)
if err != nil {
t.Fatal(err)
}
switch pkt := p.(type) {
case *cmpp.Cmpp2DeliverRspPkt:
handleDownstreamAcknowledgement(server, pkt.SeqId, pkt.MsgId, uint32(pkt.Result), nil)
case *cmpp.Cmpp3DeliverRspPkt:
handleDownstreamAcknowledgement(server, pkt.SeqId, pkt.MsgId, pkt.Result, nil)
}
select {
case ack := <-acknowledged:
if ack.Result != 0 || ack.MessageID != receipt.MsgId {
t.Fatal("wrong ACK")
}
case <-time.After(time.Second):
t.Fatal("missing ACK")
}
raw, err := json.Marshal(submitRequest{SequenceID: 0})
if err != nil {
t.Fatal(err)
}
var fields map[string]any
json.Unmarshal(raw, &fields)
if value, ok := fields["sequenceId"]; !ok || value != float64(0) {
t.Fatal("zero omitted from Submit callback")
}
})
}
}
func TestDownstreamAckCollisionDoesNotReplacePendingDelivery(t *testing.T) {
conn := &cmpp.Conn{}
session := &downstreamSession{conn: conn}
for _, sequence := range []uint32{^uint32(0), 0} {
first := registerDownstreamAck(session, "first", "a", sequence, 1, time.Now().Add(time.Minute))
if first == nil {
t.Fatal("first registration failed")
}
if registerDownstreamAck(session, "second", "b", sequence, 2, time.Now().Add(time.Minute)) != nil {
t.Fatal("overwrote pending delivery")
}
if takeDownstreamAck(conn, sequence) != first {
t.Fatal("lost original delivery")
}
}
}
func TestAbsentAndZeroSubmitSequenceRemainDistinct(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
for _, payload := range []string{`{}`, `{"submitSequenceId":null}`, `{"submitSequenceId":0}`} {
event := DownstreamReceipt{Account: "qa", MessageID: "original"}
if err := json.Unmarshal([]byte(payload), &event); err != nil {
t.Fatal(err)
}
result, err := PushReceiptWithResult(event)
if err != nil {
t.Fatal(err)
}
if result.Retryable != (event.SubmitSequenceID != nil) {
t.Fatalf("missing confused with zero: %s %+v", payload, result)
}
}
}
+10 -8
View File
@@ -212,7 +212,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
t.Fatalf("expected receipt deliver, got %+v", deliver) t.Fatalf("expected receipt deliver, got %+v", deliver)
} }
var receipt cmpp.CmppReceiptPkt var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(deliver.MsgContent)); err != nil { if err := receipt.UnpackVersion([]byte(deliver.MsgContent), cmpp.V30); err != nil {
t.Fatalf("unpack pushed receipt: %v", err) t.Fatalf("unpack pushed receipt: %v", err)
} }
if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13600002696" || receipt.MsgId != rsp.MsgId { if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13600002696" || receipt.MsgId != rsp.MsgId {
@@ -535,7 +535,7 @@ func TestSubmitResponsePrecedesQueuedFailureReceipt(t *testing.T) {
} }
payload, _ := json.Marshal(DownstreamReceipt{ payload, _ := json.Marshal(DownstreamReceipt{
Account: account, MessageID: "MSG-ORDER", PhoneNumber: "13500002696", Account: account, MessageID: "MSG-ORDER", PhoneNumber: "13500002696",
ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: submit.SequenceID, ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: sequencePointer(submit.SequenceID),
}) })
pendingReturned = true pendingReturned = true
_ = json.NewEncoder(w).Encode([]pendingDelivery{{ _ = json.NewEncoder(w).Encode([]pendingDelivery{{
@@ -667,7 +667,7 @@ func TestRecoverableReceiptWaitsForClientConnection(t *testing.T) {
DeliveryID: "delivery-retry", DeliveryID: "delivery-retry",
Account: "100001", Account: "100001",
MessageID: "MSG-RETRY", MessageID: "MSG-RETRY",
SubmitSequenceID: 77, SubmitSequenceID: sequencePointer(77),
ReceiptStatus: "delivered", ReceiptStatus: "delivered",
}) })
if err != nil { if err != nil {
@@ -1161,7 +1161,7 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
} }
recovered := recoverReceiptSession(DownstreamReceipt{ recovered := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: 1216579149, MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: sequencePointer(1216579149),
}) })
if recovered == nil { if recovered == nil {
t.Fatal("expected persisted submit sequence to recover receipt session") t.Fatal("expected persisted submit sequence to recover receipt session")
@@ -1170,10 +1170,10 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
t.Fatalf("unexpected recovered Msg_Id: %d", recovered.gatewayMsgID) t.Fatalf("unexpected recovered Msg_Id: %d", recovered.gatewayMsgID)
} }
first := recoverReceiptSession(DownstreamReceipt{ first := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-FIRST", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77, MessageID: "MSG-FIRST", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: sequencePointer(77),
}) })
second := recoverReceiptSession(DownstreamReceipt{ second := recoverReceiptSession(DownstreamReceipt{
MessageID: "MSG-SECOND", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77, MessageID: "MSG-SECOND", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: sequencePointer(77),
}) })
if first == nil || second == nil || first.gatewayMsgID != second.gatewayMsgID || first.gatewayMsgID != messageIDFrom("MSG-GROUP", 77) { if first == nil || second == nil || first.gatewayMsgID != second.gatewayMsgID || first.gatewayMsgID != messageIDFrom("MSG-GROUP", 77) {
t.Fatalf("multi-destination recovery did not preserve the original Msg_Id: first=%+v second=%+v", first, second) t.Fatalf("multi-destination recovery did not preserve the original Msg_Id: first=%+v second=%+v", first, second)
@@ -1183,10 +1183,10 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
func TestLongMessageReceiptsUseEachOriginalFragmentMsgID(t *testing.T) { func TestLongMessageReceiptsUseEachOriginalFragmentMsgID(t *testing.T) {
session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)} session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)}
first := downstreamReceiptMessageID(DownstreamReceipt{ first := downstreamReceiptMessageID(DownstreamReceipt{
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 101, MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: sequencePointer(101),
}, session) }, session)
second := downstreamReceiptMessageID(DownstreamReceipt{ second := downstreamReceiptMessageID(DownstreamReceipt{
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 102, MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: sequencePointer(102),
}, session) }, session)
if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) { if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) {
t.Fatalf("fragment receipt Msg_Id mismatch: first=%d second=%d", first, second) t.Fatalf("fragment receipt Msg_Id mismatch: first=%d second=%d", first, second)
@@ -1324,3 +1324,5 @@ func recvSubmitRsp20(t *testing.T, client *cmpp.Client) *cmpp.Cmpp2SubmitRspPkt
t.Fatal("timed out waiting CMPP2 submit response") t.Fatal("timed out waiting CMPP2 submit response")
return nil return nil
} }
func sequencePointer(n uint32) *uint32 { return &n }
+1 -1
View File
@@ -27,7 +27,7 @@ type submitRequest struct {
Content string `json:"content"` Content string `json:"content"`
SrcID string `json:"srcId,omitempty"` SrcID string `json:"srcId,omitempty"`
DestID string `json:"destId,omitempty"` DestID string `json:"destId,omitempty"`
SequenceID uint32 `json:"sequenceId,omitempty"` SequenceID uint32 `json:"sequenceId"`
RegisteredDelivery uint8 `json:"registeredDelivery"` RegisteredDelivery uint8 `json:"registeredDelivery"`
RemoteIP string `json:"remoteIp,omitempty"` RemoteIP string `json:"remoteIp,omitempty"`
LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"` LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"`
+7
View File
@@ -16,6 +16,13 @@ import (
// The reader and heartbeat goroutines share one connection lifecycle. Closing // The reader and heartbeat goroutines share one connection lifecycle. Closing
// the connection must wake pending submitters before scheduling pool recovery. // the connection must wake pending submitters before scheduling pool recovery.
// Caller holds c.mu across sequence selection, wire write and pending registration.
func (c *connection) sequenceAvailable(sequence uint32) bool {
_, submit := c.pending[sequence]
_, heartbeat := c.heartbeatPending[sequence]
return !submit && !heartbeat
}
type connection struct { type connection struct {
channelID string channelID string
config queue.UpstreamConfig config queue.UpstreamConfig
+4 -3
View File
@@ -15,6 +15,7 @@ import (
// mobile-originated content uses a separate long-message assembly path. // mobile-originated content uses a separate long-message assembly path.
type deliverPacket struct { type deliverPacket struct {
version cmpp.Type
seqID uint32 seqID uint32
msgID uint64 msgID uint64
destID string destID string
@@ -26,7 +27,7 @@ type deliverPacket struct {
} }
func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket { func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket {
return deliverPacket{ return deliverPacket{version: cmpp.V20,
seqID: pkt.SeqId, seqID: pkt.SeqId,
msgID: pkt.MsgId, msgID: pkt.MsgId,
destID: pkt.DestId, destID: pkt.DestId,
@@ -39,7 +40,7 @@ func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket {
} }
func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket { func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket {
return deliverPacket{ return deliverPacket{version: cmpp.V30,
seqID: pkt.SeqId, seqID: pkt.SeqId,
msgID: pkt.MsgId, msgID: pkt.MsgId,
destID: pkt.DestId, destID: pkt.DestId,
@@ -54,7 +55,7 @@ func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket {
func (c *connection) handleDeliver(pkt deliverPacket) error { func (c *connection) handleDeliver(pkt deliverPacket) error {
if pkt.registerDelivery == 1 { if pkt.registerDelivery == 1 {
var receipt cmpp.CmppReceiptPkt var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(pkt.msgContent)); err != nil { if err := receipt.UnpackVersion([]byte(pkt.msgContent), pkt.version); err != nil {
log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=parse_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err) log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=parse_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err)
return err return err
} }
+1 -1
View File
@@ -166,7 +166,7 @@ func (c *connection) sendHeartbeat() bool {
return false return false
} }
c.sendMu.Lock() c.sendMu.Lock()
seq, err := c.client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{}) seq, err := c.client.SendReqPktAvailable(&cmpp.CmppActiveTestReqPkt{}, c.sequenceAvailable)
c.sendMu.Unlock() c.sendMu.Unlock()
if err != nil { if err != nil {
c.mu.Unlock() c.mu.Unlock()
@@ -0,0 +1,50 @@
package upstream
import (
"cmpp-platform/gateway/internal/queue"
"encoding/json"
cmpp "github.com/bigwhite/gocmpp"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCMPP3ReceiptKeepsUnsignedFieldsAndFullDestination(t *testing.T) {
events := make(chan queue.ReceiptEvent, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var e queue.ReceiptEvent
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
t.Error(err)
}
events <- e
w.WriteHeader(200)
}))
defer api.Close()
receipt := cmpp.CmppReceiptPkt{MsgId: ^uint64(0), Stat: "DELIVRD", DestTerminalId: strings.Repeat("9", 32), SmscSequence: ^uint32(0)}
raw, err := receipt.PackVersion(cmpp.V30)
if err != nil {
t.Fatal(err)
}
conn := &connection{channelID: "qa", apiBaseURL: api.URL, httpClient: api.Client()}
packet := deliverPacketFromCMPP3(&cmpp.Cmpp3DeliverReqPkt{SeqId: ^uint32(0), RegisterDelivery: 1, MsgContent: string(raw)})
if err = conn.handleDeliver(packet); err != nil {
t.Fatal(err)
}
event := <-events
if event.GatewayMessageID != "18446744073709551615" || event.SequenceID != ^uint32(0) || event.PhoneNumber != receipt.DestTerminalId {
t.Fatalf("truncated callback: %+v", event)
}
packet.msgContent = string(raw[:60])
if conn.handleDeliver(packet) == nil {
t.Fatal("legacy 60-byte body silently accepted on CMPP3")
}
}
func TestUpstreamSequenceAvailabilityIncludesHeartbeatAndSubmit(t *testing.T) {
conn := &connection{pending: map[uint32]chan submitPartResponse{0: make(chan submitPartResponse)}, heartbeatPending: map[uint32]time.Time{^uint32(0): time.Now()}}
if conn.sequenceAvailable(0) || conn.sequenceAvailable(^uint32(0)) || !conn.sequenceAvailable(1) {
t.Fatal("wrapped sequence overwrites outstanding request")
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func (c *connection) emitDeliverResponse(pkt deliverPacket, responseErr error) {
channelID := c.channelID channelID := c.channelID
if pkt.registerDelivery == 1 { if pkt.registerDelivery == 1 {
var receipt cmpp.CmppReceiptPkt var receipt cmpp.CmppReceiptPkt
if err := receipt.Unpack([]byte(pkt.msgContent)); err == nil { if err := receipt.UnpackVersion([]byte(pkt.msgContent), pkt.version); err == nil {
gatewayMessageID = fmt.Sprint(receipt.MsgId) gatewayMessageID = fmt.Sprint(receipt.MsgId)
phone = strings.TrimSpace(receipt.DestTerminalId) phone = strings.TrimSpace(receipt.DestTerminalId)
if cmd, ok := c.commandFor(receipt.MsgId); ok { if cmd, ok := c.commandFor(receipt.MsgId); ok {
+2 -2
View File
@@ -101,7 +101,7 @@ func (p *connectionPool) submit(
return result, publishErr return result, publishErr
} }
} }
if firstSequence == 0 { if len(segments) == 1 {
firstSequence = seq firstSequence = seq
} }
if firstGatewayMessageID == "" { if firstGatewayMessageID == "" {
@@ -146,7 +146,7 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
} }
c.sendMu.Lock() c.sendMu.Lock()
wireSource = "write_uncertain" wireSource = "write_uncertain"
seq, err := client.SendReqPkt(pkt) seq, err := client.SendReqPktAvailable(pkt, c.sequenceAvailable)
if err == nil { if err == nil {
at := time.Now().UTC() at := time.Now().UTC()
wireAt = &at wireAt = &at
+22 -7
View File
@@ -15,6 +15,7 @@ package cmpp
import ( import (
"errors" "errors"
"fmt"
"net" "net"
"time" "time"
) )
@@ -72,7 +73,7 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
} }
var ok bool var ok bool
var status uint8 var status uint32
if cli.typ == V20 || cli.typ == V21 { if cli.typ == V20 || cli.typ == V21 {
var rsp *Cmpp2ConnRspPkt var rsp *Cmpp2ConnRspPkt
rsp, ok = p.(*Cmpp2ConnRspPkt) rsp, ok = p.(*Cmpp2ConnRspPkt)
@@ -80,7 +81,7 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
err = ErrRespNotMatch err = ErrRespNotMatch
return err return err
} }
status = rsp.Status status = uint32(rsp.Status)
} else { } else {
var rsp *Cmpp3ConnRspPkt var rsp *Cmpp3ConnRspPkt
rsp, ok = p.(*Cmpp3ConnRspPkt) rsp, ok = p.(*Cmpp3ConnRspPkt)
@@ -88,15 +89,16 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
err = ErrRespNotMatch err = ErrRespNotMatch
return err return err
} }
status = uint8(rsp.Status) status = rsp.Status
} }
if status != 0 { if status != 0 {
if status <= ErrnoConnOthers { //ErrnoConnOthers = 5 if status <= uint32(ErrnoConnOthers) { //ErrnoConnOthers = 5
err = ConnRspStatusErrMap[status] err = ConnRspStatusErrMap[uint8(status)]
} else { } else {
err = ConnRspStatusErrMap[ErrnoConnOthers] err = ConnRspStatusErrMap[ErrnoConnOthers]
} }
err = fmt.Errorf("CMPP CONNECT_RESP status=%d: %w", status, err)
return err return err
} }
@@ -112,8 +114,21 @@ func (cli *Client) Disconnect() {
// SendReqPkt pack the cmpp request packet structure and send it to the other peer. // SendReqPkt pack the cmpp request packet structure and send it to the other peer.
func (cli *Client) SendReqPkt(packet Packer) (uint32, error) { func (cli *Client) SendReqPkt(packet Packer) (uint32, error) {
seq := <-cli.conn.SeqId return cli.SendReqPktAvailable(packet, nil)
return seq, cli.conn.SendPkt(packet, seq) }
// The caller holds its pending-request lock until the returned sequence is registered.
// A wrapped sequence must never replace an outstanding request.
func (cli *Client) SendReqPktAvailable(packet Packer, available func(uint32) bool) (uint32, error) {
for {
seq, ok := <-cli.conn.SeqId
if !ok {
return 0, ErrConnIsClosed
}
if available == nil || available(seq) {
return seq, cli.conn.SendPkt(packet, seq)
}
}
} }
// SendRspPkt pack the cmpp response packet structure and send it to the other peer. // SendRspPkt pack the cmpp response packet structure and send it to the other peer.
+1 -1
View File
@@ -46,7 +46,7 @@ const (
CMPP_HEADER_LEN uint32 = 12 CMPP_HEADER_LEN uint32 = 12
CMPP2_PACKET_MAX uint32 = 2477 CMPP2_PACKET_MAX uint32 = 2477
CMPP2_PACKET_MIN uint32 = 12 CMPP2_PACKET_MIN uint32 = 12
CMPP3_PACKET_MAX uint32 = 3335 CMPP3_PACKET_MAX uint32 = Cmpp3SubmitReqPktMaxLen
CMPP3_PACKET_MIN uint32 = 12 CMPP3_PACKET_MIN uint32 = 12
) )
@@ -0,0 +1,181 @@
package cmpp
import (
"encoding/binary"
"fmt"
"net"
"reflect"
"strings"
"testing"
"time"
)
func TestReceiptVersionLayout(t *testing.T) {
for _, version := range []Type{V20, V21, V30} {
width, size := 21, 60
if version == V30 {
width, size = 32, 71
}
original := CmppReceiptPkt{MsgId: ^uint64(0), Stat: "DELIVRD", SubmitTime: "2609201200", DoneTime: "2609201201", DestTerminalId: strings.Repeat("9", width), SmscSequence: ^uint32(0)}
raw, err := original.PackVersion(version)
if err != nil {
t.Fatal(err)
}
if len(raw) != size || binary.BigEndian.Uint32(raw[size-4:]) != ^uint32(0) {
t.Fatalf("wrong layout: %x", raw)
}
var decoded CmppReceiptPkt
if err = decoded.UnpackVersion(raw, version); err != nil || !reflect.DeepEqual(decoded, original) {
t.Fatalf("roundtrip: %+v %v", decoded, err)
}
for _, bad := range [][]byte{raw[:len(raw)-1], append(append([]byte{}, raw...), 0)} {
if decoded.UnpackVersion(bad, version) == nil {
t.Fatal("invalid length accepted")
}
}
other := V30
if version == V30 {
other = V20
}
if decoded.UnpackVersion(raw, other) == nil {
t.Fatal("wrong version accepted")
}
original.DestTerminalId += "1"
if _, err = original.PackVersion(version); err == nil {
t.Fatal("truncated destination")
}
}
}
// Real TCP peers exercise the bounded reader, not only Pack/Unpack in memory.
func tcpPair(t *testing.T, version Type) (*Conn, net.Conn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
client, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
server, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
conn := NewConn(server, version)
conn.SetState(CONN_AUTHOK)
t.Cleanup(func() { conn.Close(); client.Close() })
return conn, client
}
func TestCMPP3LargeSubmitAndMalformedPackets(t *testing.T) {
for _, length := range []int{140, 159} {
p := Cmpp3SubmitReqPkt{DestUsrTl: 99, DestTerminalId: make([]string, 99), MsgLength: uint8(length), MsgContent: strings.Repeat("x", length)}
if length == 140 {
p.MsgFmt = 8
}
raw, err := p.Pack(^uint32(0))
if err != nil {
t.Fatal(err)
}
if len(raw) != 3331+length {
t.Fatalf("size %d", len(raw))
}
conn, peer := tcpPair(t, V30)
go peer.Write(raw)
pkt, err := conn.RecvAndUnpackPkt(time.Second)
if err != nil {
t.Fatal(err)
}
decoded := pkt.(*Cmpp3SubmitReqPkt)
if decoded.SeqId != ^uint32(0) || len(decoded.DestTerminalId) != 99 || decoded.MsgContent != p.MsgContent {
t.Fatal("wire mismatch")
}
var d Cmpp3SubmitReqPkt
if d.Unpack(raw[8:len(raw)-1]) == nil || d.Unpack(append(raw[8:], 0)) == nil {
t.Fatal("malformed body accepted")
}
p.DestUsrTl = 100
p.DestTerminalId = append(p.DestTerminalId, "")
if _, err = p.Pack(0); err == nil {
t.Fatal("100 destinations accepted")
}
}
for _, size := range []uint32{0, 11, CMPP3_PACKET_MAX + 1, ^uint32(0)} {
conn, peer := tcpPair(t, V30)
raw := make([]byte, 4)
binary.BigEndian.PutUint32(raw, size)
go peer.Write(raw)
if _, err := conn.RecvAndUnpackPkt(time.Second); err == nil {
t.Fatalf("accepted length %d", size)
}
}
p := Cmpp3SubmitReqPkt{DestUsrTl: 1, DestTerminalId: []string{"1"}, MsgFmt: 8, MsgLength: 141, MsgContent: strings.Repeat("a", 141)}
if _, err := p.Pack(0); err == nil {
t.Fatal("oversized non-ASCII accepted")
}
p.MsgFmt, p.MsgLength, p.MsgContent = 0, 160, strings.Repeat("a", 160)
if _, err := p.Pack(0); err == nil {
t.Fatal("ASCII must be strictly shorter than 160 bytes")
}
}
func TestConnectStatusKeepsAll32Bits(t *testing.T) {
for _, status := range []uint32{0, 5, 255, 256, ^uint32(0)} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
peer, e := listener.Accept()
if e != nil {
done <- e
return
}
conn := NewConn(peer, V30)
defer conn.Close()
conn.SetState(CONN_CONNECTED)
req, e := conn.RecvAndUnpackPkt(time.Second)
if e == nil {
e = conn.SendPkt(&Cmpp3ConnRspPkt{Status: status, Version: V30}, req.(*CmppConnReqPkt).SeqId)
}
done <- e
}()
client := NewClient(V30)
defer client.Disconnect()
err = client.Connect(listener.Addr().String(), "123456", "secret", time.Second)
if status == 0 && err != nil {
t.Fatal(err)
}
if status != 0 && (err == nil || !strings.Contains(err.Error(), fmt.Sprintf("status=%d", status))) {
t.Fatalf("status truncated: %v", err)
}
if e := <-done; e != nil {
t.Fatal(e)
}
})
}
}
func TestRequestSequenceSkipsInFlightAcrossWrap(t *testing.T) {
conn, peer := tcpPair(t, V30)
sequences := make(chan uint32, 3)
sequences <- ^uint32(0)
sequences <- 0
sequences <- 1
conn.SeqId = sequences
client := &Client{conn: conn, typ: V30}
read := make(chan error, 1)
go func() { raw := make([]byte, 12); _, err := peer.Read(raw); read <- err }()
seq, err := client.SendReqPktAvailable(&CmppActiveTestReqPkt{}, func(n uint32) bool { return n != ^uint32(0) })
if err != nil || seq != 0 {
t.Fatalf("zero lost: %d %v", seq, err)
}
if err = <-read; err != nil {
t.Fatal(err)
}
}
+41 -5
View File
@@ -13,11 +13,15 @@
package cmpp package cmpp
import "encoding/binary" import (
"encoding/binary"
"fmt"
)
// Packet length const for cmpp receipt packet. // Packet length const for cmpp receipt packet.
const ( const (
CmppReceiptPktLen uint32 = 60 //60d, 0x3c Cmpp3ReceiptPktLen uint32 = 71
CmppReceiptPktLen uint32 = 60 //60d, 0x3c
) )
type CmppReceiptPkt struct { type CmppReceiptPkt struct {
@@ -31,7 +35,18 @@ type CmppReceiptPkt struct {
// Pack packs the CmppReceiptPkt to bytes stream for client side. // Pack packs the CmppReceiptPkt to bytes stream for client side.
func (p *CmppReceiptPkt) Pack() ([]byte, error) { func (p *CmppReceiptPkt) Pack() ([]byte, error) {
var pktLen uint32 = CmppReceiptPktLen return p.PackVersion(V20)
}
// PackVersion uses the negotiated connection version, never a body-length guess.
func (p *CmppReceiptPkt) PackVersion(version Type) ([]byte, error) {
pktLen, width, err := receiptLayout(version)
if err != nil {
return nil, err
}
if len(p.Stat) > 7 || len(p.SubmitTime) > 10 || len(p.DoneTime) > 10 || len(p.DestTerminalId) > width {
return nil, fmt.Errorf("receipt field exceeds protocol width")
}
var w = newPacketWriter(pktLen) var w = newPacketWriter(pktLen)
@@ -39,7 +54,7 @@ func (p *CmppReceiptPkt) Pack() ([]byte, error) {
w.WriteFixedSizeString(p.Stat, 7) w.WriteFixedSizeString(p.Stat, 7)
w.WriteFixedSizeString(p.SubmitTime, 10) w.WriteFixedSizeString(p.SubmitTime, 10)
w.WriteFixedSizeString(p.DoneTime, 10) w.WriteFixedSizeString(p.DoneTime, 10)
w.WriteFixedSizeString(p.DestTerminalId, 21) w.WriteFixedSizeString(p.DestTerminalId, width)
w.WriteInt(binary.BigEndian, p.SmscSequence) w.WriteInt(binary.BigEndian, p.SmscSequence)
return w.Bytes() return w.Bytes()
@@ -49,6 +64,27 @@ func (p *CmppReceiptPkt) Pack() ([]byte, error) {
// After unpack, you will get all value of fields in // After unpack, you will get all value of fields in
// CmppReceiptPkt struct. // CmppReceiptPkt struct.
func (p *CmppReceiptPkt) Unpack(data []byte) error { func (p *CmppReceiptPkt) Unpack(data []byte) error {
return p.UnpackVersion(data, V20)
}
func receiptLayout(version Type) (uint32, int, error) {
switch version {
case V20, V21:
return CmppReceiptPktLen, 21, nil
case V30:
return Cmpp3ReceiptPktLen, 32, nil
}
return 0, 0, fmt.Errorf("unsupported receipt version: %v", version)
}
func (p *CmppReceiptPkt) UnpackVersion(data []byte, version Type) error {
size, width, err := receiptLayout(version)
if err != nil {
return err
}
if len(data) != int(size) {
return fmt.Errorf("invalid receipt length %d for version %v (expected %d)", len(data), version, size)
}
var r = newPacketReader(data) var r = newPacketReader(data)
r.ReadInt(binary.BigEndian, &p.MsgId) r.ReadInt(binary.BigEndian, &p.MsgId)
@@ -62,7 +98,7 @@ func (p *CmppReceiptPkt) Unpack(data []byte) error {
doneTime := r.ReadCString(10) doneTime := r.ReadCString(10)
p.DoneTime = string(doneTime) p.DoneTime = string(doneTime)
destTerminalId := r.ReadCString(21) destTerminalId := r.ReadCString(width)
p.DestTerminalId = string(destTerminalId) p.DestTerminalId = string(destTerminalId)
r.ReadInt(binary.BigEndian, &p.SmscSequence) r.ReadInt(binary.BigEndian, &p.SmscSequence)
+39 -2
View File
@@ -152,6 +152,9 @@ type Cmpp3SubmitRspPkt struct {
// Before calling Pack, you should initialize a Cmpp2SubmitReqPkt variable // Before calling Pack, you should initialize a Cmpp2SubmitReqPkt variable
// with correct field value. // with correct field value.
func (p *Cmpp2SubmitReqPkt) Pack(seqId uint32) ([]byte, error) { func (p *Cmpp2SubmitReqPkt) Pack(seqId uint32) ([]byte, error) {
if err := validateSubmit(p.DestUsrTl, p.DestTerminalId, p.MsgFmt, p.MsgLength, p.MsgContent); err != nil {
return nil, err
}
var pktLen uint32 = CMPP_HEADER_LEN + 117 + uint32(p.DestUsrTl)*21 + 1 + uint32(p.MsgLength) + 8 var pktLen uint32 = CMPP_HEADER_LEN + 117 + uint32(p.DestUsrTl)*21 + 1 + uint32(p.MsgLength) + 8
var w = newPacketWriter(pktLen) var w = newPacketWriter(pktLen)
@@ -200,6 +203,8 @@ func (p *Cmpp2SubmitReqPkt) Pack(seqId uint32) ([]byte, error) {
// Usually it is used in server side. After unpack, you will get all value of fields in // Usually it is used in server side. After unpack, you will get all value of fields in
// Cmpp2SubmitReqPkt struct. // Cmpp2SubmitReqPkt struct.
func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error { func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error {
p.DestTerminalId = nil
var r = newPacketReader(data) var r = newPacketReader(data)
// Sequence Id // Sequence Id
@@ -259,7 +264,13 @@ func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error {
reserve := r.ReadCString(8) reserve := r.ReadCString(8)
p.Reserve = string(reserve) p.Reserve = string(reserve)
return r.Error() if err := r.Error(); err != nil {
return err
}
if len(data) != 130+int(p.DestUsrTl)*21+int(p.MsgLength) {
return errSubmitInvalidStruct
}
return validateSubmit(p.DestUsrTl, p.DestTerminalId, p.MsgFmt, p.MsgLength, p.MsgContent)
} }
// Pack packs the Cmpp2SubmitRspPkt to bytes stream for Server side. // Pack packs the Cmpp2SubmitRspPkt to bytes stream for Server side.
@@ -302,6 +313,9 @@ func (p *Cmpp2SubmitRspPkt) Unpack(data []byte) error {
// Before calling Pack, you should initialize a Cmpp3SubmitReqPkt variable // Before calling Pack, you should initialize a Cmpp3SubmitReqPkt variable
// with correct field value. // with correct field value.
func (p *Cmpp3SubmitReqPkt) Pack(seqId uint32) ([]byte, error) { func (p *Cmpp3SubmitReqPkt) Pack(seqId uint32) ([]byte, error) {
if err := validateSubmit(p.DestUsrTl, p.DestTerminalId, p.MsgFmt, p.MsgLength, p.MsgContent); err != nil {
return nil, err
}
var pktLen uint32 = CMPP_HEADER_LEN + 129 + uint32(p.DestUsrTl)*32 + 1 + 1 + uint32(p.MsgLength) + 20 var pktLen uint32 = CMPP_HEADER_LEN + 129 + uint32(p.DestUsrTl)*32 + 1 + 1 + uint32(p.MsgLength) + 20
var w = newPacketWriter(pktLen) var w = newPacketWriter(pktLen)
@@ -352,6 +366,8 @@ func (p *Cmpp3SubmitReqPkt) Pack(seqId uint32) ([]byte, error) {
// Usually it is used in server side. After unpack, you will get all value of fields in // Usually it is used in server side. After unpack, you will get all value of fields in
// Cmpp3SubmitReqPkt struct. // Cmpp3SubmitReqPkt struct.
func (p *Cmpp3SubmitReqPkt) Unpack(data []byte) error { func (p *Cmpp3SubmitReqPkt) Unpack(data []byte) error {
p.DestTerminalId = nil
var r = newPacketReader(data) var r = newPacketReader(data)
// Sequence Id // Sequence Id
@@ -413,7 +429,13 @@ func (p *Cmpp3SubmitReqPkt) Unpack(data []byte) error {
linkId := r.ReadCString(20) linkId := r.ReadCString(20)
p.LinkId = string(linkId) p.LinkId = string(linkId)
return r.Error() if err := r.Error(); err != nil {
return err
}
if len(data) != 155+int(p.DestUsrTl)*32+int(p.MsgLength) {
return errSubmitInvalidStruct
}
return validateSubmit(p.DestUsrTl, p.DestTerminalId, p.MsgFmt, p.MsgLength, p.MsgContent)
} }
// Pack packs the Cmpp3SubmitRspPkt to bytes stream for Server side. // Pack packs the Cmpp3SubmitRspPkt to bytes stream for Server side.
@@ -451,3 +473,18 @@ func (p *Cmpp3SubmitRspPkt) Unpack(data []byte) error {
return r.Error() return r.Error()
} }
// The receive buffer is bounded separately; validate counts before accepting the body.
func validateSubmit(count uint8, destinations []string, format, length uint8, content string) error {
if count == 0 || count > 99 || int(count) != len(destinations) {
return errSubmitInvalidStruct
}
limit := 140
if format == 0 {
limit = 159 // CMPP specifies ASCII <160 bytes; other formats <=140.
}
if int(length) != len(content) || len(content) > limit {
return errSubmitInvalidMsgLength
}
return nil
}
+319
View File
@@ -0,0 +1,319 @@
// Local-only real PostgreSQL, Redis and Nest callback validation. No supplier connection or SMS submitter.
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomUUID, randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
const url = new URL(process.env.PROTOCOL_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_'));
const redisUrl = new URL(process.env.PROTOCOL_TEST_REDIS_URL || 'redis://127.0.0.1:16441');
assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname));
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.href,
REDIS_URL: redisUrl.href,
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
CMPP_PROCESS_ROLE: 'gateway-callback',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:1',
GATEWAY_CONNECTION_RECONCILER_DISABLED: 'true',
GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED: 'true',
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED: 'false',
SMS_SCHEDULED_DISPATCH_SCAN_ENABLED: 'false',
CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED: 'false',
UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED: 'false',
CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED: 'false',
API_ENABLE_SEND_WORKER: 'false',
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED: 'false',
});
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
// Match the existing bootstrap money serializer. Protocol field conversion is separately asserted below.
Object.defineProperty(BigInt.prototype, 'toJSON', {
configurable: true,
value() {
assert(this <= BigInt(Number.MAX_SAFE_INTEGER));
return Number(this);
},
});
const { NestFactory } = require('@nestjs/core');
const { GatewayCallbackModule } = require('./dist/gateway-callback.module');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SendChainService } = require('./dist/send-chain/send-chain.service');
const { protocolFieldsToJson } = require('./dist/common/protocol-uint32');
const { Client } = require('pg');
const Redis = require('ioredis');
const key = () => randomUUID();
const pass = (name, detail) => console.log(JSON.stringify({ passed: name, ...detail }));
const app = await NestFactory.create(GatewayCallbackModule, { logger: ['error'] });
app.setGlobalPrefix('api');
const redis = new Redis(redisUrl.href);
try {
await app.listen(0, '127.0.0.1');
const base = await app.getUrl(),
db = app.get(PrismaService),
chain = app.get(SendChainService);
assert.equal(
await db.smsMessageRecord.count(),
0,
'Use a fresh isolated database: fixed maximum Msg_Id fixtures must not collide with earlier runs',
);
const post = async (path, body, expected = 201) => {
const r = await fetch(`${base}/api/gateway/events/${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const json = await r.json();
assert.equal(r.status, expected, JSON.stringify(json));
return json;
};
const tenant = await db.tenant.create({ data: { name: 'protocol QA', code: key() } });
const application = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: 'protocol QA',
cmppAccount: key(),
cmppEnterpriseCode: '000001',
secretHash: 'disabled',
interfaceEnabled: true,
},
});
const channel = await db.smsChannel.create({
data: {
name: 'protocol QA disabled',
code: key(),
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
carriers: ['mobile'],
status: 'disabled',
},
});
const maxMsgId = '18446744073709551615';
for (const [index, sequenceId] of [0, 2147483647, 2147483648, 4294967295].entries()) {
const gatewayMessageId = (BigInt(maxMsgId) - BigInt(index)).toString();
const m = await db.smsMessageRecord.create({
data: {
messageId: key(),
phoneNumber: `1380013800${index}`,
content: 'isolated',
billingUnits: 1,
status: 'submitted',
channelId: channel.id,
submitId: key(),
tenantId: tenant.id,
applicationId: application.id,
cmppSubmitSequenceId: String(sequenceId),
cmppRegisteredDelivery: true,
},
});
await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
tenantId: tenant.id,
channelId: channel.id,
submitId: m.submitId,
submitStatus: 'accepted',
},
});
await post('submit-segment-result', {
messageId: m.messageId,
channelId: channel.id,
submitId: m.submitId,
gatewayMessageId,
sequenceId,
segmentIndex: 1,
segmentTotal: 1,
submitStatus: 'accepted',
});
const receipt = {
messageId: m.messageId,
channelId: channel.id,
sequenceId,
gatewayMessageId,
phoneNumber: m.phoneNumber,
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
deliveredAt: new Date().toISOString(),
};
const stream = `qa:protocol:${key()}`;
try {
await redis.xadd(stream, '*', 'payload', JSON.stringify(receipt));
const entries = await redis.xrange(stream, '-', '+');
const wire = JSON.parse(entries[0][1][1]);
assert.equal(wire.gatewayMessageId, gatewayMessageId);
assert.equal(wire.sequenceId, sequenceId);
const [intake, concurrent] = await Promise.all([post('receipt/intake', wire), post('receipt/intake', wire)]);
assert.equal(intake.inboxId, concurrent.inboxId);
for (let i = 0; i < 100; i++) {
const inbox = await db.upstreamReceiptInbox.findUnique({ where: { id: intake.inboxId } });
if (inbox.status === 'matched') break;
await new Promise((resolve) => setTimeout(resolve, 30));
}
const batch = await post('batch', {
batchId: key(),
gatewayInstanceId: 'qa',
events: [{ eventId: key(), type: 'receipt_intake', payload: wire }],
});
assert(
batch.results.every((r) => r.accepted),
JSON.stringify(batch),
);
const inbox = await db.upstreamReceiptInbox.findUnique({ where: { id: intake.inboxId } });
assert.equal(inbox.status, 'matched');
assert.equal(inbox.sequenceId, BigInt(sequenceId));
const receipts = await db.smsReceiptRecord.findMany({ where: { messageRecordId: m.id } });
assert.equal(receipts.length, 1);
assert.equal(receipts[0].sequenceId, BigInt(sequenceId));
const [submit, segment] = await Promise.all([
db.smsSubmitRecord.findFirst({ where: { messageRecordId: m.id } }),
db.smsMessageSegmentAudit.findFirst({ where: { messageRecordId: m.id } }),
]);
assert.equal(submit.sequenceId, BigInt(sequenceId));
assert.equal(segment.sequenceId, BigInt(sequenceId));
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
const delivery = await db.cmppDownstreamDelivery.findFirst({ where: { messageRecordId: m.id } });
assert(delivery);
assert.equal(delivery.payload.submitSequenceId, sequenceId);
await chain.acknowledgeDownstreamDelivery({
id: delivery.id,
claimId: key(),
connectionId: 'qa',
sequenceId: String(sequenceId),
messageId: gatewayMessageId,
result: sequenceId,
});
const ack = await db.cmppDownstreamDelivery.findUnique({
where: { id: delivery.id },
include: { attempts: true },
});
assert.equal(ack.ackResult, BigInt(sequenceId));
assert.equal(ack.attempts[0].ackResult, BigInt(sequenceId));
assert.equal(ack.status === 'delivered', sequenceId === 0);
assert.equal(protocolFieldsToJson(ack).ackResult, sequenceId);
const uplink = {
eventId: key(),
channelId: channel.id,
sequenceId,
gatewayMessageId,
phoneNumber: m.phoneNumber,
destId: '1069',
content: 'QA',
receivedAt: new Date().toISOString(),
};
const first = await post('uplink', uplink),
duplicate = await post('uplink', uplink);
assert.equal(first.id, duplicate.id);
assert.equal(first.sequenceId, sequenceId);
assert.equal(first.gatewayMessageId, gatewayMessageId);
assert.equal((await db.smsUplinkMessage.findUnique({ where: { id: first.id } })).sequenceId, BigInt(sequenceId));
pass('uint32_real_chain', {
sequenceId,
gatewayMessageId,
deduplicated: true,
sevenColumns: true,
notificationQueued: true,
});
if (sequenceId === 4294967295) {
// Model the emergency legacy case: receipt fact exists but optional sequence was omitted.
const notices = await db.cmppDownstreamDelivery.count({ where: { messageRecordId: m.id } });
await db.upstreamReceiptInbox.update({ where: { id: inbox.id }, data: { sequenceId: null } });
await db.smsReceiptRecord.update({ where: { id: receipts[0].id }, data: { sequenceId: null } });
await post('receipt/intake', wire);
await post('receipt', { ...wire, receiptStatus: 'undelivered', rawStatus: 'REJECTD' });
assert.equal((await db.upstreamReceiptInbox.findUnique({ where: { id: inbox.id } })).sequenceId, null);
assert.equal((await db.smsReceiptRecord.findUnique({ where: { id: receipts[0].id } })).sequenceId, null);
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
assert.equal(await db.cmppDownstreamDelivery.count({ where: { messageRecordId: m.id } }), notices);
pass('historical_null_and_contradictory_high_sequence_no_reopen');
}
} finally {
await redis.del(stream);
}
}
const count = await db.upstreamReceiptInbox.count();
for (const bad of [-1, 4294967296, 1.5, '', '0'])
await post('receipt/intake', { channelId: channel.id, sequenceId: bad }, 400);
assert.equal(await db.upstreamReceiptInbox.count(), count);
const invalid = await post('batch', {
batchId: key(),
gatewayInstanceId: 'qa',
events: [{ eventId: 'bad', type: 'receipt_intake', payload: { channelId: channel.id, sequenceId: -1 } }],
});
assert.equal(invalid.results[0].accepted, false);
assert.equal(invalid.results[0].retryable, false);
pass('invalid_events_no_writes');
const models = [
'UpstreamReceiptInbox',
'SmsReceiptRecord',
'SmsUplinkMessage',
'SmsSubmitRecord',
'SmsMessageSegmentAudit',
'CmppDownstreamDelivery',
'CmppDownstreamDeliveryAttempt',
];
for (const [i, table] of models.entries()) {
const col = i < 5 ? 'sequenceId' : 'ackResult';
for (const bad of [-1, 4294967296])
await assert.rejects(db.$executeRawUnsafe(`UPDATE "${table}" SET "${col}" = ${bad}`));
}
pass('database_constraints_all_seven');
// Rehearse the exact migration with old values and an unrelated index, then a bad historical value and lock contention.
const sql = readFileSync(
new URL('../../api/prisma/migrations/20260920160000_cmpp_protocol_uint32/migration.sql', import.meta.url),
'utf8',
);
const pg = new Client({ connectionString: url.href }),
blocker = new Client({ connectionString: url.href });
await pg.connect();
await blocker.connect();
try {
const schema = 'qa_' + key().replaceAll('-', '');
await pg.query(`CREATE SCHEMA "${schema}"; SET search_path TO "${schema}"`);
for (const [i, table] of models.entries()) {
const col = i < 5 ? 'sequenceId' : 'ackResult';
await pg.query(
`CREATE TABLE "${table}" (id int, "${col}" integer); CREATE INDEX "${table}_qa" ON "${table}"(id); INSERT INTO "${table}" VALUES (1,NULL),(2,0),(3,2147483647)`,
);
}
await pg.query('UPDATE "CmppDownstreamDeliveryAttempt" SET "ackResult"=-1 WHERE id=2');
await assert.rejects(pg.query(sql));
await pg.query('ROLLBACK');
assert.equal(
(await pg.query('SELECT pg_typeof("sequenceId")::text AS typ FROM "UpstreamReceiptInbox" LIMIT 1')).rows[0].typ,
'integer',
);
await pg.query('UPDATE "CmppDownstreamDeliveryAttempt" SET "ackResult"=0 WHERE id=2');
await blocker.query(`BEGIN; LOCK TABLE "${schema}"."UpstreamReceiptInbox" IN ACCESS EXCLUSIVE MODE`);
await assert.rejects(pg.query(sql), (e) => e.code === '55P03');
await pg.query('ROLLBACK');
await blocker.query('ROLLBACK');
const before = await pg.query('SELECT pg_current_wal_lsn() AS lsn');
const started = Date.now();
await pg.query(sql);
const wal = await pg.query('SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), $1) AS bytes', [before.rows[0].lsn]);
for (const [i, table] of models.entries()) {
const col = i < 5 ? 'sequenceId' : 'ackResult';
assert.deepEqual(
(await pg.query(`SELECT "${col}" FROM "${table}" ORDER BY id`)).rows.map((r) => r[col]),
[null, '0', '2147483647'],
);
}
assert.equal(
Number((await pg.query('SELECT count(*) AS n FROM pg_indexes WHERE schemaname=$1', [schema])).rows[0].n),
7,
);
pass('migration_rollback_lock_timeout_and_preservation', {
durationMs: Date.now() - started,
walBytes: wal.rows[0].bytes,
fixtureRows: 21,
});
} finally {
await pg.end();
await blocker.end();
}
} finally {
await redis.quit();
await app.close();
}