From 633ba597754c1b89943ea3a819f45042f41b019a Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Mon, 7 Sep 2026 22:55:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E4=BC=81=E4=B8=9A=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=B4=AF=E8=AE=A1=E5=A4=9C=E9=97=B4=E7=9F=AD=E4=BF=A1?= =?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=E8=81=9A=E5=90=88=E5=AE=A1=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 38 + api/prisma/schema.prisma | 32 + .../night-sending-risk.service.spec.ts | 41 + .../risk-review/night-sending-risk.service.ts | 258 ++ .../risk-review/risk-review.service.spec.ts | 275 +- api/src/risk-review/risk-review.service.ts | 324 ++- api/src/send-chain/night-sending-gate.spec.ts | 60 + api/src/send-chain/send-chain.service.spec.ts | 2551 +++++++++++------ .../send-chain/send-gateway-submit.service.ts | 854 ++++-- .../send-review-continuation.service.ts | 123 +- .../first-version-development-requirements.md | 4 + docs/phase-6-risk-review-plan.md | 10 + docs/system-functional-test-cases.md | 16 + docs/testing-progress.md | 10 + src/api/types/governance.ts | 27 +- src/apps/admin/AdminRiskRulesPage.tsx | 736 ++++- src/apps/admin/AdminSmsAuditPage.tsx | 403 ++- .../testing/verify-night-sending-postgres.mjs | 284 ++ 18 files changed, 4279 insertions(+), 1767 deletions(-) create mode 100644 api/prisma/migrations/20260907143000_night_sending_risk/migration.sql create mode 100644 api/src/risk-review/night-sending-risk.service.spec.ts create mode 100644 api/src/risk-review/night-sending-risk.service.ts create mode 100644 api/src/send-chain/night-sending-gate.spec.ts create mode 100644 tools/testing/verify-night-sending-postgres.mjs diff --git a/api/prisma/migrations/20260907143000_night_sending_risk/migration.sql b/api/prisma/migrations/20260907143000_night_sending_risk/migration.sql new file mode 100644 index 0000000..75a6cb9 --- /dev/null +++ b/api/prisma/migrations/20260907143000_night_sending_risk/migration.sql @@ -0,0 +1,38 @@ +CREATE TABLE "NightSendingWindow" ( + "id" TEXT PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "windowStartedAt" TIMESTAMP(3) NOT NULL, + "windowEndsAt" TIMESTAMP(3) NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "baselineCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL +); +CREATE UNIQUE INDEX "NightSendingWindow_applicationId_windowStartedAt_key" ON "NightSendingWindow"("applicationId", "windowStartedAt"); +CREATE INDEX "NightSendingWindow_applicationId_windowEndsAt_idx" ON "NightSendingWindow"("applicationId", "windowEndsAt"); +CREATE TABLE "NightSendingReservation" ( + "messageRecordId" TEXT PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "applicationId" TEXT NOT NULL, + "windowId" TEXT NOT NULL, + "sequence" INTEGER NOT NULL, + "thresholdValue" INTEGER NOT NULL, + "reviewTaskId" TEXT, + "continuedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX "NightSendingReservation_applicationId_windowId_idx" ON "NightSendingReservation"("applicationId", "windowId"); +CREATE INDEX "NightSendingReservation_reviewTaskId_idx" ON "NightSendingReservation"("reviewTaskId"); +ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseOwner" TEXT; +ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseExpiresAt" TIMESTAMP(3); +-- Preserve rule IDs and thresholds for application overrides and historical hits. +UPDATE "RiskRule" SET "name" = '夜间累计发送量审核', + "description" = '同一企业应用在夜间累计业务短信超过阈值后进入人工审核,所有入口与内容合并计数。', + "metric" = 'nightSendingCount', "action" = 'manual_review', + "config" = COALESCE("config", '{}'::jsonb) || '{"timeZone":"Asia/Shanghai"}'::jsonb, + "updatedAt" = CURRENT_TIMESTAMP +WHERE "code" = 'NON_WORKING_MARKETING_BULK'; +-- The newly approved policy applies by default to every application. +UPDATE "RiskRule" SET "status" = 'active', "updatedAt" = CURRENT_TIMESTAMP +WHERE "code" = 'NON_WORKING_MARKETING_BULK' AND "applicationId" IS NULL AND "status" <> 'deleted'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 6c3b65d..7e88a95 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1511,6 +1511,36 @@ model ReportReceiptImport { task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade) } +model NightSendingWindow { + id String @id + tenantId String + applicationId String + windowStartedAt DateTime + windowEndsAt DateTime + count Int @default(0) + baselineCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([applicationId, windowStartedAt]) + @@index([applicationId, windowEndsAt]) +} + +model NightSendingReservation { + messageRecordId String @id + tenantId String + applicationId String + windowId String + sequence Int + thresholdValue Int + reviewTaskId String? + continuedAt DateTime? + createdAt DateTime @default(now()) + + @@index([applicationId, windowId]) + @@index([reviewTaskId]) +} + model RiskRule { id String @id @default(cuid()) tenantId String? @@ -1563,6 +1593,8 @@ model SmsSendTask { createdById String? reviewedById String? reviewedAt DateTime? + continuationLeaseOwner String? + continuationLeaseExpiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/api/src/risk-review/night-sending-risk.service.spec.ts b/api/src/risk-review/night-sending-risk.service.spec.ts new file mode 100644 index 0000000..1b18000 --- /dev/null +++ b/api/src/risk-review/night-sending-risk.service.spec.ts @@ -0,0 +1,41 @@ +import { nightClock, nightWindow } from './night-sending-risk.service'; + +describe('night sending window', () => { + const config = nightClock(null); + it.each([ + ['2026-09-07T20:59:59+08:00', false], + ['2026-09-07T21:00:00+08:00', true], + ['2026-09-08T00:00:00+08:00', true], + ['2026-09-08T07:59:59+08:00', true], + ['2026-09-08T08:00:00+08:00', false], + ])('uses Shanghai boundaries at %s', (time, active) => { + const window = nightWindow(new Date(time), config); + expect(Boolean(window)).toBe(active); + if (window) { + expect(window.windowStartedAt.toISOString()).toBe('2026-09-07T13:00:00.000Z'); + expect(window.windowEndsAt.toISOString()).toBe('2026-09-08T00:00:00.000Z'); + } + }); + it('handles a same-day configured period', () => { + const clock = nightClock({ startTime: '01:00', endTime: '06:00' }); + expect(nightWindow(new Date('2026-09-08T03:00:00+08:00'), clock)?.windowEndsAt.toISOString()).toBe( + '2026-09-07T22:00:00.000Z', + ); + }); + it('retains the previous clock until the current night ends', () => { + const config = { + startTime: '23:00', + endTime: '07:00', + previousTimeConfig: { startTime: '21:00', endTime: '08:00' }, + timeConfigEffectiveAt: '2026-09-08T00:00:00Z', + }; + expect(nightClock(config, new Date('2026-09-07T14:00:00Z')).startTime).toBe('21:00'); + expect(nightClock(config, new Date('2026-09-08T00:00:00Z')).startTime).toBe('23:00'); + }); + it.each([ + { startTime: '25:00', endTime: '08:00' }, + { startTime: '21:00', endTime: '21:00' }, + ])('rejects invalid clock %j', (clock) => { + expect(() => nightWindow(new Date(), nightClock(clock))).toThrow(); + }); +}); diff --git a/api/src/risk-review/night-sending-risk.service.ts b/api/src/risk-review/night-sending-risk.service.ts new file mode 100644 index 0000000..dadfd15 --- /dev/null +++ b/api/src/risk-review/night-sending-risk.service.ts @@ -0,0 +1,258 @@ +import { BadRequestException } from '@nestjs/common'; +import { Prisma, RiskRule } from '@prisma/client'; +import { createHash, randomUUID } from 'node:crypto'; +import { PrismaService } from '../prisma/prisma.service'; + +export const NIGHT_RULE_CODE = 'NON_WORKING_MARKETING_BULK'; +export const NIGHT_REVIEW_SOURCE = 'night_sending_bulk'; +const REVIEW_WINDOW_MS = 10_000; +const DAY_MS = 86_400_000; +type NightClock = { startTime: string; endTime: string; timeZone: string }; + +export function nightClock(config: unknown, now = new Date()): NightClock { + const value = config && typeof config === 'object' ? (config as Record) : {}; + if ( + typeof value.timeConfigEffectiveAt === 'string' && + new Date(value.timeConfigEffectiveAt) > now && + value.previousTimeConfig + ) { + return nightClock(value.previousTimeConfig, now); + } + return { + startTime: typeof value.startTime === 'string' ? value.startTime : '21:00', + endTime: typeof value.endTime === 'string' ? value.endTime : '08:00', + timeZone: 'Asia/Shanghai', + }; +} + +export function nightWindow(now: Date, clock: NightClock) { + const minute = (value: string) => { + if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new BadRequestException('夜间时间配置无效'); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }; + const start = minute(clock.startTime), + end = minute(clock.endTime); + if (start === end || !Number.isFinite(now.getTime())) throw new BadRequestException('夜间时间配置无效'); + const local = new Date(now.getTime() + 8 * 3600_000); + const minutes = local.getUTCHours() * 60 + local.getUTCMinutes(); + const active = start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end; + if (!active) return null; + const midnight = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate()) - 8 * 3600_000; + const startDay = midnight - (start > end && minutes < end ? DAY_MS : 0); + return { + windowStartedAt: new Date(startDay + start * 60_000), + windowEndsAt: new Date(startDay + (end + (start > end ? 1440 : 0)) * 60_000), + }; +} + +/** Persistent gate for initial business-message dispatch, shared by every transport. */ +export class NightSendingRiskService { + constructor(private readonly prisma: PrismaService) {} + + async guard(messageIds: string[], now = new Date()) { + const held = new Set(messageIds); + if (!messageIds.length) return held; + const owners = await this.prisma.smsMessageRecord.findMany({ + where: { + id: { in: [...new Set(messageIds)] }, + status: 'queued', + tenantId: { not: null }, + applicationId: { not: null }, + }, + select: { id: true, applicationId: true }, + }); + const applications = [...new Set(owners.map((message) => message.applicationId!))].sort(); + for (const applicationId of applications) { + const ids = owners.filter((message) => message.applicationId === applicationId).map((message) => message.id); + for (let offset = 0; offset < ids.length; offset += 250) { + const blocked = await this.guardApplication(applicationId, ids.slice(offset, offset + 250), now); + for (const id of ids.slice(offset, offset + 250)) if (!blocked.includes(id)) held.delete(id); + } + } + return held; + } + + private async guardApplication(applicationId: string, ids: string[], now: Date) { + return this.prisma.$transaction( + async (tx) => { + // Shared by all API/worker instances; lock before reading counters or decisions. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'night-sending:' + applicationId}, 0))`; + const application = await tx.smsApplication.findUniqueOrThrow({ + where: { id: applicationId }, + select: { tenantId: true }, + }); + const tenantId = application.tenantId; + const messages = await tx.smsMessageRecord.findMany({ + where: { id: { in: ids }, applicationId, tenantId, status: 'queued' }, + orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }], + }); + const rules = await tx.riskRule.findMany({ + where: { + code: NIGHT_RULE_CODE, + status: 'active', + OR: [{ applicationId: null }, { applicationId, tenantId }], + }, + orderBy: { createdAt: 'asc' }, + }); + const rule = + rules.find((item) => item.applicationId === applicationId) ?? + rules.find((item) => item.applicationId === null); + const reservations = await tx.nightSendingReservation.findMany({ where: { messageRecordId: { in: ids } } }); + const byMessage = new Map(reservations.map((item) => [item.messageRecordId, item])); + let window = await tx.nightSendingWindow.findFirst({ + where: { applicationId, tenantId, windowStartedAt: { lte: now }, windowEndsAt: { gt: now } }, + orderBy: { windowStartedAt: 'desc' }, + }); + const period = window ?? (rule ? nightWindow(now, nightClock(rule.config, now)) : null); + const held: string[] = ids.filter((id) => !messages.some((message) => message.id === id)); + for (const message of messages) { + const previous = byMessage.get(message.id); + if (previous) { + if (previous.tenantId !== tenantId || previous.applicationId !== applicationId) + throw new Error('Night sending reservation owner mismatch'); + if (previous.reviewTaskId) { + const task = await tx.smsSendTask.findUniqueOrThrow({ where: { id: previous.reviewTaskId } }); + if (task.status !== 'approved') held.push(message.id); + } + continue; + } + // Already submitted business messages and retries never occupy a new allowance. + if (message.submitId || !rule || !period) continue; + if (!Number.isSafeInteger(rule.thresholdValue) || rule.thresholdValue < 0) + throw new Error('夜间累计阈值必须是非负整数'); + if (!window) { + // Bootstrap only once per application/night, including first deployment mid-night. + // First Submit attempt is durable even when the supplier rejects or has no receipt. + const [baseline] = await tx.$queryRaw>(Prisma.sql` + SELECT COUNT(*)::int AS count FROM "SmsMessageRecord" m + WHERE m."applicationId" = ${applicationId} AND m."tenantId" = ${tenantId} + AND EXISTS (SELECT 1 FROM "SmsSubmitRecord" s WHERE s."messageRecordId" = m.id + AND s."createdAt" >= ${period.windowStartedAt} AND s."createdAt" < ${period.windowEndsAt}) + AND NOT EXISTS (SELECT 1 FROM "SmsSubmitRecord" s WHERE s."messageRecordId" = m.id AND s."createdAt" < ${period.windowStartedAt}) + `); + window = await tx.nightSendingWindow.create({ + data: { + id: `${applicationId}:${period.windowStartedAt.toISOString()}`, + tenantId, + applicationId, + windowStartedAt: period.windowStartedAt, + windowEndsAt: period.windowEndsAt, + count: baseline.count, + baselineCount: baseline.count, + }, + }); + } + window = await tx.nightSendingWindow.update({ where: { id: window.id }, data: { count: { increment: 1 } } }); + let reviewTaskId: string | undefined; + if (window.count > rule.thresholdValue) { + reviewTaskId = await this.aggregate(tx, message, rule, window, now); + held.push(message.id); + } + await tx.nightSendingReservation.create({ + data: { + messageRecordId: message.id, + tenantId, + applicationId, + windowId: window.id, + sequence: window.count, + thresholdValue: rule.thresholdValue, + reviewTaskId, + }, + }); + } + return held; + }, + { maxWait: 15_000, timeout: 30_000 }, + ); + } + + private async aggregate( + tx: Prisma.TransactionClient, + message: { + id: string; + tenantId: string | null; + applicationId: string | null; + batchTaskId: string | null; + content: string; + phoneNumber: string; + }, + rule: RiskRule, + night: { id: string; count: number; windowStartedAt: Date; windowEndsAt: Date }, + now: Date, + ) { + const contentHash = createHash('sha256').update(message.content).digest('hex'); + const windowStartedAt = new Date(Math.floor(now.getTime() / REVIEW_WINDOW_MS) * REVIEW_WINDOW_MS); + const windowEndsAt = new Date(Math.min(windowStartedAt.getTime() + REVIEW_WINDOW_MS, night.windowEndsAt.getTime())); + const aggregationKey = createHash('sha256') + .update(`${NIGHT_REVIEW_SOURCE}|${night.id}|${contentHash}|${windowStartedAt.toISOString()}`) + .digest('hex'); + // Serialize with review decisions. A request waiting at a window boundary must + // never append to an already reviewed aggregation. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'sms-review-aggregation:' + aggregationKey}, 0))`; + const existing = await tx.smsSendTask.findUnique({ where: { aggregationKey } }); + if (existing && existing.status !== 'pending_review') throw new Error('审核聚合已关闭,请重试当前消息'); + const reason = `夜间累计发送量审核命中,阈值 ${rule.thresholdValue} 条,本夜累计 ${night.count} 条;所有入口和内容合并计数`; + const uniqueIncrement = + existing && + (await tx.smsMessageRecord.count({ where: { reviewTaskId: existing.id, phoneNumber: message.phoneNumber } })) + ? 0 + : 1; + const task = await tx.smsSendTask.upsert({ + where: { aggregationKey }, + create: { + tenantId: message.tenantId!, + applicationId: message.applicationId!, + taskNo: `NIGHT-${randomUUID()}`, + sourceType: NIGHT_REVIEW_SOURCE, + aggregationKey, + contentHash, + windowStartedAt, + windowEndsAt, + content: message.content, + phoneTotal: 1, + uniquePhoneTotal: 1, + status: 'pending_review', + riskDecision: 'manual_review', + reviewReason: reason, + variableIssues: { + nightStartedAt: night.windowStartedAt.toISOString(), + nightEndsAt: night.windowEndsAt.toISOString(), + }, + riskHits: { + create: { + tenantId: message.tenantId!, + ruleId: rule.id, + ruleCode: NIGHT_RULE_CODE, + ruleName: '夜间累计发送量审核', + thresholdValue: rule.thresholdValue, + actualValue: night.count, + action: 'manual_review', + reason, + }, + }, + }, + update: { phoneTotal: { increment: 1 }, uniquePhoneTotal: { increment: uniqueIncrement }, reviewReason: reason }, + }); + if (existing) + await tx.riskHitRecord.updateMany({ + where: { taskId: task.id, ruleCode: NIGHT_RULE_CODE }, + data: { actualValue: night.count, reason }, + }); + await tx.smsMessageRecord.update({ + where: { id: message.id }, + data: { + status: 'pending_review', + reviewTaskId: task.id, + errorCode: 'NIGHT_SENDING_REVIEW', + errorMessage: reason, + }, + }); + if (message.batchTaskId) + await tx.smsBatchTask.update({ + where: { id: message.batchTaskId }, + data: { auditStatus: 'pending_review', reviewReason: reason }, + }); + return task.id; + } +} diff --git a/api/src/risk-review/risk-review.service.spec.ts b/api/src/risk-review/risk-review.service.spec.ts index e7ab49c..9b33165 100644 --- a/api/src/risk-review/risk-review.service.spec.ts +++ b/api/src/risk-review/risk-review.service.spec.ts @@ -30,13 +30,19 @@ function createPrismaMock(overrides: Record = {}) { findMany: jest.fn().mockResolvedValue([]), }, smsSendTask: { - create: jest.fn().mockImplementation(({ data }: { data: Record }) => - Promise.resolve({ id: 'risk-task-1', ...data }), - ), + create: jest + .fn() + .mockImplementation(({ data }: { data: Record }) => + Promise.resolve({ id: 'risk-task-1', ...data }), + ), findUnique: jest.fn().mockResolvedValue({ id: 'risk-task-1', riskHits: [] }), update: jest.fn(), findMany: jest.fn(), - upsert: jest.fn().mockImplementation(({ create }: { create: Record }) => Promise.resolve({ id: 'review-task-1', ...create })), + upsert: jest + .fn() + .mockImplementation(({ create }: { create: Record }) => + Promise.resolve({ id: 'review-task-1', ...create }), + ), }, smsMessageRecord: { update: jest.fn().mockResolvedValue({ id: 'message-1' }), @@ -51,14 +57,20 @@ function createPrismaMock(overrides: Record = {}) { createMany: jest.fn(), findMany: jest.fn(), }, - $transaction: jest.fn(async (callback) => callback({ - smsSendTask: { - upsert: jest.fn().mockImplementation(({ create }: { create: Record }) => Promise.resolve({ id: 'review-task-1', ...create })), - }, - smsMessageRecord: { - update: jest.fn().mockResolvedValue({ id: 'message-1' }), - }, - })), + $transaction: jest.fn(async (callback) => + callback({ + smsSendTask: { + upsert: jest + .fn() + .mockImplementation(({ create }: { create: Record }) => + Promise.resolve({ id: 'review-task-1', ...create }), + ), + }, + smsMessageRecord: { + update: jest.fn().mockResolvedValue({ id: 'message-1' }), + }, + }), + ), ...overrides, }; } @@ -69,8 +81,20 @@ describe('RiskReviewService', () => { const service = new RiskReviewService(prisma as never); const results = await service.evaluateTasksBatch([ - { tenantId: 'tenant-1', applicationId: 'app-1', content: '【测试】验证码000001', phones: ['13800000001'], sourceType: 'cmpp' }, - { tenantId: 'tenant-1', applicationId: 'app-1', content: '【测试】验证码000002', phones: ['13800000002'], sourceType: 'cmpp' }, + { + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '【测试】验证码000001', + phones: ['13800000001'], + sourceType: 'cmpp', + }, + { + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '【测试】验证码000002', + phones: ['13800000002'], + sourceType: 'cmpp', + }, ]); expect(results).toHaveLength(2); @@ -84,7 +108,11 @@ describe('RiskReviewService', () => { it('coalesces concurrent default-rule checks and reuses the short completeness cache', async () => { const prisma = createPrismaMock(); let releaseCount: ((count: number) => void) | undefined; - prisma.riskRule.count.mockReturnValue(new Promise((resolve) => { releaseCount = resolve; })); + prisma.riskRule.count.mockReturnValue( + new Promise((resolve) => { + releaseCount = resolve; + }), + ); const service = new RiskReviewService(prisma as never); const first = service.ensureDefaultRules(); @@ -99,9 +127,7 @@ describe('RiskReviewService', () => { it('clears a failed default-rule check so the next request can retry', async () => { const prisma = createPrismaMock(); - prisma.riskRule.count - .mockRejectedValueOnce(new Error('database unavailable')) - .mockResolvedValueOnce(5); + prisma.riskRule.count.mockRejectedValueOnce(new Error('database unavailable')).mockResolvedValueOnce(5); const service = new RiskReviewService(prisma as never); await expect(service.ensureDefaultRules()).rejects.toThrow('database unavailable'); @@ -113,9 +139,9 @@ describe('RiskReviewService', () => { it('falls back to per-rule recovery when the completeness count finds a missing default', async () => { const prisma = createPrismaMock(); prisma.riskRule.count.mockResolvedValue(4); - prisma.riskRule.findFirst.mockImplementation(({ where }: { where: { code: string } }) => ( - Promise.resolve(where.code === 'PHONE_FREQUENCY_5M' ? null : { id: `rule-${where.code}` }) - )); + prisma.riskRule.findFirst.mockImplementation(({ where }: { where: { code: string } }) => + Promise.resolve(where.code === 'PHONE_FREQUENCY_5M' ? null : { id: `rule-${where.code}` }), + ); const service = new RiskReviewService(prisma as never); service.createRule = jest.fn().mockResolvedValue({ id: 'restored-rule' }) as never; @@ -129,13 +155,16 @@ describe('RiskReviewService', () => { it('keeps phone-frequency periods fixed and rejects manual-review actions', () => { const service = new RiskReviewService(createPrismaMock() as never); - expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 })) - .toThrow('号码频次周期首版固定为24小时自然日或5分钟,不允许修改'); - expect(() => service['validateRuleInput']({ - code: 'PHONE_FREQUENCY_24H', - thresholdValue: 10, - action: 'manual_review', - })).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝'); + expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 })).toThrow( + '号码频次周期首版固定为24小时自然日或5分钟,不允许修改', + ); + expect(() => + service['validateRuleInput']({ + code: 'PHONE_FREQUENCY_24H', + thresholdValue: 10, + action: 'manual_review', + }), + ).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝'); }); it('includes the sending enterprise and application in SMS review rows', async () => { @@ -145,18 +174,22 @@ describe('RiskReviewService', () => { await service.listTasks(undefined, 'pending_review'); - expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ - include: expect.objectContaining({ - tenant: { select: { id: true, name: true } }, - application: { select: { id: true, name: true } }, + expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + }), }), - })); + ); }); it('adds the associated batch task number to SMS review rows', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findMany.mockResolvedValue([{ id: 'review-task-1', taskNo: 'REVIEW-001' }]); - prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'batch-1', taskNo: 'BATCH-001', riskTaskId: 'review-task-1' }]); + prisma.smsBatchTask.findMany.mockResolvedValue([ + { id: 'batch-1', taskNo: 'BATCH-001', riskTaskId: 'review-task-1' }, + ]); const service = new RiskReviewService(prisma as never); await expect(service.listTasks(undefined, 'pending_review')).resolves.toEqual([ @@ -175,14 +208,16 @@ describe('RiskReviewService', () => { await service.listTasks(undefined, 'pending_review', '2026-08-01', '2026-08-03'); - expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - createdAt: { - gte: new Date('2026-08-01T00:00:00+08:00'), - lte: new Date('2026-08-03T23:59:59.999+08:00'), - }, + expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { + gte: new Date('2026-08-01T00:00:00+08:00'), + lte: new Date('2026-08-03T23:59:59.999+08:00'), + }, + }), }), - })); + ); }); it('groups identical CMPP template mismatches into a deterministic short review window', async () => { @@ -224,30 +259,41 @@ describe('RiskReviewService', () => { it('batch rejects unique tasks with one required rejection reason', async () => { const prisma = createPrismaMock(); - prisma.smsSendTask.findUnique.mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve({ - id: where.id, - status: 'pending_review', - reviewReason: '命中风控', - })); - prisma.smsSendTask.update.mockImplementation(({ where, data }: { where: { id: string }; data: Record }) => Promise.resolve({ - id: where.id, - ...data, - riskHits: [], - })); + prisma.smsSendTask.findUnique.mockImplementation(({ where }: { where: { id: string } }) => + Promise.resolve({ + id: where.id, + status: 'pending_review', + reviewReason: '命中风控', + }), + ); + prisma.smsSendTask.update.mockImplementation( + ({ where, data }: { where: { id: string }; data: Record }) => + Promise.resolve({ + id: where.id, + ...data, + riskHits: [], + }), + ); const service = new RiskReviewService(prisma as never); - await expect(service.rejectTasks({ ids: ['task-1', 'task-2', 'task-1'], reason: '批量人工拒绝' })).resolves.toEqual([ - expect.objectContaining({ id: 'task-1', status: 'rejected', rejectReason: '批量人工拒绝' }), - expect.objectContaining({ id: 'task-2', status: 'rejected', rejectReason: '批量人工拒绝' }), - ]); + await expect(service.rejectTasks({ ids: ['task-1', 'task-2', 'task-1'], reason: '批量人工拒绝' })).resolves.toEqual( + [ + expect.objectContaining({ id: 'task-1', status: 'rejected', rejectReason: '批量人工拒绝' }), + expect.objectContaining({ id: 'task-2', status: 'rejected', rejectReason: '批量人工拒绝' }), + ], + ); expect(prisma.smsSendTask.update).toHaveBeenCalledTimes(2); }); it('requires task ids and a reason for batch rejection', async () => { const service = new RiskReviewService(createPrismaMock() as never); - await expect(service.rejectTasks({ ids: [], reason: '拒绝' })).rejects.toThrow('At least one SMS send task id is required'); - await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow('Batch rejection reason is required'); + await expect(service.rejectTasks({ ids: [], reason: '拒绝' })).rejects.toThrow( + 'At least one SMS send task id is required', + ); + await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow( + 'Batch rejection reason is required', + ); }); it('rejects tasks over the effective max phone rule threshold', async () => { @@ -310,12 +356,14 @@ describe('RiskReviewService', () => { ]); const service = new RiskReviewService(prisma as never); - await expect(service.evaluateTask({ - tenantId: 'tenant-1', - applicationId: 'app-1', - content: 'hello', - phones: ['13800000001', '13800000002'], - })).resolves.toEqual(expect.objectContaining({ status: 'rejected' })); + await expect( + service.evaluateTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + content: 'hello', + phones: ['13800000001', '13800000002'], + }), + ).resolves.toEqual(expect.objectContaining({ status: 'rejected' })); expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ ruleId: 'rule-app', thresholdValue: 1 })], }); @@ -324,13 +372,15 @@ describe('RiskReviewService', () => { it('paginates real phone records through both direct and batch review-task relations', async () => { const prisma = createPrismaMock(); prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1' }); - prisma.smsMessageRecord.findMany.mockResolvedValue([{ - id: 'message-1', - phoneNumber: '13800000001', - province: '上海', - carrier: 'mobile', - status: 'pending_review', - }]); + prisma.smsMessageRecord.findMany.mockResolvedValue([ + { + id: 'message-1', + phoneNumber: '13800000001', + province: '上海', + carrier: 'mobile', + status: 'pending_review', + }, + ]); prisma.smsMessageRecord.count.mockResolvedValue(1); const service = new RiskReviewService(prisma as never); @@ -340,17 +390,16 @@ describe('RiskReviewService', () => { page: 1, pageSize: 20, }); - expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { - OR: [ - { reviewTaskId: 'review-task-1' }, - { batchTask: { riskTaskId: 'review-task-1' } }, - ], - phoneNumber: { contains: '138' }, - }, - skip: 0, - take: 20, - })); + expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + OR: [{ reviewTaskId: 'review-task-1' }, { batchTask: { riskTaskId: 'review-task-1' } }], + phoneNumber: { contains: '138' }, + }, + skip: 0, + take: 20, + }), + ); }); it('does not create an audit task for automatic approval', async () => { @@ -406,7 +455,7 @@ describe('RiskReviewService', () => { }); }); - it('marks non-working marketing bulk and frequent task creation for manual review', async () => { + it('defers night volume to persisted-message dispatch while retaining task frequency review', async () => { const prisma = createPrismaMock(); prisma.smsBatchTask.count.mockResolvedValue(10); prisma.riskRule.findMany.mockResolvedValue([ @@ -443,11 +492,13 @@ describe('RiskReviewService', () => { expect(result.status).toBe('pending_review'); expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ - data: expect.arrayContaining([ - expect.objectContaining({ ruleCode: 'NON_WORKING_MARKETING_BULK', actualValue: 3 }), - expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 10 }), - ]), + data: expect.arrayContaining([expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 10 })]), }); + expect( + prisma.riskHitRecord.createMany.mock.calls + .flatMap((call) => call[0].data) + .some((hit: { ruleCode: string }) => hit.ruleCode === 'NON_WORKING_MARKETING_BULK'), + ).toBe(false); expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({ where: { applicationId: 'app-1', @@ -459,31 +510,37 @@ describe('RiskReviewService', () => { it('does not include CMPP or HTTP tasks in client task frequency control', async () => { const prisma = createPrismaMock(); - prisma.riskRule.findMany.mockResolvedValue([{ - id: 'rule-frequency', - code: 'TASK_CREATE_FREQUENCY', - name: '短时间任务创建频控', - metric: 'recentTaskCount', - thresholdValue: 1, - action: 'manual_review', - priority: 30, - }]); + prisma.riskRule.findMany.mockResolvedValue([ + { + id: 'rule-frequency', + code: 'TASK_CREATE_FREQUENCY', + name: '短时间任务创建频控', + metric: 'recentTaskCount', + thresholdValue: 1, + action: 'manual_review', + priority: 30, + }, + ]); const service = new RiskReviewService(prisma as never); - await expect(service.evaluateTask({ - tenantId: 'tenant-1', - applicationId: 'app-1', - content: 'hello', - phones: ['10000000000'], - sourceType: 'cmpp', - })).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); - await expect(service.evaluateTask({ - tenantId: 'tenant-1', - applicationId: 'app-1', - content: 'hello', - phones: ['10000000000'], - sourceType: 'api', - })).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); + await expect( + service.evaluateTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + content: 'hello', + phones: ['10000000000'], + sourceType: 'cmpp', + }), + ).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); + await expect( + service.evaluateTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + content: 'hello', + phones: ['10000000000'], + sourceType: 'api', + }), + ).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); expect(prisma.smsBatchTask.count).not.toHaveBeenCalled(); }); diff --git a/api/src/risk-review/risk-review.service.ts b/api/src/risk-review/risk-review.service.ts index 4c99806..9d904ed 100644 --- a/api/src/risk-review/risk-review.service.ts +++ b/api/src/risk-review/risk-review.service.ts @@ -3,6 +3,13 @@ import { Prisma } from '@prisma/client'; import { createHash, randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; +import { + NIGHT_REVIEW_SOURCE, + NIGHT_RULE_CODE, + NightSendingRiskService, + nightClock, + nightWindow, +} from './night-sending-risk.service'; export interface CreateRiskRuleDto { tenantId?: string; @@ -71,9 +78,9 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [ }, { code: 'NON_WORKING_MARKETING_BULK', - name: '非工作时间大批量营销发送', - description: '营销任务在非工作时间且号码数超过阈值时进入人工审核。', - metric: 'nonWorkingMarketingPhones', + name: '夜间累计发送量审核', + description: '同一企业应用在夜间累计业务短信超过阈值后进入人工审核,所有入口与内容合并计数。', + metric: 'nightSendingCount', thresholdValue: 5000, action: 'manual_review', priority: 20, @@ -118,6 +125,11 @@ export class RiskReviewService { constructor(private readonly prisma: PrismaService) {} + async guardNightSending(messageIds: string[], now = new Date()) { + await this.ensureDefaultRules(); + return new NightSendingRiskService(this.prisma).guard(messageIds, now); + } + async listRules(applicationId?: string) { await this.ensureDefaultRules(); return this.prisma.riskRule.findMany({ @@ -152,10 +164,15 @@ export class RiskReviewService { description: definition.description, metric: definition.metric!, thresholdValue: data.thresholdValue, - action: isPhoneFrequencyRule(data.code) ? 'block' : data.action ?? 'manual_review', + action: isPhoneFrequencyRule(data.code) + ? 'block' + : data.code === NIGHT_RULE_CODE + ? 'manual_review' + : (data.action ?? 'manual_review'), status: data.status ?? 'active', priority: data.priority ?? definition.priority ?? 100, - config: this.normalizeRuleConfig(data.code, data.config ?? definition.config) as Prisma.InputJsonValue | undefined, + config: (await this.ruleConfigForSave(data.code, data.config ?? definition.config, data.applicationId)) as + Prisma.InputJsonValue | undefined, }, include: { application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } }, @@ -182,9 +199,15 @@ export class RiskReviewService { action: data.action, status: data.status, priority: data.priority, - config: data.config === undefined - ? undefined - : this.normalizeRuleConfig(rule.code, data.config) as Prisma.InputJsonValue, + config: + data.config === undefined + ? undefined + : ((await this.ruleConfigForSave( + rule.code, + data.config, + rule.applicationId ?? undefined, + rule.config, + )) as Prisma.InputJsonValue), }, include: { application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } }, @@ -209,18 +232,19 @@ export class RiskReviewService { status, createdAt: shanghaiDateRange(submittedAtFrom, submittedAtTo), ...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}), - ...(!status ? { - OR: [ - { status: 'pending_review' }, - { reviewedById: { not: null } }, - ], - } : {}), - ...(status === 'pending_review' ? { - OR: [ - { sourceType: { not: 'cmpp_template_mismatch' } }, - { windowEndsAt: { lte: new Date() } }, - ], - } : {}), + ...(!status + ? { + OR: [{ status: 'pending_review' }, { reviewedById: { not: null } }], + } + : {}), + ...(status === 'pending_review' + ? { + OR: [ + { sourceType: { notIn: ['cmpp_template_mismatch', NIGHT_REVIEW_SOURCE] } }, + { windowEndsAt: { lte: new Date() } }, + ], + } + : {}), }, include: { riskHits: true, @@ -231,11 +255,15 @@ export class RiskReviewService { }, orderBy: { createdAt: 'desc' }, }); - const batchTasks = tasks.length ? await this.prisma.smsBatchTask.findMany({ - where: { riskTaskId: { in: tasks.map((task) => task.id) } }, - select: { id: true, taskNo: true, riskTaskId: true }, - }) : []; - const batchByRiskTaskId = new Map(batchTasks.map((task) => [task.riskTaskId, { id: task.id, taskNo: task.taskNo }])); + const batchTasks = tasks.length + ? await this.prisma.smsBatchTask.findMany({ + where: { riskTaskId: { in: tasks.map((task) => task.id) } }, + select: { id: true, taskNo: true, riskTaskId: true }, + }) + : []; + const batchByRiskTaskId = new Map( + batchTasks.map((task) => [task.riskTaskId, { id: task.id, taskNo: task.taskNo }]), + ); return tasks.map((task) => ({ ...task, batchTask: batchByRiskTaskId.get(task.id) ?? null })); } @@ -251,10 +279,7 @@ export class RiskReviewService { const normalizedPage = Math.max(1, Math.floor(page || 1)); const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(pageSize || 20))); const where: Prisma.SmsMessageRecordWhereInput = { - OR: [ - { reviewTaskId: taskId }, - { batchTask: { riskTaskId: taskId } }, - ], + OR: [{ reviewTaskId: taskId }, { batchTask: { riskTaskId: taskId } }], ...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}), }; const [items, total] = await Promise.all([ @@ -366,16 +391,8 @@ export class RiskReviewService { reason: formatTemplateVariableIssueReason(variableIssues), }); } - const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date(); - const nonWorkingRule = rules.find((rule) => rule.code === 'NON_WORKING_MARKETING_BULK'); - const nonWorkingMarketingPhones = - isMarketing(data.category ?? template?.category) - && isNonWorkingTime(requestedAt, readNonWorkingConfig(nonWorkingRule?.config)) - ? phoneTotal - : 0; const hits = this.evaluateRules(rules, { phoneTotal, - nonWorkingMarketingPhones, recentTaskCount, }); hits.push(...contentIssues.map(contentIssueToHit)); @@ -453,65 +470,63 @@ export class RiskReviewService { // instead of silently weakening its foreign-key validation in the fast path. return Promise.all(items.map((item) => this.evaluateTask(item))); } - const applicationIds = [...new Set(items.map((item) => item.applicationId).filter((id): id is string => Boolean(id)))]; + const applicationIds = [ + ...new Set(items.map((item) => item.applicationId).filter((id): id is string => Boolean(id))), + ]; const templateIds = [...new Set(items.map((item) => item.templateId).filter((id): id is string => Boolean(id)))]; const [templates, sensitiveWords, applicationInputs] = await Promise.all([ templateIds.length ? this.prisma.smsTemplate.findMany({ where: { id: { in: templateIds } }, include: { variables: true } }) : Promise.resolve([]), this.prisma.sensitiveWord.findMany({ where: { status: 'active' }, select: { word: true, level: true } }), - Promise.all(applicationIds.map(async (applicationId) => ({ - applicationId, - rules: await this.effectiveRules(applicationId), - recentTaskCount: await this.countRecentClientTasks(applicationId, 'cmpp'), - }))), + Promise.all( + applicationIds.map(async (applicationId) => ({ + applicationId, + rules: await this.effectiveRules(applicationId), + recentTaskCount: await this.countRecentClientTasks(applicationId, 'cmpp'), + })), + ), ]); const templateById = new Map(templates.map((template) => [template.id, template])); const inputsByApplication = new Map(applicationInputs.map((entry) => [entry.applicationId, entry])); - return Promise.all(items.map(async (data) => { - const phones = data.phones ?? []; - const uniquePhones = [...new Set(phones)]; - const phoneTotal = phones.length; - const template = data.templateId ? templateById.get(data.templateId) : undefined; - const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {}); - const contentIssues = evaluateContent(data.content, sensitiveWords); - if (variableIssues.length > 0) { - contentIssues.push({ - ruleCode: 'TEMPLATE_VARIABLE_INVALID', - ruleName: '模板变量校验失败', - thresholdValue: 0, - actualValue: variableIssues.length, - action: 'block', - reason: formatTemplateVariableIssueReason(variableIssues), + return Promise.all( + items.map(async (data) => { + const phones = data.phones ?? []; + const phoneTotal = phones.length; + const template = data.templateId ? templateById.get(data.templateId) : undefined; + const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {}); + const contentIssues = evaluateContent(data.content, sensitiveWords); + if (variableIssues.length > 0) { + contentIssues.push({ + ruleCode: 'TEMPLATE_VARIABLE_INVALID', + ruleName: '模板变量校验失败', + thresholdValue: 0, + actualValue: variableIssues.length, + action: 'block', + reason: formatTemplateVariableIssueReason(variableIssues), + }); + } + const applicationInput = data.applicationId ? inputsByApplication.get(data.applicationId) : undefined; + const rules = applicationInput?.rules ?? []; + const hits = this.evaluateRules(rules, { + phoneTotal, + recentTaskCount: applicationInput?.recentTaskCount ?? 0, }); - } - const applicationInput = data.applicationId ? inputsByApplication.get(data.applicationId) : undefined; - const rules = applicationInput?.rules ?? []; - const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date(); - const nonWorkingRule = rules.find((rule) => rule.code === 'NON_WORKING_MARKETING_BULK'); - const nonWorkingMarketingPhones = isMarketing(data.category ?? template?.category) - && isNonWorkingTime(requestedAt, readNonWorkingConfig(nonWorkingRule?.config)) - ? phoneTotal - : 0; - const hits = this.evaluateRules(rules, { - phoneTotal, - nonWorkingMarketingPhones, - recentTaskCount: applicationInput?.recentTaskCount ?? 0, - }); - hits.push(...contentIssues.map(contentIssueToHit)); - const decision = decideRiskAction(hits); - if (decision.status !== 'approved') { - return this.evaluateTask(data); - } - return { - canSubmit: true, - status: decision.status, - riskDecision: decision.riskDecision, - reason: hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null, - task: null, - }; - })); + hits.push(...contentIssues.map(contentIssueToHit)); + const decision = decideRiskAction(hits); + if (decision.status !== 'approved') { + return this.evaluateTask(data); + } + return { + canSubmit: true, + status: decision.status, + riskDecision: decision.riskDecision, + reason: hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null, + task: null, + }; + }), + ); } async approveTask(taskId: string, data: ReviewSmsTaskDto) { @@ -519,6 +534,7 @@ export class RiskReviewService { if (!task) { throw new NotFoundException('SMS send task not found'); } + if (task.sourceType === NIGHT_REVIEW_SOURCE) return this.reviewNightTask(taskId, 'approved', data); this.assertAggregationWindowClosed(task); return this.prisma.smsSendTask.update({ where: { id: taskId }, @@ -539,6 +555,7 @@ export class RiskReviewService { if (!task) { throw new NotFoundException('SMS send task not found'); } + if (task.sourceType === NIGHT_REVIEW_SOURCE) return this.reviewNightTask(taskId, 'rejected', data); this.assertAggregationWindowClosed(task); const reason = data.reason ?? task.reviewReason ?? '审核拒绝'; return this.prisma.smsSendTask.update({ @@ -611,11 +628,48 @@ export class RiskReviewService { } private assertAggregationWindowClosed(task: { sourceType?: string | null; windowEndsAt?: Date | null }) { - if (task.sourceType === 'cmpp_template_mismatch' && task.windowEndsAt && task.windowEndsAt.getTime() > Date.now()) { + if ( + ['cmpp_template_mismatch', NIGHT_REVIEW_SOURCE].includes(task.sourceType ?? '') && + task.windowEndsAt && + task.windowEndsAt.getTime() > Date.now() + ) { throw new BadRequestException('聚合窗口尚未关闭,请在窗口结束后审核'); } } + private async reviewNightTask(taskId: string, decision: 'approved' | 'rejected', data: ReviewSmsTaskDto) { + return this.prisma.$transaction(async (tx) => { + const snapshot = await tx.smsSendTask.findUniqueOrThrow({ where: { id: taskId } }); + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'sms-review-aggregation:' + snapshot.aggregationKey}, 0))`; + const task = await tx.smsSendTask.findUniqueOrThrow({ where: { id: taskId } }); + this.assertAggregationWindowClosed(task); + if (task.status === decision) return task; + if (task.status !== 'pending_review') throw new BadRequestException('该任务已审核,不能更改审核决定'); + return tx.smsSendTask.update({ + where: { id: taskId }, + data: { + status: decision, + riskDecision: decision === 'approved' ? 'allow' : 'block', + reviewReason: decision === 'approved' ? (data.reason ?? task.reviewReason) : task.reviewReason, + rejectReason: decision === 'rejected' ? (data.reason ?? '审核拒绝') : null, + reviewedById: data.reviewerId, + reviewedAt: new Date(), + }, + include: { riskHits: true, reviewedBy: { select: { id: true, username: true, displayName: true } } }, + }); + }); + } + + async pendingNightContinuations() { + return this.prisma.$queryRaw>` + SELECT t.id, t.status, COALESCE(t."rejectReason", t."reviewReason", '运营审核') AS reason + FROM "SmsSendTask" t WHERE t."sourceType" = 'night_sending_bulk' AND t.status IN ('approved','rejected') + AND (t."continuationLeaseExpiresAt" IS NULL OR t."continuationLeaseExpiresAt" < (NOW() AT TIME ZONE 'UTC')) + AND EXISTS (SELECT 1 FROM "NightSendingReservation" r WHERE r."reviewTaskId" = t.id AND r."continuedAt" IS NULL) + ORDER BY t."reviewedAt", t.id LIMIT 20 + `; + } + private async effectiveRules(applicationId?: string) { const rules = await this.prisma.riskRule.findMany({ where: { @@ -626,6 +680,8 @@ export class RiskReviewService { }); const byCode = new Map(); for (const rule of rules) { + // Night volume is checked once per persisted business message by the send Worker. + if (rule.code === NIGHT_RULE_CODE) continue; if (rule.applicationId || !byCode.has(rule.code)) { byCode.set(rule.code, rule); } @@ -651,7 +707,6 @@ export class RiskReviewService { rules: Awaited>, metrics: { phoneTotal: number; - nonWorkingMarketingPhones: number; recentTaskCount: number; }, ): RuleEvaluation[] { @@ -659,9 +714,7 @@ export class RiskReviewService { for (const rule of rules) { const threshold = rule.thresholdValue; const actualValue = metricValue(rule.metric, metrics); - const shouldHit = rule.code === 'TASK_CREATE_FREQUENCY' - ? actualValue >= threshold - : actualValue > threshold; + const shouldHit = rule.code === 'TASK_CREATE_FREQUENCY' ? actualValue >= threshold : actualValue > threshold; if (!shouldHit) { continue; } @@ -687,8 +740,16 @@ export class RiskReviewService { throw new BadRequestException('风控阈值必须是大于等于0的有效数字'); } if ( - isPhoneFrequencyRule(data.code) - && (!Number.isInteger(data.thresholdValue) || data.thresholdValue < 1 || data.action === 'manual_review') + data.code === NIGHT_RULE_CODE && + (!Number.isSafeInteger(data.thresholdValue) || + data.thresholdValue > 2_147_483_647 || + (data.action && data.action !== 'manual_review')) + ) { + throw new BadRequestException('夜间累计阈值必须是非负整数,处理动作固定为人工审核'); + } + if ( + isPhoneFrequencyRule(data.code) && + (!Number.isInteger(data.thresholdValue) || data.thresholdValue < 1 || data.action === 'manual_review') ) { throw new BadRequestException('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝'); } @@ -735,6 +796,7 @@ export class RiskReviewService { const startTime = String(config?.startTime ?? '21:00'); const endTime = String(config?.endTime ?? '08:00'); const timeZone = String(config?.timeZone ?? 'Asia/Shanghai'); + if (timeZone !== 'Asia/Shanghai') throw new BadRequestException('夜间发送规则使用北京时间'); if (!isClockTime(startTime) || !isClockTime(endTime) || startTime === endTime) { throw new BadRequestException('非工作时间必须是两个不同的 HH:mm 时间'); } @@ -745,6 +807,36 @@ export class RiskReviewService { } return { startTime, endTime, timeZone }; } + + private async ruleConfigForSave( + code: string, + config?: Record, + applicationId?: string, + oldConfig?: unknown, + ) { + const normalized = this.normalizeRuleConfig(code, config); + if (code !== NIGHT_RULE_CODE || !normalized) return normalized; + const previous = + oldConfig ?? + (applicationId + ? ( + await this.prisma.riskRule.findFirst({ + where: { code, applicationId: null, status: 'active' }, + }) + )?.config + : undefined); + const now = new Date(); + const previousClock = nightClock(previous, now); + const active = nightWindow(now, previousClock); + if (active && (normalized.startTime !== previousClock.startTime || normalized.endTime !== previousClock.endTime)) { + return { + ...normalized, + previousTimeConfig: previousClock, + timeConfigEffectiveAt: active.windowEndsAt.toISOString(), + }; + } + return normalized; + } } function isPhoneFrequencyRule(code: string) { @@ -762,53 +854,16 @@ function isBasicMobileNumber(phone: string) { return /^1\d{10}$/.test(phone); } -function isMarketing(category?: string | null) { - return ['marketing', 'promo', 'promotion', '营销'].includes((category ?? '').toLowerCase()); -} - -function readNonWorkingConfig(config: Prisma.JsonValue | null | undefined) { - const value = config && typeof config === 'object' && !Array.isArray(config) - ? config as Record - : {}; - return { - startTime: typeof value.startTime === 'string' ? value.startTime : '21:00', - endTime: typeof value.endTime === 'string' ? value.endTime : '08:00', - timeZone: typeof value.timeZone === 'string' ? value.timeZone : 'Asia/Shanghai', - }; -} - function jsonObject(config: Prisma.JsonValue | null | undefined) { return config && typeof config === 'object' && !Array.isArray(config) - ? config as Record + ? (config as Record) : undefined; } -function isNonWorkingTime(date: Date, config: { startTime: string; endTime: string; timeZone: string }) { - const parts = new Intl.DateTimeFormat('en-GB', { - timeZone: config.timeZone, - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', - }).formatToParts(date); - const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? 0); - const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? 0); - const current = hour * 60 + minute; - const start = clockMinutes(config.startTime); - const end = clockMinutes(config.endTime); - return start < end - ? current >= start && current < end - : current >= start || current < end; -} - function isClockTime(value: string) { return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value); } -function clockMinutes(value: string) { - const [hour, minute] = value.split(':').map(Number); - return hour * 60 + minute; -} - function evaluateTemplateVariables( templateVariables: Array<{ name: string; required: boolean }>, content: string, @@ -833,13 +888,18 @@ function formatTemplateVariableIssueReason(issues: Array<{ type: string; name: s const details = [ missing.length > 0 ? `缺少必填变量:${missing.join('、')}` : '', extra.length > 0 ? `包含模板未定义变量:${extra.join('、')}` : '', - ].filter(Boolean).join(';'); + ] + .filter(Boolean) + .join(';'); return `模板变量校验失败(${details}),本次提交拒绝`; } function evaluateContent(content: string, sensitiveWords: Array<{ word: string; level: string }>) { const issues: RuleEvaluation[] = []; - const controlMatches = [...content].filter((char) => /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(char)); + const controlMatches = [...content].filter((char) => { + const code = char.codePointAt(0)!; + return code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127; + }); if (controlMatches.length > 0) { issues.push({ ruleCode: 'CONTENT_CONTROL_CHAR', diff --git a/api/src/send-chain/night-sending-gate.spec.ts b/api/src/send-chain/night-sending-gate.spec.ts new file mode 100644 index 0000000..df13ca0 --- /dev/null +++ b/api/src/send-chain/night-sending-gate.spec.ts @@ -0,0 +1,60 @@ +import { SendGatewaySubmitService } from './send-gateway-submit.service'; + +describe('shared dispatch night gate', () => { + function setup() { + const message = { + id: 'm1', + status: 'queued', + tenantId: 't1', + applicationId: 'a1', + batchTaskId: 'b1', + content: '任意内容', + }; + const prisma = { + smsMessageRecord: { + findUnique: jest.fn().mockResolvedValue(message), + findMany: jest.fn().mockResolvedValue([message, { ...message, id: 'm2' }]), + update: jest.fn(), + }, + }; + const risk = { guardNightSending: jest.fn().mockResolvedValue(new Set(['m1'])) }; + const facade = { + refreshTaskProgress: jest.fn(), + selectChannelForMessage: jest.fn(), + submitMessageToGateway: jest.fn(), + }; + const sut = new SendGatewaySubmitService( + prisma as never, + {} as never, + risk as never, + {} as never, + {} as never, + facade as never, + {} as never, + ); + return { sut, prisma, risk, facade }; + } + it('holds single-message jobs before any channel or Submit operation', async () => { + const { sut, facade } = setup(); + expect(await sut.processSendJob({ messageRecordId: 'm1' })).toMatchObject({ + status: 'pending_review', + submitted: false, + }); + expect(facade.selectChannelForMessage).not.toHaveBeenCalled(); + expect(facade.submitMessageToGateway).not.toHaveBeenCalled(); + }); + it('partitions the fast worker batch by persistent gate decisions', async () => { + const { sut } = setup(); + const route = jest.spyOn(sut as never, 'planRoutesBatch').mockResolvedValue({ planned: [], failed: [] } as never); + const result = await sut['processSendJobBatch']([{ messageRecordId: 'm1' }, { messageRecordId: 'm2' }]); + expect(result.get('m1')).toMatchObject({ status: 'pending_review' }); + expect(route).toHaveBeenCalledWith([expect.objectContaining({ id: 'm2' })]); + }); + it('propagates database failures without submitting or misclassifying them as routing failures', async () => { + const { sut, risk, facade, prisma } = setup(); + risk.guardNightSending.mockRejectedValue(new Error('database unavailable')); + await expect(sut.processSendJob({ messageRecordId: 'm1' })).rejects.toThrow('database unavailable'); + expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled(); + expect(facade.submitMessageToGateway).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 95d17ed..e894b7f 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -62,7 +62,17 @@ function createPrismaMock() { retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, - items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }], + items: [ + { + id: 'item-1', + groupId: 'group-1', + channelId: 'channel-1', + carrier: 'mobile', + priority: 1, + province: null, + channel, + }, + ], }, }; const prisma = { @@ -70,7 +80,15 @@ function createPrismaMock() { findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }), }, smsApplication: { - findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: true, customerUnitPrice: 3, queuePriority: 'normal' }), + findUnique: jest.fn().mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + status: 'active', + interfaceEnabled: true, + customerUnitPrice: 3, + queuePriority: 'normal', + }), findMany: jest.fn().mockResolvedValue([{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' }]), findFirst: jest.fn().mockResolvedValue({ id: 'app-1', @@ -108,7 +126,9 @@ function createPrismaMock() { findMany: jest.fn().mockResolvedValue([]), }, smsSignature: { - findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }), + findFirst: jest + .fn() + .mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }), }, smsDrainageInfo: { findMany: jest.fn().mockResolvedValue([]), @@ -148,7 +168,9 @@ function createPrismaMock() { findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', applicationId: 'app-1' }]), }, phoneCarrierRule: { - findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]), + findMany: jest + .fn() + .mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]), }, phoneSegment: { findMany: jest.fn().mockResolvedValue([{ prefix: '1380000', province: '山东', city: '济南' }]), @@ -165,12 +187,25 @@ function createPrismaMock() { create: jest.fn().mockResolvedValue({ id: 'submit-1' }), createMany: jest.fn().mockResolvedValue({ count: 2 }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), - findFirst: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted', createdAt: new Date('2026-07-01T10:00:00.000Z') }), - findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve( - where.retryOfSubmitRecordId - ? null - : { id: 'submit-1', messageRecordId: 'record-1', channelId: 'channel-1', submitId: 'SUB-1', submitStatus: 'accepted' }, - )), + findFirst: jest.fn().mockResolvedValue({ + id: 'submit-1', + submitId: 'SUB-1', + submitStatus: 'accepted', + createdAt: new Date('2026-07-01T10:00:00.000Z'), + }), + findUnique: jest.fn().mockImplementation(({ where }) => + Promise.resolve( + where.retryOfSubmitRecordId + ? null + : { + id: 'submit-1', + messageRecordId: 'record-1', + channelId: 'channel-1', + submitId: 'SUB-1', + submitStatus: 'accepted', + }, + ), + ), count: jest.fn().mockResolvedValue(1), findMany: jest.fn().mockResolvedValue([]), }, @@ -192,7 +227,11 @@ function createPrismaMock() { }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }), - findMany: jest.fn().mockImplementation(({ where }) => Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId })))), + findMany: jest + .fn() + .mockImplementation(({ where }) => + Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId }))), + ), }, smsReceiptRecord: { create: jest.fn().mockResolvedValue({ id: 'receipt-1' }), @@ -242,7 +281,11 @@ function createPrismaMock() { update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }), }, cmppDownstreamDelivery: { - create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', ...data, createdAt: new Date(), updatedAt: new Date() })), + create: jest + .fn() + .mockImplementation(({ data }) => + Promise.resolve({ id: 'delivery-1', ...data, createdAt: new Date(), updatedAt: new Date() }), + ), findUnique: jest.fn().mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', @@ -258,7 +301,16 @@ function createPrismaMock() { application: { cmppAccount: '100001' }, }), findMany: jest.fn().mockResolvedValue([]), - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', ...data })), + update: jest.fn().mockImplementation(({ data }) => + Promise.resolve({ + id: 'delivery-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + deliveryType: 'receipt', + ...data, + }), + ), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, cmppDownstreamDeliveryAttempt: { @@ -266,12 +318,14 @@ function createPrismaMock() { findMany: jest.fn().mockResolvedValue([]), }, upstreamReceiptInbox: { - upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ - id: 'receipt-inbox-1', - attemptCount: 0, - receivedAt: new Date(), - ...create, - })), + upsert: jest.fn().mockImplementation(({ create }) => + Promise.resolve({ + id: 'receipt-inbox-1', + attemptCount: 0, + receivedAt: new Date(), + ...create, + }), + ), findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'receipt-inbox-1', ...data })), @@ -306,11 +360,23 @@ function createPrismaMock() { queuePriority: 'normal', route: { channelCode: 'CMPP-A', cmppAccountCode: 'account-a', priority: 0 }, cmpp: { serviceId: 'SMS', srcId: '10690000', registeredDelivery: 1, msgFmt: 8 }, - upstream: { gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'account-a', passwordCipher: 'secret', cmppVersion: '3.0' }, + upstream: { + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + account: 'account-a', + passwordCipher: 'secret', + cmppVersion: '3.0', + }, retry: { attempt: 0, maxAttempts: 1 }, }, }), - update: jest.fn().mockResolvedValue({ id: 'dead-1', tenantId: 'tenant-1', streamMessageId: '1710000000000-0', submitId: 'SUB-1', messageId: 'MSG-1' }), + update: jest.fn().mockResolvedValue({ + id: 'dead-1', + tenantId: 'tenant-1', + streamMessageId: '1710000000000-0', + submitId: 'SUB-1', + messageId: 'MSG-1', + }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), findMany: jest.fn().mockResolvedValue([]), }, @@ -373,16 +439,13 @@ function createPrismaMock() { $executeRaw: jest.fn().mockResolvedValue(1), $transaction: jest.fn(), }; - prisma.$transaction.mockImplementation((operations: any) => typeof operations === 'function' - ? operations(prisma) - : Promise.all(operations)); + prisma.$transaction.mockImplementation((operations: any) => + typeof operations === 'function' ? operations(prisma) : Promise.all(operations), + ); return prisma; } -function createService( - prisma = createPrismaMock(), - openApi?: { queueWebhookEvent: jest.Mock }, -) { +function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEvent: jest.Mock }) { const billing = { estimateSmsCost: jest.fn().mockReturnValue({ billingUnitsPerMessage: 1, @@ -398,6 +461,8 @@ function createService( refund: jest.fn().mockResolvedValue({ id: 'tx-refund' }), } as unknown as BillingService; const riskReview = { + guardNightSending: jest.fn().mockResolvedValue(new Set()), + pendingNightContinuations: jest.fn().mockResolvedValue([]), evaluateTask: jest.fn().mockResolvedValue({ status: 'approved', reason: null, @@ -411,13 +476,7 @@ function createService( const phoneFrequency = { reserve: jest.fn().mockResolvedValue(new Map()), }; - const service = new SendChainService( - prisma as never, - billing, - riskReview, - phoneFrequency as never, - openApi as never, - ); + const service = new SendChainService(prisma as never, billing, riskReview, phoneFrequency as never, openApi as never); service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true }); service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined); service['getSendQueue'] = jest.fn().mockReturnValue({ add: jest.fn().mockResolvedValue(undefined) }); @@ -439,14 +498,28 @@ describe('SendChainService', () => { userAgent: 'jest', }); - expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['13800000001', '13800000002'] })); + expect(riskReview.evaluateTask).toHaveBeenCalledWith( + expect.objectContaining({ phones: ['13800000001', '13800000002'] }), + ); expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneTotal: 2, status: 'ready', progressTotal: 2, auditStatus: 'approved' }), }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ - expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }), - expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }), + expect.objectContaining({ + phoneNumber: '13800000001', + status: 'queued', + billingUnits: 1, + amountCents: 3, + queuePriority: 'normal', + }), + expect.objectContaining({ + phoneNumber: '13800000002', + status: 'queued', + billingUnits: 1, + amountCents: 3, + queuePriority: 'normal', + }), ]), }); expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, relatedId: 'task-1' })); @@ -498,12 +571,17 @@ describe('SendChainService', () => { it('rejects only phones that hit application frequency rules and excludes them from billing', async () => { const { service, prisma, billing, phoneFrequency } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); - phoneFrequency.reserve.mockResolvedValue(new Map([ - ['13800000002', { - code: 'PHONE_FREQUENCY_LIMIT', - reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条', - }], - ])); + phoneFrequency.reserve.mockResolvedValue( + new Map([ + [ + '13800000002', + { + code: 'PHONE_FREQUENCY_LIMIT', + reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条', + }, + ], + ]), + ); (billing.estimateSmsCost as jest.Mock).mockReturnValue({ billingUnitsPerMessage: 1, totalBillingUnits: 1, @@ -519,12 +597,7 @@ describe('SendChainService', () => { phones: ['13800000001', '13800000002'], }); - expect(phoneFrequency.reserve).toHaveBeenCalledWith( - 'tenant-1', - 'app-1', - ['13800000001', '13800000002'], - 'client', - ); + expect(phoneFrequency.reserve).toHaveBeenCalledWith('tenant-1', 'app-1', ['13800000001', '13800000002'], 'client'); expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ phoneCount: 1 })); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ @@ -549,12 +622,17 @@ describe('SendChainService', () => { reason: '命中人工审核规则', task: { id: 'review-task-1' }, }); - phoneFrequency.reserve.mockResolvedValue(new Map([ - ['13800000001', { - code: 'PHONE_FREQUENCY_LIMIT', - reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条', - }], - ])); + phoneFrequency.reserve.mockResolvedValue( + new Map([ + [ + '13800000001', + { + code: 'PHONE_FREQUENCY_LIMIT', + reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条', + }, + ], + ]), + ); await service.createBatchTask({ tenantId: 'tenant-1', @@ -600,11 +678,13 @@ describe('SendChainService', () => { }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ - data: [expect.objectContaining({ - phoneNumber: '13800000001', - status: 'pending_review', - reviewTaskId: 'review-task-1', - })], + data: [ + expect.objectContaining({ + phoneNumber: '13800000001', + status: 'pending_review', + reviewTaskId: 'review-task-1', + }), + ], }); }); @@ -612,13 +692,15 @@ describe('SendChainService', () => { const { service, prisma, billing } = createService(); prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]); - await expect(service.createBatchTask({ - tenantId: 'tenant-1', - applicationId: 'app-1', - templateId: 'tpl-1', - content: 'hello', - phones: ['13800000001', '13800000002'], - })).rejects.toThrow('应用当日发送上限1条'); + await expect( + service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: 'hello', + phones: ['13800000001', '13800000002'], + }), + ).rejects.toThrow('应用当日发送上限1条'); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.createMany).not.toHaveBeenCalled(); @@ -629,44 +711,74 @@ describe('SendChainService', () => { const { service, prisma, riskReview } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsTemplate.findFirst.mockResolvedValue({ - id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}', - auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', auditStatus: 'approved' }, + id: 'tpl-http', + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '【签名】验证码${code}', + auditStatus: 'approved', + signatureId: 'sig-1', + signature: { id: 'sig-1', auditStatus: 'approved' }, }); prisma.smsTemplate.findUnique.mockResolvedValue({ - id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}', - auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, + id: 'tpl-http', + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '【签名】验证码${code}', + auditStatus: 'approved', + signatureId: 'sig-1', + signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, }); await service.createHttpBatchTask({ - tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码123456', phones: ['13800000001'], - sourceIp: '127.0.0.1', clientMessageId: 'client-http-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '【签名】验证码123456', + phones: ['13800000001'], + sourceIp: '127.0.0.1', + clientMessageId: 'client-http-1', }); - expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ - templateId: 'tpl-http', - variables: { code: '123456' }, - })); - expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ id: 'task-1', sourceType: 'api' }), - })); + expect(riskReview.evaluateTask).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: 'tpl-http', + variables: { code: '123456' }, + }), + ); + expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'task-1', sourceType: 'api' }), + }), + ); }); it('persists the unique longest approved drainage URL match on new message records', async () => { const { service, prisma } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsTemplate.findUnique.mockResolvedValue({ - id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved', + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + auditStatus: 'approved', content: '【签名】详情请访问 https://a.example/landing', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' }, }); prisma.smsDrainageInfo.findMany.mockResolvedValue([ { id: 'drain-short', url: 'https://a.example', auditStatus: 'approved', updatedAt: new Date('2026-07-01') }, - { id: 'drain-long', url: 'https://a.example/landing', auditStatus: 'approved', updatedAt: new Date('2026-07-02') }, + { + id: 'drain-long', + url: 'https://a.example/landing', + auditStatus: 'approved', + updatedAt: new Date('2026-07-02'), + }, ]); await service.createBatchTask({ - tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', - content: '【签名】详情请访问 https://a.example/landing', phones: ['13800000001'], + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: '【签名】详情请访问 https://a.example/landing', + phones: ['13800000001'], }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ @@ -677,15 +789,24 @@ describe('SendChainService', () => { it('rejects a task when the submitted content no longer matches the selected approved template', async () => { const { service, prisma, riskReview } = createService(); prisma.smsTemplate.findUnique.mockResolvedValue({ - id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', - content: '【签名】验证码${code}', auditStatus: 'approved', + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + content: '【签名】验证码${code}', + auditStatus: 'approved', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, }); - await expect(service.createBatchTask({ - tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', - content: '【签名】被篡改的正文', phones: ['13800000001'], - })).rejects.toThrow('短信内容与选定的审核模板不匹配'); + await expect( + service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: '【签名】被篡改的正文', + phones: ['13800000001'], + }), + ).rejects.toThrow('短信内容与选定的审核模板不匹配'); expect(riskReview.evaluateTask).not.toHaveBeenCalled(); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); @@ -694,15 +815,24 @@ describe('SendChainService', () => { it('rejects a new task that selects a deleted template', async () => { const { service, prisma } = createService(); prisma.smsTemplate.findUnique.mockResolvedValue({ - id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', - content: 'hello', auditStatus: 'deleted', + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + content: 'hello', + auditStatus: 'deleted', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, }); - await expect(service.createBatchTask({ - tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', - content: 'hello', phones: ['13800000001'], - })).rejects.toThrow('短信模板不存在、未通过审核或不属于当前应用'); + await expect( + service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: 'hello', + phones: ['13800000001'], + }), + ).rejects.toThrow('短信模板不存在、未通过审核或不属于当前应用'); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); }); @@ -710,60 +840,96 @@ describe('SendChainService', () => { it('rejects free content without an approved leading signature', async () => { const { service, prisma } = createService(); prisma.smsApplication.findUnique.mockResolvedValue({ - id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true, - customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send', + id: 'app-1', + tenantId: 'tenant-1', + status: 'active', + interfaceEnabled: true, + customerUnitPrice: 3, + queuePriority: 'normal', + templateMismatchMode: 'direct_send', }); prisma.smsSignature.findFirst.mockResolvedValue(null); - await expect(service.createBatchTask({ - tenantId: 'tenant-1', applicationId: 'app-1', content: '没有签名的自由内容', phones: ['13800000001'], - })).rejects.toThrow('短信内容未以当前应用已审核通过的签名开头'); + await expect( + service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '没有签名的自由内容', + phones: ['13800000001'], + }), + ).rejects.toThrow('短信内容未以当前应用已审核通过的签名开头'); }); it('allows signed free content only when the application explicitly uses direct send', async () => { const { service, prisma } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsApplication.findUnique.mockResolvedValue({ - id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true, - customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send', + id: 'app-1', + tenantId: 'tenant-1', + status: 'active', + interfaceEnabled: true, + customerUnitPrice: 3, + queuePriority: 'normal', + templateMismatchMode: 'direct_send', }); prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【签名】', auditStatus: 'approved' }); - await expect(service.createBatchTask({ - tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】允许直接发送的自由内容', phones: ['13800000001'], - })).resolves.toBeDefined(); + await expect( + service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '【签名】允许直接发送的自由内容', + phones: ['13800000001'], + }), + ).resolves.toBeDefined(); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ signatureId: 'sig-1' })], }); }); - it.each(['pending', 'rejected'])('does not block a matched %s drainage URL and still preserves the matched resource', async (auditStatus) => { - const { service, prisma, riskReview } = createService(); - prisma.smsTemplate.findUnique.mockResolvedValue({ - id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', - content: '【签名】详情 https://blocked.example', auditStatus: 'approved', - signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, - }); - prisma.smsDrainageInfo.findMany.mockResolvedValue([ - { id: 'drain-blocked', url: 'https://blocked.example', auditStatus, updatedAt: new Date('2026-07-21') }, - ]); + it.each(['pending', 'rejected'])( + 'does not block a matched %s drainage URL and still preserves the matched resource', + async (auditStatus) => { + const { service, prisma, riskReview } = createService(); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + content: '【签名】详情 https://blocked.example', + auditStatus: 'approved', + signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, + }); + prisma.smsDrainageInfo.findMany.mockResolvedValue([ + { id: 'drain-blocked', url: 'https://blocked.example', auditStatus, updatedAt: new Date('2026-07-21') }, + ]); - await expect(service.createBatchTask({ - tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', - content: '【签名】详情 https://blocked.example', phones: ['13800000001'], - })).resolves.toBeDefined(); + await expect( + service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: '【签名】详情 https://blocked.example', + phones: ['13800000001'], + }), + ).resolves.toBeDefined(); - expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ status: 'ready', rejectReason: null }), - }); - expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ - data: [expect.objectContaining({ - drainageInfoId: 'drain-blocked', status: 'queued', errorMessage: undefined, - })], - }); - expect(riskReview.evaluateTask).toHaveBeenCalled(); - }); + expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ status: 'ready', rejectReason: null }), + }); + expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + drainageInfoId: 'drain-blocked', + status: 'queued', + errorMessage: undefined, + }), + ], + }); + expect(riskReview.evaluateTask).toHaveBeenCalled(); + }, + ); it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => { const { service, prisma, billing } = createService(); @@ -789,7 +955,9 @@ describe('SendChainService', () => { expect(billing.freeze).not.toHaveBeenCalled(); expect(service.enqueueBatchTask).not.toHaveBeenCalled(); - prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1' }]); + prisma.smsBatchTask.findMany.mockResolvedValue([ + { id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1' }, + ]); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); await expect(service.dispatchDueScheduledTasks(new Date(Date.now() + 120_000))).resolves.toEqual({ @@ -810,13 +978,16 @@ describe('SendChainService', () => { it('atomically claims a due scheduled task so concurrent scanners only freeze and enqueue once', async () => { const { service, prisma, billing } = createService(); const dueTask = { - id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', - status: 'scheduled', scheduledAt: new Date(Date.now() - 1_000), updatedAt: new Date(Date.now() - 1_000), + id: 'task-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + status: 'scheduled', + scheduledAt: new Date(Date.now() - 1_000), + updatedAt: new Date(Date.now() - 1_000), }; prisma.smsBatchTask.findMany.mockResolvedValue([dueTask]); - prisma.smsBatchTask.updateMany - .mockResolvedValueOnce({ count: 1 }) - .mockResolvedValueOnce({ count: 0 }); + prisma.smsBatchTask.updateMany.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 }); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); @@ -833,11 +1004,17 @@ describe('SendChainService', () => { it('recovers a stale claimed task without freezing its balance twice', async () => { const { service, prisma, billing } = createService(); const now = new Date(); - prisma.smsBatchTask.findMany.mockResolvedValue([{ - id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', - status: 'scheduled_dispatching', scheduledAt: new Date(now.getTime() - 300_000), - updatedAt: new Date(now.getTime() - 300_000), - }]); + prisma.smsBatchTask.findMany.mockResolvedValue([ + { + id: 'task-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + status: 'scheduled_dispatching', + scheduledAt: new Date(now.getTime() - 300_000), + updatedAt: new Date(now.getTime() - 300_000), + }, + ]); prisma.accountTransaction.findFirst.mockResolvedValue({ id: 'frozen-transaction-1' }); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); @@ -848,7 +1025,11 @@ describe('SendChainService', () => { }); expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({ - where: expect.objectContaining({ id: 'task-1', status: 'scheduled_dispatching', updatedAt: { lt: expect.any(Date) } }), + where: expect.objectContaining({ + id: 'task-1', + status: 'scheduled_dispatching', + updatedAt: { lt: expect.any(Date) }, + }), data: { status: 'scheduled_recovering' }, }); expect(billing.freeze).not.toHaveBeenCalled(); @@ -856,9 +1037,15 @@ describe('SendChainService', () => { it('keeps a zero-fee task recoverable when queue enqueue fails after preparation', async () => { const { service, prisma } = createService(); - prisma.smsBatchTask.findMany.mockResolvedValue([{ - id: 'task-free', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled', - }]); + prisma.smsBatchTask.findMany.mockResolvedValue([ + { + id: 'task-free', + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + status: 'scheduled', + }, + ]); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-free', amountCents: 0, billingUnits: 1 }]); service.enqueueBatchTask = jest.fn().mockRejectedValue(new Error('Redis unavailable')); @@ -926,9 +1113,11 @@ describe('SendChainService', () => { await service.listBatchTasks('tenant-1', 'queued'); - expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' }, - })); + expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' }, + }), + ); }); it('coalesces concurrent batch progress refreshes and keeps a trailing refresh', async () => { @@ -936,7 +1125,12 @@ describe('SendChainService', () => { prisma.$executeRaw.mockResolvedValue(0); let resolveFirst: ((value: Array<{ status: string; _count: { _all: number } }>) => void) | undefined; prisma.smsMessageRecord.groupBy - .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) .mockResolvedValue([{ status: 'delivered', _count: { _all: 2 } }]); const first = service['submission'].refreshTaskProgress('task-1'); @@ -991,9 +1185,11 @@ describe('SendChainService', () => { await expect(service.getBatchTask('task-cmpp', 'tenant-1', 'client')).rejects.toThrow('SMS batch task not found'); await expect(service.listClientTaskMessages('task-cmpp', 'tenant-1')).rejects.toThrow('SMS batch task not found'); - expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'task-cmpp', tenantId: 'tenant-1', sourceType: 'client' }, - })); + expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'task-cmpp', tenantId: 'tenant-1', sourceType: 'client' }, + }), + ); expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled(); }); @@ -1029,12 +1225,21 @@ describe('SendChainService', () => { it('dispatches an accepted scheduled task from its snapshot after the template is deleted', async () => { const { service, prisma, billing } = createService(); prisma.smsTemplate.findUnique.mockResolvedValue({ - id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted', + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + auditStatus: 'deleted', signature: { id: 'sig-1', auditStatus: 'approved' }, }); - prisma.smsBatchTask.findMany.mockResolvedValue([{ - id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled', - }]); + prisma.smsBatchTask.findMany.mockResolvedValue([ + { + id: 'task-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + status: 'scheduled', + }, + ]); prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); @@ -1051,12 +1256,21 @@ describe('SendChainService', () => { const { service, prisma, billing } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsTemplate.findUnique.mockResolvedValue({ - id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted', + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + auditStatus: 'deleted', signature: { id: 'sig-1', auditStatus: 'deleted' }, }); - prisma.smsBatchTask.findMany.mockResolvedValue([{ - id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled', - }]); + prisma.smsBatchTask.findMany.mockResolvedValue([ + { + id: 'task-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + status: 'scheduled', + }, + ]); await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({ dispatched: 0, @@ -1126,19 +1340,23 @@ describe('SendChainService', () => { it('returns the application enterprise code and audits the inbound parameters after Gateway authentication', async () => { const { service, prisma } = createService(); - await expect(service.authenticateInboundApplication({ - account: '100001', - password: 'secret-hash', - remoteIp: '127.0.0.1', - version: 'cmpp30', - requestedVersion: 48, - })).resolves.toEqual(expect.objectContaining({ - account: '100001', - enterpriseCode: 'SP0001', - maxConnections: 2, - windowSize: 32, - status: 'authenticated', - })); + await expect( + service.authenticateInboundApplication({ + account: '100001', + password: 'secret-hash', + remoteIp: '127.0.0.1', + version: 'cmpp30', + requestedVersion: 48, + }), + ).resolves.toEqual( + expect.objectContaining({ + account: '100001', + enterpriseCode: 'SP0001', + maxConnections: 2, + windowSize: 32, + status: 'authenticated', + }), + ); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: 'tenant-1', @@ -1148,7 +1366,12 @@ describe('SendChainService', () => { ipAddress: '127.0.0.1', detail: expect.objectContaining({ result: 'authenticated', - request: expect.objectContaining({ account: '100001', password: 'secret-hash', version: 'cmpp30', requestedVersion: 48 }), + request: expect.objectContaining({ + account: '100001', + password: 'secret-hash', + version: 'cmpp30', + requestedVersion: 48, + }), }), }), }); @@ -1166,11 +1389,13 @@ describe('SendChainService', () => { ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); - await expect(service.authenticateInboundApplication({ - account: '100001', - password: 'secret-hash', - remoteIp: '127.0.0.1', - })).rejects.toThrow('CMPP interface is disabled for this application'); + await expect( + service.authenticateInboundApplication({ + account: '100001', + password: 'secret-hash', + remoteIp: '127.0.0.1', + }), + ).rejects.toThrow('CMPP interface is disabled for this application'); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: 'tenant-1', @@ -1184,14 +1409,16 @@ describe('SendChainService', () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue(null); - await expect(service.authenticateInboundApplication({ - account: 'ATTACKER', - authSource: 'invalid-auth-source', - timestamp: 120000000, - remoteIp: '203.0.113.9', - version: 'cmpp30', - requestedVersion: 48, - })).rejects.toThrow('CMPP account is invalid or disabled'); + await expect( + service.authenticateInboundApplication({ + account: 'ATTACKER', + authSource: 'invalid-auth-source', + timestamp: 120000000, + remoteIp: '203.0.113.9', + version: 'cmpp30', + requestedVersion: 48, + }), + ).rejects.toThrow('CMPP account is invalid or disabled'); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: undefined, @@ -1229,34 +1456,44 @@ describe('SendChainService', () => { httpConfig: { enabled: false }, }); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: 'hello', - sequenceId: 701, - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ - accepted: true, - messageRecordId: 'record-1', - status: 'accepted', - })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: 'hello', + sequenceId: 701, + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual( + expect.objectContaining({ + accepted: true, + messageRecordId: 'record-1', + status: 'accepted', + }), + ); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); - expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'record-1' }, - data: expect.objectContaining({ - status: 'failed', - receiptStatus: 'undelivered', - receiptRawStatus: 'REJECTD', - errorCode: 'INTERFACE', + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'record-1' }, + data: expect.objectContaining({ + status: 'failed', + receiptStatus: 'undelivered', + receiptRawStatus: 'REJECTD', + errorCode: 'INTERFACE', + }), }), - })); - expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }), - })); - expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }), - })); + ); + expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }), + }), + ); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }), + }), + ); expect(service['postGatewayControl']).toHaveBeenCalledWith( '/downstream/receipt', expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }), @@ -1287,10 +1524,12 @@ describe('SendChainService', () => { payload: { receiptStatus: 'delivered' }, }); - expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({ - applicationId: 'app-1', - eventType: 'receipt', - })); + expect(openApi.queueWebhookEvent).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: 'app-1', + eventType: 'receipt', + }), + ); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); }); @@ -1326,19 +1565,33 @@ describe('SendChainService', () => { remoteIp: '127.0.0.1', }); - expect(result).toEqual(expect.objectContaining({ - accepted: true, - phoneCount: 2, - messages: [ - expect.objectContaining({ phoneNumber: '13800000001', messageRecordId: 'record-1' }), - expect.objectContaining({ phoneNumber: '13900000002', messageRecordId: 'record-2' }), - ], - })); + expect(result).toEqual( + expect.objectContaining({ + accepted: true, + phoneCount: 2, + messages: [ + expect.objectContaining({ phoneNumber: '13800000001', messageRecordId: 'record-1' }), + expect.objectContaining({ phoneNumber: '13900000002', messageRecordId: 'record-2' }), + ], + }), + ); expect(result.messageId).toBe(result.messages[0].messageId); expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(2); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2); - expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13800000001', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) }); - expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13900000002', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) }); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + phoneNumber: '13800000001', + cmppSubmitSequenceId: '777823876', + cmppSubmitGroupMessageId: result.messageId, + }), + }); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + phoneNumber: '13900000002', + cmppSubmitSequenceId: '777823876', + cmppSubmitGroupMessageId: result.messageId, + }), + }); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); }); @@ -1365,18 +1618,18 @@ describe('SendChainService', () => { createdAt: new Date(), segments, }; - prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve( - segments.length ? { ...group, segments: [...segments] } : null, - )); + prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => + Promise.resolve(segments.length ? { ...group, segments: [...segments] } : null), + ); prisma.cmppInboundLongMessage.create.mockResolvedValue(group); prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => { const segment = { id: `segment-${data.segmentIndex}`, ...data }; segments.push(segment); return Promise.resolve(segment); }); - prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve( - [...segments].sort((a, b) => a.segmentIndex - b.segmentIndex), - )); + prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => + Promise.resolve([...segments].sort((a, b) => a.segmentIndex - b.segmentIndex)), + ); prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => { Object.assign(group, data); return Promise.resolve({ ...group }); @@ -1390,12 +1643,14 @@ describe('SendChainService', () => { remoteIp: '127.0.0.1', longMessage: { reference: 16, total: 2, index: 1, format: 8 }, }); - expect(first).toEqual(expect.objectContaining({ - accepted: true, - fragmentPending: true, - messageId: 'MSG-LONG-1', - receivedSegments: 1, - })); + expect(first).toEqual( + expect.objectContaining({ + accepted: true, + fragmentPending: true, + messageId: 'MSG-LONG-1', + receivedSegments: 1, + }), + ); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); @@ -1407,11 +1662,13 @@ describe('SendChainService', () => { remoteIp: '127.0.0.1', longMessage: { reference: 16, total: 2, index: 2, format: 8 }, }); - expect(second).toEqual(expect.objectContaining({ - accepted: true, - messageId: 'MSG-LONG-1', - messageRecordId: 'record-1', - })); + expect(second).toEqual( + expect.objectContaining({ + accepted: true, + messageId: 'MSG-LONG-1', + messageRecordId: 'record-1', + }), + ); expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(1); expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ content: '【签名】第一片第二片正文', phoneTotal: 1 }), @@ -1424,10 +1681,12 @@ describe('SendChainService', () => { cmppSubmitGroupMessageId: 'MSG-LONG-1', }), }); - expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ - content: '【签名】第一片第二片正文', - phoneCount: 1, - })); + expect(billing.estimateSmsCost).toHaveBeenCalledWith( + expect.objectContaining({ + content: '【签名】第一片第二片正文', + phoneCount: 1, + }), + ); expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({ where: { id: 'long-group-1' }, data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }), @@ -1456,18 +1715,18 @@ describe('SendChainService', () => { createdAt: new Date(), segments, }; - prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve( - segments.length ? { ...group, segments: [...segments] } : null, - )); + prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => + Promise.resolve(segments.length ? { ...group, segments: [...segments] } : null), + ); prisma.cmppInboundLongMessage.create.mockResolvedValue(group); prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => { const segment = { id: `segment-${data.segmentIndex}`, ...data }; segments.push(segment); return Promise.resolve(segment); }); - prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve( - [...segments].sort((a, b) => a.segmentIndex - b.segmentIndex), - )); + prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => + Promise.resolve([...segments].sort((a, b) => a.segmentIndex - b.segmentIndex)), + ); prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => { Object.assign(group, data); return Promise.resolve({ ...group }); @@ -1481,33 +1740,43 @@ describe('SendChainService', () => { remoteIp: '127.0.0.1', longMessage: { reference: 17, total: 2, index: 2, format: 8 }, }; - await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({ - fragmentPending: true, - receivedSegments: 1, - })); - await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({ - fragmentPending: true, - receivedSegments: 1, - })); + await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual( + expect.objectContaining({ + fragmentPending: true, + receivedSegments: 1, + }), + ); + await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual( + expect.objectContaining({ + fragmentPending: true, + receivedSegments: 1, + }), + ); expect(prisma.cmppInboundLongMessageSegment.create).toHaveBeenCalledTimes(1); - await expect(service.submitInboundMessage({ - ...secondFragment, - content: '冲突的第二片', - })).rejects.toThrow('fragment 2 conflicts'); + await expect( + service.submitInboundMessage({ + ...secondFragment, + content: '冲突的第二片', + }), + ).rejects.toThrow('fragment 2 conflicts'); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: '【签名】第一片', - sequenceId: 201, - remoteIp: '127.0.0.1', - longMessage: { reference: 17, total: 2, index: 1, format: 8 }, - })).resolves.toEqual(expect.objectContaining({ - accepted: true, - messageId: 'MSG-LONG-2', - messageRecordId: 'record-1', - })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: '【签名】第一片', + sequenceId: 201, + remoteIp: '127.0.0.1', + longMessage: { reference: 17, total: 2, index: 1, format: 8 }, + }), + ).resolves.toEqual( + expect.objectContaining({ + accepted: true, + messageId: 'MSG-LONG-2', + messageRecordId: 'record-1', + }), + ); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -1564,18 +1833,22 @@ describe('SendChainService', () => { return Promise.resolve({ ...group }); }); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: '第二片正文', - sequenceId: 302, - remoteIp: '127.0.0.1', - longMessage: { reference: 18, total: 2, index: 2, format: 8 }, - })).resolves.toEqual(expect.objectContaining({ - accepted: true, - messageId: 'MSG-LONG-RESTART', - messageRecordId: 'record-1', - })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: '第二片正文', + sequenceId: 302, + remoteIp: '127.0.0.1', + longMessage: { reference: 18, total: 2, index: 2, format: 8 }, + }), + ).resolves.toEqual( + expect.objectContaining({ + accepted: true, + messageId: 'MSG-LONG-RESTART', + messageRecordId: 'record-1', + }), + ); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -1634,30 +1907,36 @@ describe('SendChainService', () => { Object.assign(group, data, { updatedAt: new Date() }); return Promise.resolve({ ...group }); }); - prisma.smsMessageRecord.findMany.mockResolvedValue([{ - id: 'persisted-record-1', - tenantId: 'tenant-1', - applicationId: 'app-1', - batchTaskId: 'persisted-task-1', - messageId: 'MSG-LONG-AFTER-RECORD', - phoneNumber: '13800000001', - status: 'failed', - errorCode: 'SIGNATURE', - }]); + prisma.smsMessageRecord.findMany.mockResolvedValue([ + { + id: 'persisted-record-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + batchTaskId: 'persisted-task-1', + messageId: 'MSG-LONG-AFTER-RECORD', + phoneNumber: '13800000001', + status: 'failed', + errorCode: 'SIGNATURE', + }, + ]); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: '第二片正文', - sequenceId: 402, - remoteIp: '127.0.0.1', - longMessage: { reference: 19, total: 2, index: 2, format: 8 }, - })).resolves.toEqual(expect.objectContaining({ - accepted: true, - messageId: 'MSG-LONG-AFTER-RECORD', - messageRecordId: 'persisted-record-1', - taskId: 'persisted-task-1', - })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: '第二片正文', + sequenceId: 402, + remoteIp: '127.0.0.1', + longMessage: { reference: 19, total: 2, index: 2, format: 8 }, + }), + ).resolves.toEqual( + expect.objectContaining({ + accepted: true, + messageId: 'MSG-LONG-AFTER-RECORD', + messageRecordId: 'persisted-record-1', + taskId: 'persisted-task-1', + }), + ); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({ @@ -1690,9 +1969,13 @@ describe('SendChainService', () => { const { service, prisma, billing } = createService(); prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]); let taskIndex = 0; - prisma.smsBatchTask.create.mockImplementation(({ data }) => Promise.resolve({ id: `task-${++taskIndex}`, ...data })); + prisma.smsBatchTask.create.mockImplementation(({ data }) => + Promise.resolve({ id: `task-${++taskIndex}`, ...data }), + ); let messageIndex = 0; - prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({ id: `record-${++messageIndex}`, ...data })); + prisma.smsMessageRecord.create.mockImplementation(({ data }) => + Promise.resolve({ id: `record-${++messageIndex}`, ...data }), + ); const result = await service.submitInboundMessage({ account: '100001', @@ -1715,17 +1998,21 @@ describe('SendChainService', () => { it('returns a failure receipt for an invalid destination while other CMPP destinations continue', async () => { const { service, prisma } = createService(); let messageIndex = 0; - prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({ - id: `record-${++messageIndex}`, - ...data, - })); + prisma.smsMessageRecord.create.mockImplementation(({ data }) => + Promise.resolve({ + id: `record-${++messageIndex}`, + ...data, + }), + ); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumbers: ['13800000001', 'invalid'], - content: 'hello', - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true, phoneCount: 2 })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumbers: ['13800000001', 'invalid'], + content: 'hello', + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ accepted: true, phoneCount: 2 })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2); expect(prisma.smsApplication.findFirst).toHaveBeenCalledTimes(1); @@ -1767,13 +2054,15 @@ describe('SendChainService', () => { tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: 'hello', - srcId: '000001', - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: 'hello', + srcId: '000001', + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ accepted: true })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ clientSrcId: '000001', applicationExtension: '0001' }), @@ -1795,13 +2084,15 @@ describe('SendChainService', () => { tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, }); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: 'hello', - srcId: '0001', - remoteIp: '127.0.0.1', - })).rejects.toThrow('CMPP Src_Id must equal the access number assigned to this application: 000001'); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: 'hello', + srcId: '0001', + remoteIp: '127.0.0.1', + }), + ).rejects.toThrow('CMPP Src_Id must equal the access number assigned to this application: 000001'); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); @@ -1811,13 +2102,15 @@ describe('SendChainService', () => { const { service, prisma, riskReview } = createService(); prisma.smsTemplate.findFirst.mockResolvedValue(null); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: 'unreported content', - sequenceId: 1216579149, - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: 'unreported content', + sequenceId: 1216579149, + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ cmppSubmitSequenceId: '1216579149' }), @@ -1828,7 +2121,12 @@ describe('SendChainService', () => { }); expect(service['postGatewayControl']).toHaveBeenCalledWith( '/downstream/receipt', - expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE', submitSequenceId: 1216579149 }), + expect.objectContaining({ + receiptStatus: 'undelivered', + rawStatus: 'REJECTD', + errorCode: 'TEMPLATE', + submitSequenceId: 1216579149, + }), ); expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled(); }); @@ -1837,25 +2135,31 @@ describe('SendChainService', () => { const { service, prisma, riskReview } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsTemplate.findFirst.mockResolvedValue(null); - prisma.smsTemplate.findMany.mockResolvedValue([{ - id: 'tpl-code', - tenantId: 'tenant-1', - applicationId: 'app-1', - content: '【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。', - auditStatus: 'approved', - signature: { id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting' }, - }]); + prisma.smsTemplate.findMany.mockResolvedValue([ + { + id: 'tpl-code', + tenantId: 'tenant-1', + applicationId: 'app-1', + content: '【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。', + auditStatus: 'approved', + signature: { id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting' }, + }, + ]); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '18821203795', - content: '【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。', - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '18821203795', + content: '【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。', + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith({ where: { - applicationId: 'app-1', content: { contains: '${' }, auditStatus: 'approved', + applicationId: 'app-1', + content: { contains: '${' }, + auditStatus: 'approved', signature: { auditStatus: 'approved' }, }, include: { signature: true }, @@ -1864,10 +2168,12 @@ describe('SendChainService', () => { expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ templateId: 'tpl-code', status: 'validating' }), }); - expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ - templateId: 'tpl-code', - variables: { code: '715021' }, - })); + expect(riskReview.evaluateTask).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: 'tpl-code', + variables: { code: '715021' }, + }), + ); expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1', { messageRecordId: 'record-1', queuePriority: 'normal', @@ -1899,22 +2205,28 @@ describe('SendChainService', () => { reportStatus: 'reporting', }); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '18821203795', - content: '【航天信息信诺网】未配置模板但允许直接发送', - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '18821203795', + content: '【航天信息信诺网】未配置模板但允许直接发送', + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({ where: { applicationId: 'app-1', name: '【航天信息信诺网】', auditStatus: 'approved' }, orderBy: { updatedAt: 'desc' }, }); - expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ - applicationId: 'app-1', - content: '【航天信息信诺网】未配置模板但允许直接发送', - })); - expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.not.objectContaining({ templateId: expect.any(String) })); + expect(riskReview.evaluateTask).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: 'app-1', + content: '【航天信息信诺网】未配置模板但允许直接发送', + }), + ); + expect(riskReview.evaluateTask).toHaveBeenCalledWith( + expect.not.objectContaining({ templateId: expect.any(String) }), + ); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, data: { status: 'queued', signatureId: 'sig-1' }, @@ -1942,19 +2254,23 @@ describe('SendChainService', () => { }); prisma.smsTemplate.findFirst.mockResolvedValue(null); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: '【签名】未匹配模板的内容', - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: '【签名】未匹配模板的内容', + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); - expect(riskReview.aggregateTemplateMismatch).toHaveBeenCalledWith(expect.objectContaining({ - applicationId: 'app-1', - account: '100001', - messageRecordId: 'record-1', - signatureId: 'sig-1', - })); + expect(riskReview.aggregateTemplateMismatch).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: 'app-1', + account: '100001', + messageRecordId: 'record-1', + signatureId: 'sig-1', + }), + ); expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: expect.objectContaining({ status: 'pending_review', riskTaskId: 'review-task-1', auditStatus: 'pending' }), @@ -1992,12 +2308,14 @@ describe('SendChainService', () => { reportStatus: 'reporting', }); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '18821203795', - content: '【航天信息信诺网】您本次操作的验证码是171102,有效时10分钟。', - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '18821203795', + content: '【航天信息信诺网】您本次操作的验证码是171102,有效时10分钟。', + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({ where: { @@ -2007,7 +2325,9 @@ describe('SendChainService', () => { }, orderBy: { updatedAt: 'desc' }, }); - expect(riskReview.aggregateTemplateMismatch).toHaveBeenCalledWith(expect.objectContaining({ signatureId: 'sig-1' })); + expect(riskReview.aggregateTemplateMismatch).toHaveBeenCalledWith( + expect.objectContaining({ signatureId: 'sig-1' }), + ); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); @@ -2017,7 +2337,8 @@ describe('SendChainService', () => { prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1', }); - prisma.smsMessageRecord.findMany.mockResolvedValue([{ + prisma.smsMessageRecord.findMany.mockResolvedValue([ + { id: 'record-1', tenantId: 'tenant-1', applicationId: 'app-1', @@ -2027,7 +2348,8 @@ describe('SendChainService', () => { amountCents: 3, billingUnits: 1, batchTask: { id: 'task-1', sourceType: 'cmpp' }, - }]); + }, + ]); await expect(service.handleReviewDecision('review-task-1', 'approved', '审核通过')).resolves.toEqual({ reviewTaskId: 'review-task-1', @@ -2079,7 +2401,11 @@ describe('SendChainService', () => { service['getSendQueue'] = jest.fn().mockReturnValue({ add }); await expect(service.enqueueBatchTask('task-1')).resolves.toEqual({ taskId: 'task-1', enqueued: 1 }); - expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-1' }, { jobId: 'record-1', attempts: 3, priority: 100 }); + expect(add).toHaveBeenCalledWith( + 'send-message', + { messageRecordId: 'record-1' }, + { jobId: 'record-1', attempts: 3, priority: 100 }, + ); expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'queued' } }); }); @@ -2094,8 +2420,16 @@ describe('SendChainService', () => { await expect(service.enqueueBatchTask('task-1')).resolves.toEqual({ taskId: 'task-1', enqueued: 2 }); - expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-priority' }, { jobId: 'record-priority', attempts: 3, priority: 1 }); - expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-normal' }, { jobId: 'record-normal', attempts: 3, priority: 100 }); + expect(add).toHaveBeenCalledWith( + 'send-message', + { messageRecordId: 'record-priority' }, + { jobId: 'record-priority', attempts: 3, priority: 1 }, + ); + expect(add).toHaveBeenCalledWith( + 'send-message', + { messageRecordId: 'record-normal' }, + { jobId: 'record-normal', attempts: 3, priority: 100 }, + ); }); it('routes queued messages to gateway submit commands', async () => { @@ -2117,7 +2451,12 @@ describe('SendChainService', () => { }); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, - data: expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', province: '山东', status: 'submit_queued' }), + data: expect.objectContaining({ + channelId: 'channel-1', + carrier: 'mobile', + province: '山东', + status: 'submit_queued', + }), }); expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith( expect.objectContaining({ @@ -2132,7 +2471,9 @@ describe('SendChainService', () => { upstream: expect.objectContaining({ gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'cmpp-account' }), }), ); - expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ messageId: 'MSG-1' })); + expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith( + expect.objectContaining({ messageId: 'MSG-1' }), + ); expect(prisma.cmppSubmitSession.findUnique).toHaveBeenCalledWith({ where: { sessionNo: 'OPEN-channel-1' }, select: { id: true }, @@ -2196,30 +2537,79 @@ describe('SendChainService', () => { const { service, prisma } = createService(); const base = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } }); const messages = [ - { ...base, id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3n, carrier: null, province: null, batchTask: { sourceType: 'cmpp', phoneTotal: 1 } }, - { ...base, id: 'record-2', batchTaskId: 'task-2', messageId: 'MSG-2', phoneNumber: '13800000002', amountCents: 3n, carrier: null, province: null, batchTask: { sourceType: 'cmpp', phoneTotal: 1 } }, + { + ...base, + id: 'record-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + amountCents: 3n, + carrier: null, + province: null, + batchTask: { sourceType: 'cmpp', phoneTotal: 1 }, + }, + { + ...base, + id: 'record-2', + batchTaskId: 'task-2', + messageId: 'MSG-2', + phoneNumber: '13800000002', + amountCents: 3n, + carrier: null, + province: null, + batchTask: { sourceType: 'cmpp', phoneTotal: 1 }, + }, ]; const channel = { - id: 'channel-1', code: 'CMPP-A', account: 'cmpp-account', srcId: '10690000', - rateLimitPerSecond: 100, unitPrice: 3n, status: 'active', carrier: 'mobile', sendRegion: '全国', - gatewayHost: '127.0.0.1', gatewayPort: 17890, passwordCipher: 'secret', cmppVersion: '3.0', + id: 'channel-1', + code: 'CMPP-A', + account: 'cmpp-account', + srcId: '10690000', + rateLimitPerSecond: 100, + unitPrice: 3n, + status: 'active', + carrier: 'mobile', + sendRegion: '全国', + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + passwordCipher: 'secret', + cmppVersion: '3.0', config: { serviceId: 'SMS' }, connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }], reportTasks: [{ signatureId: 'sig-1', carrier: 'mobile', approvalScope: 'carrier_specific' }], }; prisma.smsMessageRecord.findMany.mockResolvedValue(messages); - prisma.channelRouteRule.findMany.mockResolvedValue([{ - tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-1', - group: { - name: '默认通道组', carrier: 'mobile', status: 'active', - items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel }], + prisma.channelRouteRule.findMany.mockResolvedValue([ + { + tenantId: 'tenant-1', + applicationId: 'app-1', + carrier: 'mobile', + groupId: 'group-1', + group: { + name: '默认通道组', + carrier: 'mobile', + status: 'active', + items: [ + { + id: 'item-1', + groupId: 'group-1', + channelId: 'channel-1', + carrier: 'mobile', + province: null, + priority: 1, + weight: 1, + isBackup: false, + channel, + }, + ], + }, }, - }]); + ]); service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); const gatewaySubmit = (service as any).submission.gatewaySubmit; const result = await gatewaySubmit.processSendJobBatch([ - { messageRecordId: 'record-1' }, { messageRecordId: 'record-2' }, + { messageRecordId: 'record-1' }, + { messageRecordId: 'record-2' }, ]); expect(prisma.channelRouteRule.findMany).toHaveBeenCalledTimes(1); @@ -2232,8 +2622,14 @@ describe('SendChainService', () => { }); expect(prisma.gatewaySubmitOutbox.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ - expect.objectContaining({ messageRecordId: 'record-1', payload: expect.objectContaining({ messageId: 'MSG-1' }) }), - expect.objectContaining({ messageRecordId: 'record-2', payload: expect.objectContaining({ messageId: 'MSG-2' }) }), + expect.objectContaining({ + messageRecordId: 'record-1', + payload: expect.objectContaining({ messageId: 'MSG-1' }), + }), + expect.objectContaining({ + messageRecordId: 'record-2', + payload: expect.objectContaining({ messageId: 'MSG-2' }), + }), ]), }); expect(result.get('record-1')).toEqual(expect.objectContaining({ submitted: true })); @@ -2268,29 +2664,36 @@ describe('SendChainService', () => { const backup = { ...primary, id: 'channel-backup', code: 'CMPP-B' }; prisma.channelRouteRule.findFirst.mockResolvedValue({ ...baseRoute, - group: { ...baseRoute.group, items: [ - { ...baseRoute.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup }, - ] }, + group: { + ...baseRoute.group, + items: [{ ...baseRoute.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup }], + }, }); service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); - await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id })); + await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual( + expect.objectContaining({ submitted: true, channelId: backup.id }), + ); expect(prisma.channelSignatureReportTask.findMany).not.toHaveBeenCalled(); expect(JSON.stringify(prisma.channelRouteRule.findFirst.mock.calls.at(-1)?.[0])).toContain('sig-1'); - expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) })); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) }), + ); }); it('persists identified carrier and province before a route lookup fails', async () => { const { service, prisma } = createService(); prisma.channelRouteRule.findFirst.mockResolvedValueOnce(null); - await expect(service['selectChannelForMessage']({ - id: 'record-1', - tenantId: 'tenant-1', - applicationId: 'app-1', - signatureId: 'sig-1', - phoneNumber: '13800000001', - })).rejects.toThrow('企业应用未配置对应运营商通道组'); + await expect( + service['selectChannelForMessage']({ + id: 'record-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + phoneNumber: '13800000001', + }), + ).rejects.toThrow('企业应用未配置对应运营商通道组'); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, @@ -2305,22 +2708,26 @@ describe('SendChainService', () => { const { service, prisma, billing, riskReview, phoneFrequency } = createService(); service.enqueueBatchTask = jest.fn(); prisma.$queryRaw.mockImplementationOnce((query) => { - const payloadHash = query.values.find((value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)); - return Promise.resolve([{ - validationError: null, - payloadHash, - response: { - accepted: true, - tenantId: 'tenant-1', - applicationId: 'app-1', - taskId: '', - messageId: 'MSG-fast', - messageRecordId: '', - status: 'accepted_pending', - phoneCount: 2, - messages: [], + const payloadHash = query.values.find( + (value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value), + ); + return Promise.resolve([ + { + validationError: null, + payloadHash, + response: { + accepted: true, + tenantId: 'tenant-1', + applicationId: 'app-1', + taskId: '', + messageId: 'MSG-fast', + messageRecordId: '', + status: 'accepted_pending', + phoneCount: 2, + messages: [], + }, }, - }]); + ]); }); const result = await service.submitInboundMessage({ @@ -2332,11 +2739,13 @@ describe('SendChainService', () => { remoteIp: '127.0.0.1', }); - expect(result).toEqual(expect.objectContaining({ - accepted: true, - status: 'accepted_pending', - phoneCount: 2, - })); + expect(result).toEqual( + expect.objectContaining({ + accepted: true, + status: 'accepted_pending', + phoneCount: 2, + }), + ); expect(prisma.smsApplication.findFirst).not.toHaveBeenCalled(); expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); const sql = prisma.$queryRaw.mock.calls[0][0].strings.join(' '); @@ -2373,10 +2782,20 @@ describe('SendChainService', () => { messageRecordId: '', status: 'accepted_pending', phoneCount: 1, - messages: [{ phoneNumber: '13800000001', messageId: 'MSG-stable', messageRecordId: '', taskId: '', status: 'accepted_pending' }], + messages: [ + { + phoneNumber: '13800000001', + messageId: 'MSG-stable', + messageRecordId: '', + taskId: '', + status: 'accepted_pending', + }, + ], }; prisma.$queryRaw.mockImplementation((query) => { - const payloadHash = query.values.find((value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)); + const payloadHash = query.values.find( + (value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value), + ); return Promise.resolve([{ validationError: null, payloadHash, response: storedResponse }]); }); const request = { @@ -2405,33 +2824,41 @@ describe('SendChainService', () => { try { const { service, prisma } = createService(); prisma.$queryRaw.mockImplementationOnce((query) => { - const payloadHash = query.values.find((value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)); - return Promise.resolve([{ - validationError: null, - payloadHash, - response: { - accepted: true, - tenantId: 'tenant-1', - applicationId: 'app-1', - taskId: '', - messageId: 'MSG-disabled-after-bind', - messageRecordId: '', - status: 'accepted_pending', - phoneCount: 1, - messages: [], + const payloadHash = query.values.find( + (value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value), + ); + return Promise.resolve([ + { + validationError: null, + payloadHash, + response: { + accepted: true, + tenantId: 'tenant-1', + applicationId: 'app-1', + taskId: '', + messageId: 'MSG-disabled-after-bind', + messageRecordId: '', + status: 'accepted_pending', + phoneCount: 1, + messages: [], + }, }, - }]); + ]); }); - await expect(service.submitInboundMessage({ - requestId: 'cmpp-inbound:disabled', - account: '100001', - phoneNumber: '13800000001', - content: 'hello', - })).resolves.toEqual(expect.objectContaining({ - accepted: true, - status: 'accepted_pending', - })); + await expect( + service.submitInboundMessage({ + requestId: 'cmpp-inbound:disabled', + account: '100001', + phoneNumber: '13800000001', + content: 'hello', + }), + ).resolves.toEqual( + expect.objectContaining({ + accepted: true, + status: 'accepted_pending', + }), + ); const sql = prisma.$queryRaw.mock.calls[0][0].strings.join(' '); expect(sql).not.toContain("application.status <> 'active'"); expect(sql).not.toContain('NOT application."interfaceEnabled"'); @@ -2454,17 +2881,21 @@ describe('SendChainService', () => { payloadHash = query.values.find((value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)); return Promise.resolve([{ validationError: null, payloadHash: null, response: null }]); }); - prisma.cmppInboundSubmissionInbox.findUnique.mockImplementationOnce(() => Promise.resolve({ - payloadHash, - response: { accepted: true, messageId: 'MSG-concurrent', status: 'accepted_pending' }, - })); + prisma.cmppInboundSubmissionInbox.findUnique.mockImplementationOnce(() => + Promise.resolve({ + payloadHash, + response: { accepted: true, messageId: 'MSG-concurrent', status: 'accepted_pending' }, + }), + ); - await expect(service.submitInboundMessage({ - requestId: 'cmpp-inbound:concurrent', - account: '100001', - phoneNumber: '13800000001', - content: 'hello', - })).resolves.toEqual(expect.objectContaining({ messageId: 'MSG-concurrent' })); + await expect( + service.submitInboundMessage({ + requestId: 'cmpp-inbound:concurrent', + account: '100001', + phoneNumber: '13800000001', + content: 'hello', + }), + ).resolves.toEqual(expect.objectContaining({ messageId: 'MSG-concurrent' })); expect(prisma.cmppInboundSubmissionInbox.findUnique).toHaveBeenCalledTimes(1); } finally { if (previous == null) delete process.env.CMPP_INBOUND_FAST_PATH_ENABLED; @@ -2476,8 +2907,11 @@ describe('SendChainService', () => { const { service, prisma } = createService(); prisma.$queryRaw.mockResolvedValueOnce([{ tenantId: 'tenant-1', dailyLimit: 100000, usedCount: 1 }]); - await expect((service as any).tryReserveDailySendQuota('app-1', 1, 'workflow-1:daily-quota')) - .resolves.toEqual({ dailyLimit: 100000, usedCount: 1, reserved: true }); + await expect((service as any).tryReserveDailySendQuota('app-1', 1, 'workflow-1:daily-quota')).resolves.toEqual({ + dailyLimit: 100000, + usedCount: 1, + reserved: true, + }); expect(prisma.smsApplicationDailyReservation.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -2485,8 +2919,9 @@ describe('SendChainService', () => { usageDate: expect.any(Date), }), }); - expect(prisma.smsApplicationDailyReservation.create.mock.calls[0][0].data.usageDate.toISOString()) - .toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/); + expect(prisma.smsApplicationDailyReservation.create.mock.calls[0][0].data.usageDate.toISOString()).toMatch( + /^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/, + ); }); it('claims Inbox leases against UTC for timestamp-without-time-zone columns', async () => { @@ -2518,9 +2953,11 @@ describe('SendChainService', () => { inboundEntry.processClaimedInboundWorkflowBatch = jest.fn().mockResolvedValue(undefined); await inboundEntry.processTenantInboundWorkflowBatches(grouped.get('tenant-a'), 2, new Map()); - expect(inboundEntry.processClaimedInboundWorkflowBatch.mock.calls.map((call: unknown[]) => ( - (call[0] as Array<{ id: string }>).map((item) => item.id) - ))).toEqual([['a1', 'a2'], ['a3']]); + expect( + inboundEntry.processClaimedInboundWorkflowBatch.mock.calls.map((call: unknown[]) => + (call[0] as Array<{ id: string }>).map((item) => item.id), + ), + ).toEqual([['a1', 'a2'], ['a3']]); }); it('coalesces a small ready Inbox set before claiming the next tenant batch', async () => { @@ -2574,18 +3011,24 @@ describe('SendChainService', () => { const add = jest.fn().mockResolvedValue(undefined); service['getSendQueue'] = jest.fn().mockReturnValue({ add }); - await expect(service.enqueueBatchTask('task-1', { - messageRecordId: 'record-1', - queuePriority: 'priority', - })).resolves.toEqual({ taskId: 'task-1', enqueued: 1 }); + await expect( + service.enqueueBatchTask('task-1', { + messageRecordId: 'record-1', + queuePriority: 'priority', + }), + ).resolves.toEqual({ taskId: 'task-1', enqueued: 1 }); expect(prisma.smsBatchTask.findUnique).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled(); - expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-1' }, { - jobId: 'record-1', - attempts: 3, - priority: 1, - }); + expect(add).toHaveBeenCalledWith( + 'send-message', + { messageRecordId: 'record-1' }, + { + jobId: 'record-1', + attempts: 3, + priority: 1, + }, + ); expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'queued' } }); }); @@ -2594,15 +3037,17 @@ describe('SendChainService', () => { service['identifyCarrier'] = jest.fn(); service['identifyProvince'] = jest.fn(); - await expect(service['selectChannelForMessage']({ - id: 'record-1', - tenantId: 'tenant-1', - applicationId: 'app-1', - signatureId: 'sig-1', - phoneNumber: '13800000001', - carrier: 'mobile', - province: '山东', - })).resolves.toEqual(expect.objectContaining({ carrier: 'mobile', province: '山东' })); + await expect( + service['selectChannelForMessage']({ + id: 'record-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + phoneNumber: '13800000001', + carrier: 'mobile', + province: '山东', + }), + ).resolves.toEqual(expect.objectContaining({ carrier: 'mobile', province: '山东' })); expect(service['identifyCarrier']).not.toHaveBeenCalled(); expect(service['identifyProvince']).not.toHaveBeenCalled(); @@ -2648,15 +3093,31 @@ describe('SendChainService', () => { where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } }, data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }), }); - expect(billing.settleFrozenCharge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, taskId: 'task-1', messageId: 'MSG-1' })); + expect(billing.settleFrozenCharge).toHaveBeenCalledWith( + expect.objectContaining({ amountCents: 3, taskId: 'task-1', messageId: 'MSG-1' }), + ); expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }), + data: expect.objectContaining({ + messageId: 'MSG-1', + amountCents: 3, + billingStatus: 'charged', + transactionId: 'tx-charge', + }), }); expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledTimes(2); - expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledWith(expect.objectContaining({ - where: { messageRecordId_submitId_segmentIndex: { messageRecordId: 'record-1', submitId: 'SUB-1', segmentIndex: 1 } }, - create: expect.objectContaining({ segmentTotal: 2, segmentIndex: 1, gatewayMessageId: 'GW-1-A', submitStatus: 'accepted' }), - })); + expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + messageRecordId_submitId_segmentIndex: { messageRecordId: 'record-1', submitId: 'SUB-1', segmentIndex: 1 }, + }, + create: expect.objectContaining({ + segmentTotal: 2, + segmentIndex: 1, + gatewayMessageId: 'GW-1-A', + submitStatus: 'accepted', + }), + }), + ); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({ where: { status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] }, @@ -2760,12 +3221,14 @@ describe('SendChainService', () => { { id: 'submit-2', submitId: 'SUB-2', channelId: 'channel-1', gatewayMessageId: null }, ]); - await expect(service.handleSubmitResult({ - messageId: 'MSG-1', - channelId: 'channel-1', - gatewayMessageId: 'GW-LEGACY', - submitStatus: 'accepted', - })).rejects.toThrow('cannot be matched uniquely'); + await expect( + service.handleSubmitResult({ + messageId: 'MSG-1', + channelId: 'channel-1', + gatewayMessageId: 'GW-LEGACY', + submitStatus: 'accepted', + }), + ).rejects.toThrow('cannot be matched uniquely'); expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.updateMany).not.toHaveBeenCalled(); @@ -2789,22 +3252,28 @@ describe('SendChainService', () => { prisma.smsSubmitRecord.findMany.mockResolvedValue([ { id: 'submit-1', submitId: 'SUB-1', messageRecordId: 'record-1', channelId: primary.id, createdAt: new Date() }, ]); - const submitMessageToGateway = jest.spyOn(service as any, 'submitMessageToGateway') + const submitMessageToGateway = jest + .spyOn(service as any, 'submitMessageToGateway') .mockResolvedValue({ submitted: true, messageRecordId: 'record-1', channelId: backup.id, attempt: 1 }); - await expect((service as any).retryMessageIfAllowed({ - id: 'record-1', - tenantId: 'tenant-1', - batchTaskId: 'task-1', - applicationId: 'app-1', - templateId: null, - signatureId: 'sig-direct', - messageId: 'MSG-DIRECT-SIGNATURE', - phoneNumber: '13800000001', - content: '【签名】无模板内容', - billingUnits: 1, - queuedAt: new Date(), - }, '回执失败补发')).resolves.toEqual(expect.objectContaining({ channelId: backup.id })); + await expect( + (service as any).retryMessageIfAllowed( + { + id: 'record-1', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + templateId: null, + signatureId: 'sig-direct', + messageId: 'MSG-DIRECT-SIGNATURE', + phoneNumber: '13800000001', + content: '【签名】无模板内容', + billingUnits: 1, + queuedAt: new Date(), + }, + '回执失败补发', + ), + ).resolves.toEqual(expect.objectContaining({ channelId: backup.id })); expect(JSON.stringify(prisma.channelRouteRule.findFirst.mock.calls.at(-1)?.[0])).toContain('sig-direct'); expect(submitMessageToGateway).toHaveBeenCalledWith( @@ -2836,9 +3305,9 @@ describe('SendChainService', () => { } return { id: 'submit-1', ...data }; }); - prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => Promise.resolve( - where.retryOfSubmitRecordId ? claimedRetry : null, - )); + prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => + Promise.resolve(where.retryOfSubmitRecordId ? claimedRetry : null), + ); const message = { id: 'record-long-race', tenantId: 'tenant-1', @@ -2884,7 +3353,15 @@ describe('SendChainService', () => { applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', - group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }, + group: { + id: 'group-1', + carrier: 'mobile', + status: 'active', + retryEnabled: false, + retryTimeLimitHours: 72, + retryTimeLimitMinutes: 4320, + items: [], + }, }); await service.handleSubmitResult({ @@ -2894,7 +3371,9 @@ describe('SendChainService', () => { gatewayMessageId: 'GW-1', submitStatus: 'rejected', }); - expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') })); + expect(billing.release).toHaveBeenCalledWith( + expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }), + ); await service.handleReceipt({ messageId: 'MSG-1', @@ -2903,10 +3382,12 @@ describe('SendChainService', () => { receiptStatus: 'undelivered', rawStatus: 'UNDELIV', }); - expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ - idempotencyKey: 'sms-refund:MSG-1', - remark: '最终失败退款', - })); + expect(billing.refund).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: 'sms-refund:MSG-1', + remark: '最终失败退款', + }), + ); }); it('stops failed receipt retry after the configured minute limit', async () => { @@ -2937,7 +3418,15 @@ describe('SendChainService', () => { applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', - group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 2, retryTimeLimitMinutes: 75, items: [] }, + group: { + id: 'group-1', + carrier: 'mobile', + status: 'active', + retryEnabled: true, + retryTimeLimitHours: 2, + retryTimeLimitMinutes: 75, + items: [], + }, }); await service.handleReceipt({ @@ -2984,7 +3473,11 @@ describe('SendChainService', () => { }); expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ channelId: 'channel-old', gatewayMessageId: 'GW-OLD', receiptStatus: 'undelivered' }), + data: expect.objectContaining({ + channelId: 'channel-old', + gatewayMessageId: 'GW-OLD', + receiptStatus: 'undelivered', + }), }); expect(billing.refund).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith({ @@ -3000,24 +3493,24 @@ describe('SendChainService', () => { .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([ - { - id: 'submit-timeout-1', - channelId: 'channel-1', - gatewayMessageId: null, - submitStatus: 'timeout', - submittedAt: new Date('2026-07-01T10:00:00.000Z'), - messageRecord: { - id: 'record-1', - tenantId: 'tenant-1', - batchTaskId: 'task-1', - applicationId: 'app-1', - messageId: 'MSG-1', - phoneNumber: '13800000001', + { + id: 'submit-timeout-1', channelId: 'channel-1', gatewayMessageId: null, - status: 'timeout', + submitStatus: 'timeout', + submittedAt: new Date('2026-07-01T10:00:00.000Z'), + messageRecord: { + id: 'record-1', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + channelId: 'channel-1', + gatewayMessageId: null, + status: 'timeout', + }, }, - }, ]); await service.handleReceipt({ @@ -3082,13 +3575,15 @@ describe('SendChainService', () => { deliveredAt: '2026-07-01T10:01:00.000Z', }); - expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - channelId: 'channel-b', - gatewayMessageId: 'SHARED-UPSTREAM-ID', - messageRecord: { phoneNumber: '15601992925' }, + expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + channelId: 'channel-b', + gatewayMessageId: 'SHARED-UPSTREAM-ID', + messageRecord: { phoneNumber: '15601992925' }, + }), }), - })); + ); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-channel-b' }, data: expect.objectContaining({ @@ -3112,8 +3607,7 @@ describe('SendChainService', () => { protocol: 'CMPP', cmppVersion: '2.0', }); - prisma.smsSubmitRecord.findMany - .mockResolvedValueOnce([]); + prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]); prisma.smsMessageSegmentAudit.findMany .mockResolvedValueOnce([ { @@ -3177,41 +3671,41 @@ describe('SendChainService', () => { protocol: 'CMPP', cmppVersion: '2.0', }); - prisma.smsSubmitRecord.findMany - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]); + prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]); prisma.smsMessageSegmentAudit.findMany.mockResolvedValueOnce([ - { - id: 'segment-original', - submitRecordId: 'submit-original', - submitId: 'SUB-ORIGINAL', - channelId: 'channel-original', - gatewayMessageId: 'SHARED-ID', - submitRecord: { id: 'submit-original', submitId: 'SUB-ORIGINAL' }, - channel: { - id: 'channel-original', - account: 'C59748', - gatewayHost: 'supplier.example.com', - gatewayPort: 7890, - protocol: 'CMPP', - cmppVersion: '2.0', - }, - messageRecord: { - id: 'record-original', - messageId: 'MSG-ORIGINAL', - phoneNumber: '13127620092', - }, + { + id: 'segment-original', + submitRecordId: 'submit-original', + submitId: 'SUB-ORIGINAL', + channelId: 'channel-original', + gatewayMessageId: 'SHARED-ID', + submitRecord: { id: 'submit-original', submitId: 'SUB-ORIGINAL' }, + channel: { + id: 'channel-original', + account: 'C59748', + gatewayHost: 'supplier.example.com', + gatewayPort: 7890, + protocol: 'CMPP', + cmppVersion: '2.0', }, - ]); + messageRecord: { + id: 'record-original', + messageId: 'MSG-ORIGINAL', + phoneNumber: '13127620092', + }, + }, + ]); - await expect(service.handleReceipt({ - messageId: 'receipt-SHARED-ID', - channelId: 'channel-other', - gatewayMessageId: 'SHARED-ID', - phoneNumber: '13127620092', - receiptStatus: 'delivered', - rawStatus: 'DELIVRD', - })).rejects.toThrow('SMS message record not found'); + await expect( + service.handleReceipt({ + messageId: 'receipt-SHARED-ID', + channelId: 'channel-other', + gatewayMessageId: 'SHARED-ID', + phoneNumber: '13127620092', + receiptStatus: 'delivered', + rawStatus: 'DELIVRD', + }), + ).rejects.toThrow('SMS message record not found'); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); }); @@ -3268,9 +3762,11 @@ describe('SendChainService', () => { rawStatus: 'DELIVRD', }); - expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'delivered' }), - })); + expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'delivered' }), + }), + ); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); prisma.smsReceiptRecord.findUnique.mockResolvedValue(null); @@ -3283,10 +3779,12 @@ describe('SendChainService', () => { rawStatus: 'DELIVRD', }); - expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'record-long' }, - data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }), - })); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'record-long' }, + data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }), + }), + ); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(1, { data: expect.objectContaining({ @@ -3334,7 +3832,14 @@ describe('SendChainService', () => { ]) .mockResolvedValueOnce([ { segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() }, - { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', compensationType: 'supplier_message_level_receipt', deliveredAt: new Date() }, + { + segmentIndex: 2, + segmentTotal: 2, + receiptStatus: 'delivered', + rawStatus: 'DELIVRD', + compensationType: 'supplier_message_level_receipt', + deliveredAt: new Date(), + }, ]); await service.handleReceipt({ @@ -3346,21 +3851,25 @@ describe('SendChainService', () => { rawStatus: 'DELIVRD', }); - expect(prisma.smsMessageSegmentAudit.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - messageRecordId: 'record-message-level', - submitRecordId: 'submit-message-level', - receiptStatus: null, + expect(prisma.smsMessageSegmentAudit.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + messageRecordId: 'record-message-level', + submitRecordId: 'submit-message-level', + receiptStatus: null, + }), + data: expect.objectContaining({ + receiptStatus: 'delivered', + compensationType: 'supplier_message_level_receipt', + }), }), - data: expect.objectContaining({ - receiptStatus: 'delivered', - compensationType: 'supplier_message_level_receipt', + ); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'record-message-level' }, + data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }), }), - })); - expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'record-message-level' }, - data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }), - })); + ); }); it('records a receipt anomaly when a message-level success is followed by a failure for the same attempt', async () => { @@ -3407,17 +3916,21 @@ describe('SendChainService', () => { errorCode: 'SP_CONFLICT', }); - expect(prisma.smsReceiptAnomaly.upsert).toHaveBeenCalledWith(expect.objectContaining({ - where: { anomalyKey: 'aggregate-receipt-conflict:record-conflict:SUB-CONFLICT' }, - create: expect.objectContaining({ - anomalyType: 'aggregate_success_then_failure', - previousStatus: 'delivered', - incomingStatus: 'undelivered', + expect(prisma.smsReceiptAnomaly.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { anomalyKey: 'aggregate-receipt-conflict:record-conflict:SUB-CONFLICT' }, + create: expect.objectContaining({ + anomalyType: 'aggregate_success_then_failure', + previousStatus: 'delivered', + incomingStatus: 'undelivered', + }), }), - })); - expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'failed' }), - })); + ); + expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }), + }), + ); expect(billing.refund).not.toHaveBeenCalled(); }); @@ -3440,9 +3953,9 @@ describe('SendChainService', () => { }; return claimedDelivery; }); - prisma.cmppDownstreamDelivery.findUnique.mockImplementation(({ where }) => Promise.resolve( - where.dedupeKey || where.id === 'delivery-once' ? claimedDelivery : null, - )); + prisma.cmppDownstreamDelivery.findUnique.mockImplementation(({ where }) => + Promise.resolve(where.dedupeKey || where.id === 'delivery-once' ? claimedDelivery : null), + ); const payload = { tenantId: 'tenant-1', applicationId: 'app-1', @@ -3462,20 +3975,18 @@ describe('SendChainService', () => { (service as any).queueAndTryDownstreamDelivery(payload), ]); - expect(results.map((result) => result.id)).toEqual([ - 'delivery-once', - 'delivery-once', - 'delivery-once', - ]); + expect(results.map((result) => result.id)).toEqual(['delivery-once', 'delivery-once', 'delivery-once']); expect(service['postGatewayControl']).toHaveBeenCalledTimes(1); expect(service['postGatewayControl']).toHaveBeenCalledWith( '/downstream/receipt', expect.objectContaining({ deliveryId: 'delivery-once', claimId: expect.stringMatching(/^api-direct:/) }), ); - expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'delivery-once', status: 'pending' }, - data: expect.objectContaining({ status: 'dispatching', connectionId: expect.stringMatching(/^api-direct:/) }), - })); + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'delivery-once', status: 'pending' }, + data: expect.objectContaining({ status: 'dispatching', connectionId: expect.stringMatching(/^api-direct:/) }), + }), + ); expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(3); }); @@ -3522,17 +4033,19 @@ describe('SendChainService', () => { { segmentIndex: 1, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null }, { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'YL:1014', deliveredAt: new Date() }, ]); - prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => Promise.resolve( - where.id === 'submit-long' - ? { - id: 'submit-long', - messageRecordId: 'record-long', - channelId: 'channel-1', - submitId: 'SUB-LONG-FAIL', - submitStatus: 'accepted', - } - : null, - )); + prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => + Promise.resolve( + where.id === 'submit-long' + ? { + id: 'submit-long', + messageRecordId: 'record-long', + channelId: 'channel-1', + submitId: 'SUB-LONG-FAIL', + submitStatus: 'accepted', + } + : null, + ), + ); prisma.smsBillingRecord.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'billing-charged', billingStatus: 'charged' }); @@ -3579,13 +4092,11 @@ describe('SendChainService', () => { it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => { const { service, prisma } = createService(); - prisma.smsReceiptRecord.findUnique - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ - id: 'receipt-existing', - messageRecordId: 'record-1', - messageRecord: { id: 'record-1', messageId: 'MSG-1', status: 'delivered' }, - }); + prisma.smsReceiptRecord.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: 'receipt-existing', + messageRecordId: 'record-1', + messageRecord: { id: 'record-1', messageId: 'MSG-1', status: 'delivered' }, + }); const receipt = { messageId: 'MSG-1', @@ -3610,14 +4121,14 @@ describe('SendChainService', () => { .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([ - { - id: 'submit-timeout-1', - messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' }, - }, - { - id: 'submit-timeout-2', - messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' }, - }, + { + id: 'submit-timeout-1', + messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' }, + }, + { + id: 'submit-timeout-2', + messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' }, + }, ]); await expect( @@ -3731,7 +4242,10 @@ describe('SendChainService', () => { expect.objectContaining({ channelId: 'channel-all' }), ); expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith( - expect.objectContaining({ channelId: 'channel-all', route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }) }), + expect.objectContaining({ + channelId: 'channel-all', + route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }), + }), ); }); @@ -3762,16 +4276,18 @@ describe('SendChainService', () => { data: expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD', messageRecordId: 'record-1' }), }); expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ tenantId: 'tenant-1', channelId: 'channel-1', gatewayMessageId: '8412634832294102675', content: 'TD' }), + data: expect.objectContaining({ + tenantId: 'tenant-1', + channelId: 'channel-1', + gatewayMessageId: '8412634832294102675', + content: 'TD', + }), }); }); it('records ambiguous uplink match candidates for shared access numbers', async () => { const { service, prisma } = createService(); - prisma.channelRouteRule.findMany.mockResolvedValue([ - { applicationId: 'app-1' }, - { applicationId: 'app-2' }, - ]); + prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]); prisma.smsApplication.findMany.mockResolvedValue([ { id: 'app-1', tenantId: 'tenant-1', name: '应用A' }, { id: 'app-2', tenantId: 'tenant-2', name: '应用B' }, @@ -3796,8 +4312,18 @@ describe('SendChainService', () => { }); expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({ data: [ - expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', matchSource: 'access_number', confidence: 70 }), - expect.objectContaining({ tenantId: 'tenant-2', applicationId: 'app-2', matchSource: 'access_number', confidence: 70 }), + expect.objectContaining({ + tenantId: 'tenant-1', + applicationId: 'app-1', + matchSource: 'access_number', + confidence: 70, + }), + expect.objectContaining({ + tenantId: 'tenant-2', + applicationId: 'app-2', + matchSource: 'access_number', + confidence: 70, + }), ], skipDuplicates: true, }); @@ -3915,10 +4441,12 @@ describe('SendChainService', () => { receiptStatus: null, }); - await expect(service.requeueGatewaySubmitDeadLetter('dead-1', { - confirmedNotSubmitted: true, - reason: '尝试重新发送这条短信', - })).rejects.toThrow('为避免重复发送,禁止重新入队'); + await expect( + service.requeueGatewaySubmitDeadLetter('dead-1', { + confirmedNotSubmitted: true, + reason: '尝试重新发送这条短信', + }), + ).rejects.toThrow('为避免重复发送,禁止重新入队'); expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled(); }); @@ -3968,9 +4496,15 @@ describe('SendChainService', () => { maxAttempts: 3, }); - expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith(expect.objectContaining({ - update: expect.not.objectContaining({ status: expect.anything(), resolvedAt: expect.anything(), resolvedStatus: expect.anything() }), - })); + expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.not.objectContaining({ + status: expect.anything(), + resolvedAt: expect.anything(), + resolvedStatus: expect.anything(), + }), + }), + ); }); it('allows a disabling application to reconnect for receipt draining and audits later Submit as REJECTD', async () => { @@ -3999,27 +4533,35 @@ describe('SendChainService', () => { httpConfig: { enabled: false }, }); - await expect(service.authenticateInboundApplication({ - account: '100001', - password: 'secret-hash', - remoteIp: '127.0.0.1', - version: 'cmpp30', - requestedVersion: 48, - })).resolves.toEqual(expect.objectContaining({ status: 'authenticated' })); - await expect(service.submitInboundMessage({ - account: '100001', - phoneNumber: '13800000001', - content: 'hello', - sequenceId: 702, - remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true, status: 'accepted' })); + await expect( + service.authenticateInboundApplication({ + account: '100001', + password: 'secret-hash', + remoteIp: '127.0.0.1', + version: 'cmpp30', + requestedVersion: 48, + }), + ).resolves.toEqual(expect.objectContaining({ status: 'authenticated' })); + await expect( + service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: 'hello', + sequenceId: 702, + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ accepted: true, status: 'accepted' })); expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1); - expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }), - })); - expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }), - })); + expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }), + }), + ); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }), + }), + ); expect(service['postGatewayControl']).toHaveBeenCalledWith( '/downstream/receipt', expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }), @@ -4039,8 +4581,9 @@ describe('SendChainService', () => { prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([]); prisma.$queryRaw.mockResolvedValueOnce([]); - await expect(service.listPendingDownstreamDeliveries({ account: '100001', limit: 100, claimId: 'gateway-a:100001:1' })) - .resolves.toEqual([]); + await expect( + service.listPendingDownstreamDeliveries({ account: '100001', limit: 100, claimId: 'gateway-a:100001:1' }), + ).resolves.toEqual([]); const claimSql = prisma.$queryRaw.mock.calls[0][0].strings.join(' '); expect(claimSql).toContain('FOR UPDATE SKIP LOCKED'); expect(claimSql).toContain("status = 'dispatching'"); @@ -4061,25 +4604,29 @@ describe('SendChainService', () => { submittedAt: '2026-07-25T15:00:00.000Z', }); - expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledWith(expect.objectContaining({ - where: { - messageRecordId_submitId_segmentIndex: { - messageRecordId: 'record-1', - submitId: 'SUB-1', - segmentIndex: 1, + expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + messageRecordId_submitId_segmentIndex: { + messageRecordId: 'record-1', + submitId: 'SUB-1', + segmentIndex: 1, + }, }, - }, - create: expect.objectContaining({ - segmentTotal: 3, - sequenceId: 71, - gatewayMessageId: 'GW-SEG-1', - submitStatus: 'accepted', + create: expect.objectContaining({ + segmentTotal: 3, + sequenceId: 71, + gatewayMessageId: 'GW-SEG-1', + submitStatus: 'accepted', + }), }), - })); - expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'submit-1', gatewayMessageId: null }, - data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }), - })); + ); + expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'submit-1', gatewayMessageId: null }, + data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }), + }), + ); }); it('rejects a legacy segment result when multiple channel attempts could match', async () => { @@ -4089,15 +4636,17 @@ describe('SendChainService', () => { { id: 'submit-1', submitId: 'SUB-1', messageRecordId: 'record-1', channelId: 'channel-1' }, ]); - await expect(service.handleSubmitSegmentResult({ - messageId: 'MSG-1', - channelId: 'channel-1', - segmentTotal: 2, - segmentIndex: 1, - sequenceId: 72, - gatewayMessageId: 'GW-SEG-2', - submitStatus: 'accepted', - })).rejects.toThrow('cannot be matched uniquely'); + await expect( + service.handleSubmitSegmentResult({ + messageId: 'MSG-1', + channelId: 'channel-1', + segmentTotal: 2, + segmentIndex: 1, + sequenceId: 72, + gatewayMessageId: 'GW-SEG-2', + submitStatus: 'accepted', + }), + ).rejects.toThrow('cannot be matched uniquely'); expect(prisma.smsMessageSegmentAudit.upsert).not.toHaveBeenCalled(); expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled(); @@ -4107,48 +4656,61 @@ describe('SendChainService', () => { const { service, prisma } = createService(); jest.spyOn(service as any, 'processUpstreamReceiptInboxRecord').mockResolvedValue(false); - await expect(service.intakeReceipt({ - messageId: 'receipt-9001', - channelId: 'channel-1', - connectionId: 'gateway-connection-2', - sequenceId: 81, - gatewayMessageId: '9001', - phoneNumber: '13800000001', - receiptStatus: 'delivered', - rawStatus: 'DELIVRD', - deliveredAt: '2026-07-25T15:01:00.000Z', - })).resolves.toEqual(expect.objectContaining({ - accepted: true, - inboxId: 'receipt-inbox-1', - })); - - expect(prisma.upstreamReceiptInbox.upsert).toHaveBeenCalledWith(expect.objectContaining({ - create: expect.objectContaining({ - incomingChannelId: 'channel-1', - incomingConnectionId: 'gateway-connection-2', - upstreamAccount: 'cmpp-account', - upstreamHost: '127.0.0.1', - upstreamPort: 17890, - protocol: 'CMPP', - protocolVersion: '3.0', + await expect( + service.intakeReceipt({ + messageId: 'receipt-9001', + channelId: 'channel-1', + connectionId: 'gateway-connection-2', + sequenceId: 81, gatewayMessageId: '9001', - status: 'pending', + phoneNumber: '13800000001', + receiptStatus: 'delivered', + rawStatus: 'DELIVRD', + deliveredAt: '2026-07-25T15:01:00.000Z', }), - })); + ).resolves.toEqual( + expect.objectContaining({ + accepted: true, + inboxId: 'receipt-inbox-1', + }), + ); + + expect(prisma.upstreamReceiptInbox.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + incomingChannelId: 'channel-1', + incomingConnectionId: 'gateway-connection-2', + upstreamAccount: 'cmpp-account', + upstreamHost: '127.0.0.1', + upstreamPort: 17890, + protocol: 'CMPP', + protocolVersion: '3.0', + gatewayMessageId: '9001', + status: 'pending', + }), + }), + ); }); it('does not downgrade an early terminal receipt when the aggregate submit result arrives later', async () => { const { service, prisma } = createService(); const terminalMessage = { - id: 'record-1', messageId: 'MSG-1', tenantId: null, batchTaskId: null, applicationId: null, - channelId: 'channel-1', submitId: 'SUB-1', gatewayMessageId: 'GW-SEG-1', - phoneNumber: '13800000001', billingUnits: 1, amountCents: 0, status: 'failed', + id: 'record-1', + messageId: 'MSG-1', + tenantId: null, + batchTaskId: null, + applicationId: null, + channelId: 'channel-1', + submitId: 'SUB-1', + gatewayMessageId: 'GW-SEG-1', + phoneNumber: '13800000001', + billingUnits: 1, + amountCents: 0, + status: 'failed', }; prisma.smsMessageRecord.findFirst.mockResolvedValue(terminalMessage); prisma.smsMessageRecord.findUnique.mockResolvedValue(terminalMessage); - prisma.smsMessageRecord.updateMany - .mockResolvedValueOnce({ count: 0 }) - .mockResolvedValueOnce({ count: 1 }); + prisma.smsMessageRecord.updateMany.mockResolvedValueOnce({ count: 0 }).mockResolvedValueOnce({ count: 1 }); await service.handleSubmitResult({ messageId: 'MSG-1', @@ -4159,10 +4721,13 @@ describe('SendChainService', () => { submitStatus: 'accepted', }); - expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ - where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } }, - data: expect.objectContaining({ status: 'submitted' }), - })); + expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } }, + data: expect.objectContaining({ status: 'submitted' }), + }), + ); expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(2, { where: { id: 'record-1', gatewayMessageId: null }, data: expect.objectContaining({ gatewayMessageId: 'GW-1' }), @@ -4172,23 +4737,23 @@ describe('SendChainService', () => { it('recovers a stale submit requeue with the same Redis idempotency key', async () => { const { service, prisma } = createService(); const stale = { - ...await prisma.gatewaySubmitDeadLetter.findUnique({ where: { id: 'dead-1' } }), + ...(await prisma.gatewaySubmitDeadLetter.findUnique({ where: { id: 'dead-1' } })), status: 'requeueing', updatedAt: new Date('2026-07-21T07:00:00.000Z'), }; prisma.gatewaySubmitDeadLetter.findMany.mockResolvedValue([stale]); const publish = jest.spyOn(service as any, 'publishGatewaySubmitCommand').mockResolvedValue('1710000001000-0'); - await expect(service.recoverStaleGatewaySubmitRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1, failed: 0 }); + await expect(service.recoverStaleGatewaySubmitRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ + recovered: 1, + failed: 0, + }); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(1, { where: { id: 'dead-1', status: 'requeueing', updatedAt: stale.updatedAt }, data: { status: 'requeue_recovering' }, }); - expect(publish).toHaveBeenCalledWith( - stale.commandPayload, - 'gateway:submit:requeue:dead-1:1', - ); + expect(publish).toHaveBeenCalledWith(stale.commandPayload, 'gateway:submit:requeue:dead-1:1'); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(2, { where: { id: 'dead-1', status: 'requeue_recovering' }, data: expect.objectContaining({ @@ -4202,9 +4767,7 @@ describe('SendChainService', () => { it('atomically claims a downstream manual requeue so concurrent requests only call Gateway once', async () => { const { service, prisma } = createService(); service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: true, sequenceId: '11', messageId: '22' }); - prisma.cmppDownstreamDelivery.updateMany - .mockResolvedValueOnce({ count: 1 }) - .mockResolvedValueOnce({ count: 0 }); + prisma.cmppDownstreamDelivery.updateMany.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 }); const results = await Promise.allSettled([ service.requeueDownstreamDelivery('delivery-1'), @@ -4232,7 +4795,9 @@ describe('SendChainService', () => { const updatedAt = new Date('2026-07-21T07:00:00.000Z'); prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1', updatedAt }]); - await expect(service.recoverStaleDownstreamManualRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1 }); + await expect(service.recoverStaleDownstreamManualRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ + recovered: 1, + }); expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({ where: { status: 'manual_requeueing', updatedAt: { lt: expect.any(Date) } }, @@ -4253,41 +4818,49 @@ describe('SendChainService', () => { it('records gateway downstream recovery statuses', async () => { const { service, prisma } = createService(); - await expect(service.recordGatewayDownstreamRecoveryStatus({ - account: '100001', - gatewayInstanceId: 'gateway-a', - state: 'waiting_connection', - lastAttemptAt: '2026-07-08T12:00:00.000Z', - nextRetryAt: '2026-07-08T12:10:00.000Z', - attemptCount: 2, - lockOwner: 'gateway-a', - lockExpiresAt: '2026-07-08T12:00:30.000Z', - lastError: 'downstream client is not connected', - })).resolves.toEqual(expect.objectContaining({ - id: 'recover-1', - account: '100001', - state: 'waiting_connection', - })); + await expect( + service.recordGatewayDownstreamRecoveryStatus({ + account: '100001', + gatewayInstanceId: 'gateway-a', + state: 'waiting_connection', + lastAttemptAt: '2026-07-08T12:00:00.000Z', + nextRetryAt: '2026-07-08T12:10:00.000Z', + attemptCount: 2, + lockOwner: 'gateway-a', + lockExpiresAt: '2026-07-08T12:00:30.000Z', + lastError: 'downstream client is not connected', + }), + ).resolves.toEqual( + expect.objectContaining({ + id: 'recover-1', + account: '100001', + state: 'waiting_connection', + }), + ); - expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalledWith(expect.objectContaining({ - where: { account: '100001' }, - update: expect.objectContaining({ - lockOwner: 'gateway-a', - lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), - failureCategory: 'client_disconnected', + expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { account: '100001' }, + update: expect.objectContaining({ + lockOwner: 'gateway-a', + lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), + failureCategory: 'client_disconnected', + }), + create: expect.objectContaining({ + lockOwner: 'gateway-a', + lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), + failureCategory: 'client_disconnected', + }), }), - create: expect.objectContaining({ - lockOwner: 'gateway-a', - lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), - failureCategory: 'client_disconnected', + ); + expect(prisma.operationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'gateway.downstream_recovery_status_changed', + resource: 'gateway_downstream_recovery_status', + }), }), - })); - expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ - action: 'gateway.downstream_recovery_status_changed', - resource: 'gateway_downstream_recovery_status', - }), - })); + ); }); it('does not append recovery audit logs when only periodic timestamps change', async () => { @@ -4377,28 +4950,33 @@ describe('SendChainService', () => { 'unrecoverable', ); - expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ - status: 'failed', - retryCount: 1, - nextRetryAt: null, + expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'failed', + retryCount: 1, + nextRetryAt: null, + }), }), - })); + ); }); it('does not let the pending timeout scan overwrite a delivery that is already awaiting acknowledgement', async () => { const { service, prisma } = createService(); const awaitingAck = { - id: 'delivery-1', status: 'awaiting_ack', tenantId: 'tenant-1', applicationId: 'app-1', - messageId: 'MSG-1', deliveryType: 'receipt', retryCount: 0, + id: 'delivery-1', + status: 'awaiting_ack', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + deliveryType: 'receipt', + retryCount: 0, }; prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue(awaitingAck); - await expect(service.markDownstreamDeliveryFailed( - 'delivery-1', - '下游投递排队超过 72 小时,系统自动终止重试', - 'queue_timeout', - )).resolves.toEqual(awaitingAck); + await expect( + service.markDownstreamDeliveryFailed('delivery-1', '下游投递排队超过 72 小时,系统自动终止重试', 'queue_timeout'), + ).resolves.toEqual(awaitingAck); expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled(); }); @@ -4414,19 +4992,23 @@ describe('SendChainService', () => { sentAt: '2026-07-14T03:40:18.030Z', ackDeadlineAt: '2026-07-14T03:40:48.030Z', }); - expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({ - where: { id: 'delivery-1', status: { not: 'delivered' } }, - data: expect.objectContaining({ status: 'awaiting_ack', ackSequenceId: '37', connectionId: 'conn-1' }), - })); - expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith(expect.objectContaining({ - create: expect.objectContaining({ - deliveryId: 'delivery-1', - attemptNo: 1, - connectionId: 'conn-1', - sequenceId: '37', - status: 'awaiting_ack', + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'delivery-1', status: { not: 'delivered' } }, + data: expect.objectContaining({ status: 'awaiting_ack', ackSequenceId: '37', connectionId: 'conn-1' }), }), - })); + ); + expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + deliveryId: 'delivery-1', + attemptNo: 1, + connectionId: 'conn-1', + sequenceId: '37', + status: 'awaiting_ack', + }), + }), + ); await service.acknowledgeDownstreamDelivery({ id: 'delivery-1', @@ -4436,16 +5018,24 @@ describe('SendChainService', () => { result: 0, acknowledgedAt: '2026-07-14T03:40:18.060Z', }); - expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'delivered', ackResult: 0, deliveredAt: new Date('2026-07-14T03:40:18.060Z') }), - })); - expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith(expect.objectContaining({ - update: expect.objectContaining({ - status: 'acknowledged', - acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'), - ackResult: 0, + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'delivered', + ackResult: 0, + deliveredAt: new Date('2026-07-14T03:40:18.060Z'), + }), }), - })); + ); + expect(prisma.cmppDownstreamDeliveryAttempt.upsert).toHaveBeenLastCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + status: 'acknowledged', + acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'), + ackResult: 0, + }), + }), + ); }); it('does not treat Result=0 with Msg_Id=0 as a business acknowledgement', async () => { @@ -4460,29 +5050,43 @@ describe('SendChainService', () => { acknowledgedAt: '2026-07-14T07:07:49.336Z', }); - expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }), - })); - expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'pending', lastError: expect.stringContaining('Msg_Id=0') }), - })); - expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'delivered' }), - })); + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }), + }), + ); + expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'pending', lastError: expect.stringContaining('Msg_Id=0') }), + }), + ); + expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'delivered' }), + }), + ); }); it('does not automatically retry an unacknowledged delivery when its policy snapshot is disabled', async () => { const { service, prisma } = createService(); prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({ - id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', - deliveryType: 'receipt', status: 'awaiting_ack', retryEnabled: false, retryCount: 0, + id: 'delivery-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + deliveryType: 'receipt', + status: 'awaiting_ack', + retryEnabled: false, + retryCount: 0, }); await service.markDownstreamDeliveryFailed('delivery-1', 'CMPP_DELIVER_RESP timeout', 'ack_timeout'); - expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'unconfirmed', retryCount: 1, nextRetryAt: null }), - })); + expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'unconfirmed', retryCount: 1, nextRetryAt: null }), + }), + ); }); it('uses exponential backoff for downstream delivery retries before final failure', async () => { @@ -4575,23 +5179,25 @@ describe('SendChainService', () => { resourceId: 'delivery-1', }), }); - expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - id: 'delivery-1', - status: 'failed', - updatedAt: new Date('2026-07-21T08:00:00.000Z'), + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'delivery-1', + status: 'failed', + updatedAt: new Date('2026-07-21T08:00:00.000Z'), + }), + data: expect.objectContaining({ + status: 'manual_requeueing', + retryCount: 0, + manualRetryCount: { increment: 1 }, + lastRetriedAt: expect.any(Date), + acknowledgedAt: null, + ackResult: null, + ackMessageId: null, + deliveredAt: null, + }), }), - data: expect.objectContaining({ - status: 'manual_requeueing', - retryCount: 0, - manualRetryCount: { increment: 1 }, - lastRetriedAt: expect.any(Date), - acknowledgedAt: null, - ackResult: null, - ackMessageId: null, - deliveredAt: null, - }), - })); + ); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'gateway.downstream_delivery_requeue', @@ -4615,22 +5221,38 @@ describe('SendChainService', () => { application: { cmppAccount: '100001' }, }); - await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投'); + await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow( + '该记录正在等待客户端确认,不允许并发重投', + ); expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalled(); expect(service['postGatewayControl']).not.toHaveBeenCalled(); }); it('terminates a manual requeue when gateway reports it is unrecoverable', async () => { const { service, prisma } = createService(); - prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({ - id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', - deliveryType: 'receipt', status: 'failed', retryCount: 3, manualRetryCount: 0, - payload: { account: '100001', messageId: 'MSG-1', receiptStatus: 'delivered' }, - application: { cmppAccount: '100001' }, - }).mockResolvedValueOnce({ - id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', - deliveryType: 'receipt', status: 'pending', retryCount: 0, retryEnabled: true, - }); + prisma.cmppDownstreamDelivery.findUnique + .mockResolvedValueOnce({ + id: 'delivery-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + deliveryType: 'receipt', + status: 'failed', + retryCount: 3, + manualRetryCount: 0, + payload: { account: '100001', messageId: 'MSG-1', receiptStatus: 'delivered' }, + application: { cmppAccount: '100001' }, + }) + .mockResolvedValueOnce({ + id: 'delivery-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + deliveryType: 'receipt', + status: 'pending', + retryCount: 0, + retryEnabled: true, + }); service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: false, retryable: false, @@ -4640,18 +5262,21 @@ describe('SendChainService', () => { await service.requeueDownstreamDelivery('delivery-1'); - expect(prisma.cmppDownstreamDelivery.update).toHaveBeenLastCalledWith(expect.objectContaining({ - data: expect.objectContaining({ - status: 'failed', - nextRetryAt: null, - lastError: expect.stringContaining('MISSING_SUBMIT_SEQUENCE_ID'), + expect(prisma.cmppDownstreamDelivery.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'failed', + nextRetryAt: null, + lastError: expect.stringContaining('MISSING_SUBMIT_SEQUENCE_ID'), + }), }), - })); + ); }); it('supports batch requeue of downstream deliveries', async () => { const { service } = createService(); - service.requeueDownstreamDelivery = jest.fn() + service.requeueDownstreamDelivery = jest + .fn() .mockResolvedValueOnce({ id: 'delivery-1' }) .mockRejectedValueOnce(new Error('Gateway control delivery failed')); @@ -4674,12 +5299,40 @@ describe('SendChainService', () => { it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => { const { service, prisma, billing } = createService(); prisma.smsMessageRecord.findMany.mockResolvedValue([ - { id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3, billingUnits: 1, status: 'submitted', cmppSubmitSequenceId: '701', cmppRegisteredDelivery: true, timeoutAt: null }, - { id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-2', phoneNumber: '13900000002', amountCents: 3, billingUnits: 1, status: 'unknown', cmppSubmitSequenceId: '702', cmppRegisteredDelivery: true, timeoutAt: null }, + { + id: 'record-1', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + amountCents: 3, + billingUnits: 1, + status: 'submitted', + cmppSubmitSequenceId: '701', + cmppRegisteredDelivery: true, + timeoutAt: null, + }, + { + id: 'record-2', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + messageId: 'MSG-2', + phoneNumber: '13900000002', + amountCents: 3, + billingUnits: 1, + status: 'unknown', + cmppSubmitSequenceId: '702', + cmppRegisteredDelivery: true, + timeoutAt: null, + }, ]); prisma.smsBillingRecord.findFirst - .mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' }) - .mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-2', billingStatus: 'charged' }); + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'bill-2', billingStatus: 'charged' }); await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 2 }); expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({ @@ -4716,34 +5369,38 @@ describe('SendChainService', () => { const prisma = createPrismaMock(); const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ id: 'http-timeout-delivery' }) }; const { service } = createService(prisma, openApi); - prisma.smsMessageRecord.findMany.mockResolvedValue([{ - id: 'record-http-timeout', - tenantId: 'tenant-1', - batchTaskId: null, - applicationId: 'app-1', - messageId: 'MSG-HTTP-TIMEOUT', - phoneNumber: '13800000001', - amountCents: 0, - billingUnits: 1, - status: 'submitted', - cmppSubmitSequenceId: null, - cmppSubmitGroupMessageId: null, - cmppRegisteredDelivery: null, - timeoutAt: null, - }]); + prisma.smsMessageRecord.findMany.mockResolvedValue([ + { + id: 'record-http-timeout', + tenantId: 'tenant-1', + batchTaskId: null, + applicationId: 'app-1', + messageId: 'MSG-HTTP-TIMEOUT', + phoneNumber: '13800000001', + amountCents: 0, + billingUnits: 1, + status: 'submitted', + cmppSubmitSequenceId: null, + cmppSubmitGroupMessageId: null, + cmppRegisteredDelivery: null, + timeoutAt: null, + }, + ]); await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 1 }); - expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({ - applicationId: 'app-1', - messageRecordId: 'record-http-timeout', - eventType: 'receipt', - payload: expect.objectContaining({ - receiptStatus: 'undelivered', - rawStatus: 'EXPIRED', - errorCode: 'RECEIPT_TIMEOUT', + expect(openApi.queueWebhookEvent).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: 'app-1', + messageRecordId: 'record-http-timeout', + eventType: 'receipt', + payload: expect.objectContaining({ + receiptStatus: 'undelivered', + rawStatus: 'EXPIRED', + errorCode: 'RECEIPT_TIMEOUT', + }), }), - })); + ); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ where: { id: 'record-http-timeout', status: 'timeout', timeoutReceiptQueuedAt: null }, @@ -4756,21 +5413,23 @@ describe('SendChainService', () => { prisma.smsBillingRecord.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'billing-timeout-recovery', billingStatus: 'charged' }); - prisma.smsMessageRecord.findMany.mockResolvedValue([{ - id: 'record-timeout-recovery', - tenantId: 'tenant-1', - batchTaskId: null, - applicationId: 'app-1', - messageId: 'MSG-TIMEOUT-RECOVERY', - phoneNumber: '13800000001', - amountCents: 3, - billingUnits: 1, - status: 'timeout', - cmppSubmitSequenceId: '703', - cmppSubmitGroupMessageId: null, - cmppRegisteredDelivery: true, - timeoutAt: new Date('2026-08-01T00:00:00.000Z'), - }]); + prisma.smsMessageRecord.findMany.mockResolvedValue([ + { + id: 'record-timeout-recovery', + tenantId: 'tenant-1', + batchTaskId: null, + applicationId: 'app-1', + messageId: 'MSG-TIMEOUT-RECOVERY', + phoneNumber: '13800000001', + amountCents: 3, + billingUnits: 1, + status: 'timeout', + cmppSubmitSequenceId: '703', + cmppSubmitGroupMessageId: null, + cmppRegisteredDelivery: true, + timeoutAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]); await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 0 }); @@ -4794,15 +5453,17 @@ describe('SendChainService', () => { await expect(service.markExpiredDownstreamDeliveries(72)).resolves.toEqual({ failed: 1 }); - expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { - status: 'pending', - OR: [ - { lastRetriedAt: null, createdAt: { lte: expect.any(Date) } }, - { lastRetriedAt: { lte: expect.any(Date) } }, - ], - }, - })); + expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + status: 'pending', + OR: [ + { lastRetriedAt: null, createdAt: { lte: expect.any(Date) } }, + { lastRetriedAt: { lte: expect.any(Date) } }, + ], + }, + }), + ); expect(service.markDownstreamDeliveryFailed).toHaveBeenCalledWith( 'delivery-expired', '下游投递排队超过 72 小时,系统自动终止重试', diff --git a/api/src/send-chain/send-gateway-submit.service.ts b/api/src/send-chain/send-gateway-submit.service.ts index e02e32c..a1db4b7 100644 --- a/api/src/send-chain/send-gateway-submit.service.ts +++ b/api/src/send-chain/send-gateway-submit.service.ts @@ -1,19 +1,33 @@ -import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Queue, Worker } from 'bullmq'; import IORedis from 'ioredis'; -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } 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 { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service'; import { PrismaService } from '../prisma/prisma.service'; import { RiskReviewService } from '../risk-review/risk-review.service'; import { PhoneFrequencyService } from '../risk-review/phone-frequency.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, drainageRejectionReason, 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 { SendJob, RoutedChannel } from './send-chain.contracts'; +import { + SEND_QUEUE, + GATEWAY_SUBMIT_QUEUE, + GATEWAY_SUBMIT_STREAM, + GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, + BULLMQ_PRIORITY, + normalizeCarrier, + normalizeQueuePriority, + getPositiveConfigInteger, + getNonNegativeConfigInteger, + isNationalChannel, + composeUpstreamSrcId, + bullmqConnection, + selectChannelCandidate, +} from './send-chain.helpers'; import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; type PendingSendBatchItem = { @@ -34,6 +48,8 @@ export class SendGatewaySubmitService { private sendQueueMetricsTimer?: ReturnType; private submitOutboxTimer?: ReturnType; private submitOutboxRunning = false; + private nightReviewRecoveryTimer?: ReturnType; + private nightReviewRecovering = false; private readonly submitOutboxLeaseOwner = `send-worker-${process.pid}-${randomUUID()}`; private sendWorkerInFlight = 0; private sendWorkerConfiguredSlots = 0; @@ -56,6 +72,7 @@ export class SendGatewaySubmitService { ) {} async onModuleDestroy() { + if (this.nightReviewRecoveryTimer) clearInterval(this.nightReviewRecoveryTimer); if (this.sendQueueMetricsTimer) clearInterval(this.sendQueueMetricsTimer); if (this.submitOutboxTimer) clearInterval(this.submitOutboxTimer); if (this.sendBatchTimer) clearTimeout(this.sendBatchTimer); @@ -67,7 +84,13 @@ export class SendGatewaySubmitService { } 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); @@ -90,17 +113,20 @@ export class SendGatewaySubmitService { return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); } - -async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) { + async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) { if (preparedMessage) { // CMPP内部任务在当前请求内刚完成持久化且不暴露取消入口,可安全复用已知ID; // 普通批量任务仍走下方查询路径,以保留取消检查和多消息枚举语义。 const queuePriority = normalizeQueuePriority(preparedMessage.queuePriority); - await this.facade.getSendQueue().add('send-message', { messageRecordId: preparedMessage.messageRecordId }, { - jobId: preparedMessage.messageRecordId, - attempts: 3, - priority: BULLMQ_PRIORITY[queuePriority], - }); + await this.facade.getSendQueue().add( + 'send-message', + { messageRecordId: preparedMessage.messageRecordId }, + { + jobId: preparedMessage.messageRecordId, + attempts: 3, + priority: BULLMQ_PRIORITY[queuePriority], + }, + ); await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } }); return { taskId, enqueued: 1 }; } @@ -119,23 +145,29 @@ async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: stri const queue = this.facade.getSendQueue(); for (const message of messages) { const queuePriority = normalizeQueuePriority(message.queuePriority); - await queue.add('send-message', { messageRecordId: message.id }, { - jobId: message.id, - attempts: 3, - priority: BULLMQ_PRIORITY[queuePriority], - }); + await queue.add( + 'send-message', + { messageRecordId: message.id }, + { + jobId: message.id, + attempts: 3, + priority: BULLMQ_PRIORITY[queuePriority], + }, + ); } await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } }); return { taskId, enqueued: messages.length }; } -startWorker() { + startWorker() { if (this.worker) { return { status: 'already_started' }; } + this.nightReviewRecoveryTimer = setInterval(() => void this.recoverNightReviews(), 15_000); const connection = bullmqConnection(); const configuredConcurrency = Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20); - this.sendWorkerConfiguredSlots = Number.isInteger(configuredConcurrency) && configuredConcurrency > 0 ? configuredConcurrency : 20; + this.sendWorkerConfiguredSlots = + Number.isInteger(configuredConcurrency) && configuredConcurrency > 0 ? configuredConcurrency : 20; this.metrics?.setSendWorkerSlots(this.sendWorkerConfiguredSlots, this.sendWorkerInFlight); this.worker = new Worker( SEND_QUEUE, @@ -167,7 +199,7 @@ startWorker() { return { status: 'started' }; } -startSubmitOutboxPublisher() { + startSubmitOutboxPublisher() { if (this.submitOutboxTimer) return { status: 'already_started' }; if (!this.submitOutboxEnabled()) return { status: 'disabled' }; void this.publishSubmitOutboxBatch(); @@ -227,10 +259,12 @@ startSubmitOutboxPublisher() { return new Map([[jobs[0].messageRecordId, await this.processSendJob(jobs[0])]]); } const ids = [...new Set(jobs.map((job) => job.messageRecordId))]; - const messages = await this.measureSendStage('message_load', () => this.prisma.smsMessageRecord.findMany({ - where: { id: { in: ids } }, - include: { batchTask: true, template: { include: { signature: true } }, signature: true }, - })); + const messages = await this.measureSendStage('message_load', () => + this.prisma.smsMessageRecord.findMany({ + where: { id: { in: ids } }, + include: { batchTask: true, template: { include: { signature: true } }, signature: true }, + }), + ); const messageById = new Map(messages.map((message) => [message.id, message])); const results = new Map(); const businessMessages = messages.filter((message) => { @@ -239,21 +273,31 @@ startSubmitOutboxPublisher() { return false; } return true; - }) as Array; + }) as Array<(typeof messages)[number] & { tenantId: string; batchTaskId: string }>; for (const id of ids) if (!messageById.has(id)) results.set(id, { skipped: true }); if (businessMessages.length === 0) return results; - const { planned, failed } = await this.planRoutesBatch(businessMessages); + const held = await this.riskReview.guardNightSending(businessMessages.map((message) => message.id)); + for (const id of held) results.set(id, { submitted: false, status: 'pending_review', messageRecordId: id }); + for (const taskId of new Set( + businessMessages.filter((message) => held.has(message.id)).map((message) => message.batchTaskId), + )) + await this.facade.refreshTaskProgress(taskId); + const { planned, failed } = await this.planRoutesBatch(businessMessages.filter((message) => !held.has(message.id))); if (failed.length > 0) await this.failRouteBatch(failed, results); if (planned.length === 0) return results; - await Promise.all(planned.map(({ routed }) => ( - this.facade.waitForChannelRateLimit(routed.channel.id, routed.channel.rateLimitPerSecond) - ))); + await Promise.all( + planned.map(({ routed }) => + this.facade.waitForChannelRateLimit(routed.channel.id, routed.channel.rateLimitPerSecond), + ), + ); const sessionByChannel = new Map(); - await Promise.all([...new Set(planned.map(({ routed }) => routed.channel.id))].map(async (channelId) => { - sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId)); - })); + await Promise.all( + [...new Set(planned.map(({ routed }) => routed.channel.id))].map(async (channelId) => { + sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId)); + }), + ); const prepared = planned.map(({ message, routed }) => { const submitId = `SUB-${randomUUID()}`; const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension); @@ -266,28 +310,33 @@ startSubmitOutboxPublisher() { }; }); const writeOutbox = this.submitOutboxEnabled(); - await this.measureSendStage('submit_transaction', () => this.prisma.$transaction(async (tx) => { - await tx.smsSubmitRecord.createMany({ - data: prepared.map(({ message, routed, submitId, sessionId }) => ({ - id: randomUUID(), - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - channelId: routed.channel.id, - channelGroupId: routed.groupId, - channelGroupName: routed.groupName, - sessionId, - submitId, - submitStatus: 'queued', - costUnitPrice: routed.channel.unitPrice ?? 0, - costAmountCents: moneyToNumber(routed.channel.unitPrice) * Math.max(1, message.billingUnits ?? 1), - })), - }); - const updates = Prisma.join(prepared.map(({ message, routed, submitId }) => Prisma.sql`( + await this.measureSendStage('submit_transaction', () => + this.prisma.$transaction(async (tx) => { + await tx.smsSubmitRecord.createMany({ + data: prepared.map(({ message, routed, submitId, sessionId }) => ({ + id: randomUUID(), + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + channelId: routed.channel.id, + channelGroupId: routed.groupId, + channelGroupName: routed.groupName, + sessionId, + submitId, + submitStatus: 'queued', + costUnitPrice: routed.channel.unitPrice ?? 0, + costAmountCents: moneyToNumber(routed.channel.unitPrice) * Math.max(1, message.billingUnits ?? 1), + })), + }); + const updates = Prisma.join( + prepared.map( + ({ message, routed, submitId }) => Prisma.sql`( ${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text, ${routed.province ?? null}::text, ${submitId}::text - )`)); - await tx.$executeRaw(Prisma.sql` + )`, + ), + ); + await tx.$executeRaw(Prisma.sql` UPDATE "SmsMessageRecord" AS message SET "channelId" = updates."channelId", carrier = updates.carrier, @@ -302,74 +351,105 @@ startSubmitOutboxPublisher() { FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId") WHERE message.id = updates.id AND message.status = 'queued' `); - if (writeOutbox) { - await tx.gatewaySubmitOutbox.createMany({ - data: prepared.map(({ message, routed, submitId, command }) => ({ - id: randomUUID(), submitId, messageRecordId: message.id, - channelId: routed.channel.id, payload: command as Prisma.InputJsonValue, - })), - }); - } - })); + if (writeOutbox) { + await tx.gatewaySubmitOutbox.createMany({ + data: prepared.map(({ message, routed, submitId, command }) => ({ + id: randomUUID(), + submitId, + messageRecordId: message.id, + channelId: routed.channel.id, + payload: command as Prisma.InputJsonValue, + })), + }); + } + }), + ); if (!this.submitOutboxPublishEnabled()) { await Promise.all(prepared.map(({ command }) => this.facade.publishGatewaySubmitCommand(command))); } await this.refreshTaskProgressBatch(prepared.map(({ message }) => message)); for (const { message, routed, submitId } of prepared) { results.set(message.id, { - submitted: true, messageRecordId: message.id, channelId: routed.channel.id, attempt: 0, submitId, + submitted: true, + messageRecordId: message.id, + channelId: routed.channel.id, + attempt: 0, + submitId, }); this.metrics?.recordSendWorkerResult('completed'); } return results; } - private async planRoutesBatch(messages: T[]) { + private async planRoutesBatch< + T extends { + id: string; + tenantId: string; + batchTaskId: 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; + }, + >(messages: T[]) { const unresolvedPhones = messages.filter((message) => !message.carrier).map((message) => message.phoneNumber); - const provinces = await this.measureSendStage('phone_routing', () => this.phoneRouting.identifyProvinces(unresolvedPhones)); - const routeInputs = await Promise.all(messages.map(async (message) => ({ - message, - carrier: message.carrier ? normalizeCarrier(message.carrier) : normalizeCarrier(await this.phoneRouting.identifyCarrier(message.phoneNumber)), - province: message.carrier ? message.province ?? null : provinces.get(message.phoneNumber) ?? null, - signatureId: message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null, - }))); + const provinces = await this.measureSendStage('phone_routing', () => + this.phoneRouting.identifyProvinces(unresolvedPhones), + ); + const routeInputs = await Promise.all( + messages.map(async (message) => ({ + message, + carrier: message.carrier + ? normalizeCarrier(message.carrier) + : normalizeCarrier(await this.phoneRouting.identifyCarrier(message.phoneNumber)), + province: message.carrier ? (message.province ?? null) : (provinces.get(message.phoneNumber) ?? null), + signatureId: message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null, + })), + ); const valid = routeInputs.filter((input) => input.message.applicationId && input.signatureId); const signatures = [...new Set(valid.map((input) => input.signatureId as string))]; - const routes = valid.length === 0 ? [] : await this.measureSendStage('route_lookup', () => this.prisma.channelRouteRule.findMany({ - where: { - status: 'active', channelId: null, province: null, - OR: valid.map((input) => ({ - tenantId: input.message.tenantId, - applicationId: input.message.applicationId, - carrier: input.carrier, - })), - }, - include: { - group: { - include: { - items: { + const routes = + valid.length === 0 + ? [] + : await this.measureSendStage('route_lookup', () => + this.prisma.channelRouteRule.findMany({ + where: { + status: 'active', + channelId: null, + province: null, + OR: valid.map((input) => ({ + tenantId: input.message.tenantId, + applicationId: input.message.applicationId, + carrier: input.carrier, + })), + }, include: { - channel: { + group: { include: { - connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } }, - reportTasks: { where: { signatureId: { in: signatures }, reportType: 'signature', status: 'approved' } }, + items: { + include: { + channel: { + include: { + connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } }, + reportTasks: { + where: { signatureId: { in: signatures }, reportType: 'signature', status: 'approved' }, + }, + }, + }, + }, + orderBy: { priority: 'asc' }, + }, }, }, }, orderBy: { priority: 'asc' }, - }, - }, - }, - }, - orderBy: { priority: 'asc' }, - })); - const routeByKey = new Map(); + }), + ); + const routeByKey = new Map(); for (const route of routes) { const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`; if (!routeByKey.has(key)) routeByKey.set(key, route); @@ -394,11 +474,17 @@ startSubmitOutboxPublisher() { failed.push({ message: input.message, reason: '企业应用绑定的通道组已停用或运营商不一致' }); continue; } - const approvedItems = route.group.items.filter((item) => item.channel.status === 'active' - && item.channel.connectionStates.length > 0 - && item.channel.reportTasks.some((task) => task.signatureId === input.signatureId - && (task.carrier === input.carrier - || (process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')))); + const approvedItems = route.group.items.filter( + (item) => + item.channel.status === 'active' && + item.channel.connectionStates.length > 0 && + item.channel.reportTasks.some( + (task) => + task.signatureId === input.signatureId && + (task.carrier === input.carrier || + (process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')), + ), + ); const selected = selectChannelCandidate(approvedItems, { carrier: input.carrier, province: input.province, @@ -414,8 +500,10 @@ startSubmitOutboxPublisher() { message: input.message, routed: { channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) }, - carrier: input.carrier, province: input.province, - groupId: route.groupId, groupName: route.group.name, + carrier: input.carrier, + province: input.province, + groupId: route.groupId, + groupName: route.group.name, routeScope: isNationalChannel(selected) ? 'national' : 'province', }, }); @@ -423,43 +511,67 @@ startSubmitOutboxPublisher() { return { planned, failed }; } - private async failRouteBatch(failed: Array<{ message: T; reason: string }>, results: Map) { - const values = Prisma.join(failed.map(({ message, reason }) => Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text)`)); + private async failRouteBatch< + T extends { + id: string; + tenantId: string; + batchTaskId: string; + applicationId?: string | null; + messageId: string; + phoneNumber: string; + amountCents: bigint; + billingUnits: number; + cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; + batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null; + }, + >(failed: Array<{ message: T; reason: string }>, results: Map) { + const values = Prisma.join( + failed.map(({ message, reason }) => Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text)`), + ); 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) WHERE message.id = failures.id AND message.status = 'queued' `); - await Promise.all(failed.map(async ({ message, reason }) => { - await this.releaseMessageReservation(message, reason); - if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, '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'); - })); + await Promise.all( + failed.map(async ({ message, reason }) => { + await this.releaseMessageReservation(message, reason); + if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, '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'); + }), + ); } - private async refreshTaskProgressBatch(messages: Array<{ - batchTaskId: string; batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null; - }>) { - const singleCmppIds = [...new Set(messages - .filter((message) => message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1) - .map((message) => message.batchTaskId))]; + private async refreshTaskProgressBatch( + messages: Array<{ + batchTaskId: string; + batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null; + }>, + ) { + const singleCmppIds = [ + ...new Set( + messages + .filter((message) => message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1) + .map((message) => message.batchTaskId), + ), + ]; if (singleCmppIds.length > 0) { await this.prisma.smsBatchTask.updateMany({ where: { id: { in: singleCmppIds }, sourceType: 'cmpp', phoneTotal: 1 }, data: singleMessageTaskProgress('submit_queued'), }); } - const otherTaskIds = [...new Set(messages - .filter((message) => !singleCmppIds.includes(message.batchTaskId)) - .map((message) => message.batchTaskId))]; + const otherTaskIds = [ + ...new Set( + messages + .filter((message) => !singleCmppIds.includes(message.batchTaskId)) + .map((message) => message.batchTaskId), + ), + ]; await Promise.all(otherTaskIds.map((taskId) => this.facade.refreshTaskProgress(taskId))); } @@ -470,15 +582,21 @@ startSubmitOutboxPublisher() { if (totalFinished) return; totalFinished = true; if (totalStartedAt != null) { - this.metrics?.finishSendWorkerStage(totalStartedAt, 'total', result === 'completed' ? 'success' : result === 'skipped' ? 'skipped' : 'error'); + this.metrics?.finishSendWorkerStage( + totalStartedAt, + 'total', + result === 'completed' ? 'success' : result === 'skipped' ? 'skipped' : 'error', + ); } this.metrics?.recordSendWorkerResult(result); }; try { - const message = await this.measureSendStage('message_load', () => this.prisma.smsMessageRecord.findUnique({ - where: { id: job.messageRecordId }, - include: { batchTask: true, template: { include: { signature: true } }, signature: true }, - })); + const message = await this.measureSendStage('message_load', () => + this.prisma.smsMessageRecord.findUnique({ + where: { id: job.messageRecordId }, + include: { batchTask: true, template: { include: { signature: true } }, signature: true }, + }), + ); if (!message || message.status !== 'queued') { finish('skipped'); return { skipped: true }; @@ -488,6 +606,14 @@ startSubmitOutboxPublisher() { return { skipped: true, reason: 'standalone channel test message' }; } const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; + // Keep persistence failures outside the route-failure catch: retry the job, + // never turn a failed risk check into a submission or a silent route rejection. + const held = await this.riskReview.guardNightSending([message.id]); + if (held.has(message.id)) { + await this.facade.refreshTaskProgress(businessMessage.batchTaskId); + finish('skipped'); + return { submitted: false, status: 'pending_review', messageRecordId: message.id }; + } try { const routed = await this.facade.selectChannelForMessage(businessMessage); const result = await this.facade.submitMessageToGateway(businessMessage, routed, 0); @@ -543,80 +669,87 @@ startSubmitOutboxPublisher() { ) { const channel = routed.channel; const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension); - await this.measureSendStage('rate_limit', () => this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond)); + await this.measureSendStage('rate_limit', () => + this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond), + ); const submitId = `SUB-${randomUUID()}`; const sessionId = await this.getOpenSubmitSessionId(channel.id); const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId); const writeOutbox = this.submitOutboxEnabled(); try { - await this.measureSendStage('submit_transaction', () => this.prisma.$transaction(async (tx) => { - await tx.smsSubmitRecord.create({ - data: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - channelId: channel.id, - channelGroupId: routed.groupId, - channelGroupName: routed.groupName, - sessionId, - retryOfSubmitRecordId, - submitId, - submitStatus: 'queued', - costUnitPrice: channel.unitPrice ?? 0, - costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1), - }, - }); - await tx.smsMessageRecord.update({ - where: { id: message.id }, - data: { - channelId: channel.id, - carrier: routed.carrier, - province: routed.province, - submitId, - status: 'submit_queued', - submitStatus: 'queued', - receiptStatus: null, - errorCode: null, - errorMessage: attempt > 0 ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined, - }, - }); - if (writeOutbox) { - await tx.gatewaySubmitOutbox.create({ + await this.measureSendStage('submit_transaction', () => + this.prisma.$transaction(async (tx) => { + await tx.smsSubmitRecord.create({ data: { - submitId, + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, messageRecordId: message.id, channelId: channel.id, - payload: command as Prisma.InputJsonValue, + channelGroupId: routed.groupId, + channelGroupName: routed.groupName, + sessionId, + retryOfSubmitRecordId, + submitId, + submitStatus: 'queued', + costUnitPrice: channel.unitPrice ?? 0, + costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1), }, }); - } - })); + await tx.smsMessageRecord.update({ + where: { id: message.id }, + data: { + channelId: channel.id, + carrier: routed.carrier, + province: routed.province, + submitId, + status: 'submit_queued', + submitStatus: 'queued', + receiptStatus: null, + errorCode: null, + errorMessage: + attempt > 0 + ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` + : undefined, + }, + }); + if (writeOutbox) { + await tx.gatewaySubmitOutbox.create({ + data: { + submitId, + messageRecordId: message.id, + channelId: channel.id, + payload: command as Prisma.InputJsonValue, + }, + }); + } + }), + ); if (retryOfSubmitRecordId) { - this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - retryOfSubmitRecordId, - submitId, - channelId: channel.id, - })}`); + this.logger.log( + `sms_retry_claim_acquired ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + retryOfSubmitRecordId, + submitId, + channelId: channel.id, + })}`, + ); } } catch (error) { - if ( - retryOfSubmitRecordId - && error instanceof Prisma.PrismaClientKnownRequestError - && error.code === 'P2002' - ) { + if (retryOfSubmitRecordId && error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const existingRetry = await this.prisma.smsSubmitRecord.findUnique({ where: { retryOfSubmitRecordId }, }); if (existingRetry) { - this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({ - messageId: message.messageId, - messageRecordId: message.id, - retryOfSubmitRecordId, - submitId: existingRetry.submitId, - channelId: existingRetry.channelId, - })}`); + this.logger.warn( + `sms_retry_claim_reused ${JSON.stringify({ + messageId: message.messageId, + messageRecordId: message.id, + retryOfSubmitRecordId, + submitId: existingRetry.submitId, + channelId: existingRetry.channelId, + })}`, + ); return { submitted: false, duplicateRetry: true, @@ -632,18 +765,28 @@ startSubmitOutboxPublisher() { if (!this.submitOutboxPublishEnabled()) { await this.measureSendStage('gateway_stream_publish', () => this.facade.publishGatewaySubmitCommand(command)); } - await this.measureSendStage('task_progress', () => this.facade.refreshTaskProgress( - message.batchTaskId, - message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'submit_queued' : undefined, - )); + await this.measureSendStage('task_progress', () => + this.facade.refreshTaskProgress( + message.batchTaskId, + message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'submit_queued' : undefined, + ), + ); return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt }; } private buildGatewaySubmitCommand( message: { - id: string; tenantId: string; batchTaskId: string; applicationId?: string | null; - templateId?: string | null; messageId: string; phoneNumber: string; content: string; - billingUnits: number; queuePriority?: string | null; applicationExtension?: string | null; + id: string; + tenantId: string; + batchTaskId: string; + applicationId?: string | null; + templateId?: string | null; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + queuePriority?: string | null; + applicationExtension?: string | null; template?: { signature?: { name?: string | null } | null } | null; signature?: { name?: string | null } | null; }, @@ -681,9 +824,10 @@ startSubmitOutboxPublisher() { groupId: routed.groupId, }, cmpp: { - serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config - ? String(channel.config.serviceId) - : 'SMS', + serviceId: + channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config + ? String(channel.config.serviceId) + : 'SMS', srcId: upstreamSrcId, extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0), registeredDelivery: 1, @@ -702,15 +846,21 @@ startSubmitOutboxPublisher() { connectionWarmupSeconds: getNonNegativeConfigInteger(channel.config, 'connectionWarmupSeconds', 30), connectionDrainTimeoutSeconds: getPositiveConfigInteger(channel.config, 'connectionDrainTimeoutSeconds', 60), submitResponseTimeoutSeconds: getPositiveConfigInteger(channel.config, 'submitResponseTimeoutSeconds', 60), - connectionFailureCooldownSeconds: getPositiveConfigInteger(channel.config, 'connectionFailureCooldownSeconds', 30), + connectionFailureCooldownSeconds: getPositiveConfigInteger( + channel.config, + 'connectionFailureCooldownSeconds', + 30, + ), }, retry: { attempt, maxAttempts: 1 }, }; } private submitOutboxEnabled() { - return process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED === 'true' - || process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED === 'true'; + return ( + process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED === 'true' || + process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED === 'true' + ); } private submitOutboxPublishEnabled() { @@ -723,7 +873,9 @@ startSubmitOutboxPublisher() { try { const batchSize = Math.min(500, getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_BATCH_SIZE', 64)); const leaseSeconds = Math.min(300, getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_LEASE_SECONDS', 30)); - const rows = await this.prisma.$queryRaw>(Prisma.sql` + const rows = await this.prisma.$queryRaw< + Array<{ id: string; submitId: string; payload: Prisma.JsonValue }> + >(Prisma.sql` WITH candidates AS ( SELECT id FROM "GatewaySubmitOutbox" @@ -749,9 +901,13 @@ startSubmitOutboxPublisher() { const results = this.submitOutboxPublishEnabled() ? await this.publishGatewaySubmitCommandBatch(rows) : rows.map((row) => ({ row, streamEntryId: `shadow:${row.submitId}` })); - const succeeded = results.filter((result): result is { row: typeof rows[number]; streamEntryId: string } => 'streamEntryId' in result); + const succeeded = results.filter( + (result): result is { row: (typeof rows)[number]; streamEntryId: string } => 'streamEntryId' in result, + ); if (succeeded.length > 0) { - const values = Prisma.join(succeeded.map(({ row, streamEntryId }) => Prisma.sql`(${row.id}, ${streamEntryId})`)); + const values = Prisma.join( + succeeded.map(({ row, streamEntryId }) => Prisma.sql`(${row.id}, ${streamEntryId})`), + ); await this.prisma.$executeRaw(Prisma.sql` UPDATE "GatewaySubmitOutbox" AS outbox SET status = 'published', @@ -783,11 +939,15 @@ startSubmitOutboxPublisher() { WHERE id = ${row.id} AND "leaseOwner" = ${this.submitOutboxLeaseOwner} `); } catch (recordError) { - this.logger.error(`gateway_submit_outbox_failure_record_failed ${recordError instanceof Error ? recordError.message : String(recordError)}`); + this.logger.error( + `gateway_submit_outbox_failure_record_failed ${recordError instanceof Error ? recordError.message : String(recordError)}`, + ); } } } catch (error) { - this.logger.error(`gateway_submit_outbox_publish_failed ${error instanceof Error ? error.message : String(error)}`); + this.logger.error( + `gateway_submit_outbox_publish_failed ${error instanceof Error ? error.message : String(error)}`, + ); } finally { this.submitOutboxRunning = false; } @@ -795,10 +955,9 @@ startSubmitOutboxPublisher() { private async publishGatewaySubmitCommandBatch( rows: Array<{ id: string; submitId: string; payload: Prisma.JsonValue }>, - ): Promise> { + ): Promise< + Array<{ row: (typeof rows)[number]; streamEntryId: string } | { row: (typeof rows)[number]; error: unknown }> + > { const redis = this.facade.getRedis(); const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM; const script = `local existing = redis.call('GET', KEYS[2]) @@ -821,13 +980,26 @@ return streamId`; if (!replies || replies.length !== rows.length) { return rows.map((row) => ({ row, error: new Error('Redis Outbox pipeline result count mismatch') })); } - return replies.map(([error, value], index) => error - ? { row: rows[index], error } - : { row: rows[index], streamEntryId: typeof value === 'string' ? value : String(value ?? '') }); + return replies.map(([error, value], index) => + error + ? { row: rows[index], error } + : { row: rows[index], streamEntryId: typeof value === 'string' ? value : String(value ?? '') }, + ); } -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 }, + 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; + }, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, ): Promise { if (!message.applicationId) { @@ -836,11 +1008,11 @@ async selectChannelForMessage( const hasPersistedRouting = Boolean(message.carrier); const [carrier, province] = await this.measureSendStage('phone_routing', async () => { const resolved = hasPersistedRouting - ? [normalizeCarrier(message.carrier), message.province ?? null] as const + ? ([normalizeCarrier(message.carrier), message.province ?? null] as const) : await Promise.all([ - this.facade.identifyCarrier(message.phoneNumber), - this.facade.identifyProvince(message.phoneNumber), - ]); + this.facade.identifyCarrier(message.phoneNumber), + this.facade.identifyProvince(message.phoneNumber), + ]); if (!hasPersistedRouting) { await this.prisma.smsMessageRecord.update({ where: { id: message.id }, @@ -849,14 +1021,13 @@ async selectChannelForMessage( } return resolved; }); - const signatureId = await this.measureSendStage('signature_candidates', () => this.facade.resolveMessageSignatureId(message)); + const signatureId = await this.measureSendStage('signature_candidates', () => + this.facade.resolveMessageSignatureId(message), + ); if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道'); - const route = await this.measureSendStage('route_lookup', () => this.facade.findApplicationRoute( - message.tenantId, - message.applicationId ?? undefined, - carrier, - signatureId, - )); + const route = await this.measureSendStage('route_lookup', () => + 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 selected = selectChannelCandidate(route.group.items, { @@ -880,54 +1051,61 @@ async selectChannelForMessage( }; } -private async measureSendStage(stage: SendWorkerStage, operation: () => Promise): Promise { - const startedAt = this.metrics?.beginSendWorkerStage(); - try { - const result = await operation(); - if (startedAt != null) this.metrics?.finishSendWorkerStage(startedAt, stage, 'success'); - return result; - } catch (error) { - if (startedAt != null) this.metrics?.finishSendWorkerStage(startedAt, stage, 'error'); - throw error; - } -} - -private async refreshSendQueueMetrics() { - if (!this.metrics) return; - try { - const counts = await this.facade.getSendQueue().getJobCounts('wait', 'active', 'completed', 'failed', 'delayed', 'prioritized'); - const mappings: Array<[SendWorkerQueueState, number]> = [ - ['waiting', counts.wait ?? 0], - ['active', counts.active ?? 0], - ['completed', counts.completed ?? 0], - ['failed', counts.failed ?? 0], - ['delayed', counts.delayed ?? 0], - ['prioritized', counts.prioritized ?? 0], - ]; - for (const [state, count] of mappings) this.metrics.setSendWorkerQueueJobs(state, count); - const pool = this.prisma.getPoolState(); - for (const state of ['max', 'total', 'idle', 'waiting'] as const) { - this.metrics.setSendWorkerDatabasePool(state, pool[state]); + private async measureSendStage(stage: SendWorkerStage, operation: () => Promise): Promise { + const startedAt = this.metrics?.beginSendWorkerStage(); + try { + const result = await operation(); + if (startedAt != null) this.metrics?.finishSendWorkerStage(startedAt, stage, 'success'); + return result; + } catch (error) { + if (startedAt != null) this.metrics?.finishSendWorkerStage(startedAt, stage, 'error'); + throw error; } - } catch (error) { - this.logger.warn(`send_queue_metrics_refresh_failed ${error instanceof Error ? error.message : String(error)}`); } -} -async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) { + private async refreshSendQueueMetrics() { + if (!this.metrics) return; + try { + const counts = await this.facade + .getSendQueue() + .getJobCounts('wait', 'active', 'completed', 'failed', 'delayed', 'prioritized'); + const mappings: Array<[SendWorkerQueueState, number]> = [ + ['waiting', counts.wait ?? 0], + ['active', counts.active ?? 0], + ['completed', counts.completed ?? 0], + ['failed', counts.failed ?? 0], + ['delayed', counts.delayed ?? 0], + ['prioritized', counts.prioritized ?? 0], + ]; + for (const [state, count] of mappings) this.metrics.setSendWorkerQueueJobs(state, count); + const pool = this.prisma.getPoolState(); + for (const state of ['max', 'total', 'idle', 'waiting'] as const) { + this.metrics.setSendWorkerDatabasePool(state, pool[state]); + } + } catch (error) { + this.logger.warn(`send_queue_metrics_refresh_failed ${error instanceof Error ? error.message : String(error)}`); + } + } + + async findApplicationRoute( + tenantId: string, + applicationId: string | undefined, + carrier: string, + signatureId?: string, + ) { const approvedChannelWhere = signatureId ? { - status: 'active', - connectionStates: { some: { status: 'connected', currentConnections: { gt: 0 } } }, - reportTasks: { - some: { - signatureId, - reportType: 'signature', - status: 'approved', - OR: signatureReportApprovalScopes(carrier), + status: 'active', + connectionStates: { some: { status: 'connected', currentConnections: { gt: 0 } } }, + reportTasks: { + some: { + signatureId, + reportType: 'signature', + status: 'approved', + OR: signatureReportApprovalScopes(carrier), + }, }, - }, - } + } : undefined; const route = await this.prisma.channelRouteRule.findFirst({ where: { @@ -971,15 +1149,15 @@ async findApplicationRoute(tenantId: string, applicationId: string | undefined, return route; } -async identifyCarrier(phoneNumber: string) { + async identifyCarrier(phoneNumber: string) { return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber)); } -async identifyProvince(phoneNumber: string) { + async identifyProvince(phoneNumber: string) { return this.phoneRouting.identifyProvince(phoneNumber); } -async ensureSignatureReportedForChannel( + async ensureSignatureReportedForChannel( message: { id: string; templateId?: string | null; @@ -1008,14 +1186,22 @@ async ensureSignatureReportedForChannel( } } -async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { + async resolveMessageSignatureId(message: { + templateId?: string | null; + signatureId?: string | null; + template?: { signature?: { id?: string | null } | null } | null; + signature?: { id?: string | null } | null; + }) { const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null; if (direct || !message.templateId) return direct; - const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } }); + const template = await this.prisma.smsTemplate.findUnique({ + where: { id: message.templateId }, + include: { signature: true }, + }); return template?.signature?.id ?? null; } -async waitForChannelRateLimit(channelId: string, tps: number) { + async waitForChannelRateLimit(channelId: string, tps: number) { const redis = this.facade.getRedis(); for (;;) { const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`; @@ -1030,7 +1216,7 @@ async waitForChannelRateLimit(channelId: string, tps: number) { } } -async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) { + async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) { if (knownSingleMessageStatus) { const direct = await this.prisma.smsBatchTask.updateMany({ where: { id: batchTaskId, sourceType: 'cmpp', phoneTotal: 1 }, @@ -1053,6 +1239,7 @@ async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string status = CASE WHEN message.status IN ('delivered', 'submit_failed', 'failed', 'timeout') THEN 'finished' WHEN message.status IN ('submit_queued', 'submitted', 'unknown') THEN 'sending' + WHEN message.status = 'pending_review' THEN 'pending_review' ELSE 'queued' END, "updatedAt" = (NOW() AT TIME ZONE 'UTC') @@ -1084,46 +1271,67 @@ async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string } } -private async refreshTaskProgressUntilClean(batchTaskId: string) { - do { - this.dirtyTaskProgressRefreshes.delete(batchTaskId); - const groups = await this.prisma.smsMessageRecord.groupBy({ - by: ['status'], - where: { batchTaskId }, - _count: { _all: true }, - }); - const count = (statuses: string[]) => - groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0); - const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0); - const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']); - const successTotal = count(['delivered']); - const failedTotal = count(['submit_failed', 'failed']); - const unknownTotal = count(['unknown']); - const timeoutTotal = count(['timeout']); - const doneTotal = successTotal + failedTotal + timeoutTotal; - const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued'; - await this.prisma.smsBatchTask.update({ - where: { id: batchTaskId }, - data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status }, - }); - } while (this.dirtyTaskProgressRefreshes.has(batchTaskId)); + private async refreshTaskProgressUntilClean(batchTaskId: string) { + do { + this.dirtyTaskProgressRefreshes.delete(batchTaskId); + const groups = await this.prisma.smsMessageRecord.groupBy({ + by: ['status'], + where: { batchTaskId }, + _count: { _all: true }, + }); + const count = (statuses: string[]) => + groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0); + const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0); + const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']); + const successTotal = count(['delivered']); + const failedTotal = count(['submit_failed', 'failed']); + const unknownTotal = count(['unknown']); + const timeoutTotal = count(['timeout']); + const doneTotal = successTotal + failedTotal + timeoutTotal; + const status = + progressTotal > 0 && doneTotal >= progressTotal + ? 'finished' + : submittedTotal > 0 + ? 'sending' + : count(['pending_review']) > 0 + ? 'pending_review' + : 'queued'; + await this.prisma.smsBatchTask.update({ + where: { id: batchTaskId }, + data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status }, + }); + } while (this.dirtyTaskProgressRefreshes.has(batchTaskId)); } -getSendQueue(): Queue { + private async recoverNightReviews() { + if (this.nightReviewRecovering) return; + this.nightReviewRecovering = true; + try { + for (const task of await this.riskReview.pendingNightContinuations()) { + await this.facade.handleReviewDecision(task.id, task.status, task.reason); + } + } catch (error) { + this.logger.error(`night_review_continuation_failed ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.nightReviewRecovering = false; + } + } + + getSendQueue(): Queue { if (!this.sendQueue) { this.sendQueue = new Queue(SEND_QUEUE, { connection: bullmqConnection() }); } return this.sendQueue; } -getGatewayQueue(): Queue { + getGatewayQueue(): Queue { if (!this.gatewayQueue) { this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() }); } return this.gatewayQueue; } -getRedis() { + getRedis() { if (!this.redis) { this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: null, @@ -1158,25 +1366,35 @@ return streamId`, const cached = this.openSubmitSessionIds.get(channelId); if (cached) return cached; const sessionNo = `OPEN-${channelId}`; - const pending = this.prisma.cmppSubmitSession.findUnique({ - where: { sessionNo }, - select: { id: true }, - }).then((existing) => existing ?? this.prisma.cmppSubmitSession.upsert({ - where: { sessionNo }, - update: {}, - create: { channelId, sessionNo, submitTotal: 0 }, - select: { id: true }, - })).then((session) => session.id).catch((error) => { - this.openSubmitSessionIds.delete(channelId); - throw error; - }); + const pending = this.prisma.cmppSubmitSession + .findUnique({ + where: { sessionNo }, + select: { id: true }, + }) + .then( + (existing) => + existing ?? + this.prisma.cmppSubmitSession.upsert({ + where: { sessionNo }, + update: {}, + create: { channelId, sessionNo, submitTotal: 0 }, + select: { id: true }, + }), + ) + .then((session) => session.id) + .catch((error) => { + this.openSubmitSessionIds.delete(channelId); + throw error; + }); this.openSubmitSessionIds.set(channelId, pending); return pending; } } function singleMessageTaskProgress(status: string) { - const submittedTotal = ['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout'].includes(status) ? 1 : 0; + const submittedTotal = ['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout'].includes(status) + ? 1 + : 0; const successTotal = status === 'delivered' ? 1 : 0; const failedTotal = ['submit_failed', 'failed'].includes(status) ? 1 : 0; const unknownTotal = status === 'unknown' ? 1 : 0; diff --git a/api/src/send-chain/send-review-continuation.service.ts b/api/src/send-chain/send-review-continuation.service.ts index 2e0ef27..c91d048 100644 --- a/api/src/send-chain/send-review-continuation.service.ts +++ b/api/src/send-chain/send-review-continuation.service.ts @@ -1,18 +1,14 @@ -import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { Queue, Worker } from 'bullmq'; -import IORedis from 'ioredis'; -import { createHash, randomUUID } from 'node:crypto'; -import { setTimeout as sleep } from 'node:timers/promises'; +import { BadRequestException, Logger } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + 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 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, drainageRejectionReason, 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 { BULLMQ_PRIORITY, normalizeQueuePriority } from './send-chain.helpers'; import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; /** @@ -32,7 +28,13 @@ export class SendReviewContinuationService { ) {} 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); @@ -55,21 +57,18 @@ export class SendReviewContinuationService { return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); } - -async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { + async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { const reviewTask = await this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId }, }); if (!reviewTask) { return { reviewTaskId, decision, affected: 0 }; } + if (reviewTask.sourceType === 'night_sending_bulk') return this.continueNightReview(reviewTaskId, decision, reason); const messageRecords = await this.prisma.smsMessageRecord.findMany({ where: { status: 'pending_review', - OR: [ - { reviewTaskId }, - { batchTask: { riskTaskId: reviewTaskId } }, - ], + OR: [{ reviewTaskId }, { batchTask: { riskTaskId: reviewTaskId } }], }, include: { batchTask: true }, }); @@ -106,4 +105,94 @@ async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejecte } return { reviewTaskId, decision, affected: messageRecords.length }; } + + private async continueNightReview(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { + const owner = randomUUID(); + const acquired = await this.prisma.$queryRaw>` + UPDATE "SmsSendTask" SET "continuationLeaseOwner"=${owner}, + "continuationLeaseExpiresAt"=(NOW() AT TIME ZONE 'UTC') + INTERVAL '5 minutes' + WHERE id=${reviewTaskId} AND status=${decision} + AND ("continuationLeaseExpiresAt" IS NULL OR "continuationLeaseExpiresAt" < (NOW() AT TIME ZONE 'UTC')) + RETURNING id + `; + if (!acquired.length) return { reviewTaskId, decision, affected: 0 }; + try { + return await this.continueLockedNightReview(reviewTaskId, decision, reason, owner); + } finally { + await this.prisma.smsSendTask.updateMany({ + where: { id: reviewTaskId, continuationLeaseOwner: owner }, + data: { continuationLeaseOwner: null, continuationLeaseExpiresAt: null }, + }); + } + } + + private async continueLockedNightReview( + reviewTaskId: string, + decision: 'approved' | 'rejected', + reason: string, + owner: string, + ) { + const task = await this.prisma.smsSendTask.findUniqueOrThrow({ where: { id: reviewTaskId } }); + if (task.status !== decision) throw new BadRequestException('审核决定不一致'); + const reservations = await this.prisma.nightSendingReservation.findMany({ + where: { reviewTaskId, continuedAt: null }, + take: 100, + orderBy: { messageRecordId: 'asc' }, + }); + for (const reservation of reservations) { + const renewed = await this.prisma.smsSendTask.updateMany({ + where: { id: reviewTaskId, continuationLeaseOwner: owner }, + data: { continuationLeaseExpiresAt: new Date(Date.now() + 300_000) }, + }); + if (renewed.count !== 1) throw new Error('夜间审核续发租约已转移'); + const message = await this.prisma.smsMessageRecord.findUniqueOrThrow({ + where: { id: reservation.messageRecordId }, + include: { batchTask: true }, + }); + if (!message.tenantId || !message.applicationId || !message.batchTaskId || message.reviewTaskId !== reviewTaskId) + throw new Error('夜间审核消息关联无效'); + const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; + if (decision === 'approved') { + await this.prisma.smsMessageRecord.updateMany({ + where: { id: message.id, status: 'pending_review' }, + data: { status: 'queued', errorCode: null, errorMessage: null }, + }); + // A held initial job may already be completed. Use a distinct stable job ID + // so an approved message is resumed once; crashes can safely repeat add(). + if (['pending_review', 'queued'].includes(message.status)) + await this.facade.getSendQueue().add( + 'send-message', + { messageRecordId: message.id }, + { + jobId: `${message.id}-night-${reviewTaskId}`, + attempts: 3, + priority: BULLMQ_PRIORITY[normalizeQueuePriority(message.queuePriority)], + }, + ); + } else if (['pending_review', 'failed'].includes(message.status)) { + await this.releaseMessageReservation(businessMessage, '夜间累计发送人工审核驳回释放冻结'); + await this.recordCmppFailureReceipt(businessMessage, 'REVIEW_REJECTED', reason); + await this.prisma.smsMessageRecord.updateMany({ + where: { id: message.id, status: 'pending_review' }, + data: { status: 'rejected', submitStatus: 'rejected', errorCode: 'REVIEW_REJECTED', errorMessage: reason }, + }); + } + await this.prisma.nightSendingReservation.update({ + where: { messageRecordId: message.id }, + data: { continuedAt: new Date() }, + }); + const pending = await this.prisma.smsMessageRecord.count({ + where: { batchTaskId: message.batchTaskId, status: 'pending_review' }, + }); + await this.prisma.smsBatchTask.update({ + where: { id: message.batchTaskId }, + data: { + auditStatus: pending ? 'pending_review' : decision, + reviewReason: reason, + }, + }); + await this.facade.refreshTaskProgress(message.batchTaskId); + } + return { reviewTaskId, decision, affected: reservations.length }; + } } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 64f4892..63f30f1 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2218,3 +2218,7 @@ - 添加/编辑通道弹窗不允许设置顺序或优先级;新增全国通道固定追加末尾,编辑保留当前位置,调整只通过编辑页上移/下移,保留撤销功能。 - 待选通道隐藏已占用、不支持运营商/地区、已删除的项;编辑当前成员可保留自身,搜索只在可选集合中进行。保留原可配置的停用/暂断连状态及真实提示,不等同发送资格。 - 符合发送资格的全国通道按列表顺序首次尝试和补发,跳过已尝试、未报备和不可用通道;省网优先及失败转全国规则保持。优先级仅作为API兼容字段,整数上限时保持相对顺序压紧当前草稿后追加,不将新增项插到前面。 + +## 2026-09-07 夜间累计发送量审核 + +原“非工作时间大批量营销发送”替换为每企业应用每夜累计所有业务短信的人工审核规则,所有企业应用默认通用5000条,支持应用阈值覆盖;不依赖分类、内容或任务名。CMPP、公开HTTP、客户端合并计数,第5001条及之后待审;分片/内部重试/补发/幂等重试不重复累计。统一在首次发送Worker提交前拦截,含定时任务到期及CMPP快速入队路径。短信审核复用现有字段、号码明细、详情和批量审核,按同应用同内容10秒窗口聚合,窗口关闭后审核;批准只释放该任务消息,不豁免后续发送。时间跨午夜连续,默认21:00至次日08:00(北京时间),夜间改时间待本夜结束生效,阈值修改不清零。详见phase-6-risk-review-plan.md的2026-09-07章节;该章节替代旧营销识别和单任务阈值语义。 diff --git a/docs/phase-6-risk-review-plan.md b/docs/phase-6-risk-review-plan.md index 10e951c..920a462 100644 --- a/docs/phase-6-risk-review-plan.md +++ b/docs/phase-6-risk-review-plan.md @@ -59,3 +59,13 @@ api/src/risk-review/ 3. 直接拒绝的任务必须向客户端返回可读原因。 4. `npm run verify:phase6` 通过。 +## 2026-09-07 夜间累计发送量审核(替代营销单任务规则) + +- 用户授权实现、提交、推送并发布测试与预生产。复用短信审核页面及其内容聚合、号码明细、通过/驳回/批量审核;不改版审核页面。旧规则编码保留兼容历史命中记录,名称改为“夜间累计发送量审核”,不再判断category、内容或任务名称。 +- 所有企业应用默认开启,通用阈值5000,应用覆盖优先,停用覆盖回落通用。单位为每应用每夜业务短信数(一个业务消息×一个号码);CMPP、公开HTTP、客户端共用计数。同号码不同业务消息分别累计,长短信分片、重试、补发和重复消费不重复计数。超过阈值的消息待人工审核;其他规则直接拒绝和未进入发送阶段的消息不消耗额度。 +- 夜间默认Asia/Shanghai 21:00至次日08:00,开始包含、结束不包含,跨午夜不清零。定时任务到期后执行。统一在发送Worker首次提交前拦截,覆盖CMPP批量快速入队和普通入口;HTTP/CMPP入口受理不等于已发送,异步消息状态为准。批量跨阈值按消息分流;失败/审核拒绝不归还夜间额度。 +- PostgreSQL新增NightSendingWindow与NightSendingReservation,应用锁、消息幂等记录、累计量、审核关联与pending_review状态同事务提交。Redis仅沿用队列,不保存唯一风控计数;数据库失败不得放行。新窗口首次使用从已有首次Submit记录补齐本夜历史数,避免夜间发布或规则启用时额度重置;发布仍核对历史基线和执行计划。 +- 审核按企业应用、相同原始内容及10秒窗口聚合(不混合不同内容),窗口关闭后沿用短信审核入口。计数维度不按内容拆分。已审核窗口不得再追加;审核批准仅释放该聚合任务绑定的消息,不豁免整晚;重复/相反审核须受控,续发失败可用同一决定重试。夜间结束仍不自动释放待审消息。 +- 阈值修改不清空计数,不自动释放待审消息;时间配置在当前夜间结束后生效,界面说明延迟生效。批量任务已有部分正常发送时,保留部分发送进度并标记存在待审核,不覆盖整批消息状态。审核与入队失败不得吞错,续发使用消息ID幂等队列任务。 +- 权限沿用管理员风控配置/短信审核入口;应用必须从真实消息与企业关联取得,不能信任客户端自报企业、时间或分类;应用覆盖必须验证对象存在。历史审核记录不重写、不自动重投。回退须先停发送Worker并保留新待审及计数事实,旧版本不能继续绕过新夜间门禁。 +- 验收覆盖阈值边界、多入口/多实例并发、跨午夜、应用隔离、幂等、重启、配置覆盖/变更、历史初始化、相同内容聚合、审核范围与并发、定时任务和数据库失败;使用隔离PostgreSQL/Redis证明持久化,不发送真实短信。前后端全量测试、类型/构建/质量门禁、两环境真实API与三尺寸页面验收分别留证。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 68bc818..9c26a34 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5202,3 +5202,19 @@ npm run verify:phase8 | TC-REPORT-WPS-007 | 上述残留节点仍被另一单元格DISPIMG引用 | 两种模式均拒绝,提示实际损坏单元格及工作表、重新插入/清空建议;不得误报相邻正常图片,不进行部分业务落库 | | TC-REPORT-WPS-008 | 已引用图片ID重复,或图片relationship缺失/重复/External,或包内媒体缺失 | 明确拒绝并提示工作表及单元格;不后写覆盖重复ID、不读取外部图片、不静默跳过真实缺失图片 | | TC-REPORT-WPS-009 | 真实故障文件及仅清空损坏单元格的内存副本,保留残留cellImage节点 | 原文件准确定位N7;内存副本33张有效图片位置与独立XML解析结果一致,提交模式33张SHA256全部匹配;原始文件保持不变,不据此冒充线上导入成功 | + +## 2026-09-07 夜间累计发送量审核(替代TC-RISK-006及旧营销口径) + +| 用例 | 场景与预期 | +|---|---| +| TC-RISK-NIGHT-001 | 同应用CMPP/HTTP/客户端逐条混合发送;累计4999、5000正常处理,5001及之后待人工审核;不受分类、正文或任务名影响。 | +| TC-RISK-NIGHT-002 | 多实例并发和批量跨阈值;仅额度内消息允许正常提交,超量消息无Gateway Submit;其他企业/应用独立计数。 | +| TC-RISK-NIGHT-003 | 21:00包含、08:00不包含,午夜不清零;白天创建的夜间定时任务在发送阶段检查,白天排队夜间执行同样检查;旧待审不自动释放。 | +| TC-RISK-NIGHT-004 | 同业务消息重复消费、长短信分片、重试/补发不重复计数;同号码不同业务消息分别计数;服务/Redis重启不恢复额度。 | +| TC-RISK-NIGHT-005 | 通用5000、个性化应用覆盖、停用覆盖回落通用;修改阈值不清零,夜间改时段展示当前/待生效;非法阈值、时区及改为直接拒绝受控400。 | +| TC-RISK-NIGHT-006 | 同应用同内容10秒聚合,不同内容分开;号码数量/列表/详情/单个和批量审核沿用现有页面。窗口未关闭不可审核,已审核不可再追加或改相反决定。 | +| TC-RISK-NIGHT-007 | 批准只释放关联消息,后续仍待审;审核后的Redis入队失败可由持久恢复记录重试,消息ID稳定去重;并发续发有租约,驳回按现有机制释放冻结并保留原因。 | +| TC-RISK-NIGHT-008 | 夜间上线或首次启用从真实首次Submit补齐历史数,多个Submit尝试仅计同一业务短信一次;计数/审核持久化失败回滚且不得放行。 | +| TC-RISK-NIGHT-009 | 1600×1000、1366×768、390×844检查风控规则及短信审核,保留筛选、号码列表、详情和批量操作;真实请求、刷新、路由、空态、失败和权限状态分别留证。 | + +自动化入口:api/src/risk-review/night-sending-risk.service.spec.ts、api/src/send-chain/night-sending-gate.spec.ts及tools/testing/verify-night-sending-postgres.mjs。后者使用隔离PostgreSQL schema及独立Redis QA队列,不启动消费者、不调用Gateway、不写业务发送队列;隔离队列与schema仅清理本次随机名称。它验证真实持久化与队列恢复,不能冒充实际运营商发送或公网协议压测。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 213989b..8088e09 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4688,3 +4688,13 @@ git diff --check - 新增14项回归,含metadata/字节两模式×未引用残留及自闭合单元格、实际损坏位置、重复图片ID、媒体缺失、关系缺失/重复/外部。定向17项通过;API全量60套653项通过(测试中的Redis/Prometheus不可用warning来自既有隔离场景)。API生产配置TypeScript/构建、增量Prettier/ESLint与diff检查通过。曾误用基础tsconfig执行含测试文件的全量tsc,因该配置未加载Jest全局类型失败;改按仓库tsconfig.build.json核验生产代码,测试文件由完整ts-jest回归校验,未为此改动既有类型配置。 - 当前修复代码在本机读取此前只读取得的真实原始文件:两种模式均准确拒绝N7;只在内存清空N7公式、保留损坏cellImage节点后,两模式读取33张图片且不串位。独立Python ElementTree解析原始OOXML建立期望位置/图片SHA256,字节模式33张全部逐一匹配;原始文件摘要不变,未生成或上传替换业务表格。 - 证据在本机%TEMP%/cmpp-wps-diagnosis-20260907:api-full.log、independent-image-hashes.json、real-file-verification.json及受控原始文件。真实客户文件不进入Git。此前浏览器只读状态核验尝试登录返回401,未取得该项浏览器证据;本轮未复试、重置账号或修改服务器。已依据PG持久失败记录、真实MinIO文件、运行解析器与本地修复解析结果完成复现/回归;新版本的线上API/Worker/浏览器验收待另行授权部署后执行,不将本地文件回归称为线上导入成功。 + +## 2026-09-07 夜间累计发送量审核(实现与发布前验证) + +- 用户授权实施、提交、推送和测试/预生产发布,并要求复用短信审核。起始main为e281ff8,实时origin/main为f885f0b,暂存空;9份原文档修改及3份未跟踪草稿保护,仅提交本轮精确追加文档。发布同时包含此前本地WPS修复e281ff8。 +- 只读根因:旧规则只对category=marketing/promo/promotion/营销的单任务号码数判断;CMPP单号码评估和批量快速入队没有跨请求夜间计数。已有短信审核提供CMPP模板不匹配同内容10秒聚合、号码列表/详情/批量操作,适合复用。测试和预生产现场版本均f885f0b;夜间规则均通用5000启用,无个性化覆盖,现场本夜首次Submit均0。测试119509条消息/130769次Submit;预生产本轮基线93243/91563、签名653,不能沿用昨天的旧数字。 +- 实现:两张夜间计数/幂等表、审核续发租约字段及兼容迁移;所有业务入口在共享发送Worker首次提交前执行应用级夜间门禁,跨午夜连续,第5001条起挂起,保持分片/重试/幂等业务计数。首次初始化从持久首次Submit补齐历史;新增审核记录按应用、原始相同内容、10秒窗口聚合并复用现有UI。审核批准只释放已锁定任务,持久续发记录和15秒扫描恢复入队失败,稳定新jobId避免旧已完成任务吞掉续发。数据库故障向上抛出,不能当路由失败或默认放行。 +- 规则沿用既有编码/ID和历史记录,改名并去除旧营销计算;应用覆盖/停用回落、强制人工审核、整数阈值、北京时间与夜间时段延期生效。UI只修改口径、说明及审核来源标签,无CSS/Gateway/依赖/余额/通道/客户配置调整。触及的既有文件按现行Prettier门禁规范化,并清理历史拆分遗留的未使用导入及等价控制字符判断;未扩大lint例外。 +- 本机后端62套665项、前端21套105项回归通过;新夜间单元与发送门禁12项通过,生产类型和构建通过。测试机独立候选、真实PG双实例并发/隔离/午夜/幂等/覆盖/历史初始化/事务回滚及独立Redis审核恢复11组通过;public消息/Submit计数不变,Gateway调用0、业务队列写入0。固定时间在隔离service层注入,不构造真实短信。 +- 现场真实测试页面三尺寸基线读取规则、编辑取消、短信审核空态/刷新通过,API200、无新增控制台错误,未保存线上业务规则。测试机初期Tailscale离线超时,用户恢复后正常密码认证;不是Git认证问题,未修改SSH配置。当前浏览器技能未提供,按frontend-testing-debugging技能和既有任意浏览器授权使用Playwright/Edge。临时脚本初次将Windows路径URL编码未还原导致截图失败,修正fileURLToPath后通过;隔离验证先修正模板变量表名与BullMQ优先队列计数口径后重跑通过,不作为业务缺陷。 +- 本机证据%TEMP%/cmpp-night-risk-20260907,测试候选/opt/cmpp-night-candidate-20260907,PG/Redis隔离结果/tmp/night-pg-verification.log。最终门禁及发布后真实页面/服务/队列验收另记下节;尚未执行真实短信发送、供应商压测或实际备份恢复。 diff --git a/src/api/types/governance.ts b/src/api/types/governance.ts index 7032eab..c4c8088 100644 --- a/src/api/types/governance.ts +++ b/src/api/types/governance.ts @@ -36,7 +36,12 @@ export type RiskRuleItem = { id: string; tenantId?: string | null; applicationId?: string | null; - code: 'MAX_PHONES_PER_TASK' | 'NON_WORKING_MARKETING_BULK' | 'TASK_CREATE_FREQUENCY' | 'PHONE_FREQUENCY_24H' | 'PHONE_FREQUENCY_5M'; + code: + | 'MAX_PHONES_PER_TASK' + | 'NON_WORKING_MARKETING_BULK' + | 'TASK_CREATE_FREQUENCY' + | 'PHONE_FREQUENCY_24H' + | 'PHONE_FREQUENCY_5M'; name: string; description?: string | null; metric: string; @@ -44,7 +49,15 @@ export type RiskRuleItem = { action: 'block' | 'manual_review'; status: 'active' | 'inactive'; priority: number; - config?: { startTime?: string; endTime?: string; timeZone?: string; periodSeconds?: number; alignment?: string } | null; + config?: { + startTime?: string; + endTime?: string; + timeZone?: string; + periodSeconds?: number; + alignment?: string; + previousTimeConfig?: { startTime: string; endTime: string }; + timeConfigEffectiveAt?: string; + } | null; application?: { id: string; name: string; tenantId: string; tenant?: { id: string; name: string } } | null; updatedAt: string; }; @@ -119,7 +132,15 @@ export type DrainageDetectionRule = { export type DrainageDetectionResult = { hasDrainageContent: boolean; drainageDetection: { - matches: Array<{ ruleCode: string; ruleName: string; category: string; text: string; normalizedText: string; start: number; end: number }>; + matches: Array<{ + ruleCode: string; + ruleName: string; + category: string; + text: string; + normalizedText: string; + start: number; + end: number; + }>; categories: string[]; truncated: boolean; }; diff --git a/src/apps/admin/AdminRiskRulesPage.tsx b/src/apps/admin/AdminRiskRulesPage.tsx index c30384f..f0f52aa 100644 --- a/src/apps/admin/AdminRiskRulesPage.tsx +++ b/src/apps/admin/AdminRiskRulesPage.tsx @@ -23,7 +23,7 @@ import { formatDateTime } from '@/utils/dateTime'; const definitions: Array<{ code: RiskRuleItem['code']; label: string; unit: string }> = [ { code: 'MAX_PHONES_PER_TASK', label: '单任务最大号码数', unit: '个号码' }, - { code: 'NON_WORKING_MARKETING_BULK', label: '非工作时间大批量营销发送', unit: '个号码' }, + { code: 'NON_WORKING_MARKETING_BULK', label: '夜间累计发送量审核', unit: '条业务短信/应用/夜间' }, { code: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' }, { code: 'PHONE_FREQUENCY_24H', label: '单号码24小时发送频次', unit: '条业务短信' }, { code: 'PHONE_FREQUENCY_5M', label: '单号码5分钟发送频次', unit: '条业务短信' }, @@ -96,27 +96,32 @@ export function AdminRiskRulesPage() { Promise.all([ adminApi.listRiskRules(applicationId || undefined), applications.length === 0 ? adminApi.listEnterpriseApplications() : Promise.resolve(applications), - ]).then(([nextRules, nextApplications]) => { - setRules(nextRules); - setApplications(nextApplications); - setError(''); - }).catch((failure: Error) => setError(failure.message || '风控规则加载失败')); + ]) + .then(([nextRules, nextApplications]) => { + setRules(nextRules); + setApplications(nextApplications); + setError(''); + }) + .catch((failure: Error) => setError(failure.message || '风控规则加载失败')); } useEffect(load, [applicationId]); function loadFrequencyHits(page = hitPage, filters = { phoneNumber: hitPhone, status: hitStatus }) { - adminApi.listPhoneFrequencyHits({ - applicationId: applicationId || undefined, - phoneNumber: filters.phoneNumber.trim() || undefined, - status: filters.status || undefined, - page, - pageSize: 20, - }).then((result) => { - setFrequencyHits(result.items); - setHitTotal(result.total); - setHitPage(result.page); - }).catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败')); + adminApi + .listPhoneFrequencyHits({ + applicationId: applicationId || undefined, + phoneNumber: filters.phoneNumber.trim() || undefined, + status: filters.status || undefined, + page, + pageSize: 20, + }) + .then((result) => { + setFrequencyHits(result.items); + setHitTotal(result.total); + setHitPage(result.page); + }) + .catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败')); } useEffect(() => { @@ -125,16 +130,19 @@ export function AdminRiskRulesPage() { }, [applicationId]); function loadWhitelist(page = whitelistPage, filters = { phoneNumber: whitelistPhone, status: whitelistStatus }) { - adminApi.listPhoneFrequencyWhitelist({ - phoneNumber: filters.phoneNumber.trim() || undefined, - status: filters.status || undefined, - page, - pageSize: 20, - }).then((result) => { - setWhitelist(result.items); - setWhitelistTotal(result.total); - setWhitelistPage(result.page); - }).catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败')); + adminApi + .listPhoneFrequencyWhitelist({ + phoneNumber: filters.phoneNumber.trim() || undefined, + status: filters.status || undefined, + page, + pageSize: 20, + }) + .then((result) => { + setWhitelist(result.items); + setWhitelistTotal(result.total); + setWhitelistPage(result.page); + }) + .catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败')); } useEffect(() => { @@ -162,12 +170,17 @@ export function AdminRiskRulesPage() { const phoneFrequencyRule = isPhoneFrequencyRule(editor.code); const body = { thresholdValue, - action: phoneFrequencyRule ? 'block' as const : editor.action, + action: phoneFrequencyRule + ? ('block' as const) + : editor.code === 'NON_WORKING_MARKETING_BULK' + ? ('manual_review' as const) + : editor.action, status: editor.status, priority: Number(editor.priority) || 100, - config: editor.code === 'NON_WORKING_MARKETING_BULK' - ? { startTime: editor.startTime, endTime: editor.endTime, timeZone: 'Asia/Shanghai' } - : undefined, + config: + editor.code === 'NON_WORKING_MARKETING_BULK' + ? { startTime: editor.startTime, endTime: editor.endTime, timeZone: 'Asia/Shanghai' } + : undefined, }; try { if (editor.id) { @@ -261,55 +274,227 @@ export function AdminRiskRulesPage() { } const columns: Array> = [ - { key: 'name', title: '规则名称', render: (rule) =>
{rule.name}{rule.description}
}, - { key: 'scope', title: '适用范围', render: (rule) => rule.application ?
{rule.application.name}{rule.application.tenant?.name ?? '-'}
: 全局默认 }, - { key: 'threshold', title: '阈值', width: '150px', render: (rule) => `${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}` }, - { key: 'time', title: '生效时间', width: '180px', render: (rule) => { - if (rule.code !== 'NON_WORKING_MARKETING_BULK') return '-'; - const start = rule.config?.startTime ?? '21:00'; - const end = rule.config?.endTime ?? '08:00'; - return `${start}–${start > end ? '次日' : ''}${end}`; - } }, - { key: 'action', title: '处理动作', width: '120px', render: (rule) => {rule.action === 'block' ? '直接拒绝' : '人工审核'} }, - { key: 'status', title: '状态', width: '100px', render: (rule) => {rule.status === 'active' ? '启用' : '停用'} }, + { + key: 'name', + title: '规则名称', + render: (rule) => ( +
+ {rule.name} + {rule.description} +
+ ), + }, + { + key: 'scope', + title: '适用范围', + render: (rule) => + rule.application ? ( +
+ {rule.application.name} + {rule.application.tenant?.name ?? '-'} +
+ ) : ( + 全局默认 + ), + }, + { + key: 'threshold', + title: '阈值', + width: '150px', + render: (rule) => + `${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}`, + }, + { + key: 'time', + title: '生效时间', + width: '180px', + render: (rule) => { + if (rule.code !== 'NON_WORKING_MARKETING_BULK') return '-'; + const start = rule.config?.startTime ?? '21:00'; + const end = rule.config?.endTime ?? '08:00'; + const pending = + rule.config?.timeConfigEffectiveAt && new Date(rule.config.timeConfigEffectiveAt).getTime() > Date.now(); + if (pending && rule.config?.previousTimeConfig) { + const previous = rule.config.previousTimeConfig; + return ( + + 当前 {previous.startTime}–{previous.startTime > previous.endTime ? '次日' : ''} + {previous.endTime};{formatDateTime(rule.config.timeConfigEffectiveAt!)}起使用 {start}–{end} + + ); + } + return `${start}–${start > end ? '次日' : ''}${end}`; + }, + }, + { + key: 'action', + title: '处理动作', + width: '120px', + render: (rule) => ( + + {rule.action === 'block' ? '直接拒绝' : '人工审核'} + + ), + }, + { + key: 'status', + title: '状态', + width: '100px', + render: (rule) => ( + {rule.status === 'active' ? '启用' : '停用'} + ), + }, { key: 'priority', title: '优先级', width: '90px', render: (rule) => rule.priority }, - { key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => }, + { + key: 'actions', + title: '操作', + width: '100px', + align: 'right', + render: (rule) => ( + + ), + }, ]; const hitColumns: Array> = [ { key: 'phone', title: '号码', width: '140px', render: (hit) => {hit.phoneNumber} }, - { key: 'scope', title: '企业 / 应用', render: (hit) =>
{hit.tenant.name}{hit.application.name}
}, - { key: 'rule', title: '命中规则', render: (hit) =>
{hit.ruleName}阈值 {hit.thresholdValue} 条,触发值 {hit.actualValue} 条
}, - { key: 'window', title: '计数周期', width: '250px', render: (hit) => `${formatDateTime(hit.windowStartedAt)} 至 ${formatDateTime(hit.windowEndsAt)}` }, - { key: 'status', title: '状态', width: '100px', render: (hit) => hit.releasedAt - ? 已解除 - : new Date(hit.windowEndsAt).getTime() <= Date.now() - ? 已到期 - : 拦截中 }, + { + key: 'scope', + title: '企业 / 应用', + render: (hit) => ( +
+ {hit.tenant.name} + {hit.application.name} +
+ ), + }, + { + key: 'rule', + title: '命中规则', + render: (hit) => ( +
+ {hit.ruleName} + + 阈值 {hit.thresholdValue} 条,触发值 {hit.actualValue} 条 + +
+ ), + }, + { + key: 'window', + title: '计数周期', + width: '250px', + render: (hit) => `${formatDateTime(hit.windowStartedAt)} 至 ${formatDateTime(hit.windowEndsAt)}`, + }, + { + key: 'status', + title: '状态', + width: '100px', + render: (hit) => + hit.releasedAt ? ( + 已解除 + ) : new Date(hit.windowEndsAt).getTime() <= Date.now() ? ( + 已到期 + ) : ( + 拦截中 + ), + }, { key: 'createdAt', title: '触发时间', width: '170px', render: (hit) => formatDateTime(hit.createdAt) }, - { key: 'actions', title: '操作', width: '110px', align: 'right', render: (hit) => hit.releasedAt - ? 已清零 - : }, + { + key: 'actions', + title: '操作', + width: '110px', + align: 'right', + render: (hit) => + hit.releasedAt ? ( + 已清零 + ) : ( + + ), + }, ]; const whitelistColumns: Array> = [ { key: 'phone', title: '手机号码', width: '145px', render: (item) => {item.phoneNumber} }, - { key: 'reason', title: '用途说明', render: (item) =>
{item.reason}{item.remark ? {item.remark} : null}
}, - { key: 'status', title: '状态', width: '90px', render: (item) => {item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'} }, - { key: 'operator', title: '最后操作人', width: '150px', render: (item) => item.updatedBy.displayName || item.updatedBy.username }, + { + key: 'reason', + title: '用途说明', + render: (item) => ( +
+ {item.reason} + {item.remark ? {item.remark} : null} +
+ ), + }, + { + key: 'status', + title: '状态', + width: '90px', + render: (item) => ( + + {item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'} + + ), + }, + { + key: 'operator', + title: '最后操作人', + width: '150px', + render: (item) => item.updatedBy.displayName || item.updatedBy.username, + }, { key: 'updatedAt', title: '更新时间', width: '170px', render: (item) => formatDateTime(item.updatedAt) }, - { key: 'actions', title: '操作', width: '175px', align: 'right', render: (item) => item.status === 'deleted' - ? 历史记录 - :
- - -
}, + { + key: 'actions', + title: '操作', + width: '175px', + align: 'right', + render: (item) => + item.status === 'deleted' ? ( + 历史记录 + ) : ( +
+ + +
+ ), + }, ]; const hitTotalPages = Math.max(1, Math.ceil(hitTotal / 20)); @@ -318,13 +503,25 @@ export function AdminRiskRulesPage() { return (
-

风控规则

维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。

+
+ +

风控规则

+

维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。

+
- - + +
- {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null}
setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} /> + setWhitelistPhone(event.target.value)} + placeholder="输入完整或部分号码" + value={whitelistPhone} + /> setHitPhone(event.target.value)} placeholder="输入完整或部分号码" value={hitPhone} /> + setHitPhone(event.target.value)} + placeholder="输入完整或部分号码" + value={hitPhone} + /> setEditor({ ...editor, applicationId: event.target.value })} - options={[{ label: '请选择企业应用', value: '' }, ...applications.map((application) => ({ label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`, value: application.id }))]} - value={editor.applicationId} - /> : null} - {!editor.id ? item.code === editor.code)?.label ?? editor.code} />} - item.code === editor.code)?.unit ?? ''})`} min={isPhoneFrequencyRule(editor.code) ? '1' : '0'} onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} /> - setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} /> - setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} /> - {editor.code === 'NON_WORKING_MARKETING_BULK' ? <> - setEditor({ ...editor, startTime: event.target.value })} type="time" value={editor.startTime} /> - setEditor({ ...editor, endTime: event.target.value })} type="time" value={editor.endTime} /> - : null} -
- : null} - {whitelistEditor ? } - onClose={() => setWhitelistEditor(null)} - open - title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'} - > -
- setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })} placeholder="中国大陆11位手机号码" value={whitelistEditor.phoneNumber} /> -