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
+34
View File
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { BillingModule } from './billing/billing.module';
import { ChannelsModule } from './channels/channels.module';
import { DictionariesModule } from './dictionaries/dictionaries.module';
import { FilesModule } from './files/files.module';
import { HealthController } from './health.controller';
import { PrismaModule } from './prisma/prisma.module';
import { SmsConfigModule } from './sms-config/sms-config.module';
import { TenantsModule } from './tenants/tenants.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
PrismaModule,
AuthModule,
TenantsModule,
UsersModule,
AuditModule,
FilesModule,
DictionariesModule,
BillingModule,
SmsConfigModule,
ChannelsModule,
],
controllers: [HealthController],
})
export class AppModule {}
+20
View File
@@ -0,0 +1,20 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { AuditService, CreateOperationLogDto } from './audit.service';
@ApiTags('audit')
@Controller('admin/operation-logs')
export class AuditController {
constructor(private readonly audit: AuditService) {}
@Get()
list(@TenantId() tenantId?: string) {
return this.audit.list(tenantId);
}
@Post()
create(@Body() body: CreateOperationLogDto) {
return this.audit.create(body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
@Module({
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
+41
View File
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateOperationLogDto {
tenantId?: string;
userId?: string;
action: string;
resource: string;
resourceId?: string;
ipAddress?: string;
userAgent?: string;
detail?: Record<string, unknown>;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
list(tenantId?: string) {
return this.prisma.operationLog.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
take: 100,
});
}
create(data: CreateOperationLogDto) {
const createData: Prisma.OperationLogUncheckedCreateInput = {
tenantId: data.tenantId,
userId: data.userId,
action: data.action,
resource: data.resource,
resourceId: data.resourceId,
ipAddress: data.ipAddress,
userAgent: data.userAgent,
detail: data.detail as Prisma.InputJsonValue | undefined,
};
return this.prisma.operationLog.create({ data: createData });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthService, LoginDto } from './auth.service';
@ApiTags('auth')
@Controller('client/auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
login(@Body() body: LoginDto) {
return this.auth.login(body);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [UsersModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
+30
View File
@@ -0,0 +1,30 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { hashPassword, UsersService } from '../users/users.service';
export interface LoginDto {
username: string;
password: string;
}
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService) {}
async login(data: LoginDto) {
const user = await this.users.findByUsername(data.username);
if (!user || user.passwordHash !== hashPassword(data.password) || user.status !== 'active') {
throw new UnauthorizedException('Invalid username or password');
}
return {
accessToken: `dev-token-${user.id}`,
tokenType: 'Bearer',
user: {
id: user.id,
tenantId: user.tenantId,
username: user.username,
displayName: user.displayName,
},
};
}
}
+146
View File
@@ -0,0 +1,146 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import {
BillingService,
BillingActionDto,
CreateRechargeOrderDto,
CreateAccountTransactionDto,
CreateBillingPlanDto,
CreateBillingRuleDto,
CreateSmsBillingRecordDto,
CreateTenantAccountDto,
EstimateSmsCostDto,
} from './billing.service';
@ApiTags('billing')
@Controller('admin/billing')
export class BillingController {
constructor(private readonly billing: BillingService) {}
@Get('plans')
listPlans() {
return this.billing.listPlans();
}
@Post('plans')
createPlan(@Body() body: CreateBillingPlanDto) {
return this.billing.createPlan(body);
}
@Get('accounts')
listAccounts() {
return this.billing.listAccounts();
}
@Post('accounts')
createAccount(@Body() body: CreateTenantAccountDto) {
return this.billing.createAccount(body);
}
@Get('transactions')
listTransactions(@TenantId() tenantId?: string) {
return this.billing.listTransactions(tenantId);
}
@Post('transactions')
createTransaction(@Body() body: CreateAccountTransactionDto) {
return this.billing.createTransaction(body);
}
@Get('recharges')
listRechargeOrders(@TenantId() tenantId?: string) {
return this.billing.listRechargeOrders(tenantId);
}
@Post('recharges')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Post('estimate')
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
return this.billing.estimateSmsCost(body);
}
@Post('check')
checkAccount(@Body() body: BillingActionDto) {
return this.billing.checkAccount(body);
}
@Post('freeze')
freeze(@Body() body: BillingActionDto) {
return this.billing.freeze(body);
}
@Post('charge')
charge(@Body() body: BillingActionDto) {
return this.billing.charge(body);
}
@Post('release')
release(@Body() body: BillingActionDto) {
return this.billing.release(body);
}
@Post('refund')
refund(@Body() body: BillingActionDto) {
return this.billing.refund(body);
}
@Post('adjust')
adjust(@Body() body: BillingActionDto) {
return this.billing.adjust(body);
}
@Get('sms-billing-records')
listSmsBillingRecords(@TenantId() tenantId?: string) {
return this.billing.listSmsBillingRecords(tenantId);
}
@Post('sms-billing-records')
createSmsBillingRecord(@Body() body: CreateSmsBillingRecordDto) {
return this.billing.createSmsBillingRecord(body);
}
@Get('rules')
listRules() {
return this.billing.listRules();
}
@Post('rules')
createRule(@Body() body: CreateBillingRuleDto) {
return this.billing.createRule(body);
}
}
@ApiTags('client-billing')
@Controller('client/billing')
export class ClientBillingController {
constructor(private readonly billing: BillingService) {}
@Get('plans')
listPlans() {
return this.billing.listPlans();
}
@Get('transactions')
listTransactions(@TenantId() tenantId?: string) {
return this.billing.listTransactions(tenantId);
}
@Post('orders')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Get('orders')
listRechargeOrders(@TenantId() tenantId?: string) {
return this.billing.listRechargeOrders(tenantId);
}
@Post('estimate')
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
return this.billing.estimateSmsCost(body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BillingController, ClientBillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
controllers: [BillingController, ClientBillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}
+349
View File
@@ -0,0 +1,349 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateBillingPlanDto {
name: string;
priceCents: number;
smsUnits: number;
validDays: number;
status?: string;
description?: string;
}
export interface CreateTenantAccountDto {
tenantId: string;
balanceCents?: number;
smsUnits?: number;
creditCents?: number;
status?: string;
}
export interface CreateAccountTransactionDto {
tenantId: string;
transactionType: string;
amountCents?: number;
smsUnits?: number;
balanceAfter?: number;
relatedType?: string;
relatedId?: string;
remark?: string;
}
export interface CreateBillingRuleDto {
code: string;
name: string;
chargeBasis?: string;
unitPrice: number;
status?: string;
}
export interface CreateRechargeOrderDto {
tenantId: string;
planId?: string;
amountCents?: number;
smsUnits?: number;
payMethod?: string;
operatorId?: string;
remark?: string;
}
export interface EstimateSmsCostDto {
tenantId: string;
applicationId?: string;
content: string;
phoneCount: number;
unitPrice?: number;
taskId?: string;
}
export interface BillingActionDto {
tenantId: string;
amountCents?: number;
smsUnits?: number;
relatedType?: string;
relatedId?: string;
remark?: string;
}
export interface CreateSmsBillingRecordDto {
tenantId: string;
applicationId?: string;
taskId?: string;
messageId?: string;
phoneNumber?: string;
content: string;
unitPrice?: number;
}
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
listPlans() {
return this.prisma.billingPlan.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
}
createPlan(data: CreateBillingPlanDto) {
return this.prisma.billingPlan.create({
data: {
name: data.name,
priceCents: data.priceCents,
smsUnits: data.smsUnits,
validDays: data.validDays,
status: data.status ?? 'active',
description: data.description,
},
});
}
listAccounts() {
return this.prisma.tenantAccount.findMany({
include: { tenant: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createAccount(data: CreateTenantAccountDto) {
const createData: Prisma.TenantAccountUncheckedCreateInput = {
tenantId: data.tenantId,
balanceCents: data.balanceCents ?? 0,
smsUnits: data.smsUnits ?? 0,
creditCents: data.creditCents ?? 0,
status: data.status ?? 'active',
};
return this.prisma.tenantAccount.create({ data: createData });
}
listTransactions(tenantId?: string) {
return this.prisma.accountTransaction.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createTransaction(data: CreateAccountTransactionDto) {
const createData: Prisma.AccountTransactionUncheckedCreateInput = {
tenantId: data.tenantId,
transactionType: data.transactionType,
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
balanceAfter: data.balanceAfter ?? 0,
relatedType: data.relatedType,
relatedId: data.relatedId,
remark: data.remark,
};
return this.prisma.accountTransaction.create({ data: createData });
}
listRechargeOrders(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({
where: tenantId ? { tenantId } : undefined,
include: { plan: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async createRechargeOrder(data: CreateRechargeOrderDto) {
const plan = data.planId ? await this.prisma.billingPlan.findUnique({ where: { id: data.planId } }) : null;
const amountCents = data.amountCents ?? plan?.priceCents ?? 0;
const smsUnits = data.smsUnits ?? plan?.smsUnits ?? 0;
const order = await this.prisma.rechargeOrder.create({
data: {
tenantId: data.tenantId,
planId: data.planId,
orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
amountCents,
smsUnits,
status: 'paid',
payMethod: data.payMethod ?? 'manual',
paidAt: new Date(),
operatorId: data.operatorId,
remark: data.remark,
},
});
await this.applyAccountDelta({
tenantId: data.tenantId,
transactionType: 'recharge',
amountCents,
smsUnits,
relatedType: 'recharge_order',
relatedId: order.id,
remark: data.remark,
});
return order;
}
estimateSmsCost(data: EstimateSmsCostDto) {
const billingUnits = estimateBillingUnits(data.content);
const unitPrice = data.unitPrice ?? 0;
const totalUnits = billingUnits * data.phoneCount;
return {
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: data.taskId,
contentLength: [...data.content].length,
phoneCount: data.phoneCount,
billingUnitsPerMessage: billingUnits,
totalBillingUnits: totalUnits,
unitPrice,
amountCents: totalUnits * unitPrice,
};
}
async checkAccount(data: BillingActionDto) {
const account = await this.getAccountOrCreate(data.tenantId);
const requiredAmount = data.amountCents ?? 0;
const requiredUnits = data.smsUnits ?? 0;
const availableAmount = account.balanceCents + account.creditCents;
return {
tenantId: data.tenantId,
requiredAmount,
requiredUnits,
availableAmount,
availableSmsUnits: account.smsUnits,
canSend: availableAmount >= requiredAmount && account.smsUnits >= requiredUnits,
};
}
freeze(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'frozen',
amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
});
}
charge(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'charged',
amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
});
}
release(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'released',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
refund(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'refunded',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
adjust(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'adjusted',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
async createSmsBillingRecord(data: CreateSmsBillingRecordDto) {
const estimate = this.estimateSmsCost({
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: data.taskId,
content: data.content,
phoneCount: 1,
unitPrice: data.unitPrice ?? 0,
});
return this.prisma.smsBillingRecord.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: data.taskId,
messageId: data.messageId,
phoneNumber: data.phoneNumber,
contentLength: estimate.contentLength,
billingUnits: estimate.billingUnitsPerMessage,
unitPrice: estimate.unitPrice,
amountCents: estimate.amountCents,
billingStatus: 'estimated',
},
});
}
listSmsBillingRecords(tenantId?: string, taskId?: string) {
return this.prisma.smsBillingRecord.findMany({
where: { tenantId, taskId },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
listRules() {
return this.prisma.billingRule.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
}
createRule(data: CreateBillingRuleDto) {
return this.prisma.billingRule.create({
data: {
code: data.code,
name: data.name,
chargeBasis: data.chargeBasis ?? 'submit_success',
unitPrice: data.unitPrice,
status: data.status ?? 'active',
},
});
}
private async getAccountOrCreate(tenantId: string) {
return this.prisma.tenantAccount.upsert({
where: { tenantId },
update: {},
create: { tenantId, balanceCents: 0, smsUnits: 0, creditCents: 0, status: 'active' },
});
}
private async applyAccountDelta(data: CreateAccountTransactionDto) {
const account = await this.getAccountOrCreate(data.tenantId);
const nextBalance = account.balanceCents + (data.amountCents ?? 0);
const nextUnits = account.smsUnits + (data.smsUnits ?? 0);
await this.prisma.tenantAccount.update({
where: { tenantId: data.tenantId },
data: {
balanceCents: nextBalance,
smsUnits: nextUnits,
},
});
return this.prisma.accountTransaction.create({
data: {
tenantId: data.tenantId,
transactionType: data.transactionType,
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
balanceAfter: nextBalance,
relatedType: data.relatedType,
relatedId: data.relatedId,
remark: data.remark,
},
});
}
}
function estimateBillingUnits(content: string) {
const length = [...content].length;
if (length <= 70) {
return 1;
}
return Math.ceil(length / 67);
}
+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,
},
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const TenantId = createParamDecorator((_: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<{ header(name: string): string | undefined }>();
const value = request.header('x-tenant-id');
return value && value.trim().length > 0 ? value.trim() : undefined;
});
@@ -0,0 +1,66 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import {
CreateBlacklistDto,
CreateDrainageFieldDto,
CreatePhoneSegmentDto,
CreateSensitiveWordDto,
DictionariesService,
} from './dictionaries.service';
@ApiTags('dictionaries')
@Controller('admin/dictionaries')
export class DictionariesController {
constructor(private readonly dictionaries: DictionariesService) {}
@Get('phone-segments')
listPhoneSegments() {
return this.dictionaries.listPhoneSegments();
}
@Post('phone-segments')
createPhoneSegment(@Body() body: CreatePhoneSegmentDto) {
return this.dictionaries.createPhoneSegment(body);
}
@Get('sensitive-words')
listSensitiveWords() {
return this.dictionaries.listSensitiveWords();
}
@Post('sensitive-words')
createSensitiveWord(@Body() body: CreateSensitiveWordDto) {
return this.dictionaries.createSensitiveWord(body);
}
@Get('blacklists/global')
listGlobalBlacklist() {
return this.dictionaries.listGlobalBlacklist();
}
@Post('blacklists/global')
createGlobalBlacklist(@Body() body: CreateBlacklistDto) {
return this.dictionaries.createGlobalBlacklist(body);
}
@Get('blacklists/enterprise')
listEnterpriseBlacklist(@TenantId() tenantId?: string) {
return this.dictionaries.listEnterpriseBlacklist(tenantId);
}
@Post('blacklists/enterprise')
createEnterpriseBlacklist(@Body() body: CreateBlacklistDto) {
return this.dictionaries.createEnterpriseBlacklist(body);
}
@Get('drainage-fields')
listDrainageFields() {
return this.dictionaries.listDrainageFields();
}
@Post('drainage-fields')
createDrainageField(@Body() body: CreateDrainageFieldDto) {
return this.dictionaries.createDrainageField(body);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DictionariesController } from './dictionaries.controller';
import { DictionariesService } from './dictionaries.service';
@Module({
controllers: [DictionariesController],
providers: [DictionariesService],
exports: [DictionariesService],
})
export class DictionariesModule {}
@@ -0,0 +1,111 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface CreatePhoneSegmentDto {
prefix: string;
carrier: string;
province?: string;
city?: string;
}
export interface CreateSensitiveWordDto {
word: string;
level?: string;
status?: string;
}
export interface CreateBlacklistDto {
tenantId?: string;
phoneNumber: string;
reason?: string;
status?: string;
}
export interface CreateDrainageFieldDto {
code: string;
name: string;
fieldType: string;
required?: boolean;
status?: string;
description?: string;
}
@Injectable()
export class DictionariesService {
constructor(private readonly prisma: PrismaService) {}
listPhoneSegments() {
return this.prisma.phoneSegment.findMany({ orderBy: { prefix: 'asc' }, take: 200 });
}
createPhoneSegment(data: CreatePhoneSegmentDto) {
return this.prisma.phoneSegment.create({ data });
}
listSensitiveWords() {
return this.prisma.sensitiveWord.findMany({ orderBy: { createdAt: 'desc' }, take: 200 });
}
createSensitiveWord(data: CreateSensitiveWordDto) {
return this.prisma.sensitiveWord.create({
data: {
word: data.word,
level: data.level ?? 'block',
status: data.status ?? 'active',
},
});
}
listGlobalBlacklist() {
return this.prisma.globalBlacklist.findMany({ orderBy: { createdAt: 'desc' }, take: 200 });
}
createGlobalBlacklist(data: CreateBlacklistDto) {
return this.prisma.globalBlacklist.create({
data: {
phoneNumber: data.phoneNumber,
reason: data.reason,
status: data.status ?? 'active',
},
});
}
listEnterpriseBlacklist(tenantId?: string) {
return this.prisma.enterpriseBlacklist.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
take: 200,
});
}
createEnterpriseBlacklist(data: CreateBlacklistDto) {
if (!data.tenantId) {
throw new Error('tenantId is required for enterprise blacklist');
}
const createData: Prisma.EnterpriseBlacklistUncheckedCreateInput = {
tenantId: data.tenantId,
phoneNumber: data.phoneNumber,
reason: data.reason,
status: data.status ?? 'active',
};
return this.prisma.enterpriseBlacklist.create({ data: createData });
}
listDrainageFields() {
return this.prisma.drainageField.findMany({ orderBy: { createdAt: 'desc' }, take: 200 });
}
createDrainageField(data: CreateDrainageFieldDto) {
return this.prisma.drainageField.create({
data: {
code: data.code,
name: data.name,
fieldType: data.fieldType,
required: data.required ?? false,
status: data.status ?? 'active',
description: data.description,
},
});
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { CreateFileObjectDto, CreatePresignedUploadDto, FilesService } from './files.service';
@ApiTags('files')
@Controller('admin/files')
export class FilesController {
constructor(private readonly files: FilesService) {}
@Get()
list(@TenantId() tenantId?: string) {
return this.files.list(tenantId);
}
@Post()
create(@Body() body: CreateFileObjectDto) {
return this.files.create(body);
}
@Post('presigned-upload')
createPresignedUpload(@Body() body: CreatePresignedUploadDto) {
return this.files.createPresignedUpload(body);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
import { ObjectStorageService } from './object-storage.service';
@Module({
controllers: [FilesController],
providers: [FilesService, ObjectStorageService],
exports: [FilesService, ObjectStorageService],
})
export class FilesModule {}
+60
View File
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { ObjectStorageService } from './object-storage.service';
export interface CreateFileObjectDto {
tenantId?: string;
bucket: string;
objectKey: string;
fileName: string;
contentType: string;
sizeBytes: number | string;
checksum?: string;
purpose: string;
}
export interface CreatePresignedUploadDto {
objectKey: string;
expiresInSeconds?: number;
}
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaService,
private readonly objectStorage: ObjectStorageService,
) {}
list(tenantId?: string) {
return this.prisma.fileObject.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
take: 100,
});
}
create(data: CreateFileObjectDto) {
const createData: Prisma.FileObjectUncheckedCreateInput = {
tenantId: data.tenantId,
bucket: data.bucket,
objectKey: data.objectKey,
fileName: data.fileName,
contentType: data.contentType,
sizeBytes: BigInt(data.sizeBytes),
checksum: data.checksum,
purpose: data.purpose,
};
return this.prisma.fileObject.create({ data: createData });
}
async createPresignedUpload(data: CreatePresignedUploadDto) {
const uploadUrl = await this.objectStorage.presignedPutObject(data.objectKey, data.expiresInSeconds ?? 3600);
return {
bucket: this.objectStorage.getBucket(),
objectKey: data.objectKey,
uploadUrl,
expiresInSeconds: data.expiresInSeconds ?? 3600,
};
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Client } from 'minio';
@Injectable()
export class ObjectStorageService {
private readonly client: Client;
private readonly bucket: string;
constructor(config: ConfigService) {
const endpoint = config.get<string>('MINIO_ENDPOINT') ?? 'localhost:9000';
const [endPoint, portText] = endpoint.split(':');
this.bucket = config.get<string>('MINIO_BUCKET') ?? 'cmpp-platform';
this.client = new Client({
endPoint,
port: Number(portText ?? 9000),
useSSL: config.get<string>('MINIO_USE_SSL') === 'true',
accessKey: config.get<string>('MINIO_ACCESS_KEY') ?? 'cmpp_minio',
secretKey: config.get<string>('MINIO_SECRET_KEY') ?? 'cmpp_minio_password',
});
}
presignedPutObject(objectKey: string, expirySeconds = 3600) {
return this.client.presignedPutObject(this.bucket, objectKey, expirySeconds);
}
getBucket() {
return this.bucket;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('health')
@Controller('health')
export class HealthController {
@Get()
getHealth() {
return {
status: 'ok',
service: 'cmpp-platform-api',
timestamp: new Date().toISOString(),
};
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
const swaggerConfig = new DocumentBuilder()
.setTitle('CMPP Platform API')
.setDescription('First-version CMPP SMS platform API')
.setVersion('0.1.0')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
const port = Number(process.env.API_PORT ?? 3000);
await app.listen(port);
}
void bootstrap();
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+19
View File
@@ -0,0 +1,19 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleDestroy {
constructor() {
super({
adapter: new PrismaPg(
process.env.DATABASE_URL ??
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
),
});
}
async onModuleDestroy() {
await this.$disconnect();
}
}
@@ -0,0 +1,49 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ReviewDto, SmsConfigService } from './sms-config.service';
@ApiTags('admin-sms-config')
@Controller('admin')
export class AdminSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService) {}
@Get('enterprise-applications')
listApplications(@Query('tenantId') tenantId?: string) {
return this.smsConfig.listApplications(tenantId);
}
@Get('enterprise-signatures')
listSignatures(@Query('tenantId') tenantId?: string) {
return this.smsConfig.listSignatures(tenantId);
}
@Get('enterprise-templates')
listTemplates(@Query('tenantId') tenantId?: string) {
return this.smsConfig.listTemplates(tenantId);
}
@Get('audit-records')
listAuditRecords(@Query('targetType') targetType?: string, @Query('targetId') targetId?: string) {
return this.smsConfig.listAuditRecords(targetType, targetId);
}
@Post('signatures/:id/approve')
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
return this.smsConfig.approveSignature(signatureId, body);
}
@Post('signatures/:id/reject')
rejectSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
return this.smsConfig.rejectSignature(signatureId, body);
}
@Post('templates/:id/approve')
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
return this.smsConfig.approveTemplate(templateId, body);
}
@Post('templates/:id/reject')
rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
return this.smsConfig.rejectTemplate(templateId, body);
}
}
@@ -0,0 +1,61 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import {
CreateSignatureMaterialDto,
CreateSmsApplicationDto,
CreateSmsSignatureDto,
CreateSmsTemplateDto,
SmsConfigService,
} from './sms-config.service';
@ApiTags('client-sms-config')
@Controller('client')
export class ClientSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService) {}
@Get('applications')
listApplications(@TenantId() tenantId?: string) {
return this.smsConfig.listApplications(tenantId);
}
@Post('applications')
createApplication(@Body() body: CreateSmsApplicationDto) {
return this.smsConfig.createApplication(body);
}
@Get('signatures')
listSignatures(@TenantId() tenantId?: string) {
return this.smsConfig.listSignatures(tenantId);
}
@Post('signatures')
createSignature(@Body() body: CreateSmsSignatureDto) {
return this.smsConfig.createSignature(body);
}
@Post('signatures/:id/materials')
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: Omit<CreateSignatureMaterialDto, 'signatureId'>) {
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
}
@Post('signatures/:id/submit')
submitSignature(@Param('id') signatureId: string) {
return this.smsConfig.submitSignature(signatureId);
}
@Get('templates')
listTemplates(@TenantId() tenantId?: string) {
return this.smsConfig.listTemplates(tenantId);
}
@Post('templates')
createTemplate(@Body() body: CreateSmsTemplateDto) {
return this.smsConfig.createTemplate(body);
}
@Post('templates/:id/submit')
submitTemplate(@Param('id') templateId: string) {
return this.smsConfig.submitTemplate(templateId);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AdminSmsConfigController } from './admin-sms-config.controller';
import { ClientSmsConfigController } from './client-sms-config.controller';
import { SmsConfigService } from './sms-config.service';
@Module({
controllers: [ClientSmsConfigController, AdminSmsConfigController],
providers: [SmsConfigService],
exports: [SmsConfigService],
})
export class SmsConfigModule {}
+292
View File
@@ -0,0 +1,292 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomBytes, createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateSmsApplicationDto {
tenantId: string;
name: string;
scene?: string;
callbackUrl?: string;
dailyLimit?: number;
maxPhonesPerTask?: number;
templateMismatchMode?: string;
ipAllowlist?: string[];
}
export interface CreateSmsSignatureDto {
tenantId: string;
applicationId?: string;
name: string;
purpose?: string;
drainageInfo?: Record<string, unknown>;
}
export interface CreateSignatureMaterialDto {
signatureId: string;
fileObjectId?: string;
materialType: string;
title: string;
description?: string;
}
export interface CreateSmsTemplateDto {
tenantId: string;
applicationId: string;
signatureId?: string;
name: string;
content: string;
category?: string;
variables?: Array<{ name: string; example?: string; required?: boolean }>;
}
export interface ReviewDto {
reviewerId?: string;
reason?: string;
}
@Injectable()
export class SmsConfigService {
constructor(private readonly prisma: PrismaService) {}
listApplications(tenantId?: string) {
return this.prisma.smsApplication.findMany({
where: tenantId ? { tenantId } : undefined,
include: { ipAllowlist: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createApplication(data: CreateSmsApplicationDto) {
const secret = randomBytes(24).toString('hex');
return this.prisma.smsApplication.create({
data: {
tenantId: data.tenantId,
name: data.name,
scene: data.scene,
callbackUrl: data.callbackUrl,
secretHash: hashSecret(secret),
dailyLimit: data.dailyLimit,
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
templateMismatchMode: data.templateMismatchMode ?? 'reject',
ipAllowlist: {
create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })),
},
},
include: { ipAllowlist: true },
});
}
listSignatures(tenantId?: string) {
return this.prisma.smsSignature.findMany({
where: tenantId ? { tenantId } : undefined,
include: { materials: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createSignature(data: CreateSmsSignatureDto) {
return this.prisma.smsSignature.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
name: data.name,
purpose: data.purpose,
drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined,
},
});
}
createSignatureMaterial(data: CreateSignatureMaterialDto) {
return this.prisma.signatureMaterial.create({
data: {
signatureId: data.signatureId,
fileObjectId: data.fileObjectId,
materialType: data.materialType,
title: data.title,
description: data.description,
},
});
}
async submitSignature(signatureId: string) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) {
throw new NotFoundException('Signature not found');
}
const updated = await this.prisma.smsSignature.update({
where: { id: signatureId },
data: { auditStatus: 'pending', rejectReason: null },
});
await this.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
targetId: signatureId,
action: 'submit',
statusBefore: signature.auditStatus,
statusAfter: 'pending',
});
return updated;
}
listTemplates(tenantId?: string) {
return this.prisma.smsTemplate.findMany({
where: tenantId ? { tenantId } : undefined,
include: { variables: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createTemplate(data: CreateSmsTemplateDto) {
return this.prisma.smsTemplate.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
signatureId: data.signatureId,
name: data.name,
content: data.content,
category: data.category,
billingUnits: estimateBillingUnits(data.content),
variables: {
create: (data.variables ?? inferTemplateVariables(data.content)).map((variable: TemplateVariableInput) => ({
name: variable.name,
example: variable.example,
required: variable.required ?? true,
})),
},
},
include: { variables: true },
});
}
async submitTemplate(templateId: string) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template) {
throw new NotFoundException('Template not found');
}
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
data: { auditStatus: 'pending', rejectReason: null },
});
await this.createAuditRecord({
tenantId: template.tenantId,
targetType: 'sms_template',
targetId: templateId,
action: 'submit',
statusBefore: template.auditStatus,
statusAfter: 'pending',
});
return updated;
}
listAuditRecords(targetType?: string, targetId?: string) {
return this.prisma.auditRecord.findMany({
where: {
targetType,
targetId,
},
orderBy: { createdAt: 'desc' },
take: 100,
});
}
approveSignature(signatureId: string, data: ReviewDto) {
return this.reviewSignature(signatureId, 'approved', 'approve', data);
}
rejectSignature(signatureId: string, data: ReviewDto) {
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
}
approveTemplate(templateId: string, data: ReviewDto) {
return this.reviewTemplate(templateId, 'approved', 'approve', data);
}
rejectTemplate(templateId: string, data: ReviewDto) {
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
}
private async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) {
throw new NotFoundException('Signature not found');
}
const updated = await this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason : null,
},
});
await this.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
targetId: signatureId,
action,
statusBefore: signature.auditStatus,
statusAfter,
reason: data.reason,
reviewerId: data.reviewerId,
});
return updated;
}
private async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template) {
throw new NotFoundException('Template not found');
}
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason : null,
},
});
await this.createAuditRecord({
tenantId: template.tenantId,
targetType: 'sms_template',
targetId: templateId,
action,
statusBefore: template.auditStatus,
statusAfter,
reason: data.reason,
reviewerId: data.reviewerId,
});
return updated;
}
private createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
return this.prisma.auditRecord.create({ data });
}
}
interface TemplateVariableInput {
name: string;
example?: string;
required?: boolean;
}
function hashSecret(secret: string) {
return createHash('sha256').update(secret).digest('hex');
}
function estimateBillingUnits(content: string) {
const length = [...content].length;
if (length <= 70) {
return 1;
}
return Math.ceil(length / 67);
}
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 }));
}
+215
View File
@@ -0,0 +1,215 @@
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
const connection = new IORedis({
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? 6379),
maxRetriesPerRequest: null,
});
const submitQueueName = 'cmpp.submit.commands';
const submitResultQueueName = 'cmpp.submit.results';
const receiptQueueName = 'cmpp.receipt.events';
const submitQueue = new Queue(submitQueueName, { connection });
const submitResultQueue = new Queue(submitResultQueueName, { connection });
const receiptQueue = new Queue(receiptQueueName, { connection });
const messageCount = Number(process.env.SPIKE_MESSAGE_COUNT ?? 15000);
const concurrency = Number(process.env.SPIKE_CONCURRENCY ?? 500);
function createSubmitCommand(index) {
const padded = String(index).padStart(6, '0');
const now = new Date().toISOString();
return {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
traceId: `trace-bullmq-${padded}`,
messageId: `msg-bullmq-${padded}`,
channelId: 'sms-channel-cmpp-spike',
createdAt: now,
tenantId: 'tenant-spike',
applicationId: 'app-spike',
taskId: 'task-bullmq-spike',
submitId: `submit-bullmq-${padded}`,
phoneNumber: '13800138000',
content: '您的验证码为 1234565 分钟内有效。',
signature: '测试平台',
templateId: 'tpl-spike',
billingUnits: 1,
route: {
channelCode: 'CMCC-CMPP-SPIKE',
cmppAccountCode: 'cmpp-account-spike',
priority: 10,
rateLimitPerSecond: 500,
},
cmpp: {
serviceId: 'CMPP',
srcId: '106900000000',
registeredDelivery: 1,
msgFmt: 15,
feeUserType: 2,
feeCode: '0',
feeType: '01',
},
retry: {
attempt: 0,
maxAttempts: 3,
},
};
}
async function cleanQueues() {
for (const queue of [submitQueue, submitResultQueue, receiptQueue]) {
await queue.drain(true);
await queue.obliterate({ force: true });
}
}
async function closeAll(workers) {
await Promise.all(workers.map((worker) => worker.close()));
await Promise.all([submitQueue.close(), submitResultQueue.close(), receiptQueue.close()]);
await connection.quit();
}
let submitResults = 0;
let receiptEvents = 0;
const gatewayWorker = new Worker(
submitQueueName,
async (job) => {
const cmd = job.data;
const sequenceId = Number(job.id.replace(/\D/g, '').slice(-9)) || job.attemptsMade + 1;
const gatewayMessageId = `gw-${cmd.messageId}`;
const now = new Date().toISOString();
await submitResultQueue.add(
'SubmitResult',
{
schemaVersion: 'v1',
messageType: 'SubmitResult',
traceId: cmd.traceId,
messageId: cmd.messageId,
channelId: cmd.channelId,
createdAt: now,
sequenceId,
gatewayMessageId,
submitStatus: 'accepted',
submittedAt: now,
},
{ jobId: `submit-result-${cmd.messageId}` },
);
await receiptQueue.add(
'ReceiptEvent',
{
schemaVersion: 'v1',
messageType: 'ReceiptEvent',
traceId: cmd.traceId,
messageId: cmd.messageId,
channelId: cmd.channelId,
createdAt: now,
sequenceId,
gatewayMessageId,
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
deliveredAt: now,
},
{ jobId: `receipt-${cmd.messageId}` },
);
},
{ connection, concurrency },
);
const submitResultWorker = new Worker(
submitResultQueueName,
async () => {
submitResults += 1;
},
{ connection, concurrency },
);
const receiptWorker = new Worker(
receiptQueueName,
async () => {
receiptEvents += 1;
},
{ connection, concurrency },
);
async function waitForCompletion() {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (submitResults >= messageCount && receiptEvents >= messageCount) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for BullMQ events: submitResults=${submitResults} receiptEvents=${receiptEvents}`);
}
async function main() {
await connection.ping();
await cleanQueues();
const workers = [gatewayWorker, submitResultWorker, receiptWorker];
const runId = randomUUID();
const startedAt = performance.now();
for (let offset = 0; offset < messageCount; offset += 1000) {
const jobs = [];
for (let index = offset; index < Math.min(offset + 1000, messageCount); index += 1) {
const cmd = createSubmitCommand(index);
jobs.push({
name: 'SubmitCommand',
data: cmd,
opts: {
jobId: `${runId}-${cmd.messageId}`,
attempts: 3,
},
});
}
await submitQueue.addBulk(jobs);
}
const enqueuedAt = performance.now();
await waitForCompletion();
const completedAt = performance.now();
const enqueueDurationMs = enqueuedAt - startedAt;
const totalDurationMs = completedAt - startedAt;
const enqueueTps = messageCount / (enqueueDurationMs / 1000);
const endToEndTps = messageCount / (totalDurationMs / 1000);
const meets500Tps = enqueueTps >= 500 && endToEndTps >= 500;
console.log(
JSON.stringify(
{
messageCount,
concurrency,
submitResults,
receiptEvents,
enqueueDurationMs: Number(enqueueDurationMs.toFixed(2)),
totalDurationMs: Number(totalDurationMs.toFixed(2)),
enqueueTps: Number(enqueueTps.toFixed(2)),
endToEndTps: Number(endToEndTps.toFixed(2)),
meets500Tps,
},
null,
2,
),
);
await closeAll(workers);
if (!meets500Tps) {
process.exitCode = 1;
}
}
main().catch(async (error) => {
console.error(error);
await closeAll([gatewayWorker, submitResultWorker, receiptWorker]);
process.exitCode = 1;
});
+24
View File
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CreateTenantDto, TenantsService } from './tenants.service';
@ApiTags('tenants')
@Controller('admin/tenants')
export class TenantsController {
constructor(private readonly tenants: TenantsService) {}
@Get()
list() {
return this.tenants.list();
}
@Get(':id')
get(@Param('id') id: string) {
return this.tenants.get(id);
}
@Post()
create(@Body() body: CreateTenantDto) {
return this.tenants.create(body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service';
@Module({
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}
+34
View File
@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateTenantDto {
name: string;
code: string;
status?: string;
}
@Injectable()
export class TenantsService {
constructor(private readonly prisma: PrismaService) {}
list() {
return this.prisma.tenant.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
});
}
get(id: string) {
return this.prisma.tenant.findUnique({ where: { id } });
}
create(data: CreateTenantDto) {
return this.prisma.tenant.create({
data: {
name: data.name,
code: data.code,
status: data.status ?? 'active',
},
});
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import {
AssignPermissionDto,
AssignRoleDto,
CreatePermissionDto,
CreateRoleDto,
CreateUserDto,
UsersService,
} from './users.service';
@ApiTags('users')
@Controller('admin/users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
list(@TenantId() tenantId?: string) {
return this.users.list(tenantId);
}
@Post()
create(@Body() body: CreateUserDto) {
return this.users.create(body);
}
@Get('roles')
listRoles() {
return this.users.listRoles();
}
@Post('roles')
createRole(@Body() body: CreateRoleDto) {
return this.users.createRole(body);
}
@Get('permissions')
listPermissions() {
return this.users.listPermissions();
}
@Post('permissions')
createPermission(@Body() body: CreatePermissionDto) {
return this.users.createPermission(body);
}
@Post('roles/assign')
assignRole(@Body() body: AssignRoleDto) {
return this.users.assignRole(body);
}
@Post('permissions/assign')
assignPermission(@Body() body: AssignPermissionDto) {
return this.users.assignPermission(body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+111
View File
@@ -0,0 +1,111 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateUserDto {
tenantId?: string;
username: string;
displayName: string;
password: string;
status?: string;
}
export interface CreateRoleDto {
code: string;
name: string;
scope?: string;
description?: string;
}
export interface CreatePermissionDto {
code: string;
name: string;
description?: string;
}
export interface AssignRoleDto {
userId: string;
roleId: string;
}
export interface AssignPermissionDto {
roleId: string;
permissionId: string;
}
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
list(tenantId?: string) {
return this.prisma.user.findMany({
where: tenantId ? { tenantId } : undefined,
include: { roles: { include: { role: true } } },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
findByUsername(username: string) {
return this.prisma.user.findUnique({ where: { username } });
}
create(data: CreateUserDto) {
return this.prisma.user.create({
data: {
tenantId: data.tenantId,
username: data.username,
displayName: data.displayName,
passwordHash: hashPassword(data.password),
status: data.status ?? 'active',
},
});
}
listRoles() {
return this.prisma.role.findMany({
include: { permissions: { include: { permission: true } } },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createRole(data: CreateRoleDto) {
return this.prisma.role.create({
data: {
code: data.code,
name: data.name,
scope: data.scope ?? 'platform',
description: data.description,
},
});
}
listPermissions() {
return this.prisma.permission.findMany({ orderBy: { createdAt: 'desc' }, take: 200 });
}
createPermission(data: CreatePermissionDto) {
return this.prisma.permission.create({ data });
}
assignRole(data: AssignRoleDto) {
return this.prisma.userRole.upsert({
where: { userId_roleId: { userId: data.userId, roleId: data.roleId } },
update: {},
create: data,
});
}
assignPermission(data: AssignPermissionDto) {
return this.prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: data.roleId, permissionId: data.permissionId } },
update: {},
create: data,
});
}
}
export function hashPassword(password: string) {
return createHash('sha256').update(password).digest('hex');
}