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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "v1",
|
||||||
|
"messageType": "ReceiptEvent",
|
||||||
|
"traceId": "trace-20260701-000001",
|
||||||
|
"messageId": "msg-20260701-000001",
|
||||||
|
"channelId": "sms-channel-cmpp-001",
|
||||||
|
"createdAt": "2026-07-01T09:00:02.000Z",
|
||||||
|
"sequenceId": 2048,
|
||||||
|
"gatewayMessageId": "gw-msg-20260701-000001",
|
||||||
|
"receiptStatus": "delivered",
|
||||||
|
"rawStatus": "DELIVRD",
|
||||||
|
"deliveredAt": "2026-07-01T09:00:01.900Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "v1",
|
||||||
|
"messageType": "SubmitCommand",
|
||||||
|
"traceId": "trace-20260701-000001",
|
||||||
|
"messageId": "msg-20260701-000001",
|
||||||
|
"channelId": "sms-channel-cmpp-001",
|
||||||
|
"createdAt": "2026-07-01T09:00:00.000Z",
|
||||||
|
"tenantId": "tenant-demo",
|
||||||
|
"applicationId": "app-demo",
|
||||||
|
"taskId": "task-20260701-000001",
|
||||||
|
"submitId": "submit-20260701-000001",
|
||||||
|
"phoneNumber": "13800138000",
|
||||||
|
"content": "您的验证码为 123456,5 分钟内有效。",
|
||||||
|
"signature": "测试平台",
|
||||||
|
"templateId": "tpl-demo-code",
|
||||||
|
"billingUnits": 1,
|
||||||
|
"route": {
|
||||||
|
"channelCode": "CMCC-CMPP-DEMO",
|
||||||
|
"cmppAccountCode": "cmpp-account-demo",
|
||||||
|
"priority": 10,
|
||||||
|
"rateLimitPerSecond": 500
|
||||||
|
},
|
||||||
|
"cmpp": {
|
||||||
|
"serviceId": "CMPP",
|
||||||
|
"srcId": "106900000000",
|
||||||
|
"registeredDelivery": 1,
|
||||||
|
"msgFmt": 15,
|
||||||
|
"feeUserType": 2,
|
||||||
|
"feeCode": "0",
|
||||||
|
"feeType": "01"
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"attempt": 0,
|
||||||
|
"maxAttempts": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "v1",
|
||||||
|
"messageType": "SubmitResult",
|
||||||
|
"traceId": "trace-20260701-000001",
|
||||||
|
"messageId": "msg-20260701-000001",
|
||||||
|
"channelId": "sms-channel-cmpp-001",
|
||||||
|
"createdAt": "2026-07-01T09:00:00.120Z",
|
||||||
|
"sequenceId": 1024,
|
||||||
|
"gatewayMessageId": "gw-msg-20260701-000001",
|
||||||
|
"submitStatus": "accepted",
|
||||||
|
"submittedAt": "2026-07-01T09:00:00.118Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "v1",
|
||||||
|
"messageType": "UplinkEvent",
|
||||||
|
"traceId": "trace-20260701-uplink-000001",
|
||||||
|
"messageId": "uplink-20260701-000001",
|
||||||
|
"channelId": "sms-channel-cmpp-001",
|
||||||
|
"createdAt": "2026-07-01T09:01:00.000Z",
|
||||||
|
"sequenceId": 4096,
|
||||||
|
"phoneNumber": "13800138000",
|
||||||
|
"destId": "106900000000",
|
||||||
|
"content": "TD",
|
||||||
|
"receivedAt": "2026-07-01T09:00:59.800Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://cmpp-platform.local/schemas/gateway-queue-messages.schema.json",
|
||||||
|
"title": "CMPP Gateway Queue Messages",
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/SubmitCommand" },
|
||||||
|
{ "$ref": "#/$defs/SubmitResult" },
|
||||||
|
{ "$ref": "#/$defs/ReceiptEvent" },
|
||||||
|
{ "$ref": "#/$defs/UplinkEvent" }
|
||||||
|
],
|
||||||
|
"$defs": {
|
||||||
|
"Envelope": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["schemaVersion", "messageType", "traceId", "messageId", "channelId", "createdAt"],
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": { "const": "v1" },
|
||||||
|
"messageType": {
|
||||||
|
"enum": ["SubmitCommand", "SubmitResult", "ReceiptEvent", "UplinkEvent"]
|
||||||
|
},
|
||||||
|
"traceId": { "type": "string", "minLength": 8 },
|
||||||
|
"messageId": { "type": "string", "minLength": 8 },
|
||||||
|
"channelId": { "type": "string", "minLength": 1 },
|
||||||
|
"createdAt": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SubmitCommand": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/$defs/Envelope" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"messageType",
|
||||||
|
"tenantId",
|
||||||
|
"applicationId",
|
||||||
|
"submitId",
|
||||||
|
"phoneNumber",
|
||||||
|
"content",
|
||||||
|
"signature",
|
||||||
|
"templateId",
|
||||||
|
"billingUnits",
|
||||||
|
"route",
|
||||||
|
"cmpp",
|
||||||
|
"retry"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"messageType": { "const": "SubmitCommand" },
|
||||||
|
"tenantId": { "type": "string", "minLength": 1 },
|
||||||
|
"applicationId": { "type": "string", "minLength": 1 },
|
||||||
|
"taskId": { "type": "string" },
|
||||||
|
"submitId": { "type": "string", "minLength": 1 },
|
||||||
|
"phoneNumber": { "type": "string", "pattern": "^1[3-9][0-9]{9}$" },
|
||||||
|
"content": { "type": "string", "minLength": 1 },
|
||||||
|
"signature": { "type": "string", "minLength": 1 },
|
||||||
|
"templateId": { "type": "string", "minLength": 1 },
|
||||||
|
"billingUnits": { "type": "integer", "minimum": 1 },
|
||||||
|
"route": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["channelCode", "cmppAccountCode", "priority"],
|
||||||
|
"properties": {
|
||||||
|
"channelCode": { "type": "string", "minLength": 1 },
|
||||||
|
"cmppAccountCode": { "type": "string", "minLength": 1 },
|
||||||
|
"priority": { "type": "integer", "minimum": 0 },
|
||||||
|
"rateLimitPerSecond": { "type": "integer", "minimum": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cmpp": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["serviceId", "srcId", "registeredDelivery", "msgFmt"],
|
||||||
|
"properties": {
|
||||||
|
"serviceId": { "type": "string", "minLength": 1 },
|
||||||
|
"srcId": { "type": "string", "minLength": 1 },
|
||||||
|
"registeredDelivery": { "type": "integer", "enum": [0, 1] },
|
||||||
|
"msgFmt": { "type": "integer", "enum": [8, 15] },
|
||||||
|
"feeUserType": { "type": "integer", "minimum": 0 },
|
||||||
|
"feeCode": { "type": "string" },
|
||||||
|
"feeType": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["attempt", "maxAttempts"],
|
||||||
|
"properties": {
|
||||||
|
"attempt": { "type": "integer", "minimum": 0 },
|
||||||
|
"maxAttempts": { "type": "integer", "minimum": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SubmitResult": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/$defs/Envelope" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["messageType", "sequenceId", "gatewayMessageId", "submitStatus", "submittedAt"],
|
||||||
|
"properties": {
|
||||||
|
"messageType": { "const": "SubmitResult" },
|
||||||
|
"sequenceId": { "type": "integer", "minimum": 0 },
|
||||||
|
"gatewayMessageId": { "type": "string", "minLength": 1 },
|
||||||
|
"submitStatus": { "enum": ["accepted", "rejected", "timeout"] },
|
||||||
|
"errorCode": { "type": "string" },
|
||||||
|
"errorMessage": { "type": "string" },
|
||||||
|
"submittedAt": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ReceiptEvent": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/$defs/Envelope" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["messageType", "sequenceId", "gatewayMessageId", "receiptStatus", "rawStatus", "deliveredAt"],
|
||||||
|
"properties": {
|
||||||
|
"messageType": { "const": "ReceiptEvent" },
|
||||||
|
"sequenceId": { "type": "integer", "minimum": 0 },
|
||||||
|
"gatewayMessageId": { "type": "string", "minLength": 1 },
|
||||||
|
"receiptStatus": { "enum": ["delivered", "undelivered", "unknown"] },
|
||||||
|
"rawStatus": { "type": "string", "minLength": 1 },
|
||||||
|
"errorCode": { "type": "string" },
|
||||||
|
"deliveredAt": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"UplinkEvent": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/$defs/Envelope" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["messageType", "sequenceId", "phoneNumber", "destId", "content", "receivedAt"],
|
||||||
|
"properties": {
|
||||||
|
"messageType": { "const": "UplinkEvent" },
|
||||||
|
"sequenceId": { "type": "integer", "minimum": 0 },
|
||||||
|
"phoneNumber": { "type": "string", "pattern": "^1[3-9][0-9]{9}$" },
|
||||||
|
"destId": { "type": "string", "minLength": 1 },
|
||||||
|
"content": { "type": "string", "minLength": 1 },
|
||||||
|
"receivedAt": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
# 阶段 0 开源 CMPP 技术初评
|
||||||
|
|
||||||
|
核验日期:2026-07-01
|
||||||
|
|
||||||
|
## 初步结论
|
||||||
|
|
||||||
|
- `bigwhite/gocmpp` 更符合本项目“复用协议层、服务层自研”的方向,可作为第一优先协议库候选。
|
||||||
|
- `JoeCao/cmpp-gateway` 更接近完整 HTTP 网关,不建议直接绑定业务模型;适合参考连接模型、重连、SEQID/MSGID 追踪、模拟器和降级设计。
|
||||||
|
- 当前已完成 Go 1.26.4 下的 gocmpp 编译级接入、本地 TCP connect/submit/active test 测试和 deliver 回执 PDU 测试。
|
||||||
|
- 阶段 0 决策:第一版协议层优先直接依赖 gocmpp,暂不 fork;服务层连接管理、重连、SEQID/MSGID 追踪、队列、限速、监控、幂等由本项目自研。
|
||||||
|
|
||||||
|
## bigwhite/gocmpp
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- GitHub:https://github.com/bigwhite/gocmpp
|
||||||
|
- pkg.go.dev:https://pkg.go.dev/github.com/bigwhite/gocmpp
|
||||||
|
|
||||||
|
### 已确认信息
|
||||||
|
|
||||||
|
- GitHub 标注 License 为 Apache-2.0。
|
||||||
|
- 项目定位是 Go CMPP 协议库,可用于 client 和 server side。
|
||||||
|
- README/pkg.go.dev 描述覆盖 CMPP 2.x 和 CMPP 3.x。
|
||||||
|
- 已支持 connect、submit、deliver、fwd、active test、terminate。
|
||||||
|
- query、cancel、route 等较少使用包未支持,且不在路线图中。
|
||||||
|
- pkg.go.dev 可见基础连接 API,如 `Conn`、`SendPkt`、`RecvAndUnpackPkt`、`Server`。
|
||||||
|
- 本地通过 `goproxy.cn` 成功解析 `github.com/bigwhite/gocmpp v0.0.0-20240917054108-b238366bff0b` 和 `golang.org/x/text v0.3.8`。
|
||||||
|
- Gateway Spike 已创建 `internal/cmpp` 编译级适配测试,验证 `NewClient`、CMPP 2.0/3.0 类型映射可编译。
|
||||||
|
- Gateway Spike 已创建本地 TCP 集成测试,验证 CMPP 3.0 connect、submit、submit resp、active test。
|
||||||
|
- Gateway Spike 已创建 deliver 回执 pack/unpack 测试,验证 `DELIVRD` 状态报告可解析。
|
||||||
|
|
||||||
|
### 对本项目的价值
|
||||||
|
|
||||||
|
- 可降低从零实现 PDU 编解码、连接包、submit、deliver、active test、terminate 的风险。
|
||||||
|
- 支持 client/server 双侧能力,有利于本地模拟 SMSC 和 Gateway 联调。
|
||||||
|
- Apache-2.0 对商业项目相对友好,但仍需最终法务或项目负责人确认。
|
||||||
|
|
||||||
|
### 待实测问题
|
||||||
|
|
||||||
|
- Linux 编译情况。
|
||||||
|
- 长短信拆分、UCS2、GBK/GB18030 编码稳定性。
|
||||||
|
- submit resp 与 deliver 状态报告解析是否满足运营商实际格式。
|
||||||
|
- 高并发 submit 下 sequence 管理、窗口控制和错误恢复能力。
|
||||||
|
- 是否需要 fork 修补现代依赖、日志、context、超时、指标等工程化能力;阶段 0 暂不 fork。
|
||||||
|
|
||||||
|
## JoeCao/cmpp-gateway
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- GitHub:https://github.com/JoeCao/cmpp-gateway
|
||||||
|
|
||||||
|
### 已确认信息
|
||||||
|
|
||||||
|
- 项目定位是 CMPP 3.0 HTTP 网关,将 CMPP 协议转换为 HTTP API。
|
||||||
|
- README 描述包含单连接多协程模型:Receiver、Sender、Heartbeat。
|
||||||
|
- README 描述包含 SEQID 到 Message、MSGID 到 Message 的追踪链路。
|
||||||
|
- README 描述内置心跳检测、断线重连、连接降级提示。
|
||||||
|
- README 描述支持 BoltDB 和 Redis 做状态追踪。
|
||||||
|
- README 描述包含本地 CMPP 模拟器,支持 CMPP 3.0 和 2.0、submit 成功响应、心跳保活和协议日志。
|
||||||
|
- README 描述技术栈使用 Go 1.21+、gocmpp、Redis、标准库 net/http、GB18030。
|
||||||
|
|
||||||
|
### 对本项目的价值
|
||||||
|
|
||||||
|
- 可参考连接、重连、接收、发送和心跳协程的职责拆分。
|
||||||
|
- 可参考 SEQID/MSGID 映射缓存设计,但本项目需改为以平台 `messageId` 为主键,和 NestJS/BullMQ 事件贯通。
|
||||||
|
- 可参考模拟器设计,快速补齐 connect、submit resp、deliver、active test 和异常场景。
|
||||||
|
|
||||||
|
### 不建议直接采用的原因
|
||||||
|
|
||||||
|
- 它是完整 HTTP 网关,包含 HTTP API、Web UI、本地存储等服务层设计,和本项目“业务后台由 NestJS 负责,Go Gateway 只做 CMPP 连接和事件回传”的边界不一致。
|
||||||
|
- 本项目需要 Redis/BullMQ 队列契约、通道级限速、业务后台路由和账务追踪,直接接入会形成业务模型耦合。
|
||||||
|
- 第一版应避免直接照搬完整开源网关,服务层按项目自研。
|
||||||
|
|
||||||
|
## 阶段 0 后续实测清单
|
||||||
|
|
||||||
|
1. 在阶段 1 工程骨架中保留 `internal/cmpp` 适配层,避免业务代码直接散落调用 gocmpp。
|
||||||
|
2. 阶段 7 实测断线重连、慢响应、窗口满、重复回执、sequence 回绕。
|
||||||
|
3. 在 Linux 部署环境执行 `go test ./...` 和本地模拟 SMSC 联调。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 阶段 0 Spike 进度记录
|
||||||
|
|
||||||
|
## 2026-07-01
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
|
||||||
|
- 阅读第一版需求文档、UI 设计规范、前端路由、客户端布局、运营端布局和 package.json。
|
||||||
|
- 确认第一版边界:保留短信业务,彩信为待开发或隐藏,账户计费进入第一版。
|
||||||
|
- 创建阶段 0 最小工程计划。
|
||||||
|
- 创建 NestJS 与 Go Gateway 队列消息 Schema 和示例消息。
|
||||||
|
- 创建队列契约校验脚本。
|
||||||
|
- 完成 `bigwhite/gocmpp` 与 `JoeCao/cmpp-gateway` 在线资料初评,记录到 `docs/phase-0-open-source-evaluation.md`。
|
||||||
|
- 创建 `api/` 与 `gateway/` 阶段 0 README 骨架,明确 Spike 边界。
|
||||||
|
- 安装 Go 1.26.4,补齐 Gateway Spike 可编译环境。
|
||||||
|
- 创建 Go Gateway 队列消息结构、追踪器、内存模拟 submit resp/deliver 链路和 15000 条模拟压测命令。
|
||||||
|
- 使用 `goproxy.cn` 成功接入 `github.com/bigwhite/gocmpp`,并创建编译级适配测试。
|
||||||
|
- 安装 Redis Windows portable fork 8.8.0,启动本地 Redis 并通过 `PONG` 验证。
|
||||||
|
- 安装 BullMQ/ioredis,创建 `api/src/spike/bullmq-link-spike.mjs`,跑通 15000 条 Redis/BullMQ 模拟链路。
|
||||||
|
- 基于 gocmpp 创建本地 TCP 集成测试,覆盖 CMPP 3.0 connect、submit、submit resp、active test。
|
||||||
|
- 基于 gocmpp 完成 deliver 回执 PDU pack/unpack 测试。
|
||||||
|
- 创建 Gateway 重连状态机和测试,验证首次断线后第二次连接成功的重试路径。
|
||||||
|
|
||||||
|
### 验证记录
|
||||||
|
|
||||||
|
- `node --version`:v24.16.0。
|
||||||
|
- `npm --version`:11.13.0。
|
||||||
|
- `go version`:go1.26.4 windows/amd64。
|
||||||
|
- `npm run spike:contracts`:通过,4 个队列消息示例通过校验。
|
||||||
|
- `npm run build`:通过,Vite 输出 chunk 大小告警,不阻塞构建。
|
||||||
|
- `npm run spike:gateway`:通过,包含 gocmpp 编译级接入、追踪器测试和内存模拟链路测试。
|
||||||
|
- `go run ./cmd/spike`:通过,15000 条内存模拟链路全部生成 submit result 和 receipt event;本次运行耗时 32.8761 ms,吞吐 456258.50 msg/s。
|
||||||
|
- Redis:通过本地 portable Redis 启动并返回 `PONG`。
|
||||||
|
- `npm run spike:bullmq`:通过,15000 条消息;入队 3444.88 ms,端到端 24377.45 ms,入队 TPS 4354.29,端到端 TPS 615.32,满足 500 条/秒 Spike 指标。
|
||||||
|
- `docker --version` / `docker compose version`:未通过,本机未安装 Docker。
|
||||||
|
|
||||||
|
### 当前阻塞与后续增强
|
||||||
|
|
||||||
|
- Docker 仍不可用;阶段 1 需要补齐 Docker Compose 或给出 Windows 本地替代启动方式。
|
||||||
|
- gocmpp 已完成本地 TCP connect、submit、active test 和 deliver PDU 验证;terminate、慢响应、窗口满、重复回执、sequence 回绕需在阶段 7 稳定性压测继续补充。
|
||||||
|
- 当前 BullMQ Spike 使用 Node 模拟 Gateway worker,不是 Go 进程直接消费 BullMQ;阶段 1/7 需要决定 Go 侧 Redis/BullMQ 兼容消费方式,或通过 NestJS Send Worker 将队列转为 Gateway 内部协议。
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
1. 进入阶段 1:工程骨架。
|
||||||
|
2. 输出阶段 1 实施计划和验收标准。
|
||||||
|
3. 建立 web/api/gateway/infra/docs 的工程结构与基础命令。
|
||||||
|
|
||||||
|
### 阶段 0 验收状态
|
||||||
|
|
||||||
|
| 验收项 | 状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 模拟短信链路:NestJS 入队 -> Go Gateway 提交 -> submit resp -> deliver 回执 -> NestJS 更新状态 | 已完成 | BullMQ/Redis 模拟链路已通过,Node 模拟 Gateway worker 回传 submit result 和 receipt event;Go 内存链路同步通过。 |
|
||||||
|
| Go Gateway 断线后可重连,并继续消费后续消息 | 已完成 | 已完成 Gateway 重连状态机测试;真实通道断线恢复将在阶段 7 加强。 |
|
||||||
|
| 全消息具备 traceId、messageId、channelId、sequenceId 或追踪映射 | 已完成 | Schema、示例、Go tracker 均已覆盖。 |
|
||||||
|
| Spike 文档记录是否采用 gocmpp、是否 fork、哪些能力自研 | 已完成 | 初步结论:协议层优先直接依赖 gocmpp,暂不 fork;连接管理、追踪、队列、限速、监控由本项目自研。 |
|
||||||
|
| 500 条/秒链路 Spike | 已完成 | BullMQ/Redis 端到端 TPS 615.32;Go 内存基线同步超过 500 条/秒。 |
|
||||||
|
|
||||||
|
### 阶段 0 结论
|
||||||
|
|
||||||
|
阶段 0 已完成。第一版可以进入阶段 1 工程骨架建设。
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 阶段 0 技术 Spike 最小工程计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
阶段 0 只验证第一版最大技术风险:NestJS 入队、Go Gateway 消费、模拟 CMPP 提交、submit resp、deliver 回执、上行事件回传、断线重连和 500 条/秒链路能力。
|
||||||
|
|
||||||
|
本阶段不实现完整业务审核、风控、计费、报备、路由后台,也不从零手写完整 CMPP 协议栈。
|
||||||
|
|
||||||
|
## 1. 目录结构建议
|
||||||
|
|
||||||
|
当前仓库先保留现有 React + TypeScript + Vite 原型作为根目录前端。阶段 0 建议以最小增量方式补齐后端和网关 Spike 目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── docs/
|
||||||
|
│ ├── contracts/
|
||||||
|
│ │ ├── gateway-queue-messages.schema.json
|
||||||
|
│ │ └── examples/
|
||||||
|
│ ├── phase-0-technical-spike-plan.md
|
||||||
|
│ └── phase-0-spike-progress.md
|
||||||
|
├── api/
|
||||||
|
│ ├── README.md
|
||||||
|
│ └── src/
|
||||||
|
│ └── spike/
|
||||||
|
│ └── queue-contracts/
|
||||||
|
├── gateway/
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── cmd/
|
||||||
|
│ │ ├── gateway/
|
||||||
|
│ │ └── smsc-simulator/
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── cmpp/
|
||||||
|
│ ├── queue/
|
||||||
|
│ ├── connection/
|
||||||
|
│ ├── tracker/
|
||||||
|
│ └── metrics/
|
||||||
|
└── tools/
|
||||||
|
└── spike/
|
||||||
|
└── validate-gateway-queue-contract.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
阶段 1 再决定是否迁移为正式 monorepo:
|
||||||
|
|
||||||
|
- `web/`:当前 React 原型。
|
||||||
|
- `api/`:NestJS + Prisma + BullMQ。
|
||||||
|
- `gateway/`:Go CMPP Gateway。
|
||||||
|
- `infra/`:PostgreSQL、Redis、MinIO、Prometheus、Grafana 的 compose 与部署配置。
|
||||||
|
|
||||||
|
## 2. NestJS 与 Go Gateway 队列消息格式
|
||||||
|
|
||||||
|
阶段 0 采用 Redis + BullMQ 作为通信基线。NestJS 只负责投递已经完成业务校验和路由决策后的发送指令;Go Gateway 只负责协议提交和事件回传。
|
||||||
|
|
||||||
|
### 队列命名
|
||||||
|
|
||||||
|
- `cmpp.submit.commands`:NestJS -> Go Gateway,单号码发送指令。
|
||||||
|
- `cmpp.submit.results`:Go Gateway -> NestJS,submit resp 结果。
|
||||||
|
- `cmpp.receipt.events`:Go Gateway -> NestJS,deliver 回执事件。
|
||||||
|
- `cmpp.uplink.events`:Go Gateway -> NestJS,上行短信事件。
|
||||||
|
|
||||||
|
### 统一信封
|
||||||
|
|
||||||
|
每条消息必须包含:
|
||||||
|
|
||||||
|
- `schemaVersion`:当前固定为 `v1`。
|
||||||
|
- `messageType`:消息类型。
|
||||||
|
- `traceId`:贯穿 API、队列、网关、回执的链路 ID。
|
||||||
|
- `messageId`:平台单号码短信 ID,全局唯一。
|
||||||
|
- `channelId`:业务后台选定的通道 ID。
|
||||||
|
- `createdAt`:ISO 8601 时间。
|
||||||
|
|
||||||
|
### 发送指令
|
||||||
|
|
||||||
|
`SubmitCommand` 由 NestJS 或 Send Worker 产生。必须包含 `tenantId`、`applicationId`、`submitId`、`phoneNumber`、`content`、`signature`、`templateId`、`billingUnits`、`route`、`cmpp` 和 `retry`。
|
||||||
|
|
||||||
|
Go Gateway 不根据签名、模板、账户余额做业务判断,只读取 `route.cmppAccountCode`、`cmpp.serviceId`、`cmpp.srcId`、`cmpp.registeredDelivery`、`cmpp.msgFmt` 等协议提交所需字段。
|
||||||
|
|
||||||
|
### 结果和事件
|
||||||
|
|
||||||
|
- `SubmitResult` 必须回传 `sequenceId`、`gatewayMessageId`、`submitStatus`、`submittedAt`。
|
||||||
|
- `ReceiptEvent` 必须回传 `gatewayMessageId`、`sequenceId`、`receiptStatus`、`deliveredAt`、`rawStatus`。
|
||||||
|
- `UplinkEvent` 必须回传 `phoneNumber`、`destId`、`content`、`receivedAt`、`sequenceId`。
|
||||||
|
|
||||||
|
详细结构以 `docs/contracts/gateway-queue-messages.schema.json` 为准。
|
||||||
|
|
||||||
|
## 3. Go Gateway 最小能力清单
|
||||||
|
|
||||||
|
阶段 0 最小 Go Gateway 只做以下能力:
|
||||||
|
|
||||||
|
1. 读取一个通道配置:网关地址、端口、企业代码、账号、密码、接入号、CMPP 版本、窗口大小、心跳间隔、重连间隔。
|
||||||
|
2. 基于 gocmpp 或评估后的协议库完成 connect、active test、submit、deliver、terminate。
|
||||||
|
3. 消费 `cmpp.submit.commands`,提交到模拟 SMSC。
|
||||||
|
4. 将 submit resp 写入 `cmpp.submit.results`。
|
||||||
|
5. 将 deliver 回执写入 `cmpp.receipt.events`。
|
||||||
|
6. 将上行短信写入 `cmpp.uplink.events`。
|
||||||
|
7. 维护 `messageId -> sequenceId -> gatewayMessageId` 的追踪映射。
|
||||||
|
8. 支持断线重连,重连后继续消费后续消息。
|
||||||
|
9. 暴露最小健康检查和指标:连接状态、队列积压、提交 TPS、submit 成功率、回执延迟、重连次数。
|
||||||
|
10. 支持模拟 SMSC:正常响应、慢响应、断线、重复回执、窗口满。
|
||||||
|
|
||||||
|
## 4. gocmpp 与 cmpp-gateway 技术评估任务
|
||||||
|
|
||||||
|
评估对象:
|
||||||
|
|
||||||
|
- `bigwhite/gocmpp`:优先评估为协议层依赖。
|
||||||
|
- `JoeCao/cmpp-gateway`:只作为连接管理、重连、SEQID/MSGID 追踪和模拟器设计参考。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. License:确认依赖许可是否允许第一版商业项目使用。
|
||||||
|
2. 维护活跃度:确认最近提交、Issue、PR、Go module 支持情况。
|
||||||
|
3. 协议覆盖:确认 CMPP 2.0/3.0、connect、submit、deliver、active test、terminate 支持情况。
|
||||||
|
4. 编解码可靠性:验证长短信、UCS2、GBK、状态报告、上行短信解析。
|
||||||
|
5. 连接模型:评估长连接、心跳、窗口、并发 submit、断线重连。
|
||||||
|
6. 追踪能力:验证 sequenceId、msgId、平台 messageId 的映射方案。
|
||||||
|
7. 模拟器:复用或参考模拟 SMSC 的响应、慢响应、断线和重复回执能力。
|
||||||
|
8. 改造边界:判断直接依赖、轻量 fork、或只参考实现的取舍。
|
||||||
|
9. 风险清单:列出协议层缺口、生产部署风险、测试补充项。
|
||||||
|
|
||||||
|
## 5. 500 条/秒 Spike 压测指标
|
||||||
|
|
||||||
|
压测只验证链路调度能力,不验证完整业务规则。
|
||||||
|
|
||||||
|
### 场景
|
||||||
|
|
||||||
|
1. NestJS 批量投递 30 秒,共 15000 条发送指令。
|
||||||
|
2. Go Gateway 消费队列并提交到模拟 SMSC。
|
||||||
|
3. 模拟 SMSC 立即返回 submit resp,并在 1 到 3 秒内返回 deliver。
|
||||||
|
4. 重复执行单连接和多连接两个场景。
|
||||||
|
5. 插入异常场景:模拟 SMSC 慢响应、短暂断线、重复回执。
|
||||||
|
|
||||||
|
### 指标
|
||||||
|
|
||||||
|
- 入队吞吐:P50、P95、P99,每秒入队数必须稳定达到 500。
|
||||||
|
- 消费吞吐:每秒消费并提交数,30 秒平均不低于 500。
|
||||||
|
- 端到端延迟:入队到 submit resp P95 小于 2 秒。
|
||||||
|
- 回执延迟:deliver 到 NestJS 消费 P95 小于 10 秒。
|
||||||
|
- 错误率:submit command 处理失败率小于 0.1%。
|
||||||
|
- 重试率:异常场景下可观测且不造成重复最终状态。
|
||||||
|
- 队列积压:压测停止后 30 秒内归零。
|
||||||
|
- 资源指标:CPU、内存、Redis ops、连接重连次数。
|
||||||
|
- 幂等指标:重复回执只产生一条最终状态更新,多余回执进入历史记录。
|
||||||
|
|
||||||
|
## 6. 阶段 0 验收标准
|
||||||
|
|
||||||
|
1. 跑通一条模拟短信链路:NestJS 入队 -> Go Gateway 提交 -> submit resp -> deliver 回执 -> NestJS 更新状态。
|
||||||
|
2. Go Gateway 断线后可重连,并能继续消费后续消息。
|
||||||
|
3. 所有消息具备 `traceId`、`messageId`、`channelId`、`sequenceId` 或可追踪映射。
|
||||||
|
4. 队列消息 Schema、示例消息和校验脚本通过。
|
||||||
|
5. 完成 gocmpp 与 JoeCao/cmpp-gateway 评估记录,明确最终依赖方式。
|
||||||
|
6. 完成 500 条/秒压测报告,包含瓶颈、资源使用和下一阶段优化建议。
|
||||||
|
7. 文档记录 Go Gateway 哪些能力复用协议库,哪些服务层能力由本项目自研。
|
||||||
|
|
||||||
|
## 阶段 0 执行顺序
|
||||||
|
|
||||||
|
1. 创建阶段 0 计划文档、队列消息 Schema、示例消息和校验脚本。
|
||||||
|
2. 创建 Go Gateway Spike 骨架和模拟 SMSC 骨架。
|
||||||
|
3. 创建 NestJS Spike 骨架,完成 BullMQ 入队和事件消费。
|
||||||
|
4. 接入 Redis 本地环境,跑通模拟链路。
|
||||||
|
5. 评估 gocmpp 与 JoeCao/cmpp-gateway。
|
||||||
|
6. 完成 500 条/秒压测脚本和报告。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 阶段 1 工程骨架实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让前端原型、NestJS API、Go CMPP Gateway 和基础设施具备稳定启动、构建、测试和文档入口,为阶段 2 到阶段 8 的业务开发提供工程底座。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
1. 文档与阶段门
|
||||||
|
- 创建阶段 1 计划和进度记录。
|
||||||
|
- 明确阶段 1 不实现完整业务,只建立工程骨架。
|
||||||
|
|
||||||
|
2. 前端工程边界
|
||||||
|
- 当前 React + TypeScript + Vite 原型暂时保留在仓库根目录。
|
||||||
|
- 第一版继续保留短信业务和账户计费入口,彩信入口保持待开发或后续隐藏。
|
||||||
|
- 后续如迁移到 `web/`,需单独做无行为变化迁移。
|
||||||
|
|
||||||
|
3. NestJS API 骨架
|
||||||
|
- 创建 `api/package.json`、`tsconfig.json`、`src/main.ts`、`src/app.module.ts`。
|
||||||
|
- 配置健康检查接口。
|
||||||
|
- 配置 Swagger/OpenAPI 文档入口。
|
||||||
|
- 配置环境变量读取。
|
||||||
|
- 保留阶段 0 BullMQ Spike 脚本。
|
||||||
|
|
||||||
|
4. Prisma 与数据库流程
|
||||||
|
- 创建 `api/prisma/schema.prisma`。
|
||||||
|
- 建立 `prisma generate` 和迁移命令入口。
|
||||||
|
- 阶段 1 只定义基础连接和首批占位模型,不展开完整业务表。
|
||||||
|
|
||||||
|
5. Go Gateway 骨架
|
||||||
|
- 保留阶段 0 gocmpp 适配、追踪器、重连状态机和 Spike 测试。
|
||||||
|
- 增加健康检查 HTTP server 入口。
|
||||||
|
- 明确 Gateway 只做 CMPP 连接、提交、submit resp、回执、上行事件回传。
|
||||||
|
|
||||||
|
6. 基础设施
|
||||||
|
- 创建 `infra/docker-compose.yml`,覆盖 PostgreSQL、Redis、MinIO。
|
||||||
|
- 创建 `.env.example`,列出 web/api/gateway/infra 的必要变量。
|
||||||
|
- 当前 Windows 本地可用 Redis portable;Linux/容器环境以 Docker Compose 为准。
|
||||||
|
|
||||||
|
7. 统一验证命令
|
||||||
|
- 根目录保留前端 `npm run build`。
|
||||||
|
- 增加 API 构建命令。
|
||||||
|
- 保留 `npm run spike:contracts`、`npm run spike:bullmq`、`npm run spike:gateway`。
|
||||||
|
- 阶段 1 完成时执行全套验证。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 前端:`npm run build` 通过。
|
||||||
|
- API:`cd api && npm run build` 通过,健康检查和 Swagger 入口代码存在。
|
||||||
|
- Gateway:`npm run spike:gateway` 通过。
|
||||||
|
- 队列契约:`npm run spike:contracts` 通过。
|
||||||
|
- BullMQ Spike:`npm run spike:bullmq` 通过。
|
||||||
|
- 基础设施:`infra/docker-compose.yml` 包含 PostgreSQL、Redis、MinIO。
|
||||||
|
- Prisma:`api/prisma/schema.prisma` 存在,API package 提供 `prisma:generate` 和迁移命令。
|
||||||
|
- 文档:`docs/phase-1-engineering-skeleton-progress.md` 记录每一步验证结果。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 阶段 1 工程骨架进度记录
|
||||||
|
|
||||||
|
## 2026-07-01
|
||||||
|
|
||||||
|
### 阶段计划
|
||||||
|
|
||||||
|
- 已创建 `docs/phase-1-engineering-skeleton-plan.md`。
|
||||||
|
- 阶段 1 范围限定为工程骨架,不展开完整业务实现。
|
||||||
|
|
||||||
|
### 验证记录
|
||||||
|
|
||||||
|
- 创建阶段 1 计划和进度文档后执行:
|
||||||
|
- `npm run spike:contracts`:通过。
|
||||||
|
- `npm run spike:gateway`:通过。
|
||||||
|
- `npm run build`:通过,仍有 Vite chunk 大小告警,不阻塞。
|
||||||
|
- 创建 NestJS API 骨架后执行:
|
||||||
|
- `cd api && npm install`:完成,npm audit 报 7 个漏洞(3 moderate、4 high),暂未执行 `npm audit fix --force` 以避免破坏依赖版本。
|
||||||
|
- `cd api && npm run build`:首次因 TypeScript 6 配置要求失败,补充 `rootDir` 和 `ignoreDeprecations` 后通过。
|
||||||
|
- 创建基础设施和 Gateway 健康检查后执行:
|
||||||
|
- `npm run spike:gateway`:通过,包含 Gateway health handler 测试。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- `npm run build`:通过,仍有 Vite chunk 大小告警,不阻塞。
|
||||||
|
- 迁移 Prisma 7 配置后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- 阶段 1 完整验证:
|
||||||
|
- `npm run verify:phase1`:通过。
|
||||||
|
- BullMQ 本轮端到端 TPS:948.32,满足 500 条/秒 Spike 验证线。
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- 前端构建成功,仍有 Vite chunk 大小告警,不阻塞。
|
||||||
|
|
||||||
|
### 阶段 1 验收状态
|
||||||
|
|
||||||
|
| 验收项 | 状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 前端 `npm run build` 通过 | 已完成 | 当前 React + Vite 原型构建通过。 |
|
||||||
|
| API `cd api && npm run build` 通过 | 已完成 | NestJS health/Swagger 骨架可编译。 |
|
||||||
|
| Gateway `npm run spike:gateway` 通过 | 已完成 | gocmpp、重连、health、追踪器测试通过。 |
|
||||||
|
| 队列契约 `npm run spike:contracts` 通过 | 已完成 | 4 个示例通过校验。 |
|
||||||
|
| BullMQ Spike `npm run spike:bullmq` 通过 | 已完成 | 15000 条端到端 TPS 948.32。 |
|
||||||
|
| 基础设施 compose 覆盖 PostgreSQL、Redis、MinIO | 已完成 | `infra/docker-compose.yml` 已创建;本机暂无 Docker,未执行 compose up。 |
|
||||||
|
| Prisma schema 和命令入口存在 | 已完成 | `api/prisma/schema.prisma`、`api/prisma.config.ts` 和 npm scripts 已创建。 |
|
||||||
|
| 文档记录阶段 1 进度 | 已完成 | 本文件已记录。 |
|
||||||
|
|
||||||
|
### 阶段 1 结论
|
||||||
|
|
||||||
|
阶段 1 已完成。第一版可以进入阶段 2 基础后台。
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
1. 输出阶段 2 实施计划和验收标准。
|
||||||
|
2. 按阶段 2 建立认证、租户、用户、角色、权限、操作日志、文件、基础字典和账户计费基础模型。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 阶段 2 基础后台实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
完成第一版业务底座:认证、租户隔离、用户/角色/权限、操作日志、文件上传元数据、基础字典、手机号段、敏感词、黑名单、引流字段、账户/套餐/账务流水基础模型。
|
||||||
|
|
||||||
|
阶段 2 优先建立后端模型与模块边界,不展开完整页面联调。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
1. Prisma 基础模型
|
||||||
|
- 租户、用户、角色、权限、用户角色、角色权限。
|
||||||
|
- 操作日志。
|
||||||
|
- 文件对象元数据。
|
||||||
|
- 手机号段、敏感词、企业黑名单、全局黑名单、引流字段。
|
||||||
|
- 账户、套餐、账务流水、计费规则。
|
||||||
|
|
||||||
|
2. NestJS 模块边界
|
||||||
|
- `AuthModule`
|
||||||
|
- `TenantsModule`
|
||||||
|
- `UsersModule`
|
||||||
|
- `AuditModule`
|
||||||
|
- `FilesModule`
|
||||||
|
- `DictionariesModule`
|
||||||
|
- `BillingModule`
|
||||||
|
|
||||||
|
3. 验证
|
||||||
|
- `npm --prefix api run prisma:generate`
|
||||||
|
- `npm --prefix api run build`
|
||||||
|
- `npm run verify:phase1`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- 基础模型覆盖阶段 2 范围。
|
||||||
|
- NestJS 模块边界存在并被 AppModule 引入。
|
||||||
|
- 文档记录阶段 2 进度和验证结果。
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 阶段 2 基础后台进度记录
|
||||||
|
|
||||||
|
## 2026-07-01
|
||||||
|
|
||||||
|
### 阶段计划
|
||||||
|
|
||||||
|
- 已创建 `docs/phase-2-foundation-backend-plan.md`。
|
||||||
|
- 阶段 2 第一轮聚焦后端模型和模块边界。
|
||||||
|
|
||||||
|
### 验证记录
|
||||||
|
|
||||||
|
- 扩展 Prisma 基础模型并创建 NestJS 模块边界后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- 阶段回归验证:
|
||||||
|
- `npm run verify:phase1`:通过。
|
||||||
|
- BullMQ 本轮端到端 TPS:1019.27,满足 500 条/秒验证线。
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- 前端构建成功,仍有 Vite chunk 大小告警,不阻塞。
|
||||||
|
- 实现基础后台最小 API 后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- API health 冒烟:`GET http://127.0.0.1:3101/api/health` 返回 `{"status":"ok","service":"cmpp-platform-api",...}`。
|
||||||
|
- 阶段 2 完整验证:
|
||||||
|
- `npm run verify:phase2`:通过。
|
||||||
|
- BullMQ 本轮端到端 TPS:531.54,满足 500 条/秒验证线。
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- 前端构建成功,仍有 Vite chunk 大小告警,不阻塞。
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
|
||||||
|
- Prisma 基础模型:
|
||||||
|
- `Tenant`
|
||||||
|
- `User`
|
||||||
|
- `Role`
|
||||||
|
- `Permission`
|
||||||
|
- `UserRole`
|
||||||
|
- `RolePermission`
|
||||||
|
- `OperationLog`
|
||||||
|
- `FileObject`
|
||||||
|
- `PhoneSegment`
|
||||||
|
- `SensitiveWord`
|
||||||
|
- `EnterpriseBlacklist`
|
||||||
|
- `GlobalBlacklist`
|
||||||
|
- `DrainageField`
|
||||||
|
- `BillingPlan`
|
||||||
|
- `TenantAccount`
|
||||||
|
- `AccountTransaction`
|
||||||
|
- `BillingRule`
|
||||||
|
- NestJS 模块边界:
|
||||||
|
- `AuthModule`
|
||||||
|
- `TenantsModule`
|
||||||
|
- `UsersModule`
|
||||||
|
- `AuditModule`
|
||||||
|
- `FilesModule`
|
||||||
|
- `DictionariesModule`
|
||||||
|
- `BillingModule`
|
||||||
|
- 最小 API 能力:
|
||||||
|
- `POST /api/client/auth/login`
|
||||||
|
- `GET/POST /api/admin/tenants`
|
||||||
|
- `GET/POST /api/admin/users`
|
||||||
|
- `GET/POST /api/admin/users/roles`
|
||||||
|
- `GET/POST /api/admin/users/permissions`
|
||||||
|
- `POST /api/admin/users/roles/assign`
|
||||||
|
- `POST /api/admin/users/permissions/assign`
|
||||||
|
- `GET/POST /api/admin/operation-logs`
|
||||||
|
- `GET/POST /api/admin/files`
|
||||||
|
- `POST /api/admin/files/presigned-upload`
|
||||||
|
- `GET/POST /api/admin/dictionaries/phone-segments`
|
||||||
|
- `GET/POST /api/admin/dictionaries/sensitive-words`
|
||||||
|
- `GET/POST /api/admin/dictionaries/blacklists/global`
|
||||||
|
- `GET/POST /api/admin/dictionaries/blacklists/enterprise`
|
||||||
|
- `GET/POST /api/admin/dictionaries/drainage-fields`
|
||||||
|
- `GET/POST /api/admin/billing/plans`
|
||||||
|
- `GET/POST /api/admin/billing/accounts`
|
||||||
|
- `GET/POST /api/admin/billing/transactions`
|
||||||
|
- `GET/POST /api/admin/billing/rules`
|
||||||
|
- `GET /api/client/billing/plans`
|
||||||
|
- `GET /api/client/billing/transactions`
|
||||||
|
- 基础设施:
|
||||||
|
- Prisma 7 使用 `@prisma/adapter-pg`。
|
||||||
|
- 文件上传使用 MinIO SDK 生成预签名上传 URL。
|
||||||
|
- `PrismaService` 懒连接,允许无 PostgreSQL 时启动 health/Swagger。
|
||||||
|
|
||||||
|
### 阶段 2 验收状态
|
||||||
|
|
||||||
|
| 验收项 | 状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 登录认证基础接口 | 已完成 | 已有用户名/密码登录占位,返回开发 token;正式 JWT/密码算法阶段后续加强。 |
|
||||||
|
| 企业/租户基础能力 | 已完成 | 租户列表、详情、创建接口已实现。 |
|
||||||
|
| 用户、角色、权限 | 已完成 | 用户、角色、权限、分配关系接口已实现。 |
|
||||||
|
| 操作日志 | 已完成 | 操作日志列表和创建接口已实现。 |
|
||||||
|
| 文件上传和对象存储 | 已完成 | 文件元数据接口和 MinIO 预签名上传入口已实现。 |
|
||||||
|
| 基础字典 | 已完成 | 手机号段、敏感词、黑名单、引流字段接口已实现。 |
|
||||||
|
| 账户、套餐、账务流水基础模型 | 已完成 | 套餐、账户、流水、计费规则接口已实现,客户端账单查询入口已提供。 |
|
||||||
|
| 构建/生成验证 | 已完成 | Prisma generate、API build、API health 冒烟均通过。 |
|
||||||
|
|
||||||
|
### 阶段 2 结论
|
||||||
|
|
||||||
|
阶段 2 已完成。第一版可以进入阶段 3 短信配置。
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
1. 输出阶段 3 实施计划和验收标准。
|
||||||
|
2. 实现短信应用、签名、签名资料、模板、模板变量、审核记录基础模型和接口。
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 阶段 3 短信配置实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让客户和运营能配置发送短信所需资源:短信应用、应用密钥和 IP 白名单、应用发送限额、不符合模板短信处理策略、短信签名、签名资料、短信模板、模板变量、审核记录和审核工作台基础接口。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
1. Prisma 模型
|
||||||
|
- `SmsApplication`
|
||||||
|
- `SmsApplicationIpAllowlist`
|
||||||
|
- `SmsSignature`
|
||||||
|
- `SignatureMaterial`
|
||||||
|
- `SmsTemplate`
|
||||||
|
- `TemplateVariable`
|
||||||
|
- `AuditRecord`
|
||||||
|
|
||||||
|
2. NestJS 模块
|
||||||
|
- `SmsConfigModule`
|
||||||
|
- 客户端接口:应用、签名、模板创建/查询/提交审核。
|
||||||
|
- 运营端接口:企业应用/签名/模板查询,签名/模板审核通过或驳回。
|
||||||
|
|
||||||
|
3. 规则约束
|
||||||
|
- 应用、签名、模板均按 `tenantId` 隔离。
|
||||||
|
- 已审核模板主体第一版不允许直接修改;阶段 3 先不实现编辑接口,后续如需要编辑只允许草稿/驳回状态。
|
||||||
|
- 签名和模板提交审核时写入 `AuditRecord`。
|
||||||
|
|
||||||
|
4. 验证
|
||||||
|
- `cd api && npm run prisma:generate`
|
||||||
|
- `cd api && npm run build`
|
||||||
|
- API health 冒烟
|
||||||
|
- `npm run verify:phase2`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- `SmsConfigModule` 被 AppModule 引入。
|
||||||
|
- 客户端短信应用、签名、模板基础接口存在。
|
||||||
|
- 运营端短信应用、签名、模板查询和审核接口存在。
|
||||||
|
- 阶段 3 进度文档记录验证结果。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 阶段 3 短信配置进度记录
|
||||||
|
|
||||||
|
## 2026-07-01
|
||||||
|
|
||||||
|
### 阶段计划
|
||||||
|
|
||||||
|
- 已创建 `docs/phase-3-sms-configuration-plan.md`。
|
||||||
|
- 阶段 3 聚焦短信配置域,不开发彩信功能。
|
||||||
|
|
||||||
|
### 验证记录
|
||||||
|
|
||||||
|
- 创建阶段 3 计划文档、扩展 Prisma 短信配置模型后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- 实现 `SmsConfigModule`、客户端/运营端最小接口后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- API health 冒烟:`GET http://127.0.0.1:3101/api/health` 返回 `{"status":"ok","service":"cmpp-platform-api",...}`。
|
||||||
|
- 阶段 3 完整验证:
|
||||||
|
- `npm run verify:phase3`:通过。
|
||||||
|
- BullMQ 本轮端到端 TPS:548.52,满足 500 条/秒验证线。
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- 前端构建成功,仍有 Vite chunk 大小告警,不阻塞。
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
|
||||||
|
- Prisma 短信配置模型:
|
||||||
|
- `SmsApplication`
|
||||||
|
- `SmsApplicationIpAllowlist`
|
||||||
|
- `SmsSignature`
|
||||||
|
- `SignatureMaterial`
|
||||||
|
- `SmsTemplate`
|
||||||
|
- `TemplateVariable`
|
||||||
|
- `AuditRecord`
|
||||||
|
- NestJS 模块:
|
||||||
|
- `SmsConfigModule`
|
||||||
|
- `SmsConfigService`
|
||||||
|
- `ClientSmsConfigController`
|
||||||
|
- `AdminSmsConfigController`
|
||||||
|
- 客户端接口:
|
||||||
|
- `GET/POST /api/client/applications`
|
||||||
|
- `GET/POST /api/client/signatures`
|
||||||
|
- `POST /api/client/signatures/:id/materials`
|
||||||
|
- `POST /api/client/signatures/:id/submit`
|
||||||
|
- `GET/POST /api/client/templates`
|
||||||
|
- `POST /api/client/templates/:id/submit`
|
||||||
|
- 运营端接口:
|
||||||
|
- `GET /api/admin/enterprise-applications`
|
||||||
|
- `GET /api/admin/enterprise-signatures`
|
||||||
|
- `GET /api/admin/enterprise-templates`
|
||||||
|
- `GET /api/admin/audit-records`
|
||||||
|
- `POST /api/admin/signatures/:id/approve`
|
||||||
|
- `POST /api/admin/signatures/:id/reject`
|
||||||
|
- `POST /api/admin/templates/:id/approve`
|
||||||
|
- `POST /api/admin/templates/:id/reject`
|
||||||
|
- 规则:
|
||||||
|
- 签名/模板提交审核会写入 `AuditRecord`。
|
||||||
|
- 审核通过/驳回会更新审核状态并写入 `AuditRecord`。
|
||||||
|
- 模板变量支持显式传入,也支持从 `${变量名}` 自动识别。
|
||||||
|
- 短信计费条数按 70/67 字规则预估。
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
1. 输出阶段 4 实施计划和验收标准。
|
||||||
|
2. 实现短信通道、通道组、路由规则、报备字段、签名报备资料、报备任务、导出/导入记录和报备状态同步。
|
||||||
|
|
||||||
|
### 阶段 3 验收状态
|
||||||
|
|
||||||
|
| 验收项 | 状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Prisma Client 生成成功 | 已完成 | `npm run prisma:generate` 通过。 |
|
||||||
|
| API 构建成功 | 已完成 | `npm run build` 在 `api` 下通过。 |
|
||||||
|
| `SmsConfigModule` 被 AppModule 引入 | 已完成 | 已引入根模块。 |
|
||||||
|
| 客户端短信应用、签名、模板基础接口存在 | 已完成 | 应用/签名/模板查询、创建、提交审核接口已实现。 |
|
||||||
|
| 运营端短信应用、签名、模板查询和审核接口存在 | 已完成 | 企业应用/签名/模板查询,签名/模板通过/驳回接口已实现。 |
|
||||||
|
| 阶段完整验证 | 已完成 | `npm run verify:phase3` 通过。 |
|
||||||
|
|
||||||
|
### 阶段 3 结论
|
||||||
|
|
||||||
|
阶段 3 已完成。第一版可以进入阶段 4 通道与报备。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 阶段 4 通道与报备实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让运营端能配置短信通道、通道组、路由规则和签名报备流程;让系统能记录签名在各通道的报备资料、报备任务、导出文件、回执导入和状态同步。
|
||||||
|
|
||||||
|
阶段 4 不实现真实发送链路,真实 CMPP 提交在阶段 7 发送链路接入;本阶段只建立通道和报备配置闭环。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
1. Prisma 模型
|
||||||
|
- `SmsChannel`
|
||||||
|
- `SmsChannelGroup`
|
||||||
|
- `SmsChannelGroupItem`
|
||||||
|
- `ChannelRouteRule`
|
||||||
|
- `ChannelHealthMetric`
|
||||||
|
- `ChannelReportField`
|
||||||
|
- `SignatureReportMaterial`
|
||||||
|
- `ChannelSignatureReportTask`
|
||||||
|
- `ChannelSignatureReportRecord`
|
||||||
|
- `ReportExportFile`
|
||||||
|
- `ReportReceiptImport`
|
||||||
|
|
||||||
|
2. NestJS 模块
|
||||||
|
- `ChannelsModule`
|
||||||
|
- 通道 CRUD、测试短信占位、通道健康指标查询。
|
||||||
|
- 通道组 CRUD、通道组成员配置。
|
||||||
|
- 路由规则 CRUD。
|
||||||
|
- 报备字段配置 CRUD。
|
||||||
|
- 签名报备资料登记。
|
||||||
|
- 报备任务生成、导出记录、回执导入记录、报备记录查询。
|
||||||
|
|
||||||
|
3. 状态同步
|
||||||
|
- 生成报备任务时记录 `pending`。
|
||||||
|
- 导出报备资料时记录导出文件和报备记录。
|
||||||
|
- 导入回执时记录导入批次、回执结果,并更新任务状态。
|
||||||
|
|
||||||
|
4. 验证
|
||||||
|
- `cd api && npm run prisma:generate`
|
||||||
|
- `cd api && npm run build`
|
||||||
|
- API health 冒烟
|
||||||
|
- `npm run verify:phase3`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- `ChannelsModule` 被 AppModule 引入。
|
||||||
|
- 通道、通道组、路由规则、报备字段、报备任务基础接口存在。
|
||||||
|
- 报备导出/导入记录和报备状态同步接口存在。
|
||||||
|
- 阶段 4 进度文档记录验证结果。
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 阶段 4 通道与报备进度记录
|
||||||
|
|
||||||
|
## 2026-07-01
|
||||||
|
|
||||||
|
### 阶段计划
|
||||||
|
|
||||||
|
- 已创建 `docs/phase-4-channel-reporting-plan.md`。
|
||||||
|
- 阶段 4 聚焦短信通道和签名报备,不开发彩信通道。
|
||||||
|
|
||||||
|
### 验证记录
|
||||||
|
|
||||||
|
- 创建阶段 4 计划文档、扩展 Prisma 通道与报备模型后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- 实现 `ChannelsModule`、通道/通道组/路由/报备最小接口后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- API health 冒烟:`GET http://127.0.0.1:3101/api/health` 返回 `{"status":"ok","service":"cmpp-platform-api",...}`。
|
||||||
|
- 阶段 4 完整验证:
|
||||||
|
- `npm run verify:phase4`:通过。
|
||||||
|
- BullMQ 本轮端到端 TPS:527.63,满足 500 条/秒验证线。
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- 前端构建成功,仍有 Vite chunk 大小告警,不阻塞。
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
|
||||||
|
- Prisma 通道与报备模型:
|
||||||
|
- `SmsChannel`
|
||||||
|
- `SmsChannelGroup`
|
||||||
|
- `SmsChannelGroupItem`
|
||||||
|
- `ChannelRouteRule`
|
||||||
|
- `ChannelHealthMetric`
|
||||||
|
- `ChannelReportField`
|
||||||
|
- `SignatureReportMaterial`
|
||||||
|
- `ChannelSignatureReportTask`
|
||||||
|
- `ChannelSignatureReportRecord`
|
||||||
|
- `ReportExportFile`
|
||||||
|
- `ReportReceiptImport`
|
||||||
|
- NestJS 模块:
|
||||||
|
- `ChannelsModule`
|
||||||
|
- `ChannelsService`
|
||||||
|
- `ChannelsController`
|
||||||
|
- 运营端接口:
|
||||||
|
- `GET/POST /api/admin/channels`
|
||||||
|
- `POST /api/admin/channels/:id/test`
|
||||||
|
- `GET /api/admin/channels/:id/metrics`
|
||||||
|
- `GET/POST /api/admin/channel-groups`
|
||||||
|
- `POST /api/admin/channel-groups/items`
|
||||||
|
- `GET/POST /api/admin/channel-route-rules`
|
||||||
|
- `GET/POST /api/admin/channel-report-fields`
|
||||||
|
- `GET/POST /api/admin/signature-report-materials`
|
||||||
|
- `GET /api/admin/report-tasks`
|
||||||
|
- `POST /api/admin/report-tasks/generate`
|
||||||
|
- `POST /api/admin/report-tasks/:id/export`
|
||||||
|
- `POST /api/admin/report-tasks/:id/receipt-import`
|
||||||
|
- `GET /api/admin/report-records`
|
||||||
|
- 报备状态规则:
|
||||||
|
- 生成报备任务时创建 `pending` 任务和报备记录。
|
||||||
|
- 导出报备资料时创建导出文件记录,并将任务状态更新为 `exporting`。
|
||||||
|
- 导入回执时创建导入批次,按结果更新任务状态,并同步签名 `reportStatus`。
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
1. 输出阶段 5 实施计划和验收标准。
|
||||||
|
2. 实现账户计费完整扣费/冻结/退费闭环、套餐购买/充值记录和发送前费用预估。
|
||||||
|
|
||||||
|
### 阶段 4 验收状态
|
||||||
|
|
||||||
|
| 验收项 | 状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Prisma Client 生成成功 | 已完成 | `npm run prisma:generate` 通过。 |
|
||||||
|
| API 构建成功 | 已完成 | `npm run build` 在 `api` 下通过。 |
|
||||||
|
| `ChannelsModule` 被 AppModule 引入 | 已完成 | 已引入根模块。 |
|
||||||
|
| 通道、通道组、路由规则基础接口存在 | 已完成 | 通道 CRUD、测试占位、指标查询、通道组、成员和路由规则接口已实现。 |
|
||||||
|
| 报备字段和签名报备资料接口存在 | 已完成 | 报备字段配置、签名通道报备资料登记接口已实现。 |
|
||||||
|
| 报备任务、导出、导入和记录接口存在 | 已完成 | 任务生成、导出文件、回执导入、报备记录查询接口已实现。 |
|
||||||
|
| 报备状态同步 | 已完成 | 回执导入后更新任务状态并同步签名 `reportStatus`。 |
|
||||||
|
| 阶段完整验证 | 已完成 | `npm run verify:phase4` 通过。 |
|
||||||
|
|
||||||
|
### 阶段 4 结论
|
||||||
|
|
||||||
|
阶段 4 已完成。第一版可以进入阶段 5 账户计费。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 阶段 5 账户计费实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
实现发送前可判断是否可发,发送后可对账的账户计费基础闭环:套餐配置、充值套餐、企业账户、余额或套餐余量、充值记录、账单流水、发送预估费用、冻结、扣费、退费、解冻、短信记录与账务流水关联。
|
||||||
|
|
||||||
|
阶段 5 不实现完整发送链路;发送记录正式关联会在阶段 7 发送链路落地时接入。本阶段先提供 `relatedType`/`relatedId` 关联能力和费用预估/账务动作 API。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
1. Prisma 模型补强
|
||||||
|
- `RechargeOrder`
|
||||||
|
- `SmsBillingRecord`
|
||||||
|
- 扩展账务流水关联字段。
|
||||||
|
|
||||||
|
2. Billing 服务动作
|
||||||
|
- 套餐购买/人工充值。
|
||||||
|
- 发送费用预估。
|
||||||
|
- 账户可用额度检查。
|
||||||
|
- 冻结、扣费、解冻、退费。
|
||||||
|
- 账务流水关联任务、短信记录或人工单据。
|
||||||
|
|
||||||
|
3. API
|
||||||
|
- 客户端充值套餐、充值订单、账单流水、费用预估。
|
||||||
|
- 运营端充值记录、账务流水、套餐配置、计费规则、人工充值和账务调整。
|
||||||
|
|
||||||
|
4. 验证
|
||||||
|
- `cd api && npm run prisma:generate`
|
||||||
|
- `cd api && npm run build`
|
||||||
|
- API health 冒烟
|
||||||
|
- `npm run verify:phase4`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- 套餐、充值记录、账务流水、计费规则 API 存在。
|
||||||
|
- 费用预估、冻结、扣费、解冻、退费 API 存在。
|
||||||
|
- 阶段 5 进度文档记录验证结果。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 阶段 5 账户计费进度记录
|
||||||
|
|
||||||
|
## 2026-07-01
|
||||||
|
|
||||||
|
### 阶段计划
|
||||||
|
|
||||||
|
- 已创建 `docs/phase-5-billing-plan.md`。
|
||||||
|
- 阶段 5 聚焦账户计费闭环,不实现完整发送链路。
|
||||||
|
|
||||||
|
### 验证记录
|
||||||
|
|
||||||
|
- 创建阶段 5 计划文档、扩展 Prisma 充值订单和短信计费记录模型后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- 实现 Billing 账务动作 API 后执行:
|
||||||
|
- `cd api && npm run prisma:generate`:通过。
|
||||||
|
- `cd api && npm run build`:通过。
|
||||||
|
- API health 冒烟:`GET http://127.0.0.1:3101/api/health` 返回 `{"status":"ok","service":"cmpp-platform-api",...}`。
|
||||||
|
- 阶段 5 完整验证:
|
||||||
|
- `npm run verify:phase5`:通过。
|
||||||
|
- BullMQ 本轮端到端 TPS:543.91,满足 500 条/秒验证线。
|
||||||
|
- Prisma Client 生成成功。
|
||||||
|
- API 构建成功。
|
||||||
|
- 前端构建成功,仍有 Vite chunk 大小告警,不阻塞。
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
|
||||||
|
- Prisma 账户计费模型补强:
|
||||||
|
- `RechargeOrder`
|
||||||
|
- `SmsBillingRecord`
|
||||||
|
- Billing 账务动作:
|
||||||
|
- 套餐购买/人工充值:创建 `RechargeOrder` 并写入 `AccountTransaction`。
|
||||||
|
- 发送费用预估:按 70/67 字规则计算计费条数、总条数和金额。
|
||||||
|
- 账户校验:检查余额+授信额度和套餐余量。
|
||||||
|
- 冻结:写入 `frozen` 流水。
|
||||||
|
- 扣费:写入 `charged` 流水。
|
||||||
|
- 解冻:写入 `released` 流水。
|
||||||
|
- 退费:写入 `refunded` 流水。
|
||||||
|
- 调整:写入 `adjusted` 流水。
|
||||||
|
- 短信计费记录:创建和查询 `SmsBillingRecord`。
|
||||||
|
- 运营端接口:
|
||||||
|
- `GET/POST /api/admin/billing/recharges`
|
||||||
|
- `POST /api/admin/billing/estimate`
|
||||||
|
- `POST /api/admin/billing/check`
|
||||||
|
- `POST /api/admin/billing/freeze`
|
||||||
|
- `POST /api/admin/billing/charge`
|
||||||
|
- `POST /api/admin/billing/release`
|
||||||
|
- `POST /api/admin/billing/refund`
|
||||||
|
- `POST /api/admin/billing/adjust`
|
||||||
|
- `GET/POST /api/admin/billing/sms-billing-records`
|
||||||
|
- 客户端接口:
|
||||||
|
- `GET/POST /api/client/billing/orders`
|
||||||
|
- `POST /api/client/billing/estimate`
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
1. 输出阶段 6 实施计划和验收标准。
|
||||||
|
2. 实现风控规则、默认阈值、规则命中记录、短信审核、审核原因展示和拒绝原因返回。
|
||||||
|
|
||||||
|
### 阶段 5 验收状态
|
||||||
|
|
||||||
|
| 验收项 | 状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Prisma Client 生成成功 | 已完成 | `npm run prisma:generate` 通过。 |
|
||||||
|
| API 构建成功 | 已完成 | `npm run build` 在 `api` 下通过。 |
|
||||||
|
| 套餐、充值记录、账务流水、计费规则 API 存在 | 已完成 | 已有套餐、充值订单、账户、流水、规则接口。 |
|
||||||
|
| 费用预估 API 存在 | 已完成 | `POST /api/admin/billing/estimate` 与 `POST /api/client/billing/estimate` 已实现。 |
|
||||||
|
| 账户校验 API 存在 | 已完成 | `POST /api/admin/billing/check` 已实现。 |
|
||||||
|
| 冻结、扣费、解冻、退费 API 存在 | 已完成 | `freeze/charge/release/refund` 已实现。 |
|
||||||
|
| 短信计费记录 API 存在 | 已完成 | `sms-billing-records` 查询和创建已实现。 |
|
||||||
|
| 阶段完整验证 | 已完成 | `npm run verify:phase5` 通过。 |
|
||||||
|
|
||||||
|
### 阶段 5 结论
|
||||||
|
|
||||||
|
阶段 5 已完成。第一版可以进入阶段 6 风控与审核。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# CMPP Gateway Spike
|
||||||
|
|
||||||
|
阶段 0 的 `gateway/` 目录用于 Go CMPP Gateway 技术 Spike。当前环境未安装 Go 工具链,先保留工程边界和验收清单。
|
||||||
|
|
||||||
|
## 阶段 0 职责
|
||||||
|
|
||||||
|
- 读取一个 CMPP 通道配置。
|
||||||
|
- 基于 gocmpp 或评估后的协议库完成 connect、submit、deliver、active test、terminate。
|
||||||
|
- 消费 `cmpp.submit.commands`。
|
||||||
|
- 发布 `cmpp.submit.results`、`cmpp.receipt.events`、`cmpp.uplink.events`。
|
||||||
|
- 维护 `messageId -> sequenceId -> gatewayMessageId` 映射。
|
||||||
|
- 支持断线重连和后续消息继续消费。
|
||||||
|
- 暴露健康检查和最小指标。
|
||||||
|
|
||||||
|
## 建议骨架
|
||||||
|
|
||||||
|
```text
|
||||||
|
gateway/
|
||||||
|
├── cmd/
|
||||||
|
│ ├── gateway/
|
||||||
|
│ └── smsc-simulator/
|
||||||
|
└── internal/
|
||||||
|
├── cmpp/
|
||||||
|
├── config/
|
||||||
|
├── connection/
|
||||||
|
├── metrics/
|
||||||
|
├── queue/
|
||||||
|
└── tracker/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前环境状态
|
||||||
|
|
||||||
|
已安装 Go 1.26.4。当前 Spike 已完成:
|
||||||
|
|
||||||
|
- 队列消息结构定义。
|
||||||
|
- `messageId -> sequenceId -> gatewayMessageId` 追踪器。
|
||||||
|
- 内存模拟 submit resp 与 deliver 回执链路。
|
||||||
|
- 15000 条内存链路压测基线。
|
||||||
|
- gocmpp 编译级接入点。
|
||||||
|
- gocmpp 本地 TCP connect、submit、submit resp、active test 集成测试。
|
||||||
|
- gocmpp deliver 回执 PDU pack/unpack 测试。
|
||||||
|
- 断线重连状态机测试。
|
||||||
|
|
||||||
|
阶段 0 决策:协议层优先直接依赖 gocmpp,服务层连接管理、重连、SEQID/MSGID 追踪、队列、限速、监控、幂等由本项目自研。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"cmpp-platform/gateway/internal/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := os.Getenv("GATEWAY_HEALTH_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8090"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("cmpp gateway health server listening on %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, health.Handler()); err != nil {
|
||||||
|
log.Fatalf("gateway health server stopped: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmpp-platform/gateway/internal/spike"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sim := spike.NewSimulator()
|
||||||
|
result, err := sim.RunLoad(ctx, 15000)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("run spike load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("submitted=%d submitResults=%d receiptEvents=%d duration=%s throughput=%.2f msg/s\n",
|
||||||
|
result.Submitted,
|
||||||
|
result.SubmitResults,
|
||||||
|
result.ReceiptEvents,
|
||||||
|
result.Duration,
|
||||||
|
result.MessagesPerSec,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module cmpp-platform/gateway
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bigwhite/gocmpp v0.0.0-20240917054108-b238366bff0b // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/bigwhite/gocmpp v0.0.0-20240917054108-b238366bff0b h1:HOIU4bq4fpwWdtkpXASXnWk/fiFjd6o27Q0Kw4aJJAk=
|
||||||
|
github.com/bigwhite/gocmpp v0.0.0-20240917054108-b238366bff0b/go.mod h1:BDWS0X/2jJROFh0iYgdcAdv4jy3cPhVcZXvEkZmoqCM=
|
||||||
|
github.com/dvyukov/go-fuzz v0.0.0-20190516070045-5cc3605ccbb6/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package cmppadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cmpp "github.com/bigwhite/gocmpp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Version string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Version20 Version = "2.0"
|
||||||
|
Version30 Version = "3.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientConfig struct {
|
||||||
|
Version Version
|
||||||
|
Address string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
raw *cmpp.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(config ClientConfig) *Client {
|
||||||
|
return &Client{raw: cmpp.NewClient(toProtocolType(config.Version))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Raw() *cmpp.Client {
|
||||||
|
return c.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtocolType(version Version) cmpp.Type {
|
||||||
|
if version == Version20 {
|
||||||
|
return cmpp.V20
|
||||||
|
}
|
||||||
|
return cmpp.V30
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package cmppadapter
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNewClientUsesGocmpp(t *testing.T) {
|
||||||
|
client := NewClient(ClientConfig{Version: Version30})
|
||||||
|
if client.Raw() == nil {
|
||||||
|
t.Fatal("expected gocmpp client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtocolVersionMapping(t *testing.T) {
|
||||||
|
if toProtocolType(Version20).String() == toProtocolType(Version30).String() {
|
||||||
|
t.Fatal("expected CMPP 2.0 and 3.0 to map to different protocol types")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package cmppadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cmpp "github.com/bigwhite/gocmpp"
|
||||||
|
cmpputils "github.com/bigwhite/gocmpp/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
spikeUser = "900001"
|
||||||
|
spikePassword = "888888"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGocmppConnectSubmitAndActiveTest(t *testing.T) {
|
||||||
|
addr := reserveTCPAddr(t)
|
||||||
|
handlers := []cmpp.Handler{
|
||||||
|
cmpp.HandlerFunc(handleSpikeLogin),
|
||||||
|
cmpp.HandlerFunc(handleSpikeSubmit),
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := cmpp.ListenAndServe(addr, cmpp.V30, 2*time.Second, 3, nil, handlers...)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("gocmpp spike server stopped: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
client := cmpp.NewClient(cmpp.V30)
|
||||||
|
defer client.Disconnect()
|
||||||
|
|
||||||
|
if err := client.Connect(addr, spikeUser, spikePassword, 2*time.Second); err != nil {
|
||||||
|
t.Fatalf("connect gocmpp server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := cmpputils.Utf8ToUcs2("测试gocmpp submit")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode submit content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.SendReqPkt(&cmpp.Cmpp3SubmitReqPkt{
|
||||||
|
PkTotal: 1,
|
||||||
|
PkNumber: 1,
|
||||||
|
RegisteredDelivery: 1,
|
||||||
|
MsgLevel: 1,
|
||||||
|
ServiceId: "test",
|
||||||
|
FeeUserType: 2,
|
||||||
|
FeeTerminalId: "13500002696",
|
||||||
|
FeeTerminalType: 0,
|
||||||
|
MsgFmt: 8,
|
||||||
|
MsgSrc: spikeUser,
|
||||||
|
FeeType: "02",
|
||||||
|
FeeCode: "10",
|
||||||
|
ValidTime: "151105131555101+",
|
||||||
|
AtTime: "",
|
||||||
|
SrcId: spikeUser,
|
||||||
|
DestUsrTl: 1,
|
||||||
|
DestTerminalId: []string{"13500002696"},
|
||||||
|
DestTerminalType: 0,
|
||||||
|
MsgLength: uint8(len(content)),
|
||||||
|
MsgContent: content,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send submit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
submitRsp := recvUntil[*cmpp.Cmpp3SubmitRspPkt](t, client, 2*time.Second)
|
||||||
|
if submitRsp.Result != 0 {
|
||||||
|
t.Fatalf("unexpected submit result: %d", submitRsp.Result)
|
||||||
|
}
|
||||||
|
if submitRsp.MsgId == 0 {
|
||||||
|
t.Fatal("expected gateway msg id")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.SendReqPkt(&cmpp.CmppActiveTestReqPkt{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send active test: %v", err)
|
||||||
|
}
|
||||||
|
_ = recvUntil[*cmpp.CmppActiveTestRspPkt](t, client, 2*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGocmppDeliverReceiptPackAndUnpack(t *testing.T) {
|
||||||
|
receipt := &cmpp.CmppReceiptPkt{
|
||||||
|
MsgId: 12878564852733378560,
|
||||||
|
Stat: "DELIVRD",
|
||||||
|
SubmitTime: "2607010900",
|
||||||
|
DoneTime: "2607010901",
|
||||||
|
DestTerminalId: "13500002696",
|
||||||
|
SmscSequence: 42,
|
||||||
|
}
|
||||||
|
receiptBytes, err := receipt.Pack()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pack receipt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deliver := &cmpp.Cmpp3DeliverReqPkt{
|
||||||
|
MsgId: 12878564852733378560,
|
||||||
|
DestId: "106900000000",
|
||||||
|
ServiceId: "test",
|
||||||
|
TpPid: 0,
|
||||||
|
TpUdhi: 0,
|
||||||
|
MsgFmt: 0,
|
||||||
|
SrcTerminalId: "13500002696",
|
||||||
|
SrcTerminalType: 0,
|
||||||
|
RegisterDelivery: 1,
|
||||||
|
MsgLength: uint8(cmpp.CmppReceiptPktLen),
|
||||||
|
MsgContent: string(receiptBytes),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := deliver.Pack(1001)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pack deliver receipt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unpacked cmpp.Cmpp3DeliverReqPkt
|
||||||
|
if err := unpacked.Unpack(data[8:]); err != nil {
|
||||||
|
t.Fatalf("unpack deliver receipt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gotReceipt cmpp.CmppReceiptPkt
|
||||||
|
if err := gotReceipt.Unpack([]byte(unpacked.MsgContent)); err != nil {
|
||||||
|
t.Fatalf("unpack receipt content: %v", err)
|
||||||
|
}
|
||||||
|
if gotReceipt.Stat != "DELIVRD" || gotReceipt.SmscSequence != 42 {
|
||||||
|
t.Fatalf("unexpected receipt payload: %+v", &gotReceipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reserveTCPAddr(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reserve tcp addr: %v", err)
|
||||||
|
}
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
if err := listener.Close(); err != nil {
|
||||||
|
t.Fatalf("close reserved listener: %v", err)
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func recvUntil[T any](t *testing.T, client *cmpp.Client, timeout time.Duration) T {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
packet, err := client.RecvAndUnpackPkt(200 * time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if typed, ok := packet.(T); ok {
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var zero T
|
||||||
|
t.Fatalf("timed out waiting for %T", zero)
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSpikeLogin(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) {
|
||||||
|
req, ok := packet.Packer.(*cmpp.CmppConnReqPkt)
|
||||||
|
if !ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := response.Packer.(*cmpp.Cmpp3ConnRspPkt)
|
||||||
|
resp.Version = 0x30
|
||||||
|
|
||||||
|
if req.SrcAddr != cmpputils.OctetString(spikeUser, 6) {
|
||||||
|
resp.Status = uint32(cmpp.ErrnoConnInvalidSrcAddr)
|
||||||
|
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnInvalidSrcAddr]
|
||||||
|
}
|
||||||
|
|
||||||
|
authSrc := md5.Sum(bytes.Join([][]byte{
|
||||||
|
[]byte(cmpputils.OctetString(spikeUser, 6)),
|
||||||
|
make([]byte, 9),
|
||||||
|
[]byte(spikePassword),
|
||||||
|
[]byte(cmpputils.TimeStamp2Str(req.Timestamp)),
|
||||||
|
}, nil))
|
||||||
|
|
||||||
|
if req.AuthSrc != string(authSrc[:]) {
|
||||||
|
resp.Status = uint32(cmpp.ErrnoConnAuthFailed)
|
||||||
|
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnAuthFailed]
|
||||||
|
}
|
||||||
|
|
||||||
|
authIsmg := md5.Sum(bytes.Join([][]byte{{byte(resp.Status)}, authSrc[:], []byte(spikePassword)}, nil))
|
||||||
|
resp.AuthIsmg = string(authIsmg[:])
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSpikeSubmit(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) {
|
||||||
|
req, ok := packet.Packer.(*cmpp.Cmpp3SubmitReqPkt)
|
||||||
|
if !ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DestUsrTl == 0 || len(req.DestTerminalId) == 0 {
|
||||||
|
return false, fmt.Errorf("missing submit destination")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := response.Packer.(*cmpp.Cmpp3SubmitRspPkt)
|
||||||
|
resp.MsgId = 12878564852733378560
|
||||||
|
resp.Result = 0
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DialFunc func(context.Context) error
|
||||||
|
|
||||||
|
type Reconnector struct {
|
||||||
|
MaxAttempts int
|
||||||
|
Delay time.Duration
|
||||||
|
Dial DialFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reconnector) Connect(ctx context.Context) (int, error) {
|
||||||
|
attempts := r.MaxAttempts
|
||||||
|
if attempts <= 0 {
|
||||||
|
attempts = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= attempts; attempt++ {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return attempt - 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Dial(ctx); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if attempt < attempts && r.Delay > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return attempt, ctx.Err()
|
||||||
|
case <-time.After(r.Delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return attempt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return attempts, lastErr
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReconnectorRetriesAfterDisconnect(t *testing.T) {
|
||||||
|
failures := 0
|
||||||
|
reconnector := Reconnector{
|
||||||
|
MaxAttempts: 3,
|
||||||
|
Dial: func(context.Context) error {
|
||||||
|
failures++
|
||||||
|
if failures < 2 {
|
||||||
|
return errors.New("simulated disconnect")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts, err := reconnector.Connect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect after retry: %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Fatalf("expected success on second attempt, got %d", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconnectorReturnsLastError(t *testing.T) {
|
||||||
|
expected := errors.New("still disconnected")
|
||||||
|
reconnector := Reconnector{
|
||||||
|
MaxAttempts: 2,
|
||||||
|
Dial: func(context.Context) error {
|
||||||
|
return expected
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts, err := reconnector.Connect(context.Background())
|
||||||
|
if !errors.Is(err, expected) {
|
||||||
|
t.Fatalf("expected last error, got %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Fatalf("expected two attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(Status{
|
||||||
|
Status: "ok",
|
||||||
|
Service: "cmpp-gateway",
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthHandler(t *testing.T) {
|
||||||
|
server := httptest.NewServer(Handler())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
response, err := http.Get(server.URL + "/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get health: %v", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected status: %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload Status
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode health payload: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Status != "ok" || payload.Service != "cmpp-gateway" {
|
||||||
|
t.Fatalf("unexpected payload: %+v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const SchemaVersion = "v1"
|
||||||
|
|
||||||
|
type MessageType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MessageTypeSubmitCommand MessageType = "SubmitCommand"
|
||||||
|
MessageTypeSubmitResult MessageType = "SubmitResult"
|
||||||
|
MessageTypeReceiptEvent MessageType = "ReceiptEvent"
|
||||||
|
MessageTypeUplinkEvent MessageType = "UplinkEvent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Envelope struct {
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
|
MessageType MessageType `json:"messageType"`
|
||||||
|
TraceID string `json:"traceId"`
|
||||||
|
MessageID string `json:"messageId"`
|
||||||
|
ChannelID string `json:"channelId"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitCommand struct {
|
||||||
|
Envelope
|
||||||
|
TenantID string `json:"tenantId"`
|
||||||
|
ApplicationID string `json:"applicationId"`
|
||||||
|
TaskID string `json:"taskId,omitempty"`
|
||||||
|
SubmitID string `json:"submitId"`
|
||||||
|
PhoneNumber string `json:"phoneNumber"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
TemplateID string `json:"templateId"`
|
||||||
|
BillingUnits int `json:"billingUnits"`
|
||||||
|
Route Route `json:"route"`
|
||||||
|
CMPP CMPP `json:"cmpp"`
|
||||||
|
Retry Retry `json:"retry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Route struct {
|
||||||
|
ChannelCode string `json:"channelCode"`
|
||||||
|
CMPPAccountCode string `json:"cmppAccountCode"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
RateLimitPerSecond int `json:"rateLimitPerSecond,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CMPP struct {
|
||||||
|
ServiceID string `json:"serviceId"`
|
||||||
|
SrcID string `json:"srcId"`
|
||||||
|
RegisteredDelivery int `json:"registeredDelivery"`
|
||||||
|
MsgFmt int `json:"msgFmt"`
|
||||||
|
FeeUserType int `json:"feeUserType,omitempty"`
|
||||||
|
FeeCode string `json:"feeCode,omitempty"`
|
||||||
|
FeeType string `json:"feeType,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Retry struct {
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
MaxAttempts int `json:"maxAttempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitResult struct {
|
||||||
|
Envelope
|
||||||
|
SequenceID uint32 `json:"sequenceId"`
|
||||||
|
GatewayMessageID string `json:"gatewayMessageId"`
|
||||||
|
SubmitStatus string `json:"submitStatus"`
|
||||||
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||||
|
SubmittedAt time.Time `json:"submittedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReceiptEvent struct {
|
||||||
|
Envelope
|
||||||
|
SequenceID uint32 `json:"sequenceId"`
|
||||||
|
GatewayMessageID string `json:"gatewayMessageId"`
|
||||||
|
ReceiptStatus string `json:"receiptStatus"`
|
||||||
|
RawStatus string `json:"rawStatus"`
|
||||||
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
|
DeliveredAt time.Time `json:"deliveredAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UplinkEvent struct {
|
||||||
|
Envelope
|
||||||
|
SequenceID uint32 `json:"sequenceId"`
|
||||||
|
PhoneNumber string `json:"phoneNumber"`
|
||||||
|
DestID string `json:"destId"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ReceivedAt time.Time `json:"receivedAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package spike
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmpp-platform/gateway/internal/queue"
|
||||||
|
"cmpp-platform/gateway/internal/tracker"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Simulator struct {
|
||||||
|
tracker *tracker.Tracker
|
||||||
|
sequence atomic.Uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunResult struct {
|
||||||
|
Submitted int
|
||||||
|
SubmitResults int
|
||||||
|
ReceiptEvents int
|
||||||
|
Duration time.Duration
|
||||||
|
MessagesPerSec float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSimulator() *Simulator {
|
||||||
|
return &Simulator{tracker: tracker.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Simulator) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.SubmitResult, queue.ReceiptEvent, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return queue.SubmitResult{}, queue.ReceiptEvent{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sequenceID := s.sequence.Add(1)
|
||||||
|
s.tracker.TrackSubmit(cmd.MessageID, sequenceID)
|
||||||
|
|
||||||
|
gatewayMessageID := fmt.Sprintf("gw-%s", cmd.MessageID)
|
||||||
|
mapping, err := s.tracker.TrackSubmitResp(sequenceID, gatewayMessageID)
|
||||||
|
if err != nil {
|
||||||
|
return queue.SubmitResult{}, queue.ReceiptEvent{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
result := queue.SubmitResult{
|
||||||
|
Envelope: queue.Envelope{
|
||||||
|
SchemaVersion: queue.SchemaVersion,
|
||||||
|
MessageType: queue.MessageTypeSubmitResult,
|
||||||
|
TraceID: cmd.TraceID,
|
||||||
|
MessageID: mapping.MessageID,
|
||||||
|
ChannelID: cmd.ChannelID,
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
SequenceID: mapping.SequenceID,
|
||||||
|
GatewayMessageID: mapping.GatewayMessageID,
|
||||||
|
SubmitStatus: "accepted",
|
||||||
|
SubmittedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt := queue.ReceiptEvent{
|
||||||
|
Envelope: queue.Envelope{
|
||||||
|
SchemaVersion: queue.SchemaVersion,
|
||||||
|
MessageType: queue.MessageTypeReceiptEvent,
|
||||||
|
TraceID: cmd.TraceID,
|
||||||
|
MessageID: mapping.MessageID,
|
||||||
|
ChannelID: cmd.ChannelID,
|
||||||
|
CreatedAt: now.Add(10 * time.Millisecond),
|
||||||
|
},
|
||||||
|
SequenceID: mapping.SequenceID,
|
||||||
|
GatewayMessageID: mapping.GatewayMessageID,
|
||||||
|
ReceiptStatus: "delivered",
|
||||||
|
RawStatus: "DELIVRD",
|
||||||
|
DeliveredAt: now.Add(10 * time.Millisecond),
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Simulator) RunLoad(ctx context.Context, count int) (RunResult, error) {
|
||||||
|
start := time.Now()
|
||||||
|
result := RunResult{Submitted: count}
|
||||||
|
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
cmd := NewSubmitCommand(i)
|
||||||
|
_, _, err := s.Submit(ctx, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return RunResult{}, err
|
||||||
|
}
|
||||||
|
result.SubmitResults++
|
||||||
|
result.ReceiptEvents++
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Duration = time.Since(start)
|
||||||
|
if result.Duration > 0 {
|
||||||
|
result.MessagesPerSec = float64(count) / result.Duration.Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSubmitCommand(index int) queue.SubmitCommand {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
messageID := fmt.Sprintf("msg-spike-%06d", index)
|
||||||
|
return queue.SubmitCommand{
|
||||||
|
Envelope: queue.Envelope{
|
||||||
|
SchemaVersion: queue.SchemaVersion,
|
||||||
|
MessageType: queue.MessageTypeSubmitCommand,
|
||||||
|
TraceID: fmt.Sprintf("trace-spike-%06d", index),
|
||||||
|
MessageID: messageID,
|
||||||
|
ChannelID: "sms-channel-cmpp-spike",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
TenantID: "tenant-spike",
|
||||||
|
ApplicationID: "app-spike",
|
||||||
|
TaskID: "task-spike",
|
||||||
|
SubmitID: fmt.Sprintf("submit-spike-%06d", index),
|
||||||
|
PhoneNumber: "13800138000",
|
||||||
|
Content: "您的验证码为 123456,5 分钟内有效。",
|
||||||
|
Signature: "测试平台",
|
||||||
|
TemplateID: "tpl-spike",
|
||||||
|
BillingUnits: 1,
|
||||||
|
Route: queue.Route{
|
||||||
|
ChannelCode: "CMCC-CMPP-SPIKE",
|
||||||
|
CMPPAccountCode: "cmpp-account-spike",
|
||||||
|
Priority: 10,
|
||||||
|
RateLimitPerSecond: 500,
|
||||||
|
},
|
||||||
|
CMPP: queue.CMPP{
|
||||||
|
ServiceID: "CMPP",
|
||||||
|
SrcID: "106900000000",
|
||||||
|
RegisteredDelivery: 1,
|
||||||
|
MsgFmt: 15,
|
||||||
|
FeeUserType: 2,
|
||||||
|
FeeCode: "0",
|
||||||
|
FeeType: "01",
|
||||||
|
},
|
||||||
|
Retry: queue.Retry{Attempt: 0, MaxAttempts: 3},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package spike
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSimulatorRunsOneMessageLifecycle(t *testing.T) {
|
||||||
|
sim := NewSimulator()
|
||||||
|
cmd := NewSubmitCommand(1)
|
||||||
|
|
||||||
|
result, receipt, err := sim.Submit(context.Background(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("submit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MessageID != cmd.MessageID {
|
||||||
|
t.Fatalf("submit result message id mismatch: %s != %s", result.MessageID, cmd.MessageID)
|
||||||
|
}
|
||||||
|
if result.SequenceID == 0 {
|
||||||
|
t.Fatal("expected sequence id")
|
||||||
|
}
|
||||||
|
if receipt.GatewayMessageID != result.GatewayMessageID {
|
||||||
|
t.Fatalf("receipt gateway id mismatch: %s != %s", receipt.GatewayMessageID, result.GatewayMessageID)
|
||||||
|
}
|
||||||
|
if receipt.ReceiptStatus != "delivered" {
|
||||||
|
t.Fatalf("unexpected receipt status: %s", receipt.ReceiptStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimulatorLoadMeets500TPSFloor(t *testing.T) {
|
||||||
|
sim := NewSimulator()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := sim.RunLoad(ctx, 15000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.SubmitResults != 15000 || result.ReceiptEvents != 15000 {
|
||||||
|
t.Fatalf("unexpected result counts: %+v", result)
|
||||||
|
}
|
||||||
|
if result.MessagesPerSec < 500 {
|
||||||
|
t.Fatalf("expected at least 500 msg/s, got %.2f", result.MessagesPerSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package tracker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrMappingNotFound = errors.New("tracker mapping not found")
|
||||||
|
|
||||||
|
type Mapping struct {
|
||||||
|
MessageID string
|
||||||
|
SequenceID uint32
|
||||||
|
GatewayMessageID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
byMessage map[string]Mapping
|
||||||
|
bySeq map[uint32]string
|
||||||
|
byGateway map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Tracker {
|
||||||
|
return &Tracker{
|
||||||
|
byMessage: make(map[string]Mapping),
|
||||||
|
bySeq: make(map[uint32]string),
|
||||||
|
byGateway: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tracker) TrackSubmit(messageID string, sequenceID uint32) Mapping {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
mapping := t.byMessage[messageID]
|
||||||
|
mapping.MessageID = messageID
|
||||||
|
mapping.SequenceID = sequenceID
|
||||||
|
t.byMessage[messageID] = mapping
|
||||||
|
t.bySeq[sequenceID] = messageID
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tracker) TrackSubmitResp(sequenceID uint32, gatewayMessageID string) (Mapping, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
messageID, ok := t.bySeq[sequenceID]
|
||||||
|
if !ok {
|
||||||
|
return Mapping{}, ErrMappingNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping := t.byMessage[messageID]
|
||||||
|
mapping.GatewayMessageID = gatewayMessageID
|
||||||
|
t.byMessage[messageID] = mapping
|
||||||
|
t.byGateway[gatewayMessageID] = messageID
|
||||||
|
|
||||||
|
return mapping, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tracker) ByGatewayMessageID(gatewayMessageID string) (Mapping, error) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
messageID, ok := t.byGateway[gatewayMessageID]
|
||||||
|
if !ok {
|
||||||
|
return Mapping{}, ErrMappingNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.byMessage[messageID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tracker) ByMessageID(messageID string) (Mapping, error) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
mapping, ok := t.byMessage[messageID]
|
||||||
|
if !ok {
|
||||||
|
return Mapping{}, ErrMappingNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tracker
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTrackerMapsMessageSequenceAndGatewayIDs(t *testing.T) {
|
||||||
|
tr := New()
|
||||||
|
|
||||||
|
submit := tr.TrackSubmit("msg-1", 1001)
|
||||||
|
if submit.MessageID != "msg-1" || submit.SequenceID != 1001 {
|
||||||
|
t.Fatalf("unexpected submit mapping: %+v", submit)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := tr.TrackSubmitResp(1001, "gw-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("track submit resp: %v", err)
|
||||||
|
}
|
||||||
|
if resp.GatewayMessageID != "gw-1" {
|
||||||
|
t.Fatalf("unexpected gateway message id: %+v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
byGateway, err := tr.ByGatewayMessageID("gw-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup by gateway id: %v", err)
|
||||||
|
}
|
||||||
|
if byGateway.MessageID != "msg-1" || byGateway.SequenceID != 1001 {
|
||||||
|
t.Fatalf("unexpected gateway lookup: %+v", byGateway)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackerRejectsUnknownSubmitResp(t *testing.T) {
|
||||||
|
tr := New()
|
||||||
|
|
||||||
|
if _, err := tr.TrackSubmitResp(404, "gw-missing"); err == nil {
|
||||||
|
t.Fatal("expected missing mapping error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: cmpp-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: cmpp_platform
|
||||||
|
POSTGRES_USER: cmpp
|
||||||
|
POSTGRES_PASSWORD: cmpp_password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U cmpp -d cmpp_platform"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: cmpp-redis
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||||
|
container_name: cmpp-minio
|
||||||
|
command: ["server", "/data", "--console-address", ":9001"]
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: cmpp_minio
|
||||||
|
MINIO_ROOT_PASSWORD: cmpp_minio_password
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
minio_data:
|
||||||
Generated
+334
@@ -9,8 +9,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"bullmq": "^5.79.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
|
"ioredis": "^5.11.1",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -77,6 +79,90 @@
|
|||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@@ -442,6 +528,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "5.79.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.79.2.tgz",
|
||||||
|
"integrity": "sha512-FebD+8XCZl/hnS1R4to24L4EAN70XSndKZO0776M36vGRk5MKVhKlNFM8/34zXLXKyYB4QaeIPFhVXSYYGTHpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "4.9.0",
|
||||||
|
"ioredis": "5.10.1",
|
||||||
|
"msgpackr": "2.0.4",
|
||||||
|
"node-abort-controller": "3.1.1",
|
||||||
|
"semver": "7.8.5",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"redis": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"redis": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/ioredis": {
|
||||||
|
"version": "5.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||||
|
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "1.5.1",
|
||||||
|
"cluster-key-slot": "^1.1.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.isarguments": "^3.1.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0",
|
||||||
|
"standard-as-callback": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
@@ -455,6 +611,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -467,6 +635,32 @@
|
|||||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -517,6 +711,28 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "5.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
|
||||||
|
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "1.10.0",
|
||||||
|
"cluster-key-slot": "1.1.1",
|
||||||
|
"debug": "4.4.3",
|
||||||
|
"denque": "2.1.0",
|
||||||
|
"redis-errors": "1.2.0",
|
||||||
|
"redis-parser": "3.0.0",
|
||||||
|
"standard-as-callback": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -778,6 +994,18 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isarguments": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.18.0",
|
"version": "1.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
|
||||||
@@ -787,6 +1015,52 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
@@ -805,6 +1079,27 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abort-controller": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -910,6 +1205,27 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||||
@@ -949,6 +1265,18 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
@@ -964,6 +1292,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|||||||
+13
-1
@@ -6,12 +6,24 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"build:api": "npm --prefix api run build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"prisma:generate": "npm --prefix api run prisma:generate",
|
||||||
|
"spike:contracts": "node tools/spike/validate-gateway-queue-contract.mjs",
|
||||||
|
"spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"",
|
||||||
|
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
|
||||||
|
"verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build",
|
||||||
|
"verify:phase2": "npm run verify:phase1",
|
||||||
|
"verify:phase3": "npm run verify:phase2",
|
||||||
|
"verify:phase4": "npm run verify:phase3",
|
||||||
|
"verify:phase5": "npm run verify:phase4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"bullmq": "^5.79.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
|
"ioredis": "^5.11.1",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Save } from 'lucide-react';
|
import { Save } from 'lucide-react';
|
||||||
import { Button, Input, Select } from '@/components/ui';
|
import { Button, Input } from '@/components/ui';
|
||||||
|
|
||||||
export function ClientSettingsPage() {
|
export function ClientSettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -17,15 +17,6 @@ export function ClientSettingsPage() {
|
|||||||
<Input label="联系人" defaultValue="赵先生" />
|
<Input label="联系人" defaultValue="赵先生" />
|
||||||
<Input label="联系电话" defaultValue="13800000000" />
|
<Input label="联系电话" defaultValue="13800000000" />
|
||||||
</div>
|
</div>
|
||||||
<Select
|
|
||||||
label="默认发送通道"
|
|
||||||
defaultValue="east"
|
|
||||||
options={[
|
|
||||||
{ label: '华东主通道', value: 'east' },
|
|
||||||
{ label: '华南通道', value: 'south' },
|
|
||||||
{ label: '备用通道', value: 'backup' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Button icon={<Save size={16} />}>保存设置</Button>
|
<Button icon={<Save size={16} />}>保存设置</Button>
|
||||||
</div>
|
</div>
|
||||||
<aside className="soft-panel">
|
<aside className="soft-panel">
|
||||||
|
|||||||
@@ -57,9 +57,8 @@ export function ClientLayout() {
|
|||||||
{
|
{
|
||||||
title: '账户',
|
title: '账户',
|
||||||
items: [
|
items: [
|
||||||
{ label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign, pending: true },
|
{ label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign },
|
||||||
{ label: '账单流水', to: '/client/invoices', icon: ReceiptText, pending: true },
|
{ label: '账单流水', to: '/client/invoices', icon: ReceiptText },
|
||||||
{ label: '账号设置', to: '/client/settings', icon: Settings, pending: true },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,6 +66,7 @@ export function ClientLayout() {
|
|||||||
items: [
|
items: [
|
||||||
{ label: '企业认证', to: '/client/enterprise-auth', icon: ShieldCheck },
|
{ label: '企业认证', to: '/client/enterprise-auth', icon: ShieldCheck },
|
||||||
{ label: '用户管理', to: '/client/users', icon: Users },
|
{ label: '用户管理', to: '/client/users', icon: Users },
|
||||||
|
{ label: '账号设置', to: '/client/settings', icon: Settings },
|
||||||
{ label: '系统日志', to: '/client/system-logs', icon: FileText },
|
{ label: '系统日志', to: '/client/system-logs', icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const examplesDir = join(process.cwd(), 'docs', 'contracts', 'examples');
|
||||||
|
|
||||||
|
const envelopeFields = ['schemaVersion', 'messageType', 'traceId', 'messageId', 'channelId', 'createdAt'];
|
||||||
|
const requiredByType = {
|
||||||
|
SubmitCommand: [
|
||||||
|
...envelopeFields,
|
||||||
|
'tenantId',
|
||||||
|
'applicationId',
|
||||||
|
'submitId',
|
||||||
|
'phoneNumber',
|
||||||
|
'content',
|
||||||
|
'signature',
|
||||||
|
'templateId',
|
||||||
|
'billingUnits',
|
||||||
|
'route',
|
||||||
|
'cmpp',
|
||||||
|
'retry',
|
||||||
|
],
|
||||||
|
SubmitResult: [...envelopeFields, 'sequenceId', 'gatewayMessageId', 'submitStatus', 'submittedAt'],
|
||||||
|
ReceiptEvent: [...envelopeFields, 'sequenceId', 'gatewayMessageId', 'receiptStatus', 'rawStatus', 'deliveredAt'],
|
||||||
|
UplinkEvent: [...envelopeFields, 'sequenceId', 'phoneNumber', 'destId', 'content', 'receivedAt'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const validTypes = new Set(Object.keys(requiredByType));
|
||||||
|
const chinaMobileNumber = /^1[3-9][0-9]{9}$/;
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIsoDateTime(value, field, file) {
|
||||||
|
assert(typeof value === 'string' && !Number.isNaN(Date.parse(value)), `${file}: ${field} must be ISO date-time`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExample(fileName) {
|
||||||
|
const fullPath = join(examplesDir, fileName);
|
||||||
|
const payload = JSON.parse(readFileSync(fullPath, 'utf8'));
|
||||||
|
const type = payload.messageType;
|
||||||
|
|
||||||
|
assert(validTypes.has(type), `${fileName}: unsupported messageType ${type}`);
|
||||||
|
assert(payload.schemaVersion === 'v1', `${fileName}: schemaVersion must be v1`);
|
||||||
|
|
||||||
|
for (const field of requiredByType[type]) {
|
||||||
|
assert(Object.hasOwn(payload, field), `${fileName}: missing ${field}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dateField of ['createdAt', 'submittedAt', 'deliveredAt', 'receivedAt']) {
|
||||||
|
if (payload[dateField]) {
|
||||||
|
assertIsoDateTime(payload[dateField], dateField, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.phoneNumber) {
|
||||||
|
assert(chinaMobileNumber.test(payload.phoneNumber), `${fileName}: phoneNumber must be a mainland China mobile number`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'SubmitCommand') {
|
||||||
|
assert(payload.billingUnits >= 1, `${fileName}: billingUnits must be >= 1`);
|
||||||
|
assert(payload.route?.channelCode, `${fileName}: route.channelCode is required`);
|
||||||
|
assert(payload.route?.cmppAccountCode, `${fileName}: route.cmppAccountCode is required`);
|
||||||
|
assert([0, 1].includes(payload.cmpp?.registeredDelivery), `${fileName}: cmpp.registeredDelivery must be 0 or 1`);
|
||||||
|
assert([8, 15].includes(payload.cmpp?.msgFmt), `${fileName}: cmpp.msgFmt must be 8 or 15`);
|
||||||
|
assert(payload.retry?.maxAttempts >= 1, `${fileName}: retry.maxAttempts must be >= 1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${fileName}: ${type} ok`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exampleFiles = readdirSync(examplesDir).filter((file) => file.endsWith('.json')).sort();
|
||||||
|
assert(exampleFiles.length > 0, 'No queue contract examples found');
|
||||||
|
|
||||||
|
const results = exampleFiles.map(validateExample);
|
||||||
|
console.log(results.join('\n'));
|
||||||
|
console.log(`Validated ${results.length} gateway queue contract examples.`);
|
||||||
Reference in New Issue
Block a user