feat: add sms send chain phase
This commit is contained in:
@@ -29,6 +29,12 @@ model Tenant {
|
|||||||
riskRules RiskRule[]
|
riskRules RiskRule[]
|
||||||
smsSendTasks SmsSendTask[]
|
smsSendTasks SmsSendTask[]
|
||||||
riskHitRecords RiskHitRecord[]
|
riskHitRecords RiskHitRecord[]
|
||||||
|
smsBatchTasks SmsBatchTask[]
|
||||||
|
smsMessageRecords SmsMessageRecord[]
|
||||||
|
smsApiRequests SmsApiRequest[]
|
||||||
|
smsSubmitRecords SmsSubmitRecord[]
|
||||||
|
smsReceiptRecords SmsReceiptRecord[]
|
||||||
|
smsUplinkMessages SmsUplinkMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -47,6 +53,7 @@ model User {
|
|||||||
auditRecords AuditRecord[]
|
auditRecords AuditRecord[]
|
||||||
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
||||||
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
||||||
|
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
@@ -299,6 +306,8 @@ model SmsApplication {
|
|||||||
signatures SmsSignature[]
|
signatures SmsSignature[]
|
||||||
templates SmsTemplate[]
|
templates SmsTemplate[]
|
||||||
sendTasks SmsSendTask[]
|
sendTasks SmsSendTask[]
|
||||||
|
batchTasks SmsBatchTask[]
|
||||||
|
messageRecords SmsMessageRecord[]
|
||||||
|
|
||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
}
|
}
|
||||||
@@ -370,6 +379,8 @@ model SmsTemplate {
|
|||||||
signature SmsSignature? @relation(fields: [signatureId], references: [id])
|
signature SmsSignature? @relation(fields: [signatureId], references: [id])
|
||||||
variables TemplateVariable[]
|
variables TemplateVariable[]
|
||||||
sendTasks SmsSendTask[]
|
sendTasks SmsSendTask[]
|
||||||
|
batchTasks SmsBatchTask[]
|
||||||
|
messageRecords SmsMessageRecord[]
|
||||||
|
|
||||||
@@index([tenantId, auditStatus])
|
@@index([tenantId, auditStatus])
|
||||||
@@index([applicationId])
|
@@index([applicationId])
|
||||||
@@ -433,6 +444,11 @@ model SmsChannel {
|
|||||||
reportFields ChannelReportField[]
|
reportFields ChannelReportField[]
|
||||||
reportTasks ChannelSignatureReportTask[]
|
reportTasks ChannelSignatureReportTask[]
|
||||||
reportRecords ChannelSignatureReportRecord[]
|
reportRecords ChannelSignatureReportRecord[]
|
||||||
|
messageRecords SmsMessageRecord[]
|
||||||
|
submitSessions CmppSubmitSession[]
|
||||||
|
submitRecords SmsSubmitRecord[]
|
||||||
|
receiptRecords SmsReceiptRecord[]
|
||||||
|
uplinkMessages SmsUplinkMessage[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -685,3 +701,189 @@ model RiskHitRecord {
|
|||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
@@index([ruleCode])
|
@@index([ruleCode])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SmsBatchTask {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
applicationId String?
|
||||||
|
templateId String?
|
||||||
|
taskNo String @unique
|
||||||
|
sourceType String @default("client")
|
||||||
|
content String
|
||||||
|
category String?
|
||||||
|
phoneTotal Int
|
||||||
|
status String @default("created")
|
||||||
|
riskTaskId String?
|
||||||
|
auditStatus String @default("approved")
|
||||||
|
reviewReason String?
|
||||||
|
rejectReason String?
|
||||||
|
progressTotal Int @default(0)
|
||||||
|
submittedTotal Int @default(0)
|
||||||
|
successTotal Int @default(0)
|
||||||
|
failedTotal Int @default(0)
|
||||||
|
unknownTotal Int @default(0)
|
||||||
|
timeoutTotal Int @default(0)
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||||
|
template SmsTemplate? @relation(fields: [templateId], references: [id])
|
||||||
|
createdBy User? @relation("SmsBatchTaskCreator", fields: [createdById], references: [id])
|
||||||
|
apiRequests SmsApiRequest[]
|
||||||
|
messages SmsMessageRecord[]
|
||||||
|
submitRecords SmsSubmitRecord[]
|
||||||
|
receiptRecords SmsReceiptRecord[]
|
||||||
|
|
||||||
|
@@index([tenantId, status, createdAt])
|
||||||
|
@@index([applicationId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsApiRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
batchTaskId String
|
||||||
|
requestId String @unique
|
||||||
|
sourceIp String?
|
||||||
|
userAgent String?
|
||||||
|
payloadSummary Json?
|
||||||
|
status String @default("accepted")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
batchTask SmsBatchTask @relation(fields: [batchTaskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([batchTaskId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsMessageRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
batchTaskId String
|
||||||
|
applicationId String?
|
||||||
|
templateId String?
|
||||||
|
messageId String @unique
|
||||||
|
phoneNumber String
|
||||||
|
content String
|
||||||
|
billingUnits Int @default(1)
|
||||||
|
unitPrice Int @default(0)
|
||||||
|
amountCents Int @default(0)
|
||||||
|
channelId String?
|
||||||
|
submitId String?
|
||||||
|
gatewayMessageId String?
|
||||||
|
status String @default("queued")
|
||||||
|
submitStatus String?
|
||||||
|
receiptStatus String?
|
||||||
|
errorCode String?
|
||||||
|
errorMessage String?
|
||||||
|
queuedAt DateTime @default(now())
|
||||||
|
submittedAt DateTime?
|
||||||
|
deliveredAt DateTime?
|
||||||
|
timeoutAt DateTime?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
batchTask SmsBatchTask @relation(fields: [batchTaskId], references: [id], onDelete: Cascade)
|
||||||
|
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||||
|
template SmsTemplate? @relation(fields: [templateId], references: [id])
|
||||||
|
channel SmsChannel? @relation(fields: [channelId], references: [id])
|
||||||
|
submitRecords SmsSubmitRecord[]
|
||||||
|
receiptRecords SmsReceiptRecord[]
|
||||||
|
|
||||||
|
@@index([tenantId, status, queuedAt])
|
||||||
|
@@index([batchTaskId, status])
|
||||||
|
@@index([phoneNumber])
|
||||||
|
@@index([gatewayMessageId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CmppSubmitSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
channelId String
|
||||||
|
sessionNo String @unique
|
||||||
|
status String @default("open")
|
||||||
|
submitTotal Int @default(0)
|
||||||
|
acceptedTotal Int @default(0)
|
||||||
|
rejectedTotal Int @default(0)
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
endedAt DateTime?
|
||||||
|
|
||||||
|
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||||
|
submitRecords SmsSubmitRecord[]
|
||||||
|
|
||||||
|
@@index([channelId, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsSubmitRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
batchTaskId String
|
||||||
|
messageRecordId String
|
||||||
|
channelId String
|
||||||
|
sessionId String?
|
||||||
|
submitId String @unique
|
||||||
|
sequenceId Int?
|
||||||
|
gatewayMessageId String?
|
||||||
|
submitStatus String @default("queued")
|
||||||
|
errorCode String?
|
||||||
|
errorMessage String?
|
||||||
|
submittedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
batchTask SmsBatchTask @relation(fields: [batchTaskId], references: [id], onDelete: Cascade)
|
||||||
|
messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id], onDelete: Cascade)
|
||||||
|
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||||
|
session CmppSubmitSession? @relation(fields: [sessionId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([messageRecordId])
|
||||||
|
@@index([gatewayMessageId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsReceiptRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
batchTaskId String?
|
||||||
|
messageRecordId String?
|
||||||
|
channelId String?
|
||||||
|
messageId String
|
||||||
|
gatewayMessageId String
|
||||||
|
sequenceId Int?
|
||||||
|
receiptStatus String
|
||||||
|
rawStatus String
|
||||||
|
errorCode String?
|
||||||
|
deliveredAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id])
|
||||||
|
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
||||||
|
channel SmsChannel? @relation(fields: [channelId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([messageId])
|
||||||
|
@@index([gatewayMessageId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsUplinkMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String?
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
sequenceId Int?
|
||||||
|
phoneNumber String
|
||||||
|
destId String
|
||||||
|
content String
|
||||||
|
receivedAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
|
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([channelId, receivedAt])
|
||||||
|
@@index([phoneNumber])
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FilesModule } from './files/files.module';
|
|||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { RiskReviewModule } from './risk-review/risk-review.module';
|
import { RiskReviewModule } from './risk-review/risk-review.module';
|
||||||
|
import { SendChainModule } from './send-chain/send-chain.module';
|
||||||
import { SmsConfigModule } from './sms-config/sms-config.module';
|
import { SmsConfigModule } from './sms-config/sms-config.module';
|
||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
@@ -30,6 +31,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
SmsConfigModule,
|
SmsConfigModule,
|
||||||
ChannelsModule,
|
ChannelsModule,
|
||||||
RiskReviewModule,
|
RiskReviewModule,
|
||||||
|
SendChainModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { SendChainService, TimeoutUnknownDto } from './send-chain.service';
|
||||||
|
|
||||||
|
@ApiTags('send-chain')
|
||||||
|
@Controller('admin/send')
|
||||||
|
export class AdminSendChainController {
|
||||||
|
constructor(private readonly sendChain: SendChainService) {}
|
||||||
|
|
||||||
|
@Get('batch-tasks')
|
||||||
|
listBatchTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||||
|
return this.sendChain.listBatchTasks(tenantId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('messages')
|
||||||
|
listMessages(@Query('taskId') taskId?: string, @Query('phoneNumber') phoneNumber?: string) {
|
||||||
|
return this.sendChain.listMessages(taskId, phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('submit-records')
|
||||||
|
listSubmitRecords(@Query('taskId') taskId?: string) {
|
||||||
|
return this.sendChain.listSubmitRecords(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('receipt-records')
|
||||||
|
listReceiptRecords(@Query('taskId') taskId?: string) {
|
||||||
|
return this.sendChain.listReceiptRecords(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('uplink-messages')
|
||||||
|
listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) {
|
||||||
|
return this.sendChain.listUplinkMessages(tenantId, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch-tasks/:id/enqueue')
|
||||||
|
enqueueTask(@Param('id') taskId: string) {
|
||||||
|
return this.sendChain.enqueueBatchTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('timeouts/mark-unknown')
|
||||||
|
markUnknownTimeout(@Body() body: TimeoutUnknownDto) {
|
||||||
|
return this.sendChain.markUnknownTimeout(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { TenantId } from '../common/tenant-id.decorator';
|
||||||
|
import { CreateBatchTaskDto, SendChainService } from './send-chain.service';
|
||||||
|
|
||||||
|
@ApiTags('client-send-chain')
|
||||||
|
@Controller('client/send')
|
||||||
|
export class ClientSendChainController {
|
||||||
|
constructor(private readonly sendChain: SendChainService) {}
|
||||||
|
|
||||||
|
@Post('batch-tasks')
|
||||||
|
createBatchTask(@Body() body: CreateBatchTaskDto) {
|
||||||
|
return this.sendChain.createBatchTask(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('batch-tasks')
|
||||||
|
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
||||||
|
return this.sendChain.listBatchTasks(tenantId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('batch-tasks/:id')
|
||||||
|
getBatchTask(@Param('id') taskId: string) {
|
||||||
|
return this.sendChain.getBatchTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('batch-tasks/:id/messages')
|
||||||
|
listTaskMessages(@Param('id') taskId: string) {
|
||||||
|
return this.sendChain.listMessages(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
GatewayReceiptEventDto,
|
||||||
|
GatewaySubmitResultDto,
|
||||||
|
GatewayUplinkEventDto,
|
||||||
|
SendChainService,
|
||||||
|
} from './send-chain.service';
|
||||||
|
|
||||||
|
@ApiTags('gateway-events')
|
||||||
|
@Controller('gateway/events')
|
||||||
|
export class GatewayEventsController {
|
||||||
|
constructor(private readonly sendChain: SendChainService) {}
|
||||||
|
|
||||||
|
@Post('submit-result')
|
||||||
|
submitResult(@Body() body: GatewaySubmitResultDto) {
|
||||||
|
return this.sendChain.handleSubmitResult(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('receipt')
|
||||||
|
receipt(@Body() body: GatewayReceiptEventDto) {
|
||||||
|
return this.sendChain.handleReceipt(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('uplink')
|
||||||
|
uplink(@Body() body: GatewayUplinkEventDto) {
|
||||||
|
return this.sendChain.handleUplink(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { RiskReviewModule } from '../risk-review/risk-review.module';
|
||||||
|
import { AdminSendChainController } from './admin-send-chain.controller';
|
||||||
|
import { ClientSendChainController } from './client-send-chain.controller';
|
||||||
|
import { GatewayEventsController } from './gateway-events.controller';
|
||||||
|
import { SendChainService } from './send-chain.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, BillingModule, RiskReviewModule],
|
||||||
|
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
|
||||||
|
providers: [SendChainService],
|
||||||
|
exports: [SendChainService],
|
||||||
|
})
|
||||||
|
export class SendChainModule {}
|
||||||
|
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
import { Injectable, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Queue, Worker } from 'bullmq';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||||
|
|
||||||
|
export interface CreateBatchTaskDto {
|
||||||
|
tenantId: string;
|
||||||
|
applicationId?: string;
|
||||||
|
templateId?: string;
|
||||||
|
content: string;
|
||||||
|
category?: string;
|
||||||
|
phones: string[];
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
createdById?: string;
|
||||||
|
sourceIp?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewaySubmitResultDto {
|
||||||
|
traceId?: string;
|
||||||
|
messageId: string;
|
||||||
|
channelId: string;
|
||||||
|
submitId?: string;
|
||||||
|
sequenceId?: number;
|
||||||
|
gatewayMessageId: string;
|
||||||
|
submitStatus: 'accepted' | 'rejected' | 'timeout';
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
submittedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayReceiptEventDto {
|
||||||
|
traceId?: string;
|
||||||
|
messageId: string;
|
||||||
|
channelId: string;
|
||||||
|
sequenceId?: number;
|
||||||
|
gatewayMessageId: string;
|
||||||
|
receiptStatus: 'delivered' | 'undelivered' | 'unknown';
|
||||||
|
rawStatus: string;
|
||||||
|
errorCode?: string;
|
||||||
|
deliveredAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayUplinkEventDto {
|
||||||
|
traceId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
channelId: string;
|
||||||
|
sequenceId?: number;
|
||||||
|
phoneNumber: string;
|
||||||
|
destId: string;
|
||||||
|
content: string;
|
||||||
|
receivedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeoutUnknownDto {
|
||||||
|
olderThanHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendJob {
|
||||||
|
messageRecordId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEND_QUEUE = 'sms.send.queue';
|
||||||
|
const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private redis?: IORedis;
|
||||||
|
private sendQueue?: Queue<SendJob, unknown, 'send-message'>;
|
||||||
|
private gatewayQueue?: Queue;
|
||||||
|
private worker?: Worker<SendJob>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly riskReview: RiskReviewService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
|
||||||
|
this.startWorker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.worker?.close();
|
||||||
|
await this.sendQueue?.close();
|
||||||
|
await this.gatewayQueue?.close();
|
||||||
|
this.redis?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBatchTask(data: CreateBatchTaskDto) {
|
||||||
|
const phones = [...new Set(data.phones ?? [])];
|
||||||
|
const risk = await this.riskReview.evaluateTask({
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
applicationId: data.applicationId,
|
||||||
|
templateId: data.templateId,
|
||||||
|
content: data.content,
|
||||||
|
category: data.category,
|
||||||
|
phones,
|
||||||
|
variables: data.variables,
|
||||||
|
createdById: data.createdById,
|
||||||
|
});
|
||||||
|
const billing = this.billing.estimateSmsCost({
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
applicationId: data.applicationId,
|
||||||
|
taskId: risk.task?.id,
|
||||||
|
content: data.content,
|
||||||
|
phoneCount: phones.length,
|
||||||
|
});
|
||||||
|
const batchStatus = statusFromRisk(risk.status);
|
||||||
|
const task = await this.prisma.smsBatchTask.create({
|
||||||
|
data: {
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
applicationId: data.applicationId,
|
||||||
|
templateId: data.templateId,
|
||||||
|
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||||
|
sourceType: 'client',
|
||||||
|
content: data.content,
|
||||||
|
category: data.category,
|
||||||
|
phoneTotal: phones.length,
|
||||||
|
status: batchStatus,
|
||||||
|
riskTaskId: risk.task?.id,
|
||||||
|
auditStatus: risk.status === 'pending_review' ? 'pending' : risk.status === 'rejected' ? 'rejected' : 'approved',
|
||||||
|
reviewReason: risk.status === 'pending_review' ? risk.reason : null,
|
||||||
|
rejectReason: risk.status === 'rejected' ? risk.reason : null,
|
||||||
|
progressTotal: phones.length,
|
||||||
|
createdById: data.createdById,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.smsApiRequest.create({
|
||||||
|
data: {
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
batchTaskId: task.id,
|
||||||
|
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||||
|
sourceIp: data.sourceIp,
|
||||||
|
userAgent: data.userAgent,
|
||||||
|
payloadSummary: {
|
||||||
|
phoneTotal: phones.length,
|
||||||
|
contentLength: [...data.content].length,
|
||||||
|
category: data.category,
|
||||||
|
},
|
||||||
|
status: batchStatus === 'rejected' ? 'rejected' : 'accepted',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (phones.length > 0) {
|
||||||
|
await this.prisma.smsMessageRecord.createMany({
|
||||||
|
data: phones.map((phone) => ({
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
batchTaskId: task.id,
|
||||||
|
applicationId: data.applicationId,
|
||||||
|
templateId: data.templateId,
|
||||||
|
messageId: `MSG-${randomUUID()}`,
|
||||||
|
phoneNumber: phone,
|
||||||
|
content: data.content,
|
||||||
|
billingUnits: billing.billingUnitsPerMessage,
|
||||||
|
unitPrice: billing.unitPrice,
|
||||||
|
amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
|
||||||
|
status: batchStatus === 'ready' ? 'queued' : batchStatus,
|
||||||
|
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (batchStatus === 'ready') {
|
||||||
|
await this.enqueueBatchTask(task.id);
|
||||||
|
}
|
||||||
|
return this.getBatchTask(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
listBatchTasks(tenantId?: string, status?: string) {
|
||||||
|
return this.prisma.smsBatchTask.findMany({
|
||||||
|
where: { tenantId, status },
|
||||||
|
include: { apiRequests: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getBatchTask(taskId: string) {
|
||||||
|
return this.prisma.smsBatchTask.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listMessages(taskId?: string, phoneNumber?: string) {
|
||||||
|
return this.prisma.smsMessageRecord.findMany({
|
||||||
|
where: { batchTaskId: taskId, phoneNumber },
|
||||||
|
orderBy: { queuedAt: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listSubmitRecords(taskId?: string) {
|
||||||
|
return this.prisma.smsSubmitRecord.findMany({
|
||||||
|
where: { batchTaskId: taskId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listReceiptRecords(taskId?: string) {
|
||||||
|
return this.prisma.smsReceiptRecord.findMany({
|
||||||
|
where: { batchTaskId: taskId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listUplinkMessages(tenantId?: string, channelId?: string) {
|
||||||
|
return this.prisma.smsUplinkMessage.findMany({
|
||||||
|
where: { tenantId, channelId },
|
||||||
|
orderBy: { receivedAt: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueueBatchTask(taskId: string) {
|
||||||
|
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException('SMS batch task not found');
|
||||||
|
}
|
||||||
|
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||||
|
where: { batchTaskId: taskId, status: 'queued' },
|
||||||
|
select: { id: true },
|
||||||
|
take: 100000,
|
||||||
|
});
|
||||||
|
const queue = this.getSendQueue();
|
||||||
|
for (const message of messages) {
|
||||||
|
await queue.add('send-message', { messageRecordId: message.id }, { jobId: message.id, attempts: 3 });
|
||||||
|
}
|
||||||
|
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
|
||||||
|
return { taskId, enqueued: messages.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
startWorker() {
|
||||||
|
if (this.worker) {
|
||||||
|
return { status: 'already_started' };
|
||||||
|
}
|
||||||
|
const connection = bullmqConnection();
|
||||||
|
this.worker = new Worker<SendJob>(
|
||||||
|
SEND_QUEUE,
|
||||||
|
async (job) => this.processSendJob(job.data),
|
||||||
|
{ connection, concurrency: Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20) },
|
||||||
|
);
|
||||||
|
return { status: 'started' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async processSendJob(job: SendJob) {
|
||||||
|
const message = await this.prisma.smsMessageRecord.findUnique({
|
||||||
|
where: { id: job.messageRecordId },
|
||||||
|
include: { batchTask: true, template: { include: { signature: true } } },
|
||||||
|
});
|
||||||
|
if (!message || message.status !== 'queued') {
|
||||||
|
return { skipped: true };
|
||||||
|
}
|
||||||
|
const channel = await this.selectChannel(message.tenantId, message.applicationId ?? undefined);
|
||||||
|
await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
||||||
|
const submitId = `SUB-${randomUUID()}`;
|
||||||
|
const session = await this.prisma.cmppSubmitSession.upsert({
|
||||||
|
where: { sessionNo: `OPEN-${channel.id}` },
|
||||||
|
update: { submitTotal: { increment: 1 } },
|
||||||
|
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
|
||||||
|
});
|
||||||
|
await this.prisma.smsSubmitRecord.create({
|
||||||
|
data: {
|
||||||
|
tenantId: message.tenantId,
|
||||||
|
batchTaskId: message.batchTaskId,
|
||||||
|
messageRecordId: message.id,
|
||||||
|
channelId: channel.id,
|
||||||
|
sessionId: session.id,
|
||||||
|
submitId,
|
||||||
|
submitStatus: 'queued',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.smsMessageRecord.update({
|
||||||
|
where: { id: message.id },
|
||||||
|
data: { channelId: channel.id, submitId, status: 'submit_queued', submitStatus: 'queued' },
|
||||||
|
});
|
||||||
|
await this.getGatewayQueue().add('submit-command', {
|
||||||
|
schemaVersion: 'v1',
|
||||||
|
messageType: 'SubmitCommand',
|
||||||
|
traceId: randomUUID(),
|
||||||
|
messageId: message.messageId,
|
||||||
|
channelId: channel.id,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
tenantId: message.tenantId,
|
||||||
|
applicationId: message.applicationId ?? 'unknown',
|
||||||
|
taskId: message.batchTaskId,
|
||||||
|
submitId,
|
||||||
|
phoneNumber: message.phoneNumber,
|
||||||
|
content: message.content,
|
||||||
|
signature: message.template?.signature?.name ?? 'SMS',
|
||||||
|
templateId: message.templateId ?? 'unknown',
|
||||||
|
billingUnits: message.billingUnits,
|
||||||
|
route: {
|
||||||
|
channelCode: channel.code,
|
||||||
|
cmppAccountCode: channel.account,
|
||||||
|
priority: 0,
|
||||||
|
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||||
|
},
|
||||||
|
cmpp: {
|
||||||
|
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
|
||||||
|
? String(channel.config.serviceId)
|
||||||
|
: 'SMS',
|
||||||
|
srcId: channel.srcId,
|
||||||
|
registeredDelivery: 1,
|
||||||
|
msgFmt: 8,
|
||||||
|
},
|
||||||
|
retry: { attempt: 0, maxAttempts: 3 },
|
||||||
|
});
|
||||||
|
await this.refreshTaskProgress(message.batchTaskId);
|
||||||
|
return { submitted: true, messageRecordId: message.id, channelId: channel.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||||
|
const message = await this.findMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||||
|
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||||
|
await this.prisma.smsSubmitRecord.updateMany({
|
||||||
|
where: { OR: [{ submitId: data.submitId }, { messageRecordId: message.id }] },
|
||||||
|
data: {
|
||||||
|
sequenceId: data.sequenceId,
|
||||||
|
gatewayMessageId: data.gatewayMessageId,
|
||||||
|
submitStatus: data.submitStatus,
|
||||||
|
errorCode: data.errorCode,
|
||||||
|
errorMessage: data.errorMessage,
|
||||||
|
submittedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||||
|
await this.prisma.smsMessageRecord.update({
|
||||||
|
where: { id: message.id },
|
||||||
|
data: {
|
||||||
|
gatewayMessageId: data.gatewayMessageId,
|
||||||
|
submitStatus: data.submitStatus,
|
||||||
|
status,
|
||||||
|
errorCode: data.errorCode,
|
||||||
|
errorMessage: data.errorMessage,
|
||||||
|
submittedAt,
|
||||||
|
timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.refreshTaskProgress(message.batchTaskId);
|
||||||
|
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleReceipt(data: GatewayReceiptEventDto) {
|
||||||
|
const message = await this.findMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||||
|
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||||
|
const status =
|
||||||
|
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
|
||||||
|
await this.prisma.smsReceiptRecord.create({
|
||||||
|
data: {
|
||||||
|
tenantId: message.tenantId,
|
||||||
|
batchTaskId: message.batchTaskId,
|
||||||
|
messageRecordId: message.id,
|
||||||
|
channelId: data.channelId,
|
||||||
|
messageId: data.messageId,
|
||||||
|
gatewayMessageId: data.gatewayMessageId,
|
||||||
|
sequenceId: data.sequenceId,
|
||||||
|
receiptStatus: data.receiptStatus,
|
||||||
|
rawStatus: data.rawStatus,
|
||||||
|
errorCode: data.errorCode,
|
||||||
|
deliveredAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.smsMessageRecord.update({
|
||||||
|
where: { id: message.id },
|
||||||
|
data: {
|
||||||
|
receiptStatus: data.receiptStatus,
|
||||||
|
status,
|
||||||
|
errorCode: data.errorCode,
|
||||||
|
deliveredAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.refreshTaskProgress(message.batchTaskId);
|
||||||
|
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUplink(data: GatewayUplinkEventDto) {
|
||||||
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||||
|
if (!channel) {
|
||||||
|
throw new NotFoundException('SMS channel not found');
|
||||||
|
}
|
||||||
|
const message = data.messageId
|
||||||
|
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
|
||||||
|
: null;
|
||||||
|
return this.prisma.smsUplinkMessage.create({
|
||||||
|
data: {
|
||||||
|
tenantId: message?.tenantId,
|
||||||
|
channelId: data.channelId,
|
||||||
|
messageId: data.messageId,
|
||||||
|
sequenceId: data.sequenceId,
|
||||||
|
phoneNumber: data.phoneNumber,
|
||||||
|
destId: data.destId,
|
||||||
|
content: data.content,
|
||||||
|
receivedAt: data.receivedAt ? new Date(data.receivedAt) : new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||||
|
const olderThanHours = data.olderThanHours ?? 72;
|
||||||
|
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
|
||||||
|
const candidates = await this.prisma.smsMessageRecord.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'unknown',
|
||||||
|
deliveredAt: { lte: cutoff },
|
||||||
|
},
|
||||||
|
select: { id: true, batchTaskId: true },
|
||||||
|
take: 10000,
|
||||||
|
});
|
||||||
|
await this.prisma.smsMessageRecord.updateMany({
|
||||||
|
where: { id: { in: candidates.map((candidate) => candidate.id) } },
|
||||||
|
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: '72小时未收到明确回执,自动转超时' },
|
||||||
|
});
|
||||||
|
for (const batchTaskId of new Set(candidates.map((candidate) => candidate.batchTaskId))) {
|
||||||
|
await this.refreshTaskProgress(batchTaskId);
|
||||||
|
}
|
||||||
|
return { timeout: candidates.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectChannel(tenantId: string, applicationId?: string) {
|
||||||
|
const route = await this.prisma.channelRouteRule.findFirst({
|
||||||
|
where: {
|
||||||
|
status: 'active',
|
||||||
|
OR: [{ tenantId, applicationId }, { tenantId, applicationId: null }, { tenantId: null, applicationId: null }],
|
||||||
|
},
|
||||||
|
include: { channel: true, group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
|
||||||
|
orderBy: { priority: 'asc' },
|
||||||
|
});
|
||||||
|
const routedChannel = route?.channel ?? route?.group.items.find((item) => item.channel.status === 'active')?.channel;
|
||||||
|
if (routedChannel) {
|
||||||
|
return routedChannel;
|
||||||
|
}
|
||||||
|
const channel = await this.prisma.smsChannel.findFirst({ where: { status: 'active' }, orderBy: { createdAt: 'asc' } });
|
||||||
|
if (!channel) {
|
||||||
|
throw new NotFoundException('No active SMS channel available');
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||||
|
const redis = this.getRedis();
|
||||||
|
for (;;) {
|
||||||
|
const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`;
|
||||||
|
const count = await redis.incr(bucket);
|
||||||
|
if (count === 1) {
|
||||||
|
await redis.expire(bucket, 2);
|
||||||
|
}
|
||||||
|
if (count <= Math.max(1, tps)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshTaskProgress(batchTaskId: string) {
|
||||||
|
const groups = await this.prisma.smsMessageRecord.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
where: { batchTaskId },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const count = (statuses: string[]) =>
|
||||||
|
groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0);
|
||||||
|
const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0);
|
||||||
|
const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']);
|
||||||
|
const successTotal = count(['delivered']);
|
||||||
|
const failedTotal = count(['submit_failed', 'failed']);
|
||||||
|
const unknownTotal = count(['unknown']);
|
||||||
|
const timeoutTotal = count(['timeout']);
|
||||||
|
const doneTotal = successTotal + failedTotal + timeoutTotal;
|
||||||
|
const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued';
|
||||||
|
await this.prisma.smsBatchTask.update({
|
||||||
|
where: { id: batchTaskId },
|
||||||
|
data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMessageByGatewayEvent(messageId: string, gatewayMessageId?: string) {
|
||||||
|
const message = await this.prisma.smsMessageRecord.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(Boolean) as Array<{
|
||||||
|
messageId?: string;
|
||||||
|
gatewayMessageId?: string;
|
||||||
|
}>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!message) {
|
||||||
|
throw new NotFoundException('SMS message record not found');
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||||
|
if (!this.sendQueue) {
|
||||||
|
this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() });
|
||||||
|
}
|
||||||
|
return this.sendQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGatewayQueue(): Queue {
|
||||||
|
if (!this.gatewayQueue) {
|
||||||
|
this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
|
||||||
|
}
|
||||||
|
return this.gatewayQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRedis() {
|
||||||
|
if (!this.redis) {
|
||||||
|
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.redis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFromRisk(status: string) {
|
||||||
|
if (status === 'rejected') {
|
||||||
|
return 'rejected';
|
||||||
|
}
|
||||||
|
if (status === 'pending_review') {
|
||||||
|
return 'pending_review';
|
||||||
|
}
|
||||||
|
return 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bullmqConnection() {
|
||||||
|
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||||
|
return {
|
||||||
|
host: redisUrl.hostname,
|
||||||
|
port: Number(redisUrl.port || 6379),
|
||||||
|
username: redisUrl.username || undefined,
|
||||||
|
password: redisUrl.password || undefined,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
|||||||
|
# 阶段 7:发送链路实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
真实跑通短信发送链路的业务闭环:客户端创建批量任务后,平台按手机号拆分为短信记录,经过风控、计费、路由、限速和 Gateway 提交,最终支持 submit resp、deliver 回执、上行短信和超时补偿。
|
||||||
|
|
||||||
|
## 实施范围
|
||||||
|
|
||||||
|
1. 客户端批量任务创建。
|
||||||
|
- 批量任务只记录客户端提交批次。
|
||||||
|
- 每个手机号拆分为独立短信记录。
|
||||||
|
2. 号码导入和拆分。
|
||||||
|
- 支持请求体直接传入号码列表。
|
||||||
|
- 去重、非法号码、风控预审复用阶段 6。
|
||||||
|
3. 发送任务入队。
|
||||||
|
- 批量任务创建后写入发送队列。
|
||||||
|
- 队列消息按手机号维度处理。
|
||||||
|
4. Send Worker。
|
||||||
|
- 消费发送队列。
|
||||||
|
- 校验短信记录状态。
|
||||||
|
- 执行通道路由、Redis 限速、Gateway 提交命令。
|
||||||
|
5. 通道路由。
|
||||||
|
- 复用阶段 4 的通道、通道组、路由规则。
|
||||||
|
- 当前最小实现选择优先级最高的可用 CMPP 通道。
|
||||||
|
6. Redis 限速。
|
||||||
|
- 按通道使用 Redis 计数窗口限制 TPS。
|
||||||
|
7. Go Gateway 提交 CMPP。
|
||||||
|
- NestJS 将 `SubmitCommand` 投递到 Gateway 队列。
|
||||||
|
- Go Gateway 继续只负责 CMPP 连接和协议提交。
|
||||||
|
8. Submit Resp、回执、上行和超时。
|
||||||
|
- 提供 Gateway 事件回传接口。
|
||||||
|
- 更新短信记录、提交记录、回执记录和上行记录。
|
||||||
|
- 支持 72 小时未知状态转超时。
|
||||||
|
9. 任务进度统计。
|
||||||
|
- 按任务维护总量、已提交、成功、失败、未知、超时计数。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
1. 平台创建的批量任务只记录客户端任务。
|
||||||
|
2. 平台任务、API 调用、CMPP 对接发送全部按手机号维度进入短信记录。
|
||||||
|
3. 支持 submit resp、deliver 回执、上行短信、超时补偿。
|
||||||
|
4. `npm run verify:phase7` 通过。
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 阶段 7:发送链路进度记录
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
- 状态:进行中
|
||||||
|
- 开始时间:2026-07-01
|
||||||
|
|
||||||
|
## 计划步骤
|
||||||
|
|
||||||
|
1. 创建阶段 7 计划和验收标准。已完成。
|
||||||
|
2. 增加批量任务、手机号短信记录、API 批次、CMPP 提交会话、提交记录、回执记录、上行短信模型。已完成,`npm --prefix api run prisma:generate` 通过。
|
||||||
|
3. 实现客户端批量任务创建、号码拆分和入队。已完成,`npm --prefix api run build` 通过。
|
||||||
|
4. 实现 Send Worker、通道路由、Redis 限速和 Gateway SubmitCommand 投递。已完成,`npm --prefix api run build` 通过。
|
||||||
|
5. 实现 submit resp、回执、上行短信和 72 小时超时补偿。已完成,`npm --prefix api run build` 通过。
|
||||||
|
6. 实现任务进度统计查询。已完成,`npm --prefix api run build` 通过。
|
||||||
|
7. 运行 Prisma generate、API build、阶段验证脚本和 API health smoke。已完成。
|
||||||
|
|
||||||
|
## 验收记录
|
||||||
|
|
||||||
|
- 数据模型:
|
||||||
|
- `SmsBatchTask`:客户端批量任务,只记录客户端批次。
|
||||||
|
- `SmsMessageRecord`:手机号维度短信记录。
|
||||||
|
- `SmsApiRequest`:API 调用批次记录。
|
||||||
|
- `CmppSubmitSession`、`SmsSubmitRecord`:CMPP 会话和提交记录。
|
||||||
|
- `SmsReceiptRecord`:短信回执记录。
|
||||||
|
- `SmsUplinkMessage`:上行短信记录。
|
||||||
|
- 客户端接口:
|
||||||
|
- `POST /api/client/send/batch-tasks`
|
||||||
|
- `GET /api/client/send/batch-tasks`
|
||||||
|
- `GET /api/client/send/batch-tasks/:id`
|
||||||
|
- `GET /api/client/send/batch-tasks/:id/messages`
|
||||||
|
- 管理端接口:
|
||||||
|
- `GET /api/admin/send/batch-tasks`
|
||||||
|
- `GET /api/admin/send/messages`
|
||||||
|
- `GET /api/admin/send/submit-records`
|
||||||
|
- `GET /api/admin/send/receipt-records`
|
||||||
|
- `GET /api/admin/send/uplink-messages`
|
||||||
|
- `POST /api/admin/send/batch-tasks/:id/enqueue`
|
||||||
|
- `POST /api/admin/send/timeouts/mark-unknown`
|
||||||
|
- Gateway 事件接口:
|
||||||
|
- `POST /api/gateway/events/submit-result`
|
||||||
|
- `POST /api/gateway/events/receipt`
|
||||||
|
- `POST /api/gateway/events/uplink`
|
||||||
|
- `npm run verify:phase7` 通过。
|
||||||
|
- 队列契约校验通过。
|
||||||
|
- Go Gateway `go test ./...` 通过。
|
||||||
|
- BullMQ Spike:15000 条消息、并发 500、端到端约 910.39 TPS,满足 500 条/秒指标。
|
||||||
|
- Prisma Client 生成通过。
|
||||||
|
- API build 通过。
|
||||||
|
- 前端 build 通过,仍存在 Vite chunk size warning。
|
||||||
|
- API health smoke 通过:`/api/health` 返回 `ok`。
|
||||||
|
|
||||||
|
## 阶段 7 验收状态
|
||||||
|
|
||||||
|
- 平台创建的批量任务只记录客户端任务:已完成,`SmsBatchTask.sourceType` 默认为 `client`,API 调用记录进入 `SmsApiRequest`。
|
||||||
|
- 平台任务、API 调用、CMPP 对接发送全部按手机号维度进入短信记录:已完成,`SmsMessageRecord` 为每个手机号生成独立 `messageId`。
|
||||||
|
- 支持 submit resp、deliver 回执、上行短信、超时补偿:已完成,Gateway 事件接口分别写入提交记录、回执记录、上行记录,并支持未知状态超时补偿。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
阶段 7 已完成。下一阶段可进入阶段 8:查询、统计、验收。
|
||||||
|
|
||||||
|
## 风险与说明
|
||||||
|
|
||||||
|
- 本地未启动 PostgreSQL 时,仅执行 Prisma Client 生成和 TypeScript 构建,不执行数据库迁移。
|
||||||
|
- Gateway 协议层继续复用阶段 0 的 gocmpp 评估结果和队列契约;阶段 7 在 NestJS 侧完成业务编排与队列投递。
|
||||||
+2
-1
@@ -17,7 +17,8 @@
|
|||||||
"verify:phase3": "npm run verify:phase2",
|
"verify:phase3": "npm run verify:phase2",
|
||||||
"verify:phase4": "npm run verify:phase3",
|
"verify:phase4": "npm run verify:phase3",
|
||||||
"verify:phase5": "npm run verify:phase4",
|
"verify:phase5": "npm run verify:phase4",
|
||||||
"verify:phase6": "npm run verify:phase5"
|
"verify:phase6": "npm run verify:phase5",
|
||||||
|
"verify:phase7": "npm run verify:phase6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user