diff --git a/api/package-lock.json b/api/package-lock.json index 9965527..5562ccc 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -25,7 +25,8 @@ "minio": "^8.0.7", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.2" + "rxjs": "^7.8.2", + "tldts": "^7.4.12" }, "devDependencies": { "@types/jest": "^30.0.0", @@ -8589,6 +8590,24 @@ "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": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", diff --git a/api/package.json b/api/package.json index dbd0d23..8b6723b 100644 --- a/api/package.json +++ b/api/package.json @@ -34,7 +34,8 @@ "minio": "^8.0.7", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.2" + "rxjs": "^7.8.2", + "tldts": "^7.4.12" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/api/prisma/migrations/20260910130000_drainage_send_gate/migration.sql b/api/prisma/migrations/20260910130000_drainage_send_gate/migration.sql new file mode 100644 index 0000000..3861fb7 --- /dev/null +++ b/api/prisma/migrations/20260910130000_drainage_send_gate/migration.sql @@ -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(); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d5b0efc..d96a157 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1790,6 +1790,14 @@ model SmsApiRequest { @@index([batchTaskId]) } +model SmsDrainageDecision { + id String @id @default(cuid()) + messageRecordId String + decidedAt DateTime @default(now()) + snapshot Json + @@index([messageRecordId, decidedAt]) +} + model SmsMessageRecord { monitorFacts SendingMonitorFact[] id String @id @default(cuid()) @@ -1808,6 +1816,8 @@ model SmsMessageRecord { content String hasDrainageContent Boolean? drainageDetection Json? + drainageGate Json? + drainageReceiptPending Boolean @default(false) drainageDetectionVersion String? drainageEvaluatedAt DateTime? billingUnits Int @default(1) @@ -1880,6 +1890,7 @@ model CmppSubmitSession { } model SmsSubmitRecord { + drainageGate Json? id String @id @default(cuid()) tenantId String? batchTaskId String? diff --git a/api/src/channels/channel-reporting.service.ts b/api/src/channels/channel-reporting.service.ts index a1dad2a..99bf8e0 100644 --- a/api/src/channels/channel-reporting.service.ts +++ b/api/src/channels/channel-reporting.service.ts @@ -196,6 +196,10 @@ export class ChannelReportingService { message."signatureId" AS signature_id, message.carrier AS carrier, message."drainageInfoId" AS drainage_info_id, + CASE WHEN COALESCE(submit."drainageGate", message."drainageGate") IS NULL THEN NULL ELSE ARRAY( + SELECT DISTINCT material_id FROM jsonb_array_elements(COALESCE(COALESCE(submit."drainageGate", message."drainageGate")->'targets', '[]'::jsonb)) target + CROSS JOIN LATERAL jsonb_array_elements_text(target->'materialIds') AS ids(material_id) + ) END AS drainage_ids, submit."submitStatus" AS submit_status, COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at, CASE @@ -239,6 +243,7 @@ export class ChannelReportingService { AND receipt."receiptStatus" = 'undelivered' ) failed_receipt ON TRUE WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout') + AND NOT (COALESCE(submit."errorCode", '') LIKE 'DRN%' AND submit."firstWireSubmitAt" IS NULL) AND submit."channelId" IN (${Prisma.join(channelIds)}) AND message."signatureId" IN (${Prisma.join(signatureIds)}) ) @@ -247,6 +252,7 @@ export class ChannelReportingService { signature_id AS "signatureId", carrier, drainage_info_id AS "drainageInfoId", + drainage_ids AS "drainageIds", COUNT(*) FILTER ( WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} )::integer AS total, @@ -272,7 +278,7 @@ export class ChannelReportingService { )::integer AS "failureCount", MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt" FROM base - GROUP BY channel_id, signature_id, drainage_info_id, carrier + GROUP BY channel_id, signature_id, drainage_info_id, carrier, drainage_ids `); return tasks.map((task) => { @@ -281,7 +287,10 @@ export class ChannelReportingService { row.channelId === task.channelId && row.signatureId === task.signatureId && (!task.carrier || row.carrier === task.carrier) && - ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId), + ((task.reportType ?? 'signature') === 'signature' || + (row.drainageIds + ? row.drainageIds.includes(task.drainageItemId ?? '') + : row.drainageInfoId === task.drainageItemId)), ); const deliveryStats = summarizeChannelReportDelivery(taskRows); return { diff --git a/api/src/channels/channels.helpers.ts b/api/src/channels/channels.helpers.ts index ab1e90f..bfb4267 100644 --- a/api/src/channels/channels.helpers.ts +++ b/api/src/channels/channels.helpers.ts @@ -592,6 +592,7 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail } export type ChannelReportDeliveryRow = { + drainageIds?: string[] | null; carrier: string | null; channelId: string; signatureId: string; diff --git a/api/src/common/drainage-target.ts b/api/src/common/drainage-target.ts index 7dfec2d..2dd2798 100644 --- a/api/src/common/drainage-target.ts +++ b/api/src/common/drainage-target.ts @@ -1,8 +1,22 @@ -export const DRAINAGE_TARGET_PATTERN = /^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i; +import { parse } from 'tldts'; +import { isIP } from 'node:net'; + +export const DRAINAGE_TARGET_PATTERN = + /^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i; export const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码'; export function normalizeDrainageTarget(value?: string) { const target = value?.trim() ?? ''; - return target && DRAINAGE_TARGET_PATTERN.test(target) ? target : undefined; + const normalized = target.normalize('NFKC'); + if (!target || !DRAINAGE_TARGET_PATTERN.test(normalized)) return undefined; + if (/[a-z]/i.test(normalized) && !/ext\.?/i.test(normalized)) { + try { + const host = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`).hostname; + if (!isIP(host) && !parse(host, { allowPrivateDomains: true }).domain) return undefined; + } catch { + return undefined; + } + } + return target; } diff --git a/api/src/gateway-callback.module.ts b/api/src/gateway-callback.module.ts index 778a8a2..84d4cbf 100644 --- a/api/src/gateway-callback.module.ts +++ b/api/src/gateway-callback.module.ts @@ -1,3 +1,4 @@ +import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { BillingService } from './billing/billing.service'; @@ -18,7 +19,7 @@ import { SendChainService } from './send-chain/send-chain.service'; MetricsModule, ProtocolLogsModule, ], - controllers: [GatewayCallbackController], + controllers: [DrainageSubmitGuardController, GatewayCallbackController], providers: [ BillingService, RiskReviewService, diff --git a/api/src/operations/operations.helpers.ts b/api/src/operations/operations.helpers.ts index 2712999..eea55c7 100644 --- a/api/src/operations/operations.helpers.ts +++ b/api/src/operations/operations.helpers.ts @@ -1,17 +1,22 @@ import { Prisma } from '@prisma/client'; import { BadRequestException } from '@nestjs/common'; import { moneyToNumber } from '../common/money'; -import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts'; +import type { + MessageQuery, + DownstreamDeliveryDashboardQuery, + DownstreamRecoveryStatusQuery, +} from './operations.contracts'; // Pure query builders and response mappers shared by the R2 query domains. export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { - const statusWhere = query.status === 'submit_failed' - ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } - : query.status === 'failed' - ? { status: 'failed', submitStatus: 'accepted' } - : query.status - ? { status: query.status } - : {}; + const statusWhere = + query.status === 'submit_failed' + ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } + : query.status === 'failed' + ? { status: 'failed', submitStatus: 'accepted' } + : query.status + ? { status: query.status } + : {}; return { tenantId: query.tenantId, applicationId: query.applicationId, @@ -21,25 +26,39 @@ export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereI phoneNumber: query.phoneNumber, ...carrierWhere(query.carrier), ...statusWhere, - ...(query.hasDrainage === 'true' ? { hasDrainageContent: true } - : query.hasDrainage === 'false' ? { hasDrainageContent: false } - : query.hasDrainage === 'unknown' ? { hasDrainageContent: null } + ...(query.hasDrainage === 'true' + ? { hasDrainageContent: true } + : query.hasDrainage === 'false' + ? { hasDrainageContent: false } + : query.hasDrainage === 'unknown' + ? { hasDrainageContent: null } : {}), ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), - ...(query.queuedAtFrom || query.queuedAtTo ? { - queuedAt: { - ...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}), - ...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}), - }, - } : {}), + ...(query.queuedAtFrom || query.queuedAtTo + ? { + queuedAt: { + ...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}), + ...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}), + }, + } + : {}), }; } export const recognizedCarrierValues = [ - 'mobile', 'cmcc', '移动', '中国移动', - 'unicom', 'cucc', '联通', '中国联通', - 'telecom', 'ctcc', '电信', '中国电信', + 'mobile', + 'cmcc', + '移动', + '中国移动', + 'unicom', + 'cucc', + '联通', + '中国联通', + 'telecom', + 'ctcc', + '电信', + '中国电信', ]; export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput { if (!carrier) return {}; @@ -48,10 +67,7 @@ export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInpu return { AND: [ { - OR: [ - { carrier: null }, - { carrier: { notIn: recognizedCarrierValues } }, - ], + OR: [{ carrier: null }, { carrier: { notIn: recognizedCarrierValues } }], }, ], }; @@ -107,10 +123,7 @@ export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma return { tenantId, createdAt: { gte: since }, - OR: [ - { transactionType: 'refunded' }, - { transactionType: 'released', relatedType: 'sms_message_record' }, - ], + OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }], }; } export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined { @@ -161,13 +174,12 @@ export function downstreamAlertWhere( export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput { return { status: 'pending', - OR: [ - { lastRetriedAt: null, createdAt: { lte: cutoff } }, - { lastRetriedAt: { lte: cutoff } }, - ], + OR: [{ lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }], }; } -export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput { +export function downstreamDeliveryScopedWhere( + query: DownstreamDeliveryDashboardQuery, +): Prisma.CmppDownstreamDeliveryWhereInput { const createdAtFrom = parseDateBoundary(query.createdAtFrom, false); const createdAtTo = parseDateBoundary(query.createdAtTo, true); return { @@ -191,14 +203,16 @@ export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQue state: query.state && query.state !== 'all' ? query.state : undefined, failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined, updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined, - OR: query.keyword ? [ - { account: { contains: query.keyword } }, - { gatewayInstanceId: { contains: query.keyword } }, - { lastError: { contains: query.keyword } }, - { lastSkipReason: { contains: query.keyword } }, - { tenant: { name: { contains: query.keyword } } }, - { application: { name: { contains: query.keyword } } }, - ] : undefined, + OR: query.keyword + ? [ + { account: { contains: query.keyword } }, + { gatewayInstanceId: { contains: query.keyword } }, + { lastError: { contains: query.keyword } }, + { lastSkipReason: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] + : undefined, }; } export function escapeCsvCell(value: string) { @@ -254,6 +268,21 @@ export function clientMessageView(message: Record) { carrier: message.carrier ?? null, province: message.province ?? null, content: message.content, + drainageGate: message.drainageGate + ? { + version: message.drainageGate.version, + evaluatedAt: message.drainageGate.evaluatedAt, + reason: message.drainageGate.reason, + reasonCode: message.drainageGate.reasonCode, + targets: (message.drainageGate.targets ?? []).map( + (target: { text: string; category: string; value: string }) => ({ + text: target.text, + category: target.category, + value: target.value, + }), + ), + } + : null, billingUnits: message.billingUnits, amountCents: moneyToNumber(message.amountCents), status: message.status, @@ -338,7 +367,13 @@ export function clientRechargeView(order: Record) { completedAt: order.completedAt ?? null, }; } -export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) { +export function summarizeMessageGroups( + groups: Array<{ + status: string; + _count: { _all: number }; + _sum: { amountCents: number | bigint | null; billingUnits: number | null }; + }>, +) { return groups.reduce( (summary, group) => { const count = group._count._all; @@ -360,8 +395,29 @@ export function summarizeMessageGroups(groups: Array<{ status: string; _count: { export function groupDownstreamByType( groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>, ) { - return groups.reduce>((accumulator, item) => { - const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 }; + return groups.reduce< + Record< + string, + { + total: number; + pending: number; + awaitingAck: number; + delivered: number; + failed: number; + unconfirmed: number; + rejected: number; + } + > + >((accumulator, item) => { + const current = accumulator[item.deliveryType] ?? { + total: 0, + pending: 0, + awaitingAck: 0, + delivered: 0, + failed: 0, + unconfirmed: 0, + rejected: 0, + }; current.total += item._count._all; if (item.status === 'pending') { current.pending += item._count._all; @@ -385,7 +441,20 @@ export function groupDownstreamByApplication( applicationMap: Map, applicationAlertMap: Map, ) { - const summaryMap = new Map(); + const summaryMap = new Map< + string, + { + applicationId: string; + name: string; + pending: number; + awaitingAck: number; + failed: number; + unconfirmed: number; + rejected: number; + delivered: number; + alertCount: number; + } + >(); groups.forEach((item) => { const current = summaryMap.get(item.applicationId) ?? { applicationId: item.applicationId, @@ -430,10 +499,7 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI ], }; const warning: Prisma.OperationLogWhereInput = { - OR: [ - { action: { contains: 'warning' } }, - { action: { contains: 'risk' } }, - ], + OR: [{ action: { contains: 'warning' } }, { action: { contains: 'risk' } }], }; const success: Prisma.OperationLogWhereInput = { OR: [ @@ -459,13 +525,14 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) { const detail = (log.detail ?? {}) as Record; const result = String(detail.result ?? detail.status ?? ''); - const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject') - ? 'error' - : log.action.includes('warning') || log.action.includes('risk') - ? 'warning' - : log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected') - ? 'success' - : 'info'; + const level = + result.includes('fail') || log.action.includes('failed') || log.action.includes('reject') + ? 'error' + : log.action.includes('warning') || log.action.includes('risk') + ? 'warning' + : log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected') + ? 'success' + : 'info'; return { id: log.id, time: log.createdAt, @@ -482,22 +549,32 @@ export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ inclu } export function sanitizeGatewaySubmitException( item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>, - messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string }, + messageState?: { + status: string; + submitStatus: string | null; + receiptStatus: string | null; + phoneNumber: string; + content: string; + }, ) { const { rawPayload, commandPayload, tenant, application, channel, ...record } = item; return { ...record, tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null, - application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null, - channel: channel ? { - id: channel.id, - code: channel.code, - name: channel.name, - status: channel.status, - carrier: channel.carrier, - sendRegion: channel.sendRegion, - rateLimitPerSecond: channel.rateLimitPerSecond, - } : null, + application: application + ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } + : null, + channel: channel + ? { + id: channel.id, + code: channel.code, + name: channel.name, + status: channel.status, + carrier: channel.carrier, + sendRegion: channel.sendRegion, + rateLimitPerSecond: channel.rateLimitPerSecond, + } + : null, rawPayloadAvailable: Boolean(rawPayload), commandPayload: redactGatewayCommandValue(commandPayload), messageState: messageState ?? null, @@ -512,8 +589,15 @@ export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prism for (const [key, child] of Object.entries(value)) { const normalizedKey = key.toLowerCase(); redacted[key] = [ - 'password', 'passwordcipher', 'secret', 'secrethash', 'authsource', - 'token', 'apikey', 'accesskey', 'secretkey', + 'password', + 'passwordcipher', + 'secret', + 'secrethash', + 'authsource', + 'token', + 'apikey', + 'accesskey', + 'secretkey', ].includes(normalizedKey) ? '[REDACTED]' : redactGatewayCommandValue(child as Prisma.JsonValue); diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts index d5353e5..fd9d2c2 100644 --- a/api/src/reports/reports.service.ts +++ b/api/src/reports/reports.service.ts @@ -539,7 +539,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat JOIN "Tenant" tenant ON tenant.id = message."tenantId" ${applicationJoin} LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId" - LEFT JOIN "SmsDrainageInfo" drainage ON drainage.id = message."drainageInfoId" + LEFT JOIN "SmsDrainageInfo" drainage ON ${dimensionType === 'drainage' ? Prisma.sql`(CASE WHEN message."drainageGate" IS NULL THEN drainage.id = message."drainageInfoId" ELSE EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(message."drainageGate"->'targets', '[]'::jsonb)) target WHERE target->'materialIds' ? drainage.id) END)` : Prisma.sql`drainage.id = message."drainageInfoId"`} WHERE message."queuedAt" >= ${day.startAt} AND message."queuedAt" < ${day.endAt} ), thresholds AS ( diff --git a/api/src/send-chain/drainage-authorization.spec.ts b/api/src/send-chain/drainage-authorization.spec.ts new file mode 100644 index 0000000..028db19 --- /dev/null +++ b/api/src/send-chain/drainage-authorization.spec.ts @@ -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); + }); +}); diff --git a/api/src/send-chain/drainage-authorization.ts b/api/src/send-chain/drainage-authorization.ts new file mode 100644 index 0000000..b00a9aa --- /dev/null +++ b/api/src/send-chain/drainage-authorization.ts @@ -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; + 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 | 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(Array.from(allowed as Set).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; +} diff --git a/api/src/send-chain/drainage-content-detection.ts b/api/src/send-chain/drainage-content-detection.ts index 2565f59..38b69a8 100644 --- a/api/src/send-chain/drainage-content-detection.ts +++ b/api/src/send-chain/drainage-content-detection.ts @@ -53,7 +53,8 @@ export function invalidateDrainageDetectionRuleCache() { export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') { if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空'); - if (pattern.length > MAX_PATTERN_LENGTH) throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`); + if (pattern.length > MAX_PATTERN_LENGTH) + throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`); if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) { throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复'); } @@ -69,18 +70,20 @@ export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') } } -function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent { +export function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent { let text = ''; const sourceStarts: number[] = []; const sourceEnds: number[] = []; let sourceIndex = 0; for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) { const sourceEnd = sourceIndex + sourceChar.length; - let normalized = sourceChar.normalize('NFKC') + let normalized = sourceChar + .normalize('NFKC') + .replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '') .replace(/[.。]/g, '.') .replace(/[:﹕]/g, ':') .replace(/[/]/g, '/') - .replace(/[()]/g, (char) => char === '(' ? '(' : ')') + .replace(/[()]/g, (char) => (char === '(' ? '(' : ')')) .replace(/[+]/g, '+'); if (category === 'url') { // Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link. @@ -116,8 +119,11 @@ function sourceRange(normalized: NormalizedContent, start: number, end: number) function emailRanges(normalized: NormalizedContent) { const ranges: Array<{ start: number; end: number }> = []; - const email = /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu; + const email = + /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu; for (const match of normalized.text.matchAll(email)) { + const tokenStart = normalized.text.lastIndexOf(' ', match.index) + 1; + if (/https?:\/\/\S*$/i.test(normalized.text.slice(tokenStart, match.index + match[0].length))) continue; ranges.push({ start: match.index, end: match.index + match[0].length }); } return ranges; @@ -135,7 +141,9 @@ export function detectDrainageContentWithRules( const matches: DrainageDetectionMatch[] = []; const normalizedByCategory = new Map(); const emailNormalized = normalizeContent(content, 'email'); - const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end)); + const originalEmailRanges = emailRanges(emailNormalized).map((range) => + sourceRange(emailNormalized, range.start, range.end), + ); for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) { validateDrainageDetectionPattern(rule.pattern, rule.flags); const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category); @@ -160,7 +168,12 @@ export function detectDrainageContentWithRules( start: range.start, end: range.end, }; - if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.end)) { + if ( + !matches.some( + (item) => + item.category === candidate.category && item.start === candidate.start && item.end === candidate.end, + ) + ) { matches.push(candidate); } if (matches.length >= MAX_MATCHES) break; @@ -186,8 +199,8 @@ export function detectDrainageContentWithRules( }; } -async function activeRules(prisma: PrismaService) { - if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules; +async function activeRules(prisma: PrismaService, fresh = false) { + if (!fresh && cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules; const rules = await prisma.drainageDetectionRule.findMany({ where: { status: 'active' }, orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], @@ -206,6 +219,6 @@ async function activeRules(prisma: PrismaService) { return rules; } -export async function detectDrainageContent(prisma: PrismaService, content: string) { - return detectDrainageContentWithRules(content, await activeRules(prisma)); +export async function detectDrainageContent(prisma: PrismaService, content: string, fresh = false) { + return detectDrainageContentWithRules(content, await activeRules(prisma, fresh)); } diff --git a/api/src/send-chain/drainage-receipt-recovery.service.ts b/api/src/send-chain/drainage-receipt-recovery.service.ts new file mode 100644 index 0000000..31e291f --- /dev/null +++ b/api/src/send-chain/drainage-receipt-recovery.service.ts @@ -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; + 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); + } +} diff --git a/api/src/send-chain/drainage-submit-guard.controller.ts b/api/src/send-chain/drainage-submit-guard.controller.ts new file mode 100644 index 0000000..513cb3a --- /dev/null +++ b/api/src/send-chain/drainage-submit-guard.controller.ts @@ -0,0 +1,141 @@ +import { Body, Controller, ForbiddenException, Post, Req, BadRequestException } from '@nestjs/common'; +type Request = { socket: { remoteAddress?: string }; headers?: Record }; +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 }, + ); + } +} diff --git a/api/src/send-chain/send-chain.module.ts b/api/src/send-chain/send-chain.module.ts index 49bb076..50b351d 100644 --- a/api/src/send-chain/send-chain.module.ts +++ b/api/src/send-chain/send-chain.module.ts @@ -1,3 +1,4 @@ +import { DrainageSubmitGuardController } from './drainage-submit-guard.controller'; import { forwardRef, Module } from '@nestjs/common'; import { BillingModule } from '../billing/billing.module'; import { DictionariesModule } from '../dictionaries/dictionaries.module'; @@ -9,12 +10,26 @@ import { AdminSendChainController } from './admin-send-chain.controller'; import { ClientSendChainController } from './client-send-chain.controller'; import { GatewayEventsController } from './gateway-events.controller'; import { SendChainService } from './send-chain.service'; +import { DrainageReceiptRecoveryService } from './drainage-receipt-recovery.service'; import { SecurityDetectionModule } from '../security-detection/security-detection.module'; @Module({ - imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule), SecurityDetectionModule], - controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController], - providers: [SendChainService], + imports: [ + PrismaModule, + BillingModule, + DictionariesModule, + forwardRef(() => RiskReviewModule), + SmsConfigModule, + forwardRef(() => OpenApiModule), + SecurityDetectionModule, + ], + controllers: [ + DrainageSubmitGuardController, + AdminSendChainController, + ClientSendChainController, + GatewayEventsController, + ], + providers: [SendChainService, DrainageReceiptRecoveryService], exports: [SendChainService], }) export class SendChainModule {} diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index e894b7f..0e06c21 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client'; import { BillingService } from '../billing/billing.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; import { SendChainService } from './send-chain.service'; +import { DrainageRejection } from './drainage-authorization'; function createPrismaMock() { const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 }; @@ -134,7 +135,18 @@ function createPrismaMock() { findMany: jest.fn().mockResolvedValue([]), }, drainageDetectionRule: { - findMany: jest.fn().mockResolvedValue([]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'url', + code: 'URL', + name: 'URL', + category: 'url', + pattern: '[a-z]+\\.[a-z]+', + flags: 'giu', + priority: 1, + version: 1, + }, + ]), }, smsSendTask: { findUnique: jest.fn().mockResolvedValue(null), @@ -158,6 +170,7 @@ function createPrismaMock() { findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]), count: jest.fn().mockResolvedValue(1), findUnique: jest.fn().mockResolvedValue(message), + findUniqueOrThrow: jest.fn().mockResolvedValue(message), findFirst: jest.fn().mockResolvedValue(message), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })), updateMany: jest.fn().mockResolvedValue({ count: 1 }), @@ -233,6 +246,7 @@ function createPrismaMock() { Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId }))), ), }, + smsDrainageDecision: { create: jest.fn().mockResolvedValue({ id: 'decision-1' }) }, smsReceiptRecord: { create: jest.fn().mockResolvedValue({ id: 'receipt-1' }), upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }), @@ -484,6 +498,79 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven } describe('SendChainService', () => { + it('recovers an existing drainage rejection receipt without duplicating it', async () => { + const { service, prisma } = createService(); + prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' }); + service['queueAndTryDownstreamDelivery'] = jest.fn().mockResolvedValue({ id: 'delivery' }); + service['refreshTaskProgress'] = jest.fn().mockResolvedValue({}); + await service['recordCmppFailureReceipt']( + { + id: 'record-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + cmppSubmitSequenceId: '101', + }, + 'DRN', + '未报备', + ); + expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); + expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled(); + expect(service['queueAndTryDownstreamDelivery']).toHaveBeenCalledWith( + expect.objectContaining({ queueCmppDelivery: true, receiptDedupeKey: 'receipt:record-1' }), + ); + expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({ + where: { id: 'record-1' }, + data: { drainageReceiptPending: false }, + }); + }); + it('keeps durable recovery pending when drainage receipt intent persistence fails', async () => { + const { service, prisma } = createService(); + service['queueAndTryDownstreamDelivery'] = jest.fn().mockRejectedValue(new Error('queue persistence unavailable')); + await expect( + service['recordCmppFailureReceipt']( + { + id: 'record-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + cmppSubmitSequenceId: '101', + }, + 'DRN', + '未报备', + ), + ).rejects.toThrow('queue persistence unavailable'); + expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith( + expect.objectContaining({ data: { drainageReceiptPending: false } }), + ); + }); + it('does not push any drainage rejection receipt for a non-CMPP submission', async () => { + const { service, prisma } = createService(); + const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } }); + prisma.smsMessageRecord.findUnique.mockResolvedValue({ + ...message, + batchTask: { id: 'task-1', sourceType: 'client' }, + }); + service['selectChannelForMessage'] = jest + .fn() + .mockRejectedValue(new DrainageRejection('DRAINAGE_NOT_REGISTERED', '未报备')); + service['recordCmppFailureReceipt'] = jest.fn(); + service['releaseMessageReservation'] = jest.fn(); + service['refreshTaskProgress'] = jest.fn(); + await service.processSendJob({ messageRecordId: 'record-1' }); + expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled(); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'failed', + errorCode: 'DRAINAGE_NOT_REGISTERED', + drainageReceiptPending: false, + }), + }), + ); + }); it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => { const { service, prisma, riskReview, billing } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 }); @@ -3051,7 +3138,9 @@ describe('SendChainService', () => { expect(service['identifyCarrier']).not.toHaveBeenCalled(); expect(service['identifyProvince']).not.toHaveBeenCalled(); - expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled(); + expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ carrier: expect.any(String) }) }), + ); }); it('updates submit result status, charges billing, and task progress', async () => { diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index b44325d..c69af41 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1,22 +1,63 @@ -import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common'; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, + Optional, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Queue, Worker } from 'bullmq'; import IORedis from 'ioredis'; -import { randomUUID } from 'node:crypto'; -import { createHash } from 'node:crypto'; -import { setTimeout as sleep } from 'node:timers/promises'; + import { BillingService } from '../billing/billing.service'; -import { isIpAllowed } from '../common/ip-allowlist'; -import { moneyToNumber } from '../common/money'; + import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; import { PrismaService } from '../prisma/prisma.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import { MetricsService } from '../metrics/metrics.service'; import { OpenApiService } from '../open-api/open-api.service'; -import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; -import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers'; -import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; +import type { + CreateBatchTaskDto, + CreateHttpBatchTaskDto, + GatewayInboundAuthDto, + GatewayInboundSubmitDto, + GatewaySubmitResultDto, + GatewaySubmitSegmentResultDto, + GatewayReceiptEventDto, + GatewayUplinkEventDto, + UplinkMatchCandidateInput, + GatewayPendingDeliveryQueryDto, + GatewayDownstreamSentDto, + GatewayDownstreamAcknowledgedDto, + GatewayDownstreamFailureType, + GatewaySubmitDeadLetterDto, + RequeueGatewaySubmitExceptionDto, + GatewayDownstreamRecoveryStatusDto, + TimeoutUnknownDto, + ImportPreviewDto, + ConfirmImportDto, + SendJob, + QueuePriority, + RoutedChannel, +} from './send-chain.contracts'; +import { + DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, + RECEIPT_TIMEOUT_INITIAL_DELAY_MS, + DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, + SCHEDULED_DISPATCH_INITIAL_DELAY_MS, + DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, + INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, + DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, + UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, + downstreamPendingTimeoutHours, + positiveInteger, +} from './send-chain.helpers'; + import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service'; import { SendCompletionService, type SendCompletionFacade } from './send-completion.service'; @@ -68,12 +109,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }, metrics, ); - this.completion = new SendCompletionService( - prisma, - billing, - openApi, - this as unknown as SendCompletionFacade, - ); + this.completion = new SendCompletionService(prisma, billing, openApi, this as unknown as SendCompletionFacade); this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this); } @@ -91,7 +127,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { this.submission.startInboundWorkflowWorker(); } if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') { - this.receiptTimeoutInitialTimer = setTimeout(() => void this.runReceiptTimeoutScan(), RECEIPT_TIMEOUT_INITIAL_DELAY_MS); + this.receiptTimeoutInitialTimer = setTimeout( + () => void this.runReceiptTimeoutScan(), + RECEIPT_TIMEOUT_INITIAL_DELAY_MS, + ); this.receiptTimeoutInitialTimer.unref?.(); this.receiptTimeoutIntervalTimer = setInterval( () => void this.runReceiptTimeoutScan(), @@ -107,22 +146,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { this.scheduledDispatchInitialTimer.unref?.(); this.scheduledDispatchIntervalTimer = setInterval( () => void this.runScheduledDispatchScan(), - positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS), + positiveInteger( + process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, + DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, + ), ); this.scheduledDispatchIntervalTimer.unref?.(); } if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') { this.inboundLongMessageInitialTimer = setTimeout( - () => void this.expireInboundLongMessages().catch((error) => { - this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`); - }), + () => + void this.expireInboundLongMessages().catch((error) => { + this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`); + }), INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, ); this.inboundLongMessageInitialTimer.unref?.(); this.inboundLongMessageIntervalTimer = setInterval( - () => void this.expireInboundLongMessages().catch((error) => { - this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`); - }), + () => + void this.expireInboundLongMessages().catch((error) => { + this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`); + }), positiveInteger( process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, @@ -147,7 +191,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') { this.downstreamRequeueTaskIntervalTimer = setInterval( - () => void this.downstreamRequeueTasks.runScan().catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)), + () => + void this.downstreamRequeueTasks + .runScan() + .catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)), positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000), ); this.downstreamRequeueTaskIntervalTimer.unref?.(); @@ -191,12 +238,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { orderBy: { createdAt: 'desc' }, }); const taskIds = tasks.map((task) => task.id); - const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ - by: ['batchTaskId', 'carrier', 'province', 'status'], - where: { batchTaskId: { in: taskIds } }, - _count: { _all: true }, - _sum: { billingUnits: true }, - }) : []; + const messageStats = + taskIds.length > 0 + ? await this.prisma.smsMessageRecord.groupBy({ + by: ['batchTaskId', 'carrier', 'province', 'status'], + where: { batchTaskId: { in: taskIds } }, + _count: { _all: true }, + _sum: { billingUnits: true }, + }) + : []; return tasks.map((task) => ({ ...task, messageStats: messageStats.filter((item) => item.batchTaskId === task.id), @@ -232,11 +282,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { sourceType: query.sourceType ?? 'client', taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined, - application: query.applicationKeyword?.trim() ? { name: { contains: query.applicationKeyword.trim() } } : undefined, - createdAt: query.createdAtFrom || query.createdAtTo ? { - gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, - lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, - } : undefined, + application: query.applicationKeyword?.trim() + ? { name: { contains: query.applicationKeyword.trim() } } + : undefined, + createdAt: + query.createdAtFrom || query.createdAtTo + ? { + gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, + lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, + } + : undefined, }; const [tasks, total] = await Promise.all([ this.prisma.smsBatchTask.findMany({ @@ -254,12 +309,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { this.prisma.smsBatchTask.count({ where }), ]); const taskIds = tasks.map((task) => task.id); - const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ - by: ['batchTaskId', 'carrier', 'province', 'status'], - where: { batchTaskId: { in: taskIds } }, - _count: { _all: true }, - _sum: { billingUnits: true }, - }) : []; + const messageStats = + taskIds.length > 0 + ? await this.prisma.smsMessageRecord.groupBy({ + by: ['batchTaskId', 'carrier', 'province', 'status'], + where: { batchTaskId: { in: taskIds } }, + _count: { _all: true }, + _sum: { billingUnits: true }, + }) + : []; return { items: tasks.map((task) => ({ ...task, @@ -304,14 +362,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return { items, total, page: normalizedPage, pageSize: normalizedPageSize }; } - listMessages(query: { - tenantId?: string; - applicationId?: string; - channelId?: string; - taskId?: string; - phoneNumber?: string; - status?: string; - } = {}) { + listMessages( + query: { + tenantId?: string; + applicationId?: string; + channelId?: string; + taskId?: string; + phoneNumber?: string; + status?: string; + } = {}, + ) { return this.prisma.smsMessageRecord.findMany({ where: { tenantId: query.tenantId, @@ -460,7 +520,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { async handleReceipt( data: GatewayReceiptEventDto, - incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + incomingIdentity?: { + account: string; + gatewayHost: string; + gatewayPort: number; + protocol: string; + cmppVersion: string; + }, ) { return this.completion.handleReceipt(data, incomingIdentity); } @@ -530,7 +596,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.downstreamRequeueTasks.preview(filter, operatorId); } - createDownstreamRequeueTask(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, operatorId?: string) { + createDownstreamRequeueTask( + data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, + operatorId?: string, + ) { return this.downstreamRequeueTasks.create(data, operatorId); } @@ -542,7 +611,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.downstreamRequeueTasks.get(id); } - listDownstreamRequeueTaskItems(id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }) { + listDownstreamRequeueTaskItems( + id: string, + query: { status?: string; keyword?: string; page?: number; pageSize?: number }, + ) { return this.downstreamRequeueTasks.listItems(id, query); } @@ -592,7 +664,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { requestedMessageIds?: string[], workflowKey?: string, ) { - return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey); + return this.submission.submitCompleteInboundMessage( + data, + phoneNumbers, + application, + requestedGroupMessageId, + requestedMessageIds, + workflowKey, + ); } private async collectInboundLongMessageFragment( @@ -616,22 +695,33 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { receiptRejection?: { code: string; reason: string }, workflowItemKey?: string, ) { - return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey); + return this.submission.submitInboundSingleMessage( + data, + messageId, + submitGroupMessageId, + application, + synchronousRejection, + receiptRejection, + workflowItemKey, + ); } /** * CMPP 单号码提交必须在生成最终入队决定前原子占用号码频次。 * 频次规则是直接拒绝,因此优先级高于其他规则产生的待人工审核结果。 */ - private async evaluateRiskWithPhoneFrequency(input: { - tenantId: string; - applicationId: string; - templateId?: string; - content: string; - variables?: Record; - phoneNumber: string; - sourceType: 'cmpp'; - }, reservationKey?: string) { + private async evaluateRiskWithPhoneFrequency( + input: { + tenantId: string; + applicationId: string; + templateId?: string; + content: string; + variables?: Record; + phoneNumber: string; + sourceType: 'cmpp'; + }, + reservationKey?: string, + ) { return this.submission.evaluateRiskWithPhoneFrequency(input, reservationKey); } @@ -699,13 +789,29 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } private async selectChannelForMessage( - message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }, + message: { + id: string; + tenantId: string; + applicationId?: string | null; + templateId?: string | null; + signatureId?: string | null; + phoneNumber: string; + carrier?: string | null; + province?: string | null; + template?: { signature?: { id?: string | null } | null } | null; + signature?: { id?: string | null } | null; + }, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, ): Promise { return this.submission.selectChannelForMessage(message, options); } - private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) { + private async findApplicationRoute( + tenantId: string, + applicationId: string | undefined, + carrier: string, + signatureId?: string, + ) { return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId); } @@ -754,10 +860,40 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.submission.resolveDrainageInfoMatch(signatureId, content); } - private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) { + private async attachMessageToReviewTask( + reviewTaskId: string, + messageRecordId: string, + signatureId: string, + drainageInfoId?: string, + ) { return this.submission.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId); } + async recoverDrainageFailureReceipts() { + const messages = await this.prisma.smsMessageRecord.findMany({ + where: { + drainageReceiptPending: true, + status: { in: ['failed', 'submit_failed'] }, + batchTask: { sourceType: 'cmpp' }, + }, + orderBy: { updatedAt: 'asc' }, + take: 50, + }); + for (const message of messages) { + if (!message.tenantId || !message.batchTaskId) continue; + const reason = message.errorMessage ?? '引流发送资格校验未通过'; + await this.releaseMessageReservation( + { ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId }, + reason, + ); + await this.recordCmppFailureReceipt( + message, + message.errorCode?.startsWith('DRN') ? message.errorCode : 'DRN', + reason, + ); + } + } + private async recordCmppFailureReceipt( message: { id: string; @@ -779,7 +915,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.submission.classifyRejectedPhones(tenantId, applicationId, phones); } - private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) { + private async validateSendResources( + tenantId: string, + applicationId?: string, + templateId?: string, + options?: SendResourceValidationOptions, + ) { return this.submission.validateSendResources(tenantId, applicationId, templateId, options); } @@ -806,7 +947,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } private async releaseMessageReservation( - message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + message: { + tenantId: string; + batchTaskId: string; + messageId: string; + amountCents: number | bigint; + billingUnits: number; + }, remark: string, ) { return this.completion.releaseMessageReservation(message, remark); @@ -832,7 +979,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier); } - private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { + private async resolveMessageSignatureId(message: { + templateId?: string | null; + signatureId?: string | null; + template?: { signature?: { id?: string | null } | null } | null; + signature?: { id?: string | null } | null; + }) { return this.submission.resolveMessageSignatureId(message); } @@ -902,7 +1054,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { private async resolveReceiptMessage( data: GatewayReceiptEventDto, - incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + incomingIdentity?: { + account: string; + gatewayHost: string; + gatewayPort: number; + protocol: string; + cmppVersion: string; + }, ) { return this.completion.resolveReceiptMessage(data, incomingIdentity); } @@ -926,5 +1084,4 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) { return this.submission.publishGatewaySubmitCommand(command, idempotencyKey); } - } diff --git a/api/src/send-chain/send-downstream-delivery.service.ts b/api/src/send-chain/send-downstream-delivery.service.ts index d6e1347..511c95e 100644 --- a/api/src/send-chain/send-downstream-delivery.service.ts +++ b/api/src/send-chain/send-downstream-delivery.service.ts @@ -1,17 +1,20 @@ -import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { createHash, randomUUID } from 'node:crypto'; import { BillingService } from '../billing/billing.service'; -import { moneyToNumber } from '../common/money'; + import type { OpenApiService } from '../open-api/open-api.service'; import { PrismaService } from '../prisma/prisma.service'; -import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; -import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; -import type { SendSubmissionService } from './send-submission.service'; +import type { + GatewayUplinkEventDto, + UplinkMatchCandidateInput, + GatewayControlDeliveryResult, +} from './send-chain.contracts'; +import { downstreamControlFailureMessage } from './send-chain.helpers'; + import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; import { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; - /** * R10 downstreamDelivery implementation. * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. @@ -28,10 +31,10 @@ export class SendDownstreamDeliveryService { ) {} async handleUplink(data: GatewayUplinkEventDto) { - if (data.eventId) { - const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } }); - if (existing) return existing; - } + if (data.eventId) { + const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } }); + if (existing) return existing; + } const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); if (!channel) { throw new NotFoundException('SMS channel not found'); @@ -39,7 +42,7 @@ export class SendDownstreamDeliveryService { const match = await this.facade.resolveUplinkMatch(data, channel); const record = await this.prisma.smsUplinkMessage.create({ data: { - eventId: data.eventId, + eventId: data.eventId, tenantId: match.tenantId, applicationId: match.applicationId, messageRecordId: match.messageRecordId, @@ -224,24 +227,28 @@ export class SendDownstreamDeliveryService { payload: data.payload, }); } catch (error) { - this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`); + this.logger.error( + `HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`, + ); if (data.propagateHttpQueueError) throw error; } } if (data.queueCmppDelivery === false) { return null; } - if (!application?.cmppAccount || ( - application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true - )) { + if ( + !application?.cmppAccount || + (application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true) + ) { return null; } const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload }; - const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId - ? data.receiptDedupeKey ?? `receipt:${data.messageRecordId}` - : data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string' - ? `uplink:${data.payload.uplinkMessageId}` - : null; + const dedupeKey = + data.deliveryType === 'receipt' && data.messageRecordId + ? (data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`) + : data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string' + ? `uplink:${data.payload.uplinkMessageId}` + : null; let delivery; try { delivery = await this.prisma.cmppDownstreamDelivery.create({ @@ -253,30 +260,30 @@ export class SendDownstreamDeliveryService { dedupeKey, deliveryType: data.deliveryType, payload, - retryEnabled: cmppDeliveryAllowed && (data.deliveryType === 'uplink' - ? application?.downstreamUplinkRetryEnabled ?? true - : application?.downstreamReceiptRetryEnabled ?? true), + retryEnabled: + cmppDeliveryAllowed && + (data.deliveryType === 'uplink' + ? (application?.downstreamUplinkRetryEnabled ?? true) + : (application?.downstreamReceiptRetryEnabled ?? true)), status: cmppDeliveryAllowed ? 'pending' : 'abandoned', lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送', }, }); } catch (error) { - if ( - dedupeKey - && error instanceof Prisma.PrismaClientKnownRequestError - && error.code === 'P2002' - ) { + if (dedupeKey && error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const existing = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { dedupeKey }, }); if (existing) { - this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({ - deliveryType: data.deliveryType, - messageRecordId: data.messageRecordId, - messageId: data.messageId, - dedupeKey, - deliveryId: existing.id, - })}`); + this.logger.warn( + `downstream_delivery_deduplicated ${JSON.stringify({ + deliveryType: data.deliveryType, + messageRecordId: data.messageRecordId, + messageId: data.messageId, + dedupeKey, + deliveryId: existing.id, + })}`, + ); return existing; } } @@ -300,10 +307,10 @@ export class SendDownstreamDeliveryService { return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: delivery.id } }); } try { - const result = await this.facade.postGatewayControl( + const result = (await this.facade.postGatewayControl( data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink', { deliveryId: delivery.id, claimId, ...payload }, - ) as GatewayControlDeliveryResult; + )) as GatewayControlDeliveryResult; if (result.sent || result.delivered) { await this.facade.markDownstreamDeliverySent({ id: delivery.id, claimId, ...result }); } else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') { @@ -322,7 +329,10 @@ export class SendDownstreamDeliveryService { ); } } catch (error) { - await this.facade.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed'); + await this.facade.markDownstreamDeliveryFailed( + delivery.id, + error instanceof Error ? error.message : 'Gateway control delivery failed', + ); } return delivery; } @@ -355,22 +365,25 @@ export class SendDownstreamDeliveryService { const accessNumber = data.destId || channel.srcId || ''; const accessRoutes = accessNumber ? await this.prisma.channelRouteRule.findMany({ - where: { - applicationId: { not: null }, - status: 'active', - group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } }, - }, - select: { applicationId: true }, - take: 10, - }) - : []; - const accessApplicationIds = [...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value)))]; - const accessApplications = accessApplicationIds.length > 0 - ? await this.prisma.smsApplication.findMany({ - where: { id: { in: accessApplicationIds }, status: 'active' }, - select: { id: true, tenantId: true, name: true }, - }) + where: { + applicationId: { not: null }, + status: 'active', + group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } }, + }, + select: { applicationId: true }, + take: 10, + }) : []; + const accessApplicationIds = [ + ...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value))), + ]; + const accessApplications = + accessApplicationIds.length > 0 + ? await this.prisma.smsApplication.findMany({ + where: { id: { in: accessApplicationIds }, status: 'active' }, + select: { id: true, tenantId: true, name: true }, + }) + : []; if (accessApplications.length === 1) { return { tenantId: accessApplications[0].tenantId, @@ -421,15 +434,14 @@ export class SendDownstreamDeliveryService { return { matchStatus: 'ambiguous', matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`, - candidates: matchableRecentMessages - .map((message) => ({ - tenantId: String(message.tenantId), - applicationId: String(message.applicationId), - messageRecordId: message.id, - matchSource: 'phone_window', - confidence: 55, - reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`, - })), + candidates: matchableRecentMessages.map((message) => ({ + tenantId: String(message.tenantId), + applicationId: String(message.applicationId), + messageRecordId: message.id, + matchSource: 'phone_window', + confidence: 55, + reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`, + })), }; } return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] }; @@ -454,36 +466,49 @@ export class SendDownstreamDeliveryService { const existing = await this.prisma.smsReceiptRecord.findFirst({ where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` }, }); - if (existing) return existing; + if (existing && !errorCode.startsWith('DRN')) return existing; const deliveredAt = new Date(); await this.prisma.smsMessageRecord.update({ where: { id: message.id }, - data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt }, - }); - const gatewayMessageId = `PLATFORM:${message.messageId}`; - const receipt = await this.prisma.smsReceiptRecord.create({ data: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'), - messageId: message.messageId, - gatewayMessageId, - phoneNumber: message.phoneNumber, + status: 'failed', receiptStatus: 'undelivered', - rawStatus: 'REJECTD', + receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt, }, }); - await queueFinalReceiptDeliveries( - this.prisma, - (request) => this.facade.queueAndTryDownstreamDelivery(request), - { - message, - allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE', - payload: { + const gatewayMessageId = `PLATFORM:${message.messageId}`; + const receiptKey = createHash('sha256') + .update( + `platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`, + ) + .digest('hex'); + const receiptData = { + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + receiptKey, + messageId: message.messageId, + gatewayMessageId, + phoneNumber: message.phoneNumber, + receiptStatus: 'undelivered', + rawStatus: 'REJECTD', + errorCode, + errorMessage: reason, + deliveredAt, + }; + const receipt = + existing ?? + (errorCode.startsWith('DRN') + ? await this.prisma.smsReceiptRecord.upsert({ where: { receiptKey }, update: {}, create: receiptData }) + : await this.prisma.smsReceiptRecord.create({ data: receiptData })); + await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), { + message, + propagateHttpQueueError: errorCode.startsWith('DRN'), + allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE', + payload: { messageId: message.messageId, gatewayMessageId: `PLATFORM:${message.messageId}`, phoneNumber: message.phoneNumber, @@ -492,10 +517,11 @@ export class SendDownstreamDeliveryService { errorCode, errorMessage: reason, deliveredAt: deliveredAt.toISOString(), - }, }, - ); + }); if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId); + if (errorCode.startsWith('DRN')) + await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { drainageReceiptPending: false } }); return receipt; } diff --git a/api/src/send-chain/send-gateway-result.service.ts b/api/src/send-chain/send-gateway-result.service.ts index e825bb2..d48f696 100644 --- a/api/src/send-chain/send-gateway-result.service.ts +++ b/api/src/send-chain/send-gateway-result.service.ts @@ -202,11 +202,13 @@ export class SendGatewayResultService { message.batchTaskId ) { const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; - const retried = await this.facade.retryMessageIfAllowed( - businessMessage, - data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发', - submitRecord.id, - ); + const retried = data.errorCode?.startsWith('DRN') + ? null + : await this.facade.retryMessageIfAllowed( + businessMessage, + data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发', + submitRecord.id, + ); if (retried) { await this.facade.refreshTaskProgress(businessMessage.batchTaskId); await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt); @@ -231,6 +233,8 @@ export class SendGatewayResultService { errorMessage: data.errorMessage, submittedAt, timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined, + drainageReceiptPending: + data.errorCode?.startsWith('DRN') && batchTask?.sourceType === 'cmpp' ? true : undefined, }, }); if (updated.count === 0 && data.submitStatus === 'accepted') { diff --git a/api/src/send-chain/send-gateway-submit.service.ts b/api/src/send-chain/send-gateway-submit.service.ts index a1db4b7..0ecd734 100644 --- a/api/src/send-chain/send-gateway-submit.service.ts +++ b/api/src/send-chain/send-gateway-submit.service.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto'; import { setTimeout as sleep } from 'node:timers/promises'; import { BillingService } from '../billing/billing.service'; +import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization'; import { moneyToNumber } from '../common/money'; import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service'; @@ -390,6 +391,7 @@ export class SendGatewaySubmitService { templateId?: string | null; signatureId?: string | null; phoneNumber: string; + content?: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; @@ -454,8 +456,14 @@ export class SendGatewaySubmitService { const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`; if (!routeByKey.has(key)) routeByKey.set(key, route); } + const drainageMaterials = signatures.length + ? await this.prisma.smsDrainageInfo.findMany({ + where: { signatureId: { in: signatures }, auditStatus: { not: 'deleted' } }, + include: { reportTasks: { where: { reportType: 'drainage' } } }, + }) + : []; const planned: Array<{ message: T; routed: RoutedChannel }> = []; - const failed: Array<{ message: T; reason: string }> = []; + const failed: Array<{ message: T; reason: string; code?: string }> = []; for (const input of routeInputs) { if (!input.message.applicationId) { failed.push({ message: input.message, reason: '短信应用未配置,无法选择通道组' }); @@ -465,6 +473,35 @@ export class SendGatewaySubmitService { failed.push({ message: input.message, reason: '短信签名未配置,无法选择已报备通道' }); continue; } + let gate; + try { + const stored = + input.message.content === undefined + ? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } }) + : input.message; + gate = await evaluateMessageDrainage( + this.prisma, + { ...input.message, content: stored.content!, signatureId: input.signatureId }, + input.carrier, + drainageMaterials + .filter( + (item) => + item.signatureId === input.signatureId && + item.tenantId === input.message.tenantId && + item.applicationId === input.message.applicationId, + ) + .map((item) => ({ + ...item, + reportTasks: item.reportTasks.filter( + (task) => task.signatureId === input.signatureId && task.tenantId === input.message.tenantId, + ), + })), + ); + } catch (error) { + if (!(error instanceof DrainageRejection)) throw error; + failed.push({ message: input.message, reason: error.message, code: error.reasonCode }); + continue; + } const route = routeByKey.get(`${input.message.tenantId}:${input.message.applicationId}:${input.carrier}`); if (!route) { failed.push({ message: input.message, reason: '企业应用未配置对应运营商通道组' }); @@ -476,6 +513,7 @@ export class SendGatewaySubmitService { } const approvedItems = route.group.items.filter( (item) => + (gate.allowedChannelIds === null || gate.allowedChannelIds.includes(item.channelId)) && item.channel.status === 'active' && item.channel.connectionStates.length > 0 && item.channel.reportTasks.some( @@ -525,20 +563,25 @@ export class SendGatewaySubmitService { cmppSubmitGroupMessageId?: string | null; batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null; }, - >(failed: Array<{ message: T; reason: string }>, results: Map) { + >(failed: Array<{ message: T; reason: string; code?: string }>, results: Map) { const values = Prisma.join( - failed.map(({ message, reason }) => Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text)`), + failed.map( + ({ message, reason, code }) => + Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text, ${code ?? null}::text, ${Boolean(code && message.batchTask?.sourceType === 'cmpp')}::boolean)`, + ), ); await this.prisma.$executeRaw(Prisma.sql` UPDATE "SmsMessageRecord" AS message - SET status = 'failed', "errorMessage" = failures.reason, "updatedAt" = (NOW() AT TIME ZONE 'UTC') - FROM (VALUES ${values}) AS failures(id, reason) + SET status = 'failed', "errorMessage" = failures.reason, "errorCode" = failures.code, + "drainageReceiptPending" = failures.pending, "updatedAt" = (NOW() AT TIME ZONE 'UTC') + FROM (VALUES ${values}) AS failures(id, reason, code, pending) WHERE message.id = failures.id AND message.status = 'queued' `); await Promise.all( - failed.map(async ({ message, reason }) => { + failed.map(async ({ message, reason, code }) => { await this.releaseMessageReservation(message, reason); - if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, 'ROUTE', reason); + if (message.batchTask?.sourceType === 'cmpp') + await this.recordCmppFailureReceipt(message, code ? 'DRN' : 'ROUTE', reason); else await this.facade.refreshTaskProgress(message.batchTaskId); results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason }); this.metrics?.recordSendWorkerResult('failed'); @@ -620,14 +663,22 @@ export class SendGatewaySubmitService { finish(result.submitted ? 'completed' : 'skipped'); return result; } catch (error) { + if (error instanceof Error && 'getStatus' in error && (error as { getStatus(): number }).getStatus() >= 500) + throw error; + const code = error instanceof DrainageRejection ? error.reasonCode : undefined; const reason = error instanceof Error ? error.message : '无可用通道组或通道'; await this.prisma.smsMessageRecord.update({ where: { id: message.id }, - data: { status: 'failed', errorMessage: reason }, + data: { + status: 'failed', + errorMessage: reason, + errorCode: code, + drainageReceiptPending: Boolean(code && message.batchTask?.sourceType === 'cmpp'), + }, }); await this.releaseMessageReservation(businessMessage, reason); if (message.batchTask?.sourceType === 'cmpp') { - await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason); + await this.recordCmppFailureReceipt(businessMessage, code ? 'DRN' : 'ROUTE', reason); } else { await this.facade.refreshTaskProgress( businessMessage.batchTaskId, @@ -1029,7 +1080,15 @@ return streamId`; this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, carrier, signatureId), ); const excluded = new Set(options.excludeChannelIds ?? []); - const approvedChannelIds = new Set(route.group.items.map((item) => item.channelId)); + const stored = await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } }); + const gate = await evaluateMessageDrainage(this.prisma, { ...stored, signatureId }, carrier); + const approvedChannelIds = new Set( + route.group.items + .map((item) => item.channelId) + .filter((id) => gate.allowedChannelIds === null || gate.allowedChannelIds.includes(id)), + ); + if (gate.targets.length && approvedChannelIds.size === 0) + throw new DrainageRejection('DRAINAGE_CHANNEL_NOT_APPROVED', '引流信息未在签名可用通道报备通过'); const selected = selectChannelCandidate(route.group.items, { carrier, province, diff --git a/api/src/send-chain/send-inbound-entry.service.ts b/api/src/send-chain/send-inbound-entry.service.ts index a3869da..4d1a859 100644 --- a/api/src/send-chain/send-inbound-entry.service.ts +++ b/api/src/send-chain/send-inbound-entry.service.ts @@ -1,7 +1,6 @@ -import { BadRequestException, ConflictException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { Queue, Worker } from 'bullmq'; -import IORedis from 'ioredis'; + import { createHash, randomUUID } from 'node:crypto'; import { hostname } from 'node:os'; import { setTimeout as sleep } from 'node:timers/promises'; @@ -13,8 +12,23 @@ import { PrismaService } from '../prisma/prisma.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import { MetricsService, type CmppInboundStage } from '../metrics/metrics.service'; -import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; -import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; +import type { + GatewayInboundAuthDto, + GatewayInboundSubmitDto, + GatewayInboundSingleSubmitResult, +} from './send-chain.contracts'; +import { + DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, + BULLMQ_PRIORITY, + normalizeQueuePriority, + getNonNegativeConfigInteger, + matchTemplateContent, + validateInboundApplicationSrcId, + positiveInteger, + parseOptionalSequenceId, + shanghaiDateKey, + matchesApplicationSecret, +} from './send-chain.helpers'; import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; import { detectDrainageContent } from './drainage-content-detection'; @@ -106,7 +120,13 @@ export class SendInboundEntryService { } private releaseMessageReservation( - message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, + message: { + tenantId: string; + batchTaskId: string; + messageId: string; + amountCents: number | bigint; + billingUnits: number; + }, remark: string, ) { return this.callbacks.releaseMessageReservation(message, remark); @@ -130,15 +150,18 @@ export class SendInboundEntryService { return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); } - -async authenticateInboundApplication(data: GatewayInboundAuthDto) { + async authenticateInboundApplication(data: GatewayInboundAuthDto) { let tenantId: string | undefined; let applicationId: string | undefined; try { const application = await this.facade.findInboundApplication(data.account); tenantId = application?.tenantId; applicationId = application?.id; - if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') { + if ( + !application || + !['active', 'disabling'].includes(application.status) || + application.tenant.status !== 'active' + ) { throw new BadRequestException('CMPP account is invalid or disabled'); } if (!application.interfaceEnabled) { @@ -150,7 +173,13 @@ async authenticateInboundApplication(data: GatewayInboundAuthDto) { if (!matchesApplicationSecret(data, application.secretHash)) { throw new BadRequestException('CMPP account or password is invalid'); } - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + if ( + data.remoteIp && + !isIpAllowed( + data.remoteIp, + application.ipAllowlist.map((item) => item.ipCidr), + ) + ) { throw new BadRequestException('CMPP source IP is not in application allowlist'); } await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'authenticated' }); @@ -206,7 +235,7 @@ async authenticateInboundApplication(data: GatewayInboundAuthDto) { }); } -async submitInboundMessage(data: GatewayInboundSubmitDto) { + async submitInboundMessage(data: GatewayInboundSubmitDto) { if (data.registeredDelivery != null && ![0, 1].includes(data.registeredDelivery)) { throw new BadRequestException('CMPP Registered_Delivery must be 0 or 1'); } @@ -220,27 +249,28 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { } if (this.inboundFastPathEnabled() && !data.longMessage) { - return this.measureInboundStage( - 'inbox_persist', - () => this.persistValidatedInboundWorkflow(data, phoneNumbers), - ); + return this.measureInboundStage('inbox_persist', () => this.persistValidatedInboundWorkflow(data, phoneNumbers)); } - const application = await this.measureInboundStage( - 'application_lookup', - () => this.facade.findInboundApplication(data.account), + const application = await this.measureInboundStage('application_lookup', () => + this.facade.findInboundApplication(data.account), ); if (!application) { throw new BadRequestException('CMPP account is invalid'); } if (data.longMessage) { - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + if ( + data.remoteIp && + !isIpAllowed( + data.remoteIp, + application.ipAllowlist.map((item) => item.ipCidr), + ) + ) { throw new BadRequestException('CMPP source IP is not in application allowlist'); } validateInboundApplicationSrcId(data.srcId, application); - const collection = await this.measureInboundStage( - 'long_message_fragment', - () => this.facade.collectInboundLongMessageFragment(data, application, phoneNumbers), + const collection = await this.measureInboundStage('long_message_fragment', () => + this.facade.collectInboundLongMessageFragment(data, application, phoneNumbers), ); if (collection.response) { return collection.response; @@ -265,15 +295,20 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { } try { if (this.inboundFastPathEnabled()) { - const response = await this.measureInboundStage('inbox_persist', () => ( - this.persistInboundWorkflow({ - ...data, - content: collection.content, - sequenceId: collection.sequenceId, - registeredDelivery: collection.registeredDelivery ? 1 : 0, - longMessage: undefined, - }, phoneNumbers, application, collection.messageId) - )); + const response = await this.measureInboundStage('inbox_persist', () => + this.persistInboundWorkflow( + { + ...data, + content: collection.content, + sequenceId: collection.sequenceId, + registeredDelivery: collection.registeredDelivery ? 1 : 0, + longMessage: undefined, + }, + phoneNumbers, + application, + collection.messageId, + ), + ); await this.prisma.cmppInboundLongMessage.update({ where: { id: collection.groupId }, data: { @@ -284,18 +319,23 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { }); return response; } - const response = await this.measureInboundStage('complete_submit', async () => ( - await this.facade.recoverCompletedInboundLongMessageResponse( - collection.messageId, - phoneNumbers, - ) ?? await this.facade.submitCompleteInboundMessage({ - ...data, - content: collection.content, - sequenceId: collection.sequenceId, - registeredDelivery: collection.registeredDelivery ? 1 : 0, - longMessage: undefined, - }, phoneNumbers, application, collection.messageId) - )); + const response = await this.measureInboundStage( + 'complete_submit', + async () => + (await this.facade.recoverCompletedInboundLongMessageResponse(collection.messageId, phoneNumbers)) ?? + (await this.facade.submitCompleteInboundMessage( + { + ...data, + content: collection.content, + sequenceId: collection.sequenceId, + registeredDelivery: collection.registeredDelivery ? 1 : 0, + longMessage: undefined, + }, + phoneNumbers, + application, + collection.messageId, + )), + ); await this.prisma.cmppInboundLongMessage.update({ where: { id: collection.groupId }, data: { @@ -306,19 +346,20 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { }); return response; } catch (error) { - await this.prisma.cmppInboundLongMessage.update({ - where: { id: collection.groupId }, - data: { - status: 'rejected', - completedAt: new Date(), - }, - }).catch(() => undefined); + await this.prisma.cmppInboundLongMessage + .update({ + where: { id: collection.groupId }, + data: { + status: 'rejected', + completedAt: new Date(), + }, + }) + .catch(() => undefined); throw error; } } - return this.measureInboundStage( - 'complete_submit', - () => this.facade.submitCompleteInboundMessage(data, phoneNumbers, application), + return this.measureInboundStage('complete_submit', () => + this.facade.submitCompleteInboundMessage(data, phoneNumbers, application), ); } @@ -326,27 +367,28 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { return process.env.CMPP_INBOUND_FAST_PATH_ENABLED === 'true'; } - private async persistValidatedInboundWorkflow( - data: GatewayInboundSubmitDto, - phoneNumbers: string[], - ) { + private async persistValidatedInboundWorkflow(data: GatewayInboundSubmitDto, phoneNumbers: string[]) { const requestKey = data.requestId?.trim(); if (!requestKey || requestKey.length > 160) { throw new BadRequestException('CMPP inbound requestId is required for fast-path idempotency'); } const submitGroupMessageId = `MSG-${randomUUID()}`; - const messageIds = phoneNumbers.map((_, index) => index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`); + const messageIds = phoneNumbers.map((_, index) => (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`)); const payload: InboundWorkflowPayload = { data: JSON.parse(JSON.stringify(data)) as GatewayInboundSubmitDto, phoneNumbers, submitGroupMessageId, messageIds, }; - const payloadHash = createHash('sha256').update(JSON.stringify({ - data: payload.data, - phoneNumbers, - requestedGroupMessageId: null, - })).digest('hex'); + const payloadHash = createHash('sha256') + .update( + JSON.stringify({ + data: payload.data, + phoneNumbers, + requestedGroupMessageId: null, + }), + ) + .digest('hex'); const responseMessages = phoneNumbers.map((phoneNumber, index) => ({ phoneNumber, messageId: messageIds[index], @@ -488,12 +530,18 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { if (!requestKey || requestKey.length > 160) { throw new BadRequestException('CMPP inbound requestId is required for fast-path idempotency'); } - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + if ( + data.remoteIp && + !isIpAllowed( + data.remoteIp, + application.ipAllowlist.map((item) => item.ipCidr), + ) + ) { throw new BadRequestException('CMPP source IP is not in application allowlist'); } validateInboundApplicationSrcId(data.srcId, application); const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`; - const messageIds = phoneNumbers.map((_, index) => index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`); + const messageIds = phoneNumbers.map((_, index) => (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`)); const payload: InboundWorkflowPayload = { data: JSON.parse(JSON.stringify(data)) as GatewayInboundSubmitDto, phoneNumbers, @@ -501,11 +549,15 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { messageIds, }; const payloadJson = JSON.parse(JSON.stringify(payload)) as Prisma.InputJsonValue; - const payloadHash = createHash('sha256').update(JSON.stringify({ - data: payload.data, - phoneNumbers, - requestedGroupMessageId: requestedGroupMessageId ?? null, - })).digest('hex'); + const payloadHash = createHash('sha256') + .update( + JSON.stringify({ + data: payload.data, + phoneNumbers, + requestedGroupMessageId: requestedGroupMessageId ?? null, + }), + ) + .digest('hex'); const response = { accepted: true, tenantId: application.tenantId, @@ -546,7 +598,7 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) { } } -async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) { + async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) { const existing = await this.prisma.smsMessageRecord.findMany({ where: { cmppSubmitGroupMessageId: messageId, @@ -591,7 +643,7 @@ async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers }; } -async submitCompleteInboundMessage( + async submitCompleteInboundMessage( data: GatewayInboundSubmitDto, phoneNumbers: string[], application: Awaited>, @@ -605,35 +657,40 @@ async submitCompleteInboundMessage( const precheck = await this.measureInboundStage('submission_precheck', async () => { const persisted = requestedGroupMessageId ? await this.prisma.smsMessageRecord.findMany({ - where: { - cmppSubmitGroupMessageId: requestedGroupMessageId, - phoneNumber: { in: phoneNumbers }, - }, - select: { - id: true, - tenantId: true, - applicationId: true, - batchTaskId: true, - messageId: true, - phoneNumber: true, - status: true, - errorCode: true, - batchTask: { select: { status: true } }, - }, - }) + where: { + cmppSubmitGroupMessageId: requestedGroupMessageId, + phoneNumber: { in: phoneNumbers }, + }, + select: { + id: true, + tenantId: true, + applicationId: true, + batchTaskId: true, + messageId: true, + phoneNumber: true, + status: true, + errorCode: true, + batchTask: { select: { status: true } }, + }, + }) : []; const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item])); - const phoneRejections = await this.facade.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers); - const missingPhoneCount = phoneNumbers.filter((phoneNumber) => ( - !persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber) - )).length; - const dailyQuota = missingPhoneCount > 0 - ? await this.facade.tryReserveDailySendQuota( - application.id, - missingPhoneCount, - workflowKey ? `${workflowKey}:daily-quota` : undefined, - ) - : { reserved: true, dailyLimit: application.dailyLimit ?? 100000 }; + const phoneRejections = await this.facade.classifyRejectedPhones( + application.tenantId, + application.id, + phoneNumbers, + ); + const missingPhoneCount = phoneNumbers.filter( + (phoneNumber) => !persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber), + ).length; + const dailyQuota = + missingPhoneCount > 0 + ? await this.facade.tryReserveDailySendQuota( + application.id, + missingPhoneCount, + workflowKey ? `${workflowKey}:daily-quota` : undefined, + ) + : { reserved: true, dailyLimit: application.dailyLimit ?? 100000 }; return { persistedByPhone, phoneRejections, dailyQuota, missingPhoneCount }; }); const { persistedByPhone, phoneRejections, dailyQuota, missingPhoneCount } = precheck; @@ -649,34 +706,50 @@ async submitCompleteInboundMessage( phoneNumber, persisted: persistedByPhone.get(phoneNumber), receiptRejection: phoneRejections.get(phoneNumber), - messageId: persistedByPhone.get(phoneNumber)?.messageId - ?? requestedMessageIds?.[index] - ?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`), + messageId: + persistedByPhone.get(phoneNumber)?.messageId ?? + requestedMessageIds?.[index] ?? + (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`), workflowItemKey: workflowKey ? `${workflowKey}:message:${index}` : undefined, })); const results: GatewayInboundSingleSubmitResult[] = []; const concurrency = 10; for (let offset = 0; offset < submissions.length; offset += concurrency) { const batch = submissions.slice(offset, offset + concurrency); - results.push(...await Promise.all(batch.map((submission) => submission.persisted - && !(workflowKey && ( - submission.persisted.status === 'validating' - || (submission.persisted.status === 'queued' && submission.persisted.batchTask?.status !== 'queued') - )) - ? Promise.resolve({ - accepted: submission.persisted.errorCode !== 'DAILY_LIMIT', - tenantId: submission.persisted.tenantId ?? application.tenantId, - applicationId: submission.persisted.applicationId ?? application.id, - taskId: submission.persisted.batchTaskId ?? '', - messageId: submission.persisted.messageId, - messageRecordId: submission.persisted.id, - status: submission.persisted.status, - }) - : this.facade.submitInboundSingleMessage({ - ...data, - phoneNumber: submission.phoneNumber, - phoneNumbers: undefined, - }, submission.messageId, submitGroupMessageId, application, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection, submission.workflowItemKey)))); + results.push( + ...(await Promise.all( + batch.map((submission) => + submission.persisted && + !( + workflowKey && + (submission.persisted.status === 'validating' || + (submission.persisted.status === 'queued' && submission.persisted.batchTask?.status !== 'queued')) + ) + ? Promise.resolve({ + accepted: submission.persisted.errorCode !== 'DAILY_LIMIT', + tenantId: submission.persisted.tenantId ?? application.tenantId, + applicationId: submission.persisted.applicationId ?? application.id, + taskId: submission.persisted.batchTaskId ?? '', + messageId: submission.persisted.messageId, + messageRecordId: submission.persisted.id, + status: submission.persisted.status, + }) + : this.facade.submitInboundSingleMessage( + { + ...data, + phoneNumber: submission.phoneNumber, + phoneNumbers: undefined, + }, + submission.messageId, + submitGroupMessageId, + application, + submission.receiptRejection ? undefined : dailyLimitRejection, + submission.receiptRejection, + submission.workflowItemKey, + ), + ), + )), + ); } const first = results[0]; return { @@ -693,33 +766,47 @@ async submitCompleteInboundMessage( }; } -async collectInboundLongMessageFragment( + async collectInboundLongMessageFragment( data: GatewayInboundSubmitDto, application: NonNullable>>, phoneNumbers: string[], ) { const fragment = data.longMessage; - if (!fragment || !Number.isInteger(fragment.reference) || fragment.reference < 0 || fragment.reference > 65535 - || !Number.isInteger(fragment.total) || fragment.total < 2 || fragment.total > 255 - || !Number.isInteger(fragment.index) || fragment.index < 1 || fragment.index > fragment.total - || !Number.isInteger(fragment.format) || fragment.format < 0 || fragment.format > 255) { + if ( + !fragment || + !Number.isInteger(fragment.reference) || + fragment.reference < 0 || + fragment.reference > 65535 || + !Number.isInteger(fragment.total) || + fragment.total < 2 || + fragment.total > 255 || + !Number.isInteger(fragment.index) || + fragment.index < 1 || + fragment.index > fragment.total || + !Number.isInteger(fragment.format) || + fragment.format < 0 || + fragment.format > 255 + ) { throw new BadRequestException('CMPP long message fragment metadata is invalid'); } - const groupKey = createHash('sha256').update(JSON.stringify({ - applicationId: application.id, - account: data.account, - srcId: data.srcId?.trim() ?? '', - phoneNumbers, - reference: fragment.reference, - total: fragment.total, - format: fragment.format, - })).digest('hex'); + const groupKey = createHash('sha256') + .update( + JSON.stringify({ + applicationId: application.id, + account: data.account, + srcId: data.srcId?.trim() ?? '', + phoneNumbers, + reference: fragment.reference, + total: fragment.total, + format: fragment.format, + }), + ) + .digest('hex'); const contentHash = createHash('sha256').update(data.content).digest('hex'); const now = new Date(); - const expiresAt = new Date(now.getTime() + positiveInteger( - process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS, - 300, - ) * 1000); + const expiresAt = new Date( + now.getTime() + positiveInteger(process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS, 300) * 1000, + ); return this.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${groupKey}, 0))`; @@ -741,9 +828,12 @@ async collectInboundLongMessageFragment( orderBy: { createdAt: 'desc' }, }); const matchingRecentSegment = recent?.segments.find((item) => item.segmentIndex === fragment.index); - if (recent && ['completed', 'rejected'].includes(recent.status) - && matchingRecentSegment?.contentHash === contentHash - && matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId))) { + if ( + recent && + ['completed', 'rejected'].includes(recent.status) && + matchingRecentSegment?.contentHash === contentHash && + matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId)) + ) { return { complete: recent.status === 'completed', groupId: recent.id, @@ -776,12 +866,14 @@ async collectInboundLongMessageFragment( }); } if (group.status === 'processing') { - const processingStaleMs = positiveInteger( - process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, - DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, - ) * 1000; - const complete = group.segments.length === fragment.total - && group.segments.every((item, index) => item.segmentIndex === index + 1); + const processingStaleMs = + positiveInteger( + process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, + DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, + ) * 1000; + const complete = + group.segments.length === fragment.total && + group.segments.every((item, index) => item.segmentIndex === index + 1); if (complete && now.getTime() - group.updatedAt.getTime() >= processingStaleMs) { await tx.cmppInboundLongMessage.update({ where: { id: group.id }, @@ -811,10 +903,15 @@ async collectInboundLongMessageFragment( } const existing = group.segments.find((item) => item.segmentIndex === fragment.index); - if (existing && (existing.contentHash !== contentHash - || existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)) - || existing.registeredDelivery !== (data.registeredDelivery !== 0))) { - throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`); + if ( + existing && + (existing.contentHash !== contentHash || + existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)) || + existing.registeredDelivery !== (data.registeredDelivery !== 0)) + ) { + throw new BadRequestException( + `CMPP long message fragment ${fragment.index} conflicts with the stored fragment`, + ); } if (!existing) { await tx.cmppInboundLongMessageSegment.create({ @@ -832,8 +929,8 @@ async collectInboundLongMessageFragment( where: { groupId: group.id }, orderBy: { segmentIndex: 'asc' }, }); - const complete = segments.length === fragment.total - && segments.every((item, index) => item.segmentIndex === index + 1); + const complete = + segments.length === fragment.total && segments.every((item, index) => item.segmentIndex === index + 1); if (complete) { await tx.cmppInboundLongMessage.update({ where: { id: group.id }, @@ -853,7 +950,7 @@ async collectInboundLongMessageFragment( }); } -async expireInboundLongMessages(now = new Date()) { + async expireInboundLongMessages(now = new Date()) { return this.prisma.cmppInboundLongMessage.updateMany({ where: { status: { in: ['collecting', 'processing'] }, @@ -866,7 +963,7 @@ async expireInboundLongMessages(now = new Date()) { }); } -async submitInboundSingleMessage( + async submitInboundSingleMessage( data: GatewayInboundSubmitDto & { phoneNumber: string }, messageId: string, submitGroupMessageId: string, @@ -876,15 +973,20 @@ async submitInboundSingleMessage( workflowItemKey?: string, ) { // 入口已按账号取得并校验同一个应用快照;复用它可避免每个目标号码再次查询应用、企业和IP白名单。 - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + if ( + data.remoteIp && + !isIpAllowed( + data.remoteIp, + application.ipAllowlist.map((item) => item.ipCidr), + ) + ) { throw new BadRequestException('CMPP source IP is not in application allowlist'); } const clientSrcId = validateInboundApplicationSrcId(data.srcId, application); - const template = await this.measureInboundStage( - 'template_match', - () => this.facade.resolveInboundTemplateCandidate(application.id, data.content), + const template = await this.measureInboundStage('template_match', () => + this.facade.resolveInboundTemplateCandidate(application.id, data.content), ); - const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {}; + const templateVariables = template ? (matchTemplateContent(template.content, data.content) ?? {}) : {}; const unitPrice = moneyToNumber(application.customerUnitPrice); const queuePriority = normalizeQueuePriority(application.queuePriority); const billing = this.billing.estimateSmsCost({ @@ -894,9 +996,8 @@ async submitInboundSingleMessage( phoneCount: 1, unitPrice, }); - const drainageDetection = await this.measureInboundStage( - 'content_detection', - () => detectDrainageContent(this.prisma, data.content), + const drainageDetection = await this.measureInboundStage('content_detection', () => + detectDrainageContent(this.prisma, data.content), ); const workflowDigest = workflowItemKey ? createHash('sha256').update(workflowItemKey).digest('hex').slice(0, 32) @@ -904,67 +1005,74 @@ async submitInboundSingleMessage( let recoveredExisting = false; let persisted; try { - persisted = await this.measureInboundStage('message_persist', () => this.prisma.$transaction(async (tx) => { - const task = await tx.smsBatchTask.create({ - data: { - tenantId: application.tenantId, - applicationId: application.id, - templateId: template?.id, - taskNo: workflowDigest ? `BT-IN-${workflowDigest}` : `BT-${Date.now()}-${randomUUID().slice(0, 8)}`, - sourceType: 'cmpp', - content: data.content, - phoneTotal: 1, - status: synchronousRejection ? 'rejected' : 'validating', - auditStatus: synchronousRejection ? 'rejected' : undefined, - rejectReason: synchronousRejection?.reason, - progressTotal: 1, - }, - }); - await tx.smsApiRequest.create({ - data: { - tenantId: application.tenantId, - batchTaskId: task.id, - requestId: workflowDigest ? `REQ-IN-${workflowDigest}` : `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`, - sourceIp: data.remoteIp, - userAgent: 'cmpp-gateway', - payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account }, - status: synchronousRejection ? 'rejected' : 'accepted', - }, - }); - const message = await tx.smsMessageRecord.create({ - data: { - tenantId: application.tenantId, - batchTaskId: task.id, - applicationId: application.id, - templateId: template?.id, - messageId, - phoneNumber: data.phoneNumber, - content: data.content, - ...drainageDetection, - billingUnits: billing.billingUnitsPerMessage, - unitPrice: receiptRejection ? 0 : billing.unitPrice, - amountCents: receiptRejection ? 0 : billing.amountCents, - queuePriority, - cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), - cmppSubmitGroupMessageId: submitGroupMessageId, - cmppRegisteredDelivery: data.registeredDelivery !== 0, - clientSrcId, - applicationExtension: application.cmppApplicationExtension, - status: synchronousRejection ? 'rejected' : 'validating', - errorCode: synchronousRejection?.code, - errorMessage: synchronousRejection?.reason, - }, - }); - return { task, message }; - })); + persisted = await this.measureInboundStage('message_persist', () => + this.prisma.$transaction(async (tx) => { + const task = await tx.smsBatchTask.create({ + data: { + tenantId: application.tenantId, + applicationId: application.id, + templateId: template?.id, + taskNo: workflowDigest ? `BT-IN-${workflowDigest}` : `BT-${Date.now()}-${randomUUID().slice(0, 8)}`, + sourceType: 'cmpp', + content: data.content, + phoneTotal: 1, + status: synchronousRejection ? 'rejected' : 'validating', + auditStatus: synchronousRejection ? 'rejected' : undefined, + rejectReason: synchronousRejection?.reason, + progressTotal: 1, + }, + }); + await tx.smsApiRequest.create({ + data: { + tenantId: application.tenantId, + batchTaskId: task.id, + requestId: workflowDigest ? `REQ-IN-${workflowDigest}` : `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`, + sourceIp: data.remoteIp, + userAgent: 'cmpp-gateway', + payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account }, + status: synchronousRejection ? 'rejected' : 'accepted', + }, + }); + const message = await tx.smsMessageRecord.create({ + data: { + tenantId: application.tenantId, + batchTaskId: task.id, + applicationId: application.id, + templateId: template?.id, + messageId, + phoneNumber: data.phoneNumber, + content: data.content, + ...drainageDetection, + billingUnits: billing.billingUnitsPerMessage, + unitPrice: receiptRejection ? 0 : billing.unitPrice, + amountCents: receiptRejection ? 0 : billing.amountCents, + queuePriority, + cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), + cmppSubmitGroupMessageId: submitGroupMessageId, + cmppRegisteredDelivery: data.registeredDelivery !== 0, + clientSrcId, + applicationExtension: application.cmppApplicationExtension, + status: synchronousRejection ? 'rejected' : 'validating', + errorCode: synchronousRejection?.code, + errorMessage: synchronousRejection?.reason, + }, + }); + return { task, message }; + }), + ); } catch (error) { - if (!workflowItemKey || !(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error; + if (!workflowItemKey || !(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') + throw error; const existing = await this.prisma.smsMessageRecord.findUnique({ where: { messageId }, include: { batchTask: true }, }); - if (!existing?.batchTask || existing.cmppSubmitGroupMessageId !== submitGroupMessageId - || existing.phoneNumber !== data.phoneNumber || existing.applicationId !== application.id) { + if ( + !existing?.batchTask || + existing.cmppSubmitGroupMessageId !== submitGroupMessageId || + existing.phoneNumber !== data.phoneNumber || + existing.applicationId !== application.id + ) { throw error; } recoveredExisting = true; @@ -1012,15 +1120,18 @@ async submitInboundSingleMessage( const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => { const { drainageInfoId, risk } = await this.measureInboundStage('risk_frequency', async () => { const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content); - const evaluatedRisk = await this.facade.evaluateRiskWithPhoneFrequency({ - tenantId: application.tenantId, - applicationId: application.id, - templateId: options.templateId, - content: data.content, - variables: options.templateId ? templateVariables : undefined, - phoneNumber: data.phoneNumber, - sourceType: 'cmpp', - }, workflowItemKey ? `${workflowItemKey}:frequency` : undefined); + const evaluatedRisk = await this.facade.evaluateRiskWithPhoneFrequency( + { + tenantId: application.tenantId, + applicationId: application.id, + templateId: options.templateId, + content: data.content, + variables: options.templateId ? templateVariables : undefined, + phoneNumber: data.phoneNumber, + sourceType: 'cmpp', + }, + workflowItemKey ? `${workflowItemKey}:frequency` : undefined, + ); return { drainageInfoId: drainage?.id, risk: evaluatedRisk }; }); if (risk.status === 'rejected') { @@ -1039,7 +1150,12 @@ async submitInboundSingleMessage( }); await this.prisma.smsBatchTask.update({ where: { id: task.id }, - data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason }, + data: { + status: 'pending_review', + riskTaskId: risk.task?.id, + auditStatus: 'pending', + reviewReason: risk.reason, + }, }); return; } @@ -1093,13 +1209,16 @@ async submitInboundSingleMessage( await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); } else { const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content); - const risk = await this.facade.evaluateRiskWithPhoneFrequency({ - tenantId: application.tenantId, - applicationId: application.id, - content: data.content, - phoneNumber: data.phoneNumber, - sourceType: 'cmpp', - }, workflowItemKey ? `${workflowItemKey}:frequency` : undefined); + const risk = await this.facade.evaluateRiskWithPhoneFrequency( + { + tenantId: application.tenantId, + applicationId: application.id, + content: data.content, + phoneNumber: data.phoneNumber, + sourceType: 'cmpp', + }, + workflowItemKey ? `${workflowItemKey}:frequency` : undefined, + ); if (risk.status === 'rejected') { await reject('RISK', risk.reason || '短信被风控拒绝'); } else { @@ -1120,16 +1239,17 @@ async submitInboundSingleMessage( idempotencyKey: workflowItemKey ? `${workflowItemKey}:freeze` : undefined, }); } - const reviewTask = risk.status === 'pending_review' && risk.task - ? await this.facade.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id) - : await this.riskReview.aggregateTemplateMismatch({ - tenantId: application.tenantId, - applicationId: application.id, - account: data.account, - messageRecordId: message.id, - signatureId: signature.id, - content: data.content, - }); + const reviewTask = + risk.status === 'pending_review' && risk.task + ? await this.facade.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id) + : await this.riskReview.aggregateTemplateMismatch({ + tenantId: application.tenantId, + applicationId: application.id, + account: data.account, + messageRecordId: message.id, + signatureId: signature.id, + content: data.content, + }); await this.prisma.smsBatchTask.update({ where: { id: task.id }, data: { @@ -1169,15 +1289,18 @@ async submitInboundSingleMessage( }; } -async evaluateRiskWithPhoneFrequency(input: { - tenantId: string; - applicationId: string; - templateId?: string; - content: string; - variables?: Record; - phoneNumber: string; - sourceType: 'cmpp'; - }, reservationKey?: string) { + async evaluateRiskWithPhoneFrequency( + input: { + tenantId: string; + applicationId: string; + templateId?: string; + content: string; + variables?: Record; + phoneNumber: string; + sourceType: 'cmpp'; + }, + reservationKey?: string, + ) { const risk = await this.riskReview.evaluateTask({ tenantId: input.tenantId, applicationId: input.applicationId, @@ -1197,12 +1320,10 @@ async evaluateRiskWithPhoneFrequency(input: { reservationKey, ); const rejection = frequencyRejections.get(input.phoneNumber); - return rejection - ? { ...risk, status: 'rejected' as const, reason: rejection.reason } - : risk; + return rejection ? { ...risk, status: 'rejected' as const, reason: rejection.reason } : risk; } -startInboundWorkflowWorker() { + startInboundWorkflowWorker() { if (this.inboundWorkflowTimer || this.inboundWorkflowPumping || this.inboundWorkflowTasks.size > 0) { return { status: 'already_started' }; } @@ -1238,13 +1359,16 @@ startInboundWorkflowWorker() { await this.refreshInboundWorkflowMetrics(); const activeTenantIds = [...this.inboundWorkflowActiveTenants]; await this.waitForInboundWorkflowMicroBatch(available, activeTenantIds); - const claimed = await this.measureInboundStage('worker_claim', () => this.claimInboundWorkflows(available, activeTenantIds)); - const applications = claimed.length === 0 - ? [] - : await this.prisma.smsApplication.findMany({ - where: { id: { in: [...new Set(claimed.map((item) => item.applicationId))] } }, - include: { tenant: true, ipAllowlist: true }, - }); + const claimed = await this.measureInboundStage('worker_claim', () => + this.claimInboundWorkflows(available, activeTenantIds), + ); + const applications = + claimed.length === 0 + ? [] + : await this.prisma.smsApplication.findMany({ + where: { id: { in: [...new Set(claimed.map((item) => item.applicationId))] } }, + include: { tenant: true, ipAllowlist: true }, + }); const applicationById = new Map(applications.map((application) => [application.id, application])); const batchSize = positiveInteger(process.env.API_INBOUND_WORKFLOW_BATCH_SIZE, 64); for (const [tenantId, tenantItems] of this.groupInboundWorkflowsByTenant(claimed)) { @@ -1309,9 +1433,10 @@ startInboundWorkflowWorker() { positiveInteger(process.env.API_INBOUND_WORKFLOW_TARGET_BATCH_SIZE, 32), ); if (targetBatchSize < 2) return; - const excludedFilter = excludedTenantIds.length === 0 - ? Prisma.empty - : Prisma.sql`AND "tenantId" NOT IN (${Prisma.join(excludedTenantIds)})`; + const excludedFilter = + excludedTenantIds.length === 0 + ? Prisma.empty + : Prisma.sql`AND "tenantId" NOT IN (${Prisma.join(excludedTenantIds)})`; const rows = await this.prisma.$queryRaw>(Prisma.sql` SELECT COUNT(*)::bigint AS count FROM "CmppInboundSubmissionInbox" @@ -1325,9 +1450,10 @@ startInboundWorkflowWorker() { private claimInboundWorkflows(limit: number, excludedTenantIds: string[] = []) { const staleSeconds = positiveInteger(process.env.API_INBOUND_WORKFLOW_STALE_SECONDS, 300); - const excludedFilter = excludedTenantIds.length === 0 - ? Prisma.empty - : Prisma.sql`AND "tenantId" NOT IN (${Prisma.join(excludedTenantIds)})`; + const excludedFilter = + excludedTenantIds.length === 0 + ? Prisma.empty + : Prisma.sql`AND "tenantId" NOT IN (${Prisma.join(excludedTenantIds)})`; return this.prisma.$queryRaw(Prisma.sql` WITH candidates AS ( SELECT id @@ -1366,7 +1492,9 @@ startInboundWorkflowWorker() { applicationById: Map, ) { if (process.env.API_INBOUND_WORKFLOW_BATCH_ENABLED === 'false' || items.length < 2) { - await Promise.all(items.map((item) => this.processClaimedInboundWorkflow(item, applicationById.get(item.applicationId)))); + await Promise.all( + items.map((item) => this.processClaimedInboundWorkflow(item, applicationById.get(item.applicationId))), + ); return; } let completed = new Set(); @@ -1379,9 +1507,11 @@ startInboundWorkflowWorker() { // than duplicates the individual items. this.logger.warn(`CMPP inbound common batch fell back to individual recovery: ${String(error)}`); } - await Promise.all(items - .filter((item) => !completed.has(item.id)) - .map((item) => this.processClaimedInboundWorkflow(item, applicationById.get(item.applicationId)))); + await Promise.all( + items + .filter((item) => !completed.has(item.id)) + .map((item) => this.processClaimedInboundWorkflow(item, applicationById.get(item.applicationId))), + ); } private async processCommonInboundWorkflowBatch( @@ -1402,29 +1532,47 @@ startInboundWorkflowWorker() { const phones = [...new Set(parsed.map((entry) => entry.payload.phoneNumbers[0]))]; const messageIds = parsed.flatMap((entry) => entry.payload.messageIds); const tenantIds = [...new Set(parsed.map((entry) => entry.application.tenantId))]; - const [templates, signatures, globalBlacklist, enterpriseBlacklist, persisted] = await this.measureInboundStage('reference_preload', () => Promise.all([ - this.prisma.smsTemplate.findMany({ - where: { applicationId: { in: applicationIds }, auditStatus: 'approved', signature: { auditStatus: 'approved' } }, - include: { signature: true }, - orderBy: { updatedAt: 'desc' }, - }), - this.prisma.smsSignature.findMany({ - where: { applicationId: { in: applicationIds }, auditStatus: 'approved' }, - select: { id: true, applicationId: true, name: true, updatedAt: true }, - orderBy: { updatedAt: 'desc' }, - }), - this.prisma.globalBlacklist.findMany({ - where: { phoneNumber: { in: phones }, status: 'active' }, - select: { phoneNumber: true }, - }), - this.prisma.enterpriseBlacklist.findMany({ - where: { tenantId: { in: tenantIds }, applicationId: { in: applicationIds }, phoneNumber: { in: phones }, status: 'active' }, - select: { tenantId: true, applicationId: true, phoneNumber: true }, - }), - this.prisma.smsMessageRecord.findMany({ where: { messageId: { in: messageIds } }, select: { messageId: true } }), - ])); + const [templates, signatures, globalBlacklist, enterpriseBlacklist, persisted] = await this.measureInboundStage( + 'reference_preload', + () => + Promise.all([ + this.prisma.smsTemplate.findMany({ + where: { + applicationId: { in: applicationIds }, + auditStatus: 'approved', + signature: { auditStatus: 'approved' }, + }, + include: { signature: true }, + orderBy: { updatedAt: 'desc' }, + }), + this.prisma.smsSignature.findMany({ + where: { applicationId: { in: applicationIds }, auditStatus: 'approved' }, + select: { id: true, applicationId: true, name: true, updatedAt: true }, + orderBy: { updatedAt: 'desc' }, + }), + this.prisma.globalBlacklist.findMany({ + where: { phoneNumber: { in: phones }, status: 'active' }, + select: { phoneNumber: true }, + }), + this.prisma.enterpriseBlacklist.findMany({ + where: { + tenantId: { in: tenantIds }, + applicationId: { in: applicationIds }, + phoneNumber: { in: phones }, + status: 'active', + }, + select: { tenantId: true, applicationId: true, phoneNumber: true }, + }), + this.prisma.smsMessageRecord.findMany({ + where: { messageId: { in: messageIds } }, + select: { messageId: true }, + }), + ]), + ); const globalRejected = new Set(globalBlacklist.map((entry) => entry.phoneNumber)); - const enterpriseRejected = new Set(enterpriseBlacklist.map((entry) => `${entry.tenantId}:${entry.applicationId}:${entry.phoneNumber}`)); + const enterpriseRejected = new Set( + enterpriseBlacklist.map((entry) => `${entry.tenantId}:${entry.applicationId}:${entry.phoneNumber}`), + ); const persistedIds = new Set(persisted.map((entry) => entry.messageId)); const templatesByApplication = new Map(); for (const template of templates) { @@ -1444,30 +1592,52 @@ startInboundWorkflowWorker() { const { item, payload, application } = entry; const data = payload.data; const phoneNumber = payload.phoneNumbers[0]; - if (persistedIds.has(payload.messageIds[0]) - || application.cmppAccount !== data.account - || application.status !== 'active' - || application.tenant.status !== 'active' - || !application.interfaceEnabled - || application.tenant.certificationStatus !== 'approved' - || !/^1\d{10}$/.test(phoneNumber) - || globalRejected.has(phoneNumber) - || enterpriseRejected.has(`${application.tenantId}:${application.id}:${phoneNumber}`)) continue; + if ( + persistedIds.has(payload.messageIds[0]) || + application.cmppAccount !== data.account || + application.status !== 'active' || + application.tenant.status !== 'active' || + !application.interfaceEnabled || + application.tenant.certificationStatus !== 'approved' || + !/^1\d{10}$/.test(phoneNumber) || + globalRejected.has(phoneNumber) || + enterpriseRejected.has(`${application.tenantId}:${application.id}:${phoneNumber}`) + ) + continue; try { - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((allowlist) => allowlist.ipCidr))) continue; + if ( + data.remoteIp && + !isIpAllowed( + data.remoteIp, + application.ipAllowlist.map((allowlist) => allowlist.ipCidr), + ) + ) + continue; const clientSrcId = validateInboundApplicationSrcId(data.srcId, application); - const template = (templatesByApplication.get(application.id) ?? []).find((candidate) => ( - candidate.content === data.content || matchTemplateContent(candidate.content, data.content) !== null - )); - const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : undefined; + const template = (templatesByApplication.get(application.id) ?? []).find( + (candidate) => + candidate.content === data.content || matchTemplateContent(candidate.content, data.content) !== null, + ); + const templateVariables = template ? (matchTemplateContent(template.content, data.content) ?? {}) : undefined; let signatureId = template?.signature?.id; if (!signatureId && application.templateMismatchMode === 'direct_send') { const signatureName = data.content.match(/^【[^】]+】/)?.[0]; - signatureId = (signaturesByApplication.get(application.id) ?? []).find((signature) => signature.name === signatureName)?.id; + signatureId = (signaturesByApplication.get(application.id) ?? []).find( + (signature) => signature.name === signatureName, + )?.id; } if (!signatureId || (!template && application.templateMismatchMode !== 'direct_send')) continue; const finalSignatureId = signatureId; - candidates.push({ item, payload, application, phoneNumber, template, signatureId: finalSignatureId, templateVariables, clientSrcId }); + candidates.push({ + item, + payload, + application, + phoneNumber, + template, + signatureId: finalSignatureId, + templateVariables, + clientSrcId, + }); } catch { // Invalid source IDs and other business rejections stay on the established // individual path so their exact failure receipt remains unchanged. @@ -1478,38 +1648,57 @@ startInboundWorkflowWorker() { const quota = await this.measureInboundStage('daily_quota', () => this.reserveDailyQuotaBatch(candidates)); const quotaApproved = candidates.filter((candidate) => quota.get(candidate.item.requestKey)?.reserved); if (quotaApproved.length < 2) return new Set(); - const riskResults = await this.measureInboundStage('risk_frequency', () => this.riskReview.evaluateTasksBatch(quotaApproved.map((candidate) => ({ - tenantId: candidate.application.tenantId, - applicationId: candidate.application.id, - templateId: candidate.template?.id, - content: candidate.payload.data.content, - variables: candidate.templateVariables, - phones: [candidate.phoneNumber], - sourceType: 'cmpp' as const, - })))); + const riskResults = await this.measureInboundStage('risk_frequency', () => + this.riskReview.evaluateTasksBatch( + quotaApproved.map((candidate) => ({ + tenantId: candidate.application.tenantId, + applicationId: candidate.application.id, + templateId: candidate.template?.id, + content: candidate.payload.data.content, + variables: candidate.templateVariables, + phones: [candidate.phoneNumber], + sourceType: 'cmpp' as const, + })), + ), + ); const riskApproved = quotaApproved.filter((_, index) => riskResults[index]?.status === 'approved'); if (riskApproved.length < 2) return new Set(); - const frequency = await this.measureInboundStage('risk_frequency', () => this.phoneFrequency.reserveBatch(riskApproved.map((candidate) => ({ - tenantId: candidate.application.tenantId, - applicationId: candidate.application.id, - phoneNumber: candidate.phoneNumber, - sourceType: 'cmpp', - reservationKey: `${candidate.item.requestKey}:message:0:frequency`, - })))); - const approved = riskApproved.filter((candidate) => ( - (frequency.get(`${candidate.item.requestKey}:message:0:frequency`)?.size ?? 0) === 0 - )); + const frequency = await this.measureInboundStage('risk_frequency', () => + this.phoneFrequency.reserveBatch( + riskApproved.map((candidate) => ({ + tenantId: candidate.application.tenantId, + applicationId: candidate.application.id, + phoneNumber: candidate.phoneNumber, + sourceType: 'cmpp', + reservationKey: `${candidate.item.requestKey}:message:0:frequency`, + })), + ), + ); + const approved = riskApproved.filter( + (candidate) => (frequency.get(`${candidate.item.requestKey}:message:0:frequency`)?.size ?? 0) === 0, + ); if (approved.length < 2) return new Set(); const drainageRows = await this.prisma.smsDrainageInfo.findMany({ - where: { signatureId: { in: [...new Set(approved.map((candidate) => candidate.signatureId))] }, auditStatus: { not: 'deleted' } }, + where: { + signatureId: { in: [...new Set(approved.map((candidate) => candidate.signatureId))] }, + auditStatus: { not: 'deleted' }, + }, select: { id: true, signatureId: true, url: true, updatedAt: true }, orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], }); const finalCandidates = approved.filter((candidate) => { const matches = drainageRows - .filter((row) => row.signatureId === candidate.signatureId && row.url.trim() && candidate.payload.data.content.includes(row.url.trim())) - .sort((left, right) => right.url.trim().length - left.url.trim().length || right.updatedAt.getTime() - left.updatedAt.getTime()); + .filter( + (row) => + row.signatureId === candidate.signatureId && + row.url.trim() && + candidate.payload.data.content.includes(row.url.trim()), + ) + .sort( + (left, right) => + right.url.trim().length - left.url.trim().length || right.updatedAt.getTime() - left.updatedAt.getTime(), + ); if (matches.length > 1 && matches[0].url.trim().length === matches[1].url.trim().length) return false; candidate.drainageInfoId = matches[0]?.id; return true; @@ -1521,13 +1710,21 @@ startInboundWorkflowWorker() { private async reserveDailyQuotaBatch(candidates: InboundBatchCandidate[]) { const usageDate = shanghaiDateKey(); const usageDateValue = new Date(`${usageDate}T00:00:00.000Z`); - return this.prisma.$transaction(async (tx) => { - const applicationIds = [...new Set(candidates.map((candidate) => candidate.application.id))].sort(); - await tx.smsApplicationDailyUsage.createMany({ - data: applicationIds.map((applicationId) => ({ id: randomUUID(), applicationId, usageDate: usageDateValue, usedCount: 0 })), - skipDuplicates: true, - }); - const usage = await tx.$queryRaw>(Prisma.sql` + return this.prisma.$transaction( + async (tx) => { + const applicationIds = [...new Set(candidates.map((candidate) => candidate.application.id))].sort(); + await tx.smsApplicationDailyUsage.createMany({ + data: applicationIds.map((applicationId) => ({ + id: randomUUID(), + applicationId, + usageDate: usageDateValue, + usedCount: 0, + })), + skipDuplicates: true, + }); + const usage = await tx.$queryRaw< + Array<{ applicationId: string; tenantId: string; dailyLimit: number; usedCount: number }> + >(Prisma.sql` SELECT usage."applicationId", application."tenantId", COALESCE(application."dailyLimit", 100000)::integer AS "dailyLimit", usage."usedCount" @@ -1538,169 +1735,208 @@ startInboundWorkflowWorker() { ORDER BY usage."applicationId" FOR UPDATE OF usage `); - const state = new Map(usage.map((row) => [row.applicationId, { ...row }])); - const keys = candidates.map((candidate) => `${candidate.item.requestKey}:daily-quota`); - const existing = await tx.smsApplicationDailyReservation.findMany({ where: { reservationKey: { in: keys } } }); - const existingByKey = new Map(existing.map((row) => [row.reservationKey, row])); - const output = new Map(); - const inserts: Prisma.SmsApplicationDailyReservationCreateManyInput[] = []; - for (const candidate of candidates) { - const reservationKey = `${candidate.item.requestKey}:daily-quota`; - const replay = existingByKey.get(reservationKey); - if (replay) { - if (replay.applicationId !== candidate.application.id || replay.requestedCount !== 1) { - throw new Error('CMPP daily quota idempotency key conflicts with another reservation'); + const state = new Map(usage.map((row) => [row.applicationId, { ...row }])); + const keys = candidates.map((candidate) => `${candidate.item.requestKey}:daily-quota`); + const existing = await tx.smsApplicationDailyReservation.findMany({ where: { reservationKey: { in: keys } } }); + const existingByKey = new Map(existing.map((row) => [row.reservationKey, row])); + const output = new Map(); + const inserts: Prisma.SmsApplicationDailyReservationCreateManyInput[] = []; + for (const candidate of candidates) { + const reservationKey = `${candidate.item.requestKey}:daily-quota`; + const replay = existingByKey.get(reservationKey); + if (replay) { + if (replay.applicationId !== candidate.application.id || replay.requestedCount !== 1) { + throw new Error('CMPP daily quota idempotency key conflicts with another reservation'); + } + output.set(candidate.item.requestKey, { + reserved: replay.reserved, + dailyLimit: replay.dailyLimit, + usedCount: replay.usedCount, + }); + continue; } - output.set(candidate.item.requestKey, { reserved: replay.reserved, dailyLimit: replay.dailyLimit, usedCount: replay.usedCount }); - continue; + const current = state.get(candidate.application.id); + if (!current) throw new Error(`CMPP daily quota application ${candidate.application.id} disappeared`); + const reserved = current.usedCount + 1 <= current.dailyLimit; + if (reserved) current.usedCount += 1; + inserts.push({ + id: randomUUID(), + reservationKey, + tenantId: candidate.application.tenantId, + applicationId: candidate.application.id, + usageDate: usageDateValue, + requestedCount: 1, + dailyLimit: current.dailyLimit, + usedCount: reserved ? current.usedCount : null, + reserved, + }); + output.set(candidate.item.requestKey, { + reserved, + dailyLimit: current.dailyLimit, + usedCount: reserved ? current.usedCount : null, + }); } - const current = state.get(candidate.application.id); - if (!current) throw new Error(`CMPP daily quota application ${candidate.application.id} disappeared`); - const reserved = current.usedCount + 1 <= current.dailyLimit; - if (reserved) current.usedCount += 1; - inserts.push({ - id: randomUUID(), reservationKey, tenantId: candidate.application.tenantId, - applicationId: candidate.application.id, usageDate: usageDateValue, requestedCount: 1, - dailyLimit: current.dailyLimit, usedCount: reserved ? current.usedCount : null, reserved, - }); - output.set(candidate.item.requestKey, { reserved, dailyLimit: current.dailyLimit, usedCount: reserved ? current.usedCount : null }); - } - for (const row of state.values()) { - await tx.smsApplicationDailyUsage.update({ - where: { applicationId_usageDate: { applicationId: row.applicationId, usageDate: usageDateValue } }, - data: { usedCount: row.usedCount }, - }); - } - if (inserts.length) await tx.smsApplicationDailyReservation.createMany({ data: inserts }); - return output; - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + for (const row of state.values()) { + await tx.smsApplicationDailyUsage.update({ + where: { applicationId_usageDate: { applicationId: row.applicationId, usageDate: usageDateValue } }, + data: { usedCount: row.usedCount }, + }); + } + if (inserts.length) await tx.smsApplicationDailyReservation.createMany({ data: inserts }); + return output; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }, + ); } private async persistCommonInboundWorkflowBatch(candidates: InboundBatchCandidate[]) { - const prepared = await Promise.all(candidates.map(async (candidate) => { - const workflowDigest = createHash('sha256').update(`${candidate.item.requestKey}:message:0`).digest('hex').slice(0, 32); - const taskId = randomUUID(); - const messageRecordId = randomUUID(); - const content = candidate.payload.data.content; - const drainageDetection = await detectDrainageContent(this.prisma, content); - const billing = this.billing.estimateSmsCost({ - tenantId: candidate.application.tenantId, - applicationId: candidate.application.id, - content, - phoneCount: 1, - unitPrice: moneyToNumber(candidate.application.customerUnitPrice), - }); - return { candidate, workflowDigest, taskId, messageRecordId, content, drainageDetection, billing }; - })); - await this.measureInboundStage('message_persist', () => this.prisma.$transaction(async (tx) => { - await tx.smsBatchTask.createMany({ - data: prepared.map(({ candidate, workflowDigest, taskId, content }) => ({ - id: taskId, + const prepared = await Promise.all( + candidates.map(async (candidate) => { + const workflowDigest = createHash('sha256') + .update(`${candidate.item.requestKey}:message:0`) + .digest('hex') + .slice(0, 32); + const taskId = randomUUID(); + const messageRecordId = randomUUID(); + const content = candidate.payload.data.content; + const drainageDetection = await detectDrainageContent(this.prisma, content); + const billing = this.billing.estimateSmsCost({ tenantId: candidate.application.tenantId, applicationId: candidate.application.id, - templateId: candidate.template?.id, - taskNo: `BT-IN-${workflowDigest}`, - sourceType: 'cmpp', content, - phoneTotal: 1, - status: 'ready', - auditStatus: 'approved', - progressTotal: 1, - })), - }); - await tx.smsApiRequest.createMany({ - data: prepared.map(({ candidate, workflowDigest, taskId, content }) => ({ - id: randomUUID(), tenantId: candidate.application.tenantId, batchTaskId: taskId, - requestId: `REQ-IN-${workflowDigest}`, sourceIp: candidate.payload.data.remoteIp, - userAgent: 'cmpp-gateway', payloadSummary: { phoneTotal: 1, contentLength: [...content].length, account: candidate.payload.data.account }, - status: 'accepted', - })), - }); - await tx.smsMessageRecord.createMany({ - data: prepared.map(({ candidate, taskId, messageRecordId, content, drainageDetection, billing }) => ({ - id: messageRecordId, - tenantId: candidate.application.tenantId, - batchTaskId: taskId, - applicationId: candidate.application.id, - templateId: candidate.template?.id, - signatureId: candidate.signatureId, - drainageInfoId: candidate.drainageInfoId, - messageId: candidate.payload.messageIds[0], - phoneNumber: candidate.phoneNumber, - content, - ...drainageDetection, - billingUnits: billing.billingUnitsPerMessage, - unitPrice: billing.unitPrice, - amountCents: billing.amountCents, - queuePriority: normalizeQueuePriority(candidate.application.queuePriority), - cmppSubmitSequenceId: candidate.payload.data.sequenceId == null ? null : String(candidate.payload.data.sequenceId), - cmppSubmitGroupMessageId: candidate.payload.submitGroupMessageId, - cmppRegisteredDelivery: candidate.payload.data.registeredDelivery !== 0, - clientSrcId: candidate.clientSrcId, - applicationExtension: candidate.application.cmppApplicationExtension, - status: 'queued', - })), - }); + phoneCount: 1, + unitPrice: moneyToNumber(candidate.application.customerUnitPrice), + }); + return { candidate, workflowDigest, taskId, messageRecordId, content, drainageDetection, billing }; + }), + ); + await this.measureInboundStage('message_persist', () => + this.prisma.$transaction(async (tx) => { + await tx.smsBatchTask.createMany({ + data: prepared.map(({ candidate, workflowDigest, taskId, content }) => ({ + id: taskId, + tenantId: candidate.application.tenantId, + applicationId: candidate.application.id, + templateId: candidate.template?.id, + taskNo: `BT-IN-${workflowDigest}`, + sourceType: 'cmpp', + content, + phoneTotal: 1, + status: 'ready', + auditStatus: 'approved', + progressTotal: 1, + })), + }); + await tx.smsApiRequest.createMany({ + data: prepared.map(({ candidate, workflowDigest, taskId, content }) => ({ + id: randomUUID(), + tenantId: candidate.application.tenantId, + batchTaskId: taskId, + requestId: `REQ-IN-${workflowDigest}`, + sourceIp: candidate.payload.data.remoteIp, + userAgent: 'cmpp-gateway', + payloadSummary: { + phoneTotal: 1, + contentLength: [...content].length, + account: candidate.payload.data.account, + }, + status: 'accepted', + })), + }); + await tx.smsMessageRecord.createMany({ + data: prepared.map(({ candidate, taskId, messageRecordId, content, drainageDetection, billing }) => ({ + id: messageRecordId, + tenantId: candidate.application.tenantId, + batchTaskId: taskId, + applicationId: candidate.application.id, + templateId: candidate.template?.id, + signatureId: candidate.signatureId, + drainageInfoId: candidate.drainageInfoId, + messageId: candidate.payload.messageIds[0], + phoneNumber: candidate.phoneNumber, + content, + ...drainageDetection, + billingUnits: billing.billingUnitsPerMessage, + unitPrice: billing.unitPrice, + amountCents: billing.amountCents, + queuePriority: normalizeQueuePriority(candidate.application.queuePriority), + cmppSubmitSequenceId: + candidate.payload.data.sequenceId == null ? null : String(candidate.payload.data.sequenceId), + cmppSubmitGroupMessageId: candidate.payload.submitGroupMessageId, + cmppRegisteredDelivery: candidate.payload.data.registeredDelivery !== 0, + clientSrcId: candidate.clientSrcId, + applicationExtension: candidate.application.cmppApplicationExtension, + status: 'queued', + })), + }); - // Acquire the tenant account lock only after the independent workflow rows - // have been staged. PostgreSQL holds transaction-scoped locks until commit; - // keeping this section last preserves the all-or-nothing boundary while - // avoiding serialization across task/API/message persistence for one tenant. - // Per-message idempotency rows remain separate for retry/release/charge repair. - const tenantIds = [...new Set(prepared.map(({ candidate }) => candidate.application.tenantId))].sort(); - for (const tenantId of tenantIds) { - const paid = prepared.filter(({ candidate, billing }) => candidate.application.tenantId === tenantId && billing.amountCents > 0); - const totalAmount = paid.reduce((sum, entry) => sum + entry.billing.amountCents, 0); - if (totalAmount === 0) continue; - await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + tenantId}, 0))`; - await tx.tenantAccount.upsert({ - where: { tenantId }, update: {}, - create: { tenantId, balanceCents: 0, creditCents: 0, status: 'active' }, - }); - const account = await tx.tenantAccount.findUniqueOrThrow({ where: { tenantId } }); - const available = moneyToNumber(account.balanceCents) + moneyToNumber(account.creditCents); - if (account.status !== 'active' || available < totalAmount) { - throw new BadRequestException('企业账户余额不足'); + // Acquire the tenant account lock only after the independent workflow rows + // have been staged. PostgreSQL holds transaction-scoped locks until commit; + // keeping this section last preserves the all-or-nothing boundary while + // avoiding serialization across task/API/message persistence for one tenant. + // Per-message idempotency rows remain separate for retry/release/charge repair. + const tenantIds = [...new Set(prepared.map(({ candidate }) => candidate.application.tenantId))].sort(); + for (const tenantId of tenantIds) { + const paid = prepared.filter( + ({ candidate, billing }) => candidate.application.tenantId === tenantId && billing.amountCents > 0, + ); + const totalAmount = paid.reduce((sum, entry) => sum + entry.billing.amountCents, 0); + if (totalAmount === 0) continue; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + tenantId}, 0))`; + await tx.tenantAccount.upsert({ + where: { tenantId }, + update: {}, + create: { tenantId, balanceCents: 0, creditCents: 0, status: 'active' }, + }); + const account = await tx.tenantAccount.findUniqueOrThrow({ where: { tenantId } }); + const available = moneyToNumber(account.balanceCents) + moneyToNumber(account.creditCents); + if (account.status !== 'active' || available < totalAmount) { + throw new BadRequestException('企业账户余额不足'); + } + const existing = await tx.accountTransaction.findMany({ + where: { idempotencyKey: { in: paid.map(({ candidate }) => `${candidate.item.requestKey}:freeze`) } }, + select: { idempotencyKey: true }, + }); + if (existing.length > 0) { + throw new ConflictException('批量计费幂等流水已存在,转入逐条恢复'); + } + const balanceBefore = moneyToNumber(account.balanceCents); + let reserved = 0; + await tx.accountTransaction.createMany({ + data: paid.map(({ candidate, taskId, billing }) => { + reserved += billing.amountCents; + return { + tenantId, + transactionType: 'frozen', + idempotencyKey: `${candidate.item.requestKey}:freeze`, + amountCents: -billing.amountCents, + balanceAfter: balanceBefore - reserved, + relatedType: 'sms_batch_task', + relatedId: taskId, + remark: 'CMPP 入站短信批量冻结', + }; + }), + }); + await tx.tenantAccount.update({ + where: { tenantId }, + data: { balanceCents: { decrement: totalAmount } }, + }); } - const existing = await tx.accountTransaction.findMany({ - where: { idempotencyKey: { in: paid.map(({ candidate }) => `${candidate.item.requestKey}:freeze`) } }, - select: { idempotencyKey: true }, - }); - if (existing.length > 0) { - throw new ConflictException('批量计费幂等流水已存在,转入逐条恢复'); - } - const balanceBefore = moneyToNumber(account.balanceCents); - let reserved = 0; - await tx.accountTransaction.createMany({ - data: paid.map(({ candidate, taskId, billing }) => { - reserved += billing.amountCents; - return { - tenantId, - transactionType: 'frozen', - idempotencyKey: `${candidate.item.requestKey}:freeze`, - amountCents: -billing.amountCents, - balanceAfter: balanceBefore - reserved, - relatedType: 'sms_batch_task', - relatedId: taskId, - remark: 'CMPP 入站短信批量冻结', - }; - }), - }); - await tx.tenantAccount.update({ - where: { tenantId }, - data: { balanceCents: { decrement: totalAmount } }, - }); - } - })); - await this.measureInboundStage('queue_publish', () => this.facade.getSendQueue().addBulk(prepared.map(({ candidate, messageRecordId }) => ({ - name: 'send-message' as const, - data: { messageRecordId }, - opts: { - jobId: messageRecordId, - attempts: 3, - priority: BULLMQ_PRIORITY[normalizeQueuePriority(candidate.application.queuePriority)], - }, - })))); + }), + ); + await this.measureInboundStage('queue_publish', () => + this.facade.getSendQueue().addBulk( + prepared.map(({ candidate, messageRecordId }) => ({ + name: 'send-message' as const, + data: { messageRecordId }, + opts: { + jobId: messageRecordId, + attempts: 3, + priority: BULLMQ_PRIORITY[normalizeQueuePriority(candidate.application.queuePriority)], + }, + })), + ), + ); await this.prisma.smsBatchTask.updateMany({ where: { id: { in: prepared.map((entry) => entry.taskId) }, status: 'ready' }, data: { status: 'queued' }, @@ -1714,15 +1950,19 @@ startInboundWorkflowWorker() { messageRecordId, status: 'accepted', phoneCount: 1, - messages: [{ - phoneNumber: candidate.phoneNumber, - messageId: candidate.payload.messageIds[0], - messageRecordId, - taskId, - status: 'accepted', - }], + messages: [ + { + phoneNumber: candidate.phoneNumber, + messageId: candidate.payload.messageIds[0], + messageRecordId, + taskId, + status: 'accepted', + }, + ], })); - const values = prepared.map((entry, index) => Prisma.sql`(${entry.candidate.item.id}, ${JSON.stringify(results[index])}::jsonb)`); + const values = prepared.map( + (entry, index) => Prisma.sql`(${entry.candidate.item.id}, ${JSON.stringify(results[index])}::jsonb)`, + ); const settled = await this.prisma.$queryRaw>(Prisma.sql` UPDATE "CmppInboundSubmissionInbox" inbox SET status = 'completed', result = updates.result, "completedAt" = (NOW() AT TIME ZONE 'UTC'), @@ -1807,7 +2047,7 @@ startInboundWorkflowWorker() { ); } -findInboundApplication(account: string) { + findInboundApplication(account: string) { return this.prisma.smsApplication.findFirst({ where: { cmppAccount: account }, include: { @@ -1817,7 +2057,7 @@ findInboundApplication(account: string) { }); } -async resolveInboundTemplateCandidate(applicationId: string, content: string) { + async resolveInboundTemplateCandidate(applicationId: string, content: string) { const exact = await this.prisma.smsTemplate.findFirst({ where: { applicationId, @@ -1842,7 +2082,7 @@ async resolveInboundTemplateCandidate(applicationId: string, content: string) { return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null; } -resolveInboundSignatureCandidate(applicationId: string, content: string) { + resolveInboundSignatureCandidate(applicationId: string, content: string) { const match = content.match(/^【[^】]+】/); if (!match?.[0]) return null; return this.prisma.smsSignature.findFirst({ @@ -1855,7 +2095,7 @@ resolveInboundSignatureCandidate(applicationId: string, content: string) { }); } -async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) { + async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) { if (!signatureId) return undefined; const candidates = await this.prisma.smsDrainageInfo.findMany({ where: { signatureId, auditStatus: { not: 'deleted' } }, @@ -1865,22 +2105,25 @@ async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: const matches = candidates .map((item) => ({ ...item, normalizedUrl: item.url.trim() })) .filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl)) - .sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime()); + .sort( + (left, right) => + right.normalizedUrl.length - left.normalizedUrl.length || + right.updatedAt.getTime() - left.updatedAt.getTime(), + ); if (matches.length === 0) return undefined; const longestLength = matches[0].normalizedUrl.length; const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength); - if (longestMatches.length !== 1) { - throw new BadRequestException({ - code: 'DRAINAGE_MATCH_AMBIGUOUS', - message: '短信内容同时匹配多条等长引流地址,无法确定报备资料', - drainageInfoIds: longestMatches.map((item) => item.id), - }); - } + if (longestMatches.length !== 1) return undefined; const matched = longestMatches[0]; return { id: matched.id, auditStatus: matched.auditStatus }; } -async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) { + async attachMessageToReviewTask( + reviewTaskId: string, + messageRecordId: string, + signatureId: string, + drainageInfoId?: string, + ) { await this.prisma.smsMessageRecord.update({ where: { id: messageRecordId }, data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' }, @@ -1890,16 +2133,24 @@ async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, s } function parseInboundWorkflowPayload(value: Prisma.JsonValue): InboundWorkflowPayload { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('CMPP inbound workflow payload is invalid'); + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('CMPP inbound workflow payload is invalid'); const data = value.data; const phoneNumbers = value.phoneNumbers; const submitGroupMessageId = value.submitGroupMessageId; const messageIds = value.messageIds; - if (!data || typeof data !== 'object' || Array.isArray(data) - || !Array.isArray(phoneNumbers) || phoneNumbers.some((item) => typeof item !== 'string') - || typeof submitGroupMessageId !== 'string' - || !Array.isArray(messageIds) || messageIds.some((item) => typeof item !== 'string') - || phoneNumbers.length === 0 || phoneNumbers.length !== messageIds.length) { + if ( + !data || + typeof data !== 'object' || + Array.isArray(data) || + !Array.isArray(phoneNumbers) || + phoneNumbers.some((item) => typeof item !== 'string') || + typeof submitGroupMessageId !== 'string' || + !Array.isArray(messageIds) || + messageIds.some((item) => typeof item !== 'string') || + phoneNumbers.length === 0 || + phoneNumbers.length !== messageIds.length + ) { throw new Error('CMPP inbound workflow payload fields are invalid'); } return { diff --git a/docs/drainage-send-gating-plan-20260910.md b/docs/drainage-send-gating-plan-20260910.md new file mode 100644 index 0000000..b79f11c --- /dev/null +++ b/docs/drainage-send-gating-plan-20260910.md @@ -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 最新记录为准。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 403da8d..b26f9d3 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2249,3 +2249,16 @@ ## 2026-09-09 运营九项修正 用户确认需求及实现范围见 [九项修复设计](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。 diff --git a/docs/phase-6-risk-review-plan.md b/docs/phase-6-risk-review-plan.md index 920a462..4aa6919 100644 --- a/docs/phase-6-risk-review-plan.md +++ b/docs/phase-6-risk-review-plan.md @@ -69,3 +69,12 @@ api/src/risk-review/ - 阈值修改不清空计数,不自动释放待审消息;时间配置在当前夜间结束后生效,界面说明延迟生效。批量任务已有部分正常发送时,保留部分发送进度并标记存在待审核,不覆盖整批消息状态。审核与入队失败不得吞错,续发使用消息ID幂等队列任务。 - 权限沿用管理员风控配置/短信审核入口;应用必须从真实消息与企业关联取得,不能信任客户端自报企业、时间或分类;应用覆盖必须验证对象存在。历史审核记录不重写、不自动重投。回退须先停发送Worker并保留新待审及计数事实,旧版本不能继续绕过新夜间门禁。 - 验收覆盖阈值边界、多入口/多实例并发、跨午夜、应用隔离、幂等、重启、配置覆盖/变更、历史初始化、相同内容聚合、审核范围与并发、定时任务和数据库失败;使用隔离PostgreSQL/Redis证明持久化,不发送真实短信。前后端全量测试、类型/构建/质量门禁、两环境真实API与三尺寸页面验收分别留证。 + + +## 2026-09-10 引流资格门禁(待实施专项) + +详细规则、源码差异、多目标通道交集、终态回执/费用、并发与迁移见[引流信息拦截与通道报备匹配方案](drainage-send-gating-plan-20260910.md)。这是新增硬性发送资格,审核通过不得豁免引流检查;沿用既有夜间风控等规则,旧“只识别不拦截”在新功能生效后被替代。当前仅完成设计。 + +## 2026-09-10 引流发送门禁实施 + +后续用户已授权实施、本地提交及测试部署。具体数据模型、最终分片复核、并发锁、CMPP回执恢复和非CMPP无推送行为见 drainage-send-gating-plan-20260910.md 第10节;取代此前本主题仅处于设计阶段的状态。旧批准配置不迁移;线上状态与未执行项以测试进度为准。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 695fd46..b6f7ac9 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5352,3 +5352,39 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对 | TC-OPS0909-07 | 短信记录发送详情多个提交/回执 | 每行无通道组文案,顶部汇总及通道/回执数据仍存在 | | TC-OPS0909-08 | HTTP关/开/关/开;本地及API校验失败 | 地址随HTTP参数隐藏展示且保值;居中错误弹窗保留表单,不伪报保存成功 | | 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,测试环境只读确认,不保存客户配置。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index faedd5a..a8303ba 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4838,3 +4838,30 @@ git diff --check - 遗留边界:创建通道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。 + + +## 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材料无变更。提交及标准发布阶段、磁盘/恢复资产与目标页面结果在后续记录补齐。 diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 54d0af6..b267ce2 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -44,7 +44,7 @@ func main() { protocolLogPublisher.SuccessSampleRate = positiveEnvInt("GATEWAY_PROTOCOL_LOG_SUCCESS_SAMPLE_PERCENT", 10) 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 resultOutbox *resultoutbox.Outbox channelLimiter, err := ratelimit.New(os.Getenv("REDIS_URL")) diff --git a/gateway/internal/upstream/drainage_guard.go b/gateway/internal/upstream/drainage_guard.go new file mode 100644 index 0000000..6ee5299 --- /dev/null +++ b/gateway/internal/upstream/drainage_guard.go @@ -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 +} diff --git a/gateway/internal/upstream/drainage_guard_test.go b/gateway/internal/upstream/drainage_guard_test.go new file mode 100644 index 0000000..3ec6f47 --- /dev/null +++ b/gateway/internal/upstream/drainage_guard_test.go @@ -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") + } +} diff --git a/gateway/internal/upstream/manager.go b/gateway/internal/upstream/manager.go index 8c60d64..9744a3c 100644 --- a/gateway/internal/upstream/manager.go +++ b/gateway/internal/upstream/manager.go @@ -25,6 +25,7 @@ const ( ) type Manager struct { + DrainageGuardEnabled bool APIBaseURL string EventAPIBaseURL string HTTPClient *http.Client diff --git a/gateway/internal/upstream/submit.go b/gateway/internal/upstream/submit.go index 7e49c6f..b7667a4 100644 --- a/gateway/internal/upstream/submit.go +++ b/gateway/internal/upstream/submit.go @@ -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 // holding the supplier slot for an API round trip and minimizes untracked sends. return m.SubmitSegmentPublisher.PublishSubmitSegment(ctx, cmd, segment) - }) + }, func() error { return m.authorizeDrainage(ctx, cmd) }) return result, err } @@ -40,6 +40,7 @@ func (p *connectionPool) submit( ctx context.Context, cmd queue.SubmitCommand, onSegment func(queue.SubmitSegmentResult) error, + authorize ...func() error, ) (final queue.SubmitResult, finalErr error) { defer func() { for _, segment := range final.Segments { @@ -67,6 +68,23 @@ func (p *connectionPool) submit( result.Segments = segments 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() seq, gatewayMessageID, result, err := conn.submitPart(ctx, cmd, part) metrics.ObserveSubmitStage("supplier_rtt", err == nil, time.Since(supplierStartedAt)) diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts index d0ed90d..db06722 100644 --- a/src/api/types/operations.ts +++ b/src/api/types/operations.ts @@ -134,6 +134,13 @@ export type SmsMessageRecord = { categories?: string[]; truncated?: boolean; } | 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; drainageEvaluatedAt?: string | null; clientSrcId?: string | null; @@ -331,7 +338,14 @@ export type SystemLogExportResult = { recordCount: number; truncated: boolean; 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 = { @@ -638,7 +652,10 @@ export type GatewaySubmitException = { updatedAt: string; tenant?: Pick | null; application?: Pick | null; - channel?: Pick | null; + channel?: Pick< + AdminChannel, + 'id' | 'code' | 'name' | 'status' | 'carrier' | 'sendRegion' | 'rateLimitPerSecond' + > | null; }; export type GatewaySubmitExceptionResponse = PagedResponse & { diff --git a/src/apps/admin/sms-records/SendDetailModal.tsx b/src/apps/admin/sms-records/SendDetailModal.tsx index b8df54e..1db9d05 100644 --- a/src/apps/admin/sms-records/SendDetailModal.tsx +++ b/src/apps/admin/sms-records/SendDetailModal.tsx @@ -47,6 +47,26 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose } >
+
+

引流发送资格

+ {record.drainageGate ? ( + <> +

+ {record.drainageGate.reason || + (record.drainageGate.targets.length + ? '引流信息资格检查通过;发送仍受通道及其他规则限制' + : '未检测到引流信息')} +

+ {record.drainageGate.targets.map((target, index) => ( +

+ {target.text} · {target.materialIds.length ? '已匹配审核通过的资料' : '未匹配有效资料'} +

+ ))} + + ) : ( +

{segmentLoading ? '正在加载引流资格校验结果…' : '未执行引流资格校验'}

+ )} +
最终状态 diff --git a/tools/testing/verify-drainage-gate.mjs b/tools/testing/verify-drainage-gate.mjs new file mode 100644 index 0000000..0734bc5 --- /dev/null +++ b/tools/testing/verify-drainage-gate.mjs @@ -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(); +}