This commit is contained in:
@@ -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;
|
||||
@@ -1939,7 +1939,7 @@ model SmsSubmitRecord {
|
||||
sessionId String?
|
||||
retryOfSubmitRecordId String? @unique
|
||||
submitId String @unique
|
||||
sequenceId Int?
|
||||
sequenceId BigInt?
|
||||
gatewayMessageId String?
|
||||
submitStatus String @default("queued")
|
||||
resultEventId String? @unique
|
||||
@@ -2090,7 +2090,7 @@ model SmsMessageSegmentAudit {
|
||||
attempt Int @default(0)
|
||||
segmentTotal Int @default(1)
|
||||
segmentIndex Int @default(1)
|
||||
sequenceId Int?
|
||||
sequenceId BigInt?
|
||||
gatewayMessageId String?
|
||||
submitStatus String @default("queued")
|
||||
receiptStatus String?
|
||||
@@ -2203,7 +2203,7 @@ model SmsReceiptRecord {
|
||||
messageId String
|
||||
gatewayMessageId String
|
||||
phoneNumber String?
|
||||
sequenceId Int?
|
||||
sequenceId BigInt?
|
||||
receiptStatus String
|
||||
rawStatus String
|
||||
errorCode String?
|
||||
@@ -2274,7 +2274,7 @@ model SmsUplinkMessage {
|
||||
messageRecordId String?
|
||||
messageId String?
|
||||
gatewayMessageId String?
|
||||
sequenceId Int?
|
||||
sequenceId BigInt?
|
||||
phoneNumber String
|
||||
destId String
|
||||
content String
|
||||
@@ -2343,7 +2343,7 @@ model CmppDownstreamDelivery {
|
||||
sentAt DateTime?
|
||||
acknowledgedAt DateTime?
|
||||
ackDeadlineAt DateTime?
|
||||
ackResult Int?
|
||||
ackResult BigInt?
|
||||
ackSequenceId String?
|
||||
ackMessageId String?
|
||||
connectionId String?
|
||||
@@ -2451,7 +2451,7 @@ model CmppDownstreamDeliveryAttempt {
|
||||
sentAt DateTime?
|
||||
ackDeadlineAt DateTime?
|
||||
acknowledgedAt DateTime?
|
||||
ackResult Int?
|
||||
ackResult BigInt?
|
||||
failureType String?
|
||||
errorMessage String?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -2475,7 +2475,7 @@ model UpstreamReceiptInbox {
|
||||
protocol String
|
||||
protocolVersion String
|
||||
provisionalMessageId String?
|
||||
sequenceId Int?
|
||||
sequenceId BigInt?
|
||||
gatewayMessageId String
|
||||
phoneNumber String?
|
||||
receiptStatus String
|
||||
|
||||
@@ -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 { HomeModule } from './home-dashboard/home.module';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
@@ -67,7 +69,12 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
|
||||
SendingMonitorModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
||||
providers: [
|
||||
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
|
||||
RequestContextMiddleware,
|
||||
SessionValidationMiddleware,
|
||||
ManualOperationAuditMiddleware,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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 { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
@@ -21,6 +23,7 @@ import { SendChainService } from './send-chain/send-chain.service';
|
||||
],
|
||||
controllers: [DrainageSubmitGuardController, GatewayCallbackController],
|
||||
providers: [
|
||||
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
|
||||
BillingService,
|
||||
RiskReviewService,
|
||||
PhoneFrequencyService,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseProtocolSequence } from '../common/protocol-uint32';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type FinalReceiptMessage = {
|
||||
@@ -44,29 +45,33 @@ async function resolveClientReceiptTargets(
|
||||
});
|
||||
if (group?.segments.length) {
|
||||
return group.segments.flatMap((segment) => {
|
||||
const submitSequenceId = Number(segment.sequenceId);
|
||||
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return [];
|
||||
return [{
|
||||
segmentIndex: segment.segmentIndex,
|
||||
segmentTotal: group.segmentTotal,
|
||||
submitSequenceId,
|
||||
submitGroupMessageId: group.messageId,
|
||||
registeredDelivery: segment.registeredDelivery,
|
||||
}];
|
||||
const submitSequenceId = parseProtocolSequence(segment.sequenceId);
|
||||
if (submitSequenceId === undefined) return [];
|
||||
return [
|
||||
{
|
||||
segmentIndex: segment.segmentIndex,
|
||||
segmentTotal: group.segmentTotal,
|
||||
submitSequenceId,
|
||||
submitGroupMessageId: group.messageId,
|
||||
registeredDelivery: segment.registeredDelivery,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const submitSequenceId = Number(message.cmppSubmitSequenceId);
|
||||
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return [];
|
||||
return [{
|
||||
segmentIndex: 1,
|
||||
segmentTotal: 1,
|
||||
submitSequenceId,
|
||||
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
|
||||
// Null means a historical CMPP record created before this field existed.
|
||||
registeredDelivery: message.cmppRegisteredDelivery !== false,
|
||||
}];
|
||||
const submitSequenceId = parseProtocolSequence(message.cmppSubmitSequenceId);
|
||||
if (submitSequenceId === undefined) return [];
|
||||
return [
|
||||
{
|
||||
segmentIndex: 1,
|
||||
segmentTotal: 1,
|
||||
submitSequenceId,
|
||||
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
|
||||
// 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,
|
||||
});
|
||||
|
||||
const targets = (await resolveClientReceiptTargets(prisma, message))
|
||||
.filter((target) => target.registeredDelivery);
|
||||
const targets = (await resolveClientReceiptTargets(prisma, message)).filter((target) => target.registeredDelivery);
|
||||
for (const target of targets) {
|
||||
const isSingleFragment = target.segmentTotal === 1;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,16 @@
|
||||
import { parseProtocolSequence } from '../common/protocol-uint32';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
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.
|
||||
|
||||
@@ -70,6 +80,7 @@ export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
|
||||
|
||||
/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */
|
||||
export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) {
|
||||
void _drainage; // Kept in the signature for existing callers.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -171,7 +182,7 @@ export function parseImportRows(content: string, delimiter?: ',' | '\t') {
|
||||
return dataLines.map((line, index) => {
|
||||
const cells = splitImportLine(line, firstDelimiter);
|
||||
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],
|
||||
variables: {},
|
||||
};
|
||||
@@ -194,7 +205,9 @@ export function cellByHeader(headers: string[], cells: string[], candidates: str
|
||||
}
|
||||
|
||||
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 (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
||||
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
||||
@@ -226,14 +239,20 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba
|
||||
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));
|
||||
const normalized = normalizeCarrier(channelCarrier);
|
||||
return normalized === 'all' || normalized === targetCarrier;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -281,7 +300,10 @@ export function isNationalChannel(item: { province?: string | null; channel: { s
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
@@ -307,11 +329,13 @@ export function validateInboundApplicationSrcId(
|
||||
}
|
||||
|
||||
const fillPrefix = application.cmppAccessNumberFillEnabled
|
||||
? application.cmppAccessNumberFillPrefix?.trim() ?? ''
|
||||
? (application.cmppAccessNumberFillPrefix?.trim() ?? '')
|
||||
: '';
|
||||
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
|
||||
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;
|
||||
}
|
||||
@@ -330,9 +354,7 @@ export function positiveInteger(value: string | undefined, fallback: number) {
|
||||
}
|
||||
|
||||
export function parseOptionalSequenceId(value: string | null | undefined) {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
|
||||
return parseProtocolSequence(value);
|
||||
}
|
||||
|
||||
export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
|
||||
@@ -344,13 +366,17 @@ export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['r
|
||||
}
|
||||
|
||||
export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) {
|
||||
return createHash('sha256').update([
|
||||
data.id,
|
||||
data.connectionId ?? '',
|
||||
data.sequenceId ?? '',
|
||||
data.messageId ?? '',
|
||||
data.sequenceId ? '' : data.sentAt ?? '',
|
||||
].join('\u0000')).digest('hex');
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
[
|
||||
data.id,
|
||||
data.connectionId ?? '',
|
||||
data.sequenceId ?? '',
|
||||
data.messageId ?? '',
|
||||
data.sequenceId ? '' : (data.sentAt ?? ''),
|
||||
].join('\u0000'),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function shanghaiDateKey(now = new Date()) {
|
||||
@@ -378,12 +404,14 @@ export function bullmqConnection() {
|
||||
export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
|
||||
if (data.authSource && data.timestamp !== undefined) {
|
||||
const expected = createHash('md5')
|
||||
.update(Buffer.concat([
|
||||
Buffer.from(octetString(data.account, 6), 'binary'),
|
||||
Buffer.alloc(9),
|
||||
Buffer.from(secretHash),
|
||||
Buffer.from(String(data.timestamp).padStart(10, '0')),
|
||||
]))
|
||||
.update(
|
||||
Buffer.concat([
|
||||
Buffer.from(octetString(data.account, 6), 'binary'),
|
||||
Buffer.alloc(9),
|
||||
Buffer.from(secretHash),
|
||||
Buffer.from(String(data.timestamp).padStart(10, '0')),
|
||||
]),
|
||||
)
|
||||
.digest('base64');
|
||||
return expected === data.authSource;
|
||||
}
|
||||
@@ -410,8 +438,9 @@ export function hasRecoveryAuditStateChanged(
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
|
||||
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
|
||||
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'].some(
|
||||
(key) => (previous[key] ?? null) !== (current[key] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
@@ -463,8 +492,9 @@ export function isChannelSendAvailable(channel: ChannelCandidate['channel']) {
|
||||
if (channel.status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
return (channel.connectionStates ?? []).some((connection) =>
|
||||
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
|
||||
return (channel.connectionStates ?? []).some(
|
||||
(connection) =>
|
||||
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -483,11 +513,12 @@ export function selectChannelCandidate<T extends ChannelCandidate>(
|
||||
routingKey?: string;
|
||||
},
|
||||
) {
|
||||
const eligible = items.filter((item) =>
|
||||
!options.excludedChannelIds.has(item.channelId)
|
||||
&& options.approvedChannelIds.has(item.channelId)
|
||||
&& normalizeCarrier(item.carrier) === options.carrier
|
||||
&& isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
|
||||
const eligible = items.filter(
|
||||
(item) =>
|
||||
!options.excludedChannelIds.has(item.channelId) &&
|
||||
options.approvedChannelIds.has(item.channelId) &&
|
||||
normalizeCarrier(item.carrier) === options.carrier &&
|
||||
isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
|
||||
);
|
||||
const provinceCandidates = options.forceNational
|
||||
? []
|
||||
@@ -537,11 +568,8 @@ export function aggregateReceiptSegmentState(
|
||||
deliveredAt: Date,
|
||||
) {
|
||||
if (audits.length === 0) {
|
||||
const status = data.receiptStatus === 'delivered'
|
||||
? 'delivered'
|
||||
: data.receiptStatus === 'unknown'
|
||||
? 'unknown'
|
||||
: 'failed';
|
||||
const status =
|
||||
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal: 1,
|
||||
@@ -576,7 +604,8 @@ export function aggregateReceiptSegmentState(
|
||||
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
|
||||
if (delivered.length >= segmentTotal) {
|
||||
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 {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
@@ -617,20 +646,26 @@ export function isSameUpstreamEndpointIdentity(
|
||||
left: { 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()
|
||||
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase()
|
||||
&& left.gatewayPort === right.gatewayPort
|
||||
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase()
|
||||
&& left.cmppVersion.trim() === right.cmppVersion.trim();
|
||||
return (
|
||||
left.account.trim() === right.account.trim() &&
|
||||
left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() &&
|
||||
left.gatewayPort === right.gatewayPort &&
|
||||
left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() &&
|
||||
left.cmppVersion.trim() === right.cmppVersion.trim()
|
||||
);
|
||||
}
|
||||
|
||||
export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
|
||||
return createHash('sha256').update([
|
||||
channelId,
|
||||
data.gatewayMessageId,
|
||||
data.phoneNumber?.trim() ?? '',
|
||||
data.receiptStatus,
|
||||
data.rawStatus.trim(),
|
||||
data.errorCode ?? '',
|
||||
].join('\u0000')).digest('hex');
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
[
|
||||
channelId,
|
||||
data.gatewayMessageId,
|
||||
data.phoneNumber?.trim() ?? '',
|
||||
data.receiptStatus,
|
||||
data.rawStatus.trim(),
|
||||
data.errorCode ?? '',
|
||||
].join('\u0000'),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
@@ -3287,7 +3287,7 @@ describe('SendChainService', () => {
|
||||
|
||||
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
|
||||
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({
|
||||
where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown', 'timeout'] } },
|
||||
@@ -3736,7 +3736,7 @@ describe('SendChainService', () => {
|
||||
},
|
||||
data: {
|
||||
gatewayMessageId: 'GW-RECOVERED-1',
|
||||
sequenceId: 7,
|
||||
sequenceId: 7n,
|
||||
},
|
||||
});
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
@@ -4853,7 +4853,7 @@ describe('SendChainService', () => {
|
||||
},
|
||||
create: expect.objectContaining({
|
||||
segmentTotal: 3,
|
||||
sequenceId: 71,
|
||||
sequenceId: 71n,
|
||||
gatewayMessageId: 'GW-SEG-1',
|
||||
submitStatus: 'accepted',
|
||||
}),
|
||||
@@ -4862,7 +4862,7 @@ describe('SendChainService', () => {
|
||||
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
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({
|
||||
data: expect.objectContaining({
|
||||
status: 'delivered',
|
||||
ackResult: 0,
|
||||
ackResult: 0n,
|
||||
deliveredAt: new Date('2026-07-14T03:40:18.060Z'),
|
||||
}),
|
||||
}),
|
||||
@@ -5270,7 +5270,7 @@ describe('SendChainService', () => {
|
||||
update: expect.objectContaining({
|
||||
status: 'acknowledged',
|
||||
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.objectContaining({
|
||||
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }),
|
||||
data: expect.objectContaining({ ackResult: 0n, ackMessageId: '0' }),
|
||||
}),
|
||||
);
|
||||
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { protocolUint32ToDb } from '../common/protocol-uint32';
|
||||
import { completionContext } from './completion-context';
|
||||
import { resolveUplinkMatch } from './uplink-matching';
|
||||
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
||||
@@ -33,6 +34,7 @@ export class SendDownstreamDeliveryService {
|
||||
) {}
|
||||
|
||||
async handleUplink(data: GatewayUplinkEventDto) {
|
||||
protocolUint32ToDb(data.sequenceId);
|
||||
if (!completionContext.getStore()) {
|
||||
return this.prisma.$transaction(
|
||||
(tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)),
|
||||
@@ -63,7 +65,7 @@ export class SendDownstreamDeliveryService {
|
||||
channelId: data.channelId,
|
||||
messageId: match.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
sequenceId: data.sequenceId,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId),
|
||||
phoneNumber: data.phoneNumber,
|
||||
destId: data.destId,
|
||||
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 from = parseDateBoundary(normalized.createdAtFrom, false);
|
||||
const to = parseDateBoundary(normalized.createdAtTo, true);
|
||||
@@ -41,16 +45,19 @@ function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayable
|
||||
tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined,
|
||||
applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : 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 },
|
||||
OR: normalized.keyword ? [
|
||||
{ messageId: { contains: normalized.keyword } },
|
||||
{ payload: { path: ['account'], string_contains: normalized.keyword } },
|
||||
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
|
||||
{ lastError: { contains: normalized.keyword } },
|
||||
{ tenant: { name: { contains: normalized.keyword } } },
|
||||
{ application: { name: { contains: normalized.keyword } } },
|
||||
] : undefined,
|
||||
OR: normalized.keyword
|
||||
? [
|
||||
{ messageId: { contains: normalized.keyword } },
|
||||
{ payload: { path: ['account'], string_contains: normalized.keyword } },
|
||||
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
|
||||
{ lastError: { contains: normalized.keyword } },
|
||||
{ tenant: { name: { contains: normalized.keyword } } },
|
||||
{ application: { name: { contains: normalized.keyword } } },
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,9 +78,19 @@ function verifyPreview(token: string, operatorId?: string) {
|
||||
if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检');
|
||||
const expected = createHmac('sha256', previewSecret()).update(encoded).digest();
|
||||
let actual: Buffer;
|
||||
try { actual = Buffer.from(supplied, 'base64url'); } 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 };
|
||||
try {
|
||||
actual = Buffer.from(supplied, 'base64url');
|
||||
} 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.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致');
|
||||
return payload;
|
||||
@@ -85,21 +102,35 @@ function jsonFailures(value: unknown): Record<string, number> {
|
||||
}
|
||||
|
||||
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) {
|
||||
const snapshotAt = new Date();
|
||||
const normalized = normalizedFilter(filter);
|
||||
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([
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: base }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }),
|
||||
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.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 {
|
||||
snapshotAt,
|
||||
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();
|
||||
if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字');
|
||||
const preview = verifyPreview(data.previewToken, createdById);
|
||||
const filter = normalizedFilter(preview.filter);
|
||||
const snapshotAt = new Date(preview.snapshotAt);
|
||||
if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录');
|
||||
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({ where: {
|
||||
status: { in: ACTIVE_TASK_STATUSES },
|
||||
...(filter.applicationId !== 'all' ? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] } : {}),
|
||||
}, select: { taskNo: true } });
|
||||
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({
|
||||
where: {
|
||||
status: { in: ACTIVE_TASK_STATUSES },
|
||||
...(filter.applicationId !== 'all'
|
||||
? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] }
|
||||
: {}),
|
||||
},
|
||||
select: { taskNo: true },
|
||||
});
|
||||
if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`);
|
||||
const where = { 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 } });
|
||||
const where = {
|
||||
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 > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围');
|
||||
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 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 created = await tx.downstreamRequeueTask.create({ data: {
|
||||
taskNo,
|
||||
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
|
||||
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
|
||||
filterSnapshot: filter as Prisma.InputJsonValue,
|
||||
snapshotAt, reason, ratePerSecond, 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 } } });
|
||||
const created = await tx.downstreamRequeueTask.create({
|
||||
data: {
|
||||
taskNo,
|
||||
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
|
||||
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
|
||||
filterSnapshot: filter as Prisma.InputJsonValue,
|
||||
snapshotAt,
|
||||
reason,
|
||||
ratePerSecond,
|
||||
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 this.get(task.id);
|
||||
@@ -153,16 +230,37 @@ export class SendDownstreamRequeueTaskService {
|
||||
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where = { status: query.status && query.status !== 'all' ? query.status : undefined };
|
||||
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 }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
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('后台重投任务不存在');
|
||||
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])) };
|
||||
}
|
||||
|
||||
@@ -175,14 +273,22 @@ export class SendDownstreamRequeueTaskService {
|
||||
const where: Prisma.DownstreamRequeueTaskItemWhereInput = {
|
||||
taskId: id,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: keyword ? [
|
||||
{ delivery: { messageId: { contains: keyword } } },
|
||||
{ skipReason: { contains: keyword } },
|
||||
{ errorMessage: { contains: keyword } },
|
||||
] : undefined,
|
||||
OR: keyword
|
||||
? [
|
||||
{ delivery: { messageId: { contains: keyword } } },
|
||||
{ skipReason: { contains: keyword } },
|
||||
{ errorMessage: { contains: keyword } },
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
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 }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
@@ -192,18 +298,47 @@ export class SendDownstreamRequeueTaskService {
|
||||
if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作');
|
||||
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } });
|
||||
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('当前任务状态不允许此操作');
|
||||
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 } });
|
||||
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 } } });
|
||||
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,
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
async runScan() {
|
||||
await this.prisma.downstreamRequeueRateWindow.deleteMany({ 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 } });
|
||||
await this.prisma.downstreamRequeueRateWindow.deleteMany({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -211,7 +346,11 @@ export class SendDownstreamRequeueTaskService {
|
||||
const leaseOwner = randomUUID();
|
||||
const now = new Date();
|
||||
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) },
|
||||
});
|
||||
if (!lease.count) return;
|
||||
@@ -220,7 +359,10 @@ export class SendDownstreamRequeueTaskService {
|
||||
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
|
||||
// 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));
|
||||
const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
|
||||
if (existingFailureEntry) {
|
||||
@@ -228,19 +370,36 @@ export class SendDownstreamRequeueTaskService {
|
||||
await this.refreshTask(taskId);
|
||||
return;
|
||||
}
|
||||
await this.prisma.downstreamRequeueTask.update({ 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 } });
|
||||
await this.prisma.downstreamRequeueTask.update({
|
||||
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) {
|
||||
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 (!(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;
|
||||
const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus);
|
||||
if (outcome === 'success') failures[item.applicationId] = 0;
|
||||
if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
|
||||
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) {
|
||||
await this.autoPause(task, item.applicationId, failures[item.applicationId]);
|
||||
break;
|
||||
@@ -248,16 +407,26 @@ export class SendDownstreamRequeueTaskService {
|
||||
}
|
||||
failures = await this.reconcileWaiting(taskId, 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);
|
||||
if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]);
|
||||
await this.refreshTask(taskId);
|
||||
} 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 {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||
where: { id: deliveryId },
|
||||
@@ -271,32 +440,88 @@ export class SendDownstreamRequeueTaskService {
|
||||
return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认');
|
||||
}
|
||||
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', '执行前状态已变化');
|
||||
}
|
||||
if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled) return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
|
||||
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 (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled)
|
||||
return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
|
||||
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', '已被其他任务处理');
|
||||
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: delivery.applicationId, status: 'connected' } });
|
||||
if (connected === 0) { await this.prisma.downstreamRequeueTaskItem.update({ 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() } });
|
||||
const connected = await this.prisma.cmppDownstreamConnection.count({
|
||||
where: { applicationId: delivery.applicationId, status: 'connected' },
|
||||
});
|
||||
if (connected === 0) {
|
||||
await this.prisma.downstreamRequeueTaskItem.update({
|
||||
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';
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '后台重投失败';
|
||||
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message) ? '执行前状态已变化'
|
||||
: /payload|投递类型/.test(message) ? '投递数据不完整'
|
||||
: /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() } });
|
||||
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message)
|
||||
? '执行前状态已变化'
|
||||
: /payload|投递类型/.test(message)
|
||||
? '投递数据不完整'
|
||||
: /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';
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -314,24 +539,61 @@ export class SendDownstreamRequeueTaskService {
|
||||
}
|
||||
|
||||
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 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) {
|
||||
const connected = await this.prisma.cmppDownstreamConnection.count({ 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 connected = await this.prisma.cmppDownstreamConnection.count({
|
||||
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();
|
||||
for (const item of items) {
|
||||
if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0) {
|
||||
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.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 },
|
||||
});
|
||||
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') {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -339,21 +601,70 @@ export class SendDownstreamRequeueTaskService {
|
||||
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 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 } });
|
||||
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 } } });
|
||||
const updated = await this.prisma.downstreamRequeueTask.updateMany({
|
||||
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) {
|
||||
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 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 current = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, 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() } : {}) } });
|
||||
const current = await this.prisma.downstreamRequeueTask.findUnique({
|
||||
where: { id: taskId },
|
||||
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 { createHash } from 'node:crypto';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
|
||||
import type { OpenApiService } from '../open-api/open-api.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 { 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';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
import type {
|
||||
GatewayPendingDeliveryQueryDto,
|
||||
GatewayDownstreamSentDto,
|
||||
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.
|
||||
@@ -37,7 +56,11 @@ export class SendDownstreamStateService {
|
||||
take: 500,
|
||||
});
|
||||
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();
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
@@ -150,6 +173,7 @@ export class SendDownstreamStateService {
|
||||
}
|
||||
|
||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||
protocolUint32(data.result, 'result');
|
||||
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
|
||||
const acknowledgedMessageId = String(data.messageId ?? '').trim();
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
@@ -166,7 +190,7 @@ export class SendDownstreamStateService {
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackResult: protocolUint32ToDb(data.result, 'result'),
|
||||
ackDeadlineAt: null,
|
||||
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
|
||||
errorMessage: acknowledgementAccepted
|
||||
@@ -184,7 +208,7 @@ export class SendDownstreamStateService {
|
||||
messageId: data.messageId,
|
||||
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackResult: protocolUint32ToDb(data.result, 'result'),
|
||||
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
|
||||
errorMessage: acknowledgementAccepted
|
||||
? null
|
||||
@@ -201,7 +225,7 @@ export class SendDownstreamStateService {
|
||||
acknowledgedAt,
|
||||
deliveredAt: acknowledgedAt,
|
||||
ackDeadlineAt: null,
|
||||
ackResult: data.result,
|
||||
ackResult: protocolUint32ToDb(data.result, 'result'),
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
@@ -215,16 +239,24 @@ export class SendDownstreamStateService {
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackResult: protocolUint32ToDb(data.result, 'result'),
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
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(
|
||||
@@ -261,7 +293,11 @@ export class SendDownstreamStateService {
|
||||
return delivery;
|
||||
}
|
||||
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 nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout';
|
||||
const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries();
|
||||
@@ -330,12 +366,14 @@ export class SendDownstreamStateService {
|
||||
if (!account) {
|
||||
throw new BadRequestException('account is required');
|
||||
}
|
||||
const recoveryStatuses = (this.prisma as PrismaService & {
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findUnique: (args: Record<string, unknown>) => Promise<any>;
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
};
|
||||
}).gatewayDownstreamRecoveryStatus;
|
||||
const recoveryStatuses = (
|
||||
this.prisma as PrismaService & {
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findUnique: (args: Record<string, unknown>) => Promise<any>;
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
};
|
||||
}
|
||||
).gatewayDownstreamRecoveryStatus;
|
||||
const previous = await recoveryStatuses.findUnique({
|
||||
where: { account },
|
||||
select: {
|
||||
@@ -499,7 +537,7 @@ export class SendDownstreamStateService {
|
||||
},
|
||||
});
|
||||
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) {
|
||||
return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
}
|
||||
@@ -517,10 +555,13 @@ export class SendDownstreamStateService {
|
||||
}
|
||||
|
||||
async recoverStaleDownstreamManualRequeues(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
));
|
||||
const staleCutoff = new Date(
|
||||
now.getTime() -
|
||||
positiveInteger(
|
||||
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
),
|
||||
);
|
||||
const stale = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
|
||||
select: { id: true, updatedAt: true },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { protocolUint32ToDb } from '../common/protocol-uint32';
|
||||
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
@@ -43,6 +44,7 @@ export class SendGatewayResultService {
|
||||
) {}
|
||||
|
||||
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
|
||||
protocolUint32ToDb(data.sequenceId);
|
||||
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
||||
const effectiveSubmitId = submitRecord.submitId;
|
||||
@@ -87,7 +89,7 @@ export class SendGatewayResultService {
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId),
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submittedAt,
|
||||
},
|
||||
@@ -155,6 +157,8 @@ export class SendGatewayResultService {
|
||||
}
|
||||
|
||||
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 submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data);
|
||||
if (data.eventId && submitRecord.resultEventId) {
|
||||
@@ -171,7 +175,7 @@ export class SendGatewayResultService {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: { id: submitRecord.id },
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId),
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
@@ -434,7 +438,7 @@ export class SendGatewayResultService {
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
sequenceId: protocolUint32ToDb(segment.sequenceId ?? data.sequenceId) ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
@@ -452,7 +456,7 @@ export class SendGatewayResultService {
|
||||
attempt,
|
||||
segmentTotal,
|
||||
segmentIndex,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
sequenceId: protocolUint32ToDb(segment.sequenceId ?? data.sequenceId) ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { protocolUint32ToDb, protocolUint32FromDb } from '../common/protocol-uint32';
|
||||
import { resolveReceiptAttempt } from './receipt-attempt-resolver';
|
||||
import { completionContext } from './completion-context';
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
@@ -39,6 +40,7 @@ export class SendReceiptService {
|
||||
) {}
|
||||
|
||||
async intakeReceipt(data: GatewayReceiptEventDto) {
|
||||
protocolUint32ToDb(data.sequenceId);
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: data.channelId },
|
||||
select: {
|
||||
@@ -55,34 +57,45 @@ export class SendReceiptService {
|
||||
}
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
const receiptKey = receiptEventKey(data, data.channelId);
|
||||
const inbox = await this.prisma.upstreamReceiptInbox.upsert({
|
||||
where: { receiptKey },
|
||||
update: {
|
||||
incomingConnectionId: data.connectionId,
|
||||
},
|
||||
create: {
|
||||
receiptKey,
|
||||
incomingChannelId: data.channelId,
|
||||
incomingConnectionId: data.connectionId,
|
||||
upstreamAccount: channel.account,
|
||||
upstreamHost: channel.gatewayHost,
|
||||
upstreamPort: channel.gatewayPort,
|
||||
protocol: channel.protocol,
|
||||
protocolVersion: channel.cmppVersion,
|
||||
provisionalMessageId: data.messageId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || null,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
|
||||
status: 'pending',
|
||||
nextRetryAt: new Date(),
|
||||
},
|
||||
});
|
||||
const inbox = await this.prisma.upstreamReceiptInbox
|
||||
.upsert({
|
||||
where: { receiptKey },
|
||||
update: {
|
||||
incomingConnectionId: data.connectionId,
|
||||
},
|
||||
create: {
|
||||
receiptKey,
|
||||
incomingChannelId: data.channelId,
|
||||
incomingConnectionId: data.connectionId,
|
||||
upstreamAccount: channel.account,
|
||||
upstreamHost: channel.gatewayHost,
|
||||
upstreamPort: channel.gatewayPort,
|
||||
protocol: channel.protocol,
|
||||
protocolVersion: channel.cmppVersion,
|
||||
provisionalMessageId: data.messageId,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId),
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || null,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
|
||||
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)) {
|
||||
setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id));
|
||||
}
|
||||
@@ -143,7 +156,7 @@ export class SendReceiptService {
|
||||
messageId: inbox.provisionalMessageId ?? undefined,
|
||||
channelId: inbox.incomingChannelId,
|
||||
connectionId: inbox.incomingConnectionId ?? undefined,
|
||||
sequenceId: inbox.sequenceId ?? undefined,
|
||||
sequenceId: protocolUint32FromDb(inbox.sequenceId),
|
||||
gatewayMessageId: inbox.gatewayMessageId,
|
||||
phoneNumber: inbox.phoneNumber ?? undefined,
|
||||
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
|
||||
@@ -223,6 +236,7 @@ export class SendReceiptService {
|
||||
cmppVersion: string;
|
||||
},
|
||||
) {
|
||||
protocolUint32ToDb(data.sequenceId);
|
||||
const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
|
||||
const logicalChannelId = resolved.channelId ?? data.channelId;
|
||||
const receiptKey = receiptEventKey(data, logicalChannelId);
|
||||
@@ -245,7 +259,7 @@ export class SendReceiptService {
|
||||
},
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
sequenceId: data.sequenceId,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -262,7 +276,7 @@ export class SendReceiptService {
|
||||
messageId: resolved.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
|
||||
sequenceId: data.sequenceId,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId),
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
@@ -573,7 +587,7 @@ export class SendReceiptService {
|
||||
update: {
|
||||
submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
sequenceId: data.sequenceId ?? null,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId) ?? null,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
@@ -590,7 +604,7 @@ export class SendReceiptService {
|
||||
attempt: 0,
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId ?? null,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId) ?? null,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: submitRecord?.submitStatus ?? 'accepted',
|
||||
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 为 integer;CMPP 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 <= 0,Gateway 用 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 已是 uint32;Msg_Id 转为十进制字符串进入事件,序号超限发生在后端持久化边界。
|
||||
- [receipt.go](../gateway/third_party/gocmpp/receipt.go) 的 CmppReceiptPktLen=60,Pack/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 全程使用 uint32;2.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 回读已持久化记录再确认接收;没有对应记录或其他数据库错误仍抛出,避免假成功。
|
||||
|
||||
### 验收映射
|
||||
|
||||
| 用例 | 本地证据与结果 | 尚未覆盖的目标环境项 |
|
||||
|---|---|---|
|
||||
| T01–T06 | 新脚本 `tools/testing/verify-protocol-fields.mjs` 用真实 Nest callback、PG、Redis 验证四个边界、七字段、单/批回执、Submit 分片、上行去重、大 ACK 非成功、非法输入无写入;通过 | 真实供应商/客户在线业务回归 |
|
||||
| T07 | 最大 Msg_Id `18446744073709551615` 经过 Redis/HTTP/Prisma 保持字符串;Go TCP/回执往返不截断 | 线上供应商端到端互通 |
|
||||
| T08–T09 | vendored codec 与 upstream tests 验证 60/71 字节、32 字节号码、最大 SMSC、错误版本/截断;下游 2.0/3.0 TCP 回执验证通过 | 实际通道 3.0 是否存在非规范 60 字节回执需发布前抽样 |
|
||||
| T10–T11、T17 | 入站序号 0 JSON、缺失与 null 区分;新连接无原消息内存映射时恢复 Msg_Id,2.0/3.0 真 TCP DELIVER/DELIVER_RESP Result=0;最大/0 序号冲突保护测试通过 | 实际客户端重连验证 |
|
||||
| T12–T14 | 真 TCP CONNECT 状态 0/5/255/256/4294967295;99 号码 3471/3490;100 号码、超长内容、截断/多余内容、巨大/过短头拒绝;通过 | 目标环境日志、吞吐与异常隔离观察 |
|
||||
| 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 error,32 个存量 any warning)、格式检查和 diff 检查通过。额外运行 vendored 模块完整 vet 发现两个原有 stdmethods 提示:`packetWriter.WriteByte`/`packetReader.ReadByte` 使用累积错误接口而非标准 io 签名;本轮未更名整个协议库,记录为既有待治理项,不宣称该额外检查通过。没有通过禁用检查隐藏问题。
|
||||
|
||||
测试在保留其他会话修改的当前工作区执行,提交仅纳入本轮文件和共享文档新增段落;这些本地结果不能冒充标准发布工具绑定精确提交的发布证据。隔离 PostgreSQL、Redis 已关闭,数据/日志保留。上线前仍应按标准工具从精确提交构建并生成对应验证证据。
|
||||
@@ -2384,3 +2384,11 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
|
||||
4. “避免影响消息分片数”固定选中,接口不可关闭;增删均保持计费单位与Gateway编码分片数,否则原文发送。只处理末尾精确指令,不修改正文、标点。重试换通道从原文计算,不叠加。
|
||||
5. 短信列表显示提交通道的实际内容,详情保留原始内容及改写过消息的各次提交快照;客户端保留自身原文查看能力。现有计费、回执和报表单位不变。
|
||||
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)。本轮授权并完成本地实现/验收/提交,环境上线另行执行标准发布。
|
||||
|
||||
@@ -5715,3 +5715,18 @@ TC-SQA-01~14:真实隔离PG覆盖核心日期/日报/长短信/事务/分页
|
||||
| TC-OPT-20260920-12 | 1600×1000/1366×768/390×844 | 进度条不换行;模板保存、通道缩减、原文详情、刷新跨路由正常,无控制台异常 |
|
||||
|
||||
代码级与真实本地API/PG验收分别见 testing-progress.md;无运营商真实发送授权,因此不将Outbox构造/回滚测试称为真实短信送达验收。
|
||||
|
||||
|
||||
## 2026-09-20 CMPP 协议字段兼容性用例登记
|
||||
|
||||
将 [整改方案第 8、10 节](cmpp-protocol-field-compatibility-remediation-20260920.md) 的 CMPP-FIELD-T01~T18 纳入本表体系,ID 与预期不另行重定义。
|
||||
|
||||
| 用例组 | 执行入口 | 本轮结果与边界 |
|
||||
|---|---|---|
|
||||
| T01~T07 | protocol-uint32.spec.ts、protocol-receipt-intake.spec.ts、tools/testing/verify-protocol-fields.mjs | 数字校验/七字段真实 PG/API/Redis、大小 Msg_Id、单批重复、高值 ACK;本地通过 |
|
||||
| T08~T14 | 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 项完成态回归通过;边界分别验证,未联合运行整套供应商到客户链路 |
|
||||
| T17~T18 | inbound/protocol_fields_test.go、verify-protocol-fields.mjs | 2.0/3.0 本机 TCP 重连零序号回执与 ACK,历史缺失序号不回填且终态/通知不重开;通过 |
|
||||
|
||||
后续上线须追加目标 schema/版本、真实供应商/客户互通、队列排空和账务对账证据。当前不标记目标环境完成,也不执行历史回执重投。
|
||||
|
||||
@@ -5199,3 +5199,14 @@ API全量81套/880项通过并达覆盖率门禁(语句67.73%、分支52.89%
|
||||
- 浏览器:当前环境无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。
|
||||
- 边界/未执行:未操作测试或预生产配置;未推送、未部署、未执行运营商发送/实际回执推送/资金结算;冻结历史报表不重算。测试证明本地真实持久化和页面,不能冒称线上或实际送达验收。提交仅纳入本轮源文件、迁移和本轮文档增量,提交号以本轮Git提交为准。
|
||||
|
||||
|
||||
## 2026-09-20 CMPP 字段兼容性方案实施与本地提交
|
||||
|
||||
- 用户授权:在另一个会话最新代码基础上审查侧边方案、执行修复并本地提交。核验 main 为 b24cd7c,远端 main 为 5e4d644,工作区有其他会话修改,已保护并仅暂存本轮文件/共享文档新增段落。不推送、不部署、不修改预生产数据/配置、不重投真实短信或回执。
|
||||
- 实施 CMPP-FIELD-01~06:七个 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 个队列契约、变更代码 lint(0 error/32 存量 warning)、格式与 diff 检查通过。额外嵌套库 vet 存在原有两条 WriteByte/ReadByte 标准接口签名提示,未隐瞒或跳过配置,详细登记于方案第 10 节。
|
||||
- 真实本机 Nest callback + PostgreSQL + Redis:0/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)
|
||||
downstreamAckRegistry.Lock()
|
||||
if downstreamAckRegistry.items[key] != nil {
|
||||
downstreamAckRegistry.Unlock()
|
||||
return nil
|
||||
}
|
||||
downstreamAckRegistry.items[key] = tracker
|
||||
tracker.timer = time.AfterFunc(time.Until(deadline), func() {
|
||||
timedOut := takeDownstreamAck(session.conn, sequenceID)
|
||||
|
||||
@@ -15,19 +15,19 @@ import (
|
||||
// arbitrary account session would acknowledge a message with the wrong Msg_Id.
|
||||
|
||||
type DownstreamReceipt struct {
|
||||
DeliveryID string `json:"deliveryId,omitempty"`
|
||||
ClaimID string `json:"claimId,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
MessageID string `json:"messageId"`
|
||||
GatewayMessageID string `json:"gatewayMessageId,omitempty"`
|
||||
PhoneNumber string `json:"phoneNumber,omitempty"`
|
||||
ReceiptStatus string `json:"receiptStatus"`
|
||||
RawStatus string `json:"rawStatus,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"`
|
||||
SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"`
|
||||
DeliveredAt string `json:"deliveredAt,omitempty"`
|
||||
DeliveryID string `json:"deliveryId,omitempty"`
|
||||
ClaimID string `json:"claimId,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
MessageID string `json:"messageId"`
|
||||
GatewayMessageID string `json:"gatewayMessageId,omitempty"`
|
||||
PhoneNumber string `json:"phoneNumber,omitempty"`
|
||||
ReceiptStatus string `json:"receiptStatus"`
|
||||
RawStatus string `json:"rawStatus,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
SubmitSequenceID *uint32 `json:"submitSequenceId,omitempty"`
|
||||
SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"`
|
||||
DeliveredAt string `json:"deliveredAt,omitempty"`
|
||||
}
|
||||
|
||||
type DownstreamUplink struct {
|
||||
@@ -108,7 +108,7 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
|
||||
session = recoverReceiptSession(event)
|
||||
}
|
||||
if session == nil {
|
||||
if event.SubmitSequenceID == 0 {
|
||||
if event.SubmitSequenceID == nil {
|
||||
return DownstreamSendResult{
|
||||
Retryable: false,
|
||||
ReasonCode: "MISSING_SUBMIT_SEQUENCE_ID",
|
||||
@@ -158,7 +158,11 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
|
||||
DestTerminalId: defaultString(event.PhoneNumber, session.phoneNumber),
|
||||
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 {
|
||||
return DownstreamSendResult{}, err
|
||||
}
|
||||
@@ -167,8 +171,8 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
|
||||
}
|
||||
|
||||
func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 {
|
||||
if event.SubmitSequenceID != 0 {
|
||||
return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID)
|
||||
if event.SubmitSequenceID != nil {
|
||||
return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), *event.SubmitSequenceID)
|
||||
}
|
||||
if session == nil {
|
||||
return 0
|
||||
@@ -189,7 +193,7 @@ func findReceiptSession(messageID string, account string) *downstreamSession {
|
||||
}
|
||||
|
||||
func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
|
||||
if event.MessageID == "" || event.SubmitSequenceID == 0 || event.Account == "" {
|
||||
if event.MessageID == "" || event.SubmitSequenceID == nil || event.Account == "" {
|
||||
return nil
|
||||
}
|
||||
downstreamRegistry.RLock()
|
||||
@@ -200,7 +204,7 @@ func recoverReceiptSession(event DownstreamReceipt) *downstreamSession {
|
||||
}
|
||||
recovered := *accountSession
|
||||
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
|
||||
}
|
||||
|
||||
@@ -293,6 +297,12 @@ func sendDownstream(session *downstreamSession, deliver cmpp.Packer, deliveryID
|
||||
AckDeadlineAt: formatRFC3339Nano(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 {
|
||||
removeDownstreamAck(tracker)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
|
||||
t.Fatalf("expected receipt deliver, got %+v", deliver)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13600002696" || receipt.MsgId != rsp.MsgId {
|
||||
@@ -535,7 +535,7 @@ func TestSubmitResponsePrecedesQueuedFailureReceipt(t *testing.T) {
|
||||
}
|
||||
payload, _ := json.Marshal(DownstreamReceipt{
|
||||
Account: account, MessageID: "MSG-ORDER", PhoneNumber: "13500002696",
|
||||
ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: submit.SequenceID,
|
||||
ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: sequencePointer(submit.SequenceID),
|
||||
})
|
||||
pendingReturned = true
|
||||
_ = json.NewEncoder(w).Encode([]pendingDelivery{{
|
||||
@@ -667,7 +667,7 @@ func TestRecoverableReceiptWaitsForClientConnection(t *testing.T) {
|
||||
DeliveryID: "delivery-retry",
|
||||
Account: "100001",
|
||||
MessageID: "MSG-RETRY",
|
||||
SubmitSequenceID: 77,
|
||||
SubmitSequenceID: sequencePointer(77),
|
||||
ReceiptStatus: "delivered",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1161,7 +1161,7 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
|
||||
}
|
||||
|
||||
recovered := recoverReceiptSession(DownstreamReceipt{
|
||||
MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: 1216579149,
|
||||
MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: sequencePointer(1216579149),
|
||||
})
|
||||
if recovered == nil {
|
||||
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)
|
||||
}
|
||||
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{
|
||||
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) {
|
||||
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) {
|
||||
session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)}
|
||||
first := downstreamReceiptMessageID(DownstreamReceipt{
|
||||
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 101,
|
||||
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: sequencePointer(101),
|
||||
}, session)
|
||||
second := downstreamReceiptMessageID(DownstreamReceipt{
|
||||
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 102,
|
||||
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: sequencePointer(102),
|
||||
}, session)
|
||||
if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) {
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
func sequencePointer(n uint32) *uint32 { return &n }
|
||||
|
||||
@@ -27,7 +27,7 @@ type submitRequest struct {
|
||||
Content string `json:"content"`
|
||||
SrcID string `json:"srcId,omitempty"`
|
||||
DestID string `json:"destId,omitempty"`
|
||||
SequenceID uint32 `json:"sequenceId,omitempty"`
|
||||
SequenceID uint32 `json:"sequenceId"`
|
||||
RegisteredDelivery uint8 `json:"registeredDelivery"`
|
||||
RemoteIP string `json:"remoteIp,omitempty"`
|
||||
LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"`
|
||||
|
||||
@@ -16,6 +16,13 @@ import (
|
||||
// The reader and heartbeat goroutines share one connection lifecycle. Closing
|
||||
// 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 {
|
||||
channelID string
|
||||
config queue.UpstreamConfig
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// mobile-originated content uses a separate long-message assembly path.
|
||||
|
||||
type deliverPacket struct {
|
||||
version cmpp.Type
|
||||
seqID uint32
|
||||
msgID uint64
|
||||
destID string
|
||||
@@ -26,7 +27,7 @@ type deliverPacket struct {
|
||||
}
|
||||
|
||||
func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket {
|
||||
return deliverPacket{
|
||||
return deliverPacket{version: cmpp.V20,
|
||||
seqID: pkt.SeqId,
|
||||
msgID: pkt.MsgId,
|
||||
destID: pkt.DestId,
|
||||
@@ -39,7 +40,7 @@ func deliverPacketFromCMPP2(pkt *cmpp.Cmpp2DeliverReqPkt) deliverPacket {
|
||||
}
|
||||
|
||||
func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket {
|
||||
return deliverPacket{
|
||||
return deliverPacket{version: cmpp.V30,
|
||||
seqID: pkt.SeqId,
|
||||
msgID: pkt.MsgId,
|
||||
destID: pkt.DestId,
|
||||
@@ -54,7 +55,7 @@ func deliverPacketFromCMPP3(pkt *cmpp.Cmpp3DeliverReqPkt) deliverPacket {
|
||||
func (c *connection) handleDeliver(pkt deliverPacket) error {
|
||||
if pkt.registerDelivery == 1 {
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func (c *connection) sendHeartbeat() bool {
|
||||
return false
|
||||
}
|
||||
c.sendMu.Lock()
|
||||
seq, err := c.client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{})
|
||||
seq, err := c.client.SendReqPktAvailable(&cmpp.CmppActiveTestReqPkt{}, c.sequenceAvailable)
|
||||
c.sendMu.Unlock()
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func (c *connection) emitDeliverResponse(pkt deliverPacket, responseErr error) {
|
||||
channelID := c.channelID
|
||||
if pkt.registerDelivery == 1 {
|
||||
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)
|
||||
phone = strings.TrimSpace(receipt.DestTerminalId)
|
||||
if cmd, ok := c.commandFor(receipt.MsgId); ok {
|
||||
|
||||
@@ -101,7 +101,7 @@ func (p *connectionPool) submit(
|
||||
return result, publishErr
|
||||
}
|
||||
}
|
||||
if firstSequence == 0 {
|
||||
if len(segments) == 1 {
|
||||
firstSequence = seq
|
||||
}
|
||||
if firstGatewayMessageID == "" {
|
||||
@@ -146,7 +146,7 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
|
||||
}
|
||||
c.sendMu.Lock()
|
||||
wireSource = "write_uncertain"
|
||||
seq, err := client.SendReqPkt(pkt)
|
||||
seq, err := client.SendReqPktAvailable(pkt, c.sequenceAvailable)
|
||||
if err == nil {
|
||||
at := time.Now().UTC()
|
||||
wireAt = &at
|
||||
|
||||
Vendored
+22
-7
@@ -15,6 +15,7 @@ package cmpp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
@@ -72,7 +73,7 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
|
||||
}
|
||||
|
||||
var ok bool
|
||||
var status uint8
|
||||
var status uint32
|
||||
if cli.typ == V20 || cli.typ == V21 {
|
||||
var rsp *Cmpp2ConnRspPkt
|
||||
rsp, ok = p.(*Cmpp2ConnRspPkt)
|
||||
@@ -80,7 +81,7 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
|
||||
err = ErrRespNotMatch
|
||||
return err
|
||||
}
|
||||
status = rsp.Status
|
||||
status = uint32(rsp.Status)
|
||||
} else {
|
||||
var rsp *Cmpp3ConnRspPkt
|
||||
rsp, ok = p.(*Cmpp3ConnRspPkt)
|
||||
@@ -88,15 +89,16 @@ func (cli *Client) Connect(servAddr, user, password string, timeout time.Duratio
|
||||
err = ErrRespNotMatch
|
||||
return err
|
||||
}
|
||||
status = uint8(rsp.Status)
|
||||
status = rsp.Status
|
||||
}
|
||||
|
||||
if status != 0 {
|
||||
if status <= ErrnoConnOthers { //ErrnoConnOthers = 5
|
||||
err = ConnRspStatusErrMap[status]
|
||||
if status <= uint32(ErrnoConnOthers) { //ErrnoConnOthers = 5
|
||||
err = ConnRspStatusErrMap[uint8(status)]
|
||||
} else {
|
||||
err = ConnRspStatusErrMap[ErrnoConnOthers]
|
||||
}
|
||||
err = fmt.Errorf("CMPP CONNECT_RESP status=%d: %w", status, 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.
|
||||
func (cli *Client) SendReqPkt(packet Packer) (uint32, error) {
|
||||
seq := <-cli.conn.SeqId
|
||||
return seq, cli.conn.SendPkt(packet, seq)
|
||||
return cli.SendReqPktAvailable(packet, nil)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Vendored
+1
-1
@@ -46,7 +46,7 @@ const (
|
||||
CMPP_HEADER_LEN uint32 = 12
|
||||
CMPP2_PACKET_MAX uint32 = 2477
|
||||
CMPP2_PACKET_MIN uint32 = 12
|
||||
CMPP3_PACKET_MAX uint32 = 3335
|
||||
CMPP3_PACKET_MAX uint32 = Cmpp3SubmitReqPktMaxLen
|
||||
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
@@ -13,11 +13,15 @@
|
||||
|
||||
package cmpp
|
||||
|
||||
import "encoding/binary"
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Packet length const for cmpp receipt packet.
|
||||
const (
|
||||
CmppReceiptPktLen uint32 = 60 //60d, 0x3c
|
||||
Cmpp3ReceiptPktLen uint32 = 71
|
||||
CmppReceiptPktLen uint32 = 60 //60d, 0x3c
|
||||
)
|
||||
|
||||
type CmppReceiptPkt struct {
|
||||
@@ -31,7 +35,18 @@ type CmppReceiptPkt struct {
|
||||
|
||||
// Pack packs the CmppReceiptPkt to bytes stream for client side.
|
||||
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)
|
||||
|
||||
@@ -39,7 +54,7 @@ func (p *CmppReceiptPkt) Pack() ([]byte, error) {
|
||||
w.WriteFixedSizeString(p.Stat, 7)
|
||||
w.WriteFixedSizeString(p.SubmitTime, 10)
|
||||
w.WriteFixedSizeString(p.DoneTime, 10)
|
||||
w.WriteFixedSizeString(p.DestTerminalId, 21)
|
||||
w.WriteFixedSizeString(p.DestTerminalId, width)
|
||||
w.WriteInt(binary.BigEndian, p.SmscSequence)
|
||||
|
||||
return w.Bytes()
|
||||
@@ -49,6 +64,27 @@ func (p *CmppReceiptPkt) Pack() ([]byte, error) {
|
||||
// After unpack, you will get all value of fields in
|
||||
// CmppReceiptPkt struct.
|
||||
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)
|
||||
|
||||
r.ReadInt(binary.BigEndian, &p.MsgId)
|
||||
@@ -62,7 +98,7 @@ func (p *CmppReceiptPkt) Unpack(data []byte) error {
|
||||
doneTime := r.ReadCString(10)
|
||||
p.DoneTime = string(doneTime)
|
||||
|
||||
destTerminalId := r.ReadCString(21)
|
||||
destTerminalId := r.ReadCString(width)
|
||||
p.DestTerminalId = string(destTerminalId)
|
||||
|
||||
r.ReadInt(binary.BigEndian, &p.SmscSequence)
|
||||
|
||||
Vendored
+39
-2
@@ -152,6 +152,9 @@ type Cmpp3SubmitRspPkt struct {
|
||||
// Before calling Pack, you should initialize a Cmpp2SubmitReqPkt variable
|
||||
// with correct field value.
|
||||
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 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
|
||||
// Cmpp2SubmitReqPkt struct.
|
||||
func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error {
|
||||
p.DestTerminalId = nil
|
||||
|
||||
var r = newPacketReader(data)
|
||||
|
||||
// Sequence Id
|
||||
@@ -259,7 +264,13 @@ func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error {
|
||||
reserve := r.ReadCString(8)
|
||||
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.
|
||||
@@ -302,6 +313,9 @@ func (p *Cmpp2SubmitRspPkt) Unpack(data []byte) error {
|
||||
// Before calling Pack, you should initialize a Cmpp3SubmitReqPkt variable
|
||||
// with correct field value.
|
||||
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 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
|
||||
// Cmpp3SubmitReqPkt struct.
|
||||
func (p *Cmpp3SubmitReqPkt) Unpack(data []byte) error {
|
||||
p.DestTerminalId = nil
|
||||
|
||||
var r = newPacketReader(data)
|
||||
|
||||
// Sequence Id
|
||||
@@ -413,7 +429,13 @@ func (p *Cmpp3SubmitReqPkt) Unpack(data []byte) error {
|
||||
linkId := r.ReadCString(20)
|
||||
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.
|
||||
@@ -451,3 +473,18 @@ func (p *Cmpp3SubmitRspPkt) Unpack(data []byte) 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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user