feat: 增加模板通道拒收策略并修复运营页面

This commit is contained in:
hectorzhao
2026-09-20 15:09:57 +08:00
parent c20c2246b2
commit b24cd7c08d
38 changed files with 2485 additions and 613 deletions
@@ -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;
+4
View File
@@ -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',
+1
View File
@@ -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,
+11 -21
View File
@@ -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 };
}
+7 -1
View File
@@ -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));
}
}
+217 -181
View File
@@ -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} 开头`);
}
}
}