629 lines
18 KiB
TypeScript
629 lines
18 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
export interface CreateChannelDto {
|
|
code: string;
|
|
name: string;
|
|
carrier?: string;
|
|
protocol?: string;
|
|
gatewayHost: string;
|
|
gatewayPort: number;
|
|
enterpriseCode?: string;
|
|
account: string;
|
|
passwordCipher: string;
|
|
srcId: string;
|
|
cmppVersion?: string;
|
|
rateLimitPerSecond?: number;
|
|
unitPrice?: number;
|
|
status?: string;
|
|
config?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface CreateChannelGroupDto {
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
status?: string;
|
|
}
|
|
|
|
export interface CreateChannelGroupItemDto {
|
|
groupId: string;
|
|
channelId: string;
|
|
carrier?: string;
|
|
province?: string;
|
|
priority?: number;
|
|
weight?: number;
|
|
isBackup?: boolean;
|
|
rateLimitPerSecond?: number;
|
|
}
|
|
|
|
export interface CreateRouteRuleDto {
|
|
tenantId?: string;
|
|
applicationId?: string;
|
|
groupId: string;
|
|
channelId?: string;
|
|
carrier?: string;
|
|
province?: string;
|
|
priority?: number;
|
|
status?: string;
|
|
}
|
|
|
|
export interface CreateReportFieldDto {
|
|
channelId: string;
|
|
code: string;
|
|
name: string;
|
|
fieldType: string;
|
|
required?: boolean;
|
|
description?: string;
|
|
sortOrder?: number;
|
|
status?: string;
|
|
}
|
|
|
|
export interface CreateReportMaterialDto {
|
|
signatureId: string;
|
|
channelId: string;
|
|
fieldCode: string;
|
|
fieldValue?: string;
|
|
fileObjectId?: string;
|
|
}
|
|
|
|
export interface CreateReportTaskDto {
|
|
tenantId: string;
|
|
signatureId: string;
|
|
channelId: string;
|
|
createdById?: string;
|
|
}
|
|
|
|
export interface CreateReportExportDto {
|
|
fileObjectId?: string;
|
|
fileName: string;
|
|
rowCount?: number;
|
|
}
|
|
|
|
export interface CreateReceiptImportDto {
|
|
fileObjectId?: string;
|
|
fileName: string;
|
|
rowCount?: number;
|
|
successCount?: number;
|
|
failedCount?: number;
|
|
statusAfter?: string;
|
|
reason?: string;
|
|
result?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface UpsertConnectionStateDto {
|
|
tenantId?: string;
|
|
channelId: string;
|
|
connectionId: string;
|
|
status: string;
|
|
desiredConnections?: number;
|
|
currentConnections?: number;
|
|
lastConnectedAt?: string;
|
|
lastDisconnectedAt?: string;
|
|
lastHeartbeatAt?: string;
|
|
reconnectCount?: number;
|
|
lastError?: string;
|
|
}
|
|
|
|
export interface ChangeChannelStatusDto {
|
|
status: string;
|
|
operatorId?: string;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface CopyChannelDto {
|
|
name?: string;
|
|
code?: string;
|
|
operatorId?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ChannelsService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
listChannels() {
|
|
return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
|
|
}
|
|
|
|
createChannel(data: CreateChannelDto) {
|
|
return this.prisma.smsChannel.create({
|
|
data: {
|
|
code: data.code,
|
|
name: data.name,
|
|
carrier: data.carrier,
|
|
protocol: data.protocol ?? 'CMPP',
|
|
gatewayHost: data.gatewayHost,
|
|
gatewayPort: data.gatewayPort,
|
|
enterpriseCode: data.enterpriseCode,
|
|
account: data.account,
|
|
passwordCipher: data.passwordCipher,
|
|
srcId: data.srcId,
|
|
cmppVersion: data.cmppVersion ?? '3.0',
|
|
rateLimitPerSecond: data.rateLimitPerSecond ?? 100,
|
|
unitPrice: data.unitPrice ?? 0,
|
|
status: data.status ?? 'active',
|
|
config: data.config as Prisma.InputJsonValue | undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) {
|
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
|
if (!channel) {
|
|
throw new NotFoundException('Channel not found');
|
|
}
|
|
const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { status: data.status } });
|
|
await this.prisma.operationLog.create({
|
|
data: {
|
|
userId: data.operatorId,
|
|
action: `sms_channel.${data.status}`,
|
|
resource: 'sms_channel',
|
|
resourceId: channelId,
|
|
detail: {
|
|
statusBefore: channel.status,
|
|
statusAfter: data.status,
|
|
reason: data.reason,
|
|
} as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async copyChannel(channelId: string, data: CopyChannelDto = {}) {
|
|
const source = await this.prisma.smsChannel.findUnique({
|
|
where: { id: channelId },
|
|
include: { reportFields: true },
|
|
});
|
|
if (!source) {
|
|
throw new NotFoundException('Channel not found');
|
|
}
|
|
|
|
const suffix = Date.now().toString(36).toUpperCase();
|
|
const nextName = data.name ?? `${source.name}副本`;
|
|
const nextCode = data.code ?? `${source.code}-COPY-${suffix}`;
|
|
|
|
const copied = await this.prisma.$transaction(async (tx) => {
|
|
const nextChannel = await tx.smsChannel.create({
|
|
data: {
|
|
code: nextCode,
|
|
name: nextName,
|
|
carrier: source.carrier,
|
|
protocol: source.protocol,
|
|
gatewayHost: source.gatewayHost,
|
|
gatewayPort: source.gatewayPort,
|
|
enterpriseCode: source.enterpriseCode,
|
|
account: source.account,
|
|
passwordCipher: source.passwordCipher,
|
|
srcId: source.srcId,
|
|
cmppVersion: source.cmppVersion,
|
|
rateLimitPerSecond: source.rateLimitPerSecond,
|
|
unitPrice: source.unitPrice,
|
|
status: source.status,
|
|
config: source.config as Prisma.InputJsonValue | undefined,
|
|
reportFields: {
|
|
create: source.reportFields.map((field) => ({
|
|
code: field.code,
|
|
name: field.name,
|
|
fieldType: field.fieldType,
|
|
required: field.required,
|
|
description: field.description,
|
|
sortOrder: field.sortOrder,
|
|
status: field.status,
|
|
})),
|
|
},
|
|
},
|
|
include: { reportFields: true },
|
|
});
|
|
|
|
const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } });
|
|
if (reportMaterials.length > 0) {
|
|
await tx.signatureReportMaterial.createMany({
|
|
data: reportMaterials.map((material) => ({
|
|
signatureId: material.signatureId,
|
|
channelId: nextChannel.id,
|
|
fieldCode: material.fieldCode,
|
|
fieldValue: material.fieldValue,
|
|
fileObjectId: material.fileObjectId,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
await tx.operationLog.create({
|
|
data: {
|
|
userId: data.operatorId,
|
|
action: 'sms_channel.copy',
|
|
resource: 'sms_channel',
|
|
resourceId: nextChannel.id,
|
|
detail: {
|
|
sourceChannelId: source.id,
|
|
sourceCode: source.code,
|
|
copiedReportFields: source.reportFields.length,
|
|
copiedReportMaterials: reportMaterials.length,
|
|
} as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return nextChannel;
|
|
});
|
|
|
|
return copied;
|
|
}
|
|
|
|
async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) {
|
|
return this.changeChannelStatus(channelId, { ...data, status: 'deleted' });
|
|
}
|
|
|
|
testChannel(channelId: string) {
|
|
return {
|
|
channelId,
|
|
status: 'queued',
|
|
message: 'Channel test request accepted as a phase-4 placeholder.',
|
|
};
|
|
}
|
|
|
|
listChannelMetrics(channelId: string) {
|
|
return this.prisma.channelHealthMetric.findMany({
|
|
where: { channelId },
|
|
orderBy: { windowStart: 'desc' },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
listChannelConnections(channelId: string) {
|
|
return this.prisma.cmppConnectionState.findMany({
|
|
where: { channelId },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
async listChannelLinkLogs(channelId: string) {
|
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
|
|
if (!channel) {
|
|
throw new NotFoundException('Channel not found');
|
|
}
|
|
const [connectionStates, logs] = await Promise.all([
|
|
this.prisma.cmppConnectionState.findMany({
|
|
where: { channelId },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 50,
|
|
}),
|
|
this.prisma.operationLog.findMany({
|
|
where: {
|
|
OR: [
|
|
{ resource: 'sms_channel', resourceId: channelId },
|
|
{ resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } },
|
|
],
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
}),
|
|
]);
|
|
return {
|
|
channelId,
|
|
connectionStates,
|
|
logs: logs.map((log) => ({
|
|
id: log.id,
|
|
time: log.createdAt,
|
|
event: normalizeLinkEvent(log.action),
|
|
action: log.action,
|
|
resourceId: log.resourceId,
|
|
detail: log.detail,
|
|
})),
|
|
};
|
|
}
|
|
|
|
listTenantConnections(tenantId: string) {
|
|
return this.prisma.cmppConnectionState.findMany({
|
|
where: { tenantId },
|
|
include: { channel: true },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
async upsertConnectionState(data: UpsertConnectionStateDto) {
|
|
const payload = {
|
|
tenantId: data.tenantId,
|
|
status: data.status,
|
|
desiredConnections: data.desiredConnections ?? 1,
|
|
currentConnections: data.currentConnections ?? (data.status === 'online' || data.status === 'connected' ? 1 : 0),
|
|
lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined,
|
|
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
|
|
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
|
|
reconnectCount: data.reconnectCount ?? 0,
|
|
lastError: data.lastError,
|
|
};
|
|
const state = await this.prisma.cmppConnectionState.upsert({
|
|
where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } },
|
|
update: payload,
|
|
create: {
|
|
channelId: data.channelId,
|
|
connectionId: data.connectionId,
|
|
...payload,
|
|
},
|
|
});
|
|
await this.prisma.operationLog.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
action: `cmpp_connection.${normalizeConnectionAction(data.status)}`,
|
|
resource: 'cmpp_connection',
|
|
resourceId: `${data.channelId}:${data.connectionId}`,
|
|
detail: {
|
|
status: data.status,
|
|
desiredConnections: state.desiredConnections,
|
|
currentConnections: state.currentConnections,
|
|
lastError: state.lastError,
|
|
} as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
return state;
|
|
}
|
|
|
|
listGroups() {
|
|
return this.prisma.smsChannelGroup.findMany({
|
|
include: { items: { include: { channel: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
createGroup(data: CreateChannelGroupDto) {
|
|
return this.prisma.smsChannelGroup.create({
|
|
data: {
|
|
code: data.code,
|
|
name: data.name,
|
|
description: data.description,
|
|
status: data.status ?? 'active',
|
|
},
|
|
});
|
|
}
|
|
|
|
addGroupItem(data: CreateChannelGroupItemDto) {
|
|
return this.prisma.smsChannelGroupItem.create({
|
|
data: {
|
|
groupId: data.groupId,
|
|
channelId: data.channelId,
|
|
carrier: data.carrier,
|
|
province: data.province,
|
|
priority: data.priority ?? 100,
|
|
weight: data.weight ?? 1,
|
|
isBackup: data.isBackup ?? false,
|
|
rateLimitPerSecond: data.rateLimitPerSecond,
|
|
},
|
|
});
|
|
}
|
|
|
|
listRouteRules() {
|
|
return this.prisma.channelRouteRule.findMany({
|
|
include: { group: true, channel: true },
|
|
orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }],
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
createRouteRule(data: CreateRouteRuleDto) {
|
|
return this.prisma.channelRouteRule.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
applicationId: data.applicationId,
|
|
groupId: data.groupId,
|
|
channelId: data.channelId,
|
|
carrier: data.carrier,
|
|
province: data.province,
|
|
priority: data.priority ?? 100,
|
|
status: data.status ?? 'active',
|
|
},
|
|
});
|
|
}
|
|
|
|
listReportFields(channelId?: string) {
|
|
return this.prisma.channelReportField.findMany({
|
|
where: channelId ? { channelId } : undefined,
|
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
|
take: 200,
|
|
});
|
|
}
|
|
|
|
createReportField(data: CreateReportFieldDto) {
|
|
return this.prisma.channelReportField.create({
|
|
data: {
|
|
channelId: data.channelId,
|
|
code: data.code,
|
|
name: data.name,
|
|
fieldType: data.fieldType,
|
|
required: data.required ?? false,
|
|
description: data.description,
|
|
sortOrder: data.sortOrder ?? 100,
|
|
status: data.status ?? 'active',
|
|
},
|
|
});
|
|
}
|
|
|
|
listReportMaterials(signatureId?: string, channelId?: string) {
|
|
return this.prisma.signatureReportMaterial.findMany({
|
|
where: {
|
|
signatureId,
|
|
channelId,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 200,
|
|
});
|
|
}
|
|
|
|
upsertReportMaterial(data: CreateReportMaterialDto) {
|
|
return this.prisma.signatureReportMaterial.upsert({
|
|
where: {
|
|
signatureId_channelId_fieldCode: {
|
|
signatureId: data.signatureId,
|
|
channelId: data.channelId,
|
|
fieldCode: data.fieldCode,
|
|
},
|
|
},
|
|
update: {
|
|
fieldValue: data.fieldValue,
|
|
fileObjectId: data.fileObjectId,
|
|
},
|
|
create: {
|
|
signatureId: data.signatureId,
|
|
channelId: data.channelId,
|
|
fieldCode: data.fieldCode,
|
|
fieldValue: data.fieldValue,
|
|
fileObjectId: data.fileObjectId,
|
|
},
|
|
});
|
|
}
|
|
|
|
listReportTasks(tenantId?: string, status?: string) {
|
|
return this.prisma.channelSignatureReportTask.findMany({
|
|
where: { tenantId, status },
|
|
include: { signature: true, channel: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
async createReportTask(data: CreateReportTaskDto) {
|
|
const task = await this.prisma.channelSignatureReportTask.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
signatureId: data.signatureId,
|
|
channelId: data.channelId,
|
|
createdById: data.createdById,
|
|
status: 'pending',
|
|
},
|
|
});
|
|
await this.recordReportTask(task.id, task.channelId, 'create', undefined, 'pending');
|
|
return task;
|
|
}
|
|
|
|
async createReportExport(taskId: string, data: CreateReportExportDto) {
|
|
const task = await this.getReportTaskOrThrow(taskId);
|
|
const exported = await this.prisma.reportExportFile.create({
|
|
data: {
|
|
taskId,
|
|
fileObjectId: data.fileObjectId,
|
|
fileName: data.fileName,
|
|
rowCount: data.rowCount ?? 0,
|
|
},
|
|
});
|
|
await this.updateReportTaskStatus(taskId, task.channelId, task.status, 'exporting', 'export');
|
|
return exported;
|
|
}
|
|
|
|
async importReportReceipt(taskId: string, data: CreateReceiptImportDto) {
|
|
const task = await this.getReportTaskOrThrow(taskId);
|
|
const statusAfter = data.statusAfter ?? (data.failedCount && data.failedCount > 0 ? 'rejected' : 'approved');
|
|
const imported = await this.prisma.reportReceiptImport.create({
|
|
data: {
|
|
taskId,
|
|
fileObjectId: data.fileObjectId,
|
|
fileName: data.fileName,
|
|
rowCount: data.rowCount ?? 0,
|
|
successCount: data.successCount ?? 0,
|
|
failedCount: data.failedCount ?? 0,
|
|
status: 'imported',
|
|
result: data.result as Prisma.InputJsonValue | undefined,
|
|
},
|
|
});
|
|
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
|
|
await this.prisma.smsSignature.update({
|
|
where: { id: task.signatureId },
|
|
data: { reportStatus: statusAfter },
|
|
});
|
|
return imported;
|
|
}
|
|
|
|
listReportRecords(taskId?: string, channelId?: string) {
|
|
return this.prisma.channelSignatureReportRecord.findMany({
|
|
where: { taskId, channelId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 200,
|
|
});
|
|
}
|
|
|
|
private async getReportTaskOrThrow(taskId: string) {
|
|
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId } });
|
|
if (!task) {
|
|
throw new NotFoundException('Report task not found');
|
|
}
|
|
return task;
|
|
}
|
|
|
|
private async updateReportTaskStatus(
|
|
taskId: string,
|
|
channelId: string,
|
|
statusBefore: string,
|
|
statusAfter: string,
|
|
action: string,
|
|
reason?: string,
|
|
) {
|
|
await this.prisma.channelSignatureReportTask.update({
|
|
where: { id: taskId },
|
|
data: { status: statusAfter, reason },
|
|
});
|
|
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
|
|
}
|
|
|
|
private recordReportTask(
|
|
taskId: string,
|
|
channelId: string,
|
|
action: string,
|
|
statusBefore: string | undefined,
|
|
statusAfter: string,
|
|
reason?: string,
|
|
) {
|
|
return this.prisma.channelSignatureReportRecord.create({
|
|
data: {
|
|
taskId,
|
|
channelId,
|
|
action,
|
|
statusBefore,
|
|
statusAfter,
|
|
reason,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function normalizeConnectionAction(status: string) {
|
|
const normalized = status.toLowerCase();
|
|
if (['online', 'connected', 'open'].includes(normalized)) {
|
|
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';
|
|
}
|
|
return 'updated';
|
|
}
|
|
|
|
function normalizeLinkEvent(action: string) {
|
|
if (action.includes('connected')) {
|
|
return '新建';
|
|
}
|
|
if (action.includes('heartbeat')) {
|
|
return '心跳';
|
|
}
|
|
if (action.includes('reconnecting')) {
|
|
return '重连';
|
|
}
|
|
if (action.includes('disconnected')) {
|
|
return '断开';
|
|
}
|
|
if (action.includes('copy')) {
|
|
return '复制';
|
|
}
|
|
if (action.includes('deleted')) {
|
|
return '删除';
|
|
}
|
|
return '更新';
|
|
}
|