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,
|
||||
|
||||
Reference in New Issue
Block a user