feat: enforce signature-scoped drainage authorization before SMS submission

This commit is contained in:
hectorzhao
2026-09-10 13:29:04 +08:00
parent 5bcdbb2a03
commit 0c3f820cc9
35 changed files with 2769 additions and 791 deletions
+11 -2
View File
@@ -196,6 +196,10 @@ export class ChannelReportingService {
message."signatureId" AS signature_id,
message.carrier AS carrier,
message."drainageInfoId" AS drainage_info_id,
CASE WHEN COALESCE(submit."drainageGate", message."drainageGate") IS NULL THEN NULL ELSE ARRAY(
SELECT DISTINCT material_id FROM jsonb_array_elements(COALESCE(COALESCE(submit."drainageGate", message."drainageGate")->'targets', '[]'::jsonb)) target
CROSS JOIN LATERAL jsonb_array_elements_text(target->'materialIds') AS ids(material_id)
) END AS drainage_ids,
submit."submitStatus" AS submit_status,
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
CASE
@@ -239,6 +243,7 @@ export class ChannelReportingService {
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND NOT (COALESCE(submit."errorCode", '') LIKE 'DRN%' AND submit."firstWireSubmitAt" IS NULL)
AND submit."channelId" IN (${Prisma.join(channelIds)})
AND message."signatureId" IN (${Prisma.join(signatureIds)})
)
@@ -247,6 +252,7 @@ export class ChannelReportingService {
signature_id AS "signatureId",
carrier,
drainage_info_id AS "drainageInfoId",
drainage_ids AS "drainageIds",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
)::integer AS total,
@@ -272,7 +278,7 @@ export class ChannelReportingService {
)::integer AS "failureCount",
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
FROM base
GROUP BY channel_id, signature_id, drainage_info_id, carrier
GROUP BY channel_id, signature_id, drainage_info_id, carrier, drainage_ids
`);
return tasks.map((task) => {
@@ -281,7 +287,10 @@ export class ChannelReportingService {
row.channelId === task.channelId &&
row.signatureId === task.signatureId &&
(!task.carrier || row.carrier === task.carrier) &&
((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId),
((task.reportType ?? 'signature') === 'signature' ||
(row.drainageIds
? row.drainageIds.includes(task.drainageItemId ?? '')
: row.drainageInfoId === task.drainageItemId)),
);
const deliveryStats = summarizeChannelReportDelivery(taskRows);
return {
+1
View File
@@ -592,6 +592,7 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail
}
export type ChannelReportDeliveryRow = {
drainageIds?: string[] | null;
carrier: string | null;
channelId: string;
signatureId: string;
+16 -2
View File
@@ -1,8 +1,22 @@
export const DRAINAGE_TARGET_PATTERN = /^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i;
import { parse } from 'tldts';
import { isIP } from 'node:net';
export const DRAINAGE_TARGET_PATTERN =
/^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i;
export const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码';
export function normalizeDrainageTarget(value?: string) {
const target = value?.trim() ?? '';
return target && DRAINAGE_TARGET_PATTERN.test(target) ? target : undefined;
const normalized = target.normalize('NFKC');
if (!target || !DRAINAGE_TARGET_PATTERN.test(normalized)) return undefined;
if (/[a-z]/i.test(normalized) && !/ext\.?/i.test(normalized)) {
try {
const host = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`).hostname;
if (!isIP(host) && !parse(host, { allowPrivateDomains: true }).domain) return undefined;
} catch {
return undefined;
}
}
return target;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BillingService } from './billing/billing.service';
@@ -18,7 +19,7 @@ import { SendChainService } from './send-chain/send-chain.service';
MetricsModule,
ProtocolLogsModule,
],
controllers: [GatewayCallbackController],
controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [
BillingService,
RiskReviewService,
+153 -69
View File
@@ -1,17 +1,22 @@
import { Prisma } from '@prisma/client';
import { BadRequestException } from '@nestjs/common';
import { moneyToNumber } from '../common/money';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts';
import type {
MessageQuery,
DownstreamDeliveryDashboardQuery,
DownstreamRecoveryStatusQuery,
} from './operations.contracts';
// Pure query builders and response mappers shared by the R2 query domains.
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
const statusWhere = query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
const statusWhere =
query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
@@ -21,25 +26,39 @@ export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereI
phoneNumber: query.phoneNumber,
...carrierWhere(query.carrier),
...statusWhere,
...(query.hasDrainage === 'true' ? { hasDrainageContent: true }
: query.hasDrainage === 'false' ? { hasDrainageContent: false }
: query.hasDrainage === 'unknown' ? { hasDrainageContent: null }
...(query.hasDrainage === 'true'
? { hasDrainageContent: true }
: query.hasDrainage === 'false'
? { hasDrainageContent: false }
: query.hasDrainage === 'unknown'
? { hasDrainageContent: null }
: {}),
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
...(query.queuedAtFrom || query.queuedAtTo ? {
queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
},
} : {}),
...(query.queuedAtFrom || query.queuedAtTo
? {
queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
},
}
: {}),
};
}
export const recognizedCarrierValues = [
'mobile', 'cmcc', '移动', '中国移动',
'unicom', 'cucc', '联通', '中国联通',
'telecom', 'ctcc', '电信', '中国电信',
'mobile',
'cmcc',
'移动',
'中国移动',
'unicom',
'cucc',
'联通',
'中国联通',
'telecom',
'ctcc',
'电信',
'中国电信',
];
export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
if (!carrier) return {};
@@ -48,10 +67,7 @@ export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInpu
return {
AND: [
{
OR: [
{ carrier: null },
{ carrier: { notIn: recognizedCarrierValues } },
],
OR: [{ carrier: null }, { carrier: { notIn: recognizedCarrierValues } }],
},
],
};
@@ -107,10 +123,7 @@ export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma
return {
tenantId,
createdAt: { gte: since },
OR: [
{ transactionType: 'refunded' },
{ transactionType: 'released', relatedType: 'sms_message_record' },
],
OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }],
};
}
export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
@@ -161,13 +174,12 @@ export function downstreamAlertWhere(
export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput {
return {
status: 'pending',
OR: [
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
{ lastRetriedAt: { lte: cutoff } },
],
OR: [{ lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }],
};
}
export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
export function downstreamDeliveryScopedWhere(
query: DownstreamDeliveryDashboardQuery,
): Prisma.CmppDownstreamDeliveryWhereInput {
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
return {
@@ -191,14 +203,16 @@ export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQue
state: query.state && query.state !== 'all' ? query.state : undefined,
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
OR: query.keyword ? [
{ account: { contains: query.keyword } },
{ gatewayInstanceId: { contains: query.keyword } },
{ lastError: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
OR: query.keyword
? [
{ account: { contains: query.keyword } },
{ gatewayInstanceId: { contains: query.keyword } },
{ lastError: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
]
: undefined,
};
}
export function escapeCsvCell(value: string) {
@@ -254,6 +268,21 @@ export function clientMessageView(message: Record<string, any>) {
carrier: message.carrier ?? null,
province: message.province ?? null,
content: message.content,
drainageGate: message.drainageGate
? {
version: message.drainageGate.version,
evaluatedAt: message.drainageGate.evaluatedAt,
reason: message.drainageGate.reason,
reasonCode: message.drainageGate.reasonCode,
targets: (message.drainageGate.targets ?? []).map(
(target: { text: string; category: string; value: string }) => ({
text: target.text,
category: target.category,
value: target.value,
}),
),
}
: null,
billingUnits: message.billingUnits,
amountCents: moneyToNumber(message.amountCents),
status: message.status,
@@ -338,7 +367,13 @@ export function clientRechargeView(order: Record<string, any>) {
completedAt: order.completedAt ?? null,
};
}
export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
export function summarizeMessageGroups(
groups: Array<{
status: string;
_count: { _all: number };
_sum: { amountCents: number | bigint | null; billingUnits: number | null };
}>,
) {
return groups.reduce(
(summary, group) => {
const count = group._count._all;
@@ -360,8 +395,29 @@ export function summarizeMessageGroups(groups: Array<{ status: string; _count: {
export function groupDownstreamByType(
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
) {
return groups.reduce<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((accumulator, item) => {
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 };
return groups.reduce<
Record<
string,
{
total: number;
pending: number;
awaitingAck: number;
delivered: number;
failed: number;
unconfirmed: number;
rejected: number;
}
>
>((accumulator, item) => {
const current = accumulator[item.deliveryType] ?? {
total: 0,
pending: 0,
awaitingAck: 0,
delivered: 0,
failed: 0,
unconfirmed: 0,
rejected: 0,
};
current.total += item._count._all;
if (item.status === 'pending') {
current.pending += item._count._all;
@@ -385,7 +441,20 @@ export function groupDownstreamByApplication(
applicationMap: Map<string, string>,
applicationAlertMap: Map<string, number>,
) {
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
const summaryMap = new Map<
string,
{
applicationId: string;
name: string;
pending: number;
awaitingAck: number;
failed: number;
unconfirmed: number;
rejected: number;
delivered: number;
alertCount: number;
}
>();
groups.forEach((item) => {
const current = summaryMap.get(item.applicationId) ?? {
applicationId: item.applicationId,
@@ -430,10 +499,7 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
],
};
const warning: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'warning' } },
{ action: { contains: 'risk' } },
],
OR: [{ action: { contains: 'warning' } }, { action: { contains: 'risk' } }],
};
const success: Prisma.OperationLogWhereInput = {
OR: [
@@ -459,13 +525,14 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? '');
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
? 'error'
: log.action.includes('warning') || log.action.includes('risk')
? 'warning'
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
? 'success'
: 'info';
const level =
result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
? 'error'
: log.action.includes('warning') || log.action.includes('risk')
? 'warning'
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
? 'success'
: 'info';
return {
id: log.id,
time: log.createdAt,
@@ -482,22 +549,32 @@ export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ inclu
}
export function sanitizeGatewaySubmitException(
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
messageState?: {
status: string;
submitStatus: string | null;
receiptStatus: string | null;
phoneNumber: string;
content: string;
},
) {
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
return {
...record,
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
channel: channel ? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
} : null,
application: application
? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status }
: null,
channel: channel
? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
}
: null,
rawPayloadAvailable: Boolean(rawPayload),
commandPayload: redactGatewayCommandValue(commandPayload),
messageState: messageState ?? null,
@@ -512,8 +589,15 @@ export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prism
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
redacted[key] = [
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
'token', 'apikey', 'accesskey', 'secretkey',
'password',
'passwordcipher',
'secret',
'secrethash',
'authsource',
'token',
'apikey',
'accesskey',
'secretkey',
].includes(normalizedKey)
? '[REDACTED]'
: redactGatewayCommandValue(child as Prisma.JsonValue);
+1 -1
View File
@@ -539,7 +539,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
${applicationJoin}
LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId"
LEFT JOIN "SmsDrainageInfo" drainage ON drainage.id = message."drainageInfoId"
LEFT JOIN "SmsDrainageInfo" drainage ON ${dimensionType === 'drainage' ? Prisma.sql`(CASE WHEN message."drainageGate" IS NULL THEN drainage.id = message."drainageInfoId" ELSE EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(message."drainageGate"->'targets', '[]'::jsonb)) target WHERE target->'materialIds' ? drainage.id) END)` : Prisma.sql`drainage.id = message."drainageInfoId"`}
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
), thresholds AS (
@@ -0,0 +1,130 @@
import {
assessDrainage,
drainageHost,
drainageTargets,
materialMatches,
normalizeDrainagePhone,
type DrainageTarget,
type DrainageMaterial,
} from './drainage-authorization';
import { detectDrainageContentWithRules } from './drainage-content-detection';
const target = (value: string): DrainageTarget => ({
key: value,
category: 'url',
value: drainageHost(value)!,
text: value,
start: 0,
end: value.length,
});
const material = (id: string, url: string, channels: string[], auditStatus = 'approved'): DrainageMaterial => ({
id,
url,
auditStatus,
materialVersion: 1,
reportTasks: channels.map((channelId) => ({
id: `${id}-${channelId}`,
channelId,
status: 'approved',
carrier: 'mobile',
})),
});
describe('drainage authorization', () => {
test.each([
'lisglo.cn',
'sms.lisglo.cn',
'a.sms.lisglo.cn/path?x=1',
'https://SMS.LISGLO.CN:8443/other?a=2#fragment',
])('allows registered host and subdomains %s', (url) => {
expect(materialMatches(target(url), 'lisglo.cn')).toBe(true);
});
test.each(['lisglo.cn.evil.com', 'evillisglo.cn', 'evil.com/?next=lisglo.cn', 'https://lisglo.cn@evil.com/'])(
'rejects fake containing URL %s',
(url) => {
expect(materialMatches(target(url), 'lisglo.cn')).toBe(false);
},
);
test.each(['cn', 'com', 'com.cn', 'co.uk'])('does not authorize public suffix %s', (host) =>
expect(drainageHost(host)).toBeNull(),
);
it('does not broaden child registration to siblings or parent', () => {
expect(materialMatches(target('lisglo.cn'), 'sms.lisglo.cn')).toBe(false);
expect(materialMatches(target('other.lisglo.cn'), 'sms.lisglo.cn')).toBe(false);
expect(materialMatches(target('a.sms.lisglo.cn'), 'sms.lisglo.cn')).toBe(true);
});
it('preserves existing path-restricted authorization', () => {
expect(materialMatches(target('lisglo.cn/app/1'), 'https://lisglo.cn/app')).toBe(true);
expect(materialMatches(target('lisglo.cn/other'), 'https://lisglo.cn/app')).toBe(false);
expect(materialMatches(target('lisglo.cn/application'), 'https://lisglo.cn/app')).toBe(false);
});
it('normalizes phone separators without changing original text or dropping area code', () => {
const original = '021-77882277';
expect(normalizeDrainagePhone(original)).toBe('02177882277');
expect(original).toBe('021-77882277');
expect(materialMatches({ ...target('lisglo.cn'), category: 'landline', value: '02177882277' }, '77882277')).toBe(
false,
);
});
it('requires every target and intersects channels, unions alternatives for one target', () => {
const targets = [target('a.lisglo.cn'), target('example.com')];
const rows = [
material('a', 'lisglo.cn', ['1']),
material('a2', 'a.lisglo.cn', ['2']),
material('b', 'example.com', ['2', '3']),
];
expect(assessDrainage(targets, rows, 'mobile').allowedChannelIds).toEqual(['2']);
expect(assessDrainage(targets, rows.slice(0, 2), 'mobile').reasonCode).toBe('DRAINAGE_NOT_REGISTERED');
expect(assessDrainage(targets, rows, 'telecom').reasonCode).toBe('DRAINAGE_CHANNEL_NOT_APPROVED');
});
it('requires approval and never borrows frozen or rejected channel reports', () => {
expect(
assessDrainage([target('lisglo.cn')], [material('a', 'lisglo.cn', ['1'], 'pending')], 'mobile').reasonCode,
).toBe('DRAINAGE_NOT_APPROVED');
const row = material('a', 'lisglo.cn', ['1']);
row.reportTasks[0].status = 'waiting_review';
expect(assessDrainage([target('lisglo.cn')], [row], 'mobile').reasonCode).toBe('DRAINAGE_CHANNEL_NOT_APPROVED');
});
it('extends truncated detector tokens to complete hostile URL before checking', () => {
const rules = [
{
id: 'url',
code: 'URL',
name: 'url',
category: 'url',
priority: 1,
version: 1,
flags: 'giu',
pattern: '[a-z]+\\.[a-z]+',
},
];
for (const content of ['lisglo.cn.evil.com', 'https://lisglo.cn@evil.com/path?next=lisglo.cn']) {
const detected = detectDrainageContentWithRules(content, rules);
const targets = drainageTargets(content, (detected.drainageDetection as any).matches);
expect(targets.length).toBeGreaterThan(0);
expect(targets.every((item) => !materialMatches(item, 'lisglo.cn'))).toBe(true);
}
});
it('does not exclude a URL with userInfo as an email', () => {
const content = 'https://lisglo.cn@evil.com/path';
const rules = [
{
id: 'url',
code: 'URL',
name: 'url',
category: 'url',
priority: 1,
version: 1,
flags: 'giu',
pattern: 'https?://[^\\s]+',
},
];
const detected = detectDrainageContentWithRules(content, rules);
const targets = drainageTargets(
content,
(detected.drainageDetection as { matches: import('./drainage-content-detection').DrainageDetectionMatch[] })
.matches,
);
expect(targets).toHaveLength(1);
expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false);
});
});
@@ -0,0 +1,243 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { isIP } from 'node:net';
import { parse } from 'tldts';
import type { PrismaService } from '../prisma/prisma.service';
import { detectDrainageContent, normalizeContent, type DrainageDetectionMatch } from './drainage-content-detection';
export const DRAINAGE_POLICY_VERSION = 'domain-boundary-v1';
export class DrainageRejection extends BadRequestException {
constructor(
public readonly reasonCode: string,
reason: string,
) {
super({ code: reasonCode, message: reason });
}
}
export type DrainageTarget = { key: string; category: string; value: string; text: string; start: number; end: number };
export type DrainageMaterial = {
id: string;
url: string;
auditStatus: string;
materialVersion: number;
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>;
};
export type DrainageAssessment = {
version: string;
evaluatedAt: string;
targets: Array<DrainageTarget & { materialIds: string[] }>;
materials: DrainageMaterial[];
allowedChannelIds: string[] | null;
reasonCode: string | null;
reason: string | null;
};
export function drainageHost(raw: string) {
const value = raw
.normalize('NFKC')
.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '')
.replace(/[。。]/g, '.')
.trim();
try {
const parsed = new URL(/^[a-z]+:\/\//i.test(value) ? value : `https://${value}`);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
const host = parsed.hostname.toLowerCase().replace(/\.$/, '');
if (isIP(host)) return host;
const domain = parse(host, { allowPrivateDomains: true });
return domain.domain && domain.isIcann !== false ? host : domain.domain && domain.isPrivate ? host : null;
} catch {
return null;
}
}
export function normalizeDrainagePhone(value: string) {
return normalizeContent(value, 'landline').text.replace(/[()]/g, '');
}
export function materialMatches(target: DrainageTarget, raw: string) {
if (target.category !== 'url') return normalizeDrainagePhone(raw) === target.value;
const host = drainageHost(raw);
if (!host || !(target.value === host || (!isIP(host) && target.value.endsWith(`.${host}`)))) return false;
// Existing path-specific material does not silently authorize unrelated paths.
const normalizedRaw = normalizeContent(raw, 'url').text.trim();
const registered = new URL(/^[a-z]+:\/\//i.test(normalizedRaw) ? normalizedRaw : `https://${normalizedRaw}`);
if (registered.pathname !== '/' || registered.search) {
const candidate = new URL(/^[a-z]+:\/\//i.test(target.text) ? target.text : `https://${target.text}`);
return (
(candidate.pathname === registered.pathname ||
candidate.pathname.startsWith(`${registered.pathname.replace(/\/$/, '')}/`)) &&
(!registered.search || candidate.search === registered.search)
);
}
return true;
}
export function drainageTargets(content: string, matches: DrainageDetectionMatch[]) {
const urls: DrainageTarget[] = [];
const normalized = normalizeContent(content, 'url');
for (const match of matches.filter((item) => item.category === 'url')) {
let start = normalized.sourceStarts.findIndex((offset) => offset >= match.start);
let end = normalized.sourceEnds.findIndex((offset) => offset >= match.end) + 1;
if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效');
// Extend the entire URL token, including suffix labels, userInfo and query.
const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i;
while (start > 0 && token.test(normalized.text[start - 1])) start--;
while (end < normalized.text.length && token.test(normalized.text[end])) end++;
const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, '');
const value = drainageHost(text);
if (!value) throw new DrainageRejection('DRAINAGE_INVALID', '引流URL格式无效或不是可登记域名');
urls.push({
key: `url:${value}:${text}`,
category: 'url',
value,
text,
start: normalized.sourceStarts[start],
end: normalized.sourceEnds[end - 1],
});
}
const targets = [...urls];
for (const match of matches.filter((item) => item.category !== 'url')) {
if (urls.some((url) => match.start < url.end && match.end > url.start)) continue;
if (!['mobile', 'landline'].includes(match.category))
throw new ServiceUnavailableException('引流识别类型尚未支持发送校验');
const value = normalizeDrainagePhone(match.normalizedText);
targets.push({
key: `phone:${value}`,
category: match.category,
value,
text: match.text,
start: match.start,
end: match.end,
});
}
return [...new Map(targets.map((target) => [target.key, target])).values()];
}
export function assessDrainage(
targets: DrainageTarget[],
materials: DrainageMaterial[],
carrier?: string,
): DrainageAssessment {
let allowed: Set<string> | null = null;
const assessment: DrainageAssessment = {
version: DRAINAGE_POLICY_VERSION,
evaluatedAt: new Date().toISOString(),
targets: [],
materials: [],
allowedChannelIds: null,
reasonCode: null,
reason: null,
};
for (const target of targets) {
const matching = materials.filter((item) => item.auditStatus !== 'deleted' && materialMatches(target, item.url));
const approved = matching.filter((item) => item.auditStatus === 'approved');
assessment.targets.push({ ...target, materialIds: approved.map((item) => item.id) });
const code = !matching.length ? 'DRAINAGE_NOT_REGISTERED' : !approved.length ? 'DRAINAGE_NOT_APPROVED' : null;
if (code && !assessment.reasonCode) {
assessment.reasonCode = code;
assessment.reason = `引流信息“${target.text.slice(0, 160)}${!matching.length ? '未在当前签名下添加' : '尚未审核通过'}`;
}
const channels = new Set(
approved.flatMap((item) =>
item.reportTasks
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier))
.map((task) => task.channelId),
),
);
allowed =
allowed === null
? channels
: new Set<string>(Array.from(allowed as Set<string>).filter((id: string) => channels.has(id)));
}
const used = new Set(assessment.targets.flatMap((target) => target.materialIds));
assessment.materials = materials
.filter((item) => used.has(item.id))
.map(({ id, url, auditStatus, materialVersion, reportTasks }) => ({
id,
url,
auditStatus,
materialVersion,
reportTasks,
}));
assessment.allowedChannelIds = allowed === null ? null : [...allowed];
if (targets.length && allowed?.size === 0 && !assessment.reasonCode) {
assessment.reasonCode = 'DRAINAGE_CHANNEL_NOT_APPROVED';
assessment.reason = '当前签名下的全部引流信息没有共同报备通过的通道';
}
return assessment;
}
export async function evaluateMessageDrainage(
prisma: PrismaService,
message: {
id: string;
content: string;
tenantId?: string | null;
applicationId?: string | null;
signatureId?: string | null;
},
carrier?: string,
materials?: DrainageMaterial[],
fresh = false,
) {
let detected;
try {
detected = await detectDrainageContent(prisma, message.content, fresh);
} catch (error) {
throw new ServiceUnavailableException('引流检测暂不可用', { cause: error });
}
const detection = detected.drainageDetection as unknown as {
matches: DrainageDetectionMatch[];
truncated: boolean;
ruleCount: number;
};
if (detection.truncated || detection.ruleCount === 0)
throw new ServiceUnavailableException('引流检测不完整,暂不能发送');
let targets: DrainageTarget[] = [];
let invalid: DrainageRejection | undefined;
try {
targets = drainageTargets(message.content, detection.matches);
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
invalid = error;
}
const rows =
materials ??
(targets.length && message.signatureId
? await prisma.smsDrainageInfo.findMany({
where: {
signatureId: message.signatureId,
tenantId: message.tenantId ?? '',
applicationId: message.applicationId ?? '',
auditStatus: { not: 'deleted' },
},
include: {
reportTasks: {
where: { reportType: 'drainage', signatureId: message.signatureId, tenantId: message.tenantId ?? '' },
},
},
})
: []);
const assessment = assessDrainage(targets, rows, carrier);
if (invalid) {
assessment.reasonCode = invalid.reasonCode;
assessment.reason = invalid.message;
assessment.allowedChannelIds = [];
}
// Append-only evidence survives later approval changes and subsequent routing attempts.
await prisma.smsDrainageDecision.create({
data: { messageRecordId: message.id, snapshot: JSON.parse(JSON.stringify(assessment)) },
});
await prisma.smsMessageRecord.update({
where: { id: message.id },
data: {
...detected,
drainageGate: JSON.parse(JSON.stringify(assessment)),
drainageInfoId:
assessment.targets.length === 1 && assessment.targets[0].materialIds.length === 1
? assessment.targets[0].materialIds[0]
: null,
},
});
if (assessment.reasonCode) throw new DrainageRejection(assessment.reasonCode, assessment.reason!);
return assessment;
}
@@ -53,7 +53,8 @@ export function invalidateDrainageDetectionRuleCache() {
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 (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,且不能重复');
}
@@ -69,18 +70,20 @@ export function validateDrainageDetectionPattern(pattern: string, flags = 'giu')
}
}
function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent {
export 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')
let normalized = sourceChar
.normalize('NFKC')
.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '')
.replace(/[.。]/g, '.')
.replace(/[:﹕]/g, ':')
.replace(/[]/g, '/')
.replace(/[()]/g, (char) => char === '' ? '(' : ')')
.replace(/[()]/g, (char) => (char === '' ? '(' : ')'))
.replace(/[]/g, '+');
if (category === 'url') {
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link.
@@ -116,8 +119,11 @@ function sourceRange(normalized: NormalizedContent, start: number, end: number)
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;
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)) {
const tokenStart = normalized.text.lastIndexOf(' ', match.index) + 1;
if (/https?:\/\/\S*$/i.test(normalized.text.slice(tokenStart, match.index + match[0].length))) continue;
ranges.push({ start: match.index, end: match.index + match[0].length });
}
return ranges;
@@ -135,7 +141,9 @@ export function detectDrainageContentWithRules(
const matches: DrainageDetectionMatch[] = [];
const normalizedByCategory = new Map<string, NormalizedContent>();
const emailNormalized = normalizeContent(content, 'email');
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
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);
@@ -160,7 +168,12 @@ export function detectDrainageContentWithRules(
start: range.start,
end: range.end,
};
if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.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;
@@ -186,8 +199,8 @@ export function detectDrainageContentWithRules(
};
}
async function activeRules(prisma: PrismaService) {
if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
async function activeRules(prisma: PrismaService, fresh = false) {
if (!fresh && cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
const rules = await prisma.drainageDetectionRule.findMany({
where: { status: 'active' },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
@@ -206,6 +219,6 @@ async function activeRules(prisma: PrismaService) {
return rules;
}
export async function detectDrainageContent(prisma: PrismaService, content: string) {
return detectDrainageContentWithRules(content, await activeRules(prisma));
export async function detectDrainageContent(prisma: PrismaService, content: string, fresh = false) {
return detectDrainageContentWithRules(content, await activeRules(prisma, fresh));
}
@@ -0,0 +1,29 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { SendChainService } from './send-chain.service';
@Injectable()
export class DrainageReceiptRecoveryService implements OnModuleInit, OnModuleDestroy {
private timer?: ReturnType<typeof setInterval>;
private running = false;
private readonly logger = new Logger(DrainageReceiptRecoveryService.name);
constructor(private readonly sendChain: SendChainService) {}
onModuleInit() {
if (['api', 'callback', 'outbox'].includes(process.env.CMPP_PROCESS_ROLE ?? 'all')) return;
this.timer = setInterval(() => void this.scan(), 10000);
this.timer.unref?.();
}
async scan() {
if (this.running) return;
this.running = true;
try {
await this.sendChain.recoverDrainageFailureReceipts();
} catch (error) {
this.logger.error(`引流拦截回执恢复失败: ${String(error)}`);
} finally {
this.running = false;
}
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
}
@@ -0,0 +1,141 @@
import { Body, Controller, ForbiddenException, Post, Req, BadRequestException } from '@nestjs/common';
type Request = { socket: { remoteAddress?: string }; headers?: Record<string, unknown> };
import { createHash } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
@Controller('gateway/events')
export class DrainageSubmitGuardController {
constructor(private readonly prisma: PrismaService) {}
@Post('authorize-drainage')
async authorize(
@Req() request: Request,
@Body() body: { submitId?: string; channelId?: string; contentHash?: string },
) {
// This is a local Gateway capability, never a customer-supplied authorization.
if (
!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress ?? '') ||
request.headers?.['x-forwarded-for'] ||
request.headers?.forwarded
)
throw new ForbiddenException();
if (
typeof body.submitId !== 'string' ||
!body.submitId ||
body.submitId.length > 160 ||
typeof body.channelId !== 'string' ||
!body.channelId ||
body.channelId.length > 160 ||
typeof body.contentHash !== 'string' ||
!/^[a-f0-9]{64}$/.test(body.contentHash)
)
throw new BadRequestException('提交校验参数无效');
return this.prisma.$transaction(
async (tx) => {
const submit = await tx.smsSubmitRecord.findUnique({
where: { submitId: body.submitId },
include: { messageRecord: true },
});
if (
!submit ||
submit.channelId !== body.channelId ||
createHash('sha256').update(submit.messageRecord.content).digest('hex') !== body.contentHash
)
return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' };
let message = submit.messageRecord;
if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) {
const template = await tx.smsTemplate.findFirst({
where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId },
select: { signatureId: true },
});
if (template?.signatureId) message = { ...message, signatureId: template.signatureId };
}
if (
submit.resultProcessedAt ||
['failed', 'delivered', 'unknown', 'cancelled', 'rejected'].includes(message.status)
)
return { allowed: false, code: 'DRN', reason: '提交或消息已终结,不得重复发送' };
if (!message.tenantId && !message.batchTaskId) {
try {
await evaluateMessageDrainage(
tx as unknown as PrismaService,
message,
message.carrier ?? undefined,
undefined,
true,
);
return { allowed: true };
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
return { allowed: false, code: 'DRN', reason: error.message };
}
}
if (!message.tenantId || !message.applicationId || !message.signatureId)
return { allowed: false, code: 'DRN', reason: '提交消息未关联企业应用和签名' };
// Shared with approval/material writers through database triggers. Permission
// is linearized at this transaction; already granted wire operations are in-flight.
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(hashtextextended(${message.signatureId}, 910))`;
await tx.$queryRaw`SELECT id FROM "SmsSignature" WHERE id = ${message.signatureId} FOR SHARE`;
await tx.$executeRaw`LOCK TABLE "DrainageDetectionRule" IN SHARE MODE`;
const signature = await tx.smsSignature.findFirst({
where: {
id: message.signatureId,
tenantId: message.tenantId,
applicationId: message.applicationId,
auditStatus: 'approved',
},
});
if (!signature) return { allowed: false, code: 'DRN', reason: '签名资格已失效' };
try {
const assessment = await evaluateMessageDrainage(
tx as unknown as PrismaService,
message,
message.carrier ?? undefined,
undefined,
true,
);
const signatureReport = await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: signature.id,
tenantId: message.tenantId,
channelId: submit.channelId,
reportType: 'signature',
status: 'approved',
OR: [
{ carrier: message.carrier },
...(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true'
? [{ approvalScope: 'legacy_channel' }]
: []),
],
},
});
if (
!signatureReport ||
(assessment.allowedChannelIds !== null && !assessment.allowedChannelIds.includes(submit.channelId))
)
return { allowed: false, code: 'DRN', reason: '最终通道的签名或引流报备资格已失效' };
await tx.smsSubmitRecord.updateMany({
where: { id: submit.id, drainageGate: { equals: Prisma.DbNull } },
data: {
drainageGate: JSON.parse(
JSON.stringify({
...assessment,
channelId: submit.channelId,
carrier: message.carrier,
submitId: submit.submitId,
}),
),
},
});
return { allowed: true };
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
return { allowed: false, code: 'DRN', reason: error.message };
}
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 10000 },
);
}
}
+18 -3
View File
@@ -1,3 +1,4 @@
import { DrainageSubmitGuardController } from './drainage-submit-guard.controller';
import { forwardRef, Module } from '@nestjs/common';
import { BillingModule } from '../billing/billing.module';
import { DictionariesModule } from '../dictionaries/dictionaries.module';
@@ -9,12 +10,26 @@ import { AdminSendChainController } from './admin-send-chain.controller';
import { ClientSendChainController } from './client-send-chain.controller';
import { GatewayEventsController } from './gateway-events.controller';
import { SendChainService } from './send-chain.service';
import { DrainageReceiptRecoveryService } from './drainage-receipt-recovery.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule), SecurityDetectionModule],
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
providers: [SendChainService],
imports: [
PrismaModule,
BillingModule,
DictionariesModule,
forwardRef(() => RiskReviewModule),
SmsConfigModule,
forwardRef(() => OpenApiModule),
SecurityDetectionModule,
],
controllers: [
DrainageSubmitGuardController,
AdminSendChainController,
ClientSendChainController,
GatewayEventsController,
],
providers: [SendChainService, DrainageReceiptRecoveryService],
exports: [SendChainService],
})
export class SendChainModule {}
+91 -2
View File
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { BillingService } from '../billing/billing.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { SendChainService } from './send-chain.service';
import { DrainageRejection } from './drainage-authorization';
function createPrismaMock() {
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
@@ -134,7 +135,18 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([
{
id: 'url',
code: 'URL',
name: 'URL',
category: 'url',
pattern: '[a-z]+\\.[a-z]+',
flags: 'giu',
priority: 1,
version: 1,
},
]),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
@@ -158,6 +170,7 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]),
count: jest.fn().mockResolvedValue(1),
findUnique: jest.fn().mockResolvedValue(message),
findUniqueOrThrow: jest.fn().mockResolvedValue(message),
findFirst: jest.fn().mockResolvedValue(message),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
@@ -233,6 +246,7 @@ function createPrismaMock() {
Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId }))),
),
},
smsDrainageDecision: { create: jest.fn().mockResolvedValue({ id: 'decision-1' }) },
smsReceiptRecord: {
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }),
@@ -484,6 +498,79 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven
}
describe('SendChainService', () => {
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
service['queueAndTryDownstreamDelivery'] = jest.fn().mockResolvedValue({ id: 'delivery' });
service['refreshTaskProgress'] = jest.fn().mockResolvedValue({});
await service['recordCmppFailureReceipt'](
{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppSubmitSequenceId: '101',
},
'DRN',
'未报备',
);
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
expect(service['queueAndTryDownstreamDelivery']).toHaveBeenCalledWith(
expect.objectContaining({ queueCmppDelivery: true, receiptDedupeKey: 'receipt:record-1' }),
);
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { drainageReceiptPending: false },
});
});
it('keeps durable recovery pending when drainage receipt intent persistence fails', async () => {
const { service, prisma } = createService();
service['queueAndTryDownstreamDelivery'] = jest.fn().mockRejectedValue(new Error('queue persistence unavailable'));
await expect(
service['recordCmppFailureReceipt'](
{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppSubmitSequenceId: '101',
},
'DRN',
'未报备',
),
).rejects.toThrow('queue persistence unavailable');
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: { drainageReceiptPending: false } }),
);
});
it('does not push any drainage rejection receipt for a non-CMPP submission', async () => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({
...message,
batchTask: { id: 'task-1', sourceType: 'client' },
});
service['selectChannelForMessage'] = jest
.fn()
.mockRejectedValue(new DrainageRejection('DRAINAGE_NOT_REGISTERED', '未报备'));
service['recordCmppFailureReceipt'] = jest.fn();
service['releaseMessageReservation'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.processSendJob({ messageRecordId: 'record-1' });
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'failed',
errorCode: 'DRAINAGE_NOT_REGISTERED',
drainageReceiptPending: false,
}),
}),
);
});
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
const { service, prisma, riskReview, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
@@ -3051,7 +3138,9 @@ describe('SendChainService', () => {
expect(service['identifyCarrier']).not.toHaveBeenCalled();
expect(service['identifyProvince']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carrier: expect.any(String) }) }),
);
});
it('updates submit result status, charges billing, and task progress', async () => {
+228 -71
View File
@@ -1,22 +1,63 @@
import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
Optional,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { randomUUID } from 'node:crypto';
import { createHash } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import { MetricsService } from '../metrics/metrics.service';
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 {
CreateBatchTaskDto,
CreateHttpBatchTaskDto,
GatewayInboundAuthDto,
GatewayInboundSubmitDto,
GatewaySubmitResultDto,
GatewaySubmitSegmentResultDto,
GatewayReceiptEventDto,
GatewayUplinkEventDto,
UplinkMatchCandidateInput,
GatewayPendingDeliveryQueryDto,
GatewayDownstreamSentDto,
GatewayDownstreamAcknowledgedDto,
GatewayDownstreamFailureType,
GatewaySubmitDeadLetterDto,
RequeueGatewaySubmitExceptionDto,
GatewayDownstreamRecoveryStatusDto,
TimeoutUnknownDto,
ImportPreviewDto,
ConfirmImportDto,
SendJob,
QueuePriority,
RoutedChannel,
} from './send-chain.contracts';
import {
DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS,
RECEIPT_TIMEOUT_INITIAL_DELAY_MS,
DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS,
SCHEDULED_DISPATCH_INITIAL_DELAY_MS,
DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS,
UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS,
downstreamPendingTimeoutHours,
positiveInteger,
} from './send-chain.helpers';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service';
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
@@ -68,12 +109,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
},
metrics,
);
this.completion = new SendCompletionService(
prisma,
billing,
openApi,
this as unknown as SendCompletionFacade,
);
this.completion = new SendCompletionService(prisma, billing, openApi, this as unknown as SendCompletionFacade);
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
}
@@ -91,7 +127,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.submission.startInboundWorkflowWorker();
}
if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') {
this.receiptTimeoutInitialTimer = setTimeout(() => void this.runReceiptTimeoutScan(), RECEIPT_TIMEOUT_INITIAL_DELAY_MS);
this.receiptTimeoutInitialTimer = setTimeout(
() => void this.runReceiptTimeoutScan(),
RECEIPT_TIMEOUT_INITIAL_DELAY_MS,
);
this.receiptTimeoutInitialTimer.unref?.();
this.receiptTimeoutIntervalTimer = setInterval(
() => void this.runReceiptTimeoutScan(),
@@ -107,22 +146,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.scheduledDispatchInitialTimer.unref?.();
this.scheduledDispatchIntervalTimer = setInterval(
() => void this.runScheduledDispatchScan(),
positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS),
positiveInteger(
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS,
DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS,
),
);
this.scheduledDispatchIntervalTimer.unref?.();
}
if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') {
this.inboundLongMessageInitialTimer = setTimeout(
() => void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
() =>
void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
);
this.inboundLongMessageInitialTimer.unref?.();
this.inboundLongMessageIntervalTimer = setInterval(
() => void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
() =>
void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
@@ -147,7 +191,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') {
this.downstreamRequeueTaskIntervalTimer = setInterval(
() => void this.downstreamRequeueTasks.runScan().catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)),
() =>
void this.downstreamRequeueTasks
.runScan()
.catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)),
positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000),
);
this.downstreamRequeueTaskIntervalTimer.unref?.();
@@ -191,12 +238,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
orderBy: { createdAt: 'desc' },
});
const taskIds = tasks.map((task) => task.id);
const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
}) : [];
const messageStats =
taskIds.length > 0
? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
})
: [];
return tasks.map((task) => ({
...task,
messageStats: messageStats.filter((item) => item.batchTaskId === task.id),
@@ -232,11 +282,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
sourceType: query.sourceType ?? 'client',
taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined,
application: query.applicationKeyword?.trim() ? { name: { contains: query.applicationKeyword.trim() } } : undefined,
createdAt: query.createdAtFrom || query.createdAtTo ? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined,
application: query.applicationKeyword?.trim()
? { name: { contains: query.applicationKeyword.trim() } }
: undefined,
createdAt:
query.createdAtFrom || query.createdAtTo
? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
}
: undefined,
};
const [tasks, total] = await Promise.all([
this.prisma.smsBatchTask.findMany({
@@ -254,12 +309,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.prisma.smsBatchTask.count({ where }),
]);
const taskIds = tasks.map((task) => task.id);
const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
}) : [];
const messageStats =
taskIds.length > 0
? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
})
: [];
return {
items: tasks.map((task) => ({
...task,
@@ -304,14 +362,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return { items, total, page: normalizedPage, pageSize: normalizedPageSize };
}
listMessages(query: {
tenantId?: string;
applicationId?: string;
channelId?: string;
taskId?: string;
phoneNumber?: string;
status?: string;
} = {}) {
listMessages(
query: {
tenantId?: string;
applicationId?: string;
channelId?: string;
taskId?: string;
phoneNumber?: string;
status?: string;
} = {},
) {
return this.prisma.smsMessageRecord.findMany({
where: {
tenantId: query.tenantId,
@@ -460,7 +520,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async handleReceipt(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.completion.handleReceipt(data, incomingIdentity);
}
@@ -530,7 +596,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.downstreamRequeueTasks.preview(filter, operatorId);
}
createDownstreamRequeueTask(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, operatorId?: string) {
createDownstreamRequeueTask(
data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
operatorId?: string,
) {
return this.downstreamRequeueTasks.create(data, operatorId);
}
@@ -542,7 +611,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.downstreamRequeueTasks.get(id);
}
listDownstreamRequeueTaskItems(id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }) {
listDownstreamRequeueTaskItems(
id: string,
query: { status?: string; keyword?: string; page?: number; pageSize?: number },
) {
return this.downstreamRequeueTasks.listItems(id, query);
}
@@ -592,7 +664,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
requestedMessageIds?: string[],
workflowKey?: string,
) {
return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey);
return this.submission.submitCompleteInboundMessage(
data,
phoneNumbers,
application,
requestedGroupMessageId,
requestedMessageIds,
workflowKey,
);
}
private async collectInboundLongMessageFragment(
@@ -616,22 +695,33 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
receiptRejection?: { code: string; reason: string },
workflowItemKey?: string,
) {
return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey);
return this.submission.submitInboundSingleMessage(
data,
messageId,
submitGroupMessageId,
application,
synchronousRejection,
receiptRejection,
workflowItemKey,
);
}
/**
* CMPP 单号码提交必须在生成最终入队决定前原子占用号码频次。
* 频次规则是直接拒绝,因此优先级高于其他规则产生的待人工审核结果。
*/
private async evaluateRiskWithPhoneFrequency(input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
}, reservationKey?: string) {
private async evaluateRiskWithPhoneFrequency(
input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
},
reservationKey?: string,
) {
return this.submission.evaluateRiskWithPhoneFrequency(input, reservationKey);
}
@@ -699,13 +789,29 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async selectChannelForMessage(
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
message: {
id: string;
tenantId: string;
applicationId?: string | null;
templateId?: string | null;
signatureId?: string | null;
phoneNumber: string;
carrier?: string | null;
province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
},
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> {
return this.submission.selectChannelForMessage(message, options);
}
private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
private async findApplicationRoute(
tenantId: string,
applicationId: string | undefined,
carrier: string,
signatureId?: string,
) {
return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
}
@@ -754,10 +860,40 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.resolveDrainageInfoMatch(signatureId, content);
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
private async attachMessageToReviewTask(
reviewTaskId: string,
messageRecordId: string,
signatureId: string,
drainageInfoId?: string,
) {
return this.submission.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
}
async recoverDrainageFailureReceipts() {
const messages = await this.prisma.smsMessageRecord.findMany({
where: {
drainageReceiptPending: true,
status: { in: ['failed', 'submit_failed'] },
batchTask: { sourceType: 'cmpp' },
},
orderBy: { updatedAt: 'asc' },
take: 50,
});
for (const message of messages) {
if (!message.tenantId || !message.batchTaskId) continue;
const reason = message.errorMessage ?? '引流发送资格校验未通过';
await this.releaseMessageReservation(
{ ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId },
reason,
);
await this.recordCmppFailureReceipt(
message,
message.errorCode?.startsWith('DRN') ? message.errorCode : 'DRN',
reason,
);
}
}
private async recordCmppFailureReceipt(
message: {
id: string;
@@ -779,7 +915,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
}
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
private async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
options?: SendResourceValidationOptions,
) {
return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
}
@@ -806,7 +947,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) {
return this.completion.releaseMessageReservation(message, remark);
@@ -832,7 +979,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier);
}
private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
private async resolveMessageSignatureId(message: {
templateId?: string | null;
signatureId?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
}) {
return this.submission.resolveMessageSignatureId(message);
}
@@ -902,7 +1054,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private async resolveReceiptMessage(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.completion.resolveReceiptMessage(data, incomingIdentity);
}
@@ -926,5 +1084,4 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
return this.submission.publishGatewaySubmitCommand(command, idempotencyKey);
}
}
@@ -1,17 +1,20 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.service';
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import type {
GatewayUplinkEventDto,
UplinkMatchCandidateInput,
GatewayControlDeliveryResult,
} from './send-chain.contracts';
import { downstreamControlFailureMessage } from './send-chain.helpers';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
import { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
/**
* R10 downstreamDelivery implementation.
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
@@ -28,10 +31,10 @@ export class SendDownstreamDeliveryService {
) {}
async handleUplink(data: GatewayUplinkEventDto) {
if (data.eventId) {
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing;
}
if (data.eventId) {
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing;
}
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
if (!channel) {
throw new NotFoundException('SMS channel not found');
@@ -39,7 +42,7 @@ export class SendDownstreamDeliveryService {
const match = await this.facade.resolveUplinkMatch(data, channel);
const record = await this.prisma.smsUplinkMessage.create({
data: {
eventId: data.eventId,
eventId: data.eventId,
tenantId: match.tenantId,
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
@@ -224,24 +227,28 @@ export class SendDownstreamDeliveryService {
payload: data.payload,
});
} catch (error) {
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(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?.cmppAccount || (
application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true
)) {
if (
!application?.cmppAccount ||
(application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true)
) {
return null;
}
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId
? data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
? `uplink:${data.payload.uplinkMessageId}`
: null;
const dedupeKey =
data.deliveryType === 'receipt' && data.messageRecordId
? (data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`)
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
? `uplink:${data.payload.uplinkMessageId}`
: null;
let delivery;
try {
delivery = await this.prisma.cmppDownstreamDelivery.create({
@@ -253,30 +260,30 @@ export class SendDownstreamDeliveryService {
dedupeKey,
deliveryType: data.deliveryType,
payload,
retryEnabled: cmppDeliveryAllowed && (data.deliveryType === 'uplink'
? application?.downstreamUplinkRetryEnabled ?? true
: application?.downstreamReceiptRetryEnabled ?? true),
retryEnabled:
cmppDeliveryAllowed &&
(data.deliveryType === 'uplink'
? (application?.downstreamUplinkRetryEnabled ?? true)
: (application?.downstreamReceiptRetryEnabled ?? true)),
status: cmppDeliveryAllowed ? 'pending' : 'abandoned',
lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
},
});
} catch (error) {
if (
dedupeKey
&& error instanceof Prisma.PrismaClientKnownRequestError
&& error.code === 'P2002'
) {
if (dedupeKey && error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const existing = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { dedupeKey },
});
if (existing) {
this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({
deliveryType: data.deliveryType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
dedupeKey,
deliveryId: existing.id,
})}`);
this.logger.warn(
`downstream_delivery_deduplicated ${JSON.stringify({
deliveryType: data.deliveryType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
dedupeKey,
deliveryId: existing.id,
})}`,
);
return existing;
}
}
@@ -300,10 +307,10 @@ export class SendDownstreamDeliveryService {
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: delivery.id } });
}
try {
const result = await this.facade.postGatewayControl(
const result = (await this.facade.postGatewayControl(
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
{ deliveryId: delivery.id, claimId, ...payload },
) as GatewayControlDeliveryResult;
)) as GatewayControlDeliveryResult;
if (result.sent || result.delivered) {
await this.facade.markDownstreamDeliverySent({ id: delivery.id, claimId, ...result });
} else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') {
@@ -322,7 +329,10 @@ export class SendDownstreamDeliveryService {
);
}
} catch (error) {
await this.facade.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed');
await this.facade.markDownstreamDeliveryFailed(
delivery.id,
error instanceof Error ? error.message : 'Gateway control delivery failed',
);
}
return delivery;
}
@@ -355,22 +365,25 @@ export class SendDownstreamDeliveryService {
const accessNumber = data.destId || channel.srcId || '';
const accessRoutes = accessNumber
? await this.prisma.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
take: 10,
})
: [];
const accessApplicationIds = [...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value)))];
const accessApplications = accessApplicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: accessApplicationIds }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
take: 10,
})
: [];
const accessApplicationIds = [
...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value))),
];
const accessApplications =
accessApplicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: accessApplicationIds }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
if (accessApplications.length === 1) {
return {
tenantId: accessApplications[0].tenantId,
@@ -421,15 +434,14 @@ export class SendDownstreamDeliveryService {
return {
matchStatus: 'ambiguous',
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
candidates: matchableRecentMessages
.map((message) => ({
tenantId: String(message.tenantId),
applicationId: String(message.applicationId),
messageRecordId: message.id,
matchSource: 'phone_window',
confidence: 55,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
})),
candidates: matchableRecentMessages.map((message) => ({
tenantId: String(message.tenantId),
applicationId: String(message.applicationId),
messageRecordId: message.id,
matchSource: 'phone_window',
confidence: 55,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
})),
};
}
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
@@ -454,36 +466,49 @@ export class SendDownstreamDeliveryService {
const existing = await this.prisma.smsReceiptRecord.findFirst({
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
});
if (existing) return existing;
if (existing && !errorCode.startsWith('DRN')) return existing;
const deliveredAt = new Date();
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt },
});
const gatewayMessageId = `PLATFORM:${message.messageId}`;
const receipt = await this.prisma.smsReceiptRecord.create({
data: {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'),
messageId: message.messageId,
gatewayMessageId,
phoneNumber: message.phoneNumber,
status: 'failed',
receiptStatus: 'undelivered',
rawStatus: 'REJECTD',
receiptRawStatus: 'REJECTD',
errorCode,
errorMessage: reason,
deliveredAt,
},
});
await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message,
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
payload: {
const gatewayMessageId = `PLATFORM:${message.messageId}`;
const receiptKey = createHash('sha256')
.update(
`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`,
)
.digest('hex');
const receiptData = {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
receiptKey,
messageId: message.messageId,
gatewayMessageId,
phoneNumber: message.phoneNumber,
receiptStatus: 'undelivered',
rawStatus: 'REJECTD',
errorCode,
errorMessage: reason,
deliveredAt,
};
const receipt =
existing ??
(errorCode.startsWith('DRN')
? await this.prisma.smsReceiptRecord.upsert({ where: { receiptKey }, update: {}, create: receiptData })
: await this.prisma.smsReceiptRecord.create({ data: receiptData }));
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
message,
propagateHttpQueueError: errorCode.startsWith('DRN'),
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
payload: {
messageId: message.messageId,
gatewayMessageId: `PLATFORM:${message.messageId}`,
phoneNumber: message.phoneNumber,
@@ -492,10 +517,11 @@ export class SendDownstreamDeliveryService {
errorCode,
errorMessage: reason,
deliveredAt: deliveredAt.toISOString(),
},
},
);
});
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
if (errorCode.startsWith('DRN'))
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { drainageReceiptPending: false } });
return receipt;
}
@@ -202,11 +202,13 @@ export class SendGatewayResultService {
message.batchTaskId
) {
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
const retried = await this.facade.retryMessageIfAllowed(
businessMessage,
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
submitRecord.id,
);
const retried = data.errorCode?.startsWith('DRN')
? null
: await this.facade.retryMessageIfAllowed(
businessMessage,
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
submitRecord.id,
);
if (retried) {
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
@@ -231,6 +233,8 @@ export class SendGatewayResultService {
errorMessage: data.errorMessage,
submittedAt,
timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined,
drainageReceiptPending:
data.errorCode?.startsWith('DRN') && batchTask?.sourceType === 'cmpp' ? true : undefined,
},
});
if (updated.count === 0 && data.submitStatus === 'accepted') {
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
@@ -390,6 +391,7 @@ export class SendGatewaySubmitService {
templateId?: string | null;
signatureId?: string | null;
phoneNumber: string;
content?: string;
carrier?: string | null;
province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
@@ -454,8 +456,14 @@ export class SendGatewaySubmitService {
const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`;
if (!routeByKey.has(key)) routeByKey.set(key, route);
}
const drainageMaterials = signatures.length
? await this.prisma.smsDrainageInfo.findMany({
where: { signatureId: { in: signatures }, auditStatus: { not: 'deleted' } },
include: { reportTasks: { where: { reportType: 'drainage' } } },
})
: [];
const planned: Array<{ message: T; routed: RoutedChannel }> = [];
const failed: Array<{ message: T; reason: string }> = [];
const failed: Array<{ message: T; reason: string; code?: string }> = [];
for (const input of routeInputs) {
if (!input.message.applicationId) {
failed.push({ message: input.message, reason: '短信应用未配置,无法选择通道组' });
@@ -465,6 +473,35 @@ export class SendGatewaySubmitService {
failed.push({ message: input.message, reason: '短信签名未配置,无法选择已报备通道' });
continue;
}
let gate;
try {
const stored =
input.message.content === undefined
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
: input.message;
gate = await evaluateMessageDrainage(
this.prisma,
{ ...input.message, content: stored.content!, signatureId: input.signatureId },
input.carrier,
drainageMaterials
.filter(
(item) =>
item.signatureId === input.signatureId &&
item.tenantId === input.message.tenantId &&
item.applicationId === input.message.applicationId,
)
.map((item) => ({
...item,
reportTasks: item.reportTasks.filter(
(task) => task.signatureId === input.signatureId && task.tenantId === input.message.tenantId,
),
})),
);
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
failed.push({ message: input.message, reason: error.message, code: error.reasonCode });
continue;
}
const route = routeByKey.get(`${input.message.tenantId}:${input.message.applicationId}:${input.carrier}`);
if (!route) {
failed.push({ message: input.message, reason: '企业应用未配置对应运营商通道组' });
@@ -476,6 +513,7 @@ export class SendGatewaySubmitService {
}
const approvedItems = route.group.items.filter(
(item) =>
(gate.allowedChannelIds === null || gate.allowedChannelIds.includes(item.channelId)) &&
item.channel.status === 'active' &&
item.channel.connectionStates.length > 0 &&
item.channel.reportTasks.some(
@@ -525,20 +563,25 @@ export class SendGatewaySubmitService {
cmppSubmitGroupMessageId?: string | null;
batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
},
>(failed: Array<{ message: T; reason: string }>, results: Map<string, unknown>) {
>(failed: Array<{ message: T; reason: string; code?: string }>, results: Map<string, unknown>) {
const values = Prisma.join(
failed.map(({ message, reason }) => Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text)`),
failed.map(
({ message, reason, code }) =>
Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text, ${code ?? null}::text, ${Boolean(code && message.batchTask?.sourceType === 'cmpp')}::boolean)`,
),
);
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message
SET status = 'failed', "errorMessage" = failures.reason, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${values}) AS failures(id, reason)
SET status = 'failed', "errorMessage" = failures.reason, "errorCode" = failures.code,
"drainageReceiptPending" = failures.pending, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${values}) AS failures(id, reason, code, pending)
WHERE message.id = failures.id AND message.status = 'queued'
`);
await Promise.all(
failed.map(async ({ message, reason }) => {
failed.map(async ({ message, reason, code }) => {
await this.releaseMessageReservation(message, reason);
if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, 'ROUTE', reason);
if (message.batchTask?.sourceType === 'cmpp')
await this.recordCmppFailureReceipt(message, code ? 'DRN' : 'ROUTE', reason);
else await this.facade.refreshTaskProgress(message.batchTaskId);
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
this.metrics?.recordSendWorkerResult('failed');
@@ -620,14 +663,22 @@ export class SendGatewaySubmitService {
finish(result.submitted ? 'completed' : 'skipped');
return result;
} catch (error) {
if (error instanceof Error && 'getStatus' in error && (error as { getStatus(): number }).getStatus() >= 500)
throw error;
const code = error instanceof DrainageRejection ? error.reasonCode : undefined;
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'failed', errorMessage: reason },
data: {
status: 'failed',
errorMessage: reason,
errorCode: code,
drainageReceiptPending: Boolean(code && message.batchTask?.sourceType === 'cmpp'),
},
});
await this.releaseMessageReservation(businessMessage, reason);
if (message.batchTask?.sourceType === 'cmpp') {
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason);
await this.recordCmppFailureReceipt(businessMessage, code ? 'DRN' : 'ROUTE', reason);
} else {
await this.facade.refreshTaskProgress(
businessMessage.batchTaskId,
@@ -1029,7 +1080,15 @@ return streamId`;
this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, carrier, signatureId),
);
const excluded = new Set(options.excludeChannelIds ?? []);
const approvedChannelIds = new Set(route.group.items.map((item) => item.channelId));
const stored = await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
const gate = await evaluateMessageDrainage(this.prisma, { ...stored, signatureId }, carrier);
const approvedChannelIds = new Set(
route.group.items
.map((item) => item.channelId)
.filter((id) => gate.allowedChannelIds === null || gate.allowedChannelIds.includes(id)),
);
if (gate.targets.length && approvedChannelIds.size === 0)
throw new DrainageRejection('DRAINAGE_CHANNEL_NOT_APPROVED', '引流信息未在签名可用通道报备通过');
const selected = selectChannelCandidate(route.group.items, {
carrier,
province,
File diff suppressed because it is too large Load Diff