fix: close sms scheduling and billing gaps
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "SmsBatchTask" ADD COLUMN "scheduledAt" TIMESTAMP(3);
|
||||
ALTER TABLE "SmsBatchTask" ADD COLUMN "canceledAt" TIMESTAMP(3);
|
||||
CREATE INDEX "SmsBatchTask_status_scheduledAt_idx" ON "SmsBatchTask"("status", "scheduledAt");
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
ALTER TABLE "Tenant" ADD COLUMN "certificationStatus" TEXT NOT NULL DEFAULT 'approved';
|
||||
|
||||
CREATE TABLE "EnterpriseCertification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"companyName" TEXT NOT NULL,
|
||||
"licenseNo" TEXT,
|
||||
"contactName" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"materials" JSONB,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"rejectReason" TEXT,
|
||||
"reviewerId" TEXT,
|
||||
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "EnterpriseCertification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "CmppConnectionState" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'disconnected',
|
||||
"desiredConnections" INTEGER NOT NULL DEFAULT 1,
|
||||
"currentConnections" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastConnectedAt" TIMESTAMP(3),
|
||||
"lastDisconnectedAt" TIMESTAMP(3),
|
||||
"lastHeartbeatAt" TIMESTAMP(3),
|
||||
"reconnectCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastError" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "CmppConnectionState_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "EnterpriseCertification_tenantId_status_createdAt_idx" ON "EnterpriseCertification"("tenantId", "status", "createdAt");
|
||||
CREATE UNIQUE INDEX "CmppConnectionState_channelId_connectionId_key" ON "CmppConnectionState"("channelId", "connectionId");
|
||||
CREATE INDEX "CmppConnectionState_tenantId_status_idx" ON "CmppConnectionState"("tenantId", "status");
|
||||
CREATE INDEX "CmppConnectionState_channelId_status_idx" ON "CmppConnectionState"("channelId", "status");
|
||||
|
||||
ALTER TABLE "EnterpriseCertification" ADD CONSTRAINT "EnterpriseCertification_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "CmppConnectionState" ADD CONSTRAINT "CmppConnectionState_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "CmppConnectionState" ADD CONSTRAINT "CmppConnectionState_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -11,10 +11,12 @@ model Tenant {
|
||||
name String
|
||||
code String @unique
|
||||
status String @default("active")
|
||||
certificationStatus String @default("approved")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
enterpriseCertifications EnterpriseCertification[]
|
||||
operationLogs OperationLog[]
|
||||
fileObjects FileObject[]
|
||||
enterpriseBlacklists EnterpriseBlacklist[]
|
||||
@@ -35,6 +37,28 @@ model Tenant {
|
||||
smsSubmitRecords SmsSubmitRecord[]
|
||||
smsReceiptRecords SmsReceiptRecord[]
|
||||
smsUplinkMessages SmsUplinkMessage[]
|
||||
cmppConnectionStates CmppConnectionState[]
|
||||
}
|
||||
|
||||
model EnterpriseCertification {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
companyName String
|
||||
licenseNo String?
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
materials Json?
|
||||
status String @default("pending")
|
||||
rejectReason String?
|
||||
reviewerId String?
|
||||
submittedAt DateTime @default(now())
|
||||
reviewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@index([tenantId, status, createdAt])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -449,10 +473,35 @@ model SmsChannel {
|
||||
submitRecords SmsSubmitRecord[]
|
||||
receiptRecords SmsReceiptRecord[]
|
||||
uplinkMessages SmsUplinkMessage[]
|
||||
connectionStates CmppConnectionState[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model CmppConnectionState {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
channelId String
|
||||
connectionId String
|
||||
status String @default("disconnected")
|
||||
desiredConnections Int @default(1)
|
||||
currentConnections Int @default(0)
|
||||
lastConnectedAt DateTime?
|
||||
lastDisconnectedAt DateTime?
|
||||
lastHeartbeatAt DateTime?
|
||||
reconnectCount Int @default(0)
|
||||
lastError String?
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
|
||||
@@unique([channelId, connectionId])
|
||||
@@index([tenantId, status])
|
||||
@@index([channelId, status])
|
||||
}
|
||||
|
||||
model SmsChannelGroup {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
@@ -723,6 +772,8 @@ model SmsBatchTask {
|
||||
failedTotal Int @default(0)
|
||||
unknownTotal Int @default(0)
|
||||
timeoutTotal Int @default(0)
|
||||
scheduledAt DateTime?
|
||||
canceledAt DateTime?
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -737,6 +788,7 @@ model SmsBatchTask {
|
||||
receiptRecords SmsReceiptRecord[]
|
||||
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([status, scheduledAt])
|
||||
@@index([applicationId, createdAt])
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { CertificationModule } from './certification/certification.module';
|
||||
import { DictionariesModule } from './dictionaries/dictionaries.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthController } from './health.controller';
|
||||
@@ -29,6 +30,7 @@ import { UsersModule } from './users/users.module';
|
||||
FilesModule,
|
||||
DictionariesModule,
|
||||
BillingModule,
|
||||
CertificationModule,
|
||||
SmsConfigModule,
|
||||
ChannelsModule,
|
||||
RiskReviewModule,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
|
||||
|
||||
@ApiTags('client-certification')
|
||||
@Controller('client/enterprise-certification')
|
||||
export class ClientCertificationController {
|
||||
constructor(private readonly certifications: CertificationService) {}
|
||||
|
||||
@Get()
|
||||
list(@TenantId() tenantId?: string) {
|
||||
return this.certifications.list(tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
submit(@Body() body: SubmitCertificationDto) {
|
||||
return this.certifications.submit(body);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('admin-certification')
|
||||
@Controller('admin/enterprise-certifications')
|
||||
export class AdminCertificationController {
|
||||
constructor(private readonly certifications: CertificationService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||
return this.certifications.list(tenantId, status);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.certifications.get(id);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string, @Body() body: ReviewCertificationDto) {
|
||||
return this.certifications.approve(id, body);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string, @Body() body: ReviewCertificationDto) {
|
||||
return this.certifications.reject(id, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminCertificationController, ClientCertificationController } from './certification.controller';
|
||||
import { CertificationService } from './certification.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ClientCertificationController, AdminCertificationController],
|
||||
providers: [CertificationService],
|
||||
exports: [CertificationService],
|
||||
})
|
||||
export class CertificationModule {}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CertificationService } from './certification.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'tenant-1', certificationStatus: 'pending' }),
|
||||
},
|
||||
enterpriseCertification: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'cert-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'cert-1', tenantId: 'tenant-1', status: 'pending' }),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'cert-1', ...data })),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'reviewer-1' }),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CertificationService', () => {
|
||||
it('submits certification and marks tenant pending', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new CertificationService(prisma as never);
|
||||
|
||||
await service.submit({ tenantId: 'tenant-1', companyName: '测试企业', licenseNo: 'LIC-1' });
|
||||
|
||||
expect(prisma.enterpriseCertification.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ tenantId: 'tenant-1', companyName: '测试企业', status: 'pending' }),
|
||||
});
|
||||
expect(prisma.tenant.update).toHaveBeenCalledWith({
|
||||
where: { id: 'tenant-1' },
|
||||
data: { certificationStatus: 'pending' },
|
||||
});
|
||||
});
|
||||
|
||||
it('approves and rejects certification while syncing tenant status', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new CertificationService(prisma as never);
|
||||
|
||||
await service.approve('cert-1', { reviewerId: 'reviewer-1' });
|
||||
expect(prisma.tenant.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'tenant-1' },
|
||||
data: { certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await service.reject('cert-1', { reviewerId: 'reviewer-1', reason: '资料不清晰' });
|
||||
expect(prisma.tenant.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'tenant-1' },
|
||||
data: { certificationStatus: 'rejected' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface SubmitCertificationDto {
|
||||
tenantId: string;
|
||||
companyName: string;
|
||||
licenseNo?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
materials?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ReviewCertificationDto {
|
||||
reviewerId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CertificationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(tenantId?: string, status?: string) {
|
||||
return this.prisma.enterpriseCertification.findMany({
|
||||
where: { tenantId, status },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.prisma.enterpriseCertification.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async submit(data: SubmitCertificationDto) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: data.tenantId } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
const certification = await this.prisma.enterpriseCertification.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
companyName: data.companyName,
|
||||
licenseNo: data.licenseNo,
|
||||
contactName: data.contactName,
|
||||
contactPhone: data.contactPhone,
|
||||
materials: data.materials as Prisma.InputJsonValue | undefined,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
await this.prisma.tenant.update({
|
||||
where: { id: data.tenantId },
|
||||
data: { certificationStatus: 'pending' },
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: 'enterprise_certification.submit',
|
||||
resource: 'enterprise_certification',
|
||||
resourceId: certification.id,
|
||||
detail: { companyName: data.companyName } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return certification;
|
||||
}
|
||||
|
||||
approve(id: string, data: ReviewCertificationDto) {
|
||||
return this.review(id, 'approved', data);
|
||||
}
|
||||
|
||||
reject(id: string, data: ReviewCertificationDto) {
|
||||
if (!data.reason) {
|
||||
throw new BadRequestException('Reject reason is required');
|
||||
}
|
||||
return this.review(id, 'rejected', data);
|
||||
}
|
||||
|
||||
private async review(id: string, status: 'approved' | 'rejected', data: ReviewCertificationDto) {
|
||||
const certification = await this.prisma.enterpriseCertification.findUnique({ where: { id } });
|
||||
if (!certification) {
|
||||
throw new NotFoundException('Enterprise certification not found');
|
||||
}
|
||||
if (data.reviewerId) {
|
||||
const reviewer = await this.prisma.user.findUnique({ where: { id: data.reviewerId }, select: { id: true } });
|
||||
if (!reviewer) {
|
||||
throw new BadRequestException('reviewerId does not reference an existing user');
|
||||
}
|
||||
}
|
||||
const updated = await this.prisma.enterpriseCertification.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
rejectReason: status === 'rejected' ? data.reason : null,
|
||||
reviewerId: data.reviewerId,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await this.prisma.tenant.update({
|
||||
where: { id: certification.tenantId },
|
||||
data: { certificationStatus: status },
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: certification.tenantId,
|
||||
userId: data.reviewerId,
|
||||
action: `enterprise_certification.${status}`,
|
||||
resource: 'enterprise_certification',
|
||||
resourceId: id,
|
||||
detail: { reason: data.reason } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ChannelsService,
|
||||
ChangeChannelStatusDto,
|
||||
CreateChannelDto,
|
||||
CreateChannelGroupDto,
|
||||
CreateChannelGroupItemDto,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
CreateReportMaterialDto,
|
||||
CreateReportTaskDto,
|
||||
CreateRouteRuleDto,
|
||||
UpsertConnectionStateDto,
|
||||
} from './channels.service';
|
||||
|
||||
@ApiTags('channels')
|
||||
@@ -33,11 +35,31 @@ export class ChannelsController {
|
||||
return this.channels.testChannel(channelId);
|
||||
}
|
||||
|
||||
@Post('channels/:id/status')
|
||||
changeChannelStatus(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) {
|
||||
return this.channels.changeChannelStatus(channelId, body);
|
||||
}
|
||||
|
||||
@Get('channels/:id/metrics')
|
||||
listChannelMetrics(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelMetrics(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/connections')
|
||||
listChannelConnections(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelConnections(channelId);
|
||||
}
|
||||
|
||||
@Get('tenants/:id/connections')
|
||||
listTenantConnections(@Param('id') tenantId: string) {
|
||||
return this.channels.listTenantConnections(tenantId);
|
||||
}
|
||||
|
||||
@Post('gateway/connections')
|
||||
upsertConnectionState(@Body() body: UpsertConnectionStateDto) {
|
||||
return this.channels.upsertConnectionState(body);
|
||||
}
|
||||
|
||||
@Get('channel-groups')
|
||||
listGroups() {
|
||||
return this.channels.listGroups();
|
||||
|
||||
@@ -6,6 +6,8 @@ function createPrismaMock() {
|
||||
smsChannel: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', status: 'active' }),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
},
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
@@ -46,6 +48,13 @@ function createPrismaMock() {
|
||||
smsSignature: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'conn-1', ...create })),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,4 +143,54 @@ describe('ChannelsService', () => {
|
||||
data: { reportStatus: 'rejected' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates channel status with operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.changeChannelStatus('channel-1', { status: 'disabled', operatorId: 'admin-1', reason: 'maintenance' });
|
||||
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'disabled' } });
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: 'admin-1',
|
||||
action: 'sms_channel.disabled',
|
||||
resource: 'sms_channel',
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('upserts and lists CMPP connection states', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.upsertConnectionState({
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'conn-a',
|
||||
status: 'online',
|
||||
desiredConnections: 2,
|
||||
currentConnections: 1,
|
||||
});
|
||||
await service.listChannelConnections('channel-1');
|
||||
await service.listTenantConnections('tenant-1');
|
||||
|
||||
expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
update: expect.objectContaining({ tenantId: 'tenant-1', status: 'online', desiredConnections: 2, currentConnections: 1 }),
|
||||
create: expect.objectContaining({ channelId: 'channel-1', connectionId: 'conn-a', status: 'online' }),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
|
||||
where: { channelId: 'channel-1' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1' },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,26 @@ export interface CreateReceiptImportDto {
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpsertConnectionStateDto {
|
||||
tenantId?: string;
|
||||
channelId: string;
|
||||
connectionId: string;
|
||||
status: string;
|
||||
desiredConnections?: number;
|
||||
currentConnections?: number;
|
||||
lastConnectedAt?: string;
|
||||
lastDisconnectedAt?: string;
|
||||
lastHeartbeatAt?: string;
|
||||
reconnectCount?: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface ChangeChannelStatusDto {
|
||||
status: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -122,6 +142,28 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { status: data.status } });
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: `sms_channel.${data.status}`,
|
||||
resource: 'sms_channel',
|
||||
resourceId: channelId,
|
||||
detail: {
|
||||
statusBefore: channel.status,
|
||||
statusAfter: data.status,
|
||||
reason: data.reason,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
testChannel(channelId: string) {
|
||||
return {
|
||||
channelId,
|
||||
@@ -138,6 +180,46 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
listChannelConnections(channelId: string) {
|
||||
return this.prisma.cmppConnectionState.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
listTenantConnections(tenantId: string) {
|
||||
return this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
const payload = {
|
||||
tenantId: data.tenantId,
|
||||
status: data.status,
|
||||
desiredConnections: data.desiredConnections ?? 1,
|
||||
currentConnections: data.currentConnections ?? (data.status === 'online' || data.status === 'connected' ? 1 : 0),
|
||||
lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined,
|
||||
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
|
||||
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
|
||||
reconnectCount: data.reconnectCount ?? 0,
|
||||
lastError: data.lastError,
|
||||
};
|
||||
return this.prisma.cmppConnectionState.upsert({
|
||||
where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } },
|
||||
update: payload,
|
||||
create: {
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
include: { items: { include: { channel: true } } },
|
||||
|
||||
@@ -25,6 +25,9 @@ function createPrismaMock() {
|
||||
accountTransaction: {
|
||||
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
||||
},
|
||||
operationLog: {
|
||||
findMany: jest.fn(),
|
||||
groupBy: jest.fn(),
|
||||
@@ -66,7 +69,11 @@ describe('OperationsService', () => {
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.dashboard({ tenantId: 'tenant-1' })).resolves.toEqual(
|
||||
expect.objectContaining({ taskCount: 3, uplinkCount: 1 }),
|
||||
expect.objectContaining({
|
||||
taskCount: 3,
|
||||
uplinkCount: 1,
|
||||
gatewayConnections: [{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||
}),
|
||||
);
|
||||
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export class OperationsService {
|
||||
|
||||
async dashboard(query: { tenantId?: string }) {
|
||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||
const [taskCount, messageGroups, uplinkCount, billingAggregate, transactionAggregate] = await Promise.all([
|
||||
const [taskCount, messageGroups, uplinkCount, billingAggregate, transactionAggregate, connectionGroups] = await Promise.all([
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
@@ -91,6 +91,12 @@ export class OperationsService {
|
||||
_sum: { amountCents: true, smsUnits: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppConnectionState.groupBy({
|
||||
by: ['status'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
_sum: { currentConnections: true, desiredConnections: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
taskCount,
|
||||
@@ -98,6 +104,7 @@ export class OperationsService {
|
||||
uplinkCount,
|
||||
billing: billingAggregate,
|
||||
transactions: transactionAggregate,
|
||||
gatewayConnections: connectionGroups,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,4 +245,3 @@ function normalizeGroupBy(groupBy?: string) {
|
||||
}
|
||||
return 'channelId';
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
|
||||
enterpriseBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
sensitiveWord: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'user-1' }),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
@@ -161,10 +167,13 @@ describe('RiskReviewService', () => {
|
||||
expect(prisma.smsSendTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
illegalRatio: 0.5,
|
||||
variableIssues: expect.arrayContaining([
|
||||
{ type: 'missing_required_variable', name: 'code' },
|
||||
{ type: 'unexpected_variable', name: 'extra' },
|
||||
]),
|
||||
variableIssues: {
|
||||
variables: expect.arrayContaining([
|
||||
{ type: 'missing_required_variable', name: 'code' },
|
||||
{ type: 'unexpected_variable', name: 'extra' },
|
||||
]),
|
||||
content: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -210,4 +219,39 @@ describe('RiskReviewService', () => {
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects sensitive words and illegal control characters before sending', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.sensitiveWord.findMany.mockResolvedValue([{ word: '违法词', level: 'block' }]);
|
||||
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
const result = await service.evaluateTask({
|
||||
tenantId: 'tenant-1',
|
||||
content: '包含违法词\u0001',
|
||||
phones: ['13800000001'],
|
||||
});
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ ruleCode: 'CONTENT_CONTROL_CHAR', action: 'block' }),
|
||||
expect.objectContaining({ ruleCode: 'SENSITIVE_WORD', action: 'block' }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a bad request for unknown optional creator ids', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.user.findUnique.mockResolvedValue(null);
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
|
||||
await expect(
|
||||
service.evaluateTask({
|
||||
tenantId: 'tenant-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
createdById: 'missing-user',
|
||||
}),
|
||||
).rejects.toThrow('createdById does not reference an existing user');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -168,6 +168,12 @@ export class RiskReviewService {
|
||||
|
||||
async evaluateTask(data: EvaluateSmsTaskDto) {
|
||||
await this.ensureDefaultRules();
|
||||
if (data.createdById) {
|
||||
const creator = await this.prisma.user.findUnique({ where: { id: data.createdById }, select: { id: true } });
|
||||
if (!creator) {
|
||||
throw new BadRequestException('createdById does not reference an existing user');
|
||||
}
|
||||
}
|
||||
const phones = data.phones ?? [];
|
||||
const uniquePhones = [...new Set(phones)];
|
||||
const phoneTotal = phones.length;
|
||||
@@ -177,15 +183,17 @@ export class RiskReviewService {
|
||||
const illegalRatio = ratio(illegalCount, phoneTotal);
|
||||
const blacklistHitCount = await this.countBlacklistHits(data.tenantId, uniquePhones);
|
||||
const blacklistHitRatio = ratio(blacklistHitCount, phoneTotal);
|
||||
const [application, template, rules, recentTaskCount] = await Promise.all([
|
||||
const [application, template, rules, recentTaskCount, sensitiveWords] = await Promise.all([
|
||||
data.applicationId ? this.prisma.smsApplication.findUnique({ where: { id: data.applicationId } }) : null,
|
||||
data.templateId
|
||||
? this.prisma.smsTemplate.findUnique({ where: { id: data.templateId }, include: { variables: true } })
|
||||
: null,
|
||||
this.effectiveRules(data.tenantId),
|
||||
this.countRecentTasks(data.tenantId),
|
||||
this.prisma.sensitiveWord.findMany({ where: { status: 'active' }, select: { word: true, level: true } }),
|
||||
]);
|
||||
const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {});
|
||||
const contentIssues = evaluateContent(data.content, sensitiveWords);
|
||||
const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date();
|
||||
const nonWorkingMarketingPhones =
|
||||
isMarketing(data.category ?? template?.category) && isNonWorkingTime(requestedAt) ? phoneTotal : 0;
|
||||
@@ -199,6 +207,7 @@ export class RiskReviewService {
|
||||
recentTaskCount,
|
||||
variableIssueCount: variableIssues.length,
|
||||
});
|
||||
hits.push(...contentIssues.map(contentIssueToHit));
|
||||
const decision = decideRiskAction(hits);
|
||||
const reason = hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null;
|
||||
const task = await this.prisma.smsSendTask.create({
|
||||
@@ -214,7 +223,7 @@ export class RiskReviewService {
|
||||
duplicateRatio,
|
||||
illegalRatio,
|
||||
blacklistHitRatio,
|
||||
variableIssues: variableIssues as Prisma.InputJsonValue,
|
||||
variableIssues: { variables: variableIssues, content: contentIssues } as unknown as Prisma.InputJsonValue,
|
||||
status: decision.status,
|
||||
riskDecision: decision.riskDecision,
|
||||
reviewReason: decision.status === 'pending_review' ? reason : null,
|
||||
@@ -418,6 +427,39 @@ function evaluateTemplateVariables(
|
||||
];
|
||||
}
|
||||
|
||||
function evaluateContent(content: string, sensitiveWords: Array<{ word: string; level: string }>) {
|
||||
const issues: RuleEvaluation[] = [];
|
||||
const controlMatches = [...content].filter((char) => /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(char));
|
||||
if (controlMatches.length > 0) {
|
||||
issues.push({
|
||||
ruleCode: 'CONTENT_CONTROL_CHAR',
|
||||
ruleName: '短信内容非法控制字符',
|
||||
thresholdValue: 0,
|
||||
actualValue: controlMatches.length,
|
||||
action: 'block',
|
||||
reason: `短信内容包含 ${controlMatches.length} 个非法控制字符,处理动作 直接拒绝`,
|
||||
});
|
||||
}
|
||||
const matchedWords = sensitiveWords
|
||||
.filter((item) => item.word && content.includes(item.word))
|
||||
.map((item) => item.word);
|
||||
if (matchedWords.length > 0) {
|
||||
issues.push({
|
||||
ruleCode: 'SENSITIVE_WORD',
|
||||
ruleName: '敏感词命中',
|
||||
thresholdValue: 0,
|
||||
actualValue: matchedWords.length,
|
||||
action: 'block',
|
||||
reason: `短信内容命中敏感词:${matchedWords.join('、')},处理动作 直接拒绝`,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function contentIssueToHit(issue: RuleEvaluation) {
|
||||
return issue;
|
||||
}
|
||||
|
||||
function inferVariables(content: string) {
|
||||
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
||||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||||
@@ -450,4 +492,3 @@ function formatAction(action: string) {
|
||||
}
|
||||
return '放行';
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,13 @@ export class AdminSendChainController {
|
||||
return this.sendChain.enqueueBatchTask(taskId);
|
||||
}
|
||||
|
||||
@Post('scheduled/dispatch-due')
|
||||
dispatchDueScheduledTasks() {
|
||||
return this.sendChain.dispatchDueScheduledTasks();
|
||||
}
|
||||
|
||||
@Post('timeouts/mark-unknown')
|
||||
markUnknownTimeout(@Body() body: TimeoutUnknownDto) {
|
||||
return this.sendChain.markUnknownTimeout(body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto, SendChainService } from './send-chain.service';
|
||||
|
||||
@ApiTags('client-send-chain')
|
||||
@Controller('client/send')
|
||||
@@ -13,6 +13,16 @@ export class ClientSendChainController {
|
||||
return this.sendChain.createBatchTask(body);
|
||||
}
|
||||
|
||||
@Post('imports/preview')
|
||||
previewImport(@Body() body: ImportPreviewDto) {
|
||||
return this.sendChain.previewImport(body);
|
||||
}
|
||||
|
||||
@Post('imports/confirm')
|
||||
confirmImport(@Body() body: ConfirmImportDto) {
|
||||
return this.sendChain.confirmImport(body);
|
||||
}
|
||||
|
||||
@Get('batch-tasks')
|
||||
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
||||
return this.sendChain.listBatchTasks(tenantId, status);
|
||||
@@ -27,5 +37,9 @@ export class ClientSendChainController {
|
||||
listTaskMessages(@Param('id') taskId: string) {
|
||||
return this.sendChain.listMessages(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('batch-tasks/:id/cancel')
|
||||
cancelBatchTask(@Param('id') taskId: string) {
|
||||
return this.sendChain.cancelBatchTask(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ function createPrismaMock() {
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
billingUnits: 1,
|
||||
unitPrice: 3,
|
||||
amountCents: 3,
|
||||
status: 'queued',
|
||||
template: { signature: { name: '签名' } },
|
||||
};
|
||||
@@ -23,10 +25,26 @@ function createPrismaMock() {
|
||||
account: 'cmpp-account',
|
||||
srcId: '10690000',
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: 3,
|
||||
status: 'active',
|
||||
config: { serviceId: 'SMS' },
|
||||
};
|
||||
return {
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active' }),
|
||||
},
|
||||
smsTemplate: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'tpl-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved', reportStatus: 'approved' },
|
||||
}),
|
||||
},
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockResolvedValue(task),
|
||||
findUnique: jest.fn().mockResolvedValue(task),
|
||||
@@ -69,6 +87,18 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
smsBillingRecord: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'bill-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'bill-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
enterpriseBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
globalBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,9 +106,15 @@ function createService(prisma = createPrismaMock()) {
|
||||
const billing = {
|
||||
estimateSmsCost: jest.fn().mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
totalBillingUnits: 2,
|
||||
unitPrice: 3,
|
||||
amountCents: 6,
|
||||
}),
|
||||
checkAccount: jest.fn().mockResolvedValue({ canSend: true }),
|
||||
freeze: jest.fn().mockResolvedValue({ id: 'tx-freeze' }),
|
||||
release: jest.fn().mockResolvedValue({ id: 'tx-release' }),
|
||||
charge: jest.fn().mockResolvedValue({ id: 'tx-charge' }),
|
||||
refund: jest.fn().mockResolvedValue({ id: 'tx-refund' }),
|
||||
} as unknown as BillingService;
|
||||
const riskReview = {
|
||||
evaluateTask: jest.fn().mockResolvedValue({
|
||||
@@ -92,7 +128,7 @@ function createService(prisma = createPrismaMock()) {
|
||||
|
||||
describe('SendChainService', () => {
|
||||
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
const { service, prisma, riskReview, billing } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
|
||||
|
||||
await service.createBatchTask({
|
||||
@@ -115,9 +151,105 @@ describe('SendChainService', () => {
|
||||
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3 }),
|
||||
]),
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, smsUnits: 2, relatedId: 'task-1' }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
const scheduledAt = new Date(Date.now() + 60_000).toISOString();
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
sendMode: 'scheduled',
|
||||
scheduledAt,
|
||||
});
|
||||
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ status: 'scheduled', scheduledAt: expect.any(Date) }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({ status: 'scheduled' })],
|
||||
});
|
||||
expect(billing.freeze).not.toHaveBeenCalled();
|
||||
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
|
||||
|
||||
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1' }]);
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
||||
|
||||
await expect(service.dispatchDueScheduledTasks(new Date(Date.now() + 120_000))).resolves.toEqual({
|
||||
dispatched: 1,
|
||||
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
||||
data: { status: 'queued' },
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels scheduled tasks before dispatch', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'scheduled' });
|
||||
|
||||
await service.cancelBatchTask('task-1');
|
||||
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
||||
data: { status: 'canceled', errorMessage: '定时任务已取消' },
|
||||
});
|
||||
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'task-1' },
|
||||
data: { status: 'canceled', canceledAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks sending when enterprise certification is not approved', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.tenant.findUnique.mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'rejected' });
|
||||
|
||||
await expect(
|
||||
service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
}),
|
||||
).rejects.toThrow('企业认证未通过,不能发送短信');
|
||||
});
|
||||
|
||||
it('previews imported phone files with duplicate, invalid, blacklist, and variable errors', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000003' }]);
|
||||
|
||||
await expect(
|
||||
service.previewImport({
|
||||
tenantId: 'tenant-1',
|
||||
content: 'phoneNumber,code\n13800000001,1234\n13800000001,1234\nbad,1234\n13800000003,1234\n13900000001,',
|
||||
requiredVariables: ['code'],
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
totalRows: 5,
|
||||
validCount: 1,
|
||||
errorCount: 4,
|
||||
phones: ['13800000001'],
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({ reason: '重复号码' }),
|
||||
expect.objectContaining({ reason: '手机号格式非法' }),
|
||||
expect.objectContaining({ reason: '命中黑名单' }),
|
||||
expect.objectContaining({ reason: '变量列缺失:code' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds queued message jobs for a batch task', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const add = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -155,8 +287,8 @@ describe('SendChainService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('updates submit result status and task progress', async () => {
|
||||
const { service, prisma } = createService();
|
||||
it('updates submit result status, charges billing, and task progress', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
@@ -176,6 +308,32 @@ describe('SendChainService', () => {
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
|
||||
});
|
||||
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
|
||||
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'MSG-1' }));
|
||||
expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
|
||||
const { service, billing } = createService();
|
||||
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'rejected',
|
||||
});
|
||||
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }));
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'UNDELIV',
|
||||
});
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
});
|
||||
|
||||
it('records receipts and uplink messages from gateway events', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -14,6 +14,8 @@ export interface CreateBatchTaskDto {
|
||||
content: string;
|
||||
category?: string;
|
||||
phones: string[];
|
||||
sendMode?: 'immediate' | 'scheduled';
|
||||
scheduledAt?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
createdById?: string;
|
||||
sourceIp?: string;
|
||||
@@ -60,6 +62,20 @@ export interface TimeoutUnknownDto {
|
||||
olderThanHours?: number;
|
||||
}
|
||||
|
||||
export interface ImportPreviewDto {
|
||||
tenantId: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
encoding?: 'utf8' | 'gbk';
|
||||
delimiter?: ',' | '\t';
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export interface ConfirmImportDto extends CreateBatchTaskDto {
|
||||
importContent: string;
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
interface SendJob {
|
||||
messageRecordId: string;
|
||||
}
|
||||
@@ -95,6 +111,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async createBatchTask(data: CreateBatchTaskDto) {
|
||||
const phones = [...new Set(data.phones ?? [])];
|
||||
const schedule = parseSchedule(data);
|
||||
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||
const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId);
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
@@ -111,8 +130,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
taskId: risk.task?.id,
|
||||
content: data.content,
|
||||
phoneCount: phones.length,
|
||||
unitPrice,
|
||||
});
|
||||
const batchStatus = statusFromRisk(risk.status);
|
||||
const batchStatus = statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
|
||||
const shouldReserveBalance = batchStatus === 'ready';
|
||||
if (risk.status === 'approved') {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('企业账户余额、套餐余量或授信额度不足');
|
||||
}
|
||||
}
|
||||
const task = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
@@ -129,9 +160,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
reviewReason: risk.status === 'pending_review' ? risk.reason : null,
|
||||
rejectReason: risk.status === 'rejected' ? risk.reason : null,
|
||||
progressTotal: phones.length,
|
||||
scheduledAt: schedule.scheduledAt,
|
||||
createdById: data.createdById,
|
||||
},
|
||||
});
|
||||
if (shouldReserveBalance && billing.amountCents + billing.totalBillingUnits > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '发送任务创建冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsApiRequest.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
@@ -143,6 +185,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
phoneTotal: phones.length,
|
||||
contentLength: [...data.content].length,
|
||||
category: data.category,
|
||||
sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate',
|
||||
scheduledAt: schedule.scheduledAt?.toISOString(),
|
||||
},
|
||||
status: batchStatus === 'rejected' ? 'rejected' : 'accepted',
|
||||
},
|
||||
@@ -160,7 +204,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
unitPrice: billing.unitPrice,
|
||||
amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
|
||||
status: batchStatus === 'ready' ? 'queued' : batchStatus,
|
||||
status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus,
|
||||
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
|
||||
})),
|
||||
});
|
||||
@@ -219,11 +263,81 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async previewImport(data: ImportPreviewDto) {
|
||||
const sizeBytes = Buffer.byteLength(data.content, 'utf8');
|
||||
if (sizeBytes > 20 * 1024 * 1024) {
|
||||
throw new BadRequestException('导入文件不能超过 20MB');
|
||||
}
|
||||
const rows = parseImportRows(data.content, data.delimiter);
|
||||
const phones: string[] = [];
|
||||
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
|
||||
const requiredVariables = data.requiredVariables ?? [];
|
||||
const enterpriseBlacklist = await this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId: data.tenantId, status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
const globalBlacklist = await this.prisma.globalBlacklist.findMany({
|
||||
where: { status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
const blacklist = new Set([...enterpriseBlacklist, ...globalBlacklist].map((item) => item.phoneNumber));
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.phoneNumber) {
|
||||
errors.push({ rowNumber: row.rowNumber, reason: '缺少手机号' });
|
||||
continue;
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '手机号格式非法' });
|
||||
continue;
|
||||
}
|
||||
if (seen.has(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '重复号码' });
|
||||
continue;
|
||||
}
|
||||
if (blacklist.has(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '命中黑名单' });
|
||||
continue;
|
||||
}
|
||||
const missingVariables = requiredVariables.filter((name) => !row.variables[name]);
|
||||
if (missingVariables.length > 0) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` });
|
||||
continue;
|
||||
}
|
||||
seen.add(row.phoneNumber);
|
||||
phones.push(row.phoneNumber);
|
||||
}
|
||||
return {
|
||||
fileName: data.fileName,
|
||||
encoding: data.encoding ?? 'utf8',
|
||||
totalRows: rows.length,
|
||||
validCount: phones.length,
|
||||
errorCount: errors.length,
|
||||
phones,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
async confirmImport(data: ConfirmImportDto) {
|
||||
const preview = await this.previewImport({
|
||||
tenantId: data.tenantId,
|
||||
content: data.importContent,
|
||||
requiredVariables: data.requiredVariables,
|
||||
});
|
||||
if (preview.validCount === 0) {
|
||||
throw new BadRequestException('导入文件没有可发送号码');
|
||||
}
|
||||
return this.createBatchTask({ ...data, phones: preview.phones });
|
||||
}
|
||||
|
||||
async enqueueBatchTask(taskId: string) {
|
||||
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
if (task.status === 'canceled') {
|
||||
throw new BadRequestException('SMS batch task is canceled');
|
||||
}
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: taskId, status: 'queued' },
|
||||
select: { id: true },
|
||||
@@ -237,6 +351,77 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return { taskId, enqueued: messages.length };
|
||||
}
|
||||
|
||||
async cancelBatchTask(taskId: string) {
|
||||
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
if (task.status !== 'scheduled') {
|
||||
throw new BadRequestException('Only scheduled SMS batch tasks can be canceled before dispatch');
|
||||
}
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: taskId, status: 'scheduled' },
|
||||
data: { status: 'canceled', errorMessage: '定时任务已取消' },
|
||||
});
|
||||
return this.prisma.smsBatchTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'canceled', canceledAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchDueScheduledTasks(now = new Date()) {
|
||||
const tasks = await this.prisma.smsBatchTask.findMany({
|
||||
where: { status: 'scheduled', scheduledAt: { lte: now } },
|
||||
orderBy: { scheduledAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = [];
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
await this.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
select: { id: true, amountCents: true, billingUnits: true },
|
||||
take: 100000,
|
||||
});
|
||||
const amountCents = messages.reduce((sum, message) => sum + message.amountCents, 0);
|
||||
const smsUnits = messages.reduce((sum, message) => sum + message.billingUnits, 0);
|
||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents, smsUnits });
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('定时任务到点时企业账户余额、套餐余量或授信额度不足');
|
||||
}
|
||||
if (amountCents + smsUnits > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: task.tenantId,
|
||||
amountCents,
|
||||
smsUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '定时任务到点冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
data: { status: 'queued' },
|
||||
});
|
||||
const enqueued = await this.enqueueBatchTask(task.id);
|
||||
results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '定时任务到点执行失败';
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
data: { status: 'rejected', errorMessage: reason },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'failed', rejectReason: reason },
|
||||
});
|
||||
results.push({ taskId: task.id, status: 'failed', reason });
|
||||
}
|
||||
}
|
||||
return { dispatched: results.filter((result) => result.status === 'queued').length, results };
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.worker) {
|
||||
return { status: 'already_started' };
|
||||
@@ -332,6 +517,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
});
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
if (data.submitStatus === 'accepted') {
|
||||
await this.chargeAcceptedMessage(message);
|
||||
} else {
|
||||
await this.releaseMessageReservation(message, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
@@ -353,6 +543,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
const status =
|
||||
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
|
||||
if (status === 'failed') {
|
||||
await this.refundMessage(message, '最终失败退款');
|
||||
}
|
||||
await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
@@ -418,6 +611,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
where: { id: { in: candidates.map((candidate) => candidate.id) } },
|
||||
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: '72小时未收到明确回执,自动转超时' },
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({ where: { id: candidate.id } });
|
||||
if (message) {
|
||||
await this.refundMessage(message, '72小时未收到明确回执,自动超时退款');
|
||||
}
|
||||
}
|
||||
for (const batchTaskId of new Set(candidates.map((candidate) => candidate.batchTaskId))) {
|
||||
await this.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
@@ -444,6 +643,135 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return channel;
|
||||
}
|
||||
|
||||
private async resolveUnitPrice(tenantId: string, applicationId?: string) {
|
||||
try {
|
||||
const channel = await this.selectChannel(tenantId, applicationId);
|
||||
return channel.unitPrice ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant || tenant.status !== 'active') {
|
||||
throw new BadRequestException('企业客户不存在或已停用');
|
||||
}
|
||||
if (tenant.certificationStatus !== 'approved') {
|
||||
throw new BadRequestException('企业认证未通过,不能发送短信');
|
||||
}
|
||||
if (!applicationId) {
|
||||
return;
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application || application.tenantId !== tenantId || application.status !== 'active') {
|
||||
throw new BadRequestException('短信应用不存在或已停用');
|
||||
}
|
||||
if (!templateId) {
|
||||
return;
|
||||
}
|
||||
const template = await this.prisma.smsTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { signature: true },
|
||||
});
|
||||
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
|
||||
}
|
||||
if (!template.signature || template.signature.auditStatus !== 'approved' || template.signature.reportStatus !== 'approved') {
|
||||
throw new BadRequestException('短信签名未审核通过或通道报备未通过');
|
||||
}
|
||||
}
|
||||
|
||||
private async chargeAcceptedMessage(message: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
batchTaskId: string;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
unitPrice: number;
|
||||
amountCents: number;
|
||||
}) {
|
||||
const amountCents = message.amountCents ?? 0;
|
||||
const smsUnits = message.billingUnits ?? 0;
|
||||
if (amountCents + smsUnits > 0) {
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
smsUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: message.batchTaskId,
|
||||
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
|
||||
});
|
||||
}
|
||||
const transaction = await this.billing.charge({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
smsUnits,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: '提交成功扣费',
|
||||
});
|
||||
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
|
||||
const data = {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? undefined,
|
||||
taskId: message.batchTaskId,
|
||||
messageId: message.messageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
contentLength: [...message.content].length,
|
||||
billingUnits: smsUnits,
|
||||
unitPrice: message.unitPrice ?? 0,
|
||||
amountCents,
|
||||
billingStatus: 'charged',
|
||||
transactionId: transaction.id,
|
||||
};
|
||||
if (exists) {
|
||||
await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data });
|
||||
return;
|
||||
}
|
||||
await this.prisma.smsBillingRecord.create({ data });
|
||||
}
|
||||
|
||||
private async releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents: message.amountCents,
|
||||
smsUnits: message.billingUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: message.batchTaskId,
|
||||
remark: `${remark}: ${message.messageId}`,
|
||||
});
|
||||
}
|
||||
|
||||
private async refundMessage(
|
||||
message: { tenantId: string; messageId: string; amountCents: number; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
const transaction = await this.billing.refund({
|
||||
tenantId: message.tenantId,
|
||||
amountCents: message.amountCents,
|
||||
smsUnits: message.billingUnits,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark,
|
||||
});
|
||||
await this.prisma.smsBillingRecord.updateMany({
|
||||
where: { messageId: message.messageId },
|
||||
data: { billingStatus: 'refunded', transactionId: transaction.id },
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
const redis = this.getRedis();
|
||||
for (;;) {
|
||||
@@ -520,16 +848,72 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
function statusFromRisk(status: string) {
|
||||
function statusFromRisk(status: string, scheduled: boolean) {
|
||||
if (status === 'rejected') {
|
||||
return 'rejected';
|
||||
}
|
||||
if (status === 'pending_review') {
|
||||
return 'pending_review';
|
||||
}
|
||||
if (scheduled) {
|
||||
return 'scheduled';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
function parseSchedule(data: CreateBatchTaskDto) {
|
||||
if (data.sendMode !== 'scheduled' && !data.scheduledAt) {
|
||||
return { scheduledAt: null };
|
||||
}
|
||||
if (!data.scheduledAt) {
|
||||
throw new BadRequestException('定时发送必须提供 scheduledAt');
|
||||
}
|
||||
const scheduledAt = new Date(data.scheduledAt);
|
||||
if (Number.isNaN(scheduledAt.getTime())) {
|
||||
throw new BadRequestException('scheduledAt 时间格式无效');
|
||||
}
|
||||
if (scheduledAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('scheduledAt 必须晚于当前时间');
|
||||
}
|
||||
return { scheduledAt };
|
||||
}
|
||||
|
||||
function parseImportRows(content: string, delimiter?: ',' | '\t') {
|
||||
const normalized = content.replace(/^\uFEFF/, '');
|
||||
const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const firstDelimiter = delimiter ?? (lines[0].includes(',') ? ',' : '\t');
|
||||
const firstCells = splitImportLine(lines[0], firstDelimiter);
|
||||
const hasHeader = firstCells.some((cell) => ['phone', 'phoneNumber', 'mobile', '手机号'].includes(cell));
|
||||
const headers = hasHeader ? firstCells : ['phoneNumber'];
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
return dataLines.map((line, index) => {
|
||||
const cells = splitImportLine(line, firstDelimiter);
|
||||
const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = {
|
||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
||||
phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0],
|
||||
variables: {},
|
||||
};
|
||||
headers.forEach((header, cellIndex) => {
|
||||
if (!['phone', 'phoneNumber', 'mobile', '手机号'].includes(header)) {
|
||||
row.variables[header] = cells[cellIndex] ?? '';
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function splitImportLine(line: string, delimiter: ',' | '\t') {
|
||||
return line.split(delimiter).map((cell) => cell.trim().replace(/^"|"$/g, ''));
|
||||
}
|
||||
|
||||
function cellByHeader(headers: string[], cells: string[], candidates: string[]) {
|
||||
const index = headers.findIndex((header) => candidates.includes(header));
|
||||
return index >= 0 ? cells[index] : undefined;
|
||||
}
|
||||
|
||||
function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ReviewDto, SmsConfigService } from './sms-config.service';
|
||||
import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@Controller('admin')
|
||||
@@ -46,4 +46,19 @@ export class AdminSmsConfigController {
|
||||
rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectTemplate(templateId, body);
|
||||
}
|
||||
|
||||
@Post('enterprise-applications/:id/status')
|
||||
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeApplicationStatus(applicationId, body);
|
||||
}
|
||||
|
||||
@Post('enterprise-signatures/:id/status')
|
||||
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeSignatureStatus(signatureId, body);
|
||||
}
|
||||
|
||||
@Post('enterprise-templates/:id/status')
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeTemplateStatus(templateId, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CreateSmsApplicationDto,
|
||||
CreateSmsSignatureDto,
|
||||
CreateSmsTemplateDto,
|
||||
StatusChangeDto,
|
||||
SmsConfigService,
|
||||
} from './sms-config.service';
|
||||
|
||||
@@ -24,6 +25,16 @@ export class ClientSmsConfigController {
|
||||
return this.smsConfig.createApplication(body);
|
||||
}
|
||||
|
||||
@Post('applications/:id/secret/reset')
|
||||
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.resetApplicationSecret(applicationId, body);
|
||||
}
|
||||
|
||||
@Post('applications/:id/status')
|
||||
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeApplicationStatus(applicationId, body);
|
||||
}
|
||||
|
||||
@Get('signatures')
|
||||
listSignatures(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listSignatures(tenantId);
|
||||
@@ -44,6 +55,11 @@ export class ClientSmsConfigController {
|
||||
return this.smsConfig.submitSignature(signatureId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/status')
|
||||
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeSignatureStatus(signatureId, body);
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
listTemplates(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listTemplates(tenantId);
|
||||
@@ -58,4 +74,9 @@ export class ClientSmsConfigController {
|
||||
submitTemplate(@Param('id') templateId: string) {
|
||||
return this.smsConfig.submitTemplate(templateId);
|
||||
}
|
||||
|
||||
@Post('templates/:id/status')
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeTemplateStatus(templateId, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
smsSignature: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsTemplate: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||
update: jest.fn(),
|
||||
},
|
||||
auditRecord: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('SmsConfigService', () => {
|
||||
it('rejects unknown reviewer ids before writing audit records', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.approveSignature('sig-1', { reviewerId: 'missing-user' })).rejects.toThrow(
|
||||
'reviewerId does not reference an existing user',
|
||||
);
|
||||
|
||||
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
||||
expect(prisma.auditRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -45,6 +45,12 @@ export interface ReviewDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface StatusChangeDto {
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsConfigService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -78,6 +84,37 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const secret = randomBytes(24).toString('hex');
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: { secretHash: hashSecret(secret) },
|
||||
});
|
||||
await this.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, {
|
||||
reason: data.reason,
|
||||
});
|
||||
return { ...updated, secret };
|
||||
}
|
||||
|
||||
async changeApplicationStatus(applicationId: string, data: StatusChangeDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const status = data.status ?? 'disabled';
|
||||
const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, data: { status } });
|
||||
await this.writeOperationLog(application.tenantId, data.operatorId, `sms_application.${status}`, 'sms_application', applicationId, {
|
||||
statusBefore: application.status,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
listSignatures(tenantId?: string) {
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
@@ -211,11 +248,42 @@ export class SmsConfigService {
|
||||
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
async changeSignatureStatus(signatureId: string, data: StatusChangeDto) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } });
|
||||
await this.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, {
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeTemplateStatus(templateId: string, data: StatusChangeDto) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
|
||||
await this.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, {
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
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 reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
|
||||
const updated = await this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
@@ -232,7 +300,7 @@ export class SmsConfigService {
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId: data.reviewerId,
|
||||
reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
@@ -242,6 +310,7 @@ export class SmsConfigService {
|
||||
if (!template) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
|
||||
const updated = await this.prisma.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
@@ -258,14 +327,45 @@ export class SmsConfigService {
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId: data.reviewerId,
|
||||
reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async resolveReviewerId(reviewerId?: string) {
|
||||
if (!reviewerId) {
|
||||
return undefined;
|
||||
}
|
||||
const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } });
|
||||
if (!reviewer) {
|
||||
throw new BadRequestException('reviewerId does not reference an existing user');
|
||||
}
|
||||
return reviewerId;
|
||||
}
|
||||
|
||||
private createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
|
||||
return this.prisma.auditRecord.create({ data });
|
||||
}
|
||||
|
||||
private writeOperationLog(
|
||||
tenantId: string,
|
||||
userId: string | undefined,
|
||||
action: string,
|
||||
resource: string,
|
||||
resourceId: string,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId,
|
||||
action,
|
||||
resource,
|
||||
resourceId,
|
||||
detail: detail as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface TemplateVariableInput {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user