feat: enforce signature-scoped drainage authorization before SMS submission
This commit is contained in:
Generated
+20
-1
@@ -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
@@ -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();
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
@@ -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分开送往不同通道,也不能拼接不同企业/签名的授权。
|
||||||
|
|
||||||
|
报备事实使用ChannelSignatureReportTask:signatureId一致、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字段保留,规范键冲突先报告,不能自动合并、覆盖或继承另一条资料的报备状态。
|
||||||
|
|
||||||
|
消息需要多目标关系与不可变判定快照。建议新增SmsMessageDrainageMatch(messageRecordId、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-01~16,全部待实现/待执行。验收需要保留原文和发送字节对比、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。
|
||||||
|
|||||||
@@ -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节;取代此前本主题仅处于设计阶段的状态。旧批准配置不迁移;线上状态与未执行项以测试进度为准。
|
||||||
|
|||||||
@@ -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-01~16的实现范围以专项方案第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,测试环境只读确认,不保存客户配置。
|
||||||
|
|||||||
@@ -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-01~16 分层记录。
|
||||||
|
|
||||||
|
- Git:main 开工 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材料无变更。提交及标准发布阶段、磁盘/恢复资产与目标页面结果在后续记录补齐。
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user