import { BadRequestException } from '@nestjs/common'; 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 const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const; 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; export const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const; export interface TemplateVariableInput { name: string; example?: string; required?: boolean; } export function normalizeApplicationPassword(value: string | undefined) { const password = value?.trim() || generateApplicationPassword(); if (password.length !== 16) { throw new BadRequestException('passwordCipher must be 16 characters'); } return password; } export function generateApplicationPassword() { return randomUUID().replace(/-/g, '').slice(0, 16); } export function estimateBillingUnits(content: string) { const length = [...content].length; if (length <= 70) { return 1; } return Math.ceil(length / 67); } export function inferTemplateVariables(content: string): TemplateVariableInput[] { const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? []; return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true })); } export function validateAndNormalizeTemplateVariables( content: string, supplied?: Array<{ name: string; example?: string; required?: boolean }>, ): TemplateVariableInput[] { const names: string[] = []; let cursor = 0; while (true) { const start = content.indexOf('${', cursor); if (start < 0) break; const end = content.indexOf('}', start + 2); if (end < 0) throw new BadRequestException('模板变量未闭合'); const name = content.slice(start + 2, end); 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); 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('变量配置中包含重复变量'); if (suppliedNames.length !== names.length || suppliedNames.some((name) => !names.includes(name))) { throw new BadRequestException('变量配置必须与模板正文中的占位符完全一致'); } return supplied.map((item) => ({ ...item, name: item.name.trim() })); } export function normalizeSmsSignature(name: string) { const innerName = name .trim() .replace(/^[【[]+|[】\]]+$/g, '') .trim(); return innerName ? `【${innerName}】` : ''; } export function validateCompleteSmsSignature(name: string) { const value = name; if (/[\p{White_Space}\p{Cc}\p{Default_Ignorable_Code_Point}]/u.test(value)) { throw new BadRequestException('短信签名不能包含空格、换行或不可见字符'); } const match = value.match(/^【([^【】]+)】$/); if (!match) { throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】'); } return value; } export function startOfToday() { const date = new Date(); date.setHours(0, 0, 0, 0); return date; } export function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePriority { const queuePriority = value ?? 'normal'; if (!APPLICATION_QUEUE_PRIORITIES.includes(queuePriority as ApplicationQueuePriority)) { throw new BadRequestException('queuePriority must be normal or priority'); } return queuePriority as ApplicationQueuePriority; } export function normalizeApplicationInterfaceType(value?: string): ApplicationInterfaceType { const interfaceType = value ?? 'cmpp20'; if (!APPLICATION_INTERFACE_TYPES.includes(interfaceType as ApplicationInterfaceType)) { throw new BadRequestException('interfaceType only supports cmpp20; HTTP interface is not available yet'); } return interfaceType as ApplicationInterfaceType; } export function normalizeCmppAccessNumberConfig( data: Pick< CreateSmsApplicationDto, 'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix' >, current?: { cmppApplicationExtension?: string | null; cmppAccessNumberFillEnabled?: boolean | null; cmppAccessNumberFillPrefix?: string | null; }, ) { const applicationExtension = (data.cmppApplicationExtension === undefined ? current?.cmppApplicationExtension : data.cmppApplicationExtension )?.trim() || null; const fillEnabled = data.cmppAccessNumberFillEnabled ?? current?.cmppAccessNumberFillEnabled ?? false; const configuredPrefix = (data.cmppAccessNumberFillPrefix === undefined ? current?.cmppAccessNumberFillPrefix : data.cmppAccessNumberFillPrefix )?.trim() || null; if (applicationExtension && !/^\d+$/.test(applicationExtension)) { throw new BadRequestException('cmppApplicationExtension must contain digits only'); } if (applicationExtension && applicationExtension.length > 21) { throw new BadRequestException('cmppApplicationExtension must not exceed 21 digits'); } if (fillEnabled && !applicationExtension) { throw new BadRequestException('cmppApplicationExtension is required when access number filling is enabled'); } if (fillEnabled && !configuredPrefix) { throw new BadRequestException('cmppAccessNumberFillPrefix is required when access number filling is enabled'); } if (configuredPrefix && !/^\d+$/.test(configuredPrefix)) { throw new BadRequestException('cmppAccessNumberFillPrefix must contain digits only'); } const fillPrefix = fillEnabled ? configuredPrefix : null; const clientSrcId = applicationExtension ? `${fillPrefix ?? ''}${applicationExtension}` : null; if (clientSrcId && clientSrcId.length > 21) { throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits'); } return { applicationExtension, fillEnabled, fillPrefix, clientSrcId }; } export function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) { if (value === undefined || value === null) { return fallback; } const normalized = Number(value); if (!Number.isInteger(normalized) || normalized <= 0) { throw new BadRequestException(`${fieldName} must be a positive integer`); } return normalized; } export function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) { if (!['active', 'disabling'].includes(applicationStatus)) { return 'inactive'; } if (connections.some((connection) => connection.status === 'connected')) { return 'connected'; } if ( connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status)) ) { return 'degraded'; } return 'disconnected'; } export function getPositiveIntegerEnv(name: string, fallback: number) { const value = Number(process.env[name] ?? fallback); return Number.isInteger(value) && value > 0 ? value : fallback; } export function parseGatewayDate(value?: string) { if (!value) return undefined; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? undefined : parsed; } export function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } 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: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined }; } export function hasReportValue(value: unknown) { if (isRecord(value)) { return Boolean(value.fileObjectId || value.fieldValue || value.value); } return value !== undefined && value !== null && String(value).trim().length > 0; }