Files
lislgosms/api/src/channels/channels.helpers.ts
T

695 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
/** Constants and pure validation/normalization helpers shared by R5 domains. */
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
export const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090';
export const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
export const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
export const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
export const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000;
export const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000;
export const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000;
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
export const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3;
export const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000;
export const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
export const DEFAULT_CMPP_VERSION = '2.0';
export function normalizeTestPhones(data: TestChannelDto) {
const rawPhones = Array.isArray(data.phones)
? data.phones
: String(data.phoneNumber ?? data.phones ?? '').split(/[,\n\s]+/u);
const phones = rawPhones.map((phone) => String(phone).trim()).filter(Boolean);
const uniquePhones = Array.from(new Set(phones));
if (uniquePhones.length === 0) {
throw new BadRequestException('请填写测试手机号');
}
if (uniquePhones.length > 10) {
throw new BadRequestException('测试手机号最多允许 10 个');
}
for (const phone of uniquePhones) {
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException(`手机号格式不正确:${phone}`);
}
}
return uniquePhones;
}
export function normalizeTestContent(content?: string) {
const normalized = (content ?? '').trim();
if (!normalized) {
throw new BadRequestException('请填写测试短信内容');
}
if (normalized.length > 1000) {
throw new BadRequestException('测试短信内容不能超过 1000 字符');
}
return normalized;
}
export function calculateBillingUnits(content: string) {
return Math.max(1, Math.ceil([...content].length / 67));
}
export function buildChannelTestSubmitCommand({
channel,
content,
phoneNumber,
messageId,
submitId,
testNo,
attempt,
accessNo,
}: {
channel: {
id: string;
code: string;
gatewayHost: string;
gatewayPort: number;
account: string;
passwordCipher: string;
srcId: string;
cmppVersion: string;
rateLimitPerSecond: number;
config?: Prisma.JsonValue | null;
};
content: string;
phoneNumber: string;
messageId: string;
submitId: string;
testNo: string;
attempt: number;
accessNo?: string;
}) {
const srcId = accessNo?.trim() ? `${channel.srcId}${accessNo.trim()}` : channel.srcId;
return {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
traceId: randomUUID(),
messageId,
channelId: channel.id,
createdAt: new Date().toISOString(),
tenantId: 'platform-channel-test',
applicationId: 'admin-channel-test',
taskId: testNo,
submitId,
queuePriority: 'normal',
phoneNumber,
content,
signature: 'CHANNEL_TEST',
templateId: 'admin-channel-test',
billingUnits: calculateBillingUnits(content),
route: {
channelCode: channel.code,
cmppAccountCode: channel.account,
priority: attempt,
rateLimitPerSecond: channel.rateLimitPerSecond,
},
cmpp: {
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
srcId,
extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')),
registeredDelivery: 1,
msgFmt: 8,
},
upstream: {
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
account: channel.account,
passwordCipher: channel.passwordCipher,
cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
'heartbeatIntervalSeconds',
),
heartbeatMissThreshold: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatMissThreshold'),
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
),
},
retry: { attempt: 0, maxAttempts: 1 },
};
}
export function getConfigValue(config: Prisma.JsonValue | null | undefined, key: string) {
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
return config[key as keyof typeof config];
}
return undefined;
}
export function getStringConfigValue(config: Prisma.JsonValue | null | undefined, key: string, fallback: string) {
const value = getConfigValue(config, key);
if (value === undefined || value === null || value === '') {
return fallback;
}
return String(value);
}
export function normalizeConnectionAction(status: string) {
const normalized = status.toLowerCase();
if (normalized === 'connected') {
return 'connected';
}
if (['heartbeat', 'active_test'].includes(normalized)) {
return 'heartbeat';
}
if (['reconnecting', 'reconnect'].includes(normalized)) {
return 'reconnecting';
}
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
return 'disconnected';
}
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
return 'failed';
}
return 'updated';
}
export function normalizeCmppVersion(version?: string) {
const normalized = (version ?? DEFAULT_CMPP_VERSION).trim();
if (normalized === '2.0' || normalized === '3.0') {
return normalized;
}
throw new BadRequestException('cmppVersion must be 2.0 or 3.0');
}
export function normalizeGatewayConnectionStatus(status: string) {
const normalized = status.toLowerCase();
if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) {
return 'connected';
}
if (['connecting', 'connect_requested'].includes(normalized)) {
return 'connecting';
}
if (['reconnecting', 'reconnect'].includes(normalized)) {
return 'reconnecting';
}
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
return 'disconnected';
}
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
return 'failed';
}
return normalized;
}
export function defaultChannelConnectionId(channelId: string) {
return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`;
}
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
const value = Number(config.desiredConnections);
if (Number.isInteger(value) && value > 0) {
return value;
}
}
return 1;
}
export type ChannelConnectionSettings = {
gatewayHost: string;
gatewayPort: number;
account: string;
passwordCipher: string;
cmppVersion: string;
config?: Prisma.JsonValue | Record<string, unknown> | null;
};
export function getRuntimeConfigInteger(
config: Prisma.JsonValue | Record<string, unknown> | null | undefined,
key: string,
fallback: number,
) {
if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback;
const value = Number((config as Record<string, unknown>)[key]);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
export function channelConnectionSettingsChanged(
before: ChannelConnectionSettings,
after: ChannelConnectionSettings,
) {
return before.gatewayHost !== after.gatewayHost
|| before.gatewayPort !== after.gatewayPort
|| before.account !== after.account
|| before.passwordCipher !== after.passwordCipher
|| before.cmppVersion !== after.cmppVersion
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
}
export function channelGroupAuditSnapshot(group: {
code: string;
name: string;
carrier: string;
description?: string | null;
status: string;
retryEnabled: boolean;
retryTimeLimitMinutes: number;
items?: Array<{
channelId: string;
carrier?: string | null;
province?: string | null;
priority: number;
weight: number;
isBackup: boolean;
channel?: { code?: string; name?: string } | null;
}>;
}) {
return {
code: group.code,
name: group.name,
carrier: group.carrier,
description: group.description ?? null,
status: group.status,
retryEnabled: group.retryEnabled,
retryTimeLimitMinutes: group.retryTimeLimitMinutes,
items: (group.items ?? []).map((item) => ({
channelId: item.channelId,
channelCode: item.channel?.code ?? null,
channelName: item.channel?.name ?? null,
carrier: item.carrier ?? null,
province: item.province ?? null,
priority: item.priority,
weight: item.weight,
isBackup: item.isBackup,
})),
};
}
export function normalizeChannelRuntimeConfig(
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
incomingConfig?: Record<string, unknown> | null,
desiredConnections?: number,
windowSize?: number,
heartbeatIntervalSeconds?: number,
heartbeatMissThreshold?: number,
) {
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? existingConfig as Record<string, unknown>
: {};
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
? incomingConfig
: {};
const base = { ...existing, ...incoming };
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
'heartbeatIntervalSeconds',
);
base.heartbeatMissThreshold = getPositiveRuntimeInteger(
heartbeatMissThreshold ?? base.heartbeatMissThreshold,
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
);
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
base.serviceId = normalizeCmppServiceId(base.serviceId);
base.longMessageReceiptMode = normalizeLongMessageReceiptMode(base.longMessageReceiptMode);
return base;
}
export function normalizeLongMessageReceiptMode(value: unknown) {
const normalized = String(value ?? 'per_segment').trim() || 'per_segment';
if (!['per_segment', 'message_level'].includes(normalized)) {
throw new BadRequestException('longMessageReceiptMode must be per_segment or message_level');
}
return normalized;
}
export function normalizeCmppServiceId(value: unknown) {
const normalized = String(value ?? 'SMS').trim() || 'SMS';
if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) {
throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters');
}
return normalized;
}
export function normalizeChannelRateLimit(value: unknown) {
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
if (normalized > 2000) {
throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000');
}
return normalized;
}
export function normalizeExtensionDigits(value: unknown) {
if (value === undefined || value === null || value === '') {
return 0;
}
const normalized = Number(value);
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) {
throw new BadRequestException('extensionDigits must be an integer between 0 and 20');
}
return normalized;
}
export function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
if (value === undefined || value === null || value === '') {
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 bullmqConnection() {
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
return {
host: redisUrl.hostname,
port: Number(redisUrl.port || 6379),
username: redisUrl.username || undefined,
password: redisUrl.password || undefined,
maxRetriesPerRequest: null,
};
}
export function getPositiveIntegerEnv(name: string, fallback: number) {
const value = Number(process.env[name]);
if (Number.isInteger(value) && value > 0) {
return value;
}
return fallback;
}
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length === 0) {
throw new BadRequestException('Receipt file is empty');
}
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
const firstCells = splitReceiptLine(lines[0], separator);
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
const header = hasHeader ? firstCells : [];
const rows = hasHeader ? lines.slice(1) : lines;
const statusIndex = findReceiptStatusIndex(header);
let successCount = 0;
let failedCount = 0;
const resultRows = rows.map((line, index) => {
const cells = splitReceiptLine(line, separator);
const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? '';
const normalizedStatus = normalizeReceiptStatus(rawStatus);
if (normalizedStatus === 'success') {
successCount += 1;
} else {
failedCount += 1;
}
return {
rowNumber: (hasHeader ? index + 2 : index + 1),
phone: cells[0] ?? '',
status: normalizedStatus,
rawStatus,
raw: cells,
};
});
return {
rowCount: resultRows.length,
successCount,
failedCount,
result: {
delimiter: separator === '\t' ? 'tab' : 'comma',
hasHeader,
rows: resultRows,
},
};
}
export function splitReceiptLine(line: string, delimiter: ',' | '\t') {
if (delimiter === '\t') {
return line.split('\t').map((cell) => stripReceiptCell(cell));
}
const cells: string[] = [];
let current = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (char === '"' && quoted && next === '"') {
current += '"';
index += 1;
} else if (char === '"') {
quoted = !quoted;
} else if (char === ',' && !quoted) {
cells.push(stripReceiptCell(current));
current = '';
} else {
current += char;
}
}
cells.push(stripReceiptCell(current));
return cells;
}
export function stripReceiptCell(value: string) {
return value.trim().replace(/^"|"$/g, '').trim();
}
export function findReceiptStatusIndex(header: string[]) {
if (header.length === 0) {
return 1;
}
const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase()));
return index >= 0 ? index : Math.max(0, header.length - 1);
}
export function normalizeReceiptStatus(value: string) {
const normalized = value.trim().toLowerCase();
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
return 'success';
}
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
return 'failed';
}
return 'failed';
}
export function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) {
if (rowCount <= 0 || successCount <= 0) {
return 'failed';
}
if (failedCount > 0) {
return 'partial';
}
return 'completed';
}
export type ChannelReportDeliveryRow = {
channelId: string;
signatureId: string;
drainageInfoId: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
lastSuccessfulSentAt: Date | null;
};
export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) {
const total = sumReportDelivery(rows, 'total');
const acceptedCount = sumReportDelivery(rows, 'acceptedCount');
const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount');
const successCount = sumReportDelivery(rows, 'successCount');
const unknownCount = sumReportDelivery(rows, 'unknownCount');
const failureCount = sumReportDelivery(rows, 'failureCount');
return {
total,
acceptedCount,
submitFailureCount,
submitFailureRate: percentage(submitFailureCount, total),
successCount,
successRate: percentage(successCount, acceptedCount),
unknownCount,
unknownRate: percentage(unknownCount, acceptedCount),
failureCount,
failureRate: percentage(failureCount, acceptedCount),
};
}
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
ChannelReportDeliveryRow,
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
>) {
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
}
export function percentage(count: number, total: number) {
return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0;
}
export function latestDate(values: Array<Date | null>) {
const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime());
return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
}
export function currentShanghaiDayRange(now = new Date()) {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000);
const localDate = shifted.toISOString().slice(0, 10);
const startAt = new Date(`${localDate}T00:00:00+08:00`);
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
}
export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
}
return value;
}
export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
if (value === undefined || !Number.isFinite(value)) return fallback;
return Math.min(maximum, Math.max(minimum, Math.round(value)));
}
export function normalizeBusinessCarrier(carrier?: string | null) {
const normalized = normalizeChannelCarrier(carrier);
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
throw new BadRequestException('carrier must be mobile, unicom, or telecom');
}
return normalized;
}
export function normalizeChannelCarrier(carrier?: string | null) {
const value = String(carrier ?? '').trim().toLowerCase();
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
return value;
}
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) {
const normalized = normalizeChannelCarrier(channelCarrier);
return normalized === 'all' || normalized === groupCarrier;
}
export function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
}
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
return normalizeRegion(channelRegion) === normalizeRegion(itemProvince);
}
export function validateGroupItems(
groupCarrier: string,
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
) {
const channelIds = new Set<string>();
const provinces = new Set<string>();
const nationalPriorities = new Set<number>();
for (const item of items) {
const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier;
if (itemCarrier !== groupCarrier) {
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
}
const channel = channels.get(item.channelId);
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (channelIds.has(item.channelId)) {
throw new BadRequestException('通道组内不能重复配置同一通道');
}
channelIds.add(item.channelId);
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
}
if (item.province) {
const province = normalizeRegion(item.province);
if (provinces.has(province)) {
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
}
provinces.add(province);
if (!isRegionCompatible(channel.sendRegion, item.province)) {
throw new BadRequestException('Province route must use a channel with the same sendRegion');
}
} else {
const priority = item.priority ?? 100;
if (nationalPriorities.has(priority)) {
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
}
nationalPriorities.add(priority);
}
}
}
export function normalizeReportType(value?: string) {
if (value === 'signature' || value === 'drainage' || value === 'both') return value;
throw new BadRequestException('reportType must be signature, drainage or both');
}
export function summarizeReportStatuses(statuses: string[]) {
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
const approved = statuses.filter((status) => status === 'approved').length;
let status = 'pending';
if (approved === statuses.length) status = 'approved';
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
return { status, approved, total: statuses.length };
}
export function normalizeLinkEvent(action: string) {
if (action.includes('connect_requested')) {
return '连接请求';
}
if (action.includes('connected')) {
return '连接成功';
}
if (action.includes('heartbeat')) {
return '心跳';
}
if (action.includes('reconnecting')) {
return '重连';
}
if (action.includes('disconnected')) {
return '断开';
}
if (action.includes('failed')) {
return '连接失败';
}
if (action.includes('copy')) {
return '复制';
}
if (action.includes('deleted')) {
return '删除';
}
return '更新';
}