223 lines
8.6 KiB
TypeScript
223 lines
8.6 KiB
TypeScript
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<string, unknown> {
|
|
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;
|
|
}
|