perf: batch gateway submits and isolate callbacks

This commit is contained in:
hectorzhao
2026-08-25 11:39:59 +08:00
parent c6f11014d6
commit 761c123b65
29 changed files with 1912 additions and 193 deletions
@@ -0,0 +1,29 @@
CREATE TABLE "GatewaySubmitOutbox" (
"id" TEXT NOT NULL,
"submitId" TEXT NOT NULL,
"messageRecordId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"schemaVersion" TEXT NOT NULL DEFAULT 'v1',
"status" TEXT NOT NULL DEFAULT 'pending',
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"leaseOwner" TEXT,
"leaseExpiresAt" TIMESTAMP(3),
"streamEntryId" TEXT,
"publishedAt" TIMESTAMP(3),
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GatewaySubmitOutbox_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "GatewaySubmitOutbox_submitId_key"
ON "GatewaySubmitOutbox"("submitId");
CREATE INDEX "GatewaySubmitOutbox_pending_claim_idx"
ON "GatewaySubmitOutbox"("nextAttemptAt", "createdAt")
WHERE "status" IN ('pending', 'publishing');
CREATE INDEX "GatewaySubmitOutbox_messageRecordId_idx"
ON "GatewaySubmitOutbox"("messageRecordId");
+22
View File
@@ -1828,6 +1828,28 @@ model SmsSubmitRecord {
@@index([channelGroupId])
}
model GatewaySubmitOutbox {
id String @id @default(cuid())
submitId String @unique
messageRecordId String
channelId String
payload Json
schemaVersion String @default("v1")
status String @default("pending")
attemptCount Int @default(0)
nextAttemptAt DateTime @default(now())
leaseOwner String?
leaseExpiresAt DateTime?
streamEntryId String?
publishedAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([submitId, status])
@@index([messageRecordId])
}
model DailyReconciliationReport {
id String @id @default(cuid())
reportDate DateTime @db.Date
@@ -22,18 +22,25 @@ export class PhoneRoutingLookupService {
}
async identifyProvince(phoneNumber: string) {
const prefixes = phonePrefixes(phoneNumber);
if (prefixes.length === 0) return null;
return (await this.identifyProvinces([phoneNumber])).get(phoneNumber) ?? null;
}
async identifyProvinces(phoneNumbers: string[]) {
const uniquePhones = [...new Set(phoneNumbers)];
const prefixesByPhone = new Map(uniquePhones.map((phone) => [phone, phonePrefixes(phone)]));
const prefixes = [...new Set([...prefixesByPhone.values()].flat())];
if (prefixes.length === 0) return new Map(uniquePhones.map((phone) => [phone, null]));
const segments = await this.prisma.phoneSegment.findMany({
where: { prefix: { in: prefixes } },
select: { prefix: true, province: true },
});
const provinceByPrefix = new Map(segments.map((segment) => [segment.prefix, segment.province]));
for (const prefix of prefixes) {
const province = provinceByPrefix.get(prefix);
if (province) return province;
}
return null;
return new Map(uniquePhones.map((phone) => {
const province = (prefixesByPhone.get(phone) ?? [])
.map((prefix) => provinceByPrefix.get(prefix))
.find((value): value is string => Boolean(value)) ?? null;
return [phone, province];
}));
}
invalidateCarrierRules() {
+31
View File
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BillingService } from './billing/billing.service';
import { PhoneRoutingLookupService } from './dictionaries/phone-routing-lookup.service';
import { MetricsModule } from './metrics/metrics.module';
import { OpenApiService } from './open-api/open-api.service';
import { PrismaModule } from './prisma/prisma.module';
import { ProtocolLogsModule } from './protocol-logs/protocol-logs.module';
import { PhoneFrequencyService } from './risk-review/phone-frequency.service';
import { RiskReviewService } from './risk-review/risk-review.service';
import { GatewayCallbackController } from './send-chain/gateway-callback.controller';
import { SendChainService } from './send-chain/send-chain.service';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'] }),
PrismaModule,
MetricsModule,
ProtocolLogsModule,
],
controllers: [GatewayCallbackController],
providers: [
BillingService,
RiskReviewService,
PhoneFrequencyService,
PhoneRoutingLookupService,
SendChainService,
OpenApiService,
],
})
export class GatewayCallbackModule {}
+44
View File
@@ -0,0 +1,44 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { createServer } from 'node:http';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { GatewayCallbackModule } from './gateway-callback.module';
import { configureHttpBodyParsers } from './http-body-limits';
import { MetricsService } from './metrics/metrics.service';
Object.defineProperty(BigInt.prototype, 'toJSON', {
configurable: true,
value(this: bigint) {
const result = Number(this);
if (!Number.isSafeInteger(result)) throw new RangeError('金额超过 JavaScript 安全整数范围');
return result;
},
});
async function bootstrap() {
if (process.env.CMPP_PROCESS_ROLE !== 'callback') {
throw new Error('gateway-callback requires CMPP_PROCESS_ROLE=callback');
}
const app = await NestFactory.create<NestExpressApplication>(GatewayCallbackModule, { rawBody: true, bodyParser: false });
app.setGlobalPrefix('api');
configureHttpBodyParsers(app);
app.enableShutdownHooks();
const host = process.env.API_CALLBACK_HOST?.trim() || '127.0.0.1';
const port = Number(process.env.API_CALLBACK_PORT ?? 3001);
await app.listen(port, host);
const metrics = app.get(MetricsService);
const metricsHost = process.env.API_CALLBACK_METRICS_HOST?.trim() || '127.0.0.1';
const metricsPort = Number(process.env.API_CALLBACK_METRICS_PORT ?? 9468);
const metricsServer = createServer((request, response) => {
if (request.method !== 'GET' || request.url !== '/metrics') return void response.writeHead(404).end();
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(metrics.render());
});
await new Promise<void>((resolve, reject) => {
metricsServer.once('error', reject);
metricsServer.listen(metricsPort, metricsHost, resolve);
});
}
void bootstrap();
+16
View File
@@ -7,13 +7,29 @@ describe('MetricsService', () => {
service.finishRequest(startedAt, 'GET', '/api/admin/tenants/:id', 200);
const inboundStartedAt = service.beginCmppInboundStage();
service.finishCmppInboundStage(inboundStartedAt, 'application_lookup', 'success');
const sendStartedAt = service.beginSendWorkerStage();
service.finishSendWorkerStage(sendStartedAt, 'route_lookup', 'success');
service.setSendWorkerSlots(20, 3);
service.setSendWorkerQueueJobs('waiting', 12);
service.recordSendWorkerResult('completed');
service.setSendWorkerDatabasePool('max', 8);
service.setSendWorkerDatabasePool('waiting', 2);
const output = service.render();
expect(output).toContain('cmpp_api_process_resident_memory_bytes');
expect(output).toContain('cmpp_api_http_requests_total{method="GET",route="/api/admin/tenants/:id",status="200"} 1');
expect(output).toContain('cmpp_api_http_request_duration_seconds_bucket');
expect(output).toContain('cmpp_api_cmpp_inbound_stage_duration_seconds_count{stage="application_lookup",result="success"} 1');
expect(output).toContain('cmpp_worker_send_stage_duration_seconds_count{stage="route_lookup",result="success"} 1');
expect(output).toContain('cmpp_worker_send_slots{state="configured"} 20');
expect(output).toContain('cmpp_worker_send_slots{state="in_flight"} 3');
expect(output).toContain('cmpp_worker_send_queue_jobs{state="waiting"} 12');
expect(output).toContain('cmpp_worker_send_jobs_total{result="completed"} 1');
expect(output).toContain('cmpp_worker_database_pool_connections{state="max"} 8');
expect(output).toContain('cmpp_worker_database_pool_connections{state="waiting"} 2');
expect(output).not.toContain('phone_number');
expect(output).not.toContain('tenant_id');
expect(output).not.toContain('channel_id');
service.onModuleDestroy();
});
});
+91
View File
@@ -3,6 +3,7 @@ import { monitorEventLoopDelay } from 'node:perf_hooks';
const HTTP_DURATION_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
const CMPP_INBOUND_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
const SEND_WORKER_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
export type CmppInboundStage =
| 'application_lookup'
@@ -25,6 +26,22 @@ export type CmppInboundStage =
export type CmppInboundStageResult = 'success' | 'error';
export type SendWorkerStage =
| 'message_load'
| 'phone_routing'
| 'route_lookup'
| 'signature_candidates'
| 'signature_final_check'
| 'rate_limit'
| 'submit_transaction'
| 'gateway_bullmq_publish'
| 'gateway_stream_publish'
| 'task_progress'
| 'total';
export type SendWorkerStageResult = 'success' | 'error' | 'skipped';
export type SendWorkerQueueState = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'prioritized';
type HttpMetric = {
count: number;
durationSum: number;
@@ -48,6 +65,12 @@ export class MetricsService implements OnModuleDestroy {
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
private readonly http = new Map<string, HttpMetric>();
private readonly cmppInbound = new Map<string, HttpMetric>();
private readonly sendWorkerStages = new Map<string, HttpMetric>();
private readonly sendWorkerQueueJobs = new Map<SendWorkerQueueState, number>();
private readonly sendWorkerResults = new Map<string, number>();
private sendWorkerConfiguredSlots = 0;
private sendWorkerInFlightSlots = 0;
private readonly sendWorkerDatabasePool = new Map<'max' | 'total' | 'idle' | 'waiting', number>();
private inFlight = 0;
private inboundWorkflowPending = 0;
private inboundWorkflowProcessing = 0;
@@ -101,6 +124,43 @@ export class MetricsService implements OnModuleDestroy {
this.cmppInbound.set(key, metric);
}
beginSendWorkerStage() {
return process.hrtime.bigint();
}
finishSendWorkerStage(startedAt: bigint, stage: SendWorkerStage, result: SendWorkerStageResult) {
const key = `${stage}\u0000${result}`;
const metric = this.sendWorkerStages.get(key) ?? {
count: 0,
durationSum: 0,
buckets: SEND_WORKER_DURATION_BUCKETS.map(() => 0),
};
const durationSeconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
metric.count += 1;
metric.durationSum += durationSeconds;
SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => {
if (durationSeconds <= bucket) metric.buckets[index] += 1;
});
this.sendWorkerStages.set(key, metric);
}
setSendWorkerSlots(configured: number, inFlight: number) {
this.sendWorkerConfiguredSlots = Math.max(0, configured);
this.sendWorkerInFlightSlots = Math.max(0, inFlight);
}
setSendWorkerQueueJobs(state: SendWorkerQueueState, count: number) {
this.sendWorkerQueueJobs.set(state, Math.max(0, count));
}
recordSendWorkerResult(result: 'completed' | 'failed' | 'skipped') {
this.sendWorkerResults.set(result, (this.sendWorkerResults.get(result) ?? 0) + 1);
}
setSendWorkerDatabasePool(state: 'max' | 'total' | 'idle' | 'waiting', count: number) {
this.sendWorkerDatabasePool.set(state, Math.max(0, count));
}
setInboundWorkflowState(pending: number, processing: number, oldestPendingAgeSeconds: number) {
this.inboundWorkflowPending = Math.max(0, pending);
this.inboundWorkflowProcessing = Math.max(0, processing);
@@ -144,6 +204,18 @@ export class MetricsService implements OnModuleDestroy {
'# TYPE cmpp_api_http_request_duration_seconds histogram',
'# HELP cmpp_api_cmpp_inbound_stage_duration_seconds CMPP inbound processing duration by bounded stage and result.',
'# TYPE cmpp_api_cmpp_inbound_stage_duration_seconds histogram',
'# HELP cmpp_worker_send_stage_duration_seconds Send worker processing duration by bounded stage and result.',
'# TYPE cmpp_worker_send_stage_duration_seconds histogram',
'# HELP cmpp_worker_send_queue_jobs BullMQ send jobs by queue state.',
'# TYPE cmpp_worker_send_queue_jobs gauge',
'# HELP cmpp_worker_send_slots Send worker concurrency slots by state.',
'# TYPE cmpp_worker_send_slots gauge',
metricLine('cmpp_worker_send_slots', this.sendWorkerConfiguredSlots, { state: 'configured' }),
metricLine('cmpp_worker_send_slots', this.sendWorkerInFlightSlots, { state: 'in_flight' }),
'# HELP cmpp_worker_send_jobs_total Send worker processing outcomes.',
'# TYPE cmpp_worker_send_jobs_total counter',
'# HELP cmpp_worker_database_pool_connections Worker PostgreSQL client pool slots by state.',
'# TYPE cmpp_worker_database_pool_connections gauge',
'# HELP cmpp_worker_inbound_workflow_items Current durable CMPP inbound workflow items by state.',
'# TYPE cmpp_worker_inbound_workflow_items gauge',
metricLine('cmpp_worker_inbound_workflow_items', this.inboundWorkflowPending, { state: 'pending' }),
@@ -179,6 +251,25 @@ export class MetricsService implements OnModuleDestroy {
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_sum', metric.durationSum, labels));
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_count', metric.count, labels));
}
for (const [key, metric] of this.sendWorkerStages) {
const [stage, result] = key.split('\u0000');
const labels = { stage, result };
SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => {
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
});
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_sum', metric.durationSum, labels));
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_count', metric.count, labels));
}
for (const [state, count] of this.sendWorkerQueueJobs) {
lines.push(metricLine('cmpp_worker_send_queue_jobs', count, { state }));
}
for (const [result, count] of this.sendWorkerResults) {
lines.push(metricLine('cmpp_worker_send_jobs_total', count, { result }));
}
for (const [state, count] of this.sendWorkerDatabasePool) {
lines.push(metricLine('cmpp_worker_database_pool_connections', count, { state }));
}
for (const [result, count] of this.inboundWorkflowResults) {
lines.push(metricLine('cmpp_worker_inbound_workflow_results_total', count, { result }));
}
+4
View File
@@ -55,6 +55,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
onModuleInit() {
const connection = bullmqConnection();
this.queue = new Queue(WEBHOOK_QUEUE, { connection });
// The isolated Gateway callback process only enqueues customer callbacks.
// Delivery remains owned by the main API process so callback DB/HTTP capacity
// cannot be consumed by slow customer webhook endpoints.
if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10 });
}
+18
View File
@@ -7,7 +7,25 @@ describe('PrismaService', () => {
expect(Object.getOwnPropertyDescriptor(prisma, 'operationLog')).toEqual(
expect.objectContaining({ configurable: true }),
);
expect(prisma.getPoolState()).toEqual({ max: 32, total: 0, idle: 0, waiting: 0 });
await prisma.$disconnect();
});
it('reserves a bounded database pool for the isolated Gateway callback process', async () => {
const previousRole = process.env.CMPP_PROCESS_ROLE;
const previousMax = process.env.API_CALLBACK_DB_POOL_MAX;
process.env.CMPP_PROCESS_ROLE = 'callback';
process.env.API_CALLBACK_DB_POOL_MAX = '12';
try {
const prisma = new PrismaService();
expect(prisma.getPoolState()).toEqual({ max: 12, total: 0, idle: 0, waiting: 0 });
await prisma.$disconnect();
} finally {
if (previousRole === undefined) delete process.env.CMPP_PROCESS_ROLE;
else process.env.CMPP_PROCESS_ROLE = previousRole;
if (previousMax === undefined) delete process.env.API_CALLBACK_DB_POOL_MAX;
else process.env.API_CALLBACK_DB_POOL_MAX = previousMax;
}
});
});
+41 -16
View File
@@ -1,30 +1,46 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { requestContext } from '../common/request-context';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleDestroy {
private readonly databasePool: Pool;
private readonly databasePoolMax: number;
constructor() {
const workerRole = process.env.CMPP_PROCESS_ROLE === 'worker';
const databaseUrl = workerRole
? process.env.API_WORKER_DATABASE_URL || process.env.DATABASE_URL
: process.env.DATABASE_URL;
const configuredPoolMax = Number(workerRole
? process.env.API_WORKER_DB_POOL_MAX ?? 8
: process.env.API_DB_POOL_MAX ?? 32);
const processRole = process.env.CMPP_PROCESS_ROLE?.trim() || 'api';
const workerRole = processRole === 'worker';
const outboxRole = processRole === 'outbox';
const callbackRole = processRole === 'callback';
const databaseUrl = outboxRole
? process.env.API_OUTBOX_DATABASE_URL || process.env.DATABASE_URL
: callbackRole
? process.env.API_CALLBACK_DATABASE_URL || process.env.DATABASE_URL
: workerRole
? process.env.API_WORKER_DATABASE_URL || process.env.DATABASE_URL
: process.env.DATABASE_URL;
const configuredPoolMax = Number(outboxRole
? process.env.API_OUTBOX_DB_POOL_MAX ?? 6
: callbackRole
? process.env.API_CALLBACK_DB_POOL_MAX ?? 16
: workerRole
? process.env.API_WORKER_DB_POOL_MAX ?? 8
: process.env.API_DB_POOL_MAX ?? 32);
const poolMax = Number.isInteger(configuredPoolMax) && configuredPoolMax > 0
? configuredPoolMax
: workerRole ? 8 : 32;
super({
adapter: new PrismaPg({
connectionString: databaseUrl
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
// API capacity must be reserved independently from the heavier Worker
// transactions; explicit bounds also protect PostgreSQL max_connections.
max: poolMax,
}),
: outboxRole ? 6 : callbackRole ? 16 : workerRole ? 8 : 32;
const databasePool = new Pool({
connectionString: databaseUrl
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
// API capacity must be reserved independently from the heavier Worker
// transactions; explicit bounds also protect PostgreSQL max_connections.
max: poolMax,
});
super({ adapter: new PrismaPg(databasePool, { disposeExternalPool: true }) });
this.databasePool = databasePool;
this.databasePoolMax = poolMax;
const operationLog = this.operationLog;
Object.defineProperty(this, 'operationLog', {
value: new Proxy(operationLog, {
@@ -46,6 +62,15 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
});
}
getPoolState() {
return {
max: this.databasePoolMax,
total: this.databasePool.totalCount,
idle: this.databasePool.idleCount,
waiting: this.databasePool.waitingCount,
};
}
async onModuleDestroy() {
await this.$disconnect();
}
@@ -0,0 +1,43 @@
import { BadRequestException } from '@nestjs/common';
import { GatewayCallbackController } from './gateway-callback.controller';
describe('GatewayCallbackController', () => {
const sendChain = {
handleSubmitResult: jest.fn(), handleSubmitSegmentResult: jest.fn(),
intakeReceipt: jest.fn(), handleReceipt: jest.fn(), handleUplink: jest.fn(),
recordGatewaySubmitDeadLetter: jest.fn(),
};
const protocolLogs = { record: jest.fn() };
const prisma = { $queryRaw: jest.fn(), getPoolState: jest.fn().mockReturnValue({ max: 16, total: 2, idle: 1, waiting: 0 }) };
const controller = new GatewayCallbackController(sendChain as never, protocolLogs as never, prisma as never);
beforeEach(() => jest.clearAllMocks());
it('keeps Submit result persistence on the callback process without duplicating protocol logs', async () => {
sendChain.handleSubmitResult.mockResolvedValue({ accepted: true });
await expect(controller.submitResult({
messageId: 'MSG-1', channelId: 'channel-1', gatewayMessageId: '1', sequenceId: 1,
submitStatus: 'accepted',
})).resolves.toEqual({ accepted: true });
expect(sendChain.handleSubmitResult).toHaveBeenCalledTimes(1);
expect(protocolLogs.record).not.toHaveBeenCalled();
});
it('durably intakes receipts before recording the callback protocol event', async () => {
sendChain.intakeReceipt.mockResolvedValue({ accepted: true, inboxId: 'inbox-1' });
await controller.receiptIntake({
messageId: 'MSG-1', channelId: 'channel-1', gatewayMessageId: '1',
phoneNumber: '13800000001', receiptStatus: 'delivered', rawStatus: 'DELIVRD',
});
expect(sendChain.intakeReceipt).toHaveBeenCalledTimes(1);
expect(protocolLogs.record).toHaveBeenCalledWith(expect.objectContaining({
eventType: 'deliver_receipt', messageId: 'MSG-1', status: 'success',
}));
});
it('rejects inbound/client packet logs from the isolated supplier callback surface', () => {
expect(() => controller.protocolLog({
protocol: 'cmpp', direction: 'client_to_platform', eventType: 'submit', status: 'success',
})).toThrow(BadRequestException);
});
});
@@ -0,0 +1,102 @@
import { BadRequestException, Body, Controller, Get, Post } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
import type {
GatewayReceiptEventDto,
GatewaySubmitDeadLetterDto,
GatewaySubmitResultDto,
GatewaySubmitSegmentResultDto,
GatewayUplinkEventDto,
} from './send-chain.contracts';
import { SendChainService } from './send-chain.service';
@Controller()
export class GatewayCallbackController {
constructor(
private readonly sendChain: SendChainService,
private readonly protocolLogs: ProtocolLogsService,
private readonly prisma: PrismaService,
) {}
@Get('health')
async health() {
await this.prisma.$queryRaw`SELECT 1`;
return { status: 'ok', role: 'gateway-callback', databasePool: this.prisma.getPoolState() };
}
@Post('gateway/events/submit-result')
submitResult(@Body() body: GatewaySubmitResultDto) {
return this.sendChain.handleSubmitResult(body);
}
@Post('gateway/events/submit-segment-result')
submitSegmentResult(@Body() body: GatewaySubmitSegmentResultDto) {
return this.sendChain.handleSubmitSegmentResult(body);
}
@Post('gateway/events/receipt/intake')
receiptIntake(@Body() body: GatewayReceiptEventDto) {
return this.track('deliver_receipt', body, () => this.sendChain.intakeReceipt(body));
}
@Post('gateway/events/receipt')
receipt(@Body() body: GatewayReceiptEventDto) {
return this.track('deliver_receipt', body, () => this.sendChain.handleReceipt(body));
}
@Post('gateway/events/uplink')
uplink(@Body() body: GatewayUplinkEventDto) {
return this.track('deliver_uplink', body, () => this.sendChain.handleUplink(body));
}
@Post('gateway/events/protocol-log')
protocolLog(@Body() body: ProtocolLogInput) {
const allowedPacket = (
body.direction === 'platform_to_channel' && ['submit', 'deliver_resp'].includes(body.eventType)
) || (
body.direction === 'channel_to_platform' && body.eventType === 'submit_resp'
);
if (body.protocol !== 'cmpp' || !allowedPacket || !['success', 'failed'].includes(body.status)) {
throw new BadRequestException('Unsupported Gateway callback protocol log event');
}
this.protocolLogs.record(body);
return { accepted: true };
}
@Post('gateway/events/dead-letter')
deadLetter(@Body() body: GatewaySubmitDeadLetterDto) {
return this.sendChain.recordGatewaySubmitDeadLetter(body);
}
private async track<T>(eventType: string, body: object, action: () => Promise<T> | T) {
const startedAt = Date.now();
const value = body as Record<string, unknown>;
const common: Omit<ProtocolLogInput, 'status'> = {
protocol: 'cmpp', direction: 'channel_to_platform', eventType,
tenantId: value.tenantId as string, applicationId: value.applicationId as string,
channelId: value.channelId as string,
messageId: (value.messageId ?? value.platformMessageId) as string,
gatewayMessageId: (value.gatewayMessageId ?? value.msgId ?? value.upstreamMessageId) as string,
phone: (value.phoneNumber ?? value.srcTerminalId) as string,
resultCode: (value.rawStatus ?? value.status ?? value.stat) as string,
};
try {
const result = await action();
const resolved = result && typeof result === 'object' ? result as Record<string, unknown> : {};
this.protocolLogs.record({
...common,
tenantId: (resolved.tenantId ?? common.tenantId) as string,
applicationId: (resolved.applicationId ?? common.applicationId) as string,
messageId: (resolved.messageId ?? common.messageId) as string,
status: 'success', durationMs: Date.now() - startedAt,
});
return result;
} catch (error) {
this.protocolLogs.record({
...common, status: 'failed', durationMs: Date.now() - startedAt,
detail: { error: error instanceof Error ? error.message : String(error) },
});
throw error;
}
}
}
+160 -27
View File
@@ -158,10 +158,12 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue(channel),
},
cmppSubmitSession: {
findUnique: jest.fn().mockResolvedValue({ id: 'session-1' }),
upsert: jest.fn().mockResolvedValue({ id: 'session-1' }),
},
smsSubmitRecord: {
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
createMany: jest.fn().mockResolvedValue({ count: 2 }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted', createdAt: new Date('2026-07-01T10:00:00.000Z') }),
findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve(
@@ -329,6 +331,11 @@ function createPrismaMock() {
lastError: 'downstream client is not connected',
}),
},
gatewaySubmitOutbox: {
create: jest.fn().mockResolvedValue({ id: 'outbox-1', submitId: 'SUB-1', status: 'pending' }),
createMany: jest.fn().mockResolvedValue({ count: 2 }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
cmppInboundSubmissionInbox: {
create: jest.fn().mockResolvedValue({ id: 'inbox-1' }),
findUnique: jest.fn().mockResolvedValue(null),
@@ -926,6 +933,7 @@ describe('SendChainService', () => {
it('coalesces concurrent batch progress refreshes and keeps a trailing refresh', async () => {
const { service, prisma } = createService();
prisma.$executeRaw.mockResolvedValue(0);
let resolveFirst: ((value: Array<{ status: string; _count: { _all: number } }>) => void) | undefined;
prisma.smsMessageRecord.groupBy
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }))
@@ -946,6 +954,37 @@ describe('SendChainService', () => {
});
});
it('updates a known single-message CMPP task without grouping all message states', async () => {
const { service, prisma } = createService();
await service['submission'].refreshTaskProgress('task-cmpp-1', 'submit_queued');
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
where: { id: 'task-cmpp-1', sourceType: 'cmpp', phoneTotal: 1 },
data: {
progressTotal: 1,
submittedTotal: 1,
successTotal: 0,
failedTotal: 0,
unknownTotal: 0,
timeoutTotal: 0,
status: 'sending',
},
});
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
});
it('refreshes callback progress for a single-message CMPP task in one direct SQL update', async () => {
const { service, prisma } = createService();
prisma.$executeRaw.mockResolvedValue(1);
await service['submission'].refreshTaskProgress('task-cmpp-callback');
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
expect(prisma.smsBatchTask.update).not.toHaveBeenCalled();
});
it('does not expose CMPP internal tasks through client task detail or messages', async () => {
const { service, prisma } = createService();
prisma.smsBatchTask.findFirst.mockResolvedValue(null);
@@ -2038,9 +2077,7 @@ describe('SendChainService', () => {
it('routes queued messages to gateway submit commands', async () => {
const { service, prisma } = createService();
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
expect.objectContaining({ submitted: true, messageRecordId: 'record-1', channelId: 'channel-1' }),
@@ -2059,8 +2096,7 @@ describe('SendChainService', () => {
where: { id: 'record-1' },
data: expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', province: '山东', status: 'submit_queued' }),
});
expect(gatewayAdd).toHaveBeenCalledWith(
'submit-command',
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
expect.objectContaining({
schemaVersion: 'v1',
messageType: 'SubmitCommand',
@@ -2074,9 +2110,117 @@ describe('SendChainService', () => {
}),
);
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ messageId: 'MSG-1' }));
expect(prisma.cmppSubmitSession.findUnique).toHaveBeenCalledWith({
where: { sessionNo: 'OPEN-channel-1' },
select: { id: true },
});
expect(prisma.cmppSubmitSession.upsert).not.toHaveBeenCalled();
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
});
it('shadow-writes the durable submit Outbox without replacing the direct stream path', async () => {
const previousShadow = process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = 'true';
delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
try {
const { service, prisma } = createService();
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
await service.processSendJob({ messageRecordId: 'record-1' });
expect(prisma.gatewaySubmitOutbox.create).toHaveBeenCalledWith({
data: expect.objectContaining({
messageRecordId: 'record-1',
channelId: 'channel-1',
payload: expect.objectContaining({ messageType: 'SubmitCommand', messageId: 'MSG-1' }),
}),
});
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledTimes(1);
} finally {
if (previousShadow === undefined) delete process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
else process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = previousShadow;
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
}
});
it('uses only the durable Outbox when formal publishing is enabled', async () => {
const previousShadow = process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = 'true';
process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = 'true';
try {
const { service, prisma } = createService();
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
await service.processSendJob({ messageRecordId: 'record-1' });
expect(prisma.gatewaySubmitOutbox.create).toHaveBeenCalledTimes(1);
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
} finally {
if (previousShadow === undefined) delete process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
else process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = previousShadow;
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
}
});
it('plans routes once and bulk-creates Submit records and Outbox rows for a Worker batch', async () => {
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = 'true';
try {
const { service, prisma } = createService();
const base = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
const messages = [
{ ...base, id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3n, carrier: null, province: null, batchTask: { sourceType: 'cmpp', phoneTotal: 1 } },
{ ...base, id: 'record-2', batchTaskId: 'task-2', messageId: 'MSG-2', phoneNumber: '13800000002', amountCents: 3n, carrier: null, province: null, batchTask: { sourceType: 'cmpp', phoneTotal: 1 } },
];
const channel = {
id: 'channel-1', code: 'CMPP-A', account: 'cmpp-account', srcId: '10690000',
rateLimitPerSecond: 100, unitPrice: 3n, status: 'active', carrier: 'mobile', sendRegion: '全国',
gatewayHost: '127.0.0.1', gatewayPort: 17890, passwordCipher: 'secret', cmppVersion: '3.0',
config: { serviceId: 'SMS' },
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
reportTasks: [{ signatureId: 'sig-1', carrier: 'mobile', approvalScope: 'carrier_specific' }],
};
prisma.smsMessageRecord.findMany.mockResolvedValue(messages);
prisma.channelRouteRule.findMany.mockResolvedValue([{
tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-1',
group: {
name: '默认通道组', carrier: 'mobile', status: 'active',
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel }],
},
}]);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
const gatewaySubmit = (service as any).submission.gatewaySubmit;
const result = await gatewaySubmit.processSendJobBatch([
{ messageRecordId: 'record-1' }, { messageRecordId: 'record-2' },
]);
expect(prisma.channelRouteRule.findMany).toHaveBeenCalledTimes(1);
expect(prisma.phoneSegment.findMany).toHaveBeenCalledTimes(1);
expect(prisma.smsSubmitRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1' }),
expect.objectContaining({ messageRecordId: 'record-2', channelId: 'channel-1' }),
]),
});
expect(prisma.gatewaySubmitOutbox.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ messageRecordId: 'record-1', payload: expect.objectContaining({ messageId: 'MSG-1' }) }),
expect.objectContaining({ messageRecordId: 'record-2', payload: expect.objectContaining({ messageId: 'MSG-2' }) }),
]),
});
expect(result.get('record-1')).toEqual(expect.objectContaining({ submitted: true }));
expect(result.get('record-2')).toEqual(expect.objectContaining({ submitted: true }));
} finally {
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
}
});
it('appends the real application extension to the upstream channel base number', async () => {
const { service, prisma } = createService();
const queuedMessage = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
@@ -2085,14 +2229,11 @@ describe('SendChainService', () => {
applicationExtension: '0001',
clientSrcId: '000001',
});
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
await service.processSendJob({ messageRecordId: 'record-1' });
expect(gatewayAdd).toHaveBeenCalledWith(
'submit-command',
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
expect.objectContaining({ cmpp: expect.objectContaining({ srcId: '106900000001' }) }),
);
});
@@ -2105,17 +2246,14 @@ describe('SendChainService', () => {
prisma.channelRouteRule.findFirst.mockResolvedValue({
...baseRoute,
group: { ...baseRoute.group, items: [
{ ...baseRoute.group.items[0], channelId: primary.id, priority: 1, channel: primary },
{ ...baseRoute.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup },
] },
});
prisma.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: backup.id }]);
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id }));
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ reportType: 'signature' }) }));
expect(prisma.channelSignatureReportTask.findMany).not.toHaveBeenCalled();
expect(JSON.stringify(prisma.channelRouteRule.findFirst.mock.calls.at(-1)?.[0])).toContain('sig-1');
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) }));
});
@@ -2127,6 +2265,7 @@ describe('SendChainService', () => {
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
phoneNumber: '13800000001',
})).rejects.toThrow('企业应用未配置对应运营商通道组');
@@ -2554,9 +2693,7 @@ describe('SendChainService', () => {
queuedAt: new Date(),
}, '回执失败补发')).resolves.toEqual(expect.objectContaining({ channelId: backup.id }));
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ signatureId: 'sig-direct' }),
}));
expect(JSON.stringify(prisma.channelRouteRule.findFirst.mock.calls.at(-1)?.[0])).toContain('sig-direct');
expect(submitMessageToGateway).toHaveBeenCalledWith(
expect.objectContaining({ signatureId: 'sig-direct' }),
expect.objectContaining({ channel: expect.objectContaining({ id: backup.id }) }),
@@ -2617,7 +2754,7 @@ describe('SendChainService', () => {
expect(results.filter((result) => result.submitted)).toHaveLength(1);
expect(results.filter((result) => result.duplicateRetry)).toHaveLength(2);
expect(queueAdd).toHaveBeenCalledTimes(1);
expect(queueAdd).not.toHaveBeenCalled();
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledTimes(1);
expect(prisma.smsMessageRecord.update).toHaveBeenCalledTimes(1);
});
@@ -3378,10 +3515,9 @@ describe('SendChainService', () => {
it('blocks submit when signature is not approved on the selected channel', async () => {
const { service, prisma } = createService();
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
prisma.channelSignatureReportTask.findMany.mockResolvedValue([]);
const route = await prisma.channelRouteRule.findFirst();
prisma.channelRouteRule.findFirst.mockResolvedValue({ ...route, group: { ...route.group, items: [] } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({
id: 'record-1',
tenantId: 'tenant-1',
@@ -3396,14 +3532,14 @@ describe('SendChainService', () => {
amountCents: 3,
status: 'queued',
queuePriority: 'normal',
batchTask: { sourceType: 'cmpp' },
batchTask: { sourceType: 'cmpp', phoneTotal: 1 },
template: { signature: { id: 'sig-1', name: '签名' } },
});
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
expect.objectContaining({ submitted: false, status: 'failed', reason: '无已报备通过且在线的可用通道' }),
);
expect(gatewayAdd).not.toHaveBeenCalled();
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ receiptStatus: 'undelivered', errorCode: 'ROUTE' }),
});
@@ -3411,9 +3547,7 @@ describe('SendChainService', () => {
it('only selects channel group items allocated to the matched carrier', async () => {
const { service, prisma } = createService();
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
prisma.channelRouteRule.findFirst.mockResolvedValue({
id: 'route-1',
tenantId: 'tenant-1',
@@ -3475,8 +3609,7 @@ describe('SendChainService', () => {
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
expect.objectContaining({ channelId: 'channel-all' }),
);
expect(gatewayAdd).toHaveBeenCalledWith(
'submit-command',
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
expect.objectContaining({ channelId: 'channel-all', route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }) }),
);
});
@@ -4429,7 +4562,7 @@ describe('SendChainService', () => {
where: { id: 'record-1', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
expect(prisma.$executeRaw).toHaveBeenCalled();
});
it('queues an explicit HTTP failure webhook when a receipt times out', async () => {
+9 -5
View File
@@ -79,7 +79,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
onModuleInit() {
const processRole = process.env.CMPP_PROCESS_ROLE?.trim() || 'all';
if (processRole === 'api') return;
if (processRole === 'api' || processRole === 'callback') return;
if (processRole === 'outbox') {
this.submission.startSubmitOutboxPublisher();
return;
}
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
this.startWorker();
}
@@ -701,8 +705,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.selectChannelForMessage(message, options);
}
private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
return this.submission.findApplicationRoute(tenantId, applicationId, carrier);
private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
}
private async identifyCarrier(phoneNumber: string) {
@@ -836,8 +840,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.waitForChannelRateLimit(channelId, tps);
}
private async refreshTaskProgress(batchTaskId: string) {
return this.submission.refreshTaskProgress(batchTaskId);
private async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
return this.submission.refreshTaskProgress(batchTaskId, knownSingleMessageStatus);
}
private smsMessageSegmentAuditDelegate() {
+722 -75
View File
@@ -8,6 +8,7 @@ import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
@@ -15,6 +16,12 @@ import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto,
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
type PendingSendBatchItem = {
job: SendJob;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
};
/**
* R9 gatewaySubmit implementation. Cross-method calls return through the stable SendChainService seam.
*/
@@ -24,8 +31,18 @@ export class SendGatewaySubmitService {
private sendQueue?: Queue<SendJob, unknown, 'send-message'>;
private gatewayQueue?: Queue;
private worker?: Worker<SendJob>;
private sendQueueMetricsTimer?: ReturnType<typeof setInterval>;
private submitOutboxTimer?: ReturnType<typeof setInterval>;
private submitOutboxRunning = false;
private readonly submitOutboxLeaseOwner = `send-worker-${process.pid}-${randomUUID()}`;
private sendWorkerInFlight = 0;
private sendWorkerConfiguredSlots = 0;
private pendingSendBatch: PendingSendBatchItem[] = [];
private sendBatchTimer?: ReturnType<typeof setTimeout>;
private sendBatchFlushing = false;
private readonly taskProgressRefreshes = new Map<string, Promise<void>>();
private readonly dirtyTaskProgressRefreshes = new Set<string>();
private readonly openSubmitSessionIds = new Map<string, Promise<string>>();
constructor(
private readonly prisma: PrismaService,
@@ -35,9 +52,14 @@ export class SendGatewaySubmitService {
private readonly phoneRouting: PhoneRoutingLookupService,
private readonly facade: SendSubmissionService,
private readonly callbacks: SendSubmissionCallbacks,
private readonly metrics?: MetricsService,
) {}
async onModuleDestroy() {
if (this.sendQueueMetricsTimer) clearInterval(this.sendQueueMetricsTimer);
if (this.submitOutboxTimer) clearInterval(this.submitOutboxTimer);
if (this.sendBatchTimer) clearTimeout(this.sendBatchTimer);
await this.flushSendBatch();
await this.worker?.close();
await this.sendQueue?.close();
await this.gatewayQueue?.close();
@@ -112,46 +134,390 @@ startWorker() {
return { status: 'already_started' };
}
const connection = bullmqConnection();
const configuredConcurrency = Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20);
this.sendWorkerConfiguredSlots = Number.isInteger(configuredConcurrency) && configuredConcurrency > 0 ? configuredConcurrency : 20;
this.metrics?.setSendWorkerSlots(this.sendWorkerConfiguredSlots, this.sendWorkerInFlight);
this.worker = new Worker<SendJob>(
SEND_QUEUE,
async (job) => this.facade.processSendJob(job.data),
{ connection, concurrency: Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20) },
async (job) => {
this.sendWorkerInFlight += 1;
this.metrics?.setSendWorkerSlots(this.sendWorkerConfiguredSlots, this.sendWorkerInFlight);
try {
return process.env.API_SEND_WORKER_BATCH_ENABLED === 'false'
? await this.facade.processSendJob(job.data)
: await this.enqueueSendBatch(job.data);
} finally {
this.sendWorkerInFlight = Math.max(0, this.sendWorkerInFlight - 1);
this.metrics?.setSendWorkerSlots(this.sendWorkerConfiguredSlots, this.sendWorkerInFlight);
}
},
{ connection, concurrency: this.sendWorkerConfiguredSlots },
);
void this.refreshSendQueueMetrics();
this.sendQueueMetricsTimer = setInterval(() => void this.refreshSendQueueMetrics(), 5_000);
this.sendQueueMetricsTimer.unref?.();
if (this.submitOutboxEnabled() && process.env.SEND_SUBMIT_OUTBOX_SEPARATE_PROCESS_ENABLED !== 'true') {
void this.publishSubmitOutboxBatch();
this.submitOutboxTimer = setInterval(
() => void this.publishSubmitOutboxBatch(),
getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_POLL_INTERVAL_MS', 25),
);
this.submitOutboxTimer.unref?.();
}
return { status: 'started' };
}
async processSendJob(job: SendJob) {
const message = await this.prisma.smsMessageRecord.findUnique({
where: { id: job.messageRecordId },
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
});
if (!message || message.status !== 'queued') {
return { skipped: true };
}
if (!message.tenantId || !message.batchTaskId) {
return { skipped: true, reason: 'standalone channel test message' };
}
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
try {
const routed = await this.facade.selectChannelForMessage(businessMessage);
return await this.facade.submitMessageToGateway(businessMessage, routed, 0);
} catch (error) {
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'failed', errorMessage: reason },
});
await this.releaseMessageReservation(businessMessage, reason);
if (message.batchTask?.sourceType === 'cmpp') {
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason);
} else {
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
startSubmitOutboxPublisher() {
if (this.submitOutboxTimer) return { status: 'already_started' };
if (!this.submitOutboxEnabled()) return { status: 'disabled' };
void this.publishSubmitOutboxBatch();
this.submitOutboxTimer = setInterval(
() => void this.publishSubmitOutboxBatch(),
getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_POLL_INTERVAL_MS', 25),
);
this.submitOutboxTimer.unref?.();
return { status: 'started' };
}
private enqueueSendBatch(job: SendJob) {
return new Promise((resolve, reject) => {
this.pendingSendBatch.push({ job, resolve, reject });
const batchSize = Math.min(128, getPositiveConfigInteger(process.env, 'API_SEND_WORKER_BATCH_SIZE', 32));
if (this.pendingSendBatch.length >= batchSize) {
if (this.sendBatchTimer) clearTimeout(this.sendBatchTimer);
this.sendBatchTimer = undefined;
queueMicrotask(() => void this.flushSendBatch());
return;
}
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
if (!this.sendBatchTimer) {
this.sendBatchTimer = setTimeout(
() => {
this.sendBatchTimer = undefined;
void this.flushSendBatch();
},
Math.min(25, getPositiveConfigInteger(process.env, 'API_SEND_WORKER_BATCH_WAIT_MS', 3)),
);
this.sendBatchTimer.unref?.();
}
});
}
private async flushSendBatch() {
if (this.sendBatchFlushing) return;
this.sendBatchFlushing = true;
try {
const batchSize = Math.min(128, getPositiveConfigInteger(process.env, 'API_SEND_WORKER_BATCH_SIZE', 32));
while (this.pendingSendBatch.length > 0) {
const batch = this.pendingSendBatch.splice(0, batchSize);
try {
const results = await this.processSendJobBatch(batch.map((item) => item.job));
for (const item of batch) item.resolve(results.get(item.job.messageRecordId) ?? { skipped: true });
} catch (error) {
for (const item of batch) item.reject(error);
}
}
} finally {
this.sendBatchFlushing = false;
if (this.pendingSendBatch.length > 0) queueMicrotask(() => void this.flushSendBatch());
}
}
async submitMessageToGateway(
private async processSendJobBatch(jobs: SendJob[]) {
if (jobs.length === 1) {
return new Map([[jobs[0].messageRecordId, await this.processSendJob(jobs[0])]]);
}
const ids = [...new Set(jobs.map((job) => job.messageRecordId))];
const messages = await this.measureSendStage('message_load', () => this.prisma.smsMessageRecord.findMany({
where: { id: { in: ids } },
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
}));
const messageById = new Map(messages.map((message) => [message.id, message]));
const results = new Map<string, unknown>();
const businessMessages = messages.filter((message) => {
if (message.status !== 'queued' || !message.tenantId || !message.batchTaskId) {
results.set(message.id, { skipped: true });
return false;
}
return true;
}) as Array<typeof messages[number] & { tenantId: string; batchTaskId: string }>;
for (const id of ids) if (!messageById.has(id)) results.set(id, { skipped: true });
if (businessMessages.length === 0) return results;
const { planned, failed } = await this.planRoutesBatch(businessMessages);
if (failed.length > 0) await this.failRouteBatch(failed, results);
if (planned.length === 0) return results;
await Promise.all(planned.map(({ routed }) => (
this.facade.waitForChannelRateLimit(routed.channel.id, routed.channel.rateLimitPerSecond)
)));
const sessionByChannel = new Map<string, string>();
await Promise.all([...new Set(planned.map(({ routed }) => routed.channel.id))].map(async (channelId) => {
sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId));
}));
const prepared = planned.map(({ message, routed }) => {
const submitId = `SUB-${randomUUID()}`;
const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension);
return {
message,
routed,
submitId,
command: this.buildGatewaySubmitCommand(message, routed, 0, submitId, upstreamSrcId),
sessionId: sessionByChannel.get(routed.channel.id),
};
});
const writeOutbox = this.submitOutboxEnabled();
await this.measureSendStage('submit_transaction', () => this.prisma.$transaction(async (tx) => {
await tx.smsSubmitRecord.createMany({
data: prepared.map(({ message, routed, submitId, sessionId }) => ({
id: randomUUID(),
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
channelId: routed.channel.id,
channelGroupId: routed.groupId,
channelGroupName: routed.groupName,
sessionId,
submitId,
submitStatus: 'queued',
costUnitPrice: routed.channel.unitPrice ?? 0,
costAmountCents: moneyToNumber(routed.channel.unitPrice) * Math.max(1, message.billingUnits ?? 1),
})),
});
const updates = Prisma.join(prepared.map(({ message, routed, submitId }) => Prisma.sql`(
${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text,
${routed.province ?? null}::text, ${submitId}::text
)`));
await tx.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message
SET "channelId" = updates."channelId",
carrier = updates.carrier,
province = updates.province,
"submitId" = updates."submitId",
status = 'submit_queued',
"submitStatus" = 'queued',
"receiptStatus" = NULL,
"errorCode" = NULL,
"errorMessage" = NULL,
"updatedAt" = CURRENT_TIMESTAMP
FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId")
WHERE message.id = updates.id AND message.status = 'queued'
`);
if (writeOutbox) {
await tx.gatewaySubmitOutbox.createMany({
data: prepared.map(({ message, routed, submitId, command }) => ({
id: randomUUID(), submitId, messageRecordId: message.id,
channelId: routed.channel.id, payload: command as Prisma.InputJsonValue,
})),
});
}
}));
if (!this.submitOutboxPublishEnabled()) {
await Promise.all(prepared.map(({ command }) => this.facade.publishGatewaySubmitCommand(command)));
}
await this.refreshTaskProgressBatch(prepared.map(({ message }) => message));
for (const { message, routed, submitId } of prepared) {
results.set(message.id, {
submitted: true, messageRecordId: message.id, channelId: routed.channel.id, attempt: 0, submitId,
});
this.metrics?.recordSendWorkerResult('completed');
}
return results;
}
private async planRoutesBatch<T extends {
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null;
templateId?: string | null; signatureId?: string | null; phoneNumber: string;
carrier?: string | null; province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
}>(messages: T[]) {
const unresolvedPhones = messages.filter((message) => !message.carrier).map((message) => message.phoneNumber);
const provinces = await this.measureSendStage('phone_routing', () => this.phoneRouting.identifyProvinces(unresolvedPhones));
const routeInputs = await Promise.all(messages.map(async (message) => ({
message,
carrier: message.carrier ? normalizeCarrier(message.carrier) : normalizeCarrier(await this.phoneRouting.identifyCarrier(message.phoneNumber)),
province: message.carrier ? message.province ?? null : provinces.get(message.phoneNumber) ?? null,
signatureId: message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null,
})));
const valid = routeInputs.filter((input) => input.message.applicationId && input.signatureId);
const signatures = [...new Set(valid.map((input) => input.signatureId as string))];
const routes = valid.length === 0 ? [] : await this.measureSendStage('route_lookup', () => this.prisma.channelRouteRule.findMany({
where: {
status: 'active', channelId: null, province: null,
OR: valid.map((input) => ({
tenantId: input.message.tenantId,
applicationId: input.message.applicationId,
carrier: input.carrier,
})),
},
include: {
group: {
include: {
items: {
include: {
channel: {
include: {
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
reportTasks: { where: { signatureId: { in: signatures }, reportType: 'signature', status: 'approved' } },
},
},
},
orderBy: { priority: 'asc' },
},
},
},
},
orderBy: { priority: 'asc' },
}));
const routeByKey = new Map<string, typeof routes[number]>();
for (const route of routes) {
const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`;
if (!routeByKey.has(key)) routeByKey.set(key, route);
}
const planned: Array<{ message: T; routed: RoutedChannel }> = [];
const failed: Array<{ message: T; reason: string }> = [];
for (const input of routeInputs) {
if (!input.message.applicationId) {
failed.push({ message: input.message, reason: '短信应用未配置,无法选择通道组' });
continue;
}
if (!input.signatureId) {
failed.push({ message: input.message, reason: '短信签名未配置,无法选择已报备通道' });
continue;
}
const route = routeByKey.get(`${input.message.tenantId}:${input.message.applicationId}:${input.carrier}`);
if (!route) {
failed.push({ message: input.message, reason: '企业应用未配置对应运营商通道组' });
continue;
}
if (route.group.status !== 'active' || normalizeCarrier(route.group.carrier) !== input.carrier) {
failed.push({ message: input.message, reason: '企业应用绑定的通道组已停用或运营商不一致' });
continue;
}
const approvedItems = route.group.items.filter((item) => item.channel.status === 'active'
&& item.channel.connectionStates.length > 0
&& item.channel.reportTasks.some((task) => task.signatureId === input.signatureId
&& (task.carrier === input.carrier
|| (process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel'))));
const selected = selectChannelCandidate(approvedItems, {
carrier: input.carrier,
province: input.province,
excludedChannelIds: new Set(),
approvedChannelIds: new Set(approvedItems.map((item) => item.channelId)),
routingKey: input.message.id,
});
if (!selected) {
failed.push({ message: input.message, reason: '无已报备通过且在线的可用通道' });
continue;
}
planned.push({
message: input.message,
routed: {
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier: input.carrier, province: input.province,
groupId: route.groupId, groupName: route.group.name,
routeScope: isNationalChannel(selected) ? 'national' : 'province',
},
});
}
return { planned, failed };
}
private async failRouteBatch<T extends {
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null;
messageId: string; phoneNumber: string; amountCents: bigint; billingUnits: number;
cmppSubmitSequenceId?: string | null; cmppSubmitGroupMessageId?: string | null;
batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
}>(failed: Array<{ message: T; reason: string }>, results: Map<string, unknown>) {
const values = Prisma.join(failed.map(({ message, reason }) => Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text)`));
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message
SET status = 'failed', "errorMessage" = failures.reason, "updatedAt" = CURRENT_TIMESTAMP
FROM (VALUES ${values}) AS failures(id, reason)
WHERE message.id = failures.id AND message.status = 'queued'
`);
await Promise.all(failed.map(async ({ message, reason }) => {
await this.releaseMessageReservation(message, reason);
if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, 'ROUTE', reason);
else await this.facade.refreshTaskProgress(message.batchTaskId);
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
this.metrics?.recordSendWorkerResult('failed');
}));
}
private async refreshTaskProgressBatch(messages: Array<{
batchTaskId: string; batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
}>) {
const singleCmppIds = [...new Set(messages
.filter((message) => message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1)
.map((message) => message.batchTaskId))];
if (singleCmppIds.length > 0) {
await this.prisma.smsBatchTask.updateMany({
where: { id: { in: singleCmppIds }, sourceType: 'cmpp', phoneTotal: 1 },
data: singleMessageTaskProgress('submit_queued'),
});
}
const otherTaskIds = [...new Set(messages
.filter((message) => !singleCmppIds.includes(message.batchTaskId))
.map((message) => message.batchTaskId))];
await Promise.all(otherTaskIds.map((taskId) => this.facade.refreshTaskProgress(taskId)));
}
async processSendJob(job: SendJob) {
const totalStartedAt = this.metrics?.beginSendWorkerStage();
let totalFinished = false;
const finish = (result: 'completed' | 'failed' | 'skipped') => {
if (totalFinished) return;
totalFinished = true;
if (totalStartedAt != null) {
this.metrics?.finishSendWorkerStage(totalStartedAt, 'total', result === 'completed' ? 'success' : result === 'skipped' ? 'skipped' : 'error');
}
this.metrics?.recordSendWorkerResult(result);
};
try {
const message = await this.measureSendStage('message_load', () => this.prisma.smsMessageRecord.findUnique({
where: { id: job.messageRecordId },
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
}));
if (!message || message.status !== 'queued') {
finish('skipped');
return { skipped: true };
}
if (!message.tenantId || !message.batchTaskId) {
finish('skipped');
return { skipped: true, reason: 'standalone channel test message' };
}
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
try {
const routed = await this.facade.selectChannelForMessage(businessMessage);
const result = await this.facade.submitMessageToGateway(businessMessage, routed, 0);
finish(result.submitted ? 'completed' : 'skipped');
return result;
} catch (error) {
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'failed', errorMessage: reason },
});
await this.releaseMessageReservation(businessMessage, reason);
if (message.batchTask?.sourceType === 'cmpp') {
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason);
} else {
await this.facade.refreshTaskProgress(
businessMessage.batchTaskId,
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'failed' : undefined,
);
}
finish('failed');
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
}
} catch (error) {
finish('failed');
throw error;
}
}
async submitMessageToGateway(
message: {
id: string;
tenantId: string;
@@ -169,6 +535,7 @@ async submitMessageToGateway(
applicationExtension?: string | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
signature?: { id?: string | null; name?: string | null } | null;
batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
},
routed: RoutedChannel,
attempt: number,
@@ -176,16 +543,13 @@ async submitMessageToGateway(
) {
const channel = routed.channel;
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
await this.facade.ensureSignatureReportedForChannel(message, channel.id, routed.carrier);
await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
await this.measureSendStage('rate_limit', () => this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond));
const submitId = `SUB-${randomUUID()}`;
const sessionId = await this.getOpenSubmitSessionId(channel.id);
const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId);
const writeOutbox = this.submitOutboxEnabled();
try {
await this.prisma.$transaction(async (tx) => {
const session = await tx.cmppSubmitSession.upsert({
where: { sessionNo: `OPEN-${channel.id}` },
update: { submitTotal: { increment: 1 } },
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
});
await this.measureSendStage('submit_transaction', () => this.prisma.$transaction(async (tx) => {
await tx.smsSubmitRecord.create({
data: {
tenantId: message.tenantId,
@@ -194,7 +558,7 @@ async submitMessageToGateway(
channelId: channel.id,
channelGroupId: routed.groupId,
channelGroupName: routed.groupName,
sessionId: session.id,
sessionId,
retryOfSubmitRecordId,
submitId,
submitStatus: 'queued',
@@ -216,7 +580,17 @@ async submitMessageToGateway(
errorMessage: attempt > 0 ? `${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined,
},
});
});
if (writeOutbox) {
await tx.gatewaySubmitOutbox.create({
data: {
submitId,
messageRecordId: message.id,
channelId: channel.id,
payload: command as Prisma.InputJsonValue,
},
});
}
}));
if (retryOfSubmitRecordId) {
this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({
messageId: message.messageId,
@@ -255,7 +629,31 @@ async submitMessageToGateway(
}
throw error;
}
const command = {
if (!this.submitOutboxPublishEnabled()) {
await this.measureSendStage('gateway_stream_publish', () => this.facade.publishGatewaySubmitCommand(command));
}
await this.measureSendStage('task_progress', () => this.facade.refreshTaskProgress(
message.batchTaskId,
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'submit_queued' : undefined,
));
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
}
private buildGatewaySubmitCommand(
message: {
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null;
templateId?: string | null; messageId: string; phoneNumber: string; content: string;
billingUnits: number; queuePriority?: string | null; applicationExtension?: string | null;
template?: { signature?: { name?: string | null } | null } | null;
signature?: { name?: string | null } | null;
},
routed: RoutedChannel,
attempt: number,
submitId: string,
upstreamSrcId: string,
) {
const channel = routed.channel;
return {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
traceId: randomUUID(),
@@ -304,10 +702,124 @@ async submitMessageToGateway(
},
retry: { attempt, maxAttempts: 1 },
};
await this.facade.getGatewayQueue().add('submit-command', command);
await this.facade.publishGatewaySubmitCommand(command);
await this.facade.refreshTaskProgress(message.batchTaskId);
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
}
private submitOutboxEnabled() {
return process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED === 'true'
|| process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED === 'true';
}
private submitOutboxPublishEnabled() {
return process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED === 'true';
}
private async publishSubmitOutboxBatch() {
if (this.submitOutboxRunning || !this.submitOutboxEnabled()) return;
this.submitOutboxRunning = true;
try {
const batchSize = Math.min(500, getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_BATCH_SIZE', 64));
const leaseSeconds = Math.min(300, getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_LEASE_SECONDS', 30));
const rows = await this.prisma.$queryRaw<Array<{ id: string; submitId: string; payload: Prisma.JsonValue }>>(Prisma.sql`
WITH candidates AS (
SELECT id
FROM "GatewaySubmitOutbox"
WHERE (
(status = 'pending' AND "nextAttemptAt" <= CURRENT_TIMESTAMP)
OR (status = 'publishing' AND "leaseExpiresAt" < CURRENT_TIMESTAMP)
)
ORDER BY "createdAt", id
LIMIT ${batchSize}
FOR UPDATE SKIP LOCKED
)
UPDATE "GatewaySubmitOutbox" AS outbox
SET status = 'publishing',
"leaseOwner" = ${this.submitOutboxLeaseOwner},
"leaseExpiresAt" = CURRENT_TIMESTAMP + (${leaseSeconds} * INTERVAL '1 second'),
"attemptCount" = outbox."attemptCount" + 1,
"updatedAt" = CURRENT_TIMESTAMP
FROM candidates
WHERE outbox.id = candidates.id
RETURNING outbox.id, outbox."submitId", outbox.payload
`);
if (rows.length === 0) return;
const results = this.submitOutboxPublishEnabled()
? await this.publishGatewaySubmitCommandBatch(rows)
: rows.map((row) => ({ row, streamEntryId: `shadow:${row.submitId}` }));
const succeeded = results.filter((result): result is { row: typeof rows[number]; streamEntryId: string } => 'streamEntryId' in result);
if (succeeded.length > 0) {
const values = Prisma.join(succeeded.map(({ row, streamEntryId }) => Prisma.sql`(${row.id}, ${streamEntryId})`));
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "GatewaySubmitOutbox" AS outbox
SET status = 'published',
"streamEntryId" = published."streamEntryId",
"publishedAt" = CURRENT_TIMESTAMP,
"leaseOwner" = NULL,
"leaseExpiresAt" = NULL,
"lastError" = NULL,
"updatedAt" = CURRENT_TIMESTAMP
FROM (VALUES ${values}) AS published(id, "streamEntryId")
WHERE outbox.id = published.id
AND outbox.status = 'publishing'
AND outbox."leaseOwner" = ${this.submitOutboxLeaseOwner}
`);
}
for (const result of results) {
if ('streamEntryId' in result) continue;
const { row, error } = result;
try {
const message = error instanceof Error ? error.message : String(error);
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "GatewaySubmitOutbox"
SET status = CASE WHEN "attemptCount" >= 10 THEN 'dead' ELSE 'pending' END,
"nextAttemptAt" = CURRENT_TIMESTAMP + (LEAST(60, POWER(2, LEAST("attemptCount", 6))) * INTERVAL '1 second'),
"leaseOwner" = NULL,
"leaseExpiresAt" = NULL,
"lastError" = ${message.slice(0, 1000)},
"updatedAt" = CURRENT_TIMESTAMP
WHERE id = ${row.id} AND "leaseOwner" = ${this.submitOutboxLeaseOwner}
`);
} catch (recordError) {
this.logger.error(`gateway_submit_outbox_failure_record_failed ${recordError instanceof Error ? recordError.message : String(recordError)}`);
}
}
} catch (error) {
this.logger.error(`gateway_submit_outbox_publish_failed ${error instanceof Error ? error.message : String(error)}`);
} finally {
this.submitOutboxRunning = false;
}
}
private async publishGatewaySubmitCommandBatch(
rows: Array<{ id: string; submitId: string; payload: Prisma.JsonValue }>,
): Promise<Array<
{ row: typeof rows[number]; streamEntryId: string }
| { row: typeof rows[number]; error: unknown }
>> {
const redis = this.facade.getRedis();
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
const script = `local existing = redis.call('GET', KEYS[2])
if existing then return existing end
local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1])
redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2])
return streamId`;
const pipeline = redis.pipeline();
for (const row of rows) {
pipeline.eval(
script,
2,
stream,
`gateway:submit:outbox:${row.submitId}`,
JSON.stringify(row.payload),
String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS),
);
}
const replies = await pipeline.exec();
if (!replies || replies.length !== rows.length) {
return rows.map((row) => ({ row, error: new Error('Redis Outbox pipeline result count mismatch') }));
}
return replies.map(([error, value], index) => error
? { row: rows[index], error }
: { row: rows[index], streamEntryId: typeof value === 'string' ? value : String(value ?? '') });
}
async selectChannelForMessage(
@@ -318,33 +830,31 @@ async selectChannelForMessage(
throw new BadRequestException('短信应用未配置,无法选择通道组');
}
const hasPersistedRouting = Boolean(message.carrier);
const [carrier, province] = hasPersistedRouting
? [normalizeCarrier(message.carrier), message.province ?? null]
: await Promise.all([
this.facade.identifyCarrier(message.phoneNumber),
this.facade.identifyProvince(message.phoneNumber),
]);
if (!hasPersistedRouting) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { carrier, province },
});
}
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId, carrier);
const excluded = new Set(options.excludeChannelIds ?? []);
const signatureId = await this.facade.resolveMessageSignatureId(message);
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
where: {
signatureId,
reportType: 'signature',
status: 'approved',
channelId: { in: route.group.items.map((item) => item.channelId) },
OR: signatureReportApprovalScopes(carrier),
},
select: { channelId: true },
const [carrier, province] = await this.measureSendStage('phone_routing', async () => {
const resolved = hasPersistedRouting
? [normalizeCarrier(message.carrier), message.province ?? null] as const
: await Promise.all([
this.facade.identifyCarrier(message.phoneNumber),
this.facade.identifyProvince(message.phoneNumber),
]);
if (!hasPersistedRouting) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { carrier: resolved[0], province: resolved[1] },
});
}
return resolved;
});
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
const signatureId = await this.measureSendStage('signature_candidates', () => this.facade.resolveMessageSignatureId(message));
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
const route = await this.measureSendStage('route_lookup', () => this.facade.findApplicationRoute(
message.tenantId,
message.applicationId ?? undefined,
carrier,
signatureId,
));
const excluded = new Set(options.excludeChannelIds ?? []);
const approvedChannelIds = new Set(route.group.items.map((item) => item.channelId));
const selected = selectChannelCandidate(route.group.items, {
carrier,
province,
@@ -366,7 +876,55 @@ async selectChannelForMessage(
};
}
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
private async measureSendStage<T>(stage: SendWorkerStage, operation: () => Promise<T>): Promise<T> {
const startedAt = this.metrics?.beginSendWorkerStage();
try {
const result = await operation();
if (startedAt != null) this.metrics?.finishSendWorkerStage(startedAt, stage, 'success');
return result;
} catch (error) {
if (startedAt != null) this.metrics?.finishSendWorkerStage(startedAt, stage, 'error');
throw error;
}
}
private async refreshSendQueueMetrics() {
if (!this.metrics) return;
try {
const counts = await this.facade.getSendQueue().getJobCounts('wait', 'active', 'completed', 'failed', 'delayed', 'prioritized');
const mappings: Array<[SendWorkerQueueState, number]> = [
['waiting', counts.wait ?? 0],
['active', counts.active ?? 0],
['completed', counts.completed ?? 0],
['failed', counts.failed ?? 0],
['delayed', counts.delayed ?? 0],
['prioritized', counts.prioritized ?? 0],
];
for (const [state, count] of mappings) this.metrics.setSendWorkerQueueJobs(state, count);
const pool = this.prisma.getPoolState();
for (const state of ['max', 'total', 'idle', 'waiting'] as const) {
this.metrics.setSendWorkerDatabasePool(state, pool[state]);
}
} catch (error) {
this.logger.warn(`send_queue_metrics_refresh_failed ${error instanceof Error ? error.message : String(error)}`);
}
}
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
const approvedChannelWhere = signatureId
? {
status: 'active',
connectionStates: { some: { status: 'connected', currentConnections: { gt: 0 } } },
reportTasks: {
some: {
signatureId,
reportType: 'signature',
status: 'approved',
OR: signatureReportApprovalScopes(carrier),
},
},
}
: undefined;
const route = await this.prisma.channelRouteRule.findFirst({
where: {
status: 'active',
@@ -376,7 +934,25 @@ async findApplicationRoute(tenantId: string, applicationId: string | undefined,
channelId: null,
province: null,
},
include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } },
include: {
group: {
include: {
items: {
where: approvedChannelWhere ? { channel: approvedChannelWhere } : undefined,
include: {
channel: {
include: {
connectionStates: approvedChannelWhere
? { where: { status: 'connected', currentConnections: { gt: 0 } } }
: true,
},
},
},
orderBy: { priority: 'asc' },
},
},
},
},
orderBy: { priority: 'asc' },
});
if (!route) {
@@ -450,7 +1026,40 @@ async waitForChannelRateLimit(channelId: string, tps: number) {
}
}
async refreshTaskProgress(batchTaskId: string) {
async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
if (knownSingleMessageStatus) {
const direct = await this.prisma.smsBatchTask.updateMany({
where: { id: batchTaskId, sourceType: 'cmpp', phoneTotal: 1 },
data: singleMessageTaskProgress(knownSingleMessageStatus),
});
if (direct.count === 1) return;
} else {
// Gateway结果、回执和超时回调已经先提交了消息状态。对CMPP单号码内部任务,
// 在同一条UPDATE中读取该唯一消息的当前状态并写入精确计数,避免每次回调都
// 对一个只有一行的任务执行GROUP BY;多号码任务继续使用下方聚合路径。
const direct = await this.prisma.$executeRaw(Prisma.sql`
UPDATE "SmsBatchTask" AS task
SET
"progressTotal" = 1,
"submittedTotal" = CASE WHEN message.status IN ('submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout') THEN 1 ELSE 0 END,
"successTotal" = CASE WHEN message.status = 'delivered' THEN 1 ELSE 0 END,
"failedTotal" = CASE WHEN message.status IN ('submit_failed', 'failed') THEN 1 ELSE 0 END,
"unknownTotal" = CASE WHEN message.status = 'unknown' THEN 1 ELSE 0 END,
"timeoutTotal" = CASE WHEN message.status = 'timeout' THEN 1 ELSE 0 END,
status = CASE
WHEN message.status IN ('delivered', 'submit_failed', 'failed', 'timeout') THEN 'finished'
WHEN message.status IN ('submit_queued', 'submitted', 'unknown') THEN 'sending'
ELSE 'queued'
END,
"updatedAt" = CURRENT_TIMESTAMP
FROM "SmsMessageRecord" AS message
WHERE task.id = ${batchTaskId}
AND task."sourceType" = 'cmpp'
AND task."phoneTotal" = 1
AND message."batchTaskId" = task.id
`);
if (direct === 1) return;
}
const running = this.taskProgressRefreshes.get(batchTaskId);
if (running) {
// A state transition committed after the running aggregate may not be visible
@@ -519,7 +1128,7 @@ getRedis() {
return this.redis;
}
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
const redis = this.facade.getRedis();
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
const payload = JSON.stringify(command);
@@ -540,6 +1149,44 @@ return streamId`,
);
return typeof result === 'string' ? result : String(result ?? '');
}
private getOpenSubmitSessionId(channelId: string) {
const cached = this.openSubmitSessionIds.get(channelId);
if (cached) return cached;
const sessionNo = `OPEN-${channelId}`;
const pending = this.prisma.cmppSubmitSession.findUnique({
where: { sessionNo },
select: { id: true },
}).then((existing) => existing ?? this.prisma.cmppSubmitSession.upsert({
where: { sessionNo },
update: {},
create: { channelId, sessionNo, submitTotal: 0 },
select: { id: true },
})).then((session) => session.id).catch((error) => {
this.openSubmitSessionIds.delete(channelId);
throw error;
});
this.openSubmitSessionIds.set(channelId, pending);
return pending;
}
}
function singleMessageTaskProgress(status: string) {
const submittedTotal = ['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout'].includes(status) ? 1 : 0;
const successTotal = status === 'delivered' ? 1 : 0;
const failedTotal = ['submit_failed', 'failed'].includes(status) ? 1 : 0;
const unknownTotal = status === 'unknown' ? 1 : 0;
const timeoutTotal = status === 'timeout' ? 1 : 0;
const doneTotal = successTotal + failedTotal + timeoutTotal;
return {
progressTotal: 1,
submittedTotal,
successTotal,
failedTotal,
unknownTotal,
timeoutTotal,
status: doneTotal >= 1 ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued',
};
}
function signatureReportApprovalScopes(carrier: string) {
@@ -1542,53 +1542,6 @@ startInboundWorkflowWorker() {
return { candidate, workflowDigest, taskId, messageRecordId, content, drainageDetection, billing };
}));
await this.measureInboundStage('message_persist', () => this.prisma.$transaction(async (tx) => {
// Lock accounts in a stable order and reserve the whole tenant subtotal once.
// Per-message idempotency rows remain separate so retries, releases and charges
// keep the original accounting contract without one account update per Inbox row.
const tenantIds = [...new Set(prepared.map(({ candidate }) => candidate.application.tenantId))].sort();
for (const tenantId of tenantIds) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + tenantId}, 0))`;
await tx.tenantAccount.upsert({
where: { tenantId }, update: {},
create: { tenantId, balanceCents: 0, creditCents: 0, status: 'active' },
});
const account = await tx.tenantAccount.findUniqueOrThrow({ where: { tenantId } });
const paid = prepared.filter(({ candidate, billing }) => candidate.application.tenantId === tenantId && billing.amountCents > 0);
const totalAmount = paid.reduce((sum, entry) => sum + entry.billing.amountCents, 0);
if (totalAmount === 0) continue;
const available = moneyToNumber(account.balanceCents) + moneyToNumber(account.creditCents);
if (account.status !== 'active' || available < totalAmount) {
throw new BadRequestException('企业账户余额不足');
}
const existing = await tx.accountTransaction.findMany({
where: { idempotencyKey: { in: paid.map(({ candidate }) => `${candidate.item.requestKey}:freeze`) } },
select: { idempotencyKey: true },
});
if (existing.length > 0) {
throw new ConflictException('批量计费幂等流水已存在,转入逐条恢复');
}
const balanceBefore = moneyToNumber(account.balanceCents);
let reserved = 0;
await tx.accountTransaction.createMany({
data: paid.map(({ candidate, taskId, billing }) => {
reserved += billing.amountCents;
return {
tenantId,
transactionType: 'frozen',
idempotencyKey: `${candidate.item.requestKey}:freeze`,
amountCents: -billing.amountCents,
balanceAfter: balanceBefore - reserved,
relatedType: 'sms_batch_task',
relatedId: taskId,
remark: 'CMPP 入站短信批量冻结',
};
}),
});
await tx.tenantAccount.update({
where: { tenantId },
data: { balanceCents: { decrement: totalAmount } },
});
}
await tx.smsBatchTask.createMany({
data: prepared.map(({ candidate, workflowDigest, taskId, content }) => ({
id: taskId,
@@ -1637,6 +1590,56 @@ startInboundWorkflowWorker() {
status: 'queued',
})),
});
// Acquire the tenant account lock only after the independent workflow rows
// have been staged. PostgreSQL holds transaction-scoped locks until commit;
// keeping this section last preserves the all-or-nothing boundary while
// avoiding serialization across task/API/message persistence for one tenant.
// Per-message idempotency rows remain separate for retry/release/charge repair.
const tenantIds = [...new Set(prepared.map(({ candidate }) => candidate.application.tenantId))].sort();
for (const tenantId of tenantIds) {
const paid = prepared.filter(({ candidate, billing }) => candidate.application.tenantId === tenantId && billing.amountCents > 0);
const totalAmount = paid.reduce((sum, entry) => sum + entry.billing.amountCents, 0);
if (totalAmount === 0) continue;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + tenantId}, 0))`;
await tx.tenantAccount.upsert({
where: { tenantId }, update: {},
create: { tenantId, balanceCents: 0, creditCents: 0, status: 'active' },
});
const account = await tx.tenantAccount.findUniqueOrThrow({ where: { tenantId } });
const available = moneyToNumber(account.balanceCents) + moneyToNumber(account.creditCents);
if (account.status !== 'active' || available < totalAmount) {
throw new BadRequestException('企业账户余额不足');
}
const existing = await tx.accountTransaction.findMany({
where: { idempotencyKey: { in: paid.map(({ candidate }) => `${candidate.item.requestKey}:freeze`) } },
select: { idempotencyKey: true },
});
if (existing.length > 0) {
throw new ConflictException('批量计费幂等流水已存在,转入逐条恢复');
}
const balanceBefore = moneyToNumber(account.balanceCents);
let reserved = 0;
await tx.accountTransaction.createMany({
data: paid.map(({ candidate, taskId, billing }) => {
reserved += billing.amountCents;
return {
tenantId,
transactionType: 'frozen',
idempotencyKey: `${candidate.item.requestKey}:freeze`,
amountCents: -billing.amountCents,
balanceAfter: balanceBefore - reserved,
relatedType: 'sms_batch_task',
relatedId: taskId,
remark: 'CMPP 入站短信批量冻结',
};
}),
});
await tx.tenantAccount.update({
where: { tenantId },
data: { balanceCents: { decrement: totalAmount } },
});
}
}));
await this.measureInboundStage('queue_publish', () => this.facade.getSendQueue().addBulk(prepared.map(({ candidate, messageRecordId }) => ({
name: 'send-message' as const,
+10 -5
View File
@@ -63,7 +63,7 @@ export class SendSubmissionService {
this.inboundEntry = new SendInboundEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks, metrics);
this.reviewContinuation = new SendReviewContinuationService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.scheduledDispatch = new SendScheduledDispatchService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks, metrics);
}
onModuleDestroy() {
@@ -229,6 +229,10 @@ startWorker() {
return this.gatewaySubmit.startWorker();
}
startSubmitOutboxPublisher() {
return this.gatewaySubmit.startSubmitOutboxPublisher();
}
startInboundWorkflowWorker() {
return this.inboundEntry.startInboundWorkflowWorker();
}
@@ -259,6 +263,7 @@ async submitMessageToGateway(
applicationExtension?: string | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
signature?: { id?: string | null; name?: string | null } | null;
batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
},
routed: RoutedChannel,
attempt: number,
@@ -274,8 +279,8 @@ async selectChannelForMessage(
return this.gatewaySubmit.selectChannelForMessage(message, options);
}
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier);
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
}
async identifyCarrier(phoneNumber: string) {
@@ -307,8 +312,8 @@ async waitForChannelRateLimit(channelId: string, tps: number) {
return this.gatewaySubmit.waitForChannelRateLimit(channelId, tps);
}
async refreshTaskProgress(batchTaskId: string) {
return this.gatewaySubmit.refreshTaskProgress(batchTaskId);
async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
return this.gatewaySubmit.refreshTaskProgress(batchTaskId, knownSingleMessageStatus);
}
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
+30
View File
@@ -0,0 +1,30 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { createServer } from 'node:http';
import { MetricsService } from './metrics/metrics.service';
import { SendWorkerModule } from './send-worker.module';
async function bootstrap() {
if (process.env.CMPP_PROCESS_ROLE !== 'outbox') {
throw new Error('submit-outbox-worker requires CMPP_PROCESS_ROLE=outbox');
}
const app = await NestFactory.createApplicationContext(SendWorkerModule);
app.enableShutdownHooks();
const metrics = app.get(MetricsService);
const host = process.env.API_OUTBOX_METRICS_HOST?.trim() || '127.0.0.1';
const port = Number(process.env.API_OUTBOX_METRICS_PORT ?? 9467);
const server = createServer((request, response) => {
if (request.method !== 'GET' || request.url !== '/metrics') {
response.writeHead(404).end();
return;
}
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(metrics.render());
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, resolve);
});
}
void bootstrap();