diff --git a/api/prisma/migrations/20260914093000_drainage_carrier_reports/migration.sql b/api/prisma/migrations/20260914093000_drainage_carrier_reports/migration.sql new file mode 100644 index 0000000..2ed4c32 --- /dev/null +++ b/api/prisma/migrations/20260914093000_drainage_carrier_reports/migration.sql @@ -0,0 +1,10 @@ +-- Preserve all legacy rows; independent carrier states require distinct business keys. +BEGIN; +CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_carrier_key" +ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId", "carrier") +WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NOT NULL; +CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_legacy_key" +ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId") +WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NULL; +DROP INDEX "ChannelSignatureReportTask_drainage_target_key"; +COMMIT; diff --git a/api/src/channels/channel-reporting.service.ts b/api/src/channels/channel-reporting.service.ts index 99bf8e0..3f96f4b 100644 --- a/api/src/channels/channel-reporting.service.ts +++ b/api/src/channels/channel-reporting.service.ts @@ -1,3 +1,4 @@ +import { selectDrainageReportTask } from '../common/drainage-report-task'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; @@ -450,17 +451,25 @@ export class ChannelReportingService { }), ); const drainageDetails = signature.drainageItems.flatMap((drainageInfo) => - channels - .map((channel) => { - const existing = signature.reportTasks.find( - (task) => - task.reportType === 'drainage' && - task.channelId === channel.id && - task.drainageItemId === drainageInfo.id, + channels.flatMap((channel) => + normalizeChannelCarriers(channel.carriers, channel.carrier).flatMap((carrier) => { + const tasks = signature.reportTasks.filter( + (task) => task.reportType === 'drainage' && task.drainageItemId === drainageInfo.id, ); - return existing ? { ...existing, signature } : undefined; - }) - .filter(Boolean), + const existing = selectDrainageReportTask(tasks, channel.id, carrier); + return existing + ? [ + { + ...existing, + id: existing.carrier ? existing.id : `virtual:${drainageInfo.id}:${channel.id}:${carrier}`, + carrier, + virtual: !existing.carrier, + signature, + }, + ] + : []; + }), + ), ); return [...signatureDetails, ...drainageDetails]; }) @@ -551,6 +560,9 @@ export class ChannelReportingService { throw new BadRequestException('unsupported report task source entry'); } return this.prisma.$transaction(async (tx) => { + for (const signatureId of [...new Set(data.items.map((item) => item.signatureId))].sort()) { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`; + } const signatureIds = [ ...new Set( data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId), @@ -561,6 +573,7 @@ export class ChannelReportingService { reportType: 'drainage'; drainageItemId: string; channelId: string; + carrier: string | null; status: string; }> = []; for (const item of data.items) { @@ -577,7 +590,7 @@ export class ChannelReportingService { if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态'); } - const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null; + const carrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : null; if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) { throw new BadRequestException('报备运营商不在通道支持范围内'); } @@ -587,11 +600,23 @@ export class ChannelReportingService { channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null, - carrier: reportType === 'signature' ? carrier : null, + carrier, }, }); - if (reportType === 'drainage' && !existing) - throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核'); + if (reportType === 'drainage' && !existing) { + const legacy = carrier + ? await tx.channelSignatureReportTask.findFirst({ + where: { + signatureId: item.signatureId, + channelId: item.channelId, + reportType, + drainageItemId: item.drainageItemId, + carrier: null, + }, + }) + : null; + if (!legacy) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核'); + } if (reportType === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商'); const approvedAt = @@ -603,7 +628,7 @@ export class ChannelReportingService { const task = existing ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, - data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) }, + data: { status: item.status, reason: data.reason, approvedAt }, }) : await tx.channelSignatureReportTask.create({ data: { @@ -638,6 +663,7 @@ export class ChannelReportingService { reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, + carrier, status: item.status, }); } diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 85e59d4..5f4502f 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -370,6 +370,7 @@ describe('ChannelsService', () => { updatedAt: new Date(), }; const tx = { + $executeRaw: jest.fn().mockResolvedValue(1), channelReportField: { findMany: jest .fn() @@ -519,6 +520,7 @@ describe('ChannelsService', () => { async (sourceEntry) => { const prisma = createPrismaMock(); const tx = { + $executeRaw: jest.fn().mockResolvedValue(1), smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), @@ -601,6 +603,7 @@ describe('ChannelsService', () => { it('uses the enterprise-signature save time when creating an approved carrier task', async () => { const prisma = createPrismaMock(); const tx = { + $executeRaw: jest.fn().mockResolvedValue(1), smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), @@ -655,6 +658,7 @@ describe('ChannelsService', () => { it('changes a drainage report task without overwriting the signature report summary', async () => { const prisma = createPrismaMock(); const tx = { + $executeRaw: jest.fn().mockResolvedValue(1), smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn(), @@ -699,6 +703,7 @@ describe('ChannelsService', () => { reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', + carrier: null, status: 'approved', }, ]); @@ -1259,6 +1264,7 @@ describe('ChannelsService', () => { const transactionCallback = prisma.$transaction.mock.calls[0][0]; const tx = { + $executeRaw: jest.fn().mockResolvedValue(1), smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() }, smsChannelGroup: { update: jest.fn(), diff --git a/api/src/common/drainage-report-task.ts b/api/src/common/drainage-report-task.ts new file mode 100644 index 0000000..e947c2d --- /dev/null +++ b/api/src/common/drainage-report-task.ts @@ -0,0 +1,13 @@ +/** A carrier-specific decision overrides a legacy channel decision, including rejection. */ +export function selectDrainageReportTask< + T extends { + channelId: string; + carrier?: string | null; + approvalScope?: string; + }, +>(tasks: T[], channelId: string, carrier?: string) { + return ( + (carrier ? tasks.find((task) => task.channelId === channelId && task.carrier === carrier) : undefined) ?? + tasks.find((task) => task.channelId === channelId && !task.carrier && task.approvalScope !== 'carrier_specific') + ); +} diff --git a/api/src/report-materials/batch-generation.service.ts b/api/src/report-materials/batch-generation.service.ts index 18ed3a8..6a8b247 100644 --- a/api/src/report-materials/batch-generation.service.ts +++ b/api/src/report-materials/batch-generation.service.ts @@ -235,7 +235,10 @@ export class ReportBatchGenerationService { continue; if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId) continue; - if (scope.batchItem.reportType === 'signature' && carriers.size && task.carrier && !carriers.has(task.carrier)) + if ( + carriers.size && + (task.carrier ? !carriers.has(task.carrier) : !carriers.has('all') && !carriers.has('legacy')) + ) continue; const key = `${scope.batchItem.id}:${task.id}`; if (seen.has(key)) continue; @@ -664,7 +667,7 @@ export class ReportBatchGenerationService { where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }); - const targetCarriers = selected.reportType === 'signature' ? [...carriers].sort() : ['all']; + const targetCarriers = [...carriers].sort(); for (const carrier of targetCarriers) { const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`; const targetReasons = [...blockedReasons]; @@ -677,11 +680,13 @@ export class ReportBatchGenerationService { if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`); } - const existingTask = currentTasks.find( - (task) => task.channelId === channel.id && (selected.reportType === 'drainage' || task.carrier === carrier), - ); + const existingTask = currentTasks.find((task) => task.channelId === channel.id && task.carrier === carrier); if (existingTask?.status === 'abandoned') targetReasons.push('该通道报备明细已放弃报备'); - const duplicateBatchId = priorKeys.get(businessKey); + const duplicateBatchId = + priorKeys.get(businessKey) ?? + (selected.reportType === 'drainage' + ? priorKeys.get(businessKey.replace(/:carrier:[^:]+$/, ':carrier:all')) + : undefined); if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`); targets.push({ id: `${channel.id}:${carrier}`, diff --git a/api/src/report-materials/channel-export.service.ts b/api/src/report-materials/channel-export.service.ts index caeed04..57ea9ad 100644 --- a/api/src/report-materials/channel-export.service.ts +++ b/api/src/report-materials/channel-export.service.ts @@ -71,47 +71,48 @@ export class ReportChannelExportService { : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null; - const reportCarriers = - reportType === 'signature' - ? item.eligibleTargets - .filter((target) => target.channelId === channelId) - .map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom') - : [null]; + const reportCarriers = item.eligibleTargets + .filter((target) => target.channelId === channelId) + .map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom'); const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = []; for (const carrier of reportCarriers) { - const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ - where: { - signatureId: item.signature.id, - channelId, - carrier, - reportType, - drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null, - }, + const entry = await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`; + const existingTask = await tx.channelSignatureReportTask.findFirst({ + where: { + signatureId: item.signature.id, + channelId, + carrier, + reportType, + drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null, + }, + }); + const task = existingTask + ? await tx.channelSignatureReportTask.update({ + where: { id: existingTask.id }, + data: { + status: missingReason ? 'waiting_material' : 'exporting', + reason: missingReason, + approvedAt: null, + }, + }) + : await tx.channelSignatureReportTask.create({ + data: { + tenantId: item.signature.tenantId, + signatureId: item.signature.id, + channelId, + carrier, + approvalScope: 'carrier_specific', + reportType, + drainageItemId: item.drainageInfo?.id, + status: missingReason ? 'waiting_material' : 'exporting', + reason: missingReason, + }, + }); + return { task, existingTask }; }); - const task = existingTask - ? await this.prisma.channelSignatureReportTask.update({ - where: { id: existingTask.id }, - data: { - status: missingReason ? 'waiting_material' : 'exporting', - reason: missingReason, - ...(reportType === 'signature' ? { approvedAt: null } : {}), - }, - }) - : await this.prisma.channelSignatureReportTask.create({ - data: { - tenantId: item.signature.tenantId, - signatureId: item.signature.id, - channelId, - carrier, - approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel', - reportType, - drainageItemId: item.drainageInfo?.id, - status: missingReason ? 'waiting_material' : 'exporting', - reason: missingReason, - }, - }); - tasks.push({ task, existingTask }); + tasks.push(entry); } const task = tasks[0].task; if (missingReason) { diff --git a/api/src/send-chain/drainage-authorization.spec.ts b/api/src/send-chain/drainage-authorization.spec.ts index 028db19..8fb2035 100644 --- a/api/src/send-chain/drainage-authorization.spec.ts +++ b/api/src/send-chain/drainage-authorization.spec.ts @@ -128,3 +128,17 @@ describe('drainage authorization', () => { expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false); }); }); + +describe('drainage carrier override', () => { + it('uses explicit rejection over legacy approval and keeps other carriers independent', () => { + const row = material('m', 'example.com', []); + row.reportTasks = [ + { id: 'legacy', channelId: 'c', carrier: null, status: 'approved' }, + { id: 'mobile', channelId: 'c', carrier: 'mobile', status: 'failed' }, + { id: 'unicom', channelId: 'c', carrier: 'unicom', status: 'approved' }, + ]; + expect(assessDrainage([target('example.com')], [row], 'mobile').allowedChannelIds).toEqual([]); + expect(assessDrainage([target('example.com')], [row], 'unicom').allowedChannelIds).toEqual(['c']); + expect(assessDrainage([target('example.com')], [row], 'telecom').allowedChannelIds).toEqual(['c']); + }); +}); diff --git a/api/src/send-chain/drainage-authorization.ts b/api/src/send-chain/drainage-authorization.ts index b00a9aa..0deb369 100644 --- a/api/src/send-chain/drainage-authorization.ts +++ b/api/src/send-chain/drainage-authorization.ts @@ -1,4 +1,5 @@ import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; +import { selectDrainageReportTask } from '../common/drainage-report-task'; import { isIP } from 'node:net'; import { parse } from 'tldts'; import type { PrismaService } from '../prisma/prisma.service'; @@ -19,7 +20,7 @@ export type DrainageMaterial = { url: string; auditStatus: string; materialVersion: number; - reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>; + reportTasks: Array<{ id: string; channelId: string; carrier: string | null; approvalScope?: string; status: string }>; }; export type DrainageAssessment = { version: string; @@ -138,8 +139,9 @@ export function assessDrainage( } const channels = new Set( approved.flatMap((item) => - item.reportTasks - .filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier)) + [...new Set(item.reportTasks.map((task) => task.channelId))] + .map((channelId) => selectDrainageReportTask(item.reportTasks, channelId, carrier)) + .filter((task): task is NonNullable => task?.status === 'approved') .map((task) => task.channelId), ), ); diff --git a/api/src/sms-config/drainage.service.ts b/api/src/sms-config/drainage.service.ts index a21a2e0..7c02021 100644 --- a/api/src/sms-config/drainage.service.ts +++ b/api/src/sms-config/drainage.service.ts @@ -1,12 +1,15 @@ -import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { randomInt, randomUUID } from 'node:crypto'; -import { isIpAllowed } from '../common/ip-allowlist'; -import { assertMoneyUnits } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; -import { automaticDeliveryMode } from '../open-api/delivery-mode'; -import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; -import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import type { + CreateSmsDrainageInfoDto, + CreateSmsSignatureOptions, + DrainageInfoListQuery, + ReviewDto, + StatusChangeDto, + UpdateSmsDrainageInfoDto, +} from './sms-config.contracts'; +import { isRecord } from './sms-config.helpers'; import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; @@ -20,67 +23,98 @@ function normalizeDrainageTarget(value?: string) { /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsDrainageService { - constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {} + constructor( + private readonly prisma: PrismaService, + private readonly reportValidation: SmsReportValidationService, + private readonly audit: SmsAuditService, + ) {} + private async assertUniqueTarget( + tx: Prisma.TransactionClient, + signatureId: string, + target: string, + excludeId?: string, + ) { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`; + const duplicate = await tx.smsDrainageInfo.findFirst({ + where: { + signatureId, + url: target, + auditStatus: { not: 'deleted' }, + ...(excludeId ? { id: { not: excludeId } } : {}), + }, + select: { id: true }, + }); + if (duplicate) throw new BadRequestException('同一签名下已存在相同的引流信息'); + } async listClientDrainageInfos(tenantId?: string, itemId?: string) { - return this.prisma.smsDrainageInfo.findMany({ - where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } }, - select: { - id: true, - tenantId: true, - signatureId: true, - applicationId: true, - siteName: true, - url: true, - remark: true, - reportValues: true, - auditStatus: true, - rejectReason: true, - submittedAt: true, - reviewedAt: true, - createdAt: true, - updatedAt: true, - signature: { select: { id: true, name: true, auditStatus: true } }, - application: { select: { id: true, name: true, status: true } }, - }, - orderBy: { updatedAt: 'desc' }, - }); - } + return this.prisma.smsDrainageInfo.findMany({ + where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } }, + select: { + id: true, + tenantId: true, + signatureId: true, + applicationId: true, + siteName: true, + url: true, + remark: true, + reportValues: true, + auditStatus: true, + rejectReason: true, + submittedAt: true, + reviewedAt: true, + createdAt: true, + updatedAt: true, + signature: { select: { id: true, name: true, auditStatus: true } }, + application: { select: { id: true, name: true, status: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + } async getClientDrainageInfoView(itemId: string, tenantId?: string) { - const [item] = await this.listClientDrainageInfos(tenantId, itemId); - if (!item) throw new NotFoundException('Drainage info not found'); - return item; - } + const [item] = await this.listClientDrainageInfos(tenantId, itemId); + if (!item) throw new NotFoundException('Drainage info not found'); + return item; + } listDrainageInfos(query: DrainageInfoListQuery = {}) { - return this.prisma.smsDrainageInfo.findMany({ - where: { - tenantId: query.tenantId, - signatureId: query.signatureId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), - OR: query.keyword ? [ - { siteName: { contains: query.keyword } }, - { url: { contains: query.keyword } }, - { signature: { name: { contains: query.keyword } } }, - { tenant: { name: { contains: query.keyword } } }, - { application: { name: { contains: query.keyword } } }, - ] : undefined, - }, - include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } }, - orderBy: { updatedAt: 'desc' }, - }); - } + return this.prisma.smsDrainageInfo.findMany({ + where: { + tenantId: query.tenantId, + signatureId: query.signatureId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), + OR: query.keyword + ? [ + { siteName: { contains: query.keyword } }, + { url: { contains: query.keyword } }, + { signature: { name: { contains: query.keyword } } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] + : undefined, + }, + include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } }, + orderBy: { updatedAt: 'desc' }, + }); + } - async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature) throw new NotFoundException('Signature not found'); - if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found'); - if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息'); - const target = normalizeDrainageTarget(data.url); - await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues); - const auditStatus = options.initialAuditStatus ?? 'pending'; - const item = await this.prisma.smsDrainageInfo.create({ + async createDrainageInfo( + signatureId: string, + data: CreateSmsDrainageInfoDto, + options: CreateSmsSignatureOptions = {}, + tenantId?: string, + ) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature) throw new NotFoundException('Signature not found'); + if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found'); + if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息'); + const target = normalizeDrainageTarget(data.url); + await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues); + const auditStatus = options.initialAuditStatus ?? 'pending'; + const item = await this.prisma.$transaction(async (tx) => { + await this.assertUniqueTarget(tx, signatureId, target); + return tx.smsDrainageInfo.create({ data: { tenantId: signature.tenantId, signatureId, @@ -94,28 +128,65 @@ export class SmsDrainageService { }, include: { tenant: true, signature: true, application: true }, }); - await this.audit.createAuditRecord({ - tenantId: item.tenantId, - targetType: 'sms_drainage_info', - targetId: item.id, - action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit', - statusAfter: auditStatus, - reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined, - }); - if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id); - return item; - } + }); + await this.audit.createAuditRecord({ + tenantId: item.tenantId, + targetType: 'sms_drainage_info', + targetId: item.id, + action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit', + statusAfter: auditStatus, + reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined, + }); + if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id); + return item; + } - async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) { - const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } }); - if (!current) throw new NotFoundException('Drainage info not found'); - if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); - if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改'); - const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url); - const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined; - await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {})); - const auditStatus = options.initialAuditStatus ?? 'pending'; - const updated = await this.prisma.smsDrainageInfo.update({ + async updateDrainageInfo( + itemId: string, + data: UpdateSmsDrainageInfoDto, + options: CreateSmsSignatureOptions = {}, + tenantId?: string, + ) { + const current = await this.prisma.smsDrainageInfo.findUnique({ + where: { id: itemId }, + include: { signature: true }, + }); + if (!current) throw new NotFoundException('Drainage info not found'); + if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); + if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改'); + const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url); + const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined; + await this.reportValidation.validateDrainageReportValues( + applicationId, + data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}), + ); + const auditStatus = options.initialAuditStatus ?? 'pending'; + const updated = await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${current.signatureId}, 910))`; + const latest = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } }); + if (!latest || latest.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改'); + if (target !== undefined && target !== latest.url) + await this.assertUniqueTarget(tx, current.signatureId, target, itemId); + const priorTasks = await tx.channelSignatureReportTask.findMany({ + where: { drainageItemId: itemId, reportType: 'drainage' }, + }); + for (const task of priorTasks) { + await tx.channelSignatureReportTask.update({ + where: { id: task.id }, + data: { status: 'waiting_review', approvedAt: null, reason: '引流资料修改,原报备失效' }, + }); + await tx.channelSignatureReportRecord.create({ + data: { + taskId: task.id, + channelId: task.channelId, + action: 'material_changed', + statusBefore: task.status, + statusAfter: 'waiting_review', + reason: '引流资料修改,原报备失效', + }, + }); + } + return tx.smsDrainageInfo.update({ where: { id: itemId }, data: { applicationId, @@ -133,37 +204,51 @@ export class SmsDrainageService { }, include: { tenant: true, signature: true, application: true }, }); - await this.audit.createAuditRecord({ - tenantId: current.tenantId, - targetType: 'sms_drainage_info', - targetId: itemId, - action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit', - statusBefore: current.auditStatus, - statusAfter: auditStatus, - reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined, - }); - if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId); - else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核'); - return updated; - } + }); + await this.audit.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_drainage_info', + targetId: itemId, + action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit', + statusBefore: current.auditStatus, + statusAfter: auditStatus, + reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined, + }); + if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId); + else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核'); + return updated; + } approveDrainageInfo(itemId: string, data: ReviewDto) { - return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data); - } + return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data); + } rejectDrainageInfo(itemId: string, data: ReviewDto) { - return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data); - } + return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data); + } async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) { - const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); - if (!current) throw new NotFoundException('Drainage info not found'); - if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); - const status = data.status ?? 'deleted'; - if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态'); - const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } }); - if (status === 'deleted') await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned'); - await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason }); - return updated; - } + const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); + if (!current) throw new NotFoundException('Drainage info not found'); + if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); + const status = data.status ?? 'deleted'; + if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态'); + const updated = await this.prisma.$transaction(async (tx) => { + // Restoration must obey the same uniqueness lock as create and edit. + if (status !== 'deleted') await this.assertUniqueTarget(tx, current.signatureId, current.url, itemId); + return tx.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } }); + }); + if (status === 'deleted') + await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned'); + await this.audit.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_drainage_info', + targetId: itemId, + action: status, + statusBefore: current.auditStatus, + statusAfter: status, + reason: data.reason, + }); + return updated; + } } diff --git a/api/src/sms-config/report-validation.service.ts b/api/src/sms-config/report-validation.service.ts index fb5ab9f..6d1894f 100644 --- a/api/src/sms-config/report-validation.service.ts +++ b/api/src/sms-config/report-validation.service.ts @@ -1,122 +1,186 @@ -import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { randomInt, randomUUID } from 'node:crypto'; -import { isIpAllowed } from '../common/ip-allowlist'; -import { assertMoneyUnits } from '../common/money'; +import { normalizeChannelCarriers } from '../channels/channels.helpers'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { automaticDeliveryMode } from '../open-api/delivery-mode'; -import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; -import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; +import { hasReportValue, isRecord, reportValueParts } from './sms-config.helpers'; import { SmsApplicationConfigService } from './application-config.service'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsReportValidationService { - constructor(private readonly prisma: PrismaService, private readonly applications: SmsApplicationConfigService) {} + constructor( + private readonly prisma: PrismaService, + private readonly applications: SmsApplicationConfigService, + ) {} async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record) { - if (!drainageInfo) return drainageInfo; - const fields = await this.applications.getApplicationReportFields(applicationId); - return { - ...drainageInfo, - reportRequirementSnapshot: { - capturedAt: new Date().toISOString(), - applicationId, - fields: fields.map((field) => ({ - id: field.id, - code: field.code, - name: field.name, - fieldType: field.fieldType, - required: field.required, - reportTypes: field.reportTypes, - commonReportTypes: field.commonReportTypes, - channels: field.channels, - })), - }, - }; - } + if (!drainageInfo) return drainageInfo; + const fields = await this.applications.getApplicationReportFields(applicationId); + return { + ...drainageInfo, + reportRequirementSnapshot: { + capturedAt: new Date().toISOString(), + applicationId, + fields: fields.map((field) => ({ + id: field.id, + code: field.code, + name: field.name, + fieldType: field.fieldType, + required: field.required, + reportTypes: field.reportTypes, + commonReportTypes: field.commonReportTypes, + channels: field.channels, + })), + }, + }; + } async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record) { - if (!drainageInfo) return; - const fields = await this.applications.getApplicationReportFields(applicationId); - const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {}; - for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) { - const value = reportValueParts(signatureValues[field.code]); - for (const channel of field.channels) { - await this.prisma.signatureReportMaterial.upsert({ - where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } }, - update: value, - create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value }, - }); - } + if (!drainageInfo) return; + const fields = await this.applications.getApplicationReportFields(applicationId); + const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {}; + for (const field of fields.filter((item) => + item.reportTypes.some((type) => type === 'signature' || type === 'both'), + )) { + const value = reportValueParts(signatureValues[field.code]); + for (const channel of field.channels) { + await this.prisma.signatureReportMaterial.upsert({ + where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } }, + update: value, + create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value }, + }); } } + } async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record) { - const fields = await this.applications.getApplicationReportFields(applicationId, 'signature'); - const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {}; - const missingSignature = fields - .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')) - .filter((field) => !hasReportValue(signatureValues[field.code])); - if (missingSignature.length > 0) { - throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`); - } + const fields = await this.applications.getApplicationReportFields(applicationId, 'signature'); + const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {}; + const missingSignature = fields + .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')) + .filter((field) => !hasReportValue(signatureValues[field.code])); + if (missingSignature.length > 0) { + throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`); } + } async validateDrainageReportValues(applicationId?: string, reportValues: Record = {}) { - const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage'); - const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code])); - if (missing.length > 0) { - throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`); - } + const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage'); + const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code])); + if (missing.length > 0) { + throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`); } + } async activateDrainageReporting(itemId: string) { - const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } }); - if (!item) throw new NotFoundException('Drainage info not found'); - if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备'); - const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined; - if (!applicationId) return; - const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage')) - .filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both')); - const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])); - const values = isRecord(item.reportValues) ? item.reportValues : {}; - await this.prisma.$transaction(async (tx) => { - await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); - for (const field of fields) { - const value = reportValueParts(values[field.code]); - for (const channel of field.channels) { - await tx.drainageReportMaterial.create({ - data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value }, - }); - } - } - const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } }); - const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task])); - for (const channel of channels.values()) { - const existing = existingByChannel.get(channel.id); - const task = existing - ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } }) - : await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } }); - await tx.channelSignatureReportRecord.create({ - data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' }, + const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } }); + if (!item) throw new NotFoundException('Drainage info not found'); + if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备'); + const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined; + if (!applicationId) return; + const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage')).filter((field) => + field.reportTypes.some((type) => type === 'drainage' || type === 'both'), + ); + const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])); + const values = isRecord(item.reportValues) ? item.reportValues : {}; + await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signatureId}, 910))`; + await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); + for (const field of fields) { + const value = reportValueParts(values[field.code]); + for (const channel of field.channels) { + await tx.drainageReportMaterial.create({ + data: { + signatureId: item.signatureId, + drainageItemId: item.id, + channelId: channel.id, + fieldCode: field.code, + ...value, + }, }); } - for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) { - await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } }); - await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } }); - } + } + const existingTasks = await tx.channelSignatureReportTask.findMany({ + where: { drainageItemId: item.id, reportType: 'drainage' }, }); - } + const configuredChannels = await tx.smsChannel.findMany({ + where: { id: { in: [...channels.keys()] }, status: { not: 'deleted' } }, + }); + const activeKeys = new Set(); + for (const channel of configuredChannels) { + for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) { + const key = `${channel.id}:${carrier}`; + activeKeys.add(key); + const existing = existingTasks.find((task) => task.channelId === channel.id && task.carrier === carrier); + const task = existing + ? await tx.channelSignatureReportTask.update({ + where: { id: existing.id }, + data: { status: 'pending', reason: null, approvedAt: null }, + }) + : await tx.channelSignatureReportTask.create({ + data: { + tenantId: item.tenantId, + signatureId: item.signatureId, + channelId: channel.id, + carrier, + approvalScope: 'carrier_specific', + reportType: 'drainage', + drainageItemId: item.id, + status: 'pending', + }, + }); + await tx.channelSignatureReportRecord.create({ + data: { + taskId: task.id, + channelId: channel.id, + action: existing ? 'audit_approved_reset' : 'audit_approved_create', + statusBefore: existing?.status, + statusAfter: 'pending', + reason: '引流信息运营审核通过,按运营商重新报备', + }, + }); + } + } + for (const task of existingTasks.filter( + (current) => !activeKeys.has(`${current.channelId}:${current.carrier}`) && current.status !== 'abandoned', + )) { + const reason = '资料版本更新或应用路由已不包含此通道运营商'; + await tx.channelSignatureReportTask.update({ + where: { id: task.id }, + data: { status: 'abandoned', reason, approvedAt: null }, + }); + await tx.channelSignatureReportRecord.create({ + data: { + taskId: task.id, + channelId: task.channelId, + action: 'route_removed', + statusBefore: task.status, + statusAfter: 'abandoned', + reason, + }, + }); + } + }); + } async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') { - await this.prisma.$transaction(async (tx) => { - const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } }); - if (!item) throw new NotFoundException('Drainage info not found'); - await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); - const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } }); - for (const task of tasks.filter((current) => current.status !== statusAfter)) { - await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } }); - await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } }); - } + await this.prisma.$transaction(async (tx) => { + const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } }); + if (!item) throw new NotFoundException('Drainage info not found'); + await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); + const tasks = await tx.channelSignatureReportTask.findMany({ + where: { drainageItemId: item.id, reportType: 'drainage' }, }); - } + for (const task of tasks.filter((current) => current.status !== statusAfter)) { + await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } }); + await tx.channelSignatureReportRecord.create({ + data: { + taskId: task.id, + channelId: task.channelId, + action: 'audit_suspended', + statusBefore: task.status, + statusAfter, + reason, + }, + }); + } + }); + } } diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts index 5171c12..ba8dd20 100644 --- a/api/src/sms-config/signature.service.ts +++ b/api/src/sms-config/signature.service.ts @@ -1,64 +1,15 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - Logger, - NotFoundException, - OnModuleDestroy, - OnModuleInit, -} from '@nestjs/common'; +import { selectDrainageReportTask } from '../common/drainage-report-task'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { randomInt, randomUUID } from 'node:crypto'; -import { isIpAllowed } from '../common/ip-allowlist'; -import { assertMoneyUnits } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; -import { automaticDeliveryMode } from '../open-api/delivery-mode'; import type { - ApplicationListQuery, CreateSignatureMaterialDto, - CreateSmsApplicationDto, - CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, - CreateSmsTemplateDto, - CreateSmsTemplateOptions, - DrainageInfoListQuery, - GatewayDownstreamConnectionEventDto, - ReplaceApplicationRouteRulesDto, - ReviewDto, SignatureListQuery, - StatusChangeDto, - TemplateListQuery, - UpdateSmsApplicationDto, - UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, - UpdateSmsTemplateDto, } from './sms-config.contracts'; -import { - APPLICATION_DISABLE_GRACE_MS, - DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, - DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, - UNRESOLVED_DOWNSTREAM_STATUSES, - type TemplateVariableInput, - estimateBillingUnits, - generateApplicationPassword, - getPositiveInteger, - getPositiveIntegerEnv, - hasReportValue, - inferTemplateVariables, - isRecord, - normalizeApplicationCmppStatus, - normalizeApplicationInterfaceType, - normalizeApplicationPassword, - normalizeApplicationQueuePriority, - normalizeCmppAccessNumberConfig, - normalizeSmsSignature, - parseGatewayDate, - reportValueParts, - startOfToday, - validateAndNormalizeTemplateVariables, - validateCompleteSmsSignature, -} from './sms-config.helpers'; +import { isRecord, normalizeSmsSignature, validateCompleteSmsSignature } from './sms-config.helpers'; import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; @@ -202,6 +153,7 @@ export class SmsSignatureService { .then((count) => count > 0); return signatures.map((signature) => { const { reportBatchItems: _reportBatchItems, ...signatureView } = signature; + void _reportBatchItems; const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; const applicationChannels = [ ...new Map( @@ -305,64 +257,70 @@ export class SmsSignatureService { pendingReportBlockedReason, drainageReportTargets: Object.fromEntries( signature.drainageItems.map((drainageItem) => { - const drainageItemId = drainageItem.id; - const channels = routes - .filter((route) => route.applicationId === signature.applicationId && route.group) - .flatMap((route) => route.group!.items.map((item) => item.channel)) + const tasks = (signature.reportTasks ?? []).filter( + (task) => task.reportType === 'drainage' && task.drainageItemId === drainageItem.id, + ); + const targets = applicationChannels .filter( (channel) => - channel.status !== 'deleted' && - (hasCommonDrainageFields || - channel.reportFields.some( - (field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType), - )), + hasCommonDrainageFields || + channel.reportFields.some( + (field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType), + ), + ) + .flatMap((channel) => + normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => { + const task = selectDrainageReportTask(tasks, channel.id, carrier); + return { + channel, + channelId: channel.id, + carrier, + status: task?.status ?? 'pending', + taskId: task?.id, + approvedAt: task?.approvedAt, + approvalScope: task?.carrier ? 'carrier_specific' : task ? 'legacy_channel' : 'carrier_specific', + }; + }), ); - const taskByChannel = new Map( - (signature.reportTasks ?? []) - .filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId) - .map((task) => [task.channelId, task]), - ); - return [ - drainageItemId, - [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => { - const task = taskByChannel.get(channel.id); - return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : []; - }), - ]; + return [drainageItem.id, targets]; }), ), drainageCarrierReportSummary: Object.fromEntries( signature.drainageItems.map((drainageItem) => { - const drainageItemId = drainageItem.id; - const channels = routes - .filter((route) => route.applicationId === signature.applicationId && route.group) - .flatMap((route) => route.group!.items.map((item) => item.channel)) + const tasks = (signature.reportTasks ?? []).filter( + (task) => task.reportType === 'drainage' && task.drainageItemId === drainageItem.id, + ); + const targets = applicationChannels .filter( (channel) => - channel.status !== 'deleted' && - (hasCommonDrainageFields || - channel.reportFields.some( - (field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType), - )), - ); - const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map( - (signature.reportTasks ?? []) - .filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId) - .map((task) => [task.channelId, task]), - ); - return [ - drainageItemId, - Object.fromEntries( - ['mobile', 'unicom', 'telecom'].map((carrier) => { - const carrierTargets = targets.filter((channel) => - normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier), - ); - const statuses = carrierTargets.flatMap((channel) => - taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : [], - ); - return [carrier, summarizeReportStatuses(statuses)]; + hasCommonDrainageFields || + channel.reportFields.some( + (field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType), + ), + ) + .flatMap((channel) => + normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => { + const task = selectDrainageReportTask(tasks, channel.id, carrier); + return { + channel, + channelId: channel.id, + carrier, + status: task?.status ?? 'pending', + taskId: task?.id, + approvedAt: task?.approvedAt, + approvalScope: task?.carrier ? 'carrier_specific' : task ? 'legacy_channel' : 'carrier_specific', + }; }), + ); + return [ + drainageItem.id, + Object.fromEntries( + ['mobile', 'unicom', 'telecom'].map((carrier) => [ + carrier, + summarizeReportStatuses( + targets.filter((target) => target.carrier === carrier).map((target) => target.status), + ), + ]), ), ]; }), @@ -400,10 +358,14 @@ export class SmsSignatureService { drainageReportTargets: _drainageReportTargets, ...summary } = view; + void [_materials, _reportTasks, _reportTargets, _drainageReportTargets]; return { ...summary, drainageInfo: { - links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => link), + links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => { + void _reportValues; + return link; + }), }, }; }); @@ -508,7 +470,10 @@ export class SmsSignatureService { for (const value of businessKeys) { const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null; if (!match) continue; - for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean)) + for (const carrier of match[2] + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean)) generatedTargets.add(`${match[1]}:${carrier}`); } } @@ -534,7 +499,8 @@ export class SmsSignatureService { candidate.approvalScope === 'legacy_channel', ); if (task?.status === 'abandoned') continue; - if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue; + if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) + continue; detailTotal += 1; hasPendingTarget = true; } @@ -552,7 +518,7 @@ export class SmsSignatureService { async getSignatureReportTargets(id: string) { const item = await this.getSignature(id); - return 'reportTargets' in item ? item.reportTargets ?? [] : []; + return 'reportTargets' in item ? (item.reportTargets ?? []) : []; } async getDrainageReportTargets(id: string) { @@ -562,7 +528,7 @@ export class SmsSignatureService { }); if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('Drainage info not found'); const signature = await this.getSignature(drainage.signatureId); - return 'drainageReportTargets' in signature ? signature.drainageReportTargets?.[id] ?? [] : []; + return 'drainageReportTargets' in signature ? (signature.drainageReportTargets?.[id] ?? []) : []; } listSignatureOptions(tenantId?: string) { @@ -876,10 +842,7 @@ export class SmsSignatureService { updated.applicationId ?? undefined, drainageInfo, ); - if ( - options.initialAuditStatus === 'approved' && - (materialChanged || signature.auditStatus !== 'approved') - ) { + if (options.initialAuditStatus === 'approved' && (materialChanged || signature.auditStatus !== 'approved')) { await this.audit.createAuditRecord({ tenantId: signature.tenantId, targetType: 'sms_signature', diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index fc8c5e1..eb0f6ae 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -4,25 +4,28 @@ import { SmsConfigService } from './sms-config.service'; function createPrismaMock() { return { + $executeRaw: jest.fn().mockResolvedValue(1), smsApplication: { - findMany: jest.fn().mockResolvedValue([{ - id: 'app-1', - tenantId: 'tenant-1', - name: '应用A', - status: 'active', - cmppAccount: '100001', - cmppEnterpriseCode: 'APP-EC', - cmppApplicationExtension: '0001', - cmppAccessNumberFillEnabled: true, - cmppAccessNumberFillPrefix: '00', - cmppClientSrcId: '000001', - cmppMaxConnections: 2, - cmppWindowSize: 32, - interfaceEnabled: true, - interfaceType: 'cmpp20', - queuePriority: 'normal', - tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, - }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'app-1', + tenantId: 'tenant-1', + name: '应用A', + status: 'active', + cmppAccount: '100001', + cmppEnterpriseCode: 'APP-EC', + cmppApplicationExtension: '0001', + cmppAccessNumberFillEnabled: true, + cmppAccessNumberFillPrefix: '00', + cmppClientSrcId: '000001', + cmppMaxConnections: 2, + cmppWindowSize: 32, + interfaceEnabled: true, + interfaceType: 'cmpp20', + queuePriority: 'normal', + tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, + }, + ]), findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', @@ -43,7 +46,9 @@ function createPrismaMock() { ipAllowlist: [], tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, }), - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), + update: jest + .fn() + .mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), updateMany: jest.fn().mockResolvedValue({ count: 1 }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })), }, @@ -60,8 +65,22 @@ function createPrismaMock() { deleteMany: jest.fn().mockResolvedValue({ count: 2 }), createMany: jest.fn().mockResolvedValue({ count: 2 }), findMany: jest.fn().mockResolvedValue([ - { id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' }, - { id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' }, + { + id: 'rule-1', + applicationId: 'app-1', + groupId: 'group-mobile', + carrier: 'mobile', + priority: 10, + status: 'active', + }, + { + id: 'rule-2', + applicationId: 'app-1', + groupId: 'group-unicom', + carrier: 'unicom', + priority: 20, + status: 'active', + }, ]), }, commonReportField: { @@ -71,23 +90,35 @@ function createPrismaMock() { smsSignature: { groupBy: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), - findMany: jest.fn().mockResolvedValue([{ + findMany: jest.fn().mockResolvedValue([ + { + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + name: '签名A', + purpose: '行业通知', + auditStatus: 'pending', + drainageInfo: { carrierStatus: { mobile: 'approved', unicom: 'pending', telecom: 'filing' }, links: [] }, + tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, + application: { id: 'app-1', name: '应用A' }, + materials: [], + drainageItems: [], + reportTasks: [], + }, + ]), + findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', - purpose: '行业通知', auditStatus: 'pending', - drainageInfo: { carrierStatus: { mobile: 'approved', unicom: 'pending', telecom: 'filing' }, links: [] }, - tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, - application: { id: 'app-1', name: '应用A' }, - materials: [], - drainageItems: [], - reportTasks: [], - }]), - findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', auditStatus: 'pending' }), - create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })), - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), + }), + create: jest + .fn() + .mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })), + update: jest + .fn() + .mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), }, signatureReportMaterial: { upsert: jest.fn().mockResolvedValue({ id: 'signature-report-value-1' }), @@ -98,10 +129,32 @@ function createPrismaMock() { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), }, smsDrainageInfo: { + findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null), - create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })), - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })), + create: jest.fn().mockImplementation(({ data }) => + Promise.resolve({ + id: 'drainage-1', + createdAt: new Date(), + updatedAt: new Date(), + submittedAt: new Date(), + ...data, + }), + ), + update: jest.fn().mockImplementation(({ data }) => + Promise.resolve({ + id: 'drainage-1', + tenantId: 'tenant-1', + signatureId: 'sig-1', + applicationId: 'app-1', + siteName: '官网', + url: 'https://example.com', + createdAt: new Date(), + updatedAt: new Date(), + submittedAt: new Date(), + ...data, + }), + ), }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), @@ -115,21 +168,32 @@ function createPrismaMock() { }, smsTemplate: { count: jest.fn().mockResolvedValue(0), - findMany: jest.fn().mockResolvedValue([{ + findMany: jest.fn().mockResolvedValue([ + { + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '模板A', + content: '您好${name}', + auditStatus: 'pending', + tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, + application: { id: 'app-1', name: '应用A' }, + signature: { id: 'sig-1', name: '签名A' }, + variables: [{ name: 'name', required: true }], + }, + ]), + findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', - name: '模板A', - content: '您好${name}', + content: '【签名A】您好${name}', auditStatus: 'pending', - tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, - application: { id: 'app-1', name: '应用A' }, - signature: { id: 'sig-1', name: '签名A' }, - variables: [{ name: 'name', required: true }], - }]), - findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', content: '【签名A】您好${name}', auditStatus: 'pending' }), - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), + }), + update: jest + .fn() + .mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-new', ...data })), }, auditRecord: { @@ -140,15 +204,46 @@ function createPrismaMock() { findUnique: jest.fn().mockResolvedValue(null), }, cmppConnectionState: { - findMany: jest.fn().mockResolvedValue([{ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'connected', currentConnections: 1, desiredConnections: 1 }]), - findFirst: jest.fn().mockResolvedValue({ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }), + findMany: jest.fn().mockResolvedValue([ + { + id: 'conn-state-1', + applicationId: 'app-1', + channelId: 'channel-1', + connectionId: 'conn-a', + tenantId: 'tenant-1', + status: 'connected', + currentConnections: 1, + desiredConnections: 1, + }, + ]), + findFirst: jest.fn().mockResolvedValue({ + id: 'conn-state-1', + applicationId: 'app-1', + channelId: 'channel-1', + connectionId: 'conn-a', + tenantId: 'tenant-1', + }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })), }, cmppDownstreamConnection: { - findMany: jest.fn().mockResolvedValue([{ id: 'downstream-1', applicationId: 'app-1', tenantId: 'tenant-1', account: '100001', enterpriseCode: 'APP-EC', connectionId: 'gateway-1-1', status: 'connected', connectedAt: new Date(), lastHeartbeatAt: new Date() }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'downstream-1', + applicationId: 'app-1', + tenantId: 'tenant-1', + account: '100001', + enterpriseCode: 'APP-EC', + connectionId: 'gateway-1-1', + status: 'connected', + connectedAt: new Date(), + lastHeartbeatAt: new Date(), + }, + ]), findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), - upsert: jest.fn().mockImplementation(({ create, update }) => Promise.resolve({ id: 'downstream-1', ...(create ?? update) })), + upsert: jest + .fn() + .mockImplementation(({ create, update }) => Promise.resolve({ id: 'downstream-1', ...(create ?? update) })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }), @@ -170,6 +265,7 @@ function createPrismaMock() { updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, smsChannel: { + findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'] }]), findFirst: jest.fn().mockResolvedValue({ id: 'channel-1', gatewayHost: '127.0.0.1', @@ -188,28 +284,48 @@ function createPrismaMock() { tenant: { findUnique: jest.fn().mockResolvedValue({ code: 'TENANT-A' }), }, - $transaction: jest.fn((callback) => callback({ - smsApplication: { - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), - }, - smsApplicationIpAllowlist: { - deleteMany: jest.fn().mockResolvedValue({ count: 1 }), - }, - channelRouteRule: { - deleteMany: jest.fn().mockResolvedValue({ count: 2 }), - createMany: jest.fn().mockResolvedValue({ count: 2 }), - findMany: jest.fn().mockResolvedValue([ - { id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' }, - { id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' }, - ]), - }, - templateVariable: { - deleteMany: jest.fn().mockResolvedValue({ count: 1 }), - }, - smsTemplate: { - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), - }, - })), + $transaction: jest.fn((callback) => + callback({ + smsApplication: { + update: jest + .fn() + .mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), + }, + smsApplicationIpAllowlist: { + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + channelRouteRule: { + deleteMany: jest.fn().mockResolvedValue({ count: 2 }), + createMany: jest.fn().mockResolvedValue({ count: 2 }), + findMany: jest.fn().mockResolvedValue([ + { + id: 'rule-1', + applicationId: 'app-1', + groupId: 'group-mobile', + carrier: 'mobile', + priority: 10, + status: 'active', + }, + { + id: 'rule-2', + applicationId: 'app-1', + groupId: 'group-unicom', + carrier: 'unicom', + priority: 20, + status: 'active', + }, + ]), + }, + templateVariable: { + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + smsTemplate: { + update: jest + .fn() + .mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), + }, + }), + ), }; } @@ -230,7 +346,14 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.listApplications({ includeConnections: true, enterpriseKeyword: '租户', applicationKeyword: '应用', status: 'active' })).resolves.toEqual([ + await expect( + service.listApplications({ + includeConnections: true, + enterpriseKeyword: '租户', + applicationKeyword: '应用', + status: 'active', + }), + ).resolves.toEqual([ expect.objectContaining({ id: 'app-1', cmppStatus: 'connected', @@ -240,20 +363,28 @@ describe('SmsConfigService', () => { cmppConnections: [expect.objectContaining({ connectionId: 'gateway-1-1', account: '100001' })], }), ]); - expect(prisma.smsApplication.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' } }), - include: { tenant: true, ipAllowlist: true, httpConfig: true }, - omit: { secretHash: true }, - })); + expect(prisma.smsApplication.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'active', + tenant: { name: { contains: '租户' } }, + name: { contains: '应用' }, + }), + include: { tenant: true, ipAllowlist: true, httpConfig: true }, + omit: { secretHash: true }, + }), + ); const applicationModel = Prisma.dmmf.datamodel.models.find((model) => model.name === 'SmsApplication'); const applicationFields = new Set(applicationModel?.fields.map((field) => field.name)); const omittedFields = Object.keys(prisma.smsApplication.findMany.mock.calls[0][0].omit); expect(omittedFields.every((field) => applicationFields.has(field))).toBe(true); expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take'); - expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith(expect.objectContaining({ - by: ['applicationId', 'status'], - _count: { _all: true }, - })); + expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ['applicationId', 'status'], + _count: { _all: true }, + }), + ); }); it('moves an application with outstanding receipts into disabling for 72 hours', async () => { @@ -266,30 +397,36 @@ describe('SmsConfigService', () => { reason: '运营端停用', }); - expect(result).toEqual(expect.objectContaining({ - status: 'disabling', - autoDisableAt: expect.any(Date), - deactivation: expect.objectContaining({ awaitingSupplierReceipt: 1 }), - })); - expect(prisma.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'app-1' }, - data: expect.objectContaining({ + expect(result).toEqual( + expect.objectContaining({ status: 'disabling', - disablingAt: expect.any(Date), autoDisableAt: expect.any(Date), + deactivation: expect.objectContaining({ awaitingSupplierReceipt: 1 }), }), - })); + ); + expect(prisma.smsApplication.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'app-1' }, + data: expect.objectContaining({ + status: 'disabling', + disablingAt: expect.any(Date), + autoDisableAt: expect.any(Date), + }), + }), + ); }); it('automatically disables and abandons outstanding deliveries after 72 hours', async () => { const prisma = createPrismaMock(); - prisma.smsApplication.findMany.mockResolvedValue([{ - id: 'app-1', - tenantId: 'tenant-1', - cmppAccount: '100001', - status: 'disabling', - autoDisableAt: new Date(Date.now() - 1_000), - }]); + prisma.smsApplication.findMany.mockResolvedValue([ + { + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + status: 'disabling', + autoDisableAt: new Date(Date.now() - 1_000), + }, + ]); prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', @@ -311,13 +448,17 @@ describe('SmsConfigService', () => { await service['runApplicationDisableScan'](); - expect(prisma.smsApplication.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ id: 'app-1', status: 'disabling' }), - data: expect.objectContaining({ status: 'disabled' }), - })); - expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'abandoned', retryEnabled: false }), - })); + expect(prisma.smsApplication.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'app-1', status: 'disabling' }), + data: expect.objectContaining({ status: 'disabled' }), + }), + ); + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'abandoned', retryEnabled: false }), + }), + ); global.fetch = originalFetch; }); @@ -354,24 +495,26 @@ describe('SmsConfigService', () => { process.env.CMPP_PUBLIC_HOST = 'cmpp.example.com'; process.env.CMPP_PUBLIC_PORT = '17891'; - await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({ - applicationId: 'app-1', - tenantName: '租户A', - account: '100001', - enterpriseCode: 'APP-EC', - passwordCipher: '0123456789abcdef', - srcId: '000001', - applicationExtension: '0001', - accessNumberFillEnabled: true, - accessNumberFillPrefix: '00', - gatewayHost: 'cmpp.example.com', - gatewayPort: 17891, - interfaceEnabled: true, - interfaceType: 'cmpp20', - maxConnections: 2, - windowSize: 32, - protocolVersion: 'CMPP2.0', - })); + await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual( + expect.objectContaining({ + applicationId: 'app-1', + tenantName: '租户A', + account: '100001', + enterpriseCode: 'APP-EC', + passwordCipher: '0123456789abcdef', + srcId: '000001', + applicationExtension: '0001', + accessNumberFillEnabled: true, + accessNumberFillPrefix: '00', + gatewayHost: 'cmpp.example.com', + gatewayPort: 17891, + interfaceEnabled: true, + interfaceType: 'cmpp20', + maxConnections: 2, + windowSize: 32, + protocolVersion: 'CMPP2.0', + }), + ); expect(prisma.smsChannel.findFirst).not.toHaveBeenCalled(); delete process.env.CMPP_PUBLIC_HOST; delete process.env.CMPP_PUBLIC_PORT; @@ -382,38 +525,42 @@ describe('SmsConfigService', () => { prisma.smsApplication.findUnique.mockResolvedValueOnce(null); const service = new SmsConfigService(prisma as never); - await expect(service.createApplication({ - tenantId: 'tenant-1', - name: '优先应用', - cmppAccount: '123456', - cmppEnterpriseCode: 'CUSTOM-EC', - passwordCipher: '1234567890abcdef', - cmppMaxConnections: 3, - cmppWindowSize: 32, - interfaceEnabled: false, - interfaceType: 'cmpp20', - queuePriority: 'priority', - ipAllowlist: ['10.0.0.1/32'], - })).resolves.toEqual(expect.objectContaining({ id: 'app-new' })); - - expect(prisma.smsApplication.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ + await expect( + service.createApplication({ tenantId: 'tenant-1', name: '优先应用', cmppAccount: '123456', - cmppEnterpriseCode: '123456', - secretHash: '1234567890abcdef', + cmppEnterpriseCode: 'CUSTOM-EC', + passwordCipher: '1234567890abcdef', cmppMaxConnections: 3, cmppWindowSize: 32, interfaceEnabled: false, interfaceType: 'cmpp20', queuePriority: 'priority', - dailyLimit: 100000, - downstreamReceiptRetryEnabled: true, - downstreamUplinkRetryEnabled: true, - ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, + ipAllowlist: ['10.0.0.1/32'], }), - })); + ).resolves.toEqual(expect.objectContaining({ id: 'app-new' })); + + expect(prisma.smsApplication.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + tenantId: 'tenant-1', + name: '优先应用', + cmppAccount: '123456', + cmppEnterpriseCode: '123456', + secretHash: '1234567890abcdef', + cmppMaxConnections: 3, + cmppWindowSize: 32, + interfaceEnabled: false, + interfaceType: 'cmpp20', + queuePriority: 'priority', + dailyLimit: 100000, + downstreamReceiptRetryEnabled: true, + downstreamUplinkRetryEnabled: true, + ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, + }), + }), + ); }); it('persists a filled client Src_Id separately from the real application extension', async () => { @@ -431,14 +578,16 @@ describe('SmsConfigService', () => { cmppAccessNumberFillPrefix: '00', }); - expect(prisma.smsApplication.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ - cmppApplicationExtension: '0001', - cmppAccessNumberFillEnabled: true, - cmppAccessNumberFillPrefix: '00', - cmppClientSrcId: '000001', + expect(prisma.smsApplication.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + cmppApplicationExtension: '0001', + cmppAccessNumberFillEnabled: true, + cmppAccessNumberFillPrefix: '00', + cmppClientSrcId: '000001', + }), }), - })); + ); }); it('rejects access number filling without a numeric prefix and application extension', async () => { @@ -446,33 +595,39 @@ describe('SmsConfigService', () => { prisma.smsApplication.findUnique.mockResolvedValue(null); const service = new SmsConfigService(prisma as never); - await expect(service.createApplication({ - tenantId: 'tenant-1', - name: '缺少扩展码', - cmppAccount: '123456', - cmppAccessNumberFillEnabled: true, - cmppAccessNumberFillPrefix: '00', - })).rejects.toThrow('cmppApplicationExtension is required'); + await expect( + service.createApplication({ + tenantId: 'tenant-1', + name: '缺少扩展码', + cmppAccount: '123456', + cmppAccessNumberFillEnabled: true, + cmppAccessNumberFillPrefix: '00', + }), + ).rejects.toThrow('cmppApplicationExtension is required'); - await expect(service.createApplication({ - tenantId: 'tenant-1', - name: '错误前缀', - cmppAccount: '123456', - cmppApplicationExtension: '0001', - cmppAccessNumberFillEnabled: true, - cmppAccessNumberFillPrefix: 'AB', - })).rejects.toThrow('cmppAccessNumberFillPrefix must contain digits only'); + await expect( + service.createApplication({ + tenantId: 'tenant-1', + name: '错误前缀', + cmppAccount: '123456', + cmppApplicationExtension: '0001', + cmppAccessNumberFillEnabled: true, + cmppAccessNumberFillPrefix: 'AB', + }), + ).rejects.toThrow('cmppAccessNumberFillPrefix must contain digits only'); }); it('rejects invalid enterprise application queue priority', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.createApplication({ - tenantId: 'tenant-1', - name: '异常应用', - queuePriority: 'urgent', - })).rejects.toThrow('queuePriority must be normal or priority'); + await expect( + service.createApplication({ + tenantId: 'tenant-1', + name: '异常应用', + queuePriority: 'urgent', + }), + ).rejects.toThrow('queuePriority must be normal or priority'); expect(prisma.smsApplication.create).not.toHaveBeenCalled(); }); @@ -481,11 +636,13 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.createApplication({ - tenantId: 'tenant-1', - name: 'HTTP应用', - interfaceType: 'http', - })).rejects.toThrow('interfaceType only supports cmpp20'); + await expect( + service.createApplication({ + tenantId: 'tenant-1', + name: 'HTTP应用', + interfaceType: 'http', + }), + ).rejects.toThrow('interfaceType only supports cmpp20'); expect(prisma.smsApplication.create).not.toHaveBeenCalled(); }); @@ -494,17 +651,21 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.createApplication({ - tenantId: 'tenant-1', - name: '异常应用', - cmppAccount: 'abc', - })).rejects.toThrow('cmppAccount must be a 6-digit number'); + await expect( + service.createApplication({ + tenantId: 'tenant-1', + name: '异常应用', + cmppAccount: 'abc', + }), + ).rejects.toThrow('cmppAccount must be a 6-digit number'); - await expect(service.createApplication({ - tenantId: 'tenant-1', - name: '重复应用', - cmppAccount: '100001', - })).rejects.toThrow('cmppAccount already exists'); + await expect( + service.createApplication({ + tenantId: 'tenant-1', + name: '重复应用', + cmppAccount: '100001', + }), + ).rejects.toThrow('cmppAccount already exists'); }); it('updates enterprise application profile and allowlist through a transaction', async () => { @@ -521,22 +682,31 @@ describe('SmsConfigService', () => { prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); const service = new SmsConfigService(prisma as never); - await expect(service.updateApplication('app-1', { customerUnitPrice: 325.5 })) - .rejects.toThrow('客户单价最多支持人民币小数点后 4 位'); - await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 325, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] })) - .resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' })); - - expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } }); - expect(tx.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'app-1' }, - data: expect.objectContaining({ + await expect(service.updateApplication('app-1', { customerUnitPrice: 325.5 })).rejects.toThrow( + '客户单价最多支持人民币小数点后 4 位', + ); + await expect( + service.updateApplication('app-1', { name: '新应用', - cmppEnterpriseCode: '100001', customerUnitPrice: 325, queuePriority: 'priority', - ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, + ipAllowlist: ['10.0.0.1/32'], }), - })); + ).resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' })); + + expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } }); + expect(tx.smsApplication.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'app-1' }, + data: expect.objectContaining({ + name: '新应用', + cmppEnterpriseCode: '100001', + customerUnitPrice: 325, + queuePriority: 'priority', + ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, + }), + }), + ); }); it('derives both downstream delivery modes when CMPP is enabled alongside HTTP', async () => { @@ -585,12 +755,14 @@ describe('SmsConfigService', () => { prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); const service = new SmsConfigService(prisma as never); - await expect(service.replaceApplicationRouteRules('app-1', { - routes: [ - { carrier: 'mobile', groupId: 'group-mobile' }, - { carrier: 'unicom', groupId: 'group-unicom' }, - ], - })).resolves.toEqual([ + await expect( + service.replaceApplicationRouteRules('app-1', { + routes: [ + { carrier: 'mobile', groupId: 'group-mobile' }, + { carrier: 'unicom', groupId: 'group-unicom' }, + ], + }), + ).resolves.toEqual([ expect.objectContaining({ carrier: 'mobile' }), expect.objectContaining({ carrier: 'unicom' }), ]); @@ -600,8 +772,20 @@ describe('SmsConfigService', () => { }); expect(tx.channelRouteRule.createMany).toHaveBeenCalledWith({ data: [ - expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-mobile', priority: 10 }), - expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'unicom', groupId: 'group-unicom', priority: 20 }), + expect.objectContaining({ + tenantId: 'tenant-1', + applicationId: 'app-1', + carrier: 'mobile', + groupId: 'group-mobile', + priority: 10, + }), + expect.objectContaining({ + tenantId: 'tenant-1', + applicationId: 'app-1', + carrier: 'unicom', + groupId: 'group-unicom', + priority: 20, + }), ], }); }); @@ -610,9 +794,11 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.replaceApplicationRouteRules('app-1', { - routes: [{ carrier: 'telecom', groupId: 'group-mobile' }], - })).rejects.toThrow('channel group carrier must match route carrier'); + await expect( + service.replaceApplicationRouteRules('app-1', { + routes: [{ carrier: 'telecom', groupId: 'group-mobile' }], + }), + ).rejects.toThrow('channel group carrier must match route carrier'); expect(prisma.$transaction).not.toHaveBeenCalled(); }); @@ -627,7 +813,9 @@ describe('SmsConfigService', () => { it('forbids client CMPP parameter access when the interface is not enabled', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ - id: 'app-1', tenantId: 'tenant-1', interfaceEnabled: false, + id: 'app-1', + tenantId: 'tenant-1', + interfaceEnabled: false, tenant: { id: 'tenant-1', name: '租户A' }, }); const service = new SmsConfigService(prisma as never); @@ -709,69 +897,97 @@ describe('SmsConfigService', () => { it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ - id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1, - interfaceEnabled: true, status: 'active', ipAllowlist: [{ ipCidr: '10.0.0.0/24' }], + id: 'app-1', + tenantId: 'tenant-1', + cmppEnterpriseCode: 'APP-EC', + cmppMaxConnections: 1, + interfaceEnabled: true, + status: 'active', + ipAllowlist: [{ ipCidr: '10.0.0.0/24' }], }); prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-2', connectedAt: new Date() }); const service = new SmsConfigService(prisma as never); - await expect(service.recordDownstreamConnectionEvent({ - account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1', - })).rejects.toThrow('CMPP source IP is not in application allowlist'); + await expect( + service.recordDownstreamConnectionEvent({ + account: '100001', + connectionId: 'gateway-1-2', + status: 'heartbeat', + remoteIp: '127.0.0.1', + }), + ).rejects.toThrow('CMPP source IP is not in application allowlist'); prisma.smsApplication.findUnique.mockResolvedValue({ - id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1, - interfaceEnabled: true, status: 'active', ipAllowlist: [], + id: 'app-1', + tenantId: 'tenant-1', + cmppEnterpriseCode: 'APP-EC', + cmppMaxConnections: 1, + interfaceEnabled: true, + status: 'active', + ipAllowlist: [], }); prisma.cmppDownstreamConnection.findMany.mockResolvedValue([ - { connectionId: 'gateway-1-1' }, { connectionId: 'gateway-1-2' }, + { connectionId: 'gateway-1-1' }, + { connectionId: 'gateway-1-2' }, ]); - await expect(service.recordDownstreamConnectionEvent({ - account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1', - })).rejects.toThrow('CMPP connection limit exceeded (1)'); + await expect( + service.recordDownstreamConnectionEvent({ + account: '100001', + connectionId: 'gateway-1-2', + status: 'heartbeat', + remoteIp: '127.0.0.1', + }), + ).rejects.toThrow('CMPP connection limit exceeded (1)'); }); it.each([ ['heartbeat', 'lastHeartbeatAt'], ['submit', 'lastSubmitAt'], ['deliver', 'lastDeliverAt'], - ] as const)('updates downstream %s state without appending high-frequency operation logs', async (status, timestampField) => { - const prisma = createPrismaMock(); - prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ - id: 'downstream-1', - connectedAt: new Date('2026-07-11T11:00:00.000Z'), - lastHeartbeatAt: new Date('2026-07-11T11:00:00.000Z'), - lastSubmitAt: null, - lastDeliverAt: null, - lastError: null, - }); - const service = new SmsConfigService(prisma as never); + ] as const)( + 'updates downstream %s state without appending high-frequency operation logs', + async (status, timestampField) => { + const prisma = createPrismaMock(); + prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ + id: 'downstream-1', + connectedAt: new Date('2026-07-11T11:00:00.000Z'), + lastHeartbeatAt: new Date('2026-07-11T11:00:00.000Z'), + lastSubmitAt: null, + lastDeliverAt: null, + lastError: null, + }); + const service = new SmsConfigService(prisma as never); - await service.recordDownstreamConnectionEvent({ - account: '100001', - connectionId: 'gateway-1-1', - status, - observedAt: '2026-07-11T11:00:30.000Z', - }); + await service.recordDownstreamConnectionEvent({ + account: '100001', + connectionId: 'gateway-1-1', + status, + observedAt: '2026-07-11T11:00:30.000Z', + }); - expect(prisma.cmppDownstreamConnection.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'downstream-1' }, - data: expect.objectContaining({ [timestampField]: new Date('2026-07-11T11:00:30.000Z') }), - })); - expect(prisma.operationLog.create).not.toHaveBeenCalled(); - }); + expect(prisma.cmppDownstreamConnection.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'downstream-1' }, + data: expect.objectContaining({ [timestampField]: new Date('2026-07-11T11:00:30.000Z') }), + }), + ); + expect(prisma.operationLog.create).not.toHaveBeenCalled(); + }, + ); it('removes a disconnected downstream session instead of retaining connection history', async () => { const prisma = createPrismaMock(); prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-1' }); const service = new SmsConfigService(prisma as never); - await expect(service.recordDownstreamConnectionEvent({ - account: '100001', - connectionId: 'gateway-1-1', - status: 'disconnected', - errorMessage: 'client closed', - })).resolves.toEqual(expect.objectContaining({ status: 'disconnected', deleted: true })); + await expect( + service.recordDownstreamConnectionEvent({ + account: '100001', + connectionId: 'gateway-1-1', + status: 'disconnected', + errorMessage: 'client closed', + }), + ).resolves.toEqual(expect.objectContaining({ status: 'disconnected', deleted: true })); expect(prisma.cmppDownstreamConnection.delete).toHaveBeenCalledWith({ where: { id: 'downstream-1' } }); expect(prisma.cmppDownstreamConnection.update).not.toHaveBeenCalled(); @@ -781,43 +997,99 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网' })).resolves.toEqual([ + await expect( + service.listSignatures({ + enterpriseKeyword: '租户', + applicationKeyword: '应用', + signatureKeyword: '签名', + drainageKeyword: '官网', + }), + ).resolves.toEqual([ expect.objectContaining({ id: 'sig-1', tenant: expect.objectContaining({ name: '租户A' }), application: expect.objectContaining({ name: '应用A' }), }), ]); - expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - auditStatus: { not: 'deleted' }, - tenant: { name: { contains: '租户' } }, - application: { name: { contains: '应用' } }, - name: { contains: '签名' }, - drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }), + expect(prisma.smsSignature.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + auditStatus: { not: 'deleted' }, + tenant: { name: { contains: '租户' } }, + application: { name: { contains: '应用' } }, + name: { contains: '签名' }, + drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }), + }), + select: expect.objectContaining({ + materials: true, + tenant: { select: { id: true, name: true, code: true, status: true } }, + application: { select: { id: true, tenantId: true, name: true, status: true } }, + drainageItems: expect.objectContaining({ + where: { auditStatus: { not: 'deleted' } }, + orderBy: { updatedAt: 'desc' }, + }), + reportTasks: expect.objectContaining({ select: expect.any(Object) }), + reportBatchItems: expect.any(Object), + }), + orderBy: { createdAt: 'desc' }, }), - select: expect.objectContaining({ - materials: true, - tenant: { select: { id: true, name: true, code: true, status: true } }, - application: { select: { id: true, tenantId: true, name: true, status: true } }, - drainageItems: expect.objectContaining({ where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }), - reportTasks: expect.objectContaining({ select: expect.any(Object) }), - reportBatchItems: expect.any(Object), - }), - orderBy: { createdAt: 'desc' }, - })); + ); }); it('merges report fields from every channel in the application channel groups', async () => { const prisma = createPrismaMock(); prisma.channelRouteRule.findMany.mockResolvedValue([ { - id: 'route-1', priority: 10, + id: 'route-1', + priority: 10, group: { - id: 'group-1', name: '默认通道组', + id: 'group-1', + name: '默认通道组', items: [ - { channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: false, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }, - { channel: { id: 'channel-2', code: 'CH-2', name: '通道二', reportFields: [{ status: 'active', required: true, reportType: 'both', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }, + { + channel: { + id: 'channel-1', + code: 'CH-1', + name: '通道一', + reportFields: [ + { + status: 'active', + required: false, + reportType: 'signature', + drainageField: { + id: 'field-1', + code: 'license', + name: '营业执照', + fieldType: 'file', + description: null, + status: 'active', + }, + }, + ], + }, + }, + { + channel: { + id: 'channel-2', + code: 'CH-2', + name: '通道二', + reportFields: [ + { + status: 'active', + required: true, + reportType: 'both', + drainageField: { + id: 'field-1', + code: 'license', + name: '营业执照', + fieldType: 'file', + description: null, + status: 'active', + }, + }, + ], + }, + }, ], }, }, @@ -840,17 +1112,33 @@ describe('SmsConfigService', () => { it('merges common report fields into every target channel requirement', async () => { const prisma = createPrismaMock(); - prisma.commonReportField.findMany.mockResolvedValue([{ - id: 'common-1', reportType: 'signature', required: true, status: 'active', - drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' }, - }]); - prisma.channelRouteRule.findMany.mockResolvedValue([{ - id: 'route-1', priority: 10, - group: { - id: 'group-1', name: '默认通道组', - items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [] } }], + prisma.commonReportField.findMany.mockResolvedValue([ + { + id: 'common-1', + reportType: 'signature', + required: true, + status: 'active', + drainageField: { + id: 'field-common', + code: 'creditCode', + name: '统一社会信用代码', + fieldType: 'string', + description: null, + status: 'active', + }, }, - }] as never); + ]); + prisma.channelRouteRule.findMany.mockResolvedValue([ + { + id: 'route-1', + priority: 10, + group: { + id: 'group-1', + name: '默认通道组', + items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [] } }], + }, + }, + ] as never); const service = new SmsConfigService(prisma as never); await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([ @@ -866,13 +1154,40 @@ describe('SmsConfigService', () => { it('removes channel sources from client report-field responses', async () => { const prisma = createPrismaMock(); - prisma.channelRouteRule.findMany.mockResolvedValue([{ - id: 'route-1', priority: 10, - group: { - id: 'group-1', name: '内部通道组', - items: [{ channel: { id: 'channel-secret', code: 'SECRET-CH', name: '内部通道', reportFields: [{ status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }], + prisma.channelRouteRule.findMany.mockResolvedValue([ + { + id: 'route-1', + priority: 10, + group: { + id: 'group-1', + name: '内部通道组', + items: [ + { + channel: { + id: 'channel-secret', + code: 'SECRET-CH', + name: '内部通道', + reportFields: [ + { + status: 'active', + required: true, + reportType: 'signature', + drainageField: { + id: 'field-1', + code: 'license', + name: '营业执照', + fieldType: 'file', + description: null, + status: 'active', + }, + }, + ], + }, + }, + ], + }, }, - }] as never); + ] as never); const service = new SmsConfigService(prisma as never); const fields = await service.getClientApplicationReportFields('app-1', 'signature'); @@ -885,28 +1200,64 @@ describe('SmsConfigService', () => { it('returns client signatures without report tasks, channels, or internal requirement snapshots', async () => { const prisma = createPrismaMock(); - prisma.smsSignature.findMany.mockResolvedValue([{ - id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: '通知', - auditStatus: 'rejected', rejectReason: '请补充资料', - drainageInfo: { signatureReportValues: { license: 'file-1' }, reportRequirements: [{ channelId: 'channel-secret', channelName: '内部通道' }] }, - createdAt: new Date('2026-07-16T01:00:00Z'), updatedAt: new Date('2026-07-16T02:00:00Z'), - application: { id: 'app-1', name: '应用A', status: 'active' }, - materials: [{ id: 'material-1', fileObjectId: 'file-1', materialType: 'license', title: '营业执照', description: null, createdAt: new Date() }], - drainageItems: [{ id: 'drainage-1', siteName: '官网', url: 'https://example.com', remark: null, reportValues: { owner: '企业A' }, auditStatus: 'pending', rejectReason: null, submittedAt: new Date(), reviewedAt: null, createdAt: new Date(), updatedAt: new Date() }], - _count: { reportMaterials: 2 }, - reportTasks: [{ channelId: 'channel-secret' }], - }] as never); + prisma.smsSignature.findMany.mockResolvedValue([ + { + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + name: '签名A', + purpose: '通知', + auditStatus: 'rejected', + rejectReason: '请补充资料', + drainageInfo: { + signatureReportValues: { license: 'file-1' }, + reportRequirements: [{ channelId: 'channel-secret', channelName: '内部通道' }], + }, + createdAt: new Date('2026-07-16T01:00:00Z'), + updatedAt: new Date('2026-07-16T02:00:00Z'), + application: { id: 'app-1', name: '应用A', status: 'active' }, + materials: [ + { + id: 'material-1', + fileObjectId: 'file-1', + materialType: 'license', + title: '营业执照', + description: null, + createdAt: new Date(), + }, + ], + drainageItems: [ + { + id: 'drainage-1', + siteName: '官网', + url: 'https://example.com', + remark: null, + reportValues: { owner: '企业A' }, + auditStatus: 'pending', + rejectReason: null, + submittedAt: new Date(), + reviewedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + _count: { reportMaterials: 2 }, + reportTasks: [{ channelId: 'channel-secret' }], + }, + ] as never); const service = new SmsConfigService(prisma as never); const result = await service.listClientSignatures('tenant-1'); const serialized = JSON.stringify(result); - expect(result[0]).toEqual(expect.objectContaining({ - id: 'sig-1', - submittedMaterialCount: 3, - reportValues: { license: 'file-1' }, - drainageInfo: { links: [expect.objectContaining({ id: 'drainage-1', siteName: '官网' })] }, - })); + expect(result[0]).toEqual( + expect.objectContaining({ + id: 'sig-1', + submittedMaterialCount: 3, + reportValues: { license: 'file-1' }, + drainageInfo: { links: [expect.objectContaining({ id: 'drainage-1', siteName: '官网' })] }, + }), + ); expect(serialized).not.toContain('channel-secret'); expect(serialized).not.toContain('内部通道'); expect(result[0]).not.toHaveProperty('reportTasks'); @@ -931,9 +1282,11 @@ describe('SmsConfigService', () => { page: 1, pageSize: 10, }); - expect(prisma.smsSignature.groupBy).toHaveBeenCalledWith(expect.objectContaining({ - where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } }, - })); + expect(prisma.smsSignature.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } }, + }), + ); }); it('selects client drainage information without internal tasks or channels', async () => { @@ -941,7 +1294,9 @@ describe('SmsConfigService', () => { prisma.smsDrainageInfo.findMany.mockResolvedValue([{ id: 'drainage-1', siteName: '官网' }] as never); const service = new SmsConfigService(prisma as never); - await expect(service.listClientDrainageInfos('tenant-1')).resolves.toEqual([{ id: 'drainage-1', siteName: '官网' }]); + await expect(service.listClientDrainageInfos('tenant-1')).resolves.toEqual([ + { id: 'drainage-1', siteName: '官网' }, + ]); const query = prisma.smsDrainageInfo.findMany.mock.calls[0][0]; expect(query.where).toEqual({ id: undefined, tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }); expect(query.select).not.toHaveProperty('reportTasks'); @@ -950,50 +1305,120 @@ describe('SmsConfigService', () => { it('requires common signature fields even when a signature is not bound to an application', async () => { const prisma = createPrismaMock(); - prisma.commonReportField.findMany.mockResolvedValue([{ - id: 'common-1', reportType: 'signature', required: true, status: 'active', - drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' }, - }]); + prisma.commonReportField.findMany.mockResolvedValue([ + { + id: 'common-1', + reportType: 'signature', + required: true, + status: 'active', + drainageField: { + id: 'field-common', + code: 'creditCode', + name: '统一社会信用代码', + fieldType: 'string', + description: null, + status: 'active', + }, + }, + ]); const service = new SmsConfigService(prisma as never); - await expect(service.createSignature({ tenantId: 'tenant-1', name: '无应用签名' })) - .rejects.toThrow('缺少必填签名报备资料:统一社会信用代码'); + await expect(service.createSignature({ tenantId: 'tenant-1', name: '无应用签名' })).rejects.toThrow( + '缺少必填签名报备资料:统一社会信用代码', + ); expect(prisma.smsSignature.create).not.toHaveBeenCalled(); }); it('requires common drainage fields when adding drainage info without an application', async () => { const prisma = createPrismaMock(); - prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null, auditStatus: 'approved' }); - prisma.commonReportField.findMany.mockResolvedValue([{ - id: 'common-2', reportType: 'drainage', required: true, status: 'active', - drainageField: { id: 'field-site', code: 'siteOwner', name: '网站主体', fieldType: 'string', description: null, status: 'active' }, - }]); + prisma.smsSignature.findUnique.mockResolvedValue({ + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: null, + auditStatus: 'approved', + }); + prisma.commonReportField.findMany.mockResolvedValue([ + { + id: 'common-2', + reportType: 'drainage', + required: true, + status: 'active', + drainageField: { + id: 'field-site', + code: 'siteOwner', + name: '网站主体', + fieldType: 'string', + description: null, + status: 'active', + }, + }, + ]); const service = new SmsConfigService(prisma as never); - await expect(service.createDrainageInfo('sig-1', { url: 'https://example.com', reportValues: {} }, {}, 'tenant-1')) - .rejects.toThrow('引流信息缺少必填报备资料:网站主体'); + await expect( + service.createDrainageInfo('sig-1', { url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'), + ).rejects.toThrow('引流信息缺少必填报备资料:网站主体'); expect(prisma.smsDrainageInfo.create).not.toHaveBeenCalled(); }); it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => { const prisma = createPrismaMock(); - prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' }); - prisma.smsSignature.update.mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data })); - prisma.channelRouteRule.findMany.mockResolvedValue([{ - id: 'route-1', priority: 10, - group: { - id: 'group-1', name: '默认通道组', - items: [{ - channel: { - id: 'channel-1', code: 'CH-1', name: '通道一', - reportFields: [ - { status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }, - { status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'text', description: null, status: 'active' } }, - ], - }, - }], + prisma.smsSignature.findUnique.mockResolvedValue({ + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + auditStatus: 'draft', + }); + prisma.smsSignature.update.mockImplementation(({ data }) => + Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data }), + ); + prisma.channelRouteRule.findMany.mockResolvedValue([ + { + id: 'route-1', + priority: 10, + group: { + id: 'group-1', + name: '默认通道组', + items: [ + { + channel: { + id: 'channel-1', + code: 'CH-1', + name: '通道一', + reportFields: [ + { + status: 'active', + required: true, + reportType: 'signature', + drainageField: { + id: 'field-1', + code: 'license', + name: '营业执照', + fieldType: 'file', + description: null, + status: 'active', + }, + }, + { + status: 'active', + required: true, + reportType: 'drainage', + drainageField: { + id: 'field-2', + code: 'site_owner', + name: '网站主体', + fieldType: 'text', + description: null, + status: 'active', + }, + }, + ], + }, + }, + ], + }, }, - }] as never); + ] as never); const service = new SmsConfigService(prisma as never); await service.updateSignature('sig-1', { @@ -1004,26 +1429,44 @@ describe('SmsConfigService', () => { }, }); - expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({ - create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fileObjectId: 'file-1' }), - })); + expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + signatureId: 'sig-1', + channelId: 'channel-1', + fieldCode: 'license', + fileObjectId: 'file-1', + }), + }), + ); expect(prisma.drainageReportMaterial.create).not.toHaveBeenCalled(); expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); }); it('creates client drainage info as pending without generating channel report tasks', async () => { const prisma = createPrismaMock(); - prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' }); + prisma.smsSignature.findUnique.mockResolvedValue({ + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + auditStatus: 'approved', + }); prisma.channelRouteRule.findMany.mockResolvedValue([]); + prisma.$transaction.mockImplementation((callback) => callback(prisma)); const service = new SmsConfigService(prisma as never); - await expect(service.createDrainageInfo('sig-1', { url: '13800138000', reportValues: {} }, {}, 'tenant-1')) - .resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' })); + await expect( + service.createDrainageInfo('sig-1', { url: '13800138000', reportValues: {} }, {}, 'tenant-1'), + ).resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' })); - expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ siteName: '13800138000', url: '13800138000' }), - })); - expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }) }); + expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ siteName: '13800138000', url: '13800138000' }), + }), + ); + expect(prisma.auditRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }), + }); expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); }); @@ -1036,31 +1479,70 @@ describe('SmsConfigService', () => { for (const status of ['deleted', 'all', 'approved']) { await service.getClientSignatureWorkspace('tenant-1', { status }); const filter = { notIn: ['deleted', 'disabled'], ...(status !== 'all' ? { equals: status } : {}) }; - expect(prisma.smsSignature.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: filter }) })); - expect(prisma.smsSignature.count).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ auditStatus: filter }) })); + expect(prisma.smsSignature.findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: filter }) }), + ); + expect(prisma.smsSignature.count).toHaveBeenLastCalledWith( + expect.objectContaining({ where: expect.objectContaining({ auditStatus: filter }) }), + ); } await service.listClientTemplates('tenant-1', true); - expect(prisma.smsTemplate.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }) })); + expect(prisma.smsTemplate.findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }), + }), + ); await service.listTemplatesPage({ tenantId: 'tenant-1', status: 'all', page: 1, pageSize: 10 }); - expect(prisma.smsTemplate.count).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ auditStatus: { not: 'deleted' } }) })); - prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-deleted', tenantId: 'tenant-1', auditStatus: 'deleted' } as never); - prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-deleted', tenantId: 'tenant-1', auditStatus: 'deleted' } as never); + expect(prisma.smsTemplate.count).toHaveBeenLastCalledWith( + expect.objectContaining({ where: expect.objectContaining({ auditStatus: { not: 'deleted' } }) }), + ); + prisma.smsSignature.findUnique.mockResolvedValue({ + id: 'sig-deleted', + tenantId: 'tenant-1', + auditStatus: 'deleted', + } as never); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-deleted', + tenantId: 'tenant-1', + auditStatus: 'deleted', + } as never); await expect(service.submitSignature('sig-deleted', 'tenant-1')).rejects.toThrow('not found'); await expect(service.submitTemplate('tpl-deleted', 'tenant-1')).rejects.toThrow('not found'); }); it('uses exactly the same editable common fields in client and admin signature forms', async () => { const prisma = createPrismaMock(); - const field = { id: 'field-common', code: 'license', name: '主体证明', fieldType: 'file', description: '最新配置', status: 'active' }; - prisma.commonReportField.findMany.mockResolvedValue([{ id: 'common-1', reportType: 'signature', required: true, drainageField: field }]); + const field = { + id: 'field-common', + code: 'license', + name: '主体证明', + fieldType: 'file', + description: '最新配置', + status: 'active', + }; + prisma.commonReportField.findMany.mockResolvedValue([ + { id: 'common-1', reportType: 'signature', required: true, drainageField: field }, + ]); const service = new SmsConfigService(prisma as never); for (const applicationId of [undefined, 'app-1']) { const admin = await service.getApplicationReportFields(applicationId, 'signature'); const client = await service.getClientApplicationReportFields(applicationId, 'signature'); - expect(client).toEqual(admin.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => !['channels', 'commonReportTypes'].includes(key))))); - expect(client[0]).toMatchObject({ code: 'license', name: '主体证明', fieldType: 'file', required: true, description: '最新配置' }); + expect(client).toEqual( + admin.map((item) => + Object.fromEntries(Object.entries(item).filter(([key]) => !['channels', 'commonReportTypes'].includes(key))), + ), + ); + expect(client[0]).toMatchObject({ + code: 'license', + name: '主体证明', + fieldType: 'file', + required: true, + description: '最新配置', + }); } - prisma.commonReportField.findMany.mockResolvedValue([{ id: 'common-1', reportType: 'signature', required: false, drainageField: field }]); + prisma.commonReportField.findMany.mockResolvedValue([ + { id: 'common-1', reportType: 'signature', required: false, drainageField: field }, + ]); expect((await service.getClientApplicationReportFields(undefined, 'signature'))[0].required).toBe(false); }); @@ -1071,64 +1553,112 @@ describe('SmsConfigService', () => { { reportType: 'signature', required: true, drainageField: field }, { reportType: 'drainage', required: false, drainageField: field }, ]); - prisma.channelRouteRule.findMany.mockResolvedValue([{ group: { id: 'group-1', items: [{ channel: { id: 'deleted-channel', status: 'deleted', reportFields: [{ status: 'active', reportType: 'signature', required: true, drainageField: { ...field, id: 'old', code: 'old' } }] } }] } }] as never); + prisma.channelRouteRule.findMany.mockResolvedValue([ + { + group: { + id: 'group-1', + items: [ + { + channel: { + id: 'deleted-channel', + status: 'deleted', + reportFields: [ + { + status: 'active', + reportType: 'signature', + required: true, + drainageField: { ...field, id: 'old', code: 'old' }, + }, + ], + }, + }, + ], + }, + }, + ] as never); const result = await new SmsConfigService(prisma as never).getApplicationReportFields('app-1'); expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ required: true, reportTypes: ['signature', 'drainage'], commonReportTypes: ['signature', 'drainage'], channels: [] }); + expect(result[0]).toMatchObject({ + required: true, + reportTypes: ['signature', 'drainage'], + commonReportTypes: ['signature', 'drainage'], + channels: [], + }); }); it('accepts a scheme-less drainage URL and synchronizes the compatibility name', async () => { const prisma = createPrismaMock(); - prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' }); + prisma.smsSignature.findUnique.mockResolvedValue({ + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + auditStatus: 'approved', + }); prisma.channelRouteRule.findMany.mockResolvedValue([]); + prisma.$transaction.mockImplementation((callback) => callback(prisma)); const service = new SmsConfigService(prisma as never); - await expect(service.createDrainageInfo('sig-1', { url: 'example.com/path', reportValues: {} }, {}, 'tenant-1')) - .resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' })); + await expect( + service.createDrainageInfo('sig-1', { url: 'example.com/path', reportValues: {} }, {}, 'tenant-1'), + ).resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' })); - expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ siteName: 'example.com/path', url: 'example.com/path' }), - })); + expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ siteName: 'example.com/path', url: 'example.com/path' }), + }), + ); }); it('resets an approved signature to pending when key content is changed', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ - id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】', - purpose: '通知', drainageInfo: {}, auditStatus: 'approved', + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + name: '【旧签名】', + purpose: '通知', + drainageInfo: {}, + auditStatus: 'approved', }); const service = new SmsConfigService(prisma as never); await service.updateSignature('sig-1', { name: '【新签名】' }); - expect(prisma.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ auditStatus: 'pending', rejectReason: null }), - })); + expect(prisma.smsSignature.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ auditStatus: 'pending', rejectReason: null }), + }), + ); }); it('keeps an operator-edited signature approved and records the operator audit', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ - id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】', - purpose: '通知', drainageInfo: {}, auditStatus: 'approved', + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + name: '【旧签名】', + purpose: '通知', + drainageInfo: {}, + auditStatus: 'approved', }); const service = new SmsConfigService(prisma as never); - const result = await service.updateSignature( - 'sig-1', - { name: '【新签名】' }, - undefined, - { initialAuditStatus: 'approved', reviewerId: 'operator-1' }, - ); + const result = await service.updateSignature('sig-1', { name: '【新签名】' }, undefined, { + initialAuditStatus: 'approved', + reviewerId: 'operator-1', + }); - expect(prisma.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ - auditStatus: 'approved', - rejectReason: null, - materialVersion: { increment: 1 }, - pendingReport: true, + expect(prisma.smsSignature.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + auditStatus: 'approved', + rejectReason: null, + materialVersion: { increment: 1 }, + pendingReport: true, + }), }), - })); + ); expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_update_approved', @@ -1137,18 +1667,26 @@ describe('SmsConfigService', () => { statusAfter: 'approved', }), }); - expect(result).toEqual(expect.objectContaining({ - auditStatus: 'approved', - reportMaterialChanged: true, - reportPoolAvailableAfter: 'immediate', - })); + expect(result).toEqual( + expect.objectContaining({ + auditStatus: 'approved', + reportMaterialChanged: true, + reportPoolAvailableAfter: 'immediate', + }), + ); }); it('resets an approved template to pending when key content is changed', async () => { const prisma = createPrismaMock(); prisma.smsTemplate.findUnique.mockResolvedValue({ - id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', - name: '模板A', content: '【签名A】验证码${code}', category: '验证码', auditStatus: 'approved', + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '模板A', + content: '【签名A】验证码${code}', + category: '验证码', + auditStatus: 'approved', }); const tx = { templateVariable: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) }, @@ -1159,41 +1697,102 @@ describe('SmsConfigService', () => { await service.updateTemplate('tpl-1', { content: '【签名A】您的验证码为${code}' }); - expect(tx.smsTemplate.update).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ auditStatus: 'pending' }), - })); + expect(tx.smsTemplate.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ auditStatus: 'pending' }), + }), + ); }); it('creates real drainage materials and channel tasks after operations approval', async () => { const prisma = createPrismaMock(); - const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending' }; - const approvedItem = { ...pendingItem, auditStatus: 'approved', signature: { id: 'sig-1', applicationId: 'app-1' } }; + const pendingItem = { + id: 'drainage-1', + tenantId: 'tenant-1', + signatureId: 'sig-1', + applicationId: 'app-1', + siteName: '官网', + url: 'https://example.com', + reportValues: { site_owner: '企业A' }, + auditStatus: 'pending', + }; + const approvedItem = { + ...pendingItem, + auditStatus: 'approved', + signature: { id: 'sig-1', applicationId: 'app-1' }, + }; prisma.smsDrainageInfo.findUnique.mockResolvedValueOnce(pendingItem).mockResolvedValueOnce(approvedItem); prisma.smsDrainageInfo.update.mockResolvedValue({ ...approvedItem, tenant: {}, application: {} }); - prisma.channelRouteRule.findMany.mockResolvedValue([{ - id: 'route-1', priority: 10, - group: { id: 'group-1', name: '默认通道组', items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'string', description: null, status: 'active' } }] } }] }, - }] as never); + prisma.channelRouteRule.findMany.mockResolvedValue([ + { + id: 'route-1', + priority: 10, + group: { + id: 'group-1', + name: '默认通道组', + items: [ + { + channel: { + id: 'channel-1', + code: 'CH-1', + name: '通道一', + reportFields: [ + { + status: 'active', + required: true, + reportType: 'drainage', + drainageField: { + id: 'field-2', + code: 'site_owner', + name: '网站主体', + fieldType: 'string', + description: null, + status: 'active', + }, + }, + ], + }, + }, + ], + }, + }, + ] as never); prisma.$transaction.mockImplementation((callback) => callback(prisma)); const service = new SmsConfigService(prisma as never); await service.approveDrainageInfo('drainage-1', {}); - expect(prisma.drainageReportMaterial.create).toHaveBeenCalledWith({ data: expect.objectContaining({ drainageItemId: 'drainage-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }) }); - expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reportType: 'drainage', drainageItemId: 'drainage-1', status: 'pending' }) }); - expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'audit_approved_create', statusAfter: 'pending' }) }); + expect(prisma.drainageReportMaterial.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + drainageItemId: 'drainage-1', + channelId: 'channel-1', + fieldCode: 'site_owner', + fieldValue: '企业A', + }), + }); + expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ reportType: 'drainage', drainageItemId: 'drainage-1', status: 'pending' }), + }); + expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: 'audit_approved_create', statusAfter: 'pending' }), + }); }); it('creates admin signatures with an approved initial audit status', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await service.createSignature({ tenantId: 'tenant-1', name: '【运营新建签名】' }, { initialAuditStatus: 'approved' }); + await service.createSignature( + { tenantId: 'tenant-1', name: '【运营新建签名】' }, + { initialAuditStatus: 'approved' }, + ); expect(prisma.smsSignature.create).toHaveBeenCalledWith({ data: expect.objectContaining({ auditStatus: 'approved', name: '【运营新建签名】' }), }); - expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) }); + expect(prisma.auditRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }), + }); }); it.each(['未带括号', '[英文括号]', '【【重复括号】】'])( @@ -1202,8 +1801,9 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.createSignature({ tenantId: 'tenant-1', name })) - .rejects.toThrow('短信签名必须包含完整中文黑括号,例如:【某某科技】'); + await expect(service.createSignature({ tenantId: 'tenant-1', name })).rejects.toThrow( + '短信签名必须包含完整中文黑括号,例如:【某某科技】', + ); expect(prisma.smsSignature.create).not.toHaveBeenCalled(); }, ); @@ -1212,27 +1812,40 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.updateSignature('sig-1', { name: '编辑无括号' })) - .rejects.toThrow('短信签名必须包含完整中文黑括号,例如:【某某科技】'); + await expect(service.updateSignature('sig-1', { name: '编辑无括号' })).rejects.toThrow( + '短信签名必须包含完整中文黑括号,例如:【某某科技】', + ); expect(prisma.smsSignature.update).not.toHaveBeenCalled(); }); it('accepts concurrent state callbacks for the same newly connected Gateway session', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ - id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1, - interfaceEnabled: true, status: 'active', ipAllowlist: [], + id: 'app-1', + tenantId: 'tenant-1', + cmppEnterpriseCode: 'APP-EC', + cmppMaxConnections: 1, + interfaceEnabled: true, + status: 'active', + ipAllowlist: [], }); prisma.cmppDownstreamConnection.findUnique.mockResolvedValue(null); prisma.cmppDownstreamConnection.findMany.mockResolvedValue([{ connectionId: 'gateway-current' }]); const service = new SmsConfigService(prisma as never); - await expect(service.recordDownstreamConnectionEvent({ - account: '100001', connectionId: 'gateway-current', status: 'submit', remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ connectionId: 'gateway-current' })); - expect(prisma.cmppDownstreamConnection.upsert).toHaveBeenCalledWith(expect.objectContaining({ - where: { connectionId: 'gateway-current' }, - })); + await expect( + service.recordDownstreamConnectionEvent({ + account: '100001', + connectionId: 'gateway-current', + status: 'submit', + remoteIp: '127.0.0.1', + }), + ).resolves.toEqual(expect.objectContaining({ connectionId: 'gateway-current' })); + expect(prisma.cmppDownstreamConnection.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { connectionId: 'gateway-current' }, + }), + ); }); it('applies the audit submission range to signatures, templates and drainage records', async () => { @@ -1248,9 +1861,15 @@ describe('SmsConfigService', () => { await service.listTemplates(query); await service.listDrainageInfos(query); - expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ updatedAt: expectedRange }) })); - expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ createdAt: expectedRange }) })); - expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) })); + expect(prisma.smsSignature.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ updatedAt: expectedRange }) }), + ); + expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ createdAt: expectedRange }) }), + ); + expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) }), + ); }); it('returns a signature page summary without edit materials or report target arrays', async () => { @@ -1323,7 +1942,12 @@ describe('SmsConfigService', () => { expect(result.pendingReportDetailTotal).toBe(expectedTotal); expect(prisma.smsSignature.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { AND: [expect.objectContaining({ name: { contains: '测试' } }), { auditStatus: 'approved', pendingReport: true }] }, + where: { + AND: [ + expect.objectContaining({ name: { contains: '测试' } }), + { auditStatus: 'approved', pendingReport: true }, + ], + }, }), ); }); @@ -1340,8 +1964,9 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.createSignature({ tenantId: 'tenant-1', name })) - .rejects.toThrow('短信签名不能包含空格、换行或不可见字符'); + await expect(service.createSignature({ tenantId: 'tenant-1', name })).rejects.toThrow( + '短信签名不能包含空格、换行或不可见字符', + ); expect(prisma.smsSignature.create).not.toHaveBeenCalled(); }); @@ -1349,18 +1974,22 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.updateSignature('sig-1', { - name: '【签名B】', - auditStatus: 'approved', - drainageInfo: { - carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' }, - links: [{ id: 'drain-1', siteName: '官网', url: 'https://example.com' }], - }, - })).resolves.toEqual(expect.objectContaining({ - id: 'sig-1', - name: '【签名B】', - auditStatus: 'approved', - })); + await expect( + service.updateSignature('sig-1', { + name: '【签名B】', + auditStatus: 'approved', + drainageInfo: { + carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' }, + links: [{ id: 'drain-1', siteName: '官网', url: 'https://example.com' }], + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + id: 'sig-1', + name: '【签名B】', + auditStatus: 'approved', + }), + ); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, @@ -1379,7 +2008,14 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.listTemplates({ enterpriseKeyword: '租户', applicationKeyword: '应用', nameKeyword: '模板', contentKeyword: '验证码' })).resolves.toEqual([ + await expect( + service.listTemplates({ + enterpriseKeyword: '租户', + applicationKeyword: '应用', + nameKeyword: '模板', + contentKeyword: '验证码', + }), + ).resolves.toEqual([ expect.objectContaining({ id: 'tpl-1', tenant: expect.objectContaining({ name: '租户A' }), @@ -1387,16 +2023,18 @@ describe('SmsConfigService', () => { signature: expect.objectContaining({ name: '签名A' }), }), ]); - expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - auditStatus: { not: 'deleted' }, - tenant: { name: { contains: '租户' } }, - application: { name: { contains: '应用' } }, - name: { contains: '模板' }, - content: { contains: '验证码' }, + expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + auditStatus: { not: 'deleted' }, + tenant: { name: { contains: '租户' } }, + application: { name: { contains: '应用' } }, + name: { contains: '模板' }, + content: { contains: '验证码' }, + }), + include: { variables: true, application: true, tenant: true, signature: true }, }), - include: { variables: true, application: true, tenant: true, signature: true }, - })); + ); }); it('returns only approved templates from the client send-candidate view by default', async () => { @@ -1405,38 +2043,47 @@ describe('SmsConfigService', () => { await service.listClientTemplates('tenant-1'); - expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: 'approved' }), - })); + expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: 'approved' }), + }), + ); }); it('creates admin enterprise templates as approved when requested', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.createTemplate({ - tenantId: 'tenant-1', - applicationId: 'app-1', - signatureId: 'sig-1', - name: '运营添加模板', - content: '【签名A】您的验证码为${code}', - variables: [{ name: 'code', example: '123456', required: true }], - }, { initialAuditStatus: 'approved' })).resolves.toEqual(expect.objectContaining({ id: 'tpl-new' })); - - expect(prisma.smsTemplate.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ - tenantId: 'tenant-1', - applicationId: 'app-1', - signatureId: 'sig-1', - name: '运营添加模板', - content: '【签名A】您的验证码为${code}', - auditStatus: 'approved', - variables: { - create: [{ name: 'code', example: '123456', required: true }], + await expect( + service.createTemplate( + { + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '运营添加模板', + content: '【签名A】您的验证码为${code}', + variables: [{ name: 'code', example: '123456', required: true }], }, + { initialAuditStatus: 'approved' }, + ), + ).resolves.toEqual(expect.objectContaining({ id: 'tpl-new' })); + + expect(prisma.smsTemplate.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '运营添加模板', + content: '【签名A】您的验证码为${code}', + auditStatus: 'approved', + variables: { + create: [{ name: 'code', example: '123456', required: true }], + }, + }), + include: { variables: true, application: true, tenant: true, signature: true }, }), - include: { variables: true, application: true, tenant: true, signature: true }, - })); + ); }); it('updates enterprise templates and rebuilds template variables', async () => { @@ -1452,13 +2099,15 @@ describe('SmsConfigService', () => { prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); const service = new SmsConfigService(prisma as never); - await expect(service.updateTemplate('tpl-1', { - applicationId: 'app-1', - signatureId: 'sig-1', - name: '模板B', - content: '【签名A】验证码${code}', - variables: [{ name: 'code', example: '123456', required: true }], - })).resolves.toEqual(expect.objectContaining({ id: 'tpl-1', name: '模板B' })); + await expect( + service.updateTemplate('tpl-1', { + applicationId: 'app-1', + signatureId: 'sig-1', + name: '模板B', + content: '【签名A】验证码${code}', + variables: [{ name: 'code', example: '123456', required: true }], + }), + ).resolves.toEqual(expect.objectContaining({ id: 'tpl-1', name: '模板B' })); expect(tx.templateVariable.deleteMany).toHaveBeenCalledWith({ where: { templateId: 'tpl-1' } }); expect(tx.smsTemplate.update).toHaveBeenCalledWith({ @@ -1480,20 +2129,24 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.createTemplate({ - tenantId: 'tenant-1', - applicationId: 'app-1', - name: '缺少签名模板', - content: '您的验证码为${code}', - })).rejects.toThrow('短信模板必须选择短信签名'); + await expect( + service.createTemplate({ + tenantId: 'tenant-1', + applicationId: 'app-1', + name: '缺少签名模板', + content: '您的验证码为${code}', + }), + ).rejects.toThrow('短信模板必须选择短信签名'); - await expect(service.createTemplate({ - tenantId: 'tenant-1', - applicationId: 'app-1', - signatureId: 'sig-1', - name: '签名不匹配模板', - content: '【其他签名】您的验证码为${code}', - })).rejects.toThrow('模板内容必须以所选短信签名 【签名A】 开头'); + await expect( + service.createTemplate({ + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '签名不匹配模板', + content: '【其他签名】您的验证码为${code}', + }), + ).rejects.toThrow('模板内容必须以所选短信签名 【签名A】 开头'); expect(prisma.smsTemplate.create).not.toHaveBeenCalled(); }); @@ -1508,9 +2161,15 @@ describe('SmsConfigService', () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); - await expect(service.createTemplate({ - tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '非法模板', content, - })).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.createTemplate({ + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '非法模板', + content, + }), + ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.smsTemplate.create).not.toHaveBeenCalled(); }); }); diff --git a/docs/drainage-send-gating-plan-20260910.md b/docs/drainage-send-gating-plan-20260910.md index b79f11c..8990a07 100644 --- a/docs/drainage-send-gating-plan-20260910.md +++ b/docs/drainage-send-gating-plan-20260910.md @@ -177,3 +177,19 @@ Gateway 每个分片等待可用连接后通过仅本机直连的 POST /api/gate 新增迁移 20260910130000_drainage_send_gate 只加列、索引、决策表及锁触发器,不改既有批准/客户/余额/短信记录。测试发布按标准工具执行,包含此前九项运营修复提交;回退旧程序会失去本门禁,不能未经评估恢复发送。原治理工具草稿和备份/候选均保留。 验证:独立本机 PostgreSQL 克隆库完成新迁移,真实规则/API验证 NFKC号码、全部目标交集、审核撤销、报备撤销、并发锁等待、URL三种伪装拒绝、决策持久化及报备SQL。真实浏览器连接该API验证拦截详情、刷新、路由切换和1600×1000、1366×768、390×844;无Browser插件,使用既有Playwright/Edge。发送Worker与Gateway传输未启动,不以这些证据替代供应商零Submit、客户回执ACK、长短信物理发送、费用对账或容量测试,以上须专项发送授权后验证。自动回归及发布结果以 testing-progress.md 最新记录为准。 + + +## 11. 2026-09-14 引流唯一性与通道运营商报备设计 + +状态:本地实现与隔离验收完成;线上未部署,版本状态以 testing-progress.md 本轮记录为准。此节替代引流报备只按channelId及carrier=null通配的新增配置方式;域名匹配、平台审核、多目标交集、计费和Gateway复核协议保持。 + +1. 新增/修改及恢复引流:同一signatureId下未删除资料的引流值不得重复。按登记值trim比较,不把不同URL路径、协议或电话号码格式擅自合并;不同签名可相同。修改自身原值允许,已有重复记录不自动删除或合并,变更为其他已占用值拒绝。共同使用既有签名advisory事务锁,检查和写入同事务,覆盖管理端/客户端及并发请求;返回可读400。 +2. 新引流报备任务键为signatureId+drainageItemId+channelId+carrier,carrier为通道支持的mobile/unicom/telecom;复用已有carrier/approvalScope字段,无新表。页面与签名一样三网分组,按通道与运营商编辑;后端校验通道范围、引流归属及审核状态,返回相同维度并参与统计。新增或修改材料后,各适用运营商独立回到pending,移出通道/运营商及旧无运营商任务不继续保留旧授权。 +3. 历史carrier=null任务不批量迁移或猜测运营商;在尚无明确运营商任务时保留原通道级兼容读法,并在页面标记历史通道级继承。某运营商已有明确任务时,无论通过/失败/未报备均优先,不回落旧通过状态;运营人员保存后建立明确三网任务。旧记录保留审计,跨运营商不能互相覆盖。 +4. 路由、最终Gateway授权、签名卡片汇总、通道报备明细、批次目标/导出及按批次状态更新共用运营商语义。每个引流目标的通道交集按本条短信运营商计算;显式失败不得被旧carrier=null通过记录放行。批次按carrier业务键生成,旧all/legacy批次只保留原范围兼容,不把单运营商导出/状态结果扩散到其他运营商。 +5. 本次另外核验HTTP IP白名单英文逗号已受支持,补输入说明和回归;发送详情仅展示敏感词命中/明确异常,不展示正常零命中快照。数据库审计不删除,实际失败原因保持。 +6. 验收:并发同值新增/修改、自身/其他签名/已删除值、混合三网状态与历史覆盖、材料修改全部状态失效、真实PG/API与浏览器三尺寸、API/前端定向与全量、类型构建/质量门禁。无发送、重投、线上配置修改、推送或部署授权;本地隔离真实后端可验证配置及只读路由判断,物理短信链路不冒称通过。 + +### 11.1 实际数据库约束补核 + +真实隔离库复现原ChannelSignatureReportTask_drainage_target_key是签名+引流+通道部分唯一索引(Prisma模型未声明此部分约束)。必须新增20260914093000_drainage_carrier_reports,在同事务建立carrier非空四维唯一索引及carrier为空历史三维唯一索引,再移除旧三维索引;不改旧数据/状态。新索引同样防止状态保存与导出并发产生重复任务。迁移仅在独立验收库执行,发布后方能启用新代码;不能回退旧程序继续发送并把三网任务当通道级读取。应用回退需暂停发送并评估三网事实,不能删除新任务或直接重建旧唯一索引。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 10788c8..5676f8e 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2293,3 +2293,12 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后 ### HTTP公共接口整改验收状态(2026-09-14) 上述Webhook DNS、IPv6 URL、严格日历与正文错误边界已在测试版本97d1334完成真实验收;不改变既定业务规则、计费或租户边界。高精度小数秒输入保留兼容,上行详情仅客户业务字段,禁止通道和内部匹配字段。详见 [验收报告](http-api-full-acceptance-20260914.md),生产环境状态不可由测试结论替代。 + + +## 2026-09-14 引流信息与运营商报备补充 + +- HTTP 接口 IP 白名单支持英文逗号、中文逗号和空白分隔多个 IP/CIDR,编辑说明必须明确。 +- 同一签名下未删除引流资料的登记值(去除首尾空白)唯一;新增、编辑及状态恢复均不可绕过,并发请求最多一条成功。不同签名可使用相同值,自身原值可保留,既有重复不自动清理。 +- 引流报备按引流资料、通道、运营商独立配置,只有本运营商通过的通道可进入对应短信路由;显式未通过不能继承旧通道级通过状态。材料变化使旧审批失效。 +- 发送详情保留敏感词命中及明确异常,不显示正常的零命中检查;审计数据保留。 +- 兼容、迁移和验收以 [引流门禁方案第 11 节](drainage-send-gating-plan-20260910.md#11-2026-09-14-引流唯一性与通道运营商报备设计) 为准。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 35460a5..2a35cff 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5488,3 +5488,21 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转 完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。 新增覆盖:TC-HTTP-FULL-DATE(非法日历/query/cursor与时区、高精度兼容)、TC-HTTP-FULL-BODY(畸形/非对象/超限/字符集/编码的400/413/415及关联ID)、TC-HTTP-FULL-IPV6(回环/ULA/link-local/mapped私网拒绝且配置不变)、TC-HTTP-FULL-ROTATE(新密钥真实签名成功);记录与断言名称逐项见报告附录。 + + +## 2026-09-14 引流唯一性、三网报备及详情用例 + +| 编号 | 场景与预期 | +|---|---| +| DRN-CARRIER-01 | HTTP 白名单混合英文逗号、中文逗号、换行及空格分隔 IP/CIDR,保存后逐项正确回读。 | +| DRN-CARRIER-02 | 同签名重复新增及编辑撞值返回 400;前后空白不绕过;跨签名同值、自身原值和已删除值可使用。 | +| DRN-CARRIER-03 | 同签名五请求并发新增同值只有一条成功;两条资料并发改为同值只有一条成功;恢复已删除资料不得造成重复。 | +| DRN-CARRIER-04 | 单个三网通道分别保存移动通过、联通失败、电信未报备,刷新/报备明细/汇总维度一致;仅移动路由放行。 | +| DRN-CARRIER-05 | 有旧 carrier=null 通过记录时,明确运营商失败仍拒绝;无明确任务的运营商继承历史状态并标注来源。 | +| DRN-CARRIER-06 | 材料更新立即失效所有旧审批,各适用运营商回到未报备;旧通道级审批不可继续授权。 | +| DRN-CARRIER-07 | 批次目标、导出任务及批次状态更新保留运营商,单运营商结果不得覆盖其他运营商;旧 all/legacy 批次仅作用原范围。 | +| DRN-CARRIER-08 | 实际旧索引迁移保留旧审批所有字段,允许三网独立记录,拒绝同运营商和旧通道级重复;不自动改线上数据。 | +| DRN-CARRIER-09 | 发送详情正常零命中快照不可见,真实命中和明确失败仍可见,数据库快照数量不变。 | +| DRN-CARRIER-10 | 三尺寸首次打开、保存、刷新、切换详情;检查真实响应及数据库,不把隔离服务适配器视为完整认证/Gateway 验收。 | + +执行结果与未执行边界见 testing-progress.md 本日记录。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index c92927f..03a18d0 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4971,3 +4971,29 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转 截至 2026-09-14T07:49:14.156Z,测试环境精确版本97d133442350b9725422ed4e55386f47e37004fd。HTTP-FULL-B01至B04已修复、提交、推送、标准发布并真实复验;74套809项精确候选测试、格式、Lint、类型、构建及安全门禁通过。235项真实请求/断言中229项通过,另6条原始非通过记录已分类并有复测,不删除失败历史。20条短信19送达/1预期失败退款,23个模拟CMPP Submit,21成功计费单位,净扣6825,余额1848101→1841276。7条上行3歧义隐藏/4匹配且ACK完成;24个Webhook事件22送达/2预设终止,31次真实HTTPS收件,签名、密钥轮换、状态码、退避、超时和人工重试均核验。三个Redis Stream pending/lag均0;本轮待办排空、三个应用停用/凭据撤销、receiver及隧道关闭、hosts原字节恢复。未操作预生产、真实运营商或其他客户配置。 完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。 + + +## 2026-09-14 四项配置与引流运营商报备修复(本地提交范围) + +### 范围与只读证据 + +- 起点本地 main 与实际远端 main 均为 ac6449028c4ab072ef16a219f61d592e2d453a7e,起始暂存为空。保护既有 metrics、tools/release、HTTP 接入及需求/测试/发布草稿,不推送、不部署、不发送/补发/重投/入队,不修改线上配置或业务数据。 +- 2026-09-14 17:11:47(北京时间)只读核验预生产应用 d13ca0713abd6afbea5a62af39bcbb876b8bb186。号码 188****3795 的 MSG-b3b529de-602c-44a8-9cc9-bce3a5a8360f 在 15:39:30 至 15:41:51 有 10 条路由敏感词快照,全部 hits=0、reason=null;多轮候选选择均保存快照,详情逐条显示正常结果导致噪声。只过滤展示,审计/短信状态保持。首次只读查询使用不存在的 createdAt 后改用 queuedAt;未进行数据修复或发送。 +- HTTP 白名单原解析已支持英文逗号,本轮明确输入说明并补混合分隔符回归。引流新增/编辑此前缺少同签名重复校验;通过同签名 advisory 事务锁将检查与写入串行化,状态恢复亦校验。 +- 原引流任务按通道级 carrier=null 管理,现按通道×运营商管理,并更新路由、签名汇总、报备明细、批次/导出和状态更新。明确运营商结果优先于旧通道级状态,材料变化使旧审批失效。设计先更新于 drainage-send-gating-plan-20260910.md 第 11 节。 +- 真实 PostgreSQL 首轮创建暴露旧部分唯一索引仍限制三维键,新增 20260914093000_drainage_carrier_reports,保留全部旧记录,改为明确运营商与历史通道级两个部分唯一索引。新迁移仅在本轮独立本地库执行。 + +### 已执行验证 + +- API 全量:74 suites / 811 tests;API TypeScript 生产构建通过。 +- 前端全量最终:30 files / 144 tests(npx vitest run --maxWorkers=2);TypeScript 与 production 构建通过。最初新增三网测试发现 Select 未传递 aria 名称,修复公共组件并删除关闭时无用的 portalStyle 状态重置,覆盖三网选择与重新打开。另一轮并行构建/测试发生 17 项超时及关联断言失败,保留原日志;限制 worker 后及最终稳定代码两次全量通过,未提高超时或删除断言。 +- npm run lint、format:check、quality:verify、style:check、css:verify(15 tests)、security:verify、bundle:verify 通过;lint 留存 3 条非阻断提示(原报备页 effect 依赖、IP 解析函数导出、测试 any)。git diff --check 通过。原未格式化测试/服务文件随当前格式门禁格式化,无业务扩展。 +- tools/testing/verify-drainage-carriers.mjs:独立 loopback PostgreSQL 16414 / cmpp_qa_carriers_20260914,实际服务 HTTP 适配器 16416,25 项通过。覆盖迁移旧行完全保留及两类唯一约束、trim 重复、跨签名、自身修改、5 请求并发新增、并发改值、删除值复用及恢复防绕过、三网保存/汇总/路由、旧审批不覆盖明确失败、材料修改审批失效、批次目标与列表及 HTTP 白名单落库回读。一次新增数据扩充后列表断言受默认 10 条分页影响,限定验收签名并读取 100 条后通过。 +- 浏览器连接器本轮仍为 nodeRepl.fetch request failed;使用已安装 Playwright + Chrome。本地 Vite dev 入口加载超时后,改用 production 构建 + preview 16418,真实业务组件连接上述服务与 PG;1600×1000、1366×768、390×844 无横向溢出。三网分别保存(移动通过、联通失败、电信未报备)后刷新读取一致,重复值 HTTP 400,发送详情不显示零命中快照,切换弹窗与重新打开选择器通过。无框架错误;控制台仅验收入口 favicon 404 和故意触发的重复值 400。 +- 证据目录:%TEMP%/cmpp-drainage-carriers-20260914(preprod-records.json、api-full.log、frontend-final-stable.log、real-http-final2.log、real-fixture-final.json、ui-evidence.json、carrier-1600.png / carrier-1366.png / carrier-final-390.png 及质量日志)。保留初次失败和最终结果。 + +### 交付边界与遗留 + +- 本轮仅本地修改、文档和本地提交;未推送、未部署测试、未部署预生产。迁移和代码未在两套线上环境生效。提交号见本条记录所在提交;最终汇报提供精确 SHA。 +- 隔离 HTTP 适配器直接调用真实业务服务与 PG,不包含完整 Nest 全局认证、生产反向代理和 worker;不得将其当作在线全功能验收。测试环境/预生产完整登录页面、权限与租户隔离在线回归、MinIO 报备文件导出/导入实物、Redis/Gateway 实际短信发送与计费闭环本轮未执行。原链路单元回归通过不替代物理发送专项验收。 +- 53 项已有保护文件在最终核对中保持摘要(仅本轮文档采用追加并精确暂存);其余脏文件和草稿不纳入提交。历史重复资料不自动清理,历史通道级审批不批量重写。上线须先按标准发布流程执行新索引迁移,回退不可直接删除三网任务或重建旧索引。 diff --git a/src/api/types/identity-config.ts b/src/api/types/identity-config.ts index 1205f7a..5ce31d8 100644 --- a/src/api/types/identity-config.ts +++ b/src/api/types/identity-config.ts @@ -297,7 +297,14 @@ export type ClientSmsSignature = { carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; drainageReportTargets?: Record< string, - Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }> + Array<{ + channel: AdminChannel; + channelId: string; + carrier: 'mobile' | 'unicom' | 'telecom'; + status: string; + taskId?: string; + approvalScope?: string; + }> >; drainageCarrierReportSummary?: Record< string, diff --git a/src/apps/admin/AdminReportTasksPage.tsx b/src/apps/admin/AdminReportTasksPage.tsx index 5c1dab3..3e6932a 100644 --- a/src/apps/admin/AdminReportTasksPage.tsx +++ b/src/apps/admin/AdminReportTasksPage.tsx @@ -347,11 +347,9 @@ export function AdminReportTasksPage() { render: (record) => (
{record.channel?.name ?? record.channelId} - {record.reportType !== 'drainage' ? ( -
- {record.carrier ? : '历史通道级(未拆分)'} -
- ) : null} +
+ {record.carrier ? : '历史通道级(未拆分)'} +
), }, diff --git a/src/apps/admin/AdminSmsApplicationFormPage.test.tsx b/src/apps/admin/AdminSmsApplicationFormPage.test.tsx index e257cf9..5527051 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.test.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; -import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage'; +import { AdminSmsApplicationFormPage, parseIpAllowlist } from './AdminSmsApplicationFormPage'; vi.mock('@/api/adminApi', () => ({ adminApi: { @@ -44,3 +44,12 @@ describe('application form feedback', () => { expect(dialog).toBeInTheDocument(); }); }); + +it('accepts comma separated HTTP IP entries including IPv6 and CIDR', () => { + expect(parseIpAllowlist('203.0.113.1, 203.0.113.0/24,2001:db8::1\n2001:db8::/64')).toEqual([ + '203.0.113.1', + '203.0.113.0/24', + '2001:db8::1', + '2001:db8::/64', + ]); +}); diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 65ced26..f30dc3c 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -595,7 +595,7 @@ export function AdminSmsApplicationFormPage() { setHttpIpAddress(event.target.value)} - placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" + placeholder="多个 IP/CIDR 可用英文逗号、中文逗号或空白分隔,留空表示不限制" value={httpIpAddress} /> item.trim()) diff --git a/src/apps/admin/enterprise-signatures/SignatureReportModals.test.tsx b/src/apps/admin/enterprise-signatures/SignatureReportModals.test.tsx new file mode 100644 index 0000000..28d1122 --- /dev/null +++ b/src/apps/admin/enterprise-signatures/SignatureReportModals.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { adminApi, type ClientSmsSignature } from '@/api/adminApi'; +import { DrainageReportStatusModal } from './SignatureReportModals'; +import type { DrainageInfo } from './signature.types'; +vi.mock('@/api/adminApi', () => ({ adminApi: { changeReportTaskStatuses: vi.fn().mockResolvedValue([]) } })); +describe('drainage report carrier form', () => { + it('submits three separate carrier decisions and keeps failures visible', async () => { + const item = { id: 'd', url: 'example.com' } as DrainageInfo; + const signature = { + id: 's', + name: '【测试】', + drainageReportTargets: { + d: ['mobile', 'unicom', 'telecom'].map((carrier) => ({ + channelId: 'c', + channel: { id: 'c', name: '三网通道' }, + carrier, + status: 'pending', + })), + }, + } as unknown as ClientSmsSignature; + const saved = vi.fn(); + render( {}} onSaved={saved} />); + fireEvent.click(screen.getByLabelText('三网通道移动报备状态')); + fireEvent.click(screen.getByRole('option', { name: '报备通过' })); + fireEvent.click(screen.getByLabelText('三网通道联通报备状态')); + fireEvent.click(screen.getByRole('option', { name: '报备失败' })); + fireEvent.click(screen.getByRole('button', { name: '保存状态' })); + await waitFor(() => expect(saved).toHaveBeenCalled()); + expect(vi.mocked(adminApi.changeReportTaskStatuses).mock.calls[0][0].items).toEqual([ + { + signatureId: 's', + drainageItemId: 'd', + reportType: 'drainage', + channelId: 'c', + carrier: 'mobile', + status: 'approved', + }, + { + signatureId: 's', + drainageItemId: 'd', + reportType: 'drainage', + channelId: 'c', + carrier: 'unicom', + status: 'failed', + }, + { + signatureId: 's', + drainageItemId: 'd', + reportType: 'drainage', + channelId: 'c', + carrier: 'telecom', + status: 'pending', + }, + ]); + }); +}); diff --git a/src/apps/admin/enterprise-signatures/SignatureReportModals.tsx b/src/apps/admin/enterprise-signatures/SignatureReportModals.tsx index 775e630..0c1ca8c 100644 --- a/src/apps/admin/enterprise-signatures/SignatureReportModals.tsx +++ b/src/apps/admin/enterprise-signatures/SignatureReportModals.tsx @@ -2,69 +2,280 @@ import { useState } from 'react'; import { Info } from 'lucide-react'; import { adminApi, type ClientSmsSignature } from '@/api/adminApi'; import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui'; -import { carrierLabel, CarrierReportTag } from './signature.helpers'; +import { carrierLabel } from './signature.helpers'; import type { DrainageInfo } from './signature.types'; const reportStatusOptions = [ - { label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' }, - { label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' }, - { label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' }, + { label: '未报备', value: 'pending' }, + { label: '资料待补充', value: 'waiting_material' }, + { label: '报备中', value: 'reporting' }, + { label: '报备通过', value: 'approved' }, + { label: '报备失败', value: 'failed' }, + { label: '放弃报备', value: 'abandoned' }, ]; -export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) { +export function ChannelReportStatusModal({ + item, + onClose, + onSaved, +}: { + item: ClientSmsSignature; + onClose: () => void; + onSaved: () => void; +}) { const targets = item.reportTargets ?? []; const carriers = ['mobile', 'unicom', 'telecom'] as const; - const [statuses, setStatuses] = useState>(() => Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status]))); + const [statuses, setStatuses] = useState>(() => + Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])), + ); const [reason, setReason] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); async function save() { setSaving(true); try { - await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, carrier: target.carrier, status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status })), reason, sourceEntry: 'enterprise_signature' }); + await adminApi.changeReportTaskStatuses({ + items: targets.map((target) => ({ + signatureId: item.id, + channelId: target.channelId, + carrier: target.carrier, + status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status, + })), + reason, + sourceEntry: 'enterprise_signature', + }); onSaved(); - } catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); } + } catch (failure) { + setError(failure instanceof Error ? failure.message : '报备状态保存失败'); + } finally { + setSaving(false); + } } - return } onClose={onClose} open size="xl" title="修改签名报备状态"> -
{item.name}{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}
修改具体通道的报备状态;保存后同步通道详情、报备任务和企业签名三网状态。
- {error ?

{error}

: null} - {targets.length ?
{carriers.map((carrier) => { const carrierTargets = targets.filter((target) => target.carrier === carrier); return
{carrierTargets.length} 个通道
{carrierTargets.length ? carrierTargets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return
{target.channel.name}