@@ -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 [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', '1', 'é', '', '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('非法');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user