From b24cd7c08dfcda823066c0ecdde0508099df0575 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 20 Sep 2026 15:09:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E9=80=9A=E9=81=93=E6=8B=92=E6=94=B6=E7=AD=96=E7=95=A5=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=90=E8=90=A5=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 4 + api/prisma/schema.prisma | 4 + .../channel-carrier-reduction.spec.ts | 51 ++ .../channels/channel-configuration.service.ts | 103 ++-- api/src/operations/operations.helpers.ts | 1 + .../operations/queries/messages.queries.ts | 4 + api/src/operations/queries/quality.queries.ts | 32 +- .../channel-sensitive-routing.spec.ts | 8 + .../send-chain/channel-sensitive-routing.ts | 16 +- .../drainage-submit-guard.controller.ts | 6 +- api/src/send-chain/send-chain.contracts.ts | 1 + .../send-chain/send-gateway-submit.service.ts | 87 ++- .../send-chain/template-optout-policy.spec.ts | 57 ++ api/src/send-chain/template-optout-policy.ts | 106 ++++ api/src/sms-config/sms-config.module.ts | 8 +- .../template-optout.controller.spec.ts | 21 + .../sms-config/template-optout.controller.ts | 87 +++ api/src/sms-config/template.service.ts | 398 +++++++------ .../first-version-development-requirements.md | 10 + docs/phase-4-send-pipeline-redesign.md | 5 + docs/system-functional-test-cases.md | 22 + .../template-optout-policy-design-20260920.md | 28 + docs/testing-progress.md | 13 + src/api/admin/governance.api.ts | 538 +++++++++++++----- src/api/types/operations.ts | 3 + src/apps/admin/AdminAnalyticsPage.tsx | 14 +- .../admin/AdminEnterpriseTemplatesPage.tsx | 457 ++++++++++----- src/apps/admin/QualityStatusBar.css | 41 ++ src/apps/admin/QualityStatusBar.test.tsx | 33 ++ src/apps/admin/QualityStatusBar.tsx | 38 ++ src/apps/admin/TemplateOptOutModal.css | 32 ++ src/apps/admin/TemplateOptOutModal.test.tsx | 46 ++ src/apps/admin/TemplateOptOutModal.tsx | 146 +++++ src/apps/admin/channels/AdminChannelsPage.css | 21 + .../admin/sms-records/SendDetailModal.tsx | 18 + src/apps/client/ClientSendDetailPage.tsx | 6 + tools/quality/css-ownership.json | 233 ++++++-- tools/testing/verify-template-optout.mjs | 400 +++++++++++++ 38 files changed, 2485 insertions(+), 613 deletions(-) create mode 100644 api/prisma/migrations/20260920090000_template_optout_policy/migration.sql create mode 100644 api/src/channels/channel-carrier-reduction.spec.ts create mode 100644 api/src/send-chain/template-optout-policy.spec.ts create mode 100644 api/src/send-chain/template-optout-policy.ts create mode 100644 api/src/sms-config/template-optout.controller.spec.ts create mode 100644 api/src/sms-config/template-optout.controller.ts create mode 100644 docs/template-optout-policy-design-20260920.md create mode 100644 src/apps/admin/QualityStatusBar.css create mode 100644 src/apps/admin/QualityStatusBar.test.tsx create mode 100644 src/apps/admin/QualityStatusBar.tsx create mode 100644 src/apps/admin/TemplateOptOutModal.css create mode 100644 src/apps/admin/TemplateOptOutModal.test.tsx create mode 100644 src/apps/admin/TemplateOptOutModal.tsx create mode 100644 tools/testing/verify-template-optout.mjs diff --git a/api/prisma/migrations/20260920090000_template_optout_policy/migration.sql b/api/prisma/migrations/20260920090000_template_optout_policy/migration.sql new file mode 100644 index 0000000..8933492 --- /dev/null +++ b/api/prisma/migrations/20260920090000_template_optout_policy/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "SmsTemplate" ADD COLUMN "optOutRules" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "SmsTemplate" ADD CONSTRAINT "SmsTemplate_optOutRules_array" CHECK (jsonb_typeof("optOutRules") = 'array'); +ALTER TABLE "SmsMessageRecord" ADD COLUMN "originalContent" TEXT; +ALTER TABLE "SmsSubmitRecord" ADD COLUMN "sentContent" TEXT, ADD COLUMN "contentPolicy" JSONB; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 20256ad..1608113 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -853,6 +853,7 @@ model SignatureMaterial { } model SmsTemplate { + optOutRules Json @default("[]") id String @id @default(cuid()) tenantId String applicationId String @@ -1831,6 +1832,7 @@ model SmsDrainageDecision { } model SmsMessageRecord { + originalContent String? channelWordDecisions SmsChannelSensitiveDecision[] channelWordFinalizationPending Boolean @default(false) monitorFacts SendingMonitorFact[] @@ -1924,6 +1926,8 @@ model CmppSubmitSession { } model SmsSubmitRecord { + sentContent String? + contentPolicy Json? drainageGate Json? id String @id @default(cuid()) tenantId String? diff --git a/api/src/channels/channel-carrier-reduction.spec.ts b/api/src/channels/channel-carrier-reduction.spec.ts new file mode 100644 index 0000000..01782c8 --- /dev/null +++ b/api/src/channels/channel-carrier-reduction.spec.ts @@ -0,0 +1,51 @@ +import { ChannelConfigurationService } from './channel-configuration.service'; +import { selectChannelCandidate } from '../send-chain/send-chain.helpers'; +describe('carrier capability reduction', () => { + it('preserves group references and avoids reconnecting for a capability-only change', async () => { + const channel = { + id: 'c', + carrier: 'all', + carriers: ['mobile', 'unicom', 'telecom'], + status: 'active', + config: {}, + }; + const prisma = { + smsChannel: { + findUnique: jest.fn().mockResolvedValue(channel), + update: jest.fn().mockImplementation(({ data }) => ({ ...channel, ...data })), + }, + operationLog: { create: jest.fn() }, + smsChannelGroupItem: { + findMany: jest.fn().mockResolvedValue([{ group: { name: 'existing' } }]), + deleteMany: jest.fn(), + }, + }; + const connection = { requestChannelConnection: jest.fn(), requestChannelDisconnection: jest.fn() }; + await new ChannelConfigurationService(prisma as never, connection as never).updateChannel('c', { + carriers: ['mobile', 'unicom'], + }); + expect(prisma.smsChannel.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ carriers: ['mobile', 'unicom'] }) }), + ); + expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled(); + expect(connection.requestChannelConnection).not.toHaveBeenCalled(); + const candidate = { + channelId: 'c', + carrier: 'telecom', + channel: { + ...channel, + carrier: 'mobile', + carriers: ['mobile', 'unicom'], + sendRegion: '全国', + connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }], + }, + }; + expect( + selectChannelCandidate([candidate], { + carrier: 'telecom', + excludedChannelIds: new Set(), + approvedChannelIds: new Set(['c']), + }), + ).toBeUndefined(); + }); +}); diff --git a/api/src/channels/channel-configuration.service.ts b/api/src/channels/channel-configuration.service.ts index 190b526..f6cfcf8 100644 --- a/api/src/channels/channel-configuration.service.ts +++ b/api/src/channels/channel-configuration.service.ts @@ -1,17 +1,26 @@ -import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { randomUUID } from 'crypto'; import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; -import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; -import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; import { ChannelConnectionService } from './channel-connection.service'; +import type { ChangeChannelStatusDto, CreateChannelDto, UpdateChannelDto } from './channels.contracts'; +import { + channelConnectionSettingsChanged, + currentShanghaiDayRange, + legacyCarrierFromCapabilities, + normalizeBusinessCarrier, + normalizeChannelCarriers, + normalizeChannelRateLimit, + normalizeChannelRuntimeConfig, + normalizeCmppVersion, +} from './channels.helpers'; /** R5 channel domain service composed behind ChannelsService. */ export class ChannelConfigurationService { - constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {} + constructor( + private readonly prisma: PrismaService, + private readonly connection: ChannelConnectionService, + ) {} listChannels() { return this.prisma.smsChannel.findMany({ @@ -20,7 +29,13 @@ export class ChannelConfigurationService { }); } - async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) { + async listChannelsPage(query: { + keyword?: string; + carrier?: string; + status?: string; + page?: number; + pageSize?: number; + }) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsChannelWhereInput = { @@ -44,12 +59,18 @@ export class ChannelConfigurationService { const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)])); // 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。 const pageIds = candidates - .sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) - || left.name.localeCompare(right.name, 'zh-CN') - || left.id.localeCompare(right.id)) + .sort( + (left, right) => + (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) || + left.name.localeCompare(right.name, 'zh-CN') || + left.id.localeCompare(right.id), + ) .slice((page - 1) * pageSize, page * pageSize) .map((channel) => channel.id); - const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } }); + const pageItems = await this.prisma.smsChannel.findMany({ + where: { id: { in: pageIds } }, + include: { connectionStates: true }, + }); const itemById = new Map(pageItems.map((item) => [item.id, item])); const items = pageIds.flatMap((id) => { const item = itemById.get(id); @@ -122,39 +143,28 @@ export class ChannelConfigurationService { throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); } const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); - const config = data.config !== undefined - || data.desiredConnections !== undefined - || data.windowSize !== undefined - || data.heartbeatIntervalSeconds !== undefined - || data.heartbeatMissThreshold !== undefined - ? normalizeChannelRuntimeConfig( - channel.config, - data.config, - data.desiredConnections, - data.windowSize, - data.heartbeatIntervalSeconds, - data.heartbeatMissThreshold, - ) - : undefined; - const rateLimitPerSecond = data.rateLimitPerSecond === undefined - ? undefined - : normalizeChannelRateLimit(data.rateLimitPerSecond); + const config = + data.config !== undefined || + data.desiredConnections !== undefined || + data.windowSize !== undefined || + data.heartbeatIntervalSeconds !== undefined || + data.heartbeatMissThreshold !== undefined + ? normalizeChannelRuntimeConfig( + channel.config, + data.config, + data.desiredConnections, + data.windowSize, + data.heartbeatIntervalSeconds, + data.heartbeatMissThreshold, + ) + : undefined; + const rateLimitPerSecond = + data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond); const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier); - const carriers = data.carriers !== undefined || data.carrier !== undefined - ? normalizeChannelCarriers(data.carriers, data.carrier) - : existingCarriers; - if (data.carriers !== undefined || data.carrier !== undefined) { - const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier)); - if (removed.length) { - const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({ - where: { channelId, group: { status: 'active', carrier: { in: removed } } }, - include: { group: true }, - }); - if (blockingGroups.length) { - throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`); - } - } - } + const carriers = + data.carriers !== undefined || data.carrier !== undefined + ? normalizeChannelCarriers(data.carriers, data.carrier) + : existingCarriers; const connectionConfigChanged = channelConnectionSettingsChanged(channel, { gatewayHost: data.gatewayHost ?? channel.gatewayHost, gatewayPort: gatewayPort ?? channel.gatewayPort, @@ -168,7 +178,10 @@ export class ChannelConfigurationService { data: { code: data.code, name: data.name, - carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined, + carrier: + data.carriers !== undefined || data.carrier !== undefined + ? legacyCarrierFromCapabilities(carriers) + : undefined, carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined, sendRegion: data.sendRegion, protocol: 'CMPP', diff --git a/api/src/operations/operations.helpers.ts b/api/src/operations/operations.helpers.ts index eea55c7..f4258f7 100644 --- a/api/src/operations/operations.helpers.ts +++ b/api/src/operations/operations.helpers.ts @@ -268,6 +268,7 @@ export function clientMessageView(message: Record) { carrier: message.carrier ?? null, province: message.province ?? null, content: message.content, + originalContent: message.originalContent ?? null, drainageGate: message.drainageGate ? { version: message.drainageGate.version, diff --git a/api/src/operations/queries/messages.queries.ts b/api/src/operations/queries/messages.queries.ts index 89e0efb..3932997 100644 --- a/api/src/operations/queries/messages.queries.ts +++ b/api/src/operations/queries/messages.queries.ts @@ -84,6 +84,7 @@ export class OperationsMessageQueries { carrier: true, province: true, content: true, + originalContent: true, hasDrainageContent: true, drainageDetection: true, billingUnits: true, @@ -115,8 +116,11 @@ export class OperationsMessageQueries { application: { select: { id: true, name: true } }, channel: { select: { id: true, name: true, srcId: true } }, submitRecords: { + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], select: { id: true, + sentContent: true, + contentPolicy: true, submitId: true, channelId: true, channelGroupId: true, diff --git a/api/src/operations/queries/quality.queries.ts b/api/src/operations/queries/quality.queries.ts index 146bc71..6a5fb83 100644 --- a/api/src/operations/queries/quality.queries.ts +++ b/api/src/operations/queries/quality.queries.ts @@ -376,6 +376,12 @@ export class OperationsQualityQueries { message.status, message."submitStatus" AS submit_status, message."receiptStatus" AS receipt_status, + CASE + WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN 'success' + WHEN message.status = 'submit_failed' OR message."submitStatus" IN ('rejected', 'timeout') THEN 'submit_failed' + WHEN message."receiptStatus" = 'undelivered' OR (message.status = 'failed' AND message."receiptStatus" IS NOT NULL AND message."receiptStatus" <> 'unknown') THEN 'failure' + ELSE 'unknown' + END AS quality_status, CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') AND message."submittedAt" IS NOT NULL @@ -403,27 +409,11 @@ export class OperationsQualityQueries { tenant.name AS "tenantName", STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames", COUNT(base.signature_id)::integer AS total, - COUNT(base.signature_id) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - )::integer AS "acceptedCount", - COUNT(base.signature_id) FILTER ( - WHERE base.status = 'submit_failed' - OR base.submit_status IN ('rejected', 'timeout') - )::integer AS "submitFailureCount", - COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", - COUNT(base.signature_id) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "unknownCount", - COUNT(base.signature_id) FILTER ( - WHERE COALESCE(base.status, '') <> 'submit_failed' - AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') - AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) - )::integer AS "failureCount", + COUNT(base.signature_id) FILTER (WHERE base.quality_status <> 'submit_failed')::integer AS "acceptedCount", + COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'submit_failed')::integer AS "submitFailureCount", + COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'success')::integer AS "successCount", + COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'unknown')::integer AS "unknownCount", + COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'failure')::integer AS "failureCount", CASE WHEN COUNT(base.signature_id) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' diff --git a/api/src/send-chain/channel-sensitive-routing.spec.ts b/api/src/send-chain/channel-sensitive-routing.spec.ts index 3ffba67..5b0855b 100644 --- a/api/src/send-chain/channel-sensitive-routing.spec.ts +++ b/api/src/send-chain/channel-sensitive-routing.spec.ts @@ -11,6 +11,14 @@ const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobil const options = { carrier: 'mobile', excludedChannelIds: new Set(), approvedChannelIds: new Set(['a', 'b']) }; const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 }; describe('channel sensitive routing snapshot', () => { + it('checks rewritten content separately for each candidate', () => { + const snapshot = new ChannelWordSnapshot([{ ...rule, word: '拒收请回复R' }]); + expect( + snapshot.select('m', '正文拒收请回复R', items, options, (id) => (id === 'a' ? '正文' : '正文拒收请回复R')) + .selected?.channelId, + ).toBe('a'); + expect(snapshot.select('n', '正文', items, options, () => '正文拒收请回复R').selected?.channelId).toBe('b'); + }); it('removes only matching eligible channels before original priority selection', () => { const snapshot = new ChannelWordSnapshot([rule]); expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b'); diff --git a/api/src/send-chain/channel-sensitive-routing.ts b/api/src/send-chain/channel-sensitive-routing.ts index 255e7ae..2ced49b 100644 --- a/api/src/send-chain/channel-sensitive-routing.ts +++ b/api/src/send-chain/channel-sensitive-routing.ts @@ -40,13 +40,16 @@ export class ChannelWordSnapshot { content: string, items: T[], options: Parameters[1], + contentForChannel?: (channelId: string) => string, ) { const candidates = items.filter((item) => selectChannelCandidate([item], options)); const candidateIds = new Set(candidates.map((item) => item.channelId)); const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name])); - const hits = this.hits(content) - .filter((hit) => candidateIds.has(hit.channelId)) - .map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId })); + const hits = ( + contentForChannel + ? [...candidateIds].flatMap((id) => this.hits(contentForChannel(id)).filter((hit) => hit.channelId === id)) + : this.hits(content).filter((hit) => candidateIds.has(hit.channelId)) + ).map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId })); const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]); const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded }); const rejected = !selected && candidates.length > 0 && hits.length > 0; @@ -59,6 +62,13 @@ export class ChannelWordSnapshot { readAt: this.readAt, stage: 'route', contentHash: createHash('sha256').update(content).digest('hex'), + ...(contentForChannel + ? { + candidateContentHashes: Object.fromEntries( + [...candidateIds].map((id) => [id, createHash('sha256').update(contentForChannel(id)).digest('hex')]), + ), + } + : {}), candidateChannelIds: [...candidateIds], excludedChannelIds: hits.map((hit) => hit.channelId), hits, diff --git a/api/src/send-chain/drainage-submit-guard.controller.ts b/api/src/send-chain/drainage-submit-guard.controller.ts index 79a1249..36fd9a9 100644 --- a/api/src/send-chain/drainage-submit-guard.controller.ts +++ b/api/src/send-chain/drainage-submit-guard.controller.ts @@ -41,10 +41,12 @@ export class DrainageSubmitGuardController { if ( !submit || submit.channelId !== body.channelId || - createHash('sha256').update(submit.messageRecord.content).digest('hex') !== body.contentHash + createHash('sha256') + .update(submit.sentContent ?? submit.messageRecord.content) + .digest('hex') !== body.contentHash ) return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' }; - let message = submit.messageRecord; + let message = { ...submit.messageRecord, content: submit.sentContent ?? submit.messageRecord.content }; if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) { const template = await tx.smsTemplate.findFirst({ where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId }, diff --git a/api/src/send-chain/send-chain.contracts.ts b/api/src/send-chain/send-chain.contracts.ts index ffc862c..9e4d367 100644 --- a/api/src/send-chain/send-chain.contracts.ts +++ b/api/src/send-chain/send-chain.contracts.ts @@ -258,6 +258,7 @@ export interface SendJob { export type QueuePriority = 'normal' | 'priority'; export type RoutedChannel = { + contentPolicy?: import('./template-optout-policy').ContentPolicyDecision; channel: { id: string; code: string; diff --git a/api/src/send-chain/send-gateway-submit.service.ts b/api/src/send-chain/send-gateway-submit.service.ts index 4d62302..a508116 100644 --- a/api/src/send-chain/send-gateway-submit.service.ts +++ b/api/src/send-chain/send-gateway-submit.service.ts @@ -1,3 +1,4 @@ +import { applyOptOutRule, loadOptOutPolicies, policyAudit } from './template-optout-policy'; import { completionContext } from './completion-context'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; @@ -300,7 +301,9 @@ export class SendGatewaySubmitService { sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId)); }), ); - const prepared = planned.map(({ message, routed }) => { + const prepared = planned.map(({ message: input, routed }) => { + const decision = routed.contentPolicy ?? applyOptOutRule(input); + const message = { ...input, content: decision.content }; const submitId = `SUB-${randomUUID()}`; const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension); return { @@ -308,6 +311,7 @@ export class SendGatewaySubmitService { routed, submitId, command: this.buildGatewaySubmitCommand(message, routed, 0, submitId, upstreamSrcId), + decision, sessionId: sessionByChannel.get(routed.channel.id), }; }); @@ -315,7 +319,9 @@ export class SendGatewaySubmitService { await this.measureSendStage('submit_transaction', () => this.prisma.$transaction(async (tx) => { await tx.smsSubmitRecord.createMany({ - data: prepared.map(({ message, routed, submitId, sessionId }) => ({ + data: prepared.map(({ message, routed, submitId, sessionId, decision }) => ({ + sentContent: message.content, + contentPolicy: policyAudit(decision), id: randomUUID(), tenantId: message.tenantId, batchTaskId: message.batchTaskId, @@ -332,15 +338,18 @@ export class SendGatewaySubmitService { }); const updates = Prisma.join( prepared.map( - ({ message, routed, submitId }) => Prisma.sql`( + ({ message, routed, submitId, decision }) => Prisma.sql`( ${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text, - ${routed.province ?? null}::text, ${submitId}::text + ${routed.province ?? null}::text, ${submitId}::text, ${message.content}::text, + ${message.originalContent ?? (decision.content !== decision.originalContent ? decision.originalContent : null)}::text )`, ), ); await tx.$executeRaw(Prisma.sql` UPDATE "SmsMessageRecord" AS message - SET "channelId" = updates."channelId", + SET content = updates.content, + "originalContent" = COALESCE(message."originalContent", updates."originalContent"), + "channelId" = updates."channelId", carrier = updates.carrier, province = updates.province, "submitId" = updates."submitId", @@ -350,7 +359,7 @@ export class SendGatewaySubmitService { "errorCode" = NULL, "errorMessage" = NULL, "updatedAt" = (NOW() AT TIME ZONE 'UTC') - FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId") + FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId", content, "originalContent") WHERE message.id = updates.id AND message.status = 'queued' `); if (writeOutbox) { @@ -393,6 +402,8 @@ export class SendGatewaySubmitService { signatureId?: string | null; phoneNumber: string; content?: string; + originalContent?: string | null; + billingUnits?: number; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; @@ -467,6 +478,10 @@ export class SendGatewaySubmitService { this.prisma, routes.flatMap((route) => route.group.items.map((item) => item.channelId)), ); + const policies = await loadOptOutPolicies( + this.prisma, + messages.map((m) => ({ ...m, content: m.content ?? '' })), + ); const planned: Array<{ message: T; routed: RoutedChannel }> = []; const failed: Array<{ message: T; reason: string; code?: string }> = []; for (const input of routeInputs) { @@ -485,10 +500,10 @@ export class SendGatewaySubmitService { input.message.content === undefined ? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } }) : input.message; - content = stored.content!; + content = stored.originalContent ?? stored.content!; gate = await evaluateMessageDrainage( this.prisma, - { ...input.message, content: stored.content!, signatureId: input.signatureId }, + { ...input.message, content, signatureId: input.signatureId }, input.carrier, drainageMaterials .filter( @@ -530,13 +545,19 @@ export class SendGatewaySubmitService { (process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')), ), ); - const { selected, rejected } = channelWords.select(input.message.id, content, approvedItems, { - carrier: input.carrier, - province: input.province, - excludedChannelIds: new Set(), - approvedChannelIds: new Set(approvedItems.map((item) => item.channelId)), - routingKey: input.message.id, - }); + const { selected, rejected } = channelWords.select( + input.message.id, + content, + approvedItems, + { + carrier: input.carrier, + province: input.province, + excludedChannelIds: new Set(), + approvedChannelIds: new Set(approvedItems.map((item) => item.channelId)), + routingKey: input.message.id, + }, + (id) => policies({ ...input.message, content }, id).content, + ); if (!selected) { failed.push({ message: input.message, @@ -548,6 +569,7 @@ export class SendGatewaySubmitService { planned.push({ message: input.message, routed: { + contentPolicy: policies({ ...input.message, content }, selected.channelId), channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) }, carrier: input.carrier, province: input.province, @@ -755,6 +777,8 @@ export class SendGatewaySubmitService { attempt: number, retryOfSubmitRecordId?: string, ) { + const decision = routed.contentPolicy ?? applyOptOutRule(message); + const submittedMessage = { ...message, content: decision.content }; const channel = routed.channel; const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension); await this.measureSendStage('rate_limit', () => @@ -762,7 +786,7 @@ export class SendGatewaySubmitService { ); const submitId = `SUB-${randomUUID()}`; const sessionId = await this.getOpenSubmitSessionId(channel.id); - const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId); + const command = this.buildGatewaySubmitCommand(submittedMessage, routed, attempt, submitId, upstreamSrcId); const writeOutbox = Boolean(completionContext.getStore()) || this.submitOutboxEnabled(); try { await this.measureSendStage('submit_transaction', () => @@ -777,6 +801,8 @@ export class SendGatewaySubmitService { channelGroupName: routed.groupName, sessionId, retryOfSubmitRecordId, + sentContent: decision.content, + contentPolicy: policyAudit(decision), submitId, submitStatus: 'queued', costUnitPrice: channel.unitPrice ?? 0, @@ -786,6 +812,8 @@ export class SendGatewaySubmitService { await tx.smsMessageRecord.update({ where: { id: message.id }, data: { + content: decision.content, + originalContent: decision.content !== decision.originalContent ? decision.originalContent : undefined, channelId: channel.id, carrier: routed.carrier, province: routed.province, @@ -1118,7 +1146,9 @@ return streamId`; ); const excluded = new Set(options.excludeChannelIds ?? []); const stored = await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } }); - const gate = await evaluateMessageDrainage(this.prisma, { ...stored, signatureId }, carrier); + const original = { ...stored, content: stored.originalContent ?? stored.content }; + const policies = await loadOptOutPolicies(this.prisma, [original]); + const gate = await evaluateMessageDrainage(this.prisma, { ...original, signatureId }, carrier); const approvedChannelIds = new Set( route.group.items .map((item) => item.channelId) @@ -1130,20 +1160,27 @@ return streamId`; this.prisma, route.group.items.map((item) => item.channelId), ); - const { selected, rejected } = channelWords.select(message.id, stored.content, route.group.items, { - carrier, - province, - forceNational: options.forceNational, - excludedChannelIds: excluded, - approvedChannelIds, - routingKey: message.id, - }); + const { selected, rejected } = channelWords.select( + message.id, + original.content, + route.group.items, + { + carrier, + province, + forceNational: options.forceNational, + excludedChannelIds: excluded, + approvedChannelIds, + routingKey: message.id, + }, + (id) => policies(original, id).content, + ); if (!options.previewOnly) await channelWords.persist(this.prisma); if (rejected) throw new ChannelWordRejection(); if (!selected) { throw new NotFoundException('无已报备通过且在线的可用通道'); } return { + contentPolicy: policies(original, selected.channelId), channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) }, carrier, province, diff --git a/api/src/send-chain/template-optout-policy.spec.ts b/api/src/send-chain/template-optout-policy.spec.ts new file mode 100644 index 0000000..0417ff4 --- /dev/null +++ b/api/src/send-chain/template-optout-policy.spec.ts @@ -0,0 +1,57 @@ +import { applyOptOutRule, gatewayFragmentCount, loadOptOutPolicies, OPT_OUT_SUFFIX } from './template-optout-policy'; +import { PrismaService } from '../prisma/prisma.service'; +const add = { channelId: 'a', action: 'add' as const }; +const remove = { channelId: 'b', action: 'remove' as const }; +describe('template opt-out fragment preservation', () => { + test.each([1, 64, 65, 69, 70, 71, 77, 128, 129, 134, 135, 195, 201])( + 'preserves billing and wire parts at length %i', + (length) => { + const content = '文'.repeat(length); + const result = applyOptOutRule({ content }, add); + expect(gatewayFragmentCount(result.content)).toBe(gatewayFragmentCount(content)); + expect(result.reason).toBe( + gatewayFragmentCount(content + OPT_OUT_SUFFIX) === gatewayFragmentCount(content) + ? 'applied' + : 'fragment_count_changed', + ); + }, + ); + it('adds 71 to 77 but skips 69 to 75', () => { + expect(applyOptOutRule({ content: '文'.repeat(69) }, add).content).toHaveLength(69); + expect(applyOptOutRule({ content: '文'.repeat(71) }, add).content).toHaveLength(77); + }); + it('does not remove suffix across a fragment boundary or alter inline text', () => { + expect(applyOptOutRule({ content: '文'.repeat(69) + OPT_OUT_SUFFIX }, remove).reason).toBe( + 'fragment_count_changed', + ); + const content = `正文${OPT_OUT_SUFFIX}。后文`; + expect(applyOptOutRule({ content }, remove).content).toBe(content); + }); + it('retains original on alternate-channel retry and never stacks additions', () => { + const originalContent = '文'.repeat(71); + const first = applyOptOutRule({ content: originalContent }, add); + expect(applyOptOutRule({ originalContent, content: first.content }, add).content).toBe(first.content); + expect(applyOptOutRule({ originalContent, content: first.content }, remove).content).toBe(originalContent); + expect(applyOptOutRule({ originalContent, content: first.content }).content).toBe(originalContent); + }); + it('preserves UTF-16 parts and skips historical billing mismatch', () => { + const content = '😀'.repeat(34); + expect(applyOptOutRule({ content }, add).reason).toBe('fragment_count_changed'); + expect(applyOptOutRule({ content: '文'.repeat(71), billingUnits: 1 }, add).reason).toBe('fragment_count_changed'); + expect(gatewayFragmentCount('😀'.repeat(67))).toBe(3); + }); + it('matches independently of template admission, scopes tenants/apps and prefers exact text', async () => { + const templates = [ + { id: 'v', tenantId: 't', applicationId: 'app', content: '【测】${name}', optOutRules: [remove] }, + { id: 'e', tenantId: 't', applicationId: 'app', content: '【测】正文', optOutRules: [add] }, + ]; + const db = { smsTemplate: { findMany: jest.fn().mockResolvedValue(templates) } }; + const message = { tenantId: 't', applicationId: 'app', content: '【测】正文' }; + const policies = await loadOptOutPolicies(db as unknown as PrismaService, [message]); + expect(policies(message, 'a').reason).toBe('applied'); + expect(policies({ ...message, tenantId: 'other' }, 'a').reason).toBe('no_policy'); + expect(policies({ ...message, content: '其他短信' }, 'a').reason).toBe('no_policy'); + expect(policies({ ...message, templateId: 'v' }, 'a').reason).toBe('no_policy'); + expect(policies({ ...message, templateId: 'unconfigured-template' }, 'a').reason).toBe('no_policy'); + }); +}); diff --git a/api/src/send-chain/template-optout-policy.ts b/api/src/send-chain/template-optout-policy.ts new file mode 100644 index 0000000..2817086 --- /dev/null +++ b/api/src/send-chain/template-optout-policy.ts @@ -0,0 +1,106 @@ +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { estimateBillingUnits } from '../sms-config/sms-config.helpers'; +import { matchTemplateContent } from './send-chain.helpers'; + +export const OPT_OUT_SUFFIX = '拒收请回复R'; +export type OptOutRule = { channelId: string; action: 'add' | 'remove' }; +export type ContentPolicyDecision = { + originalContent: string; + content: string; + templateId: string | null; + action: 'add' | 'remove' | 'none'; + reason: 'applied' | 'unchanged' | 'fragment_count_changed' | 'no_policy'; +}; +type PolicyMessage = { + tenantId?: string | null; + applicationId?: string | null; + templateId?: string | null; + content: string; + originalContent?: string | null; + billingUnits?: number; +}; + +// Gateway UCS2 uses UTF-16 and never splits a Unicode character across parts. +export function gatewayFragmentCount(content: string) { + if (content.length * 2 <= 140) return 1; + let count = 1, + units = 0; + for (const character of content) { + if (units + character.length > 67) { + count++; + units = 0; + } + units += character.length; + } + return count; +} + +export function applyOptOutRule(message: PolicyMessage, rule?: OptOutRule, templateId?: string): ContentPolicyDecision { + const originalContent = message.originalContent ?? message.content; + const result: ContentPolicyDecision = { + originalContent, + content: originalContent, + templateId: templateId ?? null, + action: rule?.action ?? 'none', + reason: 'no_policy', + }; + if (!rule) return result; + const content = + rule.action === 'add' + ? originalContent.endsWith(OPT_OUT_SUFFIX) + ? originalContent + : originalContent + OPT_OUT_SUFFIX + : originalContent.endsWith(OPT_OUT_SUFFIX) + ? originalContent.slice(0, -OPT_OUT_SUFFIX.length) + : originalContent; + if (content === originalContent) return { ...result, reason: 'unchanged' }; + const originalUnits = estimateBillingUnits(originalContent); + if ( + estimateBillingUnits(content) !== originalUnits || + (message.billingUnits !== undefined && originalUnits !== message.billingUnits) || + gatewayFragmentCount(content) !== gatewayFragmentCount(originalContent) + ) { + return { ...result, reason: 'fragment_count_changed' }; + } + return { ...result, content, reason: 'applied' }; +} + +export async function loadOptOutPolicies(db: PrismaService, messages: PolicyMessage[]) { + const applicationIds = [...new Set(messages.map((m) => m.applicationId).filter((id): id is string => Boolean(id)))]; + const templates = applicationIds.length + ? await db.smsTemplate.findMany({ + where: { + applicationId: { in: applicationIds }, + auditStatus: 'approved', + signature: { auditStatus: 'approved' }, + NOT: { optOutRules: { equals: [] } }, + }, + select: { id: true, tenantId: true, applicationId: true, content: true, optOutRules: true }, + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + }) + : []; + return (message: PolicyMessage, channelId: string) => { + const content = message.originalContent ?? message.content; + const matches = templates.filter( + (t) => + t.tenantId === message.tenantId && + t.applicationId === message.applicationId && + (t.content === content || matchTemplateContent(t.content, content) !== null), + ); + const template = message.templateId + ? matches.find((t) => t.id === message.templateId) + : (matches.find((t) => t.content === content) ?? matches[0]); + const rules = (template?.optOutRules ?? []) as unknown as OptOutRule[]; + return applyOptOutRule( + message, + rules.find((r) => r.channelId === channelId), + template?.id, + ); + }; +} + +export function policyAudit(decision?: ContentPolicyDecision): Prisma.InputJsonValue | undefined { + if (!decision || decision.action === 'none') return undefined; + return { templateId: decision.templateId, action: decision.action, reason: decision.reason, preserveFragments: true }; +} diff --git a/api/src/sms-config/sms-config.module.ts b/api/src/sms-config/sms-config.module.ts index 6daadb2..3098792 100644 --- a/api/src/sms-config/sms-config.module.ts +++ b/api/src/sms-config/sms-config.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { TemplateOptOutController } from './template-optout.controller'; import { AdminSmsConfigController } from './admin-sms-config.controller'; import { ClientSmsConfigController } from './client-sms-config.controller'; import { SmsConfigService } from './sms-config.service'; @@ -8,7 +9,12 @@ import { DeletionGovernanceModule } from '../deletion-governance/deletion-govern @Module({ imports: [DeletionGovernanceModule], - controllers: [ClientSmsConfigController, AdminSmsConfigController, ReviewGovernanceController], + controllers: [ + ClientSmsConfigController, + AdminSmsConfigController, + ReviewGovernanceController, + TemplateOptOutController, + ], providers: [SmsConfigService, ReviewGovernanceService], exports: [SmsConfigService], }) diff --git a/api/src/sms-config/template-optout.controller.spec.ts b/api/src/sms-config/template-optout.controller.spec.ts new file mode 100644 index 0000000..0b20587 --- /dev/null +++ b/api/src/sms-config/template-optout.controller.spec.ts @@ -0,0 +1,21 @@ +import { TemplateOptOutController } from './template-optout.controller'; +import { PrismaService } from '../prisma/prisma.service'; +describe('template opt-out configuration constraints', () => { + const db = { $transaction: jest.fn() }; + const controller = new TemplateOptOutController(db as unknown as PrismaService); + it.each([ + { rules: [], preserveFragments: false }, + { rules: 'bad', preserveFragments: true }, + { rules: [{ channelId: 'x', action: 'replace' }], preserveFragments: true }, + { + rules: [ + { channelId: 'x', action: 'add' }, + { channelId: 'x', action: 'remove' }, + ], + preserveFragments: true, + }, + ])('rejects unsafe input without writing: %j', async (body) => { + await expect(controller.put('template', body)).rejects.toMatchObject({ status: 400 }); + expect(db.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/sms-config/template-optout.controller.ts b/api/src/sms-config/template-optout.controller.ts new file mode 100644 index 0000000..b57b25a --- /dev/null +++ b/api/src/sms-config/template-optout.controller.ts @@ -0,0 +1,87 @@ +import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Put } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { PrismaService } from '../prisma/prisma.service'; +import { OptOutRule } from '../send-chain/template-optout-policy'; + +@Controller('admin/enterprise-templates') +export class TemplateOptOutController { + constructor(private readonly db: PrismaService) {} + + @Get(':id/opt-out-policy') + async get(@Param('id') id: string) { + const template = await this.db.smsTemplate.findUnique({ where: { id } }); + if (!template || template.auditStatus === 'deleted') throw new NotFoundException('模板不存在'); + const channels = await this.channels(this.db, template); + return { rules: template.optOutRules, preserveFragments: true, channels }; + } + + @Put(':id/opt-out-policy') + async put( + @Param('id') id: string, + @Body() body: { rules?: unknown; preserveFragments?: unknown }, + @CurrentSessionUserId() operatorId?: string, + ) { + if (!body || body.preserveFragments !== true || !Array.isArray(body.rules) || body.rules.length > 500) { + throw new BadRequestException('请提交有效规则,并保持避免影响消息分片数'); + } + const rules: OptOutRule[] = []; + for (const value of body.rules) { + if ( + !value || + typeof value.channelId !== 'string' || + !['add', 'remove'].includes(value.action) || + rules.some((r) => r.channelId === value.channelId) + ) { + throw new BadRequestException('通道规则不合法或存在重复通道'); + } + rules.push({ channelId: value.channelId, action: value.action }); + } + return this.db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM "SmsTemplate" WHERE id=${id} FOR UPDATE`; + const template = await tx.smsTemplate.findUnique({ where: { id } }); + if (!template || template.auditStatus === 'deleted') throw new NotFoundException('模板不存在'); + const channels = await this.channels(tx, template); + if (rules.some((rule) => !channels.some((c) => c.id === rule.channelId))) + throw new BadRequestException('只能选择本模板所属应用通道组中的通道'); + await tx.smsTemplate.update({ where: { id }, data: { optOutRules: rules } }); + await tx.operationLog.create({ + data: { + tenantId: template.tenantId, + userId: operatorId, + action: 'sms_template.opt_out_policy.update', + resource: 'sms_template', + resourceId: id, + detail: { before: template.optOutRules, after: rules, preserveFragments: true } as Prisma.InputJsonValue, + }, + }); + return { rules, preserveFragments: true, channels }; + }); + } + + private async channels( + db: Pick, + template: { applicationId: string; tenantId: string }, + ) { + const routes = await db.channelRouteRule.findMany({ + where: { + applicationId: template.applicationId, + tenantId: template.tenantId, + status: 'active', + channelId: null, + group: { status: 'active' }, + }, + select: { + group: { select: { name: true, items: { select: { channel: { select: { id: true, name: true } } } } } }, + }, + }); + const channels = new Map(); + for (const route of routes) + for (const { channel } of route.group.items) { + const entry = channels.get(channel.id) ?? { ...channel, groupNames: [] }; + if (!entry.groupNames.includes(route.group.name)) entry.groupNames.push(route.group.name); + channels.set(channel.id, entry); + } + return [...channels.values()].sort((a, b) => a.name.localeCompare(b.name)); + } +} diff --git a/api/src/sms-config/template.service.ts b/api/src/sms-config/template.service.ts index 783e281..37c91c3 100644 --- a/api/src/sms-config/template.service.ts +++ b/api/src/sms-config/template.service.ts @@ -1,50 +1,31 @@ -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 { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsAuditService } from './audit.service'; +import type { + CreateSmsTemplateDto, + CreateSmsTemplateOptions, + TemplateListQuery, + UpdateSmsTemplateDto, +} from './sms-config.contracts'; +import { + estimateBillingUnits, + normalizeSmsSignature, + validateAndNormalizeTemplateVariables, + type TemplateVariableInput, +} from './sms-config.helpers'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsTemplateService { - constructor(private readonly prisma: PrismaService, private readonly audit: SmsAuditService) {} + constructor( + private readonly prisma: PrismaService, + private readonly audit: SmsAuditService, + ) {} listTemplates(queryOrTenantId?: string | TemplateListQuery) { - const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; - return this.prisma.smsTemplate.findMany({ - where: { - tenantId: query.tenantId, - auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, - application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, - name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, - content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, - createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { content: { contains: query.keyword } }, - { category: { contains: query.keyword } }, - { application: { name: { contains: query.keyword } } }, - { tenant: { name: { contains: query.keyword } } }, - ] : undefined, - }, - include: { variables: true, application: true, tenant: true, signature: true }, - orderBy: { createdAt: 'desc' }, - ...(query.page && query.pageSize ? { - skip: (query.page - 1) * query.pageSize, - take: query.pageSize, - } : {}), - }); - } - - async listTemplatesPage(query: TemplateListQuery) { - const page = Math.max(1, Math.floor(Number(query.page) || 1)); - const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); - const where: Prisma.SmsTemplateWhereInput = { + const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {}); + return this.prisma.smsTemplate.findMany({ + where: { tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, @@ -52,167 +33,222 @@ export class SmsTemplateService { name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), - OR: query.keyword ? [ - { name: { contains: query.keyword } }, - { content: { contains: query.keyword } }, - { category: { contains: query.keyword } }, - { application: { name: { contains: query.keyword } } }, - { tenant: { name: { contains: query.keyword } } }, - ] : undefined, - }; - const [items, total] = await Promise.all([ - this.listTemplates({ ...query, page, pageSize }), - this.prisma.smsTemplate.count({ where }), - ]); - return { items, total, page, pageSize }; - } + OR: query.keyword + ? [ + { name: { contains: query.keyword } }, + { content: { contains: query.keyword } }, + { category: { contains: query.keyword } }, + { application: { name: { contains: query.keyword } } }, + { tenant: { name: { contains: query.keyword } } }, + ] + : undefined, + }, + include: { variables: true, application: true, tenant: true, signature: true }, + orderBy: { createdAt: 'desc' }, + ...(query.page && query.pageSize + ? { + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + } + : {}), + }); + } + + async listTemplatesPage(query: TemplateListQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsTemplateWhereInput = { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, + content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, + createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), + OR: query.keyword + ? [ + { name: { contains: query.keyword } }, + { content: { contains: query.keyword } }, + { category: { contains: query.keyword } }, + { application: { name: { contains: query.keyword } } }, + { tenant: { name: { contains: query.keyword } } }, + ] + : undefined, + }; + const [items, total] = await Promise.all([ + this.listTemplates({ ...query, page, pageSize }), + this.prisma.smsTemplate.count({ where }), + ]); + return { items, total, page, pageSize }; + } listClientTemplates(tenantId: string | undefined, includeHistory = false) { - return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' }); - } + return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' }); + } async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { - const variables = validateAndNormalizeTemplateVariables(data.content, data.variables); - const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); - if (!application || application.tenantId !== data.tenantId) { + const variables = validateAndNormalizeTemplateVariables(data.content, data.variables); + const application = await this.prisma.smsApplication.findUnique({ + where: { id: data.applicationId }, + select: { tenantId: true }, + }); + if (!application || application.tenantId !== data.tenantId) { + throw new BadRequestException('applicationId does not belong to the template tenant'); + } + await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content); + return this.prisma.smsTemplate.create({ + data: { + tenantId: data.tenantId, + applicationId: data.applicationId, + signatureId: data.signatureId, + name: data.name, + content: data.content, + category: data.category, + auditStatus: options.initialAuditStatus, + billingUnits: estimateBillingUnits(data.content), + variables: { + create: variables.map((variable: TemplateVariableInput) => ({ + name: variable.name, + example: variable.example, + required: variable.required ?? true, + })), + }, + }, + include: { variables: true, application: true, tenant: true, signature: true }, + }); + } + + async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template || (tenantId && template.tenantId !== tenantId)) { + throw new NotFoundException('Template not found'); + } + if (data.applicationId) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: data.applicationId }, + select: { tenantId: true }, + }); + if (!application || application.tenantId !== template.tenantId) { throw new BadRequestException('applicationId does not belong to the template tenant'); } - await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content); - return this.prisma.smsTemplate.create({ + } + if (data.signatureId !== undefined || data.applicationId !== undefined || data.content !== undefined) { + await this.validateTemplateSignature( + data.signatureId === undefined ? template.signatureId : data.signatureId, + template.tenantId, + data.applicationId ?? template.applicationId, + data.content ?? template.content, + ); + } + const variables = + data.content !== undefined || data.variables !== undefined + ? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables) + : undefined; + const materialChanged = + (data.applicationId !== undefined && data.applicationId !== template.applicationId) || + (data.signatureId !== undefined && data.signatureId !== template.signatureId) || + (data.content !== undefined && data.content !== template.content) || + (data.category !== undefined && data.category !== template.category) || + data.variables !== undefined; + const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus; + return this.prisma.$transaction(async (tx) => { + if (variables) { + await tx.templateVariable.deleteMany({ where: { templateId } }); + } + return tx.smsTemplate.update({ + where: { id: templateId }, data: { - tenantId: data.tenantId, applicationId: data.applicationId, + optOutRules: data.applicationId && data.applicationId !== template.applicationId ? [] : undefined, signatureId: data.signatureId, name: data.name, content: data.content, category: data.category, - auditStatus: options.initialAuditStatus, - billingUnits: estimateBillingUnits(data.content), - variables: { - create: variables.map((variable: TemplateVariableInput) => ({ - name: variable.name, - example: variable.example, - required: variable.required ?? true, - })), - }, + auditStatus, + rejectReason: auditStatus === 'pending' ? null : undefined, + billingUnits: data.content ? estimateBillingUnits(data.content) : undefined, + variables: variables + ? { + create: variables.map((variable) => ({ + name: variable.name, + example: variable.example, + required: variable.required ?? true, + })), + } + : undefined, }, include: { variables: true, application: true, tenant: true, signature: true }, }); - } - - async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { - const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template || (tenantId && template.tenantId !== tenantId)) { - throw new NotFoundException('Template not found'); - } - if (data.applicationId) { - const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); - if (!application || application.tenantId !== template.tenantId) { - throw new BadRequestException('applicationId does not belong to the template tenant'); - } - } - if (data.signatureId !== undefined || data.applicationId !== undefined || data.content !== undefined) { - await this.validateTemplateSignature( - data.signatureId === undefined ? template.signatureId : data.signatureId, - template.tenantId, - data.applicationId ?? template.applicationId, - data.content ?? template.content, - ); - } - const variables = data.content !== undefined || data.variables !== undefined - ? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables) - : undefined; - const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId) - || (data.signatureId !== undefined && data.signatureId !== template.signatureId) - || (data.content !== undefined && data.content !== template.content) - || (data.category !== undefined && data.category !== template.category) - || data.variables !== undefined; - const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus; - return this.prisma.$transaction(async (tx) => { - if (variables) { - await tx.templateVariable.deleteMany({ where: { templateId } }); - } - return tx.smsTemplate.update({ - where: { id: templateId }, - data: { - applicationId: data.applicationId, - signatureId: data.signatureId, - name: data.name, - content: data.content, - category: data.category, - auditStatus, - rejectReason: auditStatus === 'pending' ? null : undefined, - billingUnits: data.content ? estimateBillingUnits(data.content) : undefined, - variables: variables ? { - create: variables.map((variable) => ({ - name: variable.name, - example: variable.example, - required: variable.required ?? true, - })), - } : undefined, - }, - include: { variables: true, application: true, tenant: true, signature: true }, - }); - }); - } + }); + } async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { - const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found'); - if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { - throw new BadRequestException('当前审核状态不允许修改模板'); - } - const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId); - await this.audit.createAuditRecord({ - tenantId: current.tenantId, - targetType: 'sms_template', - targetId: templateId, - action: 'client_update_submit', - statusBefore: current.auditStatus, - statusAfter: 'pending', - }); - return updated; + const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found'); + if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { + throw new BadRequestException('当前审核状态不允许修改模板'); } + const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId); + await this.audit.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_template', + targetId: templateId, + action: 'client_update_submit', + statusBefore: current.auditStatus, + statusAfter: 'pending', + }); + return updated; + } async submitTemplate(templateId: string, tenantId?: string) { - const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) { - throw new NotFoundException('Template not found'); - } - await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content); - - const updated = await this.prisma.smsTemplate.update({ - where: { id: templateId }, - data: { auditStatus: 'pending', rejectReason: null }, - }); - await this.audit.createAuditRecord({ - tenantId: template.tenantId, - targetType: 'sms_template', - targetId: templateId, - action: 'submit', - statusBefore: template.auditStatus, - statusAfter: 'pending', - }); - return updated; + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) { + throw new NotFoundException('Template not found'); } + await this.validateTemplateSignature( + template.signatureId, + template.tenantId, + template.applicationId, + template.content, + ); - async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) { - if (!signatureId) { - throw new BadRequestException('短信模板必须选择短信签名'); - } - const signature = await this.prisma.smsSignature.findUnique({ - where: { id: signatureId }, - select: { tenantId: true, applicationId: true, name: true }, - }); - if (!signature || signature.tenantId !== tenantId) { - throw new BadRequestException('signatureId does not belong to the template tenant'); - } - if (signature.applicationId && signature.applicationId !== applicationId) { - throw new BadRequestException('signatureId does not belong to the template application'); - } - const signaturePrefix = normalizeSmsSignature(signature.name); - if (!signaturePrefix || !content.startsWith(signaturePrefix)) { - throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`); - } + const updated = await this.prisma.smsTemplate.update({ + where: { id: templateId }, + data: { auditStatus: 'pending', rejectReason: null }, + }); + await this.audit.createAuditRecord({ + tenantId: template.tenantId, + targetType: 'sms_template', + targetId: templateId, + action: 'submit', + statusBefore: template.auditStatus, + statusAfter: 'pending', + }); + return updated; + } + + async validateTemplateSignature( + signatureId: string | null | undefined, + tenantId: string, + applicationId: string, + content: string, + ) { + if (!signatureId) { + throw new BadRequestException('短信模板必须选择短信签名'); } + const signature = await this.prisma.smsSignature.findUnique({ + where: { id: signatureId }, + select: { tenantId: true, applicationId: true, name: true }, + }); + if (!signature || signature.tenantId !== tenantId) { + throw new BadRequestException('signatureId does not belong to the template tenant'); + } + if (signature.applicationId && signature.applicationId !== applicationId) { + throw new BadRequestException('signatureId does not belong to the template application'); + } + const signaturePrefix = normalizeSmsSignature(signature.name); + if (!signaturePrefix || !content.startsWith(signaturePrefix)) { + throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`); + } + } } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index b82f2b6..8705213 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2374,3 +2374,13 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后 ## 2026-09-18 长短信回执终态与归属补充 最终失败(含明确回执超时)与成功必须保持消息、账务、客户通知一致。后续同次失败分片不重复选路或退款;矛盾成功/失败回执只留原始事实并生成异常,不自动改账或重发客户通知。unknown、缺分片、普通提交超时仍可接续。回执须按业务消息、手机号、逻辑通道/上游身份和唯一发送尝试共同匹配;相同Msg_Id不能跨尝试批量更新,有歧义留待匹配。设计见phase-4-send-pipeline-redesign.md第10.13节,测试见TC-RC-20260918-01~07。此次不改协议、数据库结构和线上历史数据。 + + +## 2026-09-20 签名质量、通道能力与模板拒收指令 + +1. 签名质量成功率条按业务短信总提交数展示已到达、提交失败、回执失败、未收到回执四段,合计100%;灰色未知段的数量、比例仅悬停展示,零提交空轨道,不拆成多条。当前查询和新生成日报将没有明确失败回执的超时归未知;既有冻结日报保留原口径,不因查询重算。 +2. 通道允许减少运营商能力;保留通道组引用,发送选路按当前能力排除不支持运营商。不自动改动客户通道组或历史报备。 +3. 企业模板管理可按所属应用通道组中的通道配置固定末尾指令“拒收请回复R”的增加/删除;默认保持原文。明确模板不串用另一模板规则;无模板ID时独立匹配有效已审核模板,包括 direct_send 应用,未匹配内容不受影响。 +4. “避免影响消息分片数”固定选中,接口不可关闭;增删均保持计费单位与Gateway编码分片数,否则原文发送。只处理末尾精确指令,不修改正文、标点。重试换通道从原文计算,不叠加。 +5. 短信列表显示提交通道的实际内容,详情保留原始内容及改写过消息的各次提交快照;客户端保留自身原文查看能力。现有计费、回执和报表单位不变。 +6. 设计及兼容边界见 [模板拒收策略方案](template-optout-policy-design-20260920.md)。本轮授权本地修改和提交,不推送或部署。 diff --git a/docs/phase-4-send-pipeline-redesign.md b/docs/phase-4-send-pipeline-redesign.md index 96449aa..5e6481e 100644 --- a/docs/phase-4-send-pipeline-redesign.md +++ b/docs/phase-4-send-pipeline-redesign.md @@ -363,3 +363,8 @@ a350aca测试环境长短信验收发现两条消息首尝试分别仅写出2/4 - 分片写入限制messageRecordId、submitRecordId/明确submitId及channelId;不得仅凭messageRecordId+gatewayMessageId批量跨尝试更新。提交分片记录同样在明确submitId存在时优先精确匹配,避免OR条件被另一尝试同Msg_Id干扰。 - API和协议不变、鉴权/租户规则不变、无新权限。冲突/未确认回执继续走现有Inbox恢复与人工排查。发布回退只涉及应用;不自动重放已完成事件或历史退款。 - 验收:真实PG验证顺序/并发失败分片仅一次终态选路,矛盾回执账务/通知/分片均不逆转,跨通道与同通道碰撞拒绝或准确关联,同供应商跨连接、早到回执、unknown转成功、旧尝试迟到及工作故障恢复。隔离固定输入比较选路次数;不能将隔离开销降低推算为线上CPU降幅。 + + +## 2026-09-20 模板拒收指令补充 + +参见 [模板拒收指令策略](template-optout-policy-design-20260920.md)。发送链在选路候选阶段按模板/通道生成内容快照,敏感词按各候选真实内容评估;每次尝试从不可变原文开始。Submit、消息内容与Outbox同事务保存,Gateway授权校验使用对应Submit的内容快照。保留既有计费单位、原收尾状态机及Outbox稳定提交身份,配置变化不改写已生成命令。未配置模板规则时正文保持原样;明确模板ID不串用其他模板规则。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 6c3873d..95b6c37 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5693,3 +5693,25 @@ TC-SQA-01~14:真实隔离PG覆盖核心日期/日报/长短信/事务/分页 | TC-RC-20260918-07 | 同供应商跨连接唯一匹配、歧义拒绝、身份变更、手机号不符、跨租户关系、历史submitId、候选截断和72小时超时恢复 | receipt-attempt-resolver.spec.ts,12项隔离单测;历史分片补关联并齐片完成另有真实PG用例 | 既有TC-RC-20260916收尾并发与故障用例继续执行tools/testing/verify-attempt-completion.mjs(真实PG、双OS进程、事务回滚、fence、一次补发Outbox、非零扣退费、通知持久化)。本轮不启动Gateway、Redis消费者或网络通知投递,不以数据库集成代替线上完整短信链路验收;目标环境与线上CPU改善待另行授权发布后验证。 + + +## 2026-09-20 模板拒收指令及运营界面验收 + +设计:[模板拒收策略](template-optout-policy-design-20260920.md)。所有写入夹具限本地隔离数据库,不能据此向测试/预生产发送短信。 + +| 编号 | 场景 | 预期 | +|---|---|---| +| TC-OPT-20260920-01 | 四类混合、零提交、统计不一致 | 单条四段合计100%,未知灰色且仅悬停显示数量比例;零提交空条;不一致明确提示而不编造比例 | +| TC-OPT-20260920-02 | 超时无回执及明确失败回执 | 当前质量查询分别计入未知/回执失败;历史冻结报表不重算 | +| TC-OPT-20260920-03 | 活动通道组仍引用通道,缩减运营商 | 保存成功,组引用保留,被移除运营商不能选该通道,不触发无关重连 | +| TC-OPT-20260920-04 | 策略GET/PUT、未登录/客户端、重复/非法/外应用通道 | 鉴权拒绝非法入口,所属应用范围严格校验,有效保存留审计 | +| TC-OPT-20260920-05 | 固定/变量模板、direct_send、明确其他模板、无匹配 | 指定模板准确命中,direct_send不绕过策略,其他短信原文不变 | +| TC-OPT-20260920-06 | 69→75、71→77、删除跨70字、Unicode代理对 | 同计费单位且同Gateway分片才执行;否则跳过;保护框默认选中且不可取消 | +| TC-OPT-20260920-07 | 正文出现指令、末尾重复添加、补发换通道 | 正文不删、添加不叠加、换通道从原文重新计算 | +| TC-OPT-20260920-08 | 通道敏感词与增删策略同时存在 | 按各候选真实改写内容筛选通道,保存对应内容摘要 | +| TC-OPT-20260920-09 | 单条/微批/补发、事务中断、配置变化 | 消息/Submit/Outbox一致,失败一起回滚,旧命令快照不变化,费用及分片单位不变化 | +| TC-OPT-20260920-10 | 短信列表和详情,历史空字段 | 列表真实提交内容,详情原文及尝试快照,历史空字段正常,客户端仅返回自身消息 | +| TC-OPT-20260920-11 | API失败、重试、通道组移除后失效规则 | 无假成功,输入保留,显式删除失效规则后可保存 | +| TC-OPT-20260920-12 | 1600×1000/1366×768/390×844 | 进度条不换行;模板保存、通道缩减、原文详情、刷新跨路由正常,无控制台异常 | + +代码级与真实本地API/PG验收分别见 testing-progress.md;无运营商真实发送授权,因此不将Outbox构造/回滚测试称为真实短信送达验收。 diff --git a/docs/template-optout-policy-design-20260920.md b/docs/template-optout-policy-design-20260920.md new file mode 100644 index 0000000..8927ec2 --- /dev/null +++ b/docs/template-optout-policy-design-20260920.md @@ -0,0 +1,28 @@ +# 模板拒收指令策略与运营页面修正 + +日期:2026-09-20。状态:本地已实施,隔离API/PG及三尺寸浏览器验收通过;未推送、未部署、未进行运营商发送。执行证据见 testing-progress.md。本方案补充发送链路设计,不替代其事务、账务、回执和 Outbox 规则。 + +## 业务规则与影响 + +- 签名质量列表使用一个四段横向条,按总提交数计算已到达、提交失败、回执失败、未收到回执的占比;未知使用灰色,数量及比例只放悬停提示。保留筛选、分页和详情。零提交显示空轨道。 +- 通道缩减运营商能力允许保存,保留已有通道组引用及历史报备;选路实时按通道能力过滤,失去可用通道时沿用既有无路由失败处理,不偷偷迁移客户配置。恢复能力后原引用可继续使用。验收发现既有窄屏查询按钮遮挡与运营商选项溢出,同页CSS增加780px以下单列/换行规则;保持桌面及筛选语义不变,所有权清单更新对应已验收摘要。 +- 运营端企业模板列表增加“拒收指令”配置入口。按模板及应用通道组中的通道选择“保持原文 / 末尾增加 / 末尾删除”,固定指令为 `拒收请回复R`。只删除末尾精确匹配的指令,不删除正文相似字样,不改其他文字或标点。重复增加不叠加。 +- “避免影响消息分片数”固定选中,后端也不接受关闭。增加和删除都必须同时保持原计费单位和 Gateway 实际编码分片数,否则原文发送并保留跳过原因。不能以本需求修改计费、回执或报表口径。 +- 仅匹配当前企业、应用下的有效已审核模板;存在明确模板ID时仅使用该模板规则,不串用其他模板。没有模板ID时按精确正文、变量模板匹配(沿用模板匹配规则和排序)。即使 direct_send 绕过模板准入,发送前仍独立识别策略模板;完全不匹配任何模板的短信保持原文。配置不赋予未审核模板发送权限。 +- 每次选路基于不可变原文产生该通道的候选内容;通道敏感词继续检查实际候选内容。换通道补发重新从原文计算,不能累计增删。已持久化的 Submit/Outbox 使用当时快照,不因配置修改而重写。 + +## 数据与接口 + +- SmsTemplate 增加 optOutRules JSON(缺省空数组),每项 channelId/action;后台验证动作、重复通道、所属应用的活动通道组成员关系。仅运营端专用 GET/PUT enterprise-templates/:id/opt-out-policy;客户端模板编辑不接受此字段。配置变更留操作审计。 +- SmsMessageRecord 增加 nullable originalContent;首次改变时保留输入原文,后续永久保留。content 沿用发送内容字段,在提交事务内与 Submit 和 Outbox 一致更新。 +- SmsSubmitRecord 增加 nullable sentContent 及 contentPolicy JSON,记录每次尝试内容、命中模板/动作和应用或跳过原因。迁移不重写历史短信;旧记录字段为空时维持原展示。 +- 发送列表展示最近一次提交内容,详情在改写过时另列原始内容;已排队未获得供应商受理不能标称送达。尝试快照用于历史通道发送内容追溯。 +- 单条、微批和补发统一使用相同改写函数,所有费用及计费单位保持不变;数据库事务失败不留下单独内容改写,Outbox 重发不重新计算策略。 + +## 验收与兼容 + +验证四类加和、零数据、悬停、通道缩减/恢复/不可路由;策略越权及非法参数、变量模板和 direct_send、无匹配不影响其他短信、69→75跳过/71→77执行、删除跨分片跳过、Unicode与编码边界、多通道补发不叠加、事务失败回滚、Outbox 快照稳定、原文详情及三尺寸页面。 + +使用隔离 PostgreSQL、真实 API/Redis和页面验证;不连接运营商发送,不操作线上客户或通道配置。执行定向及全量回归、类型检查、构建和质量门禁。线上验证和部署独立列为未执行。本轮只提交本轮代码与文档,不推送或部署。 + +质量分类补充:当前查询及今后生成的日报使用互斥四类,超时无明确失败回执归未知。已发布冻结日报不重算,其历史分类保留。进度条比例均用总提交数,原接口 successRate 字段保留兼容,不改变详情其他既有口径。未改变资金流水、客户回执数量、报表计费单位;本地验证的是生成意图及费用字段、事务一致性,并未执行运营商真实发送和资金结算。每次Submit额外保存一份内容快照用于追溯,归入原提交记录的留存治理范围。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 5428efb..c7e50a6 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -5186,3 +5186,16 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页 API全量81套/880项通过并达覆盖率门禁(语句67.73%、分支52.89%、函数68.76%、行70.60%);最终小幅兼容调整后定向2套/150项及八组真实PG、十一组并发恢复复跑通过。TypeScript生产构建通过;本轮文件ESLint零错误、30条既有any警告,Prettier及diff检查通过。新匹配器单测行覆盖100%、分支89.65%。早期旧mock未提供真实关联造成14项失败,补全关系及查询形状后通过;未降低关联约束。真实PG保留pg驱动并发query弃用警告,未出现事务失败。 可重复运行:先构建api并迁移独立本机cmpp_qa_*库,设置COMPLETION_TEST_DATABASE_URL后运行tools/testing/verify-receipt-finality.mjs和verify-attempt-completion.mjs;脚本拒绝非本机或非隔离库。原始日志保留.local-data/receipt-fix-20260918/,不进Git。此轮仅调用真实后端服务/持久层,不启动Gateway、HTTP通知投递或在线发送。前端/Go未变,未重跑其验收;目标环境、Redis/Gateway完整网络闭环、线上CPU降幅未验证。不得把本地通过视作测试/预生产已修复。本地隔离数据库进程收尾关闭,数据及日志保留。 + +## 2026-09-20 签名质量四段条、通道运营商缩减及模板拒收策略 + +- 基线:本地main c20c2246b263b22a6a84c8ac10d9f320197a8fb6,实际远端main 5e4d644788b453528e513b1c14c28b120e221b64;开始暂存区为空。保留版本3.0、metrics、发布工具/部署脚本、手机号规则迁移及其他草稿,不纳入本轮提交。 +- 需求/设计:first-version-development-requirements.md“2026-09-20”节、template-optout-policy-design-20260920.md、phase-4-send-pipeline-redesign.md本轮补充;用例TC-OPT-20260920-01~12。 +- 根因:原质量条仅绘制成功比例;通道后端在检测到被活动通道组引用的已移除运营商时直接拒绝;原发送链没有模板/通道内容改写及尝试内容快照。本轮改为互斥四段条、仅悬停未知数据;允许能力缩减并保留原路由能力过滤;模板策略独立匹配且不得改变计费及Gateway分片数,单条/微批/补发共享,原文/Submit/Outbox同事务。Gateway授权按尝试快照核对,短信详情增加原文与尝试内容,客户端只返回自身原文。 +- 迁移:新增20260920090000_template_optout_policy,旧内容不重写。初次工作区数据库包含113条迁移;随后复制原已提交迁移及本轮迁移,排除未提交20260918110000_refine_mobile_drainage_prefixes,在独立cmpp_qa_optout_commit_20260920执行112条迁移全部通过,并重新执行真实API/PG和浏览器验收。 +- 自动验证:API全量85套/971例通过,覆盖率语句67.95%、分支53.55%、函数68.91%、行70.72%;前端37文件/168例通过,现有覆盖率门禁88.48%/85.09%/84%/88.19%。TypeScript、API构建、前端production构建、npm run lint、format:check、stylelint/CSS治理、bundle:verify及git diff --check通过;lint保留9条既有hooks警告,production大chunk提示但包体门禁通过。Gateway go test ./...及go vet ./...通过,队列契约未改动。 +- 初次覆盖率运行并行竞争资源,前端24例触发原5000ms超时;串行安排并限制maxWorkers=2后168例全通过,没有放宽时限或断言。新增通道单测首次缺少required desiredConnections导致类型失败,补齐测试夹具后971例全通过。原失败记录保留。 +- 真实验收:tools/testing/verify-template-optout.mjs使用真实NestJS、PostgreSQL16及本机Redis5.0.14.1(运行库提示建议6.2+,已记录环境差异)。验证登录/客户端入口拒绝、外应用/非法策略拒绝、固定分片保护、审计持久化、活动通道组引用下缩减保存、direct_send无templateId仍应用、单条及微批快照、换通道补发恢复原文、费用字段不变、事务回滚及旧Outbox快照不变;Outbox全部pending,未启动Gateway/SMSC或发布器,未实际发短信。 +- 浏览器:当前环境无Browser插件,按前端验收技能使用独立Playwright/Edge、真实API及会话,1600×1000、1366×768、390×844分别验证策略保存、固定勾选、四段条、刷新/跨路由、原文/尝试详情、通道弹窗减少运营商后保存及PG回读,无pageerror。发现并修复既有窄屏查询遮挡/弹窗运营商溢出,仅增加同页响应式规则,保持桌面布局;已查看截图核对。 +- 证据:.local-data/template-optout-20260920/ 下 api-coverage-final.log、frontend-coverage-final.log、migrations-commit.log、browser-accepted.log、lint-accepted.log、format-accepted.log、api-build-accepted.log、build-accepted.log、bundle-accepted.log、go-test.log、go-vet.log及template/quality/detail/channel三尺寸截图。验证脚本不保存会话或密码,fixture.json仅含隔离业务ID。 +- 边界/未执行:未操作测试或预生产配置;未推送、未部署、未执行运营商发送/实际回执推送/资金结算;冻结历史报表不重算。测试证明本地真实持久化和页面,不能冒称线上或实际送达验收。提交仅纳入本轮源文件、迁移和本轮文档增量,提交号以本轮Git提交为准。 diff --git a/src/api/admin/governance.api.ts b/src/api/admin/governance.api.ts index 639ccc4..fe8cd74 100644 --- a/src/api/admin/governance.api.ts +++ b/src/api/admin/governance.api.ts @@ -1,5 +1,29 @@ -import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; -import type { AdministrativeRegion, AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types'; +import { request, withQuery } from '../core/httpClient'; +import type { + AdministrativeRegion, + AuditRecord, + ClientSmsSignature, + ClientSmsTemplate, + CommonReportField, + DictionaryItem, + DrainageDetectionResult, + DrainageDetectionRule, + EnterpriseCertification, + ManualRechargePreflight, + ManualRechargeResult, + PagedResult, + PhoneFrequencyHit, + PhoneFrequencyWhitelistItem, + RechargeOrder, + ReviewDecisionResult, + ReviewPreflight, + RiskReviewTask, + RiskRuleItem, + RiskTaskMessagePage, + SmsDrainageInfo, + SmsTemplateAudit, + TenantAccount, +} from '../types'; // Review, risk and billing mutations keep their original URLs, payloads and // response types behind one governance boundary. @@ -7,15 +31,38 @@ export const adminGovernanceApi = { listAdministrativeRegions: () => request('/admin/dictionaries/administrative-regions'), listAccounts: () => request('/admin/billing/accounts'), updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) => - request(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }), - listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), - listManualRechargesPage: (query: { enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/billing/manual-recharges', query)), + request(`/admin/billing/accounts/${tenantId}/credit-limit`, { + method: 'POST', + body: JSON.stringify(body), + }), + listManualRecharges: (tenantId?: string) => + request(withQuery('/admin/billing/manual-recharges', { tenantId })), + listManualRechargesPage: (query: { + enterpriseKeyword?: string; + createdAtFrom?: string; + createdAtTo?: string; + page: number; + pageSize: number; + }) => request>(withQuery('/admin/billing/manual-recharges', query)), preflightManualRecharge: (body: { tenantId: string; amountCents: number }) => - request('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }), - createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) => + request('/admin/billing/manual-recharges/preflight', { + method: 'POST', + body: JSON.stringify(body), + }), + createManualRecharge: (body: { + tenantId: string; + amountCents: number; + expectedAccountUpdatedAt: string; + idempotencyKey: string; + remark?: string; + }) => request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), - listTemplateAudits: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => { + listTemplateAudits: (query: { + keyword?: string; + status?: string; + submittedAtFrom?: string; + submittedAtTo?: string; + }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); if (query.status && query.status !== 'all') params.set('status', query.status); @@ -24,60 +71,187 @@ export const adminGovernanceApi = { const suffix = params.toString() ? `?${params}` : ''; return request(`/admin/enterprise-templates${suffix}`); }, - approveTemplate: (id: string) => request(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), - rejectTemplate: (id: string, reason = '运营审核驳回') => request(`/admin/templates/${id}/reject`, { - method: 'POST', - body: JSON.stringify({ reason }), - }), - approveSignature: (id: string) => request(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), - rejectSignature: (id: string, reason = '运营审核驳回') => request(`/admin/signatures/${id}/reject`, { - method: 'POST', - body: JSON.stringify({ reason }), - }), + approveTemplate: (id: string) => + request(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), + rejectTemplate: (id: string, reason = '运营审核驳回') => + request(`/admin/templates/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + approveSignature: (id: string) => + request(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), + rejectSignature: (id: string, reason = '运营审核驳回') => + request(`/admin/signatures/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), getReviewPreflight: (type: 'signature' | 'template', id: string) => request(`/admin/reviews/${type}/${id}/preflight`), - submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) => - request(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), - listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => - request(withQuery('/admin/enterprise-signatures', query)), - listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) => - request & { pendingReportMaterialTotal: number; pendingReportDetailTotal: number }>(withQuery('/admin/enterprise-signatures', query)), + submitReviewDecision: ( + type: 'signature' | 'template', + id: string, + body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }, + ) => + request(`/admin/reviews/${type}/${id}/decision`, { + method: 'POST', + body: JSON.stringify(body), + }), + listEnterpriseSignatures: ( + query: { + tenantId?: string; + keyword?: string; + status?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + signatureKeyword?: string; + drainageKeyword?: string; + submittedAtFrom?: string; + submittedAtTo?: string; + } = {}, + ) => request(withQuery('/admin/enterprise-signatures', query)), + listEnterpriseSignaturesPage: (query: { + tenantId?: string; + keyword?: string; + status?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + signatureKeyword?: string; + drainageKeyword?: string; + page: number; + pageSize: number; + }) => + request & { pendingReportMaterialTotal: number; pendingReportDetailTotal: number }>( + withQuery('/admin/enterprise-signatures', query), + ), listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) => request(withQuery('/admin/enterprise-signature-options', query)), getEnterpriseSignature: (id: string) => request(`/admin/enterprise-signatures/${id}`), - getEnterpriseSignatureReportTargets: (id: string) => request>(`/admin/enterprise-signatures/${id}/report-targets`), - getDrainageInfoReportTargets: (id: string) => request[string]>(`/admin/drainage-infos/${id}/report-targets`), - createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => - request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), - updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record }) => - request(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + getEnterpriseSignatureReportTargets: (id: string) => + request>(`/admin/enterprise-signatures/${id}/report-targets`), + getDrainageInfoReportTargets: (id: string) => + request[string]>( + `/admin/drainage-infos/${id}/report-targets`, + ), + createEnterpriseSignature: (body: { + tenantId: string; + applicationId?: string; + name: string; + purpose?: string; + drainageInfo?: Record; + }) => request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), + updateEnterpriseSignature: ( + id: string, + body: { + applicationId?: string | null; + name?: string; + purpose?: string; + auditStatus?: string; + drainageInfo?: Record; + }, + ) => request(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) => - request(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), - listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => - request(withQuery('/admin/drainage-infos', query)), + request(`/admin/enterprise-signatures/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status, reason }), + }), + listDrainageInfos: ( + query: { + tenantId?: string; + signatureId?: string; + keyword?: string; + status?: string; + submittedAtFrom?: string; + submittedAtTo?: string; + } = {}, + ) => request(withQuery('/admin/drainage-infos', query)), listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) => request(withQuery('/admin/audit-records', query)), - createDrainageInfo: (signatureId: string, body: { url: string; remark?: string; reportValues?: Record }) => - request(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }), + createDrainageInfo: ( + signatureId: string, + body: { url: string; remark?: string; reportValues?: Record }, + ) => + request(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { + method: 'POST', + body: JSON.stringify(body), + }), updateDrainageInfo: (id: string, body: { url?: string; remark?: string; reportValues?: Record }) => request(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }), approveDrainageInfo: (id: string) => request(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), rejectDrainageInfo: (id: string, reason: string) => - request(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), + request(`/admin/drainage-infos/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), changeDrainageInfoStatus: (id: string, status: string, reason?: string) => - request(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), - listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) => - request(withQuery('/admin/enterprise-templates', query)), - listEnterpriseTemplatesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/enterprise-templates', query)), - createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => - request('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), - updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => - request(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + request(`/admin/drainage-infos/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status, reason }), + }), + listEnterpriseTemplates: ( + query: { + tenantId?: string; + keyword?: string; + status?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + nameKeyword?: string; + contentKeyword?: string; + } = {}, + ) => request(withQuery('/admin/enterprise-templates', query)), + listEnterpriseTemplatesPage: (query: { + tenantId?: string; + keyword?: string; + status?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + nameKeyword?: string; + contentKeyword?: string; + page: number; + pageSize: number; + }) => request>(withQuery('/admin/enterprise-templates', query)), + createEnterpriseTemplate: (body: { + tenantId: string; + applicationId: string; + signatureId?: string; + name: string; + content: string; + category?: string; + variables?: Array<{ name: string; example?: string; required?: boolean }>; + }) => request('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), + updateEnterpriseTemplate: ( + id: string, + body: { + applicationId?: string; + signatureId?: string | null; + name?: string; + content?: string; + category?: string; + auditStatus?: string; + variables?: Array<{ name: string; example?: string; required?: boolean }>; + }, + ) => request(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) => - request(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), - listEnterpriseCertifications: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => { + request(`/admin/enterprise-templates/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status, reason }), + }), + getTemplateOptOutPolicy: (id: string) => + request<{ + rules: Array<{ channelId: string; action: 'add' | 'remove' }>; + preserveFragments: boolean; + channels: Array<{ id: string; name: string; groupNames: string[] }>; + }>(`/admin/enterprise-templates/${id}/opt-out-policy`), + updateTemplateOptOutPolicy: ( + id: string, + body: { rules: Array<{ channelId: string; action: 'add' | 'remove' }>; preserveFragments: true }, + ) => request(`/admin/enterprise-templates/${id}/opt-out-policy`, { method: 'PUT', body: JSON.stringify(body) }), + listEnterpriseCertifications: (query: { + keyword?: string; + status?: string; + submittedAtFrom?: string; + submittedAtTo?: string; + }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); if (query.status && query.status !== 'all') params.set('status', query.status); @@ -86,16 +260,21 @@ export const adminGovernanceApi = { const suffix = params.toString() ? `?${params}` : ''; return request(`/admin/enterprise-certifications${suffix}`); }, - getEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}`), - approveEnterpriseCertification: (id: string) => request(`/admin/enterprise-certifications/${id}/approve`, { - method: 'POST', - body: JSON.stringify({}), - }), - rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => request(`/admin/enterprise-certifications/${id}/reject`, { - method: 'POST', - body: JSON.stringify({ reason }), - }), - listRiskReviewTasks: (query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), + getEnterpriseCertification: (id: string) => + request(`/admin/enterprise-certifications/${id}`), + approveEnterpriseCertification: (id: string) => + request(`/admin/enterprise-certifications/${id}/approve`, { + method: 'POST', + body: JSON.stringify({}), + }), + rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => + request(`/admin/enterprise-certifications/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + listRiskReviewTasks: ( + query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}, + ) => request(withQuery('/admin/risk-review/tasks', query)), listRiskRules: (applicationId?: string) => request(withQuery('/admin/risk-review/rules', { applicationId })), createRiskRule: (body: { @@ -107,55 +286,68 @@ export const adminGovernanceApi = { priority?: number; config?: RiskRuleItem['config']; }) => request('/admin/risk-review/rules', { method: 'POST', body: JSON.stringify(body) }), - updateRiskRule: (id: string, body: { - thresholdValue?: number; - action?: RiskRuleItem['action']; - status?: RiskRuleItem['status']; - priority?: number; - config?: RiskRuleItem['config']; - }) => request(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - listPhoneFrequencyHits: (query: { - tenantId?: string; - applicationId?: string; - phoneNumber?: string; - status?: 'active' | 'expired' | 'released'; - createdAtFrom?: string; - createdAtTo?: string; - page?: number; - pageSize?: number; - } = {}) => request>(withQuery('/admin/risk-review/phone-frequency-hits', query)), + updateRiskRule: ( + id: string, + body: { + thresholdValue?: number; + action?: RiskRuleItem['action']; + status?: RiskRuleItem['status']; + priority?: number; + config?: RiskRuleItem['config']; + }, + ) => request(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + listPhoneFrequencyHits: ( + query: { + tenantId?: string; + applicationId?: string; + phoneNumber?: string; + status?: 'active' | 'expired' | 'released'; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + } = {}, + ) => request>(withQuery('/admin/risk-review/phone-frequency-hits', query)), releasePhoneFrequencyHit: (id: string, reason: string) => request(`/admin/risk-review/phone-frequency-hits/${id}/release`, { method: 'POST', body: JSON.stringify({ reason }), }), - listPhoneFrequencyWhitelist: (query: { - phoneNumber?: string; - keyword?: string; - status?: 'active' | 'inactive' | 'deleted'; - updatedAtFrom?: string; - updatedAtTo?: string; - page?: number; - pageSize?: number; - } = {}) => request>(withQuery('/admin/risk-review/phone-frequency-whitelist', query)), + listPhoneFrequencyWhitelist: ( + query: { + phoneNumber?: string; + keyword?: string; + status?: 'active' | 'inactive' | 'deleted'; + updatedAtFrom?: string; + updatedAtTo?: string; + page?: number; + pageSize?: number; + } = {}, + ) => + request>(withQuery('/admin/risk-review/phone-frequency-whitelist', query)), createPhoneFrequencyWhitelist: (body: { phoneNumber: string; reason: string; remark?: string; status?: 'active' | 'inactive'; - }) => request('/admin/risk-review/phone-frequency-whitelist', { - method: 'POST', - body: JSON.stringify(body), - }), - updatePhoneFrequencyWhitelist: (id: string, body: { - phoneNumber?: string; - reason?: string; - remark?: string; - status?: 'active' | 'inactive'; - }) => request(`/admin/risk-review/phone-frequency-whitelist/${id}`, { - method: 'PUT', - body: JSON.stringify(body), - }), + }) => + request('/admin/risk-review/phone-frequency-whitelist', { + method: 'POST', + body: JSON.stringify(body), + }), + updatePhoneFrequencyWhitelist: ( + id: string, + body: { + phoneNumber?: string; + reason?: string; + remark?: string; + status?: 'active' | 'inactive'; + }, + ) => + request(`/admin/risk-review/phone-frequency-whitelist/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + }), deletePhoneFrequencyWhitelist: (id: string, reason: string) => request(`/admin/risk-review/phone-frequency-whitelist/${id}`, { method: 'DELETE', @@ -164,58 +356,148 @@ export const adminGovernanceApi = { listRiskReviewTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => request(withQuery(`/admin/risk-review/tasks/${id}/messages`, query)), approveRiskReviewTask: (id: string, reason?: string) => - request(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }), + request(`/admin/risk-review/tasks/${id}/approve`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), rejectRiskReviewTask: (id: string, reason?: string) => - request(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), + request(`/admin/risk-review/tasks/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), rejectRiskReviewTasks: (ids: string[], reason: string) => - request('/admin/risk-review/tasks/batch/reject', { method: 'POST', body: JSON.stringify({ ids, reason }) }), - listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/sensitive-words', query)), + request('/admin/risk-review/tasks/batch/reject', { + method: 'POST', + body: JSON.stringify({ ids, reason }), + }), + listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => + request(withQuery('/admin/dictionaries/sensitive-words', query)), createSensitiveWord: (body: { word: string; level?: string; status?: string }) => request('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }), - deleteSensitiveWord: (id: string) => request(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }), - listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/global', query)), + deleteSensitiveWord: (id: string) => + request(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }), + listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => + request(withQuery('/admin/dictionaries/blacklists/global', query)), createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => request('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), - deleteGlobalBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), - listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/enterprise', query)), - createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => - request('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), - deleteEnterpriseBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), + deleteGlobalBlacklist: (id: string) => + request(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), + listEnterpriseBlacklist: ( + query: { + tenantId?: string; + applicationId?: string; + keyword?: string; + status?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + phoneNumber?: string; + reasonKeyword?: string; + } = {}, + ) => request(withQuery('/admin/dictionaries/blacklists/enterprise', query)), + createEnterpriseBlacklist: (body: { + tenantId: string; + applicationId: string; + phoneNumber: string; + reason?: string; + status?: string; + operatorId?: string; + }) => + request('/admin/dictionaries/blacklists/enterprise', { + method: 'POST', + body: JSON.stringify(body), + }), + deleteEnterpriseBlacklist: (id: string) => + request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => - request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)), + request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>( + withQuery('/admin/dictionaries/phone-segments', query), + ), createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), - deletePhoneSegment: (id: string) => request(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }), + deletePhoneSegment: (id: string) => + request(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }), listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => - request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)), - createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => + request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>( + withQuery('/admin/dictionaries/phone-carrier-rules', query), + ), + createPhoneCarrierRule: (body: { + carrier: string; + pattern: string; + priority?: number; + status?: string; + remark?: string; + }) => request('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), - deletePhoneCarrierRule: (id: string) => request(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }), + deletePhoneCarrierRule: (id: string) => + request(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }), listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), - createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => - request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), - updateDrainageField: (id: string, body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string }) => + createDrainageField: (body: { + code: string; + name: string; + fieldType: 'string' | 'image' | 'file'; + required?: boolean; + status?: string; + description?: string; + }) => request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), + updateDrainageField: ( + id: string, + body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string }, + ) => request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - deleteDrainageField: (id: string) => request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), + deleteDrainageField: (id: string) => + request(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }), listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/drainage-detection-rules', query)), createDrainageDetectionRule: (body: Omit) => - request('/admin/dictionaries/drainage-detection-rules', { method: 'POST', body: JSON.stringify(body) }), - updateDrainageDetectionRule: (id: string, body: Omit) => - request(`/admin/dictionaries/drainage-detection-rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + request('/admin/dictionaries/drainage-detection-rules', { + method: 'POST', + body: JSON.stringify(body), + }), + updateDrainageDetectionRule: ( + id: string, + body: Omit, + ) => + request(`/admin/dictionaries/drainage-detection-rules/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + }), changeDrainageDetectionRuleStatus: (id: string, status: 'active' | 'inactive') => - request(`/admin/dictionaries/drainage-detection-rules/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), - testDrainageDetectionRule: (body: { content: string; rule?: Omit }) => - request('/admin/dictionaries/drainage-detection-rules/test', { method: 'POST', body: JSON.stringify(body) }), + request(`/admin/dictionaries/drainage-detection-rules/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status }), + }), + testDrainageDetectionRule: (body: { + content: string; + rule?: Omit; + }) => + request('/admin/dictionaries/drainage-detection-rules/test', { + method: 'POST', + body: JSON.stringify(body), + }), listCommonReportFields: () => request('/admin/dictionaries/common-report-fields'), - createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => - request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), + createCommonReportField: (body: { + drainageFieldId: string; + reportType: 'signature' | 'drainage'; + required: boolean; + sortOrder?: number; + }) => + request('/admin/dictionaries/common-report-fields', { + method: 'POST', + body: JSON.stringify(body), + }), reorderCommonReportFields: (body: { reportType: 'signature' | 'drainage'; ids: string[] }) => request('/admin/dictionaries/common-report-fields/order', { method: 'PUT', body: JSON.stringify(body), }), - deleteCommonReportField: (id: string) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), - updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) => - request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + deleteCommonReportField: (id: string) => + request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), + updateCommonReportField: ( + id: string, + body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }, + ) => + request(`/admin/dictionaries/common-report-fields/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + }), }; diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts index e0992d1..c14e557 100644 --- a/src/api/types/operations.ts +++ b/src/api/types/operations.ts @@ -119,6 +119,7 @@ export type SendQualityResponse = { }; export type SmsMessageRecord = { + originalContent?: string | null; channelWordDecisions?: Array<{ id: string; decidedAt: string; @@ -185,6 +186,8 @@ export type SmsMessageRecord = { }; export type SmsSubmitRecord = { + sentContent?: string | null; + contentPolicy?: { templateId: string | null; action: string; reason: string; preserveFragments: boolean } | null; id: string; channelId: string; channelGroupName?: string | null; diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index deee5c1..2201413 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { QualityStatusBar } from './QualityStatusBar'; import { BarChart3, Eye, Search, X } from 'lucide-react'; import { adminApi, @@ -229,7 +230,7 @@ function AnalyticsPanel({ kind }: { kind: string }) { key: 'successRate', title: '成功率', width: '170px', - render: (record) => , + render: (record) => , }, { key: 'averageArrivalMs', @@ -928,17 +929,6 @@ function MatrixMetric({ ); } -function QualityRate({ value }: { value: number }) { - return ( -
-
- -
- {value.toFixed(1)}% -
- ); -} - function normalizeCarrier(value: string) { const normalized = value.toLowerCase(); if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile'; diff --git a/src/apps/admin/AdminEnterpriseTemplatesPage.tsx b/src/apps/admin/AdminEnterpriseTemplatesPage.tsx index 93f2d93..1172ca1 100644 --- a/src/apps/admin/AdminEnterpriseTemplatesPage.tsx +++ b/src/apps/admin/AdminEnterpriseTemplatesPage.tsx @@ -1,9 +1,27 @@ -import { useEffect, useRef, useState } from 'react'; -import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react'; -import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi'; +import { + adminApi, + type ClientSmsApplication, + type ClientSmsSignature, + type ClientSmsTemplate, + type TenantOption, +} from '@/api/adminApi'; +import { + Breadcrumb, + Button, + DeleteRiskAction, + Input, + Modal, + Pagination, + Select, + Tabs, + Tag, + Textarea, +} from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; -import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui'; import { replaceLeadingSmsSignature } from '@/utils/smsSignature'; +import { Edit3, Eye, Plus, Search } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { TemplateOptOutModal } from './TemplateOptOutModal'; type TemplateFormState = { tenantId: string; @@ -89,7 +107,7 @@ function TemplateFormModal({ const initialSignature = signatures.find((signature) => signature.id === item?.signatureId); const initialContent = item?.signatureId ? replaceLeadingSmsSignature(item.content, initialSignature?.name) - : item?.content ?? ''; + : (item?.content ?? ''); const [form, setForm] = useState({ tenantId: item?.tenantId ?? '', applicationId: item?.applicationId ?? '', @@ -97,16 +115,24 @@ function TemplateFormModal({ name: item?.name ?? '', content: initialContent, category: item?.category ?? '行业通知', - variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [], + variables: + item?.variables?.map((variable) => ({ + name: variable.name, + example: variable.example ?? undefined, + required: variable.required ?? true, + })) ?? [], }); - const initialForm = useRef(form).current; + const [initialForm] = useState(form); const dirty = JSON.stringify(form) !== JSON.stringify(initialForm); - const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted'); - const tenantSignatures = signatures.filter((signature) => ( - signature.tenantId === form.tenantId - && signature.auditStatus !== 'deleted' - && (!signature.applicationId || signature.applicationId === form.applicationId) - )); + const tenantApplications = applications.filter( + (application) => application.tenantId === form.tenantId && application.status !== 'deleted', + ); + const tenantSignatures = signatures.filter( + (signature) => + signature.tenantId === form.tenantId && + signature.auditStatus !== 'deleted' && + (!signature.applicationId || signature.applicationId === form.applicationId), + ); const currentVariables = form.variables.length ? form.variables : extractVariables(form.content); function update(key: Key, value: TemplateFormState[Key]) { @@ -142,7 +168,9 @@ function TemplateFormModal({ } function updateVariableExample(name: string, example: string) { - const variables = currentVariables.map((variable) => variable.name === name ? { ...variable, example } : variable); + const variables = currentVariables.map((variable) => + variable.name === name ? { ...variable, example } : variable, + ); update('variables', variables); } @@ -151,14 +179,26 @@ function TemplateFormModal({ dirty={dirty} footer={({ requestClose }) => ( <> - - + + )} onClose={onClose} open size="xl" - title={

{item ? '编辑短信模板' : '添加短信模板'}

模板内容和变量将写入真实后台。

} + title={ +
+

{item ? '编辑短信模板' : '添加短信模板'}

+

模板内容和变量将写入真实后台。

+
+ } >
update('name', event.target.value)} placeholder="请输入模板名称" required value={form.name} /> - update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} /> + update('name', event.target.value)} + placeholder="请输入模板名称" + required + value={form.name} + /> + update('category', event.target.value)} + placeholder="行业通知/营销推广/验证码" + value={form.category} + />