feat: 增加模板通道拒收策略并修复运营页面
This commit is contained in:
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -268,6 +268,7 @@ export function clientMessageView(message: Record<string, any>) {
|
||||
carrier: message.carrier ?? null,
|
||||
province: message.province ?? null,
|
||||
content: message.content,
|
||||
originalContent: message.originalContent ?? null,
|
||||
drainageGate: message.drainageGate
|
||||
? {
|
||||
version: message.drainageGate.version,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -11,6 +11,14 @@ const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobil
|
||||
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), 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');
|
||||
|
||||
@@ -40,13 +40,16 @@ export class ChannelWordSnapshot {
|
||||
content: string,
|
||||
items: T[],
|
||||
options: Parameters<typeof selectChannelCandidate>[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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<Prisma.TransactionClient, 'channelRouteRule'>,
|
||||
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<string, { id: string; name: string; groupNames: string[] }>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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} 开头`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user