feat: integrate analytics and fragment receipt improvements
This commit is contained in:
@@ -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'],
|
||||
['括号区号和分机', '致电(010)8888-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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user