feat: complete cmpp platform phases 0-5
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# CMPP Platform API Spike
|
||||
|
||||
阶段 0 的 `api/` 目录用于 NestJS + BullMQ 通信 Spike。当前只定义边界,不展开业务模块。
|
||||
|
||||
## 阶段 0 职责
|
||||
|
||||
- 生产 `cmpp.submit.commands` 队列消息。
|
||||
- 消费 `cmpp.submit.results`、`cmpp.receipt.events`、`cmpp.uplink.events`。
|
||||
- 使用 `traceId`、`messageId`、`channelId` 记录最小链路状态。
|
||||
- 为 500 条/秒 Spike 提供批量入队脚本或测试入口。
|
||||
|
||||
## 不在阶段 0 实现
|
||||
|
||||
- 认证、权限、租户完整模型。
|
||||
- 企业、应用、签名、模板 CRUD。
|
||||
- 风控、审核、报备、计费真实业务规则。
|
||||
- PostgreSQL/Prisma 正式 schema。
|
||||
|
||||
## 队列契约
|
||||
|
||||
以 `docs/contracts/gateway-queue-messages.schema.json` 和 `docs/contracts/examples/` 为准。
|
||||
Generated
+3714
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "cmpp-platform-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "ts-node src/main.ts",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/platform-express": "^11.1.9",
|
||||
"@nestjs/swagger": "^11.2.3",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.0.1",
|
||||
"bullmq": "^5.79.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"ioredis": "^5.11.1",
|
||||
"minio": "^8.0.7",
|
||||
"pg": "^8.22.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"prisma": "^7.0.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url:
|
||||
process.env.DATABASE_URL ??
|
||||
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,601 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String @unique
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
operationLogs OperationLog[]
|
||||
fileObjects FileObject[]
|
||||
enterpriseBlacklists EnterpriseBlacklist[]
|
||||
accounts TenantAccount[]
|
||||
accountTransactions AccountTransaction[]
|
||||
rechargeOrders RechargeOrder[]
|
||||
smsBillingRecords SmsBillingRecord[]
|
||||
smsApplications SmsApplication[]
|
||||
smsSignatures SmsSignature[]
|
||||
smsTemplates SmsTemplate[]
|
||||
auditRecords AuditRecord[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
username String @unique
|
||||
displayName String
|
||||
passwordHash String
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
roles UserRole[]
|
||||
operationLogs OperationLog[]
|
||||
auditRecords AuditRecord[]
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
scope String @default("platform")
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users UserRole[]
|
||||
permissions RolePermission[]
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
roles RolePermission[]
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
userId String
|
||||
roleId String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, roleId])
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId String
|
||||
permissionId String
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
}
|
||||
|
||||
model OperationLog {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
userId String?
|
||||
action String
|
||||
resource String
|
||||
resourceId String?
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
detail Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model FileObject {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
bucket String
|
||||
objectKey String @unique
|
||||
fileName String
|
||||
contentType String
|
||||
sizeBytes BigInt
|
||||
checksum String?
|
||||
purpose String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
}
|
||||
|
||||
model PhoneSegment {
|
||||
id String @id @default(cuid())
|
||||
prefix String @unique
|
||||
carrier String
|
||||
province String?
|
||||
city String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model SensitiveWord {
|
||||
id String @id @default(cuid())
|
||||
word String @unique
|
||||
level String @default("block")
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model EnterpriseBlacklist {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
phoneNumber String
|
||||
reason String?
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@unique([tenantId, phoneNumber])
|
||||
}
|
||||
|
||||
model GlobalBlacklist {
|
||||
id String @id @default(cuid())
|
||||
phoneNumber String @unique
|
||||
reason String?
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model DrainageField {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
fieldType String
|
||||
required Boolean @default(false)
|
||||
status String @default("active")
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model BillingPlan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
priceCents Int
|
||||
smsUnits Int
|
||||
validDays Int
|
||||
status String @default("active")
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
rechargeOrders RechargeOrder[]
|
||||
}
|
||||
|
||||
model TenantAccount {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
balanceCents Int @default(0)
|
||||
smsUnits Int @default(0)
|
||||
creditCents Int @default(0)
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@unique([tenantId])
|
||||
}
|
||||
|
||||
model AccountTransaction {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
transactionType String
|
||||
amountCents Int @default(0)
|
||||
smsUnits Int @default(0)
|
||||
balanceAfter Int @default(0)
|
||||
relatedType String?
|
||||
relatedId String?
|
||||
remark String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([relatedType, relatedId])
|
||||
}
|
||||
|
||||
model BillingRule {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
chargeBasis String @default("submit_success")
|
||||
unitPrice Int
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model RechargeOrder {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
planId String?
|
||||
orderNo String @unique
|
||||
amountCents Int
|
||||
smsUnits Int @default(0)
|
||||
status String @default("created")
|
||||
payMethod String?
|
||||
paidAt DateTime?
|
||||
operatorId String?
|
||||
remark String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
plan BillingPlan? @relation(fields: [planId], references: [id])
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
}
|
||||
|
||||
model SmsBillingRecord {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String?
|
||||
taskId String?
|
||||
messageId String?
|
||||
phoneNumber String?
|
||||
contentLength Int
|
||||
billingUnits Int
|
||||
unitPrice Int
|
||||
amountCents Int
|
||||
billingStatus String @default("estimated")
|
||||
transactionId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([taskId])
|
||||
@@index([messageId])
|
||||
}
|
||||
|
||||
model SmsApplication {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
name String
|
||||
scene String?
|
||||
callbackUrl String?
|
||||
secretHash String
|
||||
dailyLimit Int?
|
||||
maxPhonesPerTask Int @default(1000000)
|
||||
templateMismatchMode String @default("reject")
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
ipAllowlist SmsApplicationIpAllowlist[]
|
||||
signatures SmsSignature[]
|
||||
templates SmsTemplate[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
}
|
||||
|
||||
model SmsApplicationIpAllowlist {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
ipCidr String
|
||||
remark String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([applicationId, ipCidr])
|
||||
}
|
||||
|
||||
model SmsSignature {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String?
|
||||
name String
|
||||
purpose String?
|
||||
drainageInfo Json?
|
||||
auditStatus String @default("draft")
|
||||
reportStatus String @default("waiting_material")
|
||||
rejectReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||
materials SignatureMaterial[]
|
||||
templates SmsTemplate[]
|
||||
reportMaterials SignatureReportMaterial[]
|
||||
reportTasks ChannelSignatureReportTask[]
|
||||
|
||||
@@index([tenantId, auditStatus])
|
||||
@@index([tenantId, reportStatus])
|
||||
}
|
||||
|
||||
model SignatureMaterial {
|
||||
id String @id @default(cuid())
|
||||
signatureId String
|
||||
fileObjectId String?
|
||||
materialType String
|
||||
title String
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model SmsTemplate {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String
|
||||
signatureId String?
|
||||
name String
|
||||
content String
|
||||
category String?
|
||||
auditStatus String @default("draft")
|
||||
rejectReason String?
|
||||
billingUnits Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||
signature SmsSignature? @relation(fields: [signatureId], references: [id])
|
||||
variables TemplateVariable[]
|
||||
|
||||
@@index([tenantId, auditStatus])
|
||||
@@index([applicationId])
|
||||
}
|
||||
|
||||
model TemplateVariable {
|
||||
id String @id @default(cuid())
|
||||
templateId String
|
||||
name String
|
||||
example String?
|
||||
required Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
template SmsTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([templateId, name])
|
||||
}
|
||||
|
||||
model AuditRecord {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
targetType String
|
||||
targetId String
|
||||
action String
|
||||
statusBefore String?
|
||||
statusAfter String
|
||||
reason String?
|
||||
reviewerId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
reviewer User? @relation(fields: [reviewerId], references: [id])
|
||||
|
||||
@@index([targetType, targetId])
|
||||
@@index([tenantId, createdAt])
|
||||
}
|
||||
|
||||
model SmsChannel {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
carrier String?
|
||||
protocol String @default("CMPP")
|
||||
gatewayHost String
|
||||
gatewayPort Int
|
||||
enterpriseCode String?
|
||||
account String
|
||||
passwordCipher String
|
||||
srcId String
|
||||
cmppVersion String @default("3.0")
|
||||
rateLimitPerSecond Int @default(100)
|
||||
unitPrice Int @default(0)
|
||||
status String @default("active")
|
||||
config Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
groupItems SmsChannelGroupItem[]
|
||||
routeRules ChannelRouteRule[]
|
||||
healthMetrics ChannelHealthMetric[]
|
||||
reportFields ChannelReportField[]
|
||||
reportTasks ChannelSignatureReportTask[]
|
||||
reportRecords ChannelSignatureReportRecord[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model SmsChannelGroup {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items SmsChannelGroupItem[]
|
||||
routeRules ChannelRouteRule[]
|
||||
}
|
||||
|
||||
model SmsChannelGroupItem {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
channelId String
|
||||
carrier String?
|
||||
province String?
|
||||
priority Int @default(100)
|
||||
weight Int @default(1)
|
||||
isBackup Boolean @default(false)
|
||||
rateLimitPerSecond Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
group SmsChannelGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
|
||||
@@unique([groupId, channelId, carrier, province])
|
||||
@@index([channelId])
|
||||
}
|
||||
|
||||
model ChannelRouteRule {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
applicationId String?
|
||||
groupId String
|
||||
channelId String?
|
||||
carrier String?
|
||||
province String?
|
||||
priority Int @default(100)
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group SmsChannelGroup @relation(fields: [groupId], references: [id])
|
||||
channel SmsChannel? @relation(fields: [channelId], references: [id])
|
||||
|
||||
@@index([tenantId, applicationId, status])
|
||||
@@index([groupId, priority])
|
||||
}
|
||||
|
||||
model ChannelHealthMetric {
|
||||
id String @id @default(cuid())
|
||||
channelId String
|
||||
submitTotal Int @default(0)
|
||||
submitSuccess Int @default(0)
|
||||
submitFailed Int @default(0)
|
||||
receiptSuccess Int @default(0)
|
||||
receiptFailed Int @default(0)
|
||||
unknownTotal Int @default(0)
|
||||
windowStart DateTime
|
||||
windowEnd DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
|
||||
@@index([channelId, windowStart])
|
||||
}
|
||||
|
||||
model ChannelReportField {
|
||||
id String @id @default(cuid())
|
||||
channelId String
|
||||
code String
|
||||
name String
|
||||
fieldType String
|
||||
required Boolean @default(false)
|
||||
description String?
|
||||
sortOrder Int @default(100)
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([channelId, code])
|
||||
}
|
||||
|
||||
model SignatureReportMaterial {
|
||||
id String @id @default(cuid())
|
||||
signatureId String
|
||||
channelId String
|
||||
fieldCode String
|
||||
fieldValue String?
|
||||
fileObjectId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([signatureId, channelId, fieldCode])
|
||||
@@index([channelId])
|
||||
}
|
||||
|
||||
model ChannelSignatureReportTask {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
signatureId String
|
||||
channelId String
|
||||
status String @default("pending")
|
||||
reason String?
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
signature SmsSignature @relation(fields: [signatureId], references: [id])
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
records ChannelSignatureReportRecord[]
|
||||
exportFiles ReportExportFile[]
|
||||
receiptImports ReportReceiptImport[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([signatureId, channelId])
|
||||
}
|
||||
|
||||
model ChannelSignatureReportRecord {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
channelId String
|
||||
action String
|
||||
statusBefore String?
|
||||
statusAfter String
|
||||
reason String?
|
||||
operatorId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
|
||||
@@index([taskId, createdAt])
|
||||
@@index([channelId, createdAt])
|
||||
}
|
||||
|
||||
model ReportExportFile {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
fileObjectId String?
|
||||
fileName String
|
||||
rowCount Int @default(0)
|
||||
status String @default("generated")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ReportReceiptImport {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
fileObjectId String?
|
||||
fileName String
|
||||
rowCount Int @default(0)
|
||||
successCount Int @default(0)
|
||||
failedCount Int @default(0)
|
||||
status String @default("imported")
|
||||
result Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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: '您的验证码为 123456,5 分钟内有效。',
|
||||
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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user