fix: 修复模板唯一性及六项运营页面问题
CSS quality / css-quality (push) Waiting to run

This commit is contained in:
hectorzhao
2026-09-21 19:28:50 +08:00
parent 28951b4fc4
commit 3cacb6e8e7
38 changed files with 1122 additions and 194 deletions
@@ -0,0 +1,4 @@
-- Deleted records remain available for audit but do not reserve an application template name.
-- Fail on conflicting legacy rows; never rename or delete business data during migration.
CREATE UNIQUE INDEX "SmsTemplate_application_name_active_key"
ON "SmsTemplate" ("applicationId", btrim(name)) WHERE "auditStatus" <> 'deleted';
@@ -0,0 +1,21 @@
-- Serialize member writes against channel capability changes; validate whole carrier strings.
CREATE FUNCTION cmpp_check_group_channel_carrier() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
capabilities text[];
legacy text;
target_carrier text;
BEGIN
SELECT carriers, carrier INTO capabilities, legacy FROM "SmsChannel" WHERE id=NEW."channelId" FOR SHARE;
SELECT carrier INTO target_carrier FROM "SmsChannelGroup" WHERE id=NEW."groupId";
IF cardinality(capabilities) = 0 THEN
capabilities := CASE WHEN legacy='all' THEN ARRAY['mobile','unicom','telecom'] ELSE ARRAY[legacy] END;
END IF;
IF target_carrier IS NOT NULL AND NOT (target_carrier=ANY(capabilities)) THEN
RAISE EXCEPTION 'Channel carrier is not compatible with the channel group carrier' USING ERRCODE='23514';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER "SmsChannelGroupItem_carrier_guard"
BEFORE INSERT OR UPDATE OF "groupId", "channelId" ON "SmsChannelGroupItem"
FOR EACH ROW EXECUTE FUNCTION cmpp_check_group_channel_carrier();
@@ -1,7 +1,7 @@
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 () => {
it('removes incompatible group members in a transaction without reconnecting', async () => {
const channel = {
id: 'c',
carrier: 'all',
@@ -10,16 +10,18 @@ describe('carrier capability reduction', () => {
config: {},
};
const prisma = {
$transaction: jest.fn(),
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' } }]),
findMany: jest.fn().mockResolvedValue([{ id: 'member', group: { name: 'existing', carrier: 'telecom' } }]),
deleteMany: jest.fn(),
},
};
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const connection = { requestChannelConnection: jest.fn(), requestChannelDisconnection: jest.fn() };
await new ChannelConfigurationService(prisma as never, connection as never).updateChannel('c', {
carriers: ['mobile', 'unicom'],
@@ -27,7 +29,7 @@ describe('carrier capability reduction', () => {
expect(prisma.smsChannel.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carriers: ['mobile', 'unicom'] }) }),
);
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['member'] } } });
expect(connection.requestChannelConnection).not.toHaveBeenCalled();
const candidate = {
channelId: 'c',
@@ -173,53 +173,72 @@ export class ChannelConfigurationService {
cmppVersion: cmppVersion ?? channel.cmppVersion,
config: config ?? channel.config,
});
const updated = await this.prisma.smsChannel.update({
where: { id: channelId },
data: {
code: data.code,
name: data.name,
carrier:
data.carriers !== undefined || data.carrier !== undefined
? legacyCarrierFromCapabilities(carriers)
: undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: config as Prisma.InputJsonValue | undefined,
},
});
await this.prisma.operationLog.create({
data: {
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: channelId,
detail: {
before: {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
enterpriseCode: channel.enterpriseCode,
account: channel.account,
srcId: channel.srcId,
unitPrice: moneyToNumber(channel.unitPrice),
},
after: data,
} as Prisma.InputJsonValue,
},
const updated = await this.prisma.$transaction(async (tx) => {
const updated = await tx.smsChannel.update({
where: { id: channelId },
data: {
code: data.code,
name: data.name,
carrier:
data.carriers !== undefined || data.carrier !== undefined
? legacyCarrierFromCapabilities(carriers)
: undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: config as Prisma.InputJsonValue | undefined,
},
});
const removedGroupItems =
data.carriers !== undefined || data.carrier !== undefined
? await tx.smsChannelGroupItem.findMany({
where: { channelId, group: { carrier: { notIn: carriers } } },
select: {
id: true,
groupId: true,
carrier: true,
province: true,
group: { select: { name: true, carrier: true } },
},
})
: [];
if (removedGroupItems.length)
await tx.smsChannelGroupItem.deleteMany({ where: { id: { in: removedGroupItems.map((item) => item.id) } } });
await tx.operationLog.create({
data: {
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: channelId,
detail: {
before: {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
enterpriseCode: channel.enterpriseCode,
account: channel.account,
srcId: channel.srcId,
unitPrice: moneyToNumber(channel.unitPrice),
},
after: { ...data, passwordCipher: data.passwordCipher ? '[updated]' : undefined },
removedGroupItems,
} as Prisma.InputJsonValue,
},
});
return updated;
});
const updatedStatus = data.status ?? channel.status;
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
@@ -363,6 +363,10 @@ export class ChannelReportingService {
status?: string;
reportType?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
@@ -482,6 +486,16 @@ export class ChannelReportingService {
const changedAt = new Date(task.updatedAt);
if (query.createdAtFrom && changedAt < new Date(`${query.createdAtFrom}T00:00:00+08:00`)) return false;
if (query.createdAtTo && changedAt > new Date(`${query.createdAtTo}T23:59:59.999+08:00`)) return false;
const matches = (value: string | null | undefined, filter?: string) =>
!filter?.trim() || (value ?? '').includes(filter.trim());
if (!matches(task.signature.tenant.name, query.enterpriseKeyword)) return false;
if (!matches(task.signature.application?.name, query.applicationKeyword)) return false;
if (!matches(task.channel.name, query.channelKeyword)) return false;
const objects =
task.reportType === 'drainage'
? [task.drainageInfo?.siteName, task.drainageInfo?.url]
: [task.signature.name];
if (query.objectKeyword?.trim() && !objects.some((value) => matches(value, query.objectKeyword))) return false;
if (!query.keyword?.trim()) return true;
const keyword = query.keyword.trim();
return [
+8
View File
@@ -246,6 +246,10 @@ export class ChannelsController {
@Query('status') status?: string,
@Query('reportType') reportType?: string,
@Query('keyword') keyword?: string,
@Query('enterpriseKeyword') enterpriseKeyword?: string,
@Query('applicationKeyword') applicationKeyword?: string,
@Query('channelKeyword') channelKeyword?: string,
@Query('objectKeyword') objectKeyword?: string,
@Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string,
@@ -260,6 +264,10 @@ export class ChannelsController {
status,
reportType,
keyword,
enterpriseKeyword,
applicationKeyword,
channelKeyword,
objectKeyword,
createdAtFrom,
createdAtTo,
page: Number(page),
+7 -3
View File
@@ -72,11 +72,14 @@ function createPrismaMock() {
},
],
};
const channelUpdate = jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data }));
const operationLogCreate = jest.fn();
return {
$queryRaw: jest.fn().mockResolvedValue([]),
$transaction: jest.fn((callback) =>
callback({
smsChannel: {
update: channelUpdate,
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
},
smsChannelGroup: {
@@ -86,6 +89,7 @@ function createPrismaMock() {
.mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }),
},
smsChannelGroupItem: {
findMany: jest.fn().mockResolvedValue([]),
deleteMany: jest.fn(),
createMany: jest.fn(),
},
@@ -98,7 +102,7 @@ function createPrismaMock() {
createMany: jest.fn(),
},
operationLog: {
create: jest.fn(),
create: operationLogCreate,
},
}),
),
@@ -106,7 +110,7 @@ function createPrismaMock() {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data })),
update: channelUpdate,
},
channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: {
@@ -228,7 +232,7 @@ function createPrismaMock() {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
operationLog: {
create: jest.fn(),
create: operationLogCreate,
findMany: jest.fn().mockResolvedValue([
{
id: 'log-1',
+4
View File
@@ -202,6 +202,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
status?: string;
reportType?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
+16
View File
@@ -103,3 +103,19 @@ export async function operationStatus(tx: Prisma.TransactionClient) {
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
};
}
/** Restore the original hourly business-message series, bounded by today's queuedAt index. */
export async function hourlySendTrend(tx: Prisma.TransactionClient, date: string) {
const rows = await tx.$queryRaw<Array<{ hour: number; submittedCount: bigint; successCount: bigint }>>(Prisma.sql`
SELECT EXTRACT(HOUR FROM ("queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
COUNT(*)::bigint AS "submittedCount",COUNT(*) FILTER (WHERE status='delivered')::bigint AS "successCount"
FROM "SmsMessageRecord" WHERE "queuedAt">=${startOfDay(date)} AND "queuedAt"<${startOfDay(addDays(date, 1))}
GROUP BY hour ORDER BY hour`);
const byHour = new Map(rows.map((row) => [row.hour, row]));
return Array.from({ length: 24 }, (_, hour) => ({
hour,
label: String(hour).padStart(2, '0') + ':00',
submittedCount: Number(byHour.get(hour)?.submittedCount ?? 0),
successCount: Number(byHour.get(hour)?.successCount ?? 0),
}));
}
+4 -2
View File
@@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { aggregateHome, enterpriseRanks, metrics, operationStatus } from './home-read';
import { aggregateHome, enterpriseRanks, hourlySendTrend, metrics, operationStatus } from './home-read';
@Injectable()
export class HomeService {
@@ -40,7 +40,7 @@ export class HomeService {
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (!state.initialized || state.seededDay !== date)
throw new ServiceUnavailableException('今日统计正在初始化,请稍后刷新');
const [rows, ranks, status, pending, unresolved] = await Promise.all([
const [rows, ranks, status, pending, unresolved, hourlyTrend] = await Promise.all([
aggregateHome(tx, date, state.version),
enterpriseRanks(tx, date),
operationStatus(tx),
@@ -51,6 +51,7 @@ export class HomeService {
receivedAt: { gte: startOfDay(addDays(date, -3)), lt: startOfDay(addDays(date, 1)) },
},
}),
hourlySendTrend(tx, date),
]);
const values = metrics(rows[0]);
const summary = {
@@ -66,6 +67,7 @@ export class HomeService {
timeSourceCoverage: { approximate: values.approximate, incomplete: values.incomplete },
today: values,
enterpriseSpendRanks: ranks,
hourlySendTrend: hourlyTrend,
...status,
};
const snapshot = await tx.homeSnapshot.create({
+9 -7
View File
@@ -10,13 +10,15 @@ import type {
// Pure query builders and response mappers shared by the R2 query domains.
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
const statusWhere =
query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
query.status === 'unknown'
? { status: { in: ['submitted', 'unknown'] } }
: query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
+13 -8
View File
@@ -5,6 +5,7 @@ import type { ReviewDto, StatusChangeDto } from './sms-config.contracts';
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
import { SmsReportValidationService } from './report-validation.service';
import { writeUniqueSignature } from './signature-uniqueness';
import { templateWriteError } from './template-uniqueness';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsAuditService {
@@ -72,7 +73,9 @@ export class SmsAuditService {
throw new NotFoundException('Template not found');
}
const status = data.status ?? 'deleted';
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
const updated = await this.prisma.smsTemplate
.update({ where: { id: templateId }, data: { auditStatus: status } })
.catch(templateWriteError);
await this.lifecycle.writeOperationLog(
template.tenantId,
data.operatorId,
@@ -158,13 +161,15 @@ export class SmsAuditService {
}
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason : null,
},
});
const updated = await this.prisma.smsTemplate
.update({
where: { id: templateId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason : null,
},
})
.catch(templateWriteError);
await this.createAuditRecord({
tenantId: template.tenantId,
targetType: 'sms_template',
+30 -23
View File
@@ -1,12 +1,12 @@
import { BadRequestException } from '@nestjs/common';
import { randomInt, randomUUID } from 'node:crypto';
import { randomUUID } from 'node:crypto';
import type { CreateSmsApplicationDto } from './sms-config.contracts';
/** Pure normalization and report-value helpers shared by the R3 domain services. */
export const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const;
export type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
export type ApplicationQueuePriority = (typeof APPLICATION_QUEUE_PRIORITIES)[number];
export const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
export type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
export type ApplicationInterfaceType = (typeof APPLICATION_INTERFACE_TYPES)[number];
export const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000;
export const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000;
export const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000;
@@ -55,16 +55,16 @@ export function validateAndNormalizeTemplateVariables(
const end = content.indexOf('}', start + 2);
if (end < 0) throw new BadRequestException('模板变量未闭合');
const name = content.slice(start + 2, end);
if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name)) {
throw new BadRequestException('模板变量名必须以英文字母开头,仅包含英文字母数字和下划线,长度1至32位');
if (!/^[A-Za-z0-9]{1,32}$/.test(name)) {
throw new BadRequestException('模板变量名仅允许英文字母数字,长度1至32位');
}
if (names.includes(name)) throw new BadRequestException(`模板变量 ${name} 重复`);
names.push(name);
cursor = end + 1;
}
if (!supplied) return names.map((name) => ({ name, required: true }));
const suppliedNames = supplied.map((item) => item.name?.trim());
if (suppliedNames.some((name) => !name || !/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name))) {
const suppliedNames = supplied.map((item) => item.name);
if (suppliedNames.some((name) => typeof name !== 'string' || !/^[A-Za-z0-9]{1,32}$/.test(name))) {
throw new BadRequestException('变量配置中包含非法变量名');
}
if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量');
@@ -75,7 +75,10 @@ export function validateAndNormalizeTemplateVariables(
}
export function normalizeSmsSignature(name: string) {
const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
const innerName = name
.trim()
.replace(/^[【[]+|[】\]]+$/g, '')
.trim();
return innerName ? `${innerName}` : '';
}
@@ -114,26 +117,27 @@ export function normalizeApplicationInterfaceType(value?: string): ApplicationIn
}
export function normalizeCmppAccessNumberConfig(
data: Pick<CreateSmsApplicationDto, 'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'>,
data: Pick<
CreateSmsApplicationDto,
'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'
>,
current?: {
cmppApplicationExtension?: string | null;
cmppAccessNumberFillEnabled?: boolean | null;
cmppAccessNumberFillPrefix?: string | null;
},
) {
const applicationExtension = (
data.cmppApplicationExtension === undefined
const applicationExtension =
(data.cmppApplicationExtension === undefined
? current?.cmppApplicationExtension
: data.cmppApplicationExtension
)?.trim() || null;
const fillEnabled = data.cmppAccessNumberFillEnabled
?? current?.cmppAccessNumberFillEnabled
?? false;
const configuredPrefix = (
data.cmppAccessNumberFillPrefix === undefined
)?.trim() || null;
const fillEnabled = data.cmppAccessNumberFillEnabled ?? current?.cmppAccessNumberFillEnabled ?? false;
const configuredPrefix =
(data.cmppAccessNumberFillPrefix === undefined
? current?.cmppAccessNumberFillPrefix
: data.cmppAccessNumberFillPrefix
)?.trim() || null;
)?.trim() || null;
if (applicationExtension && !/^\d+$/.test(applicationExtension)) {
throw new BadRequestException('cmppApplicationExtension must contain digits only');
@@ -152,9 +156,7 @@ export function normalizeCmppAccessNumberConfig(
}
const fillPrefix = fillEnabled ? configuredPrefix : null;
const clientSrcId = applicationExtension
? `${fillPrefix ?? ''}${applicationExtension}`
: null;
const clientSrcId = applicationExtension ? `${fillPrefix ?? ''}${applicationExtension}` : null;
if (clientSrcId && clientSrcId.length > 21) {
throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits');
}
@@ -179,7 +181,9 @@ export function normalizeApplicationCmppStatus(connections: Array<{ status: stri
if (connections.some((connection) => connection.status === 'connected')) {
return 'connected';
}
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
if (
connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))
) {
return 'degraded';
}
return 'disconnected';
@@ -202,7 +206,10 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
export function reportValueParts(value: unknown) {
if (isRecord(value) && typeof value.fileObjectId === 'string') {
return { fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined, fileObjectId: value.fileObjectId };
return {
fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined,
fileObjectId: value.fileObjectId,
};
}
return { fieldValue: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined };
}
@@ -0,0 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
export function templateWriteError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
throw new BadRequestException('同一企业应用下已存在相同名称的模板,请修改模板名称');
throw error;
}
@@ -0,0 +1,19 @@
import { validateAndNormalizeTemplateVariables } from './sms-config.helpers';
describe('template variable names', () => {
it.each(['code', 'Code123', '123', 'A'.repeat(32)])('accepts ASCII alphanumeric name %s', (name) => {
expect(validateAndNormalizeTemplateVariables(`正文\${${name}}`, [{ name, example: '中文示例' }])).toEqual([
{ name, example: '中文示例' },
]);
});
it.each(['中文', 'code_1', 'code-1', ' name', '', 'é', '', 'A'.repeat(33)])('rejects invalid name %s', (name) => {
expect(() => validateAndNormalizeTemplateVariables(`正文\${${name}}`)).toThrow('变量名仅允许');
});
it('rejects unclosed, repeated and mismatched variables', () => {
expect(() => validateAndNormalizeTemplateVariables('${code')).toThrow('未闭合');
expect(() => validateAndNormalizeTemplateVariables('${code}${code}')).toThrow('重复');
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'other' }])).toThrow('完全一致');
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'code_' }])).toThrow('非法');
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'code ' }])).toThrow('非法');
});
});
+59 -48
View File
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { shanghaiDateRange } from '../common/shanghai-date-range';
import { PrismaService } from '../prisma/prisma.service';
import { SmsAuditService } from './audit.service';
import { templateWriteError } from './template-uniqueness';
import type {
CreateSmsTemplateDto,
CreateSmsTemplateOptions,
@@ -87,6 +88,7 @@ export class SmsTemplateService {
}
async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) {
const name = this.templateName(data.name);
const variables = validateAndNormalizeTemplateVariables(data.content, data.variables);
const application = await this.prisma.smsApplication.findUnique({
where: { id: data.applicationId },
@@ -96,26 +98,28 @@ export class SmsTemplateService {
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,
})),
return this.prisma.smsTemplate
.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
signatureId: data.signatureId,
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 },
});
include: { variables: true, application: true, tenant: true, signature: true },
})
.catch(templateWriteError);
}
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
@@ -151,35 +155,42 @@ export class SmsTemplateService {
(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,
optOutRules: data.applicationId && data.applicationId !== template.applicationId ? [] : undefined,
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 },
});
});
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,
optOutRules: data.applicationId && data.applicationId !== template.applicationId ? [] : undefined,
signatureId: data.signatureId,
name: data.name === undefined ? undefined : this.templateName(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 },
});
})
.catch(templateWriteError);
}
private templateName(value: string) {
if (typeof value !== 'string' || !value.trim()) throw new BadRequestException('模板名称不能为空');
return value.trim();
}
async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {