feat: integrate analytics and fragment receipt improvements

This commit is contained in:
hectorzhao
2026-08-03 15:34:41 +08:00
parent 3357ace7e1
commit 530a65de80
89 changed files with 1964 additions and 324 deletions
@@ -0,0 +1,73 @@
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
describe('queueFinalReceiptDeliveries', () => {
it('queues one HTTP event and one CMPP receipt for each registered client fragment', async () => {
const prisma = {
cmppInboundLongMessage: {
findFirst: jest.fn().mockResolvedValue({
messageId: 'MSG-GROUP',
segmentTotal: 3,
segments: [
{ segmentIndex: 1, sequenceId: '101', registeredDelivery: true },
{ segmentIndex: 2, sequenceId: '102', registeredDelivery: false },
{ segmentIndex: 3, sequenceId: '103', registeredDelivery: true },
],
}),
},
};
const queue = jest.fn().mockResolvedValue({ id: 'queued' });
await queueFinalReceiptDeliveries(prisma as never, queue, {
message: {
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppSubmitGroupMessageId: 'MSG-GROUP',
},
payload: { receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
segmentPayloads: {
1: { receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
3: { receiptStatus: 'undelivered', rawStatus: 'REJECTD' },
},
});
expect(queue).toHaveBeenCalledTimes(3);
expect(queue).toHaveBeenNthCalledWith(1, expect.objectContaining({
queueHttpWebhook: true,
queueCmppDelivery: false,
}));
expect(queue).toHaveBeenNthCalledWith(2, expect.objectContaining({
receiptDedupeKey: 'receipt:record-1:segment:1',
queueHttpWebhook: false,
payload: expect.objectContaining({ submitSequenceId: 101, clientSegmentIndex: 1 }),
}));
expect(queue).toHaveBeenNthCalledWith(3, expect.objectContaining({
receiptDedupeKey: 'receipt:record-1:segment:3',
payload: expect.objectContaining({ submitSequenceId: 103, clientSegmentIndex: 3, receiptStatus: 'undelivered', rawStatus: 'REJECTD' }),
}));
});
it('queues only the message-level HTTP event when the submission did not originate from CMPP', async () => {
const prisma = { cmppInboundLongMessage: { findFirst: jest.fn() } };
const queue = jest.fn().mockResolvedValue({ id: 'queued' });
await queueFinalReceiptDeliveries(prisma as never, queue, {
message: {
id: 'record-http',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-HTTP',
phoneNumber: '13800000001',
},
payload: { receiptStatus: 'undelivered', rawStatus: 'EXPIRED' },
});
expect(queue).toHaveBeenCalledTimes(1);
expect(queue).toHaveBeenCalledWith(expect.objectContaining({
queueHttpWebhook: true,
queueCmppDelivery: false,
}));
});
});
@@ -0,0 +1,131 @@
import type { PrismaService } from '../prisma/prisma.service';
export type FinalReceiptMessage = {
id: string;
tenantId?: string | null;
applicationId?: string | null;
messageId: string;
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
};
export type DownstreamDeliveryQueueRequest = {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
receiptDedupeKey?: string;
queueHttpWebhook?: boolean;
queueCmppDelivery?: boolean;
propagateHttpQueueError?: boolean;
};
type ClientReceiptTarget = {
segmentIndex: number;
segmentTotal: number;
submitSequenceId: number;
submitGroupMessageId: string;
registeredDelivery: boolean;
};
async function resolveClientReceiptTargets(
prisma: PrismaService,
message: FinalReceiptMessage,
): Promise<ClientReceiptTarget[]> {
if (message.cmppSubmitGroupMessageId) {
const group = await prisma.cmppInboundLongMessage.findFirst({
where: { messageId: message.cmppSubmitGroupMessageId },
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
});
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 = 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,
}];
}
/**
* Queue one business-level HTTP callback and one CMPP status report for every
* original client fragment that requested Registered_Delivery. Internal retry,
* refund and billing remain message-level; only protocol delivery is expanded.
*/
export async function queueFinalReceiptDeliveries(
prisma: PrismaService,
queue: (request: DownstreamDeliveryQueueRequest) => Promise<unknown>,
data: {
message: FinalReceiptMessage;
payload: Record<string, unknown>;
segmentPayloads?: Record<number, Record<string, unknown>>;
propagateHttpQueueError?: boolean;
},
) {
const { message } = data;
if (!message.tenantId || !message.applicationId) {
return { queued: false, cmppTargetCount: 0 };
}
// HTTP submissions have one client message identity, so their webhook stays
// message-level even when the carrier internally split the SMS into segments.
await queue({
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
deliveryType: 'receipt',
payload: data.payload,
queueHttpWebhook: true,
queueCmppDelivery: false,
propagateHttpQueueError: data.propagateHttpQueueError,
});
const targets = (await resolveClientReceiptTargets(prisma, message))
.filter((target) => target.registeredDelivery);
for (const target of targets) {
const isSingleFragment = target.segmentTotal === 1;
await queue({
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
deliveryType: 'receipt',
payload: {
...data.payload,
...data.segmentPayloads?.[target.segmentIndex],
submitSequenceId: target.submitSequenceId,
submitGroupMessageId: target.submitGroupMessageId,
clientSegmentIndex: target.segmentIndex,
clientSegmentTotal: target.segmentTotal,
},
receiptDedupeKey: isSingleFragment
? `receipt:${message.id}`
: `receipt:${message.id}:segment:${target.segmentIndex}`,
queueHttpWebhook: false,
queueCmppDelivery: true,
});
}
return { queued: true, cmppTargetCount: targets.length };
}
@@ -0,0 +1,35 @@
import { detectDrainageContentWithRules, type DrainageDetectionRuleSnapshot } from './drainage-content-detection';
const rules: DrainageDetectionRuleSnapshot[] = [
{ id: 'url', code: 'URL', name: 'URL', category: 'url', priority: 10, version: 1, flags: 'giu', pattern: '(?:https?:\\/\\/)?(?:www\\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,24}|(?:\\d{1,3}\\.){3}\\d{1,3})(?::\\d{1,5})?(?:\\/[^\\s,;!?<>《》]*)?' },
{ id: 'mobile', code: 'MOBILE', name: '手机', category: 'mobile', priority: 20, version: 1, flags: 'giu', pattern: '(?:^|[^0-9])((?:\\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])' },
{ id: 'landline', code: 'LANDLINE', name: '固话', category: 'landline', priority: 30, version: 1, flags: 'giu', pattern: '(?:^|[^0-9])((?:\\+?86)?(?:\\(0[0-9]{2,3}\\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:转|分机|ext)[0-9]{1,6})?)(?:$|[^0-9])' },
];
describe('drainage content detection', () => {
test.each([
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
['空格拆分域名', '请访问 ex ample . com 领取', 'url'],
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
['短横线手机号', '电话 138-0013-8000', 'mobile'],
['括号区号和分机', '致电(0108888-8888 转 123', 'landline'],
])('%s', (_name, content, category) => {
const result = detectDrainageContentWithRules(content, rules);
expect(result.hasDrainageContent).toBe(true);
expect((result.drainageDetection as { matches: Array<{ category: string }> }).matches.some((item) => item.category === category)).toBe(true);
});
it('does not classify an email address as drainage information', () => {
expect(detectDrainageContentWithRules('联系邮箱 service@example.com,谢谢', rules).hasDrainageContent).toBe(false);
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
});
it('keeps original offsets for record-page highlighting', () => {
const content = '📨详情请看 example。com/path,谢谢';
const result = detectDrainageContentWithRules(content, rules);
const [match] = (result.drainageDetection as { matches: Array<{ start: number; end: number }> }).matches;
expect(content.slice(match.start, match.end)).toContain('example。com/path');
});
});
@@ -0,0 +1,208 @@
import { BadRequestException } from '@nestjs/common';
import { createHash } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
export type DrainageDetectionCategory = 'url' | 'mobile' | 'landline' | string;
export type DrainageDetectionRuleSnapshot = {
id: string;
code: string;
name: string;
category: DrainageDetectionCategory;
pattern: string;
flags: string;
priority: number;
version: number;
};
export type DrainageDetectionMatch = {
ruleId: string;
ruleCode: string;
ruleName: string;
category: DrainageDetectionCategory;
text: string;
normalizedText: string;
start: number;
end: number;
};
export type DrainageDetectionResult = {
hasDrainageContent: boolean;
drainageDetection: Prisma.InputJsonValue;
drainageDetectionVersion: string;
drainageEvaluatedAt: Date;
};
type NormalizedContent = {
text: string;
sourceStarts: number[];
sourceEnds: number[];
};
const RULE_CACHE_TTL_MS = 30_000;
const MAX_PATTERN_LENGTH = 1_000;
const MAX_CONTENT_LENGTH = 20_000;
const MAX_MATCHES = 50;
let cachedRules: { expiresAt: number; rules: DrainageDetectionRuleSnapshot[] } | undefined;
export function invalidateDrainageDetectionRuleCache() {
cachedRules = undefined;
}
export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') {
if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空');
if (pattern.length > MAX_PATTERN_LENGTH) throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`);
if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) {
throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复');
}
// 可配置规则会运行在发送入口,禁止容易造成灾难性回溯或跨文本引用的结构。
if (/\\[1-9]/.test(pattern) || /\(\?<([=!])/.test(pattern) || /\([^)]*[+*][^)]*\)[+*{]/.test(pattern)) {
throw new BadRequestException('表达式包含不安全的回溯、后行断言或嵌套量词');
}
try {
// 强制全局匹配,避免配置遗漏 g 后只能识别首个命中。
new RegExp(pattern, flags.includes('g') ? flags : `${flags}g`);
} catch {
throw new BadRequestException('识别表达式格式不正确');
}
}
function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent {
let text = '';
const sourceStarts: number[] = [];
const sourceEnds: number[] = [];
let sourceIndex = 0;
for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) {
const sourceEnd = sourceIndex + sourceChar.length;
let normalized = sourceChar.normalize('NFKC')
.replace(/[.。]/g, '.')
.replace(/[:﹕]/g, ':')
.replace(/[]/g, '/')
.replace(/[()]/g, (char) => char === '' ? '(' : ')')
.replace(/[]/g, '+');
if (category === 'url') {
// 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。
normalized = normalized.replace(/\s+/gu, '').replace(//g, '.');
} else if (category === 'mobile' || category === 'landline') {
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
normalized = normalized.replace(/[\s\-.·,]/gu, '');
}
for (const char of normalized) {
text += char;
// RegExp.index 使用 UTF-16 code unit,映射数组必须采用相同计数方式,避免表情符号导致高亮偏移。
for (let codeUnit = 0; codeUnit < char.length; codeUnit += 1) {
sourceStarts.push(sourceIndex);
sourceEnds.push(sourceEnd);
}
}
sourceIndex = sourceEnd;
}
return { text, sourceStarts, sourceEnds };
}
function sourceRange(normalized: NormalizedContent, start: number, end: number) {
const safeStart = Math.max(0, Math.min(start, normalized.sourceStarts.length - 1));
const safeEnd = Math.max(safeStart, Math.min(end - 1, normalized.sourceEnds.length - 1));
return {
start: normalized.sourceStarts[safeStart] ?? 0,
end: normalized.sourceEnds[safeEnd] ?? 0,
};
}
function emailRanges(normalized: NormalizedContent) {
const ranges: Array<{ start: number; end: number }> = [];
const email = /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu;
for (const match of normalized.text.matchAll(email)) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
return ranges;
}
function overlaps(start: number, end: number, range: { start: number; end: number }) {
return start < range.end && end > range.start;
}
export function detectDrainageContentWithRules(
content: string,
rules: DrainageDetectionRuleSnapshot[],
evaluatedAt = new Date(),
): DrainageDetectionResult {
const matches: DrainageDetectionMatch[] = [];
const normalizedByCategory = new Map<string, NormalizedContent>();
const emailNormalized = normalizeContent(content, 'url');
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
validateDrainageDetectionPattern(rule.pattern, rule.flags);
const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category);
normalizedByCategory.set(rule.category, normalized);
const regex = new RegExp(rule.pattern, rule.flags.includes('g') ? rule.flags : `${rule.flags}g`);
for (const match of normalized.text.matchAll(regex)) {
const captured = match[1] || match[0];
const capturedOffset = match[0].indexOf(captured);
const normalizedStart = match.index + Math.max(0, capturedOffset);
const normalizedEnd = normalizedStart + captured.length;
const range = sourceRange(normalized, normalizedStart, normalizedEnd);
if (range.end <= range.start) continue;
// 邮箱整体不是引流信息;不仅排除其中的域名,也排除数字本地部分被电话规则误识别。
if (originalEmailRanges.some((emailRange) => overlaps(range.start, range.end, emailRange))) continue;
const candidate: DrainageDetectionMatch = {
ruleId: rule.id,
ruleCode: rule.code,
ruleName: rule.name,
category: rule.category,
text: content.slice(range.start, range.end),
normalizedText: captured,
start: range.start,
end: range.end,
};
if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.end)) {
matches.push(candidate);
}
if (matches.length >= MAX_MATCHES) break;
}
if (matches.length >= MAX_MATCHES) break;
}
matches.sort((a, b) => a.start - b.start || a.end - b.end);
const versionSource = rules
.map((rule) => `${rule.code}:${rule.version}`)
.sort()
.join('|');
const drainageDetectionVersion = createHash('sha256').update(versionSource).digest('hex').slice(0, 16);
return {
hasDrainageContent: matches.length > 0,
drainageDetection: {
matches,
categories: [...new Set(matches.map((item) => item.category))],
ruleCount: rules.length,
truncated: content.length > MAX_CONTENT_LENGTH || matches.length >= MAX_MATCHES,
} as Prisma.InputJsonValue,
drainageDetectionVersion,
drainageEvaluatedAt: evaluatedAt,
};
}
async function activeRules(prisma: PrismaService) {
if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
const rules = await prisma.drainageDetectionRule.findMany({
where: { status: 'active' },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
select: {
id: true,
code: true,
name: true,
category: true,
pattern: true,
flags: true,
priority: true,
version: true,
},
});
cachedRules = { rules, expiresAt: Date.now() + RULE_CACHE_TTL_MS };
return rules;
}
export async function detectDrainageContent(prisma: PrismaService, content: string) {
return detectDrainageContentWithRules(content, await activeRules(prisma));
}
@@ -12,7 +12,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
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 { 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, 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 { detectDrainageContent } from './drainage-content-detection';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
/**
@@ -62,11 +63,12 @@ async createBatchTask(data: CreateBatchTaskDto) {
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
this.facade.resolveUnitPrice(data.tenantId, data.applicationId),
this.facade.resolveQueuePriority(data.tenantId, data.applicationId),
this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
detectDrainageContent(this.prisma, data.content),
]);
const risk = messageClassification.rejectionReason
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
@@ -205,6 +207,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
clientMessageId: data.clientMessageId,
phoneNumber: phone,
content: data.content,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: rejection ? 0 : billing.unitPrice,
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
@@ -395,7 +398,8 @@ async resolveTemplateMessageClassification(
signatureId: template.signatureId,
drainageInfoId: drainage?.id,
variables,
rejectionReason: drainageRejectionReason(drainage),
// 引流资料只做关联与监控,报备审核状态不参与本期发送决策。
rejectionReason: undefined,
};
}
@@ -423,7 +427,7 @@ async resolveTemplateMessageClassification(
signatureId: signature.id,
drainageInfoId: drainage?.id,
variables: undefined,
rejectionReason: drainageRejectionReason(drainage),
rejectionReason: undefined,
};
}
@@ -35,6 +35,7 @@ export interface GatewayInboundSubmitDto {
srcId?: string;
destId?: string;
sequenceId?: number;
registeredDelivery?: number;
remoteIp?: string;
longMessage?: {
reference: number;
+3 -3
View File
@@ -61,9 +61,9 @@ export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
return `gateway:submit:requeue:${deadLetterId}:${attempt}`;
}
export function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) {
if (!drainage || drainage.auditStatus === 'approved') return undefined;
return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`;
/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */
export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) {
return undefined;
}
export function statusFromRisk(status: string, scheduled: boolean) {
+143 -16
View File
@@ -23,6 +23,9 @@ function createPrismaMock() {
submitId: 'SUB-1',
gatewayMessageId: 'GW-1',
channelId: 'channel-1',
cmppSubmitSequenceId: '101',
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: true,
template: { signature: { id: 'sig-1', name: '签名' } },
};
const channel = {
@@ -109,6 +112,9 @@ function createPrismaMock() {
smsDrainageInfo: {
findMany: jest.fn().mockResolvedValue([]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
@@ -688,7 +694,7 @@ describe('SendChainService', () => {
});
});
it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => {
it.each(['pending', 'rejected'])('does not block a matched %s drainage URL and still preserves the matched resource', async (auditStatus) => {
const { service, prisma, riskReview } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
@@ -705,14 +711,14 @@ describe('SendChainService', () => {
})).resolves.toBeDefined();
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }),
data: expect.objectContaining({ status: 'ready', rejectReason: null }),
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`),
drainageInfoId: 'drain-blocked', status: 'queued', errorMessage: undefined,
})],
});
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
expect(riskReview.evaluateTask).toHaveBeenCalled();
});
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
@@ -2576,6 +2582,18 @@ describe('SendChainService', () => {
gatewayMessageId: 'GW-SEG-1',
status: 'submitted',
billingUnits: 2,
cmppSubmitSequenceId: '501',
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-1',
cmppRegisteredDelivery: true,
});
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
id: 'long-group-receipt-1',
messageId: 'MSG-LONG-GROUP-1',
segmentTotal: 2,
segments: [
{ segmentIndex: 1, sequenceId: '501', registeredDelivery: true },
{ segmentIndex: 2, sequenceId: '502', registeredDelivery: true },
],
});
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
id: 'submit-long',
@@ -2621,10 +2639,22 @@ describe('SendChainService', () => {
where: { id: 'record-long' },
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
}));
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(1, {
data: expect.objectContaining({
dedupeKey: 'receipt:record-long:segment:1',
payload: expect.objectContaining({ submitSequenceId: 501, clientSegmentIndex: 1, clientSegmentTotal: 2 }),
}),
});
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(2, {
data: expect.objectContaining({
dedupeKey: 'receipt:record-long:segment:2',
payload: expect.objectContaining({ submitSequenceId: 502, clientSegmentIndex: 2, clientSegmentTotal: 2 }),
}),
});
});
it('creates and sends only one downstream final receipt under concurrent completion', async () => {
it('creates and sends only one downstream receipt for the same fragment dedupe key', async () => {
const { service, prisma } = createService();
let claimedDelivery: Record<string, unknown> | null = null;
prisma.cmppDownstreamDelivery.create.mockImplementation(async ({ data }) => {
@@ -2690,6 +2720,18 @@ describe('SendChainService', () => {
billingUnits: 2,
amountCents: 6,
unitPrice: 3,
cmppSubmitSequenceId: '601',
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-FAIL',
cmppRegisteredDelivery: true,
});
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
id: 'long-group-receipt-fail',
messageId: 'MSG-LONG-GROUP-FAIL',
segmentTotal: 2,
segments: [
{ segmentIndex: 1, sequenceId: '601', registeredDelivery: true },
{ segmentIndex: 2, sequenceId: '602', registeredDelivery: true },
],
});
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
id: 'segment-2',
@@ -2754,12 +2796,9 @@ describe('SendChainService', () => {
}),
});
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
messageRecordId: 'record-long',
deliveryType: 'receipt',
status: 'pending',
}),
data: expect.objectContaining({ messageRecordId: 'record-long', deliveryType: 'receipt', status: 'pending' }),
});
});
@@ -3801,8 +3840,8 @@ describe('SendChainService', () => {
it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => {
const { service, prisma, billing } = createService();
prisma.smsMessageRecord.findMany.mockResolvedValue([
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-1', amountCents: 3, billingUnits: 1 },
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-2', amountCents: 3, billingUnits: 1 },
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3, billingUnits: 1, status: 'submitted', cmppSubmitSequenceId: '701', cmppRegisteredDelivery: true, timeoutAt: null },
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-2', phoneNumber: '13900000002', amountCents: 3, billingUnits: 1, status: 'unknown', cmppSubmitSequenceId: '702', cmppRegisteredDelivery: true, timeoutAt: null },
]);
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' })
@@ -3812,10 +3851,12 @@ describe('SendChainService', () => {
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
where: {
tenantId: { not: null },
status: { in: ['submitted', 'unknown'] },
submittedAt: { lte: expect.any(Date) },
OR: [
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: expect.any(Date) } },
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
],
},
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
select: expect.objectContaining({ id: true, applicationId: true, cmppSubmitSequenceId: true, timeoutAt: true }),
take: 10000,
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
@@ -3823,9 +3864,95 @@ describe('SendChainService', () => {
data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }),
});
expect(billing.refund).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
dedupeKey: 'receipt:record-1',
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 701 }),
}),
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-1', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
});
it('queues an explicit HTTP failure webhook when a receipt times out', async () => {
const prisma = createPrismaMock();
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ id: 'http-timeout-delivery' }) };
const { service } = createService(prisma, openApi);
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-http-timeout',
tenantId: 'tenant-1',
batchTaskId: null,
applicationId: 'app-1',
messageId: 'MSG-HTTP-TIMEOUT',
phoneNumber: '13800000001',
amountCents: 0,
billingUnits: 1,
status: 'submitted',
cmppSubmitSequenceId: null,
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: null,
timeoutAt: null,
}]);
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 1 });
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({
applicationId: 'app-1',
messageRecordId: 'record-http-timeout',
eventType: 'receipt',
payload: expect.objectContaining({
receiptStatus: 'undelivered',
rawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
}),
}));
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-http-timeout', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
});
it('recovers timeout refund and downstream queueing when the prior scan stopped before setting the outbox marker', async () => {
const { service, prisma, billing } = createService();
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'billing-timeout-recovery', billingStatus: 'charged' });
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-timeout-recovery',
tenantId: 'tenant-1',
batchTaskId: null,
applicationId: 'app-1',
messageId: 'MSG-TIMEOUT-RECOVERY',
phoneNumber: '13800000001',
amountCents: 3,
billingUnits: 1,
status: 'timeout',
cmppSubmitSequenceId: '703',
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: true,
timeoutAt: new Date('2026-08-01T00:00:00.000Z'),
}]);
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 0 });
expect(billing.refund).toHaveBeenCalledTimes(1);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
dedupeKey: 'receipt:record-timeout-recovery',
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 703 }),
}),
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-timeout-recovery', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
});
it('terminates downstream deliveries that remain pending for 72 hours after the latest manual retry', async () => {
const { service, prisma } = createService();
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-expired' }]);
+2 -8
View File
@@ -16,6 +16,7 @@ import { OpenApiService } from '../open-api/open-api.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, 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_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers';
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
import { SendSubmissionService } from './send-submission.service';
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
@@ -500,14 +501,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
}
private async queueAndTryDownstreamDelivery(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
private async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
return this.completion.queueAndTryDownstreamDelivery(data);
}
@@ -11,6 +11,7 @@ import { SendGatewayResultService } from './send-gateway-result.service';
import { SendReceiptService } from './send-receipt.service';
import { SendRetryService } from './send-retry.service';
import { SendTimeoutService } from './send-timeout.service';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
export type SendCompletionCallbacks = Record<string, never>;
@@ -263,14 +264,7 @@ export class SendCompletionService {
return this.downstreamDelivery.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
}
async queueAndTryDownstreamDelivery(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
return this.downstreamDelivery.queueAndTryDownstreamDelivery(data);
}
@@ -298,6 +292,7 @@ export class SendCompletionService {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
@@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece
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 { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
/**
@@ -188,14 +189,7 @@ export class SendDownstreamDeliveryService {
});
}
async queueAndTryDownstreamDelivery(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
if (!data.applicationId) {
return null;
}
@@ -211,7 +205,7 @@ export class SendDownstreamDeliveryService {
},
});
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
if (deliveryAllowed) {
if (deliveryAllowed && data.queueHttpWebhook !== false) {
try {
await this.openApi?.queueWebhookEvent({
tenantId: data.tenantId,
@@ -224,14 +218,18 @@ export class SendDownstreamDeliveryService {
});
} catch (error) {
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
if (data.propagateHttpQueueError) throw error;
}
}
if (data.queueCmppDelivery === false) {
return null;
}
if (application?.interfaceEnabled !== true) {
return null;
}
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId
? `receipt:${data.messageRecordId}`
? data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
? `uplink:${data.payload.uplinkMessageId}`
: null;
@@ -419,6 +417,7 @@ export class SendDownstreamDeliveryService {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
@@ -450,13 +449,12 @@ export class SendDownstreamDeliveryService {
deliveredAt,
},
});
await this.facade.queueAndTryDownstreamDelivery({
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
deliveryType: 'receipt',
payload: {
await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message,
payload: {
messageId: message.messageId,
gatewayMessageId: `PLATFORM:${message.messageId}`,
phoneNumber: message.phoneNumber,
@@ -464,11 +462,10 @@ export class SendDownstreamDeliveryService {
rawStatus: 'REJECTD',
errorCode,
errorMessage: reason,
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
deliveredAt: deliveredAt.toISOString(),
},
},
});
);
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
return receipt;
}
@@ -12,8 +12,9 @@ import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
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 { 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, 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';
import { detectDrainageContent } from './drainage-content-detection';
/**
* R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam.
@@ -48,6 +49,7 @@ export class SendInboundEntryService {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
@@ -85,6 +87,9 @@ async authenticateInboundApplication(data: GatewayInboundAuthDto) {
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
if (data.registeredDelivery != null && ![0, 1].includes(data.registeredDelivery)) {
throw new BadRequestException('CMPP Registered_Delivery must be 0 or 1');
}
const phoneNumbers = data.phoneNumbers?.length
? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim())
: data.phoneNumber
@@ -136,6 +141,7 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
...data,
content: collection.content,
sequenceId: collection.sequenceId,
registeredDelivery: collection.registeredDelivery ? 1 : 0,
longMessage: undefined,
}, phoneNumbers, application, collection.messageId);
await this.prisma.cmppInboundLongMessage.update({
@@ -350,6 +356,7 @@ async collectInboundLongMessageFragment(
response: recent.response as any,
content: recent.segments.map((item) => item.content).join(''),
sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId),
registeredDelivery: recent.segments[0]?.registeredDelivery ?? true,
};
}
@@ -392,6 +399,7 @@ async collectInboundLongMessageFragment(
response: null,
content: group.segments.map((item) => item.content).join(''),
sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId),
registeredDelivery: group.segments[0]?.registeredDelivery ?? true,
};
}
return {
@@ -402,12 +410,14 @@ async collectInboundLongMessageFragment(
response: group.response as any,
content: '',
sequenceId: undefined,
registeredDelivery: true,
};
}
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
if (existing && (existing.contentHash !== contentHash
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) {
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId))
|| existing.registeredDelivery !== (data.registeredDelivery !== 0))) {
throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`);
}
if (!existing) {
@@ -416,6 +426,7 @@ async collectInboundLongMessageFragment(
groupId: group.id,
segmentIndex: fragment.index,
sequenceId: data.sequenceId == null ? null : String(data.sequenceId),
registeredDelivery: data.registeredDelivery !== 0,
content: data.content,
contentHash,
},
@@ -441,6 +452,7 @@ async collectInboundLongMessageFragment(
response: null,
content: complete ? segments.map((item) => item.content).join('') : '',
sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId),
registeredDelivery: segments[0]?.registeredDelivery ?? true,
};
});
}
@@ -510,6 +522,7 @@ async submitInboundSingleMessage(
status: synchronousRejection ? 'rejected' : 'accepted',
},
});
const drainageDetection = await detectDrainageContent(this.prisma, data.content);
const message = await this.prisma.smsMessageRecord.create({
data: {
tenantId: application.tenantId,
@@ -519,12 +532,14 @@ async submitInboundSingleMessage(
messageId,
phoneNumber: data.phoneNumber,
content: data.content,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: receiptRejection ? 0 : billing.unitPrice,
amountCents: receiptRejection ? 0 : billing.amountCents,
queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
cmppSubmitGroupMessageId: submitGroupMessageId,
cmppRegisteredDelivery: data.registeredDelivery !== 0,
clientSrcId,
applicationExtension: application.cmppApplicationExtension,
status: synchronousRejection ? 'rejected' : 'validating',
@@ -555,15 +570,6 @@ async submitInboundSingleMessage(
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content);
const drainageInfoId = drainage?.id;
const drainageReason = drainageRejectionReason(drainage);
if (drainageReason) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { drainageInfoId, signatureId: options.signatureId },
});
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
return;
}
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
tenantId: application.tenantId,
applicationId: application.id,
@@ -634,23 +640,6 @@ async submitInboundSingleMessage(
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
} else {
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content);
const drainageReason = drainageRejectionReason(drainage);
if (drainageReason) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { drainageInfoId: drainage?.id, signatureId: signature.id },
});
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
messageId,
messageRecordId: message.id,
taskId: task.id,
status: 'rejected',
};
}
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
tenantId: application.tenantId,
applicationId: application.id,
+30 -18
View File
@@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece
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 { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
/**
@@ -309,24 +310,32 @@ export class SendReceiptService {
},
});
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
await this.facade.queueAndTryDownstreamDelivery({
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
deliveryType: 'receipt',
payload: {
messageId: message.messageId,
gatewayMessageId: data.gatewayMessageId,
phoneNumber: message.phoneNumber,
receiptStatus: aggregate.receiptStatus,
rawStatus: aggregate.rawStatus,
errorCode: aggregate.errorCode,
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
deliveredAt: aggregate.deliveredAt.toISOString(),
await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message,
payload: {
messageId: message.messageId,
gatewayMessageId: data.gatewayMessageId,
phoneNumber: message.phoneNumber,
receiptStatus: aggregate.receiptStatus,
rawStatus: aggregate.rawStatus,
errorCode: aggregate.errorCode,
deliveredAt: aggregate.deliveredAt.toISOString(),
},
segmentPayloads: Object.fromEntries(
aggregate.segments
.filter((segment) => segment.receiptStatus)
.map((segment) => [segment.segmentIndex, {
receiptStatus: segment.receiptStatus,
rawStatus: segment.rawStatus,
errorCode: segment.errorCode,
deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(),
}]),
),
},
});
);
}
if (message.batchTaskId) {
await this.facade.refreshTaskProgress(message.batchTaskId);
@@ -427,7 +436,10 @@ export class SendReceiptService {
: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
orderBy: { segmentIndex: 'asc' },
});
return aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt);
return {
...aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt),
segments: audits,
};
}
async resolveReceiptMessage(
@@ -27,6 +27,7 @@ export type SendSubmissionCallbacks = {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
+63 -9
View File
@@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece
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 { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
/**
@@ -33,23 +34,76 @@ export class SendTimeoutService {
const candidates = await this.prisma.smsMessageRecord.findMany({
where: {
tenantId: { not: null },
status: { in: ['submitted', 'unknown'] },
submittedAt: { lte: cutoff },
OR: [
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: cutoff } },
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
],
},
select: {
id: true,
tenantId: true,
batchTaskId: true,
applicationId: true,
messageId: true,
phoneNumber: true,
amountCents: true,
billingUnits: true,
status: true,
cmppSubmitSequenceId: true,
cmppSubmitGroupMessageId: true,
cmppRegisteredDelivery: true,
timeoutAt: true,
},
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
take: 10000,
});
const timedOutTaskIds = new Set<string>();
let timeout = 0;
for (const candidate of candidates) {
if (!candidate.tenantId) continue;
const transitioned = await this.prisma.smsMessageRecord.updateMany({
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` },
});
if (transitioned.count !== 1) continue;
timeout += 1;
const timedOutAt = candidate.timeoutAt ?? new Date();
if (candidate.status !== 'timeout') {
const transitioned = await this.prisma.smsMessageRecord.updateMany({
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
data: {
status: 'timeout',
receiptStatus: 'undelivered',
receiptRawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`,
timeoutAt: timedOutAt,
},
});
if (transitioned.count !== 1) continue;
timeout += 1;
}
// Refund uses the platform-message idempotency key. Re-running it for a
// timeout whose downstream outbox was not fully queued also recovers a
// crash between the state transition and the original refund call.
await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
const queued = await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message: candidate,
payload: {
messageId: candidate.messageId,
gatewayMessageId: `PLATFORM_TIMEOUT:${candidate.messageId}`,
phoneNumber: candidate.phoneNumber,
receiptStatus: 'undelivered',
rawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`,
deliveredAt: timedOutAt.toISOString(),
},
propagateHttpQueueError: true,
},
);
if (queued.queued) {
await this.prisma.smsMessageRecord.updateMany({
where: { id: candidate.id, status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: new Date() },
});
}
if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId);
}
for (const batchTaskId of timedOutTaskIds) {