feat: complete cmpp platform phases 0-5

This commit is contained in:
hectorzhao
2026-07-01 13:22:04 +08:00
parent 824a8b334f
commit ee926fea04
86 changed files with 10490 additions and 14 deletions
+110
View File
@@ -0,0 +1,110 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
ChannelsService,
CreateChannelDto,
CreateChannelGroupDto,
CreateChannelGroupItemDto,
CreateReceiptImportDto,
CreateReportExportDto,
CreateReportFieldDto,
CreateReportMaterialDto,
CreateReportTaskDto,
CreateRouteRuleDto,
} from './channels.service';
@ApiTags('channels')
@Controller('admin')
export class ChannelsController {
constructor(private readonly channels: ChannelsService) {}
@Get('channels')
listChannels() {
return this.channels.listChannels();
}
@Post('channels')
createChannel(@Body() body: CreateChannelDto) {
return this.channels.createChannel(body);
}
@Post('channels/:id/test')
testChannel(@Param('id') channelId: string) {
return this.channels.testChannel(channelId);
}
@Get('channels/:id/metrics')
listChannelMetrics(@Param('id') channelId: string) {
return this.channels.listChannelMetrics(channelId);
}
@Get('channel-groups')
listGroups() {
return this.channels.listGroups();
}
@Post('channel-groups')
createGroup(@Body() body: CreateChannelGroupDto) {
return this.channels.createGroup(body);
}
@Post('channel-groups/items')
addGroupItem(@Body() body: CreateChannelGroupItemDto) {
return this.channels.addGroupItem(body);
}
@Get('channel-route-rules')
listRouteRules() {
return this.channels.listRouteRules();
}
@Post('channel-route-rules')
createRouteRule(@Body() body: CreateRouteRuleDto) {
return this.channels.createRouteRule(body);
}
@Get('channel-report-fields')
listReportFields(@Query('channelId') channelId?: string) {
return this.channels.listReportFields(channelId);
}
@Post('channel-report-fields')
createReportField(@Body() body: CreateReportFieldDto) {
return this.channels.createReportField(body);
}
@Get('signature-report-materials')
listReportMaterials(@Query('signatureId') signatureId?: string, @Query('channelId') channelId?: string) {
return this.channels.listReportMaterials(signatureId, channelId);
}
@Post('signature-report-materials')
upsertReportMaterial(@Body() body: CreateReportMaterialDto) {
return this.channels.upsertReportMaterial(body);
}
@Get('report-tasks')
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
return this.channels.listReportTasks(tenantId, status);
}
@Post('report-tasks/generate')
createReportTask(@Body() body: CreateReportTaskDto) {
return this.channels.createReportTask(body);
}
@Post('report-tasks/:id/export')
createReportExport(@Param('id') taskId: string, @Body() body: CreateReportExportDto) {
return this.channels.createReportExport(taskId, body);
}
@Post('report-tasks/:id/receipt-import')
importReportReceipt(@Param('id') taskId: string, @Body() body: CreateReceiptImportDto) {
return this.channels.importReportReceipt(taskId, body);
}
@Get('report-records')
listReportRecords(@Query('taskId') taskId?: string, @Query('channelId') channelId?: string) {
return this.channels.listReportRecords(taskId, channelId);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ChannelsController } from './channels.controller';
import { ChannelsService } from './channels.service';
@Module({
controllers: [ChannelsController],
providers: [ChannelsService],
exports: [ChannelsService],
})
export class ChannelsModule {}
+365
View File
@@ -0,0 +1,365 @@
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>;
}
@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,
},
});
}
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,
});
}
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,
},
});
}
}