feat: enforce signature-scoped drainage authorization before SMS submission
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user