feat: enforce signature-scoped drainage authorization before SMS submission

This commit is contained in:
hectorzhao
2026-09-10 13:29:04 +08:00
parent 5bcdbb2a03
commit 0c3f820cc9
35 changed files with 2769 additions and 791 deletions
+20 -1
View File
@@ -25,7 +25,8 @@
"minio": "^8.0.7", "minio": "^8.0.7",
"pg": "^8.22.0", "pg": "^8.22.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2",
"tldts": "^7.4.12"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
@@ -8589,6 +8590,24 @@
"readable-stream": "3" "readable-stream": "3"
} }
}, },
"node_modules/tldts": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
"integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.12"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
"integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
"license": "MIT"
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+2 -1
View File
@@ -34,7 +34,8 @@
"minio": "^8.0.7", "minio": "^8.0.7",
"pg": "^8.22.0", "pg": "^8.22.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2",
"tldts": "^7.4.12"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
@@ -0,0 +1,30 @@
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageGate" JSONB;
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "drainageGate" JSONB;
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageReceiptPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_drainage_receipt_pending" ON "SmsMessageRecord" ("updatedAt") WHERE "drainageReceiptPending" = true;
CREATE TABLE "SmsDrainageDecision" (
"id" TEXT PRIMARY KEY,
"messageRecordId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"snapshot" JSONB NOT NULL
);
CREATE INDEX "SmsDrainageDecision_messageRecordId_decidedAt_idx" ON "SmsDrainageDecision" ("messageRecordId", "decidedAt");
CREATE OR REPLACE FUNCTION drainage_authorization_lock() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD."signatureId" IS DISTINCT FROM NEW."signatureId" THEN
PERFORM pg_advisory_xact_lock(hashtextextended(value, 910))
FROM unnest(ARRAY[OLD."signatureId", NEW."signatureId"]) AS ids(value) ORDER BY value;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
PERFORM pg_advisory_xact_lock(hashtextextended(OLD."signatureId", 910));
RETURN OLD;
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(NEW."signatureId", 910));
RETURN NEW;
END $$;
CREATE TRIGGER drainage_material_authorization_lock BEFORE INSERT OR UPDATE OR DELETE ON "SmsDrainageInfo"
FOR EACH ROW EXECUTE FUNCTION drainage_authorization_lock();
CREATE TRIGGER drainage_report_authorization_lock BEFORE INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
FOR EACH ROW EXECUTE FUNCTION drainage_authorization_lock();
+11
View File
@@ -1790,6 +1790,14 @@ model SmsApiRequest {
@@index([batchTaskId]) @@index([batchTaskId])
} }
model SmsDrainageDecision {
id String @id @default(cuid())
messageRecordId String
decidedAt DateTime @default(now())
snapshot Json
@@index([messageRecordId, decidedAt])
}
model SmsMessageRecord { model SmsMessageRecord {
monitorFacts SendingMonitorFact[] monitorFacts SendingMonitorFact[]
id String @id @default(cuid()) id String @id @default(cuid())
@@ -1808,6 +1816,8 @@ model SmsMessageRecord {
content String content String
hasDrainageContent Boolean? hasDrainageContent Boolean?
drainageDetection Json? drainageDetection Json?
drainageGate Json?
drainageReceiptPending Boolean @default(false)
drainageDetectionVersion String? drainageDetectionVersion String?
drainageEvaluatedAt DateTime? drainageEvaluatedAt DateTime?
billingUnits Int @default(1) billingUnits Int @default(1)
@@ -1880,6 +1890,7 @@ model CmppSubmitSession {
} }
model SmsSubmitRecord { model SmsSubmitRecord {
drainageGate Json?
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String? tenantId String?
batchTaskId String? batchTaskId String?
+11 -2
View File
@@ -196,6 +196,10 @@ export class ChannelReportingService {
message."signatureId" AS signature_id, message."signatureId" AS signature_id,
message.carrier AS carrier, message.carrier AS carrier,
message."drainageInfoId" AS drainage_info_id, 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, submit."submitStatus" AS submit_status,
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at, COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
CASE CASE
@@ -239,6 +243,7 @@ export class ChannelReportingService {
AND receipt."receiptStatus" = 'undelivered' AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE ) failed_receipt ON TRUE
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout') 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 submit."channelId" IN (${Prisma.join(channelIds)})
AND message."signatureId" IN (${Prisma.join(signatureIds)}) AND message."signatureId" IN (${Prisma.join(signatureIds)})
) )
@@ -247,6 +252,7 @@ export class ChannelReportingService {
signature_id AS "signatureId", signature_id AS "signatureId",
carrier, carrier,
drainage_info_id AS "drainageInfoId", drainage_info_id AS "drainageInfoId",
drainage_ids AS "drainageIds",
COUNT(*) FILTER ( COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
)::integer AS total, )::integer AS total,
@@ -272,7 +278,7 @@ export class ChannelReportingService {
)::integer AS "failureCount", )::integer AS "failureCount",
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt" MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
FROM base 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) => { return tasks.map((task) => {
@@ -281,7 +287,10 @@ export class ChannelReportingService {
row.channelId === task.channelId && row.channelId === task.channelId &&
row.signatureId === task.signatureId && row.signatureId === task.signatureId &&
(!task.carrier || row.carrier === task.carrier) && (!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); const deliveryStats = summarizeChannelReportDelivery(taskRows);
return { return {
+1
View File
@@ -592,6 +592,7 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail
} }
export type ChannelReportDeliveryRow = { export type ChannelReportDeliveryRow = {
drainageIds?: string[] | null;
carrier: string | null; carrier: string | null;
channelId: string; channelId: string;
signatureId: string; signatureId: string;
+16 -2
View File
@@ -1,8 +1,22 @@
export const DRAINAGE_TARGET_PATTERN = /^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i; import { parse } from 'tldts';
import { isIP } from 'node:net';
export const DRAINAGE_TARGET_PATTERN =
/^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i;
export const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码'; export const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码';
export function normalizeDrainageTarget(value?: string) { export function normalizeDrainageTarget(value?: string) {
const target = value?.trim() ?? ''; const target = value?.trim() ?? '';
return target && DRAINAGE_TARGET_PATTERN.test(target) ? target : undefined; const normalized = target.normalize('NFKC');
if (!target || !DRAINAGE_TARGET_PATTERN.test(normalized)) return undefined;
if (/[a-z]/i.test(normalized) && !/ext\.?/i.test(normalized)) {
try {
const host = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`).hostname;
if (!isIP(host) && !parse(host, { allowPrivateDomains: true }).domain) return undefined;
} catch {
return undefined;
}
}
return target;
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { BillingService } from './billing/billing.service'; import { BillingService } from './billing/billing.service';
@@ -18,7 +19,7 @@ import { SendChainService } from './send-chain/send-chain.service';
MetricsModule, MetricsModule,
ProtocolLogsModule, ProtocolLogsModule,
], ],
controllers: [GatewayCallbackController], controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [ providers: [
BillingService, BillingService,
RiskReviewService, RiskReviewService,
+153 -69
View File
@@ -1,17 +1,22 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { moneyToNumber } from '../common/money'; 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. // Pure query builders and response mappers shared by the R2 query domains.
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
const statusWhere = query.status === 'submit_failed' const statusWhere =
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } query.status === 'submit_failed'
: query.status === 'failed' ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
? { status: 'failed', submitStatus: 'accepted' } : query.status === 'failed'
: query.status ? { status: 'failed', submitStatus: 'accepted' }
? { status: query.status } : query.status
: {}; ? { status: query.status }
: {};
return { return {
tenantId: query.tenantId, tenantId: query.tenantId,
applicationId: query.applicationId, applicationId: query.applicationId,
@@ -21,25 +26,39 @@ export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereI
phoneNumber: query.phoneNumber, phoneNumber: query.phoneNumber,
...carrierWhere(query.carrier), ...carrierWhere(query.carrier),
...statusWhere, ...statusWhere,
...(query.hasDrainage === 'true' ? { hasDrainageContent: true } ...(query.hasDrainage === 'true'
: query.hasDrainage === 'false' ? { hasDrainageContent: false } ? { hasDrainageContent: true }
: query.hasDrainage === 'unknown' ? { hasDrainageContent: null } : query.hasDrainage === 'false'
? { hasDrainageContent: false }
: query.hasDrainage === 'unknown'
? { hasDrainageContent: null }
: {}), : {}),
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
...(query.queuedAtFrom || query.queuedAtTo ? { ...(query.queuedAtFrom || query.queuedAtTo
queuedAt: { ? {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}), queuedAt: {
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}), ...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
}, ...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
} : {}), },
}
: {}),
}; };
} }
export const recognizedCarrierValues = [ export const recognizedCarrierValues = [
'mobile', 'cmcc', '移动', '中国移动', 'mobile',
'unicom', 'cucc', '联通', '中国联通', 'cmcc',
'telecom', 'ctcc', '电信', '中国电信', '移动',
'中国移动',
'unicom',
'cucc',
'联通',
'中国联通',
'telecom',
'ctcc',
'电信',
'中国电信',
]; ];
export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput { export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
if (!carrier) return {}; if (!carrier) return {};
@@ -48,10 +67,7 @@ export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInpu
return { return {
AND: [ AND: [
{ {
OR: [ OR: [{ carrier: null }, { carrier: { notIn: recognizedCarrierValues } }],
{ carrier: null },
{ carrier: { notIn: recognizedCarrierValues } },
],
}, },
], ],
}; };
@@ -107,10 +123,7 @@ export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma
return { return {
tenantId, tenantId,
createdAt: { gte: since }, createdAt: { gte: since },
OR: [ OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }],
{ transactionType: 'refunded' },
{ transactionType: 'released', relatedType: 'sms_message_record' },
],
}; };
} }
export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined { export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
@@ -161,13 +174,12 @@ export function downstreamAlertWhere(
export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput { export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput {
return { return {
status: 'pending', status: 'pending',
OR: [ OR: [{ lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }],
{ 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 createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
const createdAtTo = parseDateBoundary(query.createdAtTo, true); const createdAtTo = parseDateBoundary(query.createdAtTo, true);
return { return {
@@ -191,14 +203,16 @@ export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQue
state: query.state && query.state !== 'all' ? query.state : undefined, state: query.state && query.state !== 'all' ? query.state : undefined,
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined, failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined, updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
OR: query.keyword ? [ OR: query.keyword
{ account: { contains: query.keyword } }, ? [
{ gatewayInstanceId: { contains: query.keyword } }, { account: { contains: query.keyword } },
{ lastError: { contains: query.keyword } }, { gatewayInstanceId: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } }, { lastError: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } }, { lastSkipReason: { contains: query.keyword } },
{ application: { name: { contains: query.keyword } } }, { tenant: { name: { contains: query.keyword } } },
] : undefined, { application: { name: { contains: query.keyword } } },
]
: undefined,
}; };
} }
export function escapeCsvCell(value: string) { export function escapeCsvCell(value: string) {
@@ -254,6 +268,21 @@ export function clientMessageView(message: Record<string, any>) {
carrier: message.carrier ?? null, carrier: message.carrier ?? null,
province: message.province ?? null, province: message.province ?? null,
content: message.content, 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, billingUnits: message.billingUnits,
amountCents: moneyToNumber(message.amountCents), amountCents: moneyToNumber(message.amountCents),
status: message.status, status: message.status,
@@ -338,7 +367,13 @@ export function clientRechargeView(order: Record<string, any>) {
completedAt: order.completedAt ?? null, 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( return groups.reduce(
(summary, group) => { (summary, group) => {
const count = group._count._all; const count = group._count._all;
@@ -360,8 +395,29 @@ export function summarizeMessageGroups(groups: Array<{ status: string; _count: {
export function groupDownstreamByType( export function groupDownstreamByType(
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>, 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) => { return groups.reduce<
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 }; 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; current.total += item._count._all;
if (item.status === 'pending') { if (item.status === 'pending') {
current.pending += item._count._all; current.pending += item._count._all;
@@ -385,7 +441,20 @@ export function groupDownstreamByApplication(
applicationMap: Map<string, string>, applicationMap: Map<string, string>,
applicationAlertMap: Map<string, number>, 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) => { groups.forEach((item) => {
const current = summaryMap.get(item.applicationId) ?? { const current = summaryMap.get(item.applicationId) ?? {
applicationId: item.applicationId, applicationId: item.applicationId,
@@ -430,10 +499,7 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
], ],
}; };
const warning: Prisma.OperationLogWhereInput = { const warning: Prisma.OperationLogWhereInput = {
OR: [ OR: [{ action: { contains: 'warning' } }, { action: { contains: 'risk' } }],
{ action: { contains: 'warning' } },
{ action: { contains: 'risk' } },
],
}; };
const success: Prisma.OperationLogWhereInput = { const success: Prisma.OperationLogWhereInput = {
OR: [ OR: [
@@ -459,13 +525,14 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) { export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>; const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? ''); const result = String(detail.result ?? detail.status ?? '');
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject') const level =
? 'error' result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
: log.action.includes('warning') || log.action.includes('risk') ? 'error'
? 'warning' : log.action.includes('warning') || log.action.includes('risk')
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected') ? 'warning'
? 'success' : log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
: 'info'; ? 'success'
: 'info';
return { return {
id: log.id, id: log.id,
time: log.createdAt, time: log.createdAt,
@@ -482,22 +549,32 @@ export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ inclu
} }
export function sanitizeGatewaySubmitException( export function sanitizeGatewaySubmitException(
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>, 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; const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
return { return {
...record, ...record,
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null, 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, application: application
channel: channel ? { ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status }
id: channel.id, : null,
code: channel.code, channel: channel
name: channel.name, ? {
status: channel.status, id: channel.id,
carrier: channel.carrier, code: channel.code,
sendRegion: channel.sendRegion, name: channel.name,
rateLimitPerSecond: channel.rateLimitPerSecond, status: channel.status,
} : null, carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
}
: null,
rawPayloadAvailable: Boolean(rawPayload), rawPayloadAvailable: Boolean(rawPayload),
commandPayload: redactGatewayCommandValue(commandPayload), commandPayload: redactGatewayCommandValue(commandPayload),
messageState: messageState ?? null, messageState: messageState ?? null,
@@ -512,8 +589,15 @@ export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prism
for (const [key, child] of Object.entries(value)) { for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase(); const normalizedKey = key.toLowerCase();
redacted[key] = [ redacted[key] = [
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource', 'password',
'token', 'apikey', 'accesskey', 'secretkey', 'passwordcipher',
'secret',
'secrethash',
'authsource',
'token',
'apikey',
'accesskey',
'secretkey',
].includes(normalizedKey) ].includes(normalizedKey)
? '[REDACTED]' ? '[REDACTED]'
: redactGatewayCommandValue(child as Prisma.JsonValue); : redactGatewayCommandValue(child as Prisma.JsonValue);
+1 -1
View File
@@ -539,7 +539,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
JOIN "Tenant" tenant ON tenant.id = message."tenantId" JOIN "Tenant" tenant ON tenant.id = message."tenantId"
${applicationJoin} ${applicationJoin}
LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId" 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} WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt} AND message."queuedAt" < ${day.endAt}
), thresholds AS ( ), 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') { export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') {
if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空'); 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) { if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) {
throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复'); 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 = ''; let text = '';
const sourceStarts: number[] = []; const sourceStarts: number[] = [];
const sourceEnds: number[] = []; const sourceEnds: number[] = [];
let sourceIndex = 0; let sourceIndex = 0;
for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) { for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) {
const sourceEnd = sourceIndex + sourceChar.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, ':')
.replace(/[]/g, '/') .replace(/[]/g, '/')
.replace(/[()]/g, (char) => char === '' ? '(' : ')') .replace(/[()]/g, (char) => (char === '' ? '(' : ')'))
.replace(/[]/g, '+'); .replace(/[]/g, '+');
if (category === 'url') { if (category === 'url') {
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link. // 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) { function emailRanges(normalized: NormalizedContent) {
const ranges: Array<{ start: number; end: number }> = []; 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)) { 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 }); ranges.push({ start: match.index, end: match.index + match[0].length });
} }
return ranges; return ranges;
@@ -135,7 +141,9 @@ export function detectDrainageContentWithRules(
const matches: DrainageDetectionMatch[] = []; const matches: DrainageDetectionMatch[] = [];
const normalizedByCategory = new Map<string, NormalizedContent>(); const normalizedByCategory = new Map<string, NormalizedContent>();
const emailNormalized = normalizeContent(content, 'email'); 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))) { for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
validateDrainageDetectionPattern(rule.pattern, rule.flags); validateDrainageDetectionPattern(rule.pattern, rule.flags);
const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category); const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category);
@@ -160,7 +168,12 @@ export function detectDrainageContentWithRules(
start: range.start, start: range.start,
end: range.end, 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); matches.push(candidate);
} }
if (matches.length >= MAX_MATCHES) break; if (matches.length >= MAX_MATCHES) break;
@@ -186,8 +199,8 @@ export function detectDrainageContentWithRules(
}; };
} }
async function activeRules(prisma: PrismaService) { async function activeRules(prisma: PrismaService, fresh = false) {
if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules; if (!fresh && cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
const rules = await prisma.drainageDetectionRule.findMany({ const rules = await prisma.drainageDetectionRule.findMany({
where: { status: 'active' }, where: { status: 'active' },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
@@ -206,6 +219,6 @@ async function activeRules(prisma: PrismaService) {
return rules; return rules;
} }
export async function detectDrainageContent(prisma: PrismaService, content: string) { export async function detectDrainageContent(prisma: PrismaService, content: string, fresh = false) {
return detectDrainageContentWithRules(content, await activeRules(prisma)); return detectDrainageContentWithRules(content, await activeRules(prisma, fresh));
} }
@@ -0,0 +1,29 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { SendChainService } from './send-chain.service';
@Injectable()
export class DrainageReceiptRecoveryService implements OnModuleInit, OnModuleDestroy {
private timer?: ReturnType<typeof setInterval>;
private running = false;
private readonly logger = new Logger(DrainageReceiptRecoveryService.name);
constructor(private readonly sendChain: SendChainService) {}
onModuleInit() {
if (['api', 'callback', 'outbox'].includes(process.env.CMPP_PROCESS_ROLE ?? 'all')) return;
this.timer = setInterval(() => void this.scan(), 10000);
this.timer.unref?.();
}
async scan() {
if (this.running) return;
this.running = true;
try {
await this.sendChain.recoverDrainageFailureReceipts();
} catch (error) {
this.logger.error(`引流拦截回执恢复失败: ${String(error)}`);
} finally {
this.running = false;
}
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
}
@@ -0,0 +1,141 @@
import { Body, Controller, ForbiddenException, Post, Req, BadRequestException } from '@nestjs/common';
type Request = { socket: { remoteAddress?: string }; headers?: Record<string, unknown> };
import { createHash } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
@Controller('gateway/events')
export class DrainageSubmitGuardController {
constructor(private readonly prisma: PrismaService) {}
@Post('authorize-drainage')
async authorize(
@Req() request: Request,
@Body() body: { submitId?: string; channelId?: string; contentHash?: string },
) {
// This is a local Gateway capability, never a customer-supplied authorization.
if (
!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress ?? '') ||
request.headers?.['x-forwarded-for'] ||
request.headers?.forwarded
)
throw new ForbiddenException();
if (
typeof body.submitId !== 'string' ||
!body.submitId ||
body.submitId.length > 160 ||
typeof body.channelId !== 'string' ||
!body.channelId ||
body.channelId.length > 160 ||
typeof body.contentHash !== 'string' ||
!/^[a-f0-9]{64}$/.test(body.contentHash)
)
throw new BadRequestException('提交校验参数无效');
return this.prisma.$transaction(
async (tx) => {
const submit = await tx.smsSubmitRecord.findUnique({
where: { submitId: body.submitId },
include: { messageRecord: true },
});
if (
!submit ||
submit.channelId !== body.channelId ||
createHash('sha256').update(submit.messageRecord.content).digest('hex') !== body.contentHash
)
return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' };
let message = submit.messageRecord;
if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) {
const template = await tx.smsTemplate.findFirst({
where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId },
select: { signatureId: true },
});
if (template?.signatureId) message = { ...message, signatureId: template.signatureId };
}
if (
submit.resultProcessedAt ||
['failed', 'delivered', 'unknown', 'cancelled', 'rejected'].includes(message.status)
)
return { allowed: false, code: 'DRN', reason: '提交或消息已终结,不得重复发送' };
if (!message.tenantId && !message.batchTaskId) {
try {
await evaluateMessageDrainage(
tx as unknown as PrismaService,
message,
message.carrier ?? undefined,
undefined,
true,
);
return { allowed: true };
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
return { allowed: false, code: 'DRN', reason: error.message };
}
}
if (!message.tenantId || !message.applicationId || !message.signatureId)
return { allowed: false, code: 'DRN', reason: '提交消息未关联企业应用和签名' };
// Shared with approval/material writers through database triggers. Permission
// is linearized at this transaction; already granted wire operations are in-flight.
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(hashtextextended(${message.signatureId}, 910))`;
await tx.$queryRaw`SELECT id FROM "SmsSignature" WHERE id = ${message.signatureId} FOR SHARE`;
await tx.$executeRaw`LOCK TABLE "DrainageDetectionRule" IN SHARE MODE`;
const signature = await tx.smsSignature.findFirst({
where: {
id: message.signatureId,
tenantId: message.tenantId,
applicationId: message.applicationId,
auditStatus: 'approved',
},
});
if (!signature) return { allowed: false, code: 'DRN', reason: '签名资格已失效' };
try {
const assessment = await evaluateMessageDrainage(
tx as unknown as PrismaService,
message,
message.carrier ?? undefined,
undefined,
true,
);
const signatureReport = await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: signature.id,
tenantId: message.tenantId,
channelId: submit.channelId,
reportType: 'signature',
status: 'approved',
OR: [
{ carrier: message.carrier },
...(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true'
? [{ approvalScope: 'legacy_channel' }]
: []),
],
},
});
if (
!signatureReport ||
(assessment.allowedChannelIds !== null && !assessment.allowedChannelIds.includes(submit.channelId))
)
return { allowed: false, code: 'DRN', reason: '最终通道的签名或引流报备资格已失效' };
await tx.smsSubmitRecord.updateMany({
where: { id: submit.id, drainageGate: { equals: Prisma.DbNull } },
data: {
drainageGate: JSON.parse(
JSON.stringify({
...assessment,
channelId: submit.channelId,
carrier: message.carrier,
submitId: submit.submitId,
}),
),
},
});
return { allowed: true };
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
return { allowed: false, code: 'DRN', reason: error.message };
}
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 10000 },
);
}
}
+18 -3
View File
@@ -1,3 +1,4 @@
import { DrainageSubmitGuardController } from './drainage-submit-guard.controller';
import { forwardRef, Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { DictionariesModule } from '../dictionaries/dictionaries.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 { ClientSendChainController } from './client-send-chain.controller';
import { GatewayEventsController } from './gateway-events.controller'; import { GatewayEventsController } from './gateway-events.controller';
import { SendChainService } from './send-chain.service'; import { SendChainService } from './send-chain.service';
import { DrainageReceiptRecoveryService } from './drainage-receipt-recovery.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module'; import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({ @Module({
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule), SecurityDetectionModule], imports: [
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController], PrismaModule,
providers: [SendChainService], BillingModule,
DictionariesModule,
forwardRef(() => RiskReviewModule),
SmsConfigModule,
forwardRef(() => OpenApiModule),
SecurityDetectionModule,
],
controllers: [
DrainageSubmitGuardController,
AdminSendChainController,
ClientSendChainController,
GatewayEventsController,
],
providers: [SendChainService, DrainageReceiptRecoveryService],
exports: [SendChainService], exports: [SendChainService],
}) })
export class SendChainModule {} export class SendChainModule {}
+91 -2
View File
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { RiskReviewService } from '../risk-review/risk-review.service'; import { RiskReviewService } from '../risk-review/risk-review.service';
import { SendChainService } from './send-chain.service'; import { SendChainService } from './send-chain.service';
import { DrainageRejection } from './drainage-authorization';
function createPrismaMock() { function createPrismaMock() {
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 }; const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
@@ -134,7 +135,18 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
}, },
drainageDetectionRule: { 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: { smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
@@ -158,6 +170,7 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]), findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
findUnique: jest.fn().mockResolvedValue(message), findUnique: jest.fn().mockResolvedValue(message),
findUniqueOrThrow: jest.fn().mockResolvedValue(message),
findFirst: jest.fn().mockResolvedValue(message), findFirst: jest.fn().mockResolvedValue(message),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
updateMany: jest.fn().mockResolvedValue({ count: 1 }), updateMany: jest.fn().mockResolvedValue({ count: 1 }),
@@ -233,6 +246,7 @@ function createPrismaMock() {
Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId }))), Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId }))),
), ),
}, },
smsDrainageDecision: { create: jest.fn().mockResolvedValue({ id: 'decision-1' }) },
smsReceiptRecord: { smsReceiptRecord: {
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }), create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }), 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', () => { 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 () => { it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
const { service, prisma, riskReview, billing } = createService(); const { service, prisma, riskReview, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 }); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
@@ -3051,7 +3138,9 @@ describe('SendChainService', () => {
expect(service['identifyCarrier']).not.toHaveBeenCalled(); expect(service['identifyCarrier']).not.toHaveBeenCalled();
expect(service['identifyProvince']).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 () => { it('updates submit result status, charges billing, and task progress', async () => {
+228 -71
View File
@@ -1,22 +1,63 @@
import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common'; import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
Optional,
} from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis'; 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 { 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 { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service'; import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import { MetricsService } from '../metrics/metrics.service'; import { MetricsService } from '../metrics/metrics.service';
import { OpenApiService } from '../open-api/open-api.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 type {
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'; CreateBatchTaskDto,
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; 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 type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service'; import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service';
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service'; import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
@@ -68,12 +109,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}, },
metrics, metrics,
); );
this.completion = new SendCompletionService( this.completion = new SendCompletionService(prisma, billing, openApi, this as unknown as SendCompletionFacade);
prisma,
billing,
openApi,
this as unknown as SendCompletionFacade,
);
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this); this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
} }
@@ -91,7 +127,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.submission.startInboundWorkflowWorker(); this.submission.startInboundWorkflowWorker();
} }
if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') { 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.receiptTimeoutInitialTimer.unref?.();
this.receiptTimeoutIntervalTimer = setInterval( this.receiptTimeoutIntervalTimer = setInterval(
() => void this.runReceiptTimeoutScan(), () => void this.runReceiptTimeoutScan(),
@@ -107,22 +146,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.scheduledDispatchInitialTimer.unref?.(); this.scheduledDispatchInitialTimer.unref?.();
this.scheduledDispatchIntervalTimer = setInterval( this.scheduledDispatchIntervalTimer = setInterval(
() => void this.runScheduledDispatchScan(), () => 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?.(); this.scheduledDispatchIntervalTimer.unref?.();
} }
if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') { if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') {
this.inboundLongMessageInitialTimer = setTimeout( 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, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
); );
this.inboundLongMessageInitialTimer.unref?.(); this.inboundLongMessageInitialTimer.unref?.();
this.inboundLongMessageIntervalTimer = setInterval( 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( positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
DEFAULT_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') { if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') {
this.downstreamRequeueTaskIntervalTimer = setInterval( 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), positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000),
); );
this.downstreamRequeueTaskIntervalTimer.unref?.(); this.downstreamRequeueTaskIntervalTimer.unref?.();
@@ -191,12 +238,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
const taskIds = tasks.map((task) => task.id); const taskIds = tasks.map((task) => task.id);
const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ const messageStats =
by: ['batchTaskId', 'carrier', 'province', 'status'], taskIds.length > 0
where: { batchTaskId: { in: taskIds } }, ? await this.prisma.smsMessageRecord.groupBy({
_count: { _all: true }, by: ['batchTaskId', 'carrier', 'province', 'status'],
_sum: { billingUnits: true }, where: { batchTaskId: { in: taskIds } },
}) : []; _count: { _all: true },
_sum: { billingUnits: true },
})
: [];
return tasks.map((task) => ({ return tasks.map((task) => ({
...task, ...task,
messageStats: messageStats.filter((item) => item.batchTaskId === task.id), messageStats: messageStats.filter((item) => item.batchTaskId === task.id),
@@ -232,11 +282,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
sourceType: query.sourceType ?? 'client', sourceType: query.sourceType ?? 'client',
taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined, tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined,
application: query.applicationKeyword?.trim() ? { name: { contains: query.applicationKeyword.trim() } } : undefined, application: query.applicationKeyword?.trim()
createdAt: query.createdAtFrom || query.createdAtTo ? { ? { name: { contains: query.applicationKeyword.trim() } }
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, createdAt:
} : undefined, 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([ const [tasks, total] = await Promise.all([
this.prisma.smsBatchTask.findMany({ this.prisma.smsBatchTask.findMany({
@@ -254,12 +309,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.prisma.smsBatchTask.count({ where }), this.prisma.smsBatchTask.count({ where }),
]); ]);
const taskIds = tasks.map((task) => task.id); const taskIds = tasks.map((task) => task.id);
const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ const messageStats =
by: ['batchTaskId', 'carrier', 'province', 'status'], taskIds.length > 0
where: { batchTaskId: { in: taskIds } }, ? await this.prisma.smsMessageRecord.groupBy({
_count: { _all: true }, by: ['batchTaskId', 'carrier', 'province', 'status'],
_sum: { billingUnits: true }, where: { batchTaskId: { in: taskIds } },
}) : []; _count: { _all: true },
_sum: { billingUnits: true },
})
: [];
return { return {
items: tasks.map((task) => ({ items: tasks.map((task) => ({
...task, ...task,
@@ -304,14 +362,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return { items, total, page: normalizedPage, pageSize: normalizedPageSize }; return { items, total, page: normalizedPage, pageSize: normalizedPageSize };
} }
listMessages(query: { listMessages(
tenantId?: string; query: {
applicationId?: string; tenantId?: string;
channelId?: string; applicationId?: string;
taskId?: string; channelId?: string;
phoneNumber?: string; taskId?: string;
status?: string; phoneNumber?: string;
} = {}) { status?: string;
} = {},
) {
return this.prisma.smsMessageRecord.findMany({ return this.prisma.smsMessageRecord.findMany({
where: { where: {
tenantId: query.tenantId, tenantId: query.tenantId,
@@ -460,7 +520,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async handleReceipt( async handleReceipt(
data: GatewayReceiptEventDto, 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); return this.completion.handleReceipt(data, incomingIdentity);
} }
@@ -530,7 +596,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.downstreamRequeueTasks.preview(filter, operatorId); 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); return this.downstreamRequeueTasks.create(data, operatorId);
} }
@@ -542,7 +611,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.downstreamRequeueTasks.get(id); 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); return this.downstreamRequeueTasks.listItems(id, query);
} }
@@ -592,7 +664,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
requestedMessageIds?: string[], requestedMessageIds?: string[],
workflowKey?: 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( private async collectInboundLongMessageFragment(
@@ -616,22 +695,33 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
receiptRejection?: { code: string; reason: string }, receiptRejection?: { code: string; reason: string },
workflowItemKey?: 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 * CMPP
* *
*/ */
private async evaluateRiskWithPhoneFrequency(input: { private async evaluateRiskWithPhoneFrequency(
tenantId: string; input: {
applicationId: string; tenantId: string;
templateId?: string; applicationId: string;
content: string; templateId?: string;
variables?: Record<string, unknown>; content: string;
phoneNumber: string; variables?: Record<string, unknown>;
sourceType: 'cmpp'; phoneNumber: string;
}, reservationKey?: string) { sourceType: 'cmpp';
},
reservationKey?: string,
) {
return this.submission.evaluateRiskWithPhoneFrequency(input, reservationKey); return this.submission.evaluateRiskWithPhoneFrequency(input, reservationKey);
} }
@@ -699,13 +789,29 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
private async selectChannelForMessage( 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[] } = {}, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> { ): Promise<RoutedChannel> {
return this.submission.selectChannelForMessage(message, options); 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); return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
} }
@@ -754,10 +860,40 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.resolveDrainageInfoMatch(signatureId, content); 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); 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( private async recordCmppFailureReceipt(
message: { message: {
id: string; id: string;
@@ -779,7 +915,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones); 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); return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
} }
@@ -806,7 +947,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
private async releaseMessageReservation( 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, remark: string,
) { ) {
return this.completion.releaseMessageReservation(message, remark); return this.completion.releaseMessageReservation(message, remark);
@@ -832,7 +979,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier); 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); return this.submission.resolveMessageSignatureId(message);
} }
@@ -902,7 +1054,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private async resolveReceiptMessage( private async resolveReceiptMessage(
data: GatewayReceiptEventDto, 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); return this.completion.resolveReceiptMessage(data, incomingIdentity);
} }
@@ -926,5 +1084,4 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) { private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
return this.submission.publishGatewaySubmitCommand(command, idempotencyKey); 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 { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.service'; import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.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 type {
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'; GatewayUplinkEventDto,
import type { SendSubmissionService } from './send-submission.service'; UplinkMatchCandidateInput,
GatewayControlDeliveryResult,
} from './send-chain.contracts';
import { downstreamControlFailureMessage } from './send-chain.helpers';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
import { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; import { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
/** /**
* R10 downstreamDelivery implementation. * R10 downstreamDelivery implementation.
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. * 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) { async handleUplink(data: GatewayUplinkEventDto) {
if (data.eventId) { if (data.eventId) {
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } }); const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing; if (existing) return existing;
} }
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
if (!channel) { if (!channel) {
throw new NotFoundException('SMS channel not found'); throw new NotFoundException('SMS channel not found');
@@ -39,7 +42,7 @@ export class SendDownstreamDeliveryService {
const match = await this.facade.resolveUplinkMatch(data, channel); const match = await this.facade.resolveUplinkMatch(data, channel);
const record = await this.prisma.smsUplinkMessage.create({ const record = await this.prisma.smsUplinkMessage.create({
data: { data: {
eventId: data.eventId, eventId: data.eventId,
tenantId: match.tenantId, tenantId: match.tenantId,
applicationId: match.applicationId, applicationId: match.applicationId,
messageRecordId: match.messageRecordId, messageRecordId: match.messageRecordId,
@@ -224,24 +227,28 @@ export class SendDownstreamDeliveryService {
payload: data.payload, payload: data.payload,
}); });
} catch (error) { } 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.propagateHttpQueueError) throw error;
} }
} }
if (data.queueCmppDelivery === false) { if (data.queueCmppDelivery === false) {
return null; return null;
} }
if (!application?.cmppAccount || ( if (
application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true !application?.cmppAccount ||
)) { (application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true)
) {
return null; return null;
} }
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload }; const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId const dedupeKey =
? data.receiptDedupeKey ?? `receipt:${data.messageRecordId}` data.deliveryType === 'receipt' && data.messageRecordId
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string' ? (data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`)
? `uplink:${data.payload.uplinkMessageId}` : data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
: null; ? `uplink:${data.payload.uplinkMessageId}`
: null;
let delivery; let delivery;
try { try {
delivery = await this.prisma.cmppDownstreamDelivery.create({ delivery = await this.prisma.cmppDownstreamDelivery.create({
@@ -253,30 +260,30 @@ export class SendDownstreamDeliveryService {
dedupeKey, dedupeKey,
deliveryType: data.deliveryType, deliveryType: data.deliveryType,
payload, payload,
retryEnabled: cmppDeliveryAllowed && (data.deliveryType === 'uplink' retryEnabled:
? application?.downstreamUplinkRetryEnabled ?? true cmppDeliveryAllowed &&
: application?.downstreamReceiptRetryEnabled ?? true), (data.deliveryType === 'uplink'
? (application?.downstreamUplinkRetryEnabled ?? true)
: (application?.downstreamReceiptRetryEnabled ?? true)),
status: cmppDeliveryAllowed ? 'pending' : 'abandoned', status: cmppDeliveryAllowed ? 'pending' : 'abandoned',
lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送', lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
}, },
}); });
} catch (error) { } catch (error) {
if ( if (dedupeKey && error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
dedupeKey
&& error instanceof Prisma.PrismaClientKnownRequestError
&& error.code === 'P2002'
) {
const existing = await this.prisma.cmppDownstreamDelivery.findUnique({ const existing = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { dedupeKey }, where: { dedupeKey },
}); });
if (existing) { if (existing) {
this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({ this.logger.warn(
deliveryType: data.deliveryType, `downstream_delivery_deduplicated ${JSON.stringify({
messageRecordId: data.messageRecordId, deliveryType: data.deliveryType,
messageId: data.messageId, messageRecordId: data.messageRecordId,
dedupeKey, messageId: data.messageId,
deliveryId: existing.id, dedupeKey,
})}`); deliveryId: existing.id,
})}`,
);
return existing; return existing;
} }
} }
@@ -300,10 +307,10 @@ export class SendDownstreamDeliveryService {
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: delivery.id } }); return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: delivery.id } });
} }
try { try {
const result = await this.facade.postGatewayControl( const result = (await this.facade.postGatewayControl(
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink', data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
{ deliveryId: delivery.id, claimId, ...payload }, { deliveryId: delivery.id, claimId, ...payload },
) as GatewayControlDeliveryResult; )) as GatewayControlDeliveryResult;
if (result.sent || result.delivered) { if (result.sent || result.delivered) {
await this.facade.markDownstreamDeliverySent({ id: delivery.id, claimId, ...result }); await this.facade.markDownstreamDeliverySent({ id: delivery.id, claimId, ...result });
} else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') { } else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') {
@@ -322,7 +329,10 @@ export class SendDownstreamDeliveryService {
); );
} }
} catch (error) { } 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; return delivery;
} }
@@ -355,22 +365,25 @@ export class SendDownstreamDeliveryService {
const accessNumber = data.destId || channel.srcId || ''; const accessNumber = data.destId || channel.srcId || '';
const accessRoutes = accessNumber const accessRoutes = accessNumber
? await this.prisma.channelRouteRule.findMany({ ? await this.prisma.channelRouteRule.findMany({
where: { where: {
applicationId: { not: null }, applicationId: { not: null },
status: 'active', status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } }, group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
}, },
select: { applicationId: true }, select: { applicationId: true },
take: 10, 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 },
})
: []; : [];
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) { if (accessApplications.length === 1) {
return { return {
tenantId: accessApplications[0].tenantId, tenantId: accessApplications[0].tenantId,
@@ -421,15 +434,14 @@ export class SendDownstreamDeliveryService {
return { return {
matchStatus: 'ambiguous', matchStatus: 'ambiguous',
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`, matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
candidates: matchableRecentMessages candidates: matchableRecentMessages.map((message) => ({
.map((message) => ({ tenantId: String(message.tenantId),
tenantId: String(message.tenantId), applicationId: String(message.applicationId),
applicationId: String(message.applicationId), messageRecordId: message.id,
messageRecordId: message.id, matchSource: 'phone_window',
matchSource: 'phone_window', confidence: 55,
confidence: 55, reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`, })),
})),
}; };
} }
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] }; return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
@@ -454,36 +466,49 @@ export class SendDownstreamDeliveryService {
const existing = await this.prisma.smsReceiptRecord.findFirst({ const existing = await this.prisma.smsReceiptRecord.findFirst({
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` }, where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
}); });
if (existing) return existing; if (existing && !errorCode.startsWith('DRN')) return existing;
const deliveredAt = new Date(); const deliveredAt = new Date();
await this.prisma.smsMessageRecord.update({ await this.prisma.smsMessageRecord.update({
where: { id: message.id }, 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: { data: {
tenantId: message.tenantId, status: 'failed',
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,
receiptStatus: 'undelivered', receiptStatus: 'undelivered',
rawStatus: 'REJECTD', receiptRawStatus: 'REJECTD',
errorCode, errorCode,
errorMessage: reason, errorMessage: reason,
deliveredAt, deliveredAt,
}, },
}); });
await queueFinalReceiptDeliveries( const gatewayMessageId = `PLATFORM:${message.messageId}`;
this.prisma, const receiptKey = createHash('sha256')
(request) => this.facade.queueAndTryDownstreamDelivery(request), .update(
{ `platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`,
message, )
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE', .digest('hex');
payload: { 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, messageId: message.messageId,
gatewayMessageId: `PLATFORM:${message.messageId}`, gatewayMessageId: `PLATFORM:${message.messageId}`,
phoneNumber: message.phoneNumber, phoneNumber: message.phoneNumber,
@@ -492,10 +517,11 @@ export class SendDownstreamDeliveryService {
errorCode, errorCode,
errorMessage: reason, errorMessage: reason,
deliveredAt: deliveredAt.toISOString(), deliveredAt: deliveredAt.toISOString(),
},
}, },
); });
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId); 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; return receipt;
} }
@@ -202,11 +202,13 @@ export class SendGatewayResultService {
message.batchTaskId message.batchTaskId
) { ) {
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
const retried = await this.facade.retryMessageIfAllowed( const retried = data.errorCode?.startsWith('DRN')
businessMessage, ? null
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发', : await this.facade.retryMessageIfAllowed(
submitRecord.id, businessMessage,
); data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
submitRecord.id,
);
if (retried) { if (retried) {
await this.facade.refreshTaskProgress(businessMessage.batchTaskId); await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt); await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
@@ -231,6 +233,8 @@ export class SendGatewayResultService {
errorMessage: data.errorMessage, errorMessage: data.errorMessage,
submittedAt, submittedAt,
timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined, timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined,
drainageReceiptPending:
data.errorCode?.startsWith('DRN') && batchTask?.sourceType === 'cmpp' ? true : undefined,
}, },
}); });
if (updated.count === 0 && data.submitStatus === 'accepted') { 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 { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
import { moneyToNumber } from '../common/money'; import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service'; import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
@@ -390,6 +391,7 @@ export class SendGatewaySubmitService {
templateId?: string | null; templateId?: string | null;
signatureId?: string | null; signatureId?: string | null;
phoneNumber: string; phoneNumber: string;
content?: string;
carrier?: string | null; carrier?: string | null;
province?: string | null; province?: string | null;
template?: { signature?: { id?: string | null } | null } | null; template?: { signature?: { id?: string | null } | null } | null;
@@ -454,8 +456,14 @@ export class SendGatewaySubmitService {
const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`; const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`;
if (!routeByKey.has(key)) routeByKey.set(key, route); 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 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) { for (const input of routeInputs) {
if (!input.message.applicationId) { if (!input.message.applicationId) {
failed.push({ message: input.message, reason: '短信应用未配置,无法选择通道组' }); failed.push({ message: input.message, reason: '短信应用未配置,无法选择通道组' });
@@ -465,6 +473,35 @@ export class SendGatewaySubmitService {
failed.push({ message: input.message, reason: '短信签名未配置,无法选择已报备通道' }); failed.push({ message: input.message, reason: '短信签名未配置,无法选择已报备通道' });
continue; 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}`); const route = routeByKey.get(`${input.message.tenantId}:${input.message.applicationId}:${input.carrier}`);
if (!route) { if (!route) {
failed.push({ message: input.message, reason: '企业应用未配置对应运营商通道组' }); failed.push({ message: input.message, reason: '企业应用未配置对应运营商通道组' });
@@ -476,6 +513,7 @@ export class SendGatewaySubmitService {
} }
const approvedItems = route.group.items.filter( const approvedItems = route.group.items.filter(
(item) => (item) =>
(gate.allowedChannelIds === null || gate.allowedChannelIds.includes(item.channelId)) &&
item.channel.status === 'active' && item.channel.status === 'active' &&
item.channel.connectionStates.length > 0 && item.channel.connectionStates.length > 0 &&
item.channel.reportTasks.some( item.channel.reportTasks.some(
@@ -525,20 +563,25 @@ export class SendGatewaySubmitService {
cmppSubmitGroupMessageId?: string | null; cmppSubmitGroupMessageId?: string | null;
batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | 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( 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` await this.prisma.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message UPDATE "SmsMessageRecord" AS message
SET status = 'failed', "errorMessage" = failures.reason, "updatedAt" = (NOW() AT TIME ZONE 'UTC') SET status = 'failed', "errorMessage" = failures.reason, "errorCode" = failures.code,
FROM (VALUES ${values}) AS failures(id, reason) "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' WHERE message.id = failures.id AND message.status = 'queued'
`); `);
await Promise.all( await Promise.all(
failed.map(async ({ message, reason }) => { failed.map(async ({ message, reason, code }) => {
await this.releaseMessageReservation(message, reason); 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); else await this.facade.refreshTaskProgress(message.batchTaskId);
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason }); results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
this.metrics?.recordSendWorkerResult('failed'); this.metrics?.recordSendWorkerResult('failed');
@@ -620,14 +663,22 @@ export class SendGatewaySubmitService {
finish(result.submitted ? 'completed' : 'skipped'); finish(result.submitted ? 'completed' : 'skipped');
return result; return result;
} catch (error) { } 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 : '无可用通道组或通道'; const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({ await this.prisma.smsMessageRecord.update({
where: { id: message.id }, 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); await this.releaseMessageReservation(businessMessage, reason);
if (message.batchTask?.sourceType === 'cmpp') { if (message.batchTask?.sourceType === 'cmpp') {
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason); await this.recordCmppFailureReceipt(businessMessage, code ? 'DRN' : 'ROUTE', reason);
} else { } else {
await this.facade.refreshTaskProgress( await this.facade.refreshTaskProgress(
businessMessage.batchTaskId, businessMessage.batchTaskId,
@@ -1029,7 +1080,15 @@ return streamId`;
this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, carrier, signatureId), this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, carrier, signatureId),
); );
const excluded = new Set(options.excludeChannelIds ?? []); 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, { const selected = selectChannelCandidate(route.group.items, {
carrier, carrier,
province, province,
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
# 引流信息拦截与通道报备匹配方案
日期:2026-09-10。状态:已按后续授权实施,本地代码与隔离数据库/API验证完成,提交及测试发布状态见测试进度最新记录。授权为修改、本地提交、测试环境部署;未授权推送、预生产部署或真实短信发送。第2节保留设计阶段基线,第10节为实施事实。
## 1. 结论、范围及设计关系
该需求可实施,属于发送链路与风控规则变更,不是增加一个页面开关。复用已有引流资料、检测规则、签名报备、客户回执和费用处理能力;补齐统一规范化匹配、多引流关联、通道资格交集和可靠拒绝闭环。必须同时覆盖普通与批量快速入口、长短信合并、定时发送、审核释放及最终通道路由,不能只修改 resolveDrainageInfoMatch。
本方案是 [风控审核方案](phase-6-risk-review-plan.md) 的专项补充,关联 [发送链路设计](phase-4-send-pipeline-redesign.md)、[通道报备方案](phase-4-channel-reporting-plan.md)、[计费方案](phase-5-billing-plan.md)。实施生效后,替代 [需求](first-version-development-requirements.md) 中现有“本期只识别、记录、查询和统计”“引流审核/报备不得拦截”的规则,以及对应旧用例中的允许发送断言;历史实施记录保留,检测、高亮、统计能力继续保留。旧规则目前仍是代码现状,写方案不等于已启用拦截。
不新增独立引流登记入口,不自动添加资料或审核通过,不修改短信内容,不改企业余额/路由配置,不恢复关闭账号,不执行发送/补发/重新入队。后续实施、本地提交和测试部署以用户明确指令为准;历史短信重发仍未授权。
## 2. 当前证据与缺口
核验基线:本地 main / HEAD 为 5bcdbb2a03637b1ab1aeda59a4e1db9cc9fc4243;实际 ls-remote origin main 为6d63eb5452ffc7c802960d044bf598cc8646564d,本地领先1提交。暂存区空;保护原有19个跟踪修改和全部未跟踪文件。
| 现有证据 | 代码现状 | 本次缺口 |
|---|---|---|
| api/src/send-chain/drainage-content-detection.ts | 检测副本做NFKC及分类清洗,保留原文位置;记录多个matches;规则缓存30秒;最多20000字符/50命中,有truncated标记 | 检测结果未统一成为发送资格;截断和规则故障不能作为无引流放行 |
| api/src/common/drainage-target.ts | 登记值做trim和格式校验,未形成与检测一致的类型化规范键 | 02177882277与021-77882277在旧匹配中不等价 |
| api/src/send-chain/send-inbound-entry.service.ts | 普通路径使用原文includes,取最长单条;微批路径另有同类实现 | 未命中不拒绝;多项匹配不完整;两套路径易出现差异 |
| api/src/send-chain/send-chain.helpers.ts | drainageRejectionReason明确废弃并返回undefined | 不能简单恢复旧函数就宣称全入口拦截完成 |
| api/prisma/schema.prisma | SmsDrainageInfo含tenantId/signatureId/applicationId/url/auditStatus/materialVersion;消息只有单个drainageInfoId,另有drainageDetection JSON | 单个外键不足以表达多目标、多个匹配资料和每通道资格证据 |
| api/src/send-chain/send-gateway-submit.service.ts | 批量路由读取reportType=signature的approved任务;最终签名检查也有独立方法 | 需接入引流报备资格,不能把存在方法当成所有执行路径均调用 |
| api/src/send-chain/send-downstream-delivery.service.ts | recordCmppFailureReceipt写failed/undelivered/REJECTD和平台回执,再调用queueFinalReceiptDeliveries | 按提交来源区分:CMPP沿用既有分发,非CMPP拦截不推送;需验证长短信及请求回执标记;当前先查再写与已有回执直接返回不能直接宣称并发/崩溃可靠 |
| api/src/sms-config/drainage.service.ts、admin-sms-config.controller.ts | 企业签名下新增引流;真实资料审核及通道报备目标API已存在 | 复用关联,不能使用页面三网汇总、材料齐全或跨签名同值代替发送授权 |
证据为当前源码、Prisma模型与真实远端Git读取。本轮未启动应用服务、连接业务数据库或查询远端API;不作当前数据规模、报备覆盖、运行版本或线上行为结论。PostgreSQL/Redis/MinIO/Gateway状态及数据库实际索引需在实施前只读复核,历史交接数据不代替此次实测。
## 3. 业务判定规则
### 3.1 两道资格检查
1. 先沿用现有认证、企业应用和签名解析,签名必须属于当前企业及应用的有效授权范围;客户端传入的signatureId/drainageInfoId不能作为放行依据。
2. 对最终完整短信内容执行检测,包含模板变量替换及CMPP长短信合并后的内容。未识别出引流且检测完整时,继续原发送流程。
3. 识别出引流后,每个不同目标均须在当前有效签名下找到匹配引流资料。仅其他签名、其他企业或其他应用登记的同值不能借用。
4. “已添加”解决关联存在;平台审核和通道报备是另外的资格。用户已确认沿用既有资料审核链:deleted/pending/rejected不能作为可发资料,只有auditStatus=approved且材料有效的资料可参与通道匹配。仅添加但未审核通过不能发送。
5. 最终通道须同时满足应用当前路由、号码运营商、签名报备及每一个引流目标的报备资格;再走既有连接、地区、优先级、限速、额度等判断。引流通过不豁免其他门禁。
6. 一条短信包含多个目标时全部检查;同一目标重复出现只判定一次,保留全部原文位置。任一目标无对应资料即拒绝整条业务消息,不允许只匹配第一项/最长项。批量发送按每条业务消息记录判定,不牵连其他合法消息。
### 3.2 规范化与原文保护
实现共享、带版本的纯函数,登记侧与检测侧使用同一规则,保留类型、原值、规范值、原文起止位置。NFKC及清洗仅作用于比较副本;SmsMessageRecord.content、模板渲染结果、编码、计费长度、CMPP提交字节均使用原文。高亮位置仍指向原文,覆盖全角和代理对字符。
- 电话类:NFKC后清洗空白、横线及现有电话检测定义的干扰标点,再按完整号码比较。021-77882277与02177882277等价;不使用号码子串contains,不把77882277与02177882277自动等价。国家码、区号和分机不擅自补齐/删除,首期只有明确干扰字符清洗,不做号码归属推断。
- URL类:NFKC及明确的全角标点映射,清理零宽干扰字符的具体白名单需固定测试;保留host中的横线、点和path/query的结构字符,不沿用电话清洗。不把跨空格的两个目标拼成一个URL。协议/host按解析结果处理大小写,path/query保留大小写;不默认解码百分号或把+当空格。
- 邮箱仍沿用现有排除规则,不能从邮箱中截出域名或数字冒充引流;其他检测分类未经定义规范化和匹配策略时视为“不支持判定”,不悄悄忽略。
- 内容超限、命中截断、规则不可用、无有效规则或正则执行异常:不得以空matches判为放行;记录技术校验失败、停止向供应商提交,走有界重试/失败告警,区别于“客户未报备”。不得新增无限扫描或不受约束正则。
### 3.3 URL域名层级匹配(用户已明确)
用户补充确认:报备父域名后,其下三级、四级及更深子域名可使用,后续路径和参数不受影响;lisglo.cn.evil.com不是lisglo.cn的子域名,不允许。此规则取代此前“任意字面包含”的初稿,不再将URL包含边界列为待确认项。
提取完整URL目标,使用URL解析器取得hostname,不对整条URL做includes。缺少协议的目标仅在解析副本中补协议;规范化NFKC、host小写、国际化域名统一转换及可选单个末尾根点后,以标签边界判定:
~~~text
candidateHost === registeredHost
|| candidateHost.endsWith('.' + registeredHost)
~~~
报备lisglo.cn,等于该域或任何层级子域均匹配;报备sms.lisglo.cn,则只匹配它自己及a.sms.lisglo.cn等更深子域,不反向授权lisglo.cn或其他兄弟子域。不要靠字符串中的点数推断“一、二级域名”,以实际登记的合法域名作为授权根;不允许登记cn、com、com.cn等公共后缀来授权无关企业域名,实施时使用可维护的公共后缀数据校验,不能仅维护这几个示例。
对于纯域名报备,协议、端口、路径、query参数及fragment不参与域名资格比较;这仅表示引流域名匹配,仍受已有发送规则约束。例如https://a.sms.lisglo.cn:8443/1yhf7e87?x=1#top可匹配lisglo.cn。userInfo必须由解析器与hostname区分,不能将@前的内容当主机名;畸形URL不通过解析,不使用正则截出其中合法片段放行。
| 登记域名 | 短信中的目标 | 域名匹配结果 |
|---|---|---|
| lisglo.cn | lisglo.cn | 通过 |
| lisglo.cn | sms.lisglo.cn/1yhf7e87 | 通过 |
| lisglo.cn | a.sms.lisglo.cn/path?x=1&y=2#top | 通过 |
| lisglo.cn | lisglo.cn.evil.com | 不通过 |
| lisglo.cn | evillisglo.cn | 不通过 |
| lisglo.cn | evil.com/?next=lisglo.cn | 不通过 |
| lisglo.cn | https://lisglo.cn@evil.com/path | 不通过 |
| sms.lisglo.cn | a.sms.lisglo.cn?x=1 | 通过 |
| sms.lisglo.cn | lisglo.cn或other.lisglo.cn | 不通过 |
检测必须保留完整目标边界,不能把lisglo.cn.evil.com识别成lisglo.cn后再比较;参数内域名也不能成为另一个可独立授权外层URL的匹配结果。检测器与解析器均需相应失败回归。任何“通过”仅指域名关联匹配,尚须审核有效及相应通道报备通过。
现有资料可登记带协议/路径的完整URL:不能在未说明的情况下把历史路径级报备自动扩大为整个host授权。建议新增/确认纯域名报备使用上述规则;历史带路径资料标注为兼容待处理,先盘点,再决定保留路径限定或经业务确认升级为域名级。路径级资料的迁移策略仍待明确,但不影响本次已确认的纯域名报备行为。IP地址不是域名,不适用子域后缀规则,若支持则按完整规范IP相等。不会访问URL、跟随重定向、解析短链或发起HTTP探测。
## 4. 多目标与通道选择
对目标t求出同一签名下全部匹配且有效的资料集合 M(t)。允许多个匹配项,不再用最长匹配及更新时间任选唯一项;一个目标可由其中任一有该通道有效报备的资料证明,但审计必须记录实际使用的资料ID和报备任务ID。
设R是当前应用/运营商的可路由通道集合,S是签名已报备通道集合,D(d)是资料d已报备的通道集合:
```text
C = R ∩ S ∩ 对每个目标t求交集(对d属于M(t)求并集D(d))
```
示例:目标A可走通道1/2,目标B可走通道2/3,签名可走1/2/3,则仅可走通道2。不能把A与B分开送往不同通道,也不能拼接不同企业/签名的授权。
报备事实使用ChannelSignatureReportTasksignatureId一致、channelId一致、reportType=drainage、drainageItemId指向匹配资料、status=approved;不能只看签名任务或前端三网绿色状态。报备批准必须对应当前有效材料,资料修改/删除及waiting_review、pending、rejected、failed、abandoned均不能继续借旧通过记录放行。
报备配置边界(用户已确认):旧通道报备配置由用户负责调整,本次不迁移、不补齐、不推断旧记录应覆盖哪些运营商,也不自动重置报备状态。开发保证现有报备配置页面/API的查看、修改、保存、刷新和发送资格读取正常;按用户当前有效配置及通道支持范围判断,无有效资格则不发送。旧记录保持可查看、可操作,不因字段为空导致页面崩溃或无法保存;旧配置盘点/兼容改造不作为本次实施前置条件。
C为空时区分“引流未报备到可路由通道”和“报备合格但通道离线/限速”等情况,保留不同原因。选中失败、切换通道、自动重试都不得扩展到C之外;从未通过的通道不能成为兜底。
## 5. 处理流程、状态与回执
1. 各入口完成协议/认证校验并按既有耐久受理契约保存消息。入口受理成功与供应商发送成功分开;不能为业务拒绝同时返回不受理又伪造已受理的终态回执。
2. 用统一服务提取全部目标、规范化、校验签名关联并保存决策;必须在首次供应商提交前执行。定时任务到期、审核释放及已有排队消息均按生效策略重新检查,不信任旧单个drainageInfoId。
3. 规划通道时按第4节求交集;最终生成Gateway提交意图前复核资格版本,批量和单条路径共用实现。入口预检用于提前反馈,最终门禁为权威判定。
4. 各入口业务拒绝均保存消息failed和可读原因;CMPP来源再复用平台未送达回执路径,记录receiptStatus=undelivered、receiptRawStatus=REJECTD。非CMPP来源保留失败记录和任务进度,不为本次拦截新增回执记录或推送。建议内部原因码为DRAINAGE_NOT_REGISTERED、DRAINAGE_NOT_APPROVED、DRAINAGE_CHANNEL_NOT_APPROVED;这些是待新增内部码,不能直接把长字符串塞入CMPP固定长度字段,协议短码须核对现有映射并补兼容测试。
5. 消息状态、拒绝证据、费用结算,以及CMPP来源适用的回执投递意图需具备事务/耐久幂等;建议以messageRecordId+终态业务拒绝建立唯一键。已存在回执但尚未创建/发送下游投递时必须能恢复,不能因早返回丢失回执,也不能并发重复退款。
6. 用户已确认按提交来源沿用签名未报备行为:CMPP提交沿用现有失败回执分发、原请求标记、长短信关联和接收配置;非CMPP提交本次不推送回执,即使应用配置了HTTP回调也不得因引流拦截新建投递。实现必须在来源分支处约束,不能对全部入口无条件调用会生成HTTP投递意图的统一回执函数。CMPP来源现有配置允许的分发行为不另行改动;接收方离线等情况留真实投递状态,不伪报成功。不修改其他正常送达回执或上行推送功能。
7. 无供应商Submit不得产生供应商成本。入口尚未扣费则不扣;已扣/冻结的消息按既有签名/路由失败计费策略幂等退回或解冻。上线前对不同入口当前扣费位置逐一对账,不新建独立余额调整捷径。
8. 拒绝后补登记/补报备不自动释放已失败短信。回执重试仅恢复回执投递,不重发短信。短信发送、补发和重新入队须另有专项授权。
## 6. 数据、API与页面
复用SmsDrainageInfo和ChannelSignatureReportTask作为授权事实,不另建重复登记库。建议增加规范化类型、规范值及normalizationVersion用于索引和诊断,初期可由共享函数实时计算;是否落列取决于只读规模与执行计划,不在方案阶段执行迁移。历史原始url字段保留,规范键冲突先报告,不能自动合并、覆盖或继承另一条资料的报备状态。
消息需要多目标关系与不可变判定快照。建议新增SmsMessageDrainageMatchmessageRecordId、targetKey、匹配资料ID、实际采用的报备任务ID、carrier、channelId、资料/规则版本、decisionId),按一次决策与目标/资料建立唯一约束;未登记目标也需由快照保留,不能因无外键而丢证据。快照含原文位置、规范值、全部候选资料、最终采用项、原因、时间和策略版本,独立于既有drainageDetection检测JSON。现有drainageInfoId只保留兼容单目标历史展示,不能作为新门禁真相来源。
多引流会影响报备今日发送、引流质量和统计SQL:按message/submit ID去重后关联相应引流,单条短信可以归属多个引流,但首页消息数/客户分片/计费不得因此倍增。明确交叉归属统计不可直接相加,更新说明与查询,不在旧最长外键上冒充完整多引流统计。
复用接口:POST /api/admin/enterprise-signatures/:id/drainage-infos、PUT /api/admin/drainage-infos/:id、GET /api/admin/drainage-infos/:id/report-targets及对应client入口;继续服务器端租户/应用/签名校验。新增规范字段由服务端计算,客户端不得提交已通过判定或通道白名单。发送API不要求客户新增放行参数;消息详情API增加只读判定结果,历史未判定明确显示“未执行引流资格校验”,不默认通过。
企业签名管理仍负责登记/审核/报备;短信记录保留发送状态、原文和既有详情入口,在失败原因及详情展示具体未登记目标、对应签名、未通过通道原因。客户端只能看本企业证据,跨租户对象按不可见处理。不得输出内部凭据、完整路由配置或不相关客户资料。复用公共Table/Tag/Modal,错误/空态/加载/权限分别呈现,关闭方式沿用显式关闭规范;不改CSS和页面布局作为前置条件。
## 7. 一致性、性能与兼容风险
- 统一判断入口,CMPP微批必须按企业/应用/签名批量读取资料和批准事实,禁止按每号码×每目标×每通道做N+1查询;缓存以版本为键,仅用于候选,不以30秒旧缓存授予最终发送资格。
- 资料审核/修改/删除、通道报备撤销与发送意图创建需共同的锁或版本并发协议;建议同签名授权版本作为一致性锚点,所有写入口包括导入/批量状态变更都递增并参与校验。固定锁顺序,测试多Worker并发。
- 生成意图后到物理发送存在分布式窗口:已在Gateway队列但未发出的旧意图必须复核版本或取消,不能只在API检查后宣称“撤销即刻阻断”。实施需盘点Gateway/Redis提交消费者;若现契约不足,增加版本验证及耐久拒绝返回。已实际发出的短信不能撤回,不能伪造为未发送。切换生效时先暂停领取并处理在途意图,定义明确生效边界。
- 规则异常、数据库/Redis不可用应阻止提交并可恢复,禁止吞错放行;校验技术错误与客户未报备分开计数,避免误记客户违规。重试次数、超时沿用现有调度上限并留告警。
- 旧已终态消息不补判不回写;待发送、定时和待审核消息在生效后执行新判定;已有已发部分分片/提交尝试的消息单独列为在途,不能当首次拒绝统一全额退款。
- 实施按需核对规范化与表/索引规模;不开展旧通道报备配置盘点、自动迁移或补齐,旧配置由用户调整。可用历史内容离线评估,不调用发送入口,不修改短信状态。无覆盖率证据不承诺无影响上线。
- 回退到只识别旧版会绕过新规则。策略启用后回退须停止发送Worker/提交消费,保留决策与失败回执事实;不能因回退恢复已拒绝任务。启用/回退另行遵循标准发布入口和授权。
## 8. 实施拆分与验证成本
| 阶段 | 交付及依赖 | 验证重点 |
|---|---|---|
| A | 落实已确认域名规则、审核和按提交来源的回执规则;核对配置入口 | 保证用户可正常配置,不处理旧报备配置,不发送 |
| B | 共享规范化/多目标匹配及消息证据模型,历史兼容迁移 | 号码、URL、全角干扰、冲突、多项及原文不变;迁移回退 |
| C | 所有入口/快速批量/最终路由/重试接入;并发授权版本 | 多目标通道交集、撤销窗口、定时及审核释放、Redis与Gateway契约 |
| D | 终态、费用、可靠回执及详情/统计展示 | 幂等、崩溃恢复、CMPP回执/非CMPP不推送、租户隔离、统计去重 |
| E | 隔离环境全链路与回归,取得专项许可后发布 | PostgreSQL/Redis/Gateway模拟器、MinIO相关资料验证、浏览器与容量对比 |
复杂度中高,主要成本在多入口一致性、多引流数据模型和拒绝回执/费用闭环,单一正则修改不足。暂不承诺工期/TPS;完成阶段A后按实际消费者数量、存量数据和迁移规模估算。未来实施按测试计划运行API/前端定向及全量、类型/构建、格式/样式/包体门禁;涉及Gateway执行Go测试和vet,涉及迁移/发布执行对应门禁。真实发送模拟验收也须先获得明确专项授权,不能以测试方案存在代替许可。
## 9. 已确认事项、实施核查与完成标准
已确认:纯域名授权自身及所有层级子域,路径/参数不限制;资料必须已经添加到对应签名且平台审核通过;引流拦截参考签名未报备的处理。
以下用业务语言说明,不把内部数据兼容问题转成用户必须理解的审批项:
- 旧资料如果存的是https://lisglo.cn/app,而不是lisglo.cn,问题只是“它是否也允许lisglo.cn/other”。这与新登记纯域名后的子域/参数规则不同。建议按域名使用的目标统一考虑;实施先盘点实际是否存在这种旧资料,再明确处理,不能把尚未证实的数据情况当成阻塞。
- 旧通道报备配置由用户调整,本次只保证配置入口和保存后读取的资格判断正常,不处理旧配置。
- CMPP提交沿用签名失败回执;非CMPP提交不推送回执,是用户确认的正常规则,不是缺陷。将来如需非CMPP拦截回执,由用户另提需求。
### 9.1 本轮补核的签名未报备失败路径(源码证据)
send-gateway-submit.service.ts的批量failRouteBatch及单条路由失败处理均写消息failed和失败原因、释放费用预留。对于sourceType=cmpp,调用recordCmppFailureReceipt,使用ROUTE错误码,记录undelivered/REJECTD平台失败回执,再交由统一回执分发。普通路由原因可能为“无已报备通过且在线的可用通道”,其中包含签名资格/在线状态,不能把所有ROUTE失败都说成签名未报备。
CMPP回执分发按客户原提交及长短信分片的registeredDelivery标记:请求回执才生成对应CMPP投递;历史null按既有兼容默认处理。HTTP分发仍需应用可投递、HTTP回执配置开启及有效地址,沿用现有配置,不自动开启接口或添加地址。
现有非CMPP路由失败分支只刷新任务进度,没有调用统一失败回执方法。用户已确认这是预期行为,撤销初稿将其列为“缺口/闭环修复”的判断;本次不修改该行为。引流拦截同样按提交来源分支:全部记录失败及原因,仅CMPP来源进入适用的既有失败回执流程。此处是源码核验,未执行真实发送复现。费用释放、记录、CMPP回执队列创建与成功送达分别验收,非CMPP验证无新增投递。
验收用例见 [系统功能用例](system-functional-test-cases.md) TC-DRAINAGE-GATE-0116,全部待实现/待执行。验收需要保留原文和发送字节对比、PostgreSQL拒绝/授权/费用事实、供应商Submit为0的证明、CMPP来源适用的下游回执ACK/接收及持久化状态,以及非CMPP来源无拦截回执投递的证据;仅HTTP200、mock或记录failed均不足以证明拦截闭环完成。
## 10. 2026-09-10 实施落地与验收边界
本节描述本地实现,取代上文的待实现状态;不把本地验证写成线上生效。统一服务 drainage-authorization.ts 对全部目标判断,微批一次读取签名资料并按企业/应用/签名隔离,普通路由及换通道共用门禁;旧最长匹配仅作兼容归属,不再决定是否放行。使用 tldts 公共后缀数据检查域名边界,域名按自身或点边界子域匹配;手机号清洗后完整比较。检测副本保留原文位置,不改消息原文、分片长度或计费。配置资料保留原值,不迁移旧通道批准、不扩大历史带路径资料授权范围。
数据实现选择独立的 SmsDrainageDecision 追加式 JSON 决策表,而非逐目标关系表;每份快照保存全部目标、资料版本、报备任务、候选通道、时间和原因,避免单外键表达不了多目标。SmsMessageRecord.drainageGate 保存最新判定供详情使用,SmsSubmitRecord.drainageGate 保存该尝试首次最终许可的快照,后续撤销不覆盖该尝试证据。旧终态消息不回填;历史未判定显示“未执行引流资格校验”。报备统计从尝试快照读取多目标,按运营商聚合;无实际写入的 DRN 拒绝不算通道发送尝试。质量统计只在引流维度展开,其他维度不倍增。
Gateway 每个分片等待可用连接后通过仅本机直连的 POST /api/gateway/events/authorize-drainage 复核真实消息、提交ID、通道及完整内容 SHA256。转发头请求拒绝,客户端不能提交白名单。校验读取新规则、平台审核、签名及引流批准,不使用30秒规则缓存授予最终许可。数据库触发器覆盖资料/报备的新增、修改、删除,与资格事务使用同签名 advisory lock;签名行及规则表有读锁。提交事务取得许可后对应的物理写入属于在途,不能承诺已经取得许可的网络写入被撤回。无引流的独立通道测试保留原路径,含引流但未关联签名仍拒绝。已终结提交不重新授权。
业务拦截内部原因使用 DRAINAGE_*CMPP沿用 REJECTD 与短码 DRN;技术不可用使用 DRNCHK,并在尚未写任何分片时交给现有Worker有限重试/死信策略,不当作客户未报备违规。已有部分分片的尝试保留分片事实,不自动另选通道重发。资料为空、规则为空/异常、检测截断均不能当成无引流放行。
本次修复的回执崩溃窗口:原方法遇已有平台回执直接返回,可能漏建投递。引流业务拒绝在终态写入时同时保存 drainageReceiptPending,沿用原幂等费用释放;回执按 receiptKey upsert,已有回执仍补齐幂等下游意图,持久化失败不清标记。Worker每10秒恢复最多50条本类CMPP拒绝的回执;仅补回执,不重新发送短信。HTTP/CMPP下游各沿用既有唯一键。非CMPP路由拦截不调用该方法,不新增回执或推送。
新增迁移 20260910130000_drainage_send_gate 只加列、索引、决策表及锁触发器,不改既有批准/客户/余额/短信记录。测试发布按标准工具执行,包含此前九项运营修复提交;回退旧程序会失去本门禁,不能未经评估恢复发送。原治理工具草稿和备份/候选均保留。
验证:独立本机 PostgreSQL 克隆库完成新迁移,真实规则/API验证 NFKC号码、全部目标交集、审核撤销、报备撤销、并发锁等待、URL三种伪装拒绝、决策持久化及报备SQL。真实浏览器连接该API验证拦截详情、刷新、路由切换和1600×1000、1366×768、390×844;无Browser插件,使用既有Playwright/Edge。发送Worker与Gateway传输未启动,不以这些证据替代供应商零Submit、客户回执ACK、长短信物理发送、费用对账或容量测试,以上须专项发送授权后验证。自动回归及发布结果以 testing-progress.md 最新记录为准。
@@ -2249,3 +2249,16 @@
## 2026-09-09 运营九项修正 ## 2026-09-09 运营九项修正
用户确认需求及实现范围见 [九项修复设计](operations-fixes-20260909.md)。通道报备发送统计按实际运营商分开;创建/修改弹窗默认仅显式关闭;签名活跃度的企业、应用、签名、通道独立组合筛选;系统监控增加日期可选、默认近7日历史;首页客户分片按唯一业务消息汇总;发送质量告警已读计数与阅读筛选;详情行去通道组重复文案;HTTP地址随开关显示,保存校验错误居中;清退预警展示去“请通知 企业:”。本节客户分片口径替代旧供应商分片总数口径,到达率仍沿用供应商分片分子/分母。 用户确认需求及实现范围见 [九项修复设计](operations-fixes-20260909.md)。通道报备发送统计按实际运营商分开;创建/修改弹窗默认仅显式关闭;签名活跃度的企业、应用、签名、通道独立组合筛选;系统监控增加日期可选、默认近7日历史;首页客户分片按唯一业务消息汇总;发送质量告警已读计数与阅读筛选;详情行去通道组重复文案;HTTP地址随开关显示,保存校验错误居中;清退预警展示去“请通知 企业:”。本节客户分片口径替代旧供应商分片总数口径,到达率仍沿用供应商分片分子/分母。
## 2026-09-10 引流发送资格与通道匹配(待实施)
新增需求:完整短信经NFKC及分类干扰字符清洗后检测引流;每个目标须匹配当前企业应用/签名下的登记资料,号码规范值完整匹配,URL按hostname等于登记域名或为其任意层级子域名匹配,路径和参数不限制,排除lisglo.cn.evil.com等伪包含,短信原文不得修改。最终只能选同时满足签名及全部引流信息报备通过的通道;不满足时不向供应商提交,记录原因并走未送达客户回执闭环。方案、旧规则替代范围、URL包含边界与待明确事项见[专项方案](drainage-send-gating-plan-20260910.md)。本需求在实施启用后替代前文“引流只识别不得拦截”,不表示当前已上线;未授权本轮开发/提交/部署。
2026-09-10补充确认:引流资料仅添加不够,必须平台审核通过。回执参考现有签名未报备处理及客户原接收配置;源码核查发现非CMPP路由失败缺少统一失败回执调用,实施时纳入相关闭环修复和回归,不冒称当前HTTP一定推送。详见专项方案9.1。
2026-09-10最终澄清:旧通道报备配置由用户处理,本次不迁移/补齐/推断旧配置,保证配置页面/API可正常操作和资格读取。非CMPP提交遭拦截不推送回执,属于预期规则,撤销前条“纳入闭环修复”的要求;CMPP沿用签名未报备的既有失败回执行为。非CMPP回执需求以后另提。
## 2026-09-10 引流发送资格实施确认
后续已授权修改、本地提交及测试部署。以 drainage-send-gating-plan-20260910.md 第10节为实现说明,替代此前“未授权实施”的阶段性描述。每个引流目标须匹配本企业应用/签名审核通过的资料并满足最终通道报备;NFKC及干扰清洗只用于检测,域名自身与子域按点边界匹配,纯域名不限制路径参数。非CMPP拦截不推送回执;CMPP失败回执需幂等且可恢复。旧报备配置仍由用户处理。是否线上生效及未执行发送验收见 testing-progress.md。
+9
View File
@@ -69,3 +69,12 @@ api/src/risk-review/
- 阈值修改不清空计数,不自动释放待审消息;时间配置在当前夜间结束后生效,界面说明延迟生效。批量任务已有部分正常发送时,保留部分发送进度并标记存在待审核,不覆盖整批消息状态。审核与入队失败不得吞错,续发使用消息ID幂等队列任务。 - 阈值修改不清空计数,不自动释放待审消息;时间配置在当前夜间结束后生效,界面说明延迟生效。批量任务已有部分正常发送时,保留部分发送进度并标记存在待审核,不覆盖整批消息状态。审核与入队失败不得吞错,续发使用消息ID幂等队列任务。
- 权限沿用管理员风控配置/短信审核入口;应用必须从真实消息与企业关联取得,不能信任客户端自报企业、时间或分类;应用覆盖必须验证对象存在。历史审核记录不重写、不自动重投。回退须先停发送Worker并保留新待审及计数事实,旧版本不能继续绕过新夜间门禁。 - 权限沿用管理员风控配置/短信审核入口;应用必须从真实消息与企业关联取得,不能信任客户端自报企业、时间或分类;应用覆盖必须验证对象存在。历史审核记录不重写、不自动重投。回退须先停发送Worker并保留新待审及计数事实,旧版本不能继续绕过新夜间门禁。
- 验收覆盖阈值边界、多入口/多实例并发、跨午夜、应用隔离、幂等、重启、配置覆盖/变更、历史初始化、相同内容聚合、审核范围与并发、定时任务和数据库失败;使用隔离PostgreSQL/Redis证明持久化,不发送真实短信。前后端全量测试、类型/构建/质量门禁、两环境真实API与三尺寸页面验收分别留证。 - 验收覆盖阈值边界、多入口/多实例并发、跨午夜、应用隔离、幂等、重启、配置覆盖/变更、历史初始化、相同内容聚合、审核范围与并发、定时任务和数据库失败;使用隔离PostgreSQL/Redis证明持久化,不发送真实短信。前后端全量测试、类型/构建/质量门禁、两环境真实API与三尺寸页面验收分别留证。
## 2026-09-10 引流资格门禁(待实施专项)
详细规则、源码差异、多目标通道交集、终态回执/费用、并发与迁移见[引流信息拦截与通道报备匹配方案](drainage-send-gating-plan-20260910.md)。这是新增硬性发送资格,审核通过不得豁免引流检查;沿用既有夜间风控等规则,旧“只识别不拦截”在新功能生效后被替代。当前仅完成设计。
## 2026-09-10 引流发送门禁实施
后续用户已授权实施、本地提交及测试部署。具体数据模型、最终分片复核、并发锁、CMPP回执恢复和非CMPP无推送行为见 drainage-send-gating-plan-20260910.md 第10节;取代此前本主题仅处于设计阶段的状态。旧批准配置不迁移;线上状态与未执行项以测试进度为准。
+36
View File
@@ -5352,3 +5352,39 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对
| TC-OPS0909-07 | 短信记录发送详情多个提交/回执 | 每行无通道组文案,顶部汇总及通道/回执数据仍存在 | | TC-OPS0909-07 | 短信记录发送详情多个提交/回执 | 每行无通道组文案,顶部汇总及通道/回执数据仍存在 |
| TC-OPS0909-08 | HTTP关/开/关/开;本地及API校验失败 | 地址随HTTP参数隐藏展示且保值;居中错误弹窗保留表单,不伪报保存成功 | | TC-OPS0909-08 | HTTP关/开/关/开;本地及API校验失败 | 地址随HTTP参数隐藏展示且保值;居中错误弹窗保留表单,不伪报保存成功 |
| TC-OPS0909-09 | 历史/新清退预警多行、包含企业名称、非前缀正文 | 仅展示去行首请通知企业文案,保留签名/运营商/数量正文;不改数据库/外发消息 | | TC-OPS0909-09 | 历史/新清退预警多行、包含企业名称、非前缀正文 | 仅展示去行首请通知企业文案,保留签名/运营商/数量正文;不改数据库/外发消息 |
## 2026-09-10 引流发送资格验收(全部待实施/待执行)
权威方案见[引流门禁方案](drainage-send-gating-plan-20260910.md)。生效后替代旧引流未登记/未审核仍允许发送的断言;历史结果不改写。真实发送/入队验收需专项授权,本轮未执行。
| 用例 | 场景 | 预期 |
|---|---|---|
| TC-DRAINAGE-GATE-01 | 无引流、检测正常 | 继续原签名/风控/路由,不新增拒绝 |
| TC-DRAINAGE-GATE-02 | 02177882277登记,原文021-77882277及全角/干扰符 | 规范值匹配;数据库原文、计费长度和提交字节不变 |
| TC-DRAINAGE-GATE-03 | 号码子串、区号不同、跨签名/企业/应用同值 | 不得借用或子串放行 |
| TC-DRAINAGE-GATE-04 | 登记lisglo.cn,短信sms.lisglo.cn及带路径 | 两例通过目标关联;仍须合格通道 |
| TC-DRAINAGE-GATE-05 | lisglo.cn.evil.com、evillisglo.cn、query/userInfo伪包含;任意子域+路径参数 | 前四类不通过;hostname等于或点边界子域通过,深层子域不授权父/兄弟域;检测不得截短目标 |
| TC-DRAINAGE-GATE-06 | 一条短信两目标,仅一个登记 | 整条拒绝,另一个目标和原因可追溯;同批合法消息不受影响 |
| TC-DRAINAGE-GATE-07 | 一个目标匹配多条资料,多个目标通道1/2及2/3 | 单目标资料取并集,多目标取交集,仅通道2;记录授权任务 |
| TC-DRAINAGE-GATE-08 | 资料pending/rejected/deleted、修改冻结、旧版本报备 | 不借失效资料通过;兼容策略经确认后执行 |
| TC-DRAINAGE-GATE-09 | 同通道签名通过而引流未通过、运营商不匹配、历史通道级任务 | 严格区分签名/引流资格;旧配置不迁移补齐,用户修改/保存/刷新正常,按当前配置读取资格 |
| TC-DRAINAGE-GATE-10 | 普通HTTP/CMPP/客户端、微批、模板替换、长短信跨片URL | 统一结果,完整内容判定,入口受理不等于发送 |
| TC-DRAINAGE-GATE-11 | 定时到期、审核释放、换通道/重试、启用前排队 | 最终重新校验,不因旧快照/人工批准绕过 |
| TC-DRAINAGE-GATE-12 | 撤销报备与多Worker并发,Gateway意图已排队 | 授权版本/生效边界可证,未发送的失效意图不继续Submit |
| TC-DRAINAGE-GATE-13 | 拦截与回执并发、写状态后崩溃、回调失败/无目标 | 终态/费用幂等;CMPP沿用适用的既有回执且可恢复;非CMPP不创建拦截回执投递,即使配置了HTTP回调 |
| TC-DRAINAGE-GATE-14 | 已扣/未扣/冻结及部分已发历史记录 | 复用原策略不多扣多退,无供应商提交不产生供应商成本 |
| TC-DRAINAGE-GATE-15 | 50命中/20000字符上限、规则为空/故障、Redis/DB故障 | 不因不完整检测判无引流,不吞错放行;技术失败独立记录 |
| TC-DRAINAGE-GATE-16 | 详情/历史未校验/多引流统计/三尺寸与权限 | 保留原文和证据,历史不冒称通过,首页及费用不倍增,跨租户不可见 |
TC-DRAINAGE-GATE-05补充:覆盖host大小写、国际化域名、末尾根点、畸形URL、公共后缀登记拒绝和历史带路径资料迁移边界;纯域名报备不限制下级域名、URL路径或参数。此项已按用户澄清确定域名边界,不再使用字面includes。
TC-DRAINAGE-GATE-08审核要求已获用户确认:已添加但未审核通过不得发送。TC-DRAINAGE-GATE-13补充签名与引流在HTTP/CMPP路由失败的对照回归:当前非CMPP路由失败没有统一失败回执调用,需补闭环后验证,不能以CMPP通过代替HTTP通过;尊重原回执标记及接收配置。
2026-09-10最终澄清替代前条TC-DRAINAGE-GATE-13“补非CMPP回执闭环”要求:非CMPP不推送是预期行为,不作为Bug;CMPP继续沿用签名未报备的回执路径。TC-DRAINAGE-GATE-09不做旧报备配置迁移或补齐,增加旧记录可查看、用户修改保存刷新及当前资格读取回归;旧配置调整由用户负责。
## 2026-09-10 引流门禁实施及验证分层
TC-DRAINAGE-GATE-0116的实现范围以专项方案第10节为准,不再笼统标为全部待实现。新增真实数据库/API脚本 tools/testing/verify-drainage-gate.mjs 只接受本机 cmpp_qa_drainage_ 数字后缀克隆库,不调用发送入口或Gateway传输;覆盖02、04~09、12、16中的规范化、关联、通道交集、审核/报备撤销、并发锁、伪URL及SQL部分。Gateway隔离单元覆盖最终复核与故障拒绝;API单元覆盖CMPP已有回执恢复、持久化失败保留待恢复标记和非CMPP无推送。真实页面覆盖16的三尺寸、关闭、刷新和路由切换。新增用例:转发头不能调用最终资格接口;已终态提交不能重复授予许可;无实际wire的DRN拒绝不计入通道发送尝试。
保留待专项发送验收:10/11的各入口到供应商端到端、12的物理提交边界、13的客户回执ACK/离线重投、14的真实费用及部分分片对账、15的真实基础设施故障恢复及吞吐。单元测试、持久化夹具和浏览器不代替这些证据。旧配置不迁移、不自动补齐;配置入口沿用原API,测试环境只读确认,不保存客户配置。
+27
View File
@@ -4838,3 +4838,30 @@ git diff --check
- 遗留边界:创建通道390宽原有布局拥挤,关闭按钮可见可用,未扩大为视觉改版;历史告警受Prometheus保留和采样限制,含等待触发周期,最后采样不等于恢复时刻。预生产历史ERR_CONNECTION_CLOSED根因和已有管理员会话锁定未在本轮解决,未尝试恢复管理员。未推送、未测试部署、未预生产部署,线上真实业务验收未执行。 - 遗留边界:创建通道390宽原有布局拥挤,关闭按钮可见可用,未扩大为视觉改版;历史告警受Prometheus保留和采样限制,含等待触发周期,最后采样不等于恢复时刻。预生产历史ERR_CONNECTION_CLOSED根因和已有管理员会话锁定未在本轮解决,未尝试恢复管理员。未推送、未测试部署、未预生产部署,线上真实业务验收未执行。
本地证据目录:C:/Users/hectorzhao/AppData/Local/Temp/cmpp-nine-fixes-20260909。自动日志api-full.log、frontend-final.log、build-last.log、api-build-final.log、lint-last.log、security.log等;浏览器成功记录browser-run7.log、browser-extra2.log、browser-retirement2.log、browser-counts2.log及各browser-*目录截图/结果,失败日志保留。敏感认证值不写入文档或Git。 本地证据目录:C:/Users/hectorzhao/AppData/Local/Temp/cmpp-nine-fixes-20260909。自动日志api-full.log、frontend-final.log、build-last.log、api-build-final.log、lint-last.log、security.log等;浏览器成功记录browser-run7.log、browser-extra2.log、browser-retirement2.log、browser-counts2.log及各browser-*目录截图/结果,失败日志保留。敏感认证值不写入文档或Git。
## 2026-09-10 引流拦截需求评估与方案
仅文档授权;本地main为5bcdbb2a03637b1ab1aeda59a4e1db9cc9fc4243,实际ls-remote远端main为6d63eb5452ffc7c802960d044bf598cc8646564d,暂存区空,保护既有19个跟踪修改及全部未跟踪文件。核对检测器、两条原文最长匹配路径、报备路由、Prisma模型和平台失败回执后,新增[专项方案](drainage-send-gating-plan-20260910.md),同步需求/风控索引/16项待执行用例。明确旧只识别规则被新需求替代的生效关系、多目标交集、号码清洗且原文不变、URL字面包含边界、审核和legacy任务兼容、拒绝回执/费用幂等、队列与撤销并发、数据迁移及阶段实施。
证据仅当前源码/模型和真实Git远端读取;未启动服务、未连接业务数据库或远端API,未验证PostgreSQL/Redis/MinIO/Gateway当前状态,未执行业务测试、构建、浏览器或发送。文档完成不代表引流门禁已实现。业务边界待明确项列在方案第9节;本轮运行代码/配置/数据库无修改,未提交、未推送、未测试部署、未预生产部署。文档开工副本及保护核验记录位于%TEMP%/cmpp-drainage-design-20260910。
用户随后明确URL规则:登记父域名授权自身及任意层级子域,路径/参数不限制,排除lisglo.cn.evil.com等伪包含。已将方案3.3改为URL解析后的hostname相等或点边界后缀匹配,同步需求和TC-DRAINAGE-GATE-05;仅历史带路径资料的迁移策略等仍待明确,域名边界已确定。
文档核验完成:4份既有文档保持开工字节前缀不变,专项方案6个相对链接有效,16项验收用例均明确待执行;git diff --check通过,暂存区仍空。src无修改;api仅保留开工已有metrics两文件修改。本轮交付为新增1份方案与4份既有文档追加,无代码实现、业务测试或提交/发布。
2026-09-10解释与补核:用户确认引流资料须平台审核通过,已写入方案及用例。将旧带路径资料/通道级报备术语改为业务示例和实施核查项。只读核对单条/批量路由失败、recordCmppFailureReceipt、queueFinalReceiptDeliveries及HTTP投递配置:CMPP路径生成REJECTD并按请求标记投递;非CMPP路由失败只刷新任务进度,未调用统一失败回执,列为后续闭环修复点。无代码修改或真实发送复现。
2026-09-10用户最终确认:旧通道报备配置由用户调整,仅要求配置功能正常;非CMPP提交本来就不应推送回执,未来另提需求。已修正专项方案的现状评价、来源分支、实施范围和验收条件,同步需求与用例;撤销此前将非CMPP不推送判为缺口及纳入修复的判断。未修改代码/配置/数据,未迁移旧报备,未提交或部署。
## 2026-09-10 引流发送资格实施与本地验收(13:20 CST)
授权:修改、本地提交、测试环境部署;未授权推送、预生产部署、发送/补发/重投/重新入队短信或修改既有客户/通道/余额配置。实施见 drainage-send-gating-plan-20260910.md 第10节,用例 TC-DRAINAGE-GATE-0116 分层记录。
- Gitmain 开工 HEAD 5bcdbb2a03637b1ab1aeda59a4e1db9cc9fc4243,实际远端 main 6d63eb5452ffc7c802960d044bf598cc8646564d。本轮保护42个已有具体文件;发布工具、metrics、AGENTS及治理草稿不提交。本轮变更前副本、日志及浏览器证据位于 %TEMP%/cmpp-drainage-implementation-20260910。
- 实现:全目标规范化与同签名审核校验、通道交集、批量/普通路由、Gateway每分片最终复核、域名边界及伪URL排除、判定快照、CMPP拒绝回执耐久恢复、非CMPP不推送、多引流统计和短信详情。运行时仅新门禁产生拒绝,不处理旧批准或重发旧短信。治理清理未实施。
- 新增 schema 迁移在独立真实 PostgreSQL 克隆库 cmpp_qa_drainage_1789017023546 应用成功;另一个早期迁移试验库保留,均未改本机业务原库。真实规则+资格HTTP接口验证NFKC号码/原文不变、两个目标通道交集、平台审核、报备撤销、写事务与复核并发锁、伪后缀/query/userInfo、转发请求拒绝、持久化决策及报备SQL;结果 real-gate2.log。仅构造隔离数据库夹具,无发送Worker、Gateway transport或提交outbox。
- 自动检查:API全量67套723项通过;最终定向2套152项通过。前端27文件136项通过;TypeScript、API构建、前端production构建通过。Gateway全量Go测试及vet通过。lint(含类型/Stylelint/CSS治理15项)、format:check、security:verify、deploy:verify、bundle:verify通过。原有API metrics未提交测试包含在工作区总数中,精确提交的发布validate独立核验,不冒称所有测试文件均被提交。改动文件必要格式化和未使用导入清理用于满足当前门禁,未触及其他会话源码。
- 浏览器:Browser插件不可用,使用既有Playwright/Edge,真实本地API+独立PostgreSQL库;Redis5仅隔离本机队列,登录使用测试Redis7的DB15独立随机前缀,经SSH转发,结束删除仅本轮认证键和临时QA用户。1600×1000、1366×768、390×844通过,拦截原因、显式关闭、刷新与跨路由正常,无pageerror。首轮在详情请求返回前断言历史占位,已修正测试等待,并为加载中新增明确提示;最终production构建再次验收通过。证据 browser-final.log 及该目录最新 browser-* 截图。
- 环境:13:12重新核验测试SSH和health可达,线上版本仍809175b544f2526891ba6d2dece1a50eadaf57a0。期间两次SSH连接超时,Tailscale通过DERP后恢复;不记作认证失败或网络根因已永久解决。本次测试发布包同时包含此前九项运营修复提交与本功能;不推送远端。
- 未执行:真实短信提交、供应商零Submit/长短信跨分片对账、客户回执ACK/离线送达、费用和部分已发记录对账、TPS/故障注入容量测试;没有专项发送授权,以上不以单元、夹具或浏览器代替。既有通道配置不修改,MinIO材料无变更。提交及标准发布阶段、磁盘/恢复资产与目标页面结果在后续记录补齐。
+1 -1
View File
@@ -44,7 +44,7 @@ func main() {
protocolLogPublisher.SuccessSampleRate = positiveEnvInt("GATEWAY_PROTOCOL_LOG_SUCCESS_SAMPLE_PERCENT", 10) protocolLogPublisher.SuccessSampleRate = positiveEnvInt("GATEWAY_PROTOCOL_LOG_SUCCESS_SAMPLE_PERCENT", 10)
protocolLogPublisher.MaxLen = int64(positiveEnvInt("GATEWAY_PROTOCOL_LOG_STREAM_MAX_LEN", 200000)) protocolLogPublisher.MaxLen = int64(positiveEnvInt("GATEWAY_PROTOCOL_LOG_STREAM_MAX_LEN", 200000))
} }
upstreamManager := &upstream.Manager{APIBaseURL: apiBaseURL, EventAPIBaseURL: callbackBaseURL, ProtocolLogPublisher: protocolLogPublisher, GatewayInstanceID: gatewayInstanceID} upstreamManager := &upstream.Manager{DrainageGuardEnabled: true, APIBaseURL: apiBaseURL, EventAPIBaseURL: callbackBaseURL, ProtocolLogPublisher: protocolLogPublisher, GatewayInstanceID: gatewayInstanceID}
var worker *submitworker.Worker var worker *submitworker.Worker
var resultOutbox *resultoutbox.Outbox var resultOutbox *resultoutbox.Outbox
channelLimiter, err := ratelimit.New(os.Getenv("REDIS_URL")) channelLimiter, err := ratelimit.New(os.Getenv("REDIS_URL"))
@@ -0,0 +1,70 @@
package upstream
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"cmpp-platform/gateway/internal/queue"
)
type drainageBusinessRejection struct{ reason string }
func (e *drainageBusinessRejection) Error() string { return "引流资格拒绝: " + e.reason }
// Recheck current DB authorization after queue/connection waits. Neither a stale
// command nor a disabled detection flag on a command can grant permission.
func (m *Manager) authorizeDrainage(ctx context.Context, cmd queue.SubmitCommand) error {
if !m.DrainageGuardEnabled {
return nil
}
digest := sha256.Sum256([]byte(cmd.Content))
body, err := json.Marshal(map[string]string{"submitId": cmd.SubmitID, "channelId": cmd.ChannelID, "contentHash": hex.EncodeToString(digest[:])})
if err != nil {
return err
}
base := m.EventAPIBaseURL
if base == "" {
base = m.APIBaseURL
}
if base == "" {
return fmt.Errorf("引流资格校验服务未配置")
}
checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(checkCtx, http.MethodPost, strings.TrimRight(base, "/")+"/gateway/events/authorize-drainage", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := m.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("引流资格校验暂不可用: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("引流资格校验失败: HTTP %d", resp.StatusCode)
}
var result struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 16384)).Decode(&result); err != nil {
return fmt.Errorf("引流资格响应无效: %w", err)
}
if !result.Allowed {
return &drainageBusinessRejection{reason: result.Reason}
}
return nil
}
@@ -0,0 +1,43 @@
package upstream
import (
"cmpp-platform/gateway/internal/queue"
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestDrainageGuardFailClosed(t *testing.T) {
for _, body := range []string{`{"allowed":false,"reason":"未报备"}`, `{}`, `not json`} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) }))
m := &Manager{DrainageGuardEnabled: true, EventAPIBaseURL: server.URL}
if err := m.authorizeDrainage(context.Background(), queue.SubmitCommand{SubmitID: "s", Content: "原文"}); err == nil {
t.Fatalf("unexpected authorization: %s", body)
}
server.Close()
}
}
func TestDrainageGuardRequiresFreshResponse(t *testing.T) {
allowed := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gateway/events/authorize-drainage" {
t.Error(r.URL.Path)
}
if allowed {
w.Write([]byte(`{"allowed":true}`))
} else {
w.WriteHeader(503)
}
}))
defer server.Close()
m := &Manager{DrainageGuardEnabled: true, EventAPIBaseURL: server.URL}
cmd := queue.SubmitCommand{SubmitID: "s", Content: "unchanged"}
if err := m.authorizeDrainage(context.Background(), cmd); err != nil {
t.Fatal(err)
}
allowed = false
if err := m.authorizeDrainage(context.Background(), cmd); err == nil {
t.Fatal("reused stale permission")
}
}
+1
View File
@@ -25,6 +25,7 @@ const (
) )
type Manager struct { type Manager struct {
DrainageGuardEnabled bool
APIBaseURL string APIBaseURL string
EventAPIBaseURL string EventAPIBaseURL string
HTTPClient *http.Client HTTPClient *http.Client
+19 -1
View File
@@ -32,7 +32,7 @@ func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.Su
// This cannot make the supplier/Redis boundary globally atomic, but it avoids // This cannot make the supplier/Redis boundary globally atomic, but it avoids
// holding the supplier slot for an API round trip and minimizes untracked sends. // holding the supplier slot for an API round trip and minimizes untracked sends.
return m.SubmitSegmentPublisher.PublishSubmitSegment(ctx, cmd, segment) return m.SubmitSegmentPublisher.PublishSubmitSegment(ctx, cmd, segment)
}) }, func() error { return m.authorizeDrainage(ctx, cmd) })
return result, err return result, err
} }
@@ -40,6 +40,7 @@ func (p *connectionPool) submit(
ctx context.Context, ctx context.Context,
cmd queue.SubmitCommand, cmd queue.SubmitCommand,
onSegment func(queue.SubmitSegmentResult) error, onSegment func(queue.SubmitSegmentResult) error,
authorize ...func() error,
) (final queue.SubmitResult, finalErr error) { ) (final queue.SubmitResult, finalErr error) {
defer func() { defer func() {
for _, segment := range final.Segments { for _, segment := range final.Segments {
@@ -67,6 +68,23 @@ func (p *connectionPool) submit(
result.Segments = segments result.Segments = segments
return result, err return result, err
} }
for _, check := range authorize {
if err := check(); err != nil {
release()
code := "DRNCHK"
status := "rejected"
if _, business := err.(*drainageBusinessRejection); business {
code = "DRN"
} else if len(segments) == 0 {
// No bytes were submitted. Let the existing durable worker retry
// a technical outage with its bounded backoff/dead-letter policy.
status = ""
}
result := submitResult(cmd, 0, "", status, code, err.Error())
result.Segments = segments
return result, err
}
}
supplierStartedAt := time.Now() supplierStartedAt := time.Now()
seq, gatewayMessageID, result, err := conn.submitPart(ctx, cmd, part) seq, gatewayMessageID, result, err := conn.submitPart(ctx, cmd, part)
metrics.ObserveSubmitStage("supplier_rtt", err == nil, time.Since(supplierStartedAt)) metrics.ObserveSubmitStage("supplier_rtt", err == nil, time.Since(supplierStartedAt))
+19 -2
View File
@@ -134,6 +134,13 @@ export type SmsMessageRecord = {
categories?: string[]; categories?: string[];
truncated?: boolean; truncated?: boolean;
} | null; } | null;
drainageGate?: {
version: string;
evaluatedAt: string;
reason: string | null;
reasonCode: string | null;
targets: Array<{ category: string; text: string; value: string; materialIds: string[] }>;
} | null;
drainageDetectionVersion?: string | null; drainageDetectionVersion?: string | null;
drainageEvaluatedAt?: string | null; drainageEvaluatedAt?: string | null;
clientSrcId?: string | null; clientSrcId?: string | null;
@@ -331,7 +338,14 @@ export type SystemLogExportResult = {
recordCount: number; recordCount: number;
truncated: boolean; truncated: boolean;
content: string; content: string;
filters: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }; filters: {
keyword?: string;
level?: string;
module?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
};
}; };
export type DailyReconciliationReport = { export type DailyReconciliationReport = {
@@ -638,7 +652,10 @@ export type GatewaySubmitException = {
updatedAt: string; updatedAt: string;
tenant?: Pick<TenantOption, 'id' | 'name' | 'code' | 'status'> | null; tenant?: Pick<TenantOption, 'id' | 'name' | 'code' | 'status'> | null;
application?: Pick<EnterpriseApplication, 'id' | 'tenantId' | 'name' | 'status'> | null; application?: Pick<EnterpriseApplication, 'id' | 'tenantId' | 'name' | 'status'> | null;
channel?: Pick<AdminChannel, 'id' | 'code' | 'name' | 'status' | 'carrier' | 'sendRegion' | 'rateLimitPerSecond'> | null; channel?: Pick<
AdminChannel,
'id' | 'code' | 'name' | 'status' | 'carrier' | 'sendRegion' | 'rateLimitPerSecond'
> | null;
}; };
export type GatewaySubmitExceptionResponse = PagedResponse<GatewaySubmitException> & { export type GatewaySubmitExceptionResponse = PagedResponse<GatewaySubmitException> & {
@@ -47,6 +47,26 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
} }
> >
<div className="admin-sms-send-detail"> <div className="admin-sms-send-detail">
<section aria-label="引流发送资格">
<h3></h3>
{record.drainageGate ? (
<>
<p>
{record.drainageGate.reason ||
(record.drainageGate.targets.length
? '引流信息资格检查通过;发送仍受通道及其他规则限制'
: '未检测到引流信息')}
</p>
{record.drainageGate.targets.map((target, index) => (
<p key={index}>
{target.text} · {target.materialIds.length ? '已匹配审核通过的资料' : '未匹配有效资料'}
</p>
))}
</>
) : (
<p className="muted">{segmentLoading ? '正在加载引流资格校验结果…' : '未执行引流资格校验'}</p>
)}
</section>
<div className="admin-sms-detail-overview"> <div className="admin-sms-detail-overview">
<div> <div>
<span></span> <span></span>
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
// Only a separately created QA database is accepted. No send entry, worker,
// Gateway transport, balance mutation or remote business configuration is used.
const require = createRequire(resolve('api/package.json'));
for (const file of ['api/.env', '.env']) if (existsSync(file)) process.loadEnvFile(file);
const name = process.env.CMPP_DRAINAGE_QA_DATABASE;
assert.match(name ?? '', /^cmpp_qa_drainage_\d+$/);
const url = new URL(process.env.DATABASE_URL);
assert(['localhost', '127.0.0.1'].includes(url.hostname));
url.pathname = `/${name}`;
process.env.DATABASE_URL = url.toString();
process.env.CMPP_PROCESS_ROLE = 'api';
const { PrismaService } = require('./dist/prisma/prisma.service.js');
const { evaluateMessageDrainage } = require('./dist/send-chain/drainage-authorization.js');
const { DrainageSubmitGuardController } = require('./dist/send-chain/drainage-submit-guard.controller.js');
const { ChannelReportingService } = require('./dist/channels/channel-reporting.service.js');
const { ReportsService } = require('./dist/reports/reports.service.js');
const { Module } = require('@nestjs/common');
const { NestFactory } = require('@nestjs/core');
const db = new PrismaService();
const checks = [];
const prefix = `qa-drainage-${randomUUID()}`;
let app;
try {
const application = await db.smsApplication.findFirstOrThrow({ where: { status: { not: 'deleted' } } });
const channels = await db.smsChannel.findMany({ take: 2 });
assert.equal(channels.length, 2);
const signature = await db.smsSignature.create({
data: {
id: prefix,
tenantId: application.tenantId,
applicationId: application.id,
name: prefix,
auditStatus: 'approved',
},
});
const common = { tenantId: application.tenantId, applicationId: application.id, signatureId: signature.id };
const materials = [];
for (const [index, target] of ['lisglo.cn', '02177882277'].entries()) {
const material = await db.smsDrainageInfo.create({
data: { ...common, siteName: prefix, url: target, auditStatus: 'approved' },
});
materials.push(material);
for (const channel of channels.slice(index))
await db.channelSignatureReportTask.create({
data: {
tenantId: application.tenantId,
signatureId: signature.id,
channelId: channel.id,
carrier: 'mobile',
approvalScope: 'carrier',
reportType: 'drainage',
drainageItemId: material.id,
status: 'approved',
},
});
}
for (const channel of channels)
await db.channelSignatureReportTask.create({
data: {
tenantId: application.tenantId,
signatureId: signature.id,
channelId: channel.id,
carrier: 'mobile',
approvalScope: 'carrier',
reportType: 'signature',
status: 'approved',
},
});
const original = `${prefix}】访问 https://sms.lisglo.cn/path?x=1 或联系 021-77882277`;
const message = await db.smsMessageRecord.create({
data: { ...common, messageId: prefix, content: original, phoneNumber: '13800000000', carrier: 'mobile' },
});
const gate = await evaluateMessageDrainage(db, message, 'mobile', undefined, true);
assert.deepEqual(gate.allowedChannelIds, [channels[1].id]);
assert.equal(gate.targets.length, 2);
assert.equal((await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } })).content, original);
assert.equal(await db.smsDrainageDecision.count({ where: { messageRecordId: message.id } }), 1);
checks.push(
'real rules, NFKC phone, two targets, channel intersection, original content preserved, durable decision',
);
const submit = await db.smsSubmitRecord.create({
data: { messageRecordId: message.id, channelId: channels[1].id, submitId: `${prefix}-submit` },
});
class QAOnlyModule {}
Module({ controllers: [DrainageSubmitGuardController], providers: [{ provide: PrismaService, useValue: db }] })(
QAOnlyModule,
);
app = await NestFactory.create(QAOnlyModule, { logger: false });
await app.listen(0, '127.0.0.1');
const base = await app.getUrl();
const body = {
submitId: submit.submitId,
channelId: channels[1].id,
contentHash: createHash('sha256').update(original).digest('hex'),
};
const check = async (extra = {}) => {
const response = await fetch(`${base}/gateway/events/authorize-drainage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...extra },
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
assert.equal(response.status, 201);
return response.json();
};
assert.equal((await check()).allowed, true);
await db.channelSignatureReportTask.updateMany({
where: { drainageItemId: materials[1].id },
data: { status: 'rejected' },
});
assert.equal((await check()).allowed, false);
checks.push('real HTTP final permission changes immediately after carrier report revocation');
await db.channelSignatureReportTask.updateMany({
where: { drainageItemId: materials[1].id },
data: { status: 'approved' },
});
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'pending' } });
assert.equal((await check()).allowed, false);
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'approved' } });
const denied = await fetch(`${base}/gateway/events/authorize-drainage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '198.51.100.1' },
body: JSON.stringify(body),
});
assert.equal(denied.status, 403);
checks.push('platform audit required; proxy-origin requests forbidden');
let acquired;
let release;
const locked = new Promise((resolve) => {
acquired = resolve;
});
const unlock = new Promise((resolve) => {
release = resolve;
});
const writer = db.$transaction(
async (tx) => {
await tx.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'pending' } });
acquired();
await unlock;
},
{ timeout: 10000 },
);
await locked;
let finished = false;
const concurrent = check().then((result) => {
finished = true;
return result;
});
await new Promise((resolve) => setTimeout(resolve, 100));
const waitedForWriter = !finished;
release();
await writer;
assert(waitedForWriter);
assert.equal((await concurrent).allowed, false);
checks.push('PostgreSQL writer lock blocks concurrent permission and committed revocation is observed');
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'approved' } });
for (const content of [
'https://lisglo.cn.evil.com/p',
'https://evil.com/?next=lisglo.cn',
'https://lisglo.cn@evil.com/p',
]) {
const rejected = await db.smsMessageRecord.create({
data: {
...common,
messageId: `${prefix}-${randomUUID()}`,
content,
phoneNumber: '13800000000',
carrier: 'mobile',
},
});
await assert.rejects(evaluateMessageDrainage(db, rejected, 'mobile', undefined, true), /未在当前签名下添加/);
}
checks.push('real configured detectors reject suffix spoof, query spoof and URL userInfo spoof');
await evaluateMessageDrainage(db, message, 'mobile', undefined, true);
await new ReportsService(db).refreshRollingWindow(new Date(Date.now() + 86400000));
const quality = await db.dailyQualityReport.findMany({
where: { dimensionType: 'drainage', drainageInfoId: { in: materials.map((item) => item.id) } },
});
assert.equal(quality.length, 2);
assert(quality.every((row) => row.submittedUnits === 1));
const today = new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00+08:00');
const customerUnits = await db.smsMessageRecord.aggregate({
where: { applicationId: application.id, queuedAt: { gte: today, lt: new Date(today.getTime() + 86400000) } },
_sum: { billingUnits: true },
});
const applicationQuality = await db.dailyQualityReport.findFirstOrThrow({
where: { dimensionType: 'application', dimensionId: application.id },
orderBy: { reportDate: 'desc' },
});
assert.equal(applicationQuality.submittedUnits, customerUnits._sum.billingUnits);
// Synthetic rejected metadata only, no supplier transmission is performed.
await db.smsSubmitRecord.update({ where: { id: submit.id }, data: { submitStatus: 'rejected', errorCode: 'DRN' } });
const reports = await new ChannelReportingService(db).listReportTasks(
application.tenantId,
undefined,
channels[1].id,
'drainage',
);
const ownReports = reports.filter((row) => row.signatureId === signature.id);
assert.equal(ownReports.length, 2);
assert(ownReports.every((row) => row.deliveryStats.total === 0));
checks.push(
'real quality SQL attributes one message to both targets without multiplying customer units; no-wire rejection counts zero channel attempts',
);
assert.equal(await db.gatewaySubmitOutbox.count({ where: { messageRecordId: message.id } }), 0);
assert.equal(await db.smsReceiptRecord.count({ where: { messageRecordId: message.id } }), 0);
checks.push('no send intent or customer receipt was created by qualification checks');
console.log(JSON.stringify({ success: true, database: name, fixturePrefix: prefix, checks }));
} finally {
if (app) await app.close();
await db.$disconnect();
}