fix: close sms scheduling and billing gaps

This commit is contained in:
hectorzhao
2026-07-01 18:56:05 +08:00
parent 8ba4ef8a13
commit f8c9b78c21
28 changed files with 1480 additions and 26 deletions
@@ -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");
@@ -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;
+52
View File
@@ -11,10 +11,12 @@ model Tenant {
name String name String
code String @unique code String @unique
status String @default("active") status String @default("active")
certificationStatus String @default("approved")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
users User[] users User[]
enterpriseCertifications EnterpriseCertification[]
operationLogs OperationLog[] operationLogs OperationLog[]
fileObjects FileObject[] fileObjects FileObject[]
enterpriseBlacklists EnterpriseBlacklist[] enterpriseBlacklists EnterpriseBlacklist[]
@@ -35,6 +37,28 @@ model Tenant {
smsSubmitRecords SmsSubmitRecord[] smsSubmitRecords SmsSubmitRecord[]
smsReceiptRecords SmsReceiptRecord[] smsReceiptRecords SmsReceiptRecord[]
smsUplinkMessages SmsUplinkMessage[] 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 { model User {
@@ -449,10 +473,35 @@ model SmsChannel {
submitRecords SmsSubmitRecord[] submitRecords SmsSubmitRecord[]
receiptRecords SmsReceiptRecord[] receiptRecords SmsReceiptRecord[]
uplinkMessages SmsUplinkMessage[] uplinkMessages SmsUplinkMessage[]
connectionStates CmppConnectionState[]
@@index([status]) @@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 { model SmsChannelGroup {
id String @id @default(cuid()) id String @id @default(cuid())
code String @unique code String @unique
@@ -723,6 +772,8 @@ model SmsBatchTask {
failedTotal Int @default(0) failedTotal Int @default(0)
unknownTotal Int @default(0) unknownTotal Int @default(0)
timeoutTotal Int @default(0) timeoutTotal Int @default(0)
scheduledAt DateTime?
canceledAt DateTime?
createdById String? createdById String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -737,6 +788,7 @@ model SmsBatchTask {
receiptRecords SmsReceiptRecord[] receiptRecords SmsReceiptRecord[]
@@index([tenantId, status, createdAt]) @@index([tenantId, status, createdAt])
@@index([status, scheduledAt])
@@index([applicationId, createdAt]) @@index([applicationId, createdAt])
} }
+2
View File
@@ -4,6 +4,7 @@ import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BillingModule } from './billing/billing.module'; import { BillingModule } from './billing/billing.module';
import { ChannelsModule } from './channels/channels.module'; import { ChannelsModule } from './channels/channels.module';
import { CertificationModule } from './certification/certification.module';
import { DictionariesModule } from './dictionaries/dictionaries.module'; import { DictionariesModule } from './dictionaries/dictionaries.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
@@ -29,6 +30,7 @@ import { UsersModule } from './users/users.module';
FilesModule, FilesModule,
DictionariesModule, DictionariesModule,
BillingModule, BillingModule,
CertificationModule,
SmsConfigModule, SmsConfigModule,
ChannelsModule, ChannelsModule,
RiskReviewModule, 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;
}
}
+22
View File
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { import {
ChannelsService, ChannelsService,
ChangeChannelStatusDto,
CreateChannelDto, CreateChannelDto,
CreateChannelGroupDto, CreateChannelGroupDto,
CreateChannelGroupItemDto, CreateChannelGroupItemDto,
@@ -11,6 +12,7 @@ import {
CreateReportMaterialDto, CreateReportMaterialDto,
CreateReportTaskDto, CreateReportTaskDto,
CreateRouteRuleDto, CreateRouteRuleDto,
UpsertConnectionStateDto,
} from './channels.service'; } from './channels.service';
@ApiTags('channels') @ApiTags('channels')
@@ -33,11 +35,31 @@ export class ChannelsController {
return this.channels.testChannel(channelId); 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') @Get('channels/:id/metrics')
listChannelMetrics(@Param('id') channelId: string) { listChannelMetrics(@Param('id') channelId: string) {
return this.channels.listChannelMetrics(channelId); 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') @Get('channel-groups')
listGroups() { listGroups() {
return this.channels.listGroups(); return this.channels.listGroups();
+59
View File
@@ -6,6 +6,8 @@ function createPrismaMock() {
smsChannel: { smsChannel: {
findMany: jest.fn(), findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), 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() }, channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: { smsChannelGroup: {
@@ -46,6 +48,13 @@ function createPrismaMock() {
smsSignature: { smsSignature: {
update: jest.fn(), 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' }, 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,
});
});
}); });
+82
View File
@@ -92,6 +92,26 @@ export interface CreateReceiptImportDto {
result?: Record<string, unknown>; 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() @Injectable()
export class ChannelsService { export class ChannelsService {
constructor(private readonly prisma: PrismaService) {} 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) { testChannel(channelId: string) {
return { return {
channelId, 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() { listGroups() {
return this.prisma.smsChannelGroup.findMany({ return this.prisma.smsChannelGroup.findMany({
include: { items: { include: { channel: true } } }, include: { items: { include: { channel: true } } },
@@ -25,6 +25,9 @@ function createPrismaMock() {
accountTransaction: { accountTransaction: {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }), 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: { operationLog: {
findMany: jest.fn(), findMany: jest.fn(),
groupBy: jest.fn(), groupBy: jest.fn(),
@@ -66,7 +69,11 @@ describe('OperationsService', () => {
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.dashboard({ tenantId: 'tenant-1' })).resolves.toEqual( 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' }); await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
+8 -2
View File
@@ -72,7 +72,7 @@ export class OperationsService {
async dashboard(query: { tenantId?: string }) { async dashboard(query: { tenantId?: string }) {
const messageWhereClause = messageWhere({ tenantId: query.tenantId }); 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.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
this.prisma.smsMessageRecord.groupBy({ this.prisma.smsMessageRecord.groupBy({
by: ['status'], by: ['status'],
@@ -91,6 +91,12 @@ export class OperationsService {
_sum: { amountCents: true, smsUnits: true }, _sum: { amountCents: true, smsUnits: true },
_count: { _all: true }, _count: { _all: true },
}), }),
this.prisma.cmppConnectionState.groupBy({
by: ['status'],
where: { tenantId: query.tenantId },
_count: { _all: true },
_sum: { currentConnections: true, desiredConnections: true },
}),
]); ]);
return { return {
taskCount, taskCount,
@@ -98,6 +104,7 @@ export class OperationsService {
uplinkCount, uplinkCount,
billing: billingAggregate, billing: billingAggregate,
transactions: transactionAggregate, transactions: transactionAggregate,
gatewayConnections: connectionGroups,
}; };
} }
@@ -238,4 +245,3 @@ function normalizeGroupBy(groupBy?: string) {
} }
return 'channelId'; return 'channelId';
} }
@@ -13,6 +13,12 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
enterpriseBlacklist: { enterpriseBlacklist: {
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
}, },
sensitiveWord: {
findMany: jest.fn().mockResolvedValue([]),
},
user: {
findUnique: jest.fn().mockResolvedValue({ id: 'user-1' }),
},
smsApplication: { smsApplication: {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
}, },
@@ -161,10 +167,13 @@ describe('RiskReviewService', () => {
expect(prisma.smsSendTask.create).toHaveBeenCalledWith({ expect(prisma.smsSendTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
illegalRatio: 0.5, illegalRatio: 0.5,
variableIssues: expect.arrayContaining([ variableIssues: {
{ type: 'missing_required_variable', name: 'code' }, variables: expect.arrayContaining([
{ type: 'unexpected_variable', name: 'extra' }, { 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');
});
}); });
+45 -4
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -168,6 +168,12 @@ export class RiskReviewService {
async evaluateTask(data: EvaluateSmsTaskDto) { async evaluateTask(data: EvaluateSmsTaskDto) {
await this.ensureDefaultRules(); 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 phones = data.phones ?? [];
const uniquePhones = [...new Set(phones)]; const uniquePhones = [...new Set(phones)];
const phoneTotal = phones.length; const phoneTotal = phones.length;
@@ -177,15 +183,17 @@ export class RiskReviewService {
const illegalRatio = ratio(illegalCount, phoneTotal); const illegalRatio = ratio(illegalCount, phoneTotal);
const blacklistHitCount = await this.countBlacklistHits(data.tenantId, uniquePhones); const blacklistHitCount = await this.countBlacklistHits(data.tenantId, uniquePhones);
const blacklistHitRatio = ratio(blacklistHitCount, phoneTotal); 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.applicationId ? this.prisma.smsApplication.findUnique({ where: { id: data.applicationId } }) : null,
data.templateId data.templateId
? this.prisma.smsTemplate.findUnique({ where: { id: data.templateId }, include: { variables: true } }) ? this.prisma.smsTemplate.findUnique({ where: { id: data.templateId }, include: { variables: true } })
: null, : null,
this.effectiveRules(data.tenantId), this.effectiveRules(data.tenantId),
this.countRecentTasks(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 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 requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date();
const nonWorkingMarketingPhones = const nonWorkingMarketingPhones =
isMarketing(data.category ?? template?.category) && isNonWorkingTime(requestedAt) ? phoneTotal : 0; isMarketing(data.category ?? template?.category) && isNonWorkingTime(requestedAt) ? phoneTotal : 0;
@@ -199,6 +207,7 @@ export class RiskReviewService {
recentTaskCount, recentTaskCount,
variableIssueCount: variableIssues.length, variableIssueCount: variableIssues.length,
}); });
hits.push(...contentIssues.map(contentIssueToHit));
const decision = decideRiskAction(hits); const decision = decideRiskAction(hits);
const reason = hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null; const reason = hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null;
const task = await this.prisma.smsSendTask.create({ const task = await this.prisma.smsSendTask.create({
@@ -214,7 +223,7 @@ export class RiskReviewService {
duplicateRatio, duplicateRatio,
illegalRatio, illegalRatio,
blacklistHitRatio, blacklistHitRatio,
variableIssues: variableIssues as Prisma.InputJsonValue, variableIssues: { variables: variableIssues, content: contentIssues } as unknown as Prisma.InputJsonValue,
status: decision.status, status: decision.status,
riskDecision: decision.riskDecision, riskDecision: decision.riskDecision,
reviewReason: decision.status === 'pending_review' ? reason : null, 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) { function inferVariables(content: string) {
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? []; const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true })); return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
@@ -450,4 +492,3 @@ function formatAction(action: string) {
} }
return '放行'; return '放行';
} }
@@ -37,9 +37,13 @@ export class AdminSendChainController {
return this.sendChain.enqueueBatchTask(taskId); return this.sendChain.enqueueBatchTask(taskId);
} }
@Post('scheduled/dispatch-due')
dispatchDueScheduledTasks() {
return this.sendChain.dispatchDueScheduledTasks();
}
@Post('timeouts/mark-unknown') @Post('timeouts/mark-unknown')
markUnknownTimeout(@Body() body: TimeoutUnknownDto) { markUnknownTimeout(@Body() body: TimeoutUnknownDto) {
return this.sendChain.markUnknownTimeout(body); return this.sendChain.markUnknownTimeout(body);
} }
} }
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator'; 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') @ApiTags('client-send-chain')
@Controller('client/send') @Controller('client/send')
@@ -13,6 +13,16 @@ export class ClientSendChainController {
return this.sendChain.createBatchTask(body); 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') @Get('batch-tasks')
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) { listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
return this.sendChain.listBatchTasks(tenantId, status); return this.sendChain.listBatchTasks(tenantId, status);
@@ -27,5 +37,9 @@ export class ClientSendChainController {
listTaskMessages(@Param('id') taskId: string) { listTaskMessages(@Param('id') taskId: string) {
return this.sendChain.listMessages(taskId); return this.sendChain.listMessages(taskId);
} }
}
@Post('batch-tasks/:id/cancel')
cancelBatchTask(@Param('id') taskId: string) {
return this.sendChain.cancelBatchTask(taskId);
}
}
+161 -3
View File
@@ -14,6 +14,8 @@ function createPrismaMock() {
phoneNumber: '13800000001', phoneNumber: '13800000001',
content: 'hello', content: 'hello',
billingUnits: 1, billingUnits: 1,
unitPrice: 3,
amountCents: 3,
status: 'queued', status: 'queued',
template: { signature: { name: '签名' } }, template: { signature: { name: '签名' } },
}; };
@@ -23,10 +25,26 @@ function createPrismaMock() {
account: 'cmpp-account', account: 'cmpp-account',
srcId: '10690000', srcId: '10690000',
rateLimitPerSecond: 100, rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active', status: 'active',
config: { serviceId: 'SMS' }, config: { serviceId: 'SMS' },
}; };
return { 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: { smsBatchTask: {
create: jest.fn().mockResolvedValue(task), create: jest.fn().mockResolvedValue(task),
findUnique: 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 })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
findMany: jest.fn(), 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 = { const billing = {
estimateSmsCost: jest.fn().mockReturnValue({ estimateSmsCost: jest.fn().mockReturnValue({
billingUnitsPerMessage: 1, billingUnitsPerMessage: 1,
totalBillingUnits: 2,
unitPrice: 3, unitPrice: 3,
amountCents: 6, 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; } as unknown as BillingService;
const riskReview = { const riskReview = {
evaluateTask: jest.fn().mockResolvedValue({ evaluateTask: jest.fn().mockResolvedValue({
@@ -92,7 +128,7 @@ function createService(prisma = createPrismaMock()) {
describe('SendChainService', () => { describe('SendChainService', () => {
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => { 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 }); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
await service.createBatchTask({ await service.createBatchTask({
@@ -115,9 +151,105 @@ describe('SendChainService', () => {
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3 }), 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'); 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 () => { it('adds queued message jobs for a batch task', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
const add = jest.fn().mockResolvedValue(undefined); const add = jest.fn().mockResolvedValue(undefined);
@@ -155,8 +287,8 @@ describe('SendChainService', () => {
); );
}); });
it('updates submit result status and task progress', async () => { it('updates submit result status, charges billing, and task progress', async () => {
const { service, prisma } = createService(); const { service, prisma, billing } = createService();
await service.handleSubmitResult({ await service.handleSubmitResult({
messageId: 'MSG-1', messageId: 'MSG-1',
@@ -176,6 +308,32 @@ describe('SendChainService', () => {
where: { id: 'record-1' }, where: { id: 'record-1' },
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }), 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 () => { it('records receipts and uplink messages from gateway events', async () => {
+388 -4
View File
@@ -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 { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis'; import IORedis from 'ioredis';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -14,6 +14,8 @@ export interface CreateBatchTaskDto {
content: string; content: string;
category?: string; category?: string;
phones: string[]; phones: string[];
sendMode?: 'immediate' | 'scheduled';
scheduledAt?: string;
variables?: Record<string, unknown>; variables?: Record<string, unknown>;
createdById?: string; createdById?: string;
sourceIp?: string; sourceIp?: string;
@@ -60,6 +62,20 @@ export interface TimeoutUnknownDto {
olderThanHours?: number; 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 { interface SendJob {
messageRecordId: string; messageRecordId: string;
} }
@@ -95,6 +111,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async createBatchTask(data: CreateBatchTaskDto) { async createBatchTask(data: CreateBatchTaskDto) {
const phones = [...new Set(data.phones ?? [])]; 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({ const risk = await this.riskReview.evaluateTask({
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId,
@@ -111,8 +130,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
taskId: risk.task?.id, taskId: risk.task?.id,
content: data.content, content: data.content,
phoneCount: phones.length, 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({ const task = await this.prisma.smsBatchTask.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
@@ -129,9 +160,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
reviewReason: risk.status === 'pending_review' ? risk.reason : null, reviewReason: risk.status === 'pending_review' ? risk.reason : null,
rejectReason: risk.status === 'rejected' ? risk.reason : null, rejectReason: risk.status === 'rejected' ? risk.reason : null,
progressTotal: phones.length, progressTotal: phones.length,
scheduledAt: schedule.scheduledAt,
createdById: data.createdById, 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({ await this.prisma.smsApiRequest.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
@@ -143,6 +185,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
phoneTotal: phones.length, phoneTotal: phones.length,
contentLength: [...data.content].length, contentLength: [...data.content].length,
category: data.category, category: data.category,
sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: schedule.scheduledAt?.toISOString(),
}, },
status: batchStatus === 'rejected' ? 'rejected' : 'accepted', status: batchStatus === 'rejected' ? 'rejected' : 'accepted',
}, },
@@ -160,7 +204,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
billingUnits: billing.billingUnitsPerMessage, billingUnits: billing.billingUnitsPerMessage,
unitPrice: billing.unitPrice, unitPrice: billing.unitPrice,
amountCents: billing.billingUnitsPerMessage * 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, 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) { async enqueueBatchTask(taskId: string) {
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } }); const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
if (!task) { if (!task) {
throw new NotFoundException('SMS batch task not found'); 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({ const messages = await this.prisma.smsMessageRecord.findMany({
where: { batchTaskId: taskId, status: 'queued' }, where: { batchTaskId: taskId, status: 'queued' },
select: { id: true }, select: { id: true },
@@ -237,6 +351,77 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return { taskId, enqueued: messages.length }; 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() { startWorker() {
if (this.worker) { if (this.worker) {
return { status: 'already_started' }; 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'; 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({ await this.prisma.smsMessageRecord.update({
where: { id: message.id }, where: { id: message.id },
data: { data: {
@@ -353,6 +543,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const status = const status =
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed'; data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
if (status === 'failed') {
await this.refundMessage(message, '最终失败退款');
}
await this.prisma.smsReceiptRecord.create({ await this.prisma.smsReceiptRecord.create({
data: { data: {
tenantId: message.tenantId, tenantId: message.tenantId,
@@ -418,6 +611,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
where: { id: { in: candidates.map((candidate) => candidate.id) } }, where: { id: { in: candidates.map((candidate) => candidate.id) } },
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: '72小时未收到明确回执,自动转超时' }, 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))) { for (const batchTaskId of new Set(candidates.map((candidate) => candidate.batchTaskId))) {
await this.refreshTaskProgress(batchTaskId); await this.refreshTaskProgress(batchTaskId);
} }
@@ -444,6 +643,135 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return channel; 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) { private async waitForChannelRateLimit(channelId: string, tps: number) {
const redis = this.getRedis(); const redis = this.getRedis();
for (;;) { for (;;) {
@@ -520,16 +848,72 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
} }
function statusFromRisk(status: string) { function statusFromRisk(status: string, scheduled: boolean) {
if (status === 'rejected') { if (status === 'rejected') {
return 'rejected'; return 'rejected';
} }
if (status === 'pending_review') { if (status === 'pending_review') {
return 'pending_review'; return 'pending_review';
} }
if (scheduled) {
return 'scheduled';
}
return 'ready'; 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() { function bullmqConnection() {
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
return { return {
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { ReviewDto, SmsConfigService } from './sms-config.service'; import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service';
@ApiTags('admin-sms-config') @ApiTags('admin-sms-config')
@Controller('admin') @Controller('admin')
@@ -46,4 +46,19 @@ export class AdminSmsConfigController {
rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) { rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
return this.smsConfig.rejectTemplate(templateId, body); 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, CreateSmsApplicationDto,
CreateSmsSignatureDto, CreateSmsSignatureDto,
CreateSmsTemplateDto, CreateSmsTemplateDto,
StatusChangeDto,
SmsConfigService, SmsConfigService,
} from './sms-config.service'; } from './sms-config.service';
@@ -24,6 +25,16 @@ export class ClientSmsConfigController {
return this.smsConfig.createApplication(body); 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') @Get('signatures')
listSignatures(@TenantId() tenantId?: string) { listSignatures(@TenantId() tenantId?: string) {
return this.smsConfig.listSignatures(tenantId); return this.smsConfig.listSignatures(tenantId);
@@ -44,6 +55,11 @@ export class ClientSmsConfigController {
return this.smsConfig.submitSignature(signatureId); 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') @Get('templates')
listTemplates(@TenantId() tenantId?: string) { listTemplates(@TenantId() tenantId?: string) {
return this.smsConfig.listTemplates(tenantId); return this.smsConfig.listTemplates(tenantId);
@@ -58,4 +74,9 @@ export class ClientSmsConfigController {
submitTemplate(@Param('id') templateId: string) { submitTemplate(@Param('id') templateId: string) {
return this.smsConfig.submitTemplate(templateId); 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();
});
});
+103 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomBytes, createHash } from 'node:crypto'; import { randomBytes, createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -45,6 +45,12 @@ export interface ReviewDto {
reason?: string; reason?: string;
} }
export interface StatusChangeDto {
status?: string;
operatorId?: string;
reason?: string;
}
@Injectable() @Injectable()
export class SmsConfigService { export class SmsConfigService {
constructor(private readonly prisma: PrismaService) {} 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) { listSignatures(tenantId?: string) {
return this.prisma.smsSignature.findMany({ return this.prisma.smsSignature.findMany({
where: tenantId ? { tenantId } : undefined, where: tenantId ? { tenantId } : undefined,
@@ -211,11 +248,42 @@ export class SmsConfigService {
return this.reviewTemplate(templateId, 'rejected', 'reject', data); 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) { private async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) { if (!signature) {
throw new NotFoundException('Signature not found'); throw new NotFoundException('Signature not found');
} }
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsSignature.update({ const updated = await this.prisma.smsSignature.update({
where: { id: signatureId }, where: { id: signatureId },
@@ -232,7 +300,7 @@ export class SmsConfigService {
statusBefore: signature.auditStatus, statusBefore: signature.auditStatus,
statusAfter, statusAfter,
reason: data.reason, reason: data.reason,
reviewerId: data.reviewerId, reviewerId,
}); });
return updated; return updated;
} }
@@ -242,6 +310,7 @@ export class SmsConfigService {
if (!template) { if (!template) {
throw new NotFoundException('Template not found'); throw new NotFoundException('Template not found');
} }
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsTemplate.update({ const updated = await this.prisma.smsTemplate.update({
where: { id: templateId }, where: { id: templateId },
@@ -258,14 +327,45 @@ export class SmsConfigService {
statusBefore: template.auditStatus, statusBefore: template.auditStatus,
statusAfter, statusAfter,
reason: data.reason, reason: data.reason,
reviewerId: data.reviewerId, reviewerId,
}); });
return updated; 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) { private createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
return this.prisma.auditRecord.create({ data }); 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 { interface TemplateVariableInput {
File diff suppressed because one or more lines are too long
+15
View File
@@ -96,6 +96,21 @@ node <inline step5 schedule probe>
主要缺口是定时发送未实现,且发送链路尚未把余额冻结扣费、模板/签名/报备状态阻断等业务规则接入完整闭环。 主要缺口是定时发送未实现,且发送链路尚未把余额冻结扣费、模板/签名/报备状态阻断等业务规则接入完整闭环。
## 缺口修复复测记录
- 定时发送已补齐 `scheduledAt``canceledAt` 字段,支持 `sendMode=scheduled` 创建 scheduled 任务。
- 新增 `POST /api/client/send/batch-tasks/:id/cancel` 支持到点前取消。
- 新增 `POST /api/admin/send/scheduled/dispatch-due` 支持到点触发入队。
- 到点触发前会重新校验企业状态、认证状态、应用状态、模板审核状态、签名审核/报备状态和账户余额。
- `api/src/send-chain/send-chain.service.spec.ts` 已覆盖定时创建、取消、到点触发和到点余额/资源校验基础路径。
复测命令:
```bash
npm --prefix api test -- send-chain.service.spec.ts
npm --prefix api run build
```
## 下一步 ## 下一步
进入第 6 步:执行计费和对账闭环测试。重点覆盖人工充值、费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录、对账 reconciliation,以及 dashboard/statistics 中计费相关数据准确性。 进入第 6 步:执行计费和对账闭环测试。重点覆盖人工充值、费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录、对账 reconciliation,以及 dashboard/statistics 中计费相关数据准确性。
+16
View File
@@ -90,6 +90,22 @@ node <inline step6 auto-billing probe>
主要缺口是发送链路尚未自动调用计费闭环:发送任务创建后不会自动冻结或扣费,也不会自动生成短信计费记录,消息金额默认为 0。后续需要把第 5 步发送链路和第 6 步计费能力连接起来。 主要缺口是发送链路尚未自动调用计费闭环:发送任务创建后不会自动冻结或扣费,也不会自动生成短信计费记录,消息金额默认为 0。后续需要把第 5 步发送链路和第 6 步计费能力连接起来。
## 缺口修复复测记录
- 发送创建时会按通道单价写入 `SmsMessageRecord.billingUnits/unitPrice/amountCents`
- 立即发送创建时执行账户检查和冻结;定时发送创建时检查余额,到点触发再冻结。
- Gateway submit accepted 后会生成/更新 `SmsBillingRecord`,写入 charged 流水。
- Gateway submit rejected/timeout 会释放冻结。
- 最终失败回执和 72 小时 unknown 转 timeout 会执行退款并更新计费记录状态。
- trace、dashboard 和 reconciliation 可通过消息金额、计费记录和账户交易聚合看到自动计费数据。
复测命令:
```bash
npm --prefix api test -- send-chain.service.spec.ts billing.service.spec.ts operations.service.spec.ts
npm --prefix api run build
```
## 下一步 ## 下一步
进入第 7 步:执行 CMPP 连接状态、通道路由、客户/通道连接数量管理和展示测试。重点覆盖客户连接状态、通道 CMPP 连接状态、连接数量展示、通道 health/metrics、路由规则和 Gateway 相关契约。 进入第 7 步:执行 CMPP 连接状态、通道路由、客户/通道连接数量管理和展示测试。重点覆盖客户连接状态、通道 CMPP 连接状态、连接数量展示、通道 health/metrics、路由规则和 Gateway 相关契约。
+36
View File
@@ -90,6 +90,42 @@ npm run test:gateway
| Gateway 未接真实运营商 SMSC | 真实 CMPP 互通仍需联调环境验证。 | 在运营商测试环境补充联调测试报告。 | | Gateway 未接真实运营商 SMSC | 真实 CMPP 互通仍需联调环境验证。 | 在运营商测试环境补充联调测试报告。 |
| 前端自动化测试缺失 | 当前只有 build smoke,缺少页面级断言。 | 后续补充 Playwright/Vitest smoke 覆盖 dashboard、发送、配置核心页面。 | | 前端自动化测试缺失 | 当前只有 build smoke,缺少页面级断言。 | 后续补充 Playwright/Vitest smoke 覆盖 dashboard、发送、配置核心页面。 |
## 缺口修复复测追加
本轮已修复或补齐以下原缺口:
- 定时短信:新增计划发送时间、scheduled 状态、取消接口、到点触发和到点重校验。
- 自动计费:发送链路接入余额检查、冻结、扣费、释放、退款和短信计费记录。
- 内容校验:发送前风控接入敏感词和控制字符拦截。
- 企业认证:新增企业认证模型/API,并接入发送前阻断。
- 导入发送:新增客户侧导入预览/确认 API,覆盖重复、非法、黑名单和变量列缺失。
- 配置状态:应用、签名、模板、通道状态变化和应用密钥重置写入系统日志,并影响后续发送校验。
- CMPP 连接状态:新增连接状态模型/API,支持客户/通道维度查询和 dashboard 聚合。
- 接口健壮性:无效 reviewerId/createdById 不再返回数据库外键 500。
已执行复测命令:
```bash
npm run prisma:generate
npm --prefix api test
npm --prefix api run build
npm --prefix api run prisma:migrate:deploy
npm run verify:phase8
npm run test:api
npm run test:gateway
```
最终复测结果:
- `npm run verify:phase8` 通过,BullMQ 15000 条消息、并发 500,端到端 TPS 608.93,满足 500 TPSPrisma generate、API build、前端 build 均通过。
- `npm run test:api` 通过,7 个 test suite、34 个 tests 全部通过。
- `npm run test:gateway` 通过,Go Gateway health、connection、tracker、cmpp、spike 测试全部通过。
当前剩余说明:
- Gateway 仍不连接真实运营商 SMSC,继续使用本地模拟器、mock 回写和契约测试。
- 前端自动化测试仍未新增,当前继续以 build smoke 为主。
## 最终结论 ## 最终结论
第一版系统化测试第 1 步到第 8 步已全部执行并归档。当前版本在 mock 单元/轻集成、真实环境 smoke、Go Gateway 测试、BullMQ 性能 smoke、API build 和前端 build 层面均通过。 第一版系统化测试第 1 步到第 8 步已全部执行并归档。当前版本在 mock 单元/轻集成、真实环境 smoke、Go Gateway 测试、BullMQ 性能 smoke、API build 和前端 build 层面均通过。
+66
View File
@@ -107,3 +107,69 @@ npm run test:gateway
- 定时短信发送缺少计划发送时间字段、scheduled 状态、到点触发和取消接口。 - 定时短信发送缺少计划发送时间字段、scheduled 状态、到点触发和取消接口。
- 发送链路尚未自动接入计费闭环,发送任务不会自动冻结/扣费/生成短信计费记录。 - 发送链路尚未自动接入计费闭环,发送任务不会自动冻结/扣费/生成短信计费记录。
- 客户/通道 CMPP 连接状态和连接数量尚未形成运营端/客户端一等接口,Gateway health 未聚合到 NestJS dashboard。 - 客户/通道 CMPP 连接状态和连接数量尚未形成运营端/客户端一等接口,Gateway health 未聚合到 NestJS dashboard。
## 2026-07-01 缺口修复复测
### 本轮修复范围
- P0 定时短信发送闭环:
- `SmsBatchTask` 增加 `scheduledAt``canceledAt` 字段和 `status/scheduledAt` 索引。
- `CreateBatchTaskDto` 支持 `sendMode=scheduled``scheduledAt`
- 新增定时任务取消和到点触发入口:`POST /api/client/send/batch-tasks/:id/cancel``POST /api/admin/send/scheduled/dispatch-due`
- 到点触发前重新校验企业状态、认证状态、应用状态、模板审核状态、签名审核/报备状态和账户余额。
- P0 发送链路自动计费闭环:
- 发送创建时按通道单价写入消息计费条数、单价和金额。
- 立即发送创建时执行账户检查和冻结;定时发送创建时检查余额,到点再冻结。
- submit accepted 后生成/更新 `SmsBillingRecord`、写入 charged 流水;submit rejected/timeout 释放冻结;失败回执和 72 小时超时执行退款。
- P0 发送前内容校验:
- 风控评估接入敏感词字典和控制字符扫描。
- `variableIssues` 扩展为 `{ variables, content }`,同时保留变量异常和内容异常证据。
- emoji/UCS2、多空格和换行不直接拒绝,继续通过 70/67 字符长度影响计费。
- P1/P2 补齐:
- 新增企业认证模型/API,提交、审核通过、驳回会同步 `Tenant.certificationStatus`,发送前强制认证通过。
- 新增客户侧导入预览/确认入口,覆盖 CSV/TXT 文本解析、20MB 限制、重复/非法/黑名单/变量缺失提示。
- 新增客户/通道 CMPP 连接状态模型/APIGateway/mock 可回写连接状态,运营 dashboard 聚合连接状态。
- 新增应用密钥重置、应用/签名/模板状态变化、通道启停接口,并写入系统日志。
- 无效 `createdById``reviewerId` 改为明确 400,不再冒泡数据库外键 500。
### 新增/更新测试
| 测试文件 | 新增覆盖 |
| --- | --- |
| `api/src/send-chain/send-chain.service.spec.ts` | TC-SCHEDULE-001 到 006、TC-BILLING-AUTO-001 到 004、TC-IMPORT-001 到 006 子集、企业认证/状态阻断。 |
| `api/src/risk-review/risk-review.service.spec.ts` | TC-CONTENT-001 到 004 子集、敏感词和控制字符拦截、无效 createdById。 |
| `api/src/certification/certification.service.spec.ts` | TC-CERT-001 到 004 子集,认证提交、审核、驳回和租户状态同步。 |
| `api/src/channels/channels.service.spec.ts` | 通道启停日志、通道连接状态 upsert/list、客户连接状态 list。 |
| `api/src/sms-config/sms-config.service.spec.ts` | 无效 reviewerId 400,避免审核外键 500。 |
| `api/src/operations/operations.service.spec.ts` | Dashboard Gateway 连接状态聚合。 |
### 已执行命令
```bash
npm run prisma:generate
npm --prefix api test -- send-chain.service.spec.ts risk-review.service.spec.ts sms-config.service.spec.ts
npm --prefix api test
npm --prefix api run build
npm --prefix api run prisma:migrate:deploy
npm run verify:phase8
npm run test:api
npm run test:gateway
```
### 当前结果
- Prisma Client 生成通过。
- 新增迁移已应用到真实 PostgreSQL:
- `20260701103000_add_scheduled_sms_fields`
- `20260701110000_add_certification_and_connection_state`
- API build 通过。
- API Jest7 个 test suite 通过,34 个测试通过。
- 最终回归通过:
- `npm run verify:phase8` 通过,BullMQ 15000 条消息、并发 500、端到端 TPS 608.93,满足 500 TPSPrisma generate、API build、前端 build 均通过。
- `npm run test:api` 通过,7 个 test suite、34 个 tests 全部通过。
- `npm run test:gateway` 通过,Go Gateway health、connection、tracker、cmpp、spike 测试全部通过。
### 剩余说明
- 客户侧导入当前提供 API 级文本预览/确认闭环;浏览器端真实文件选择、GBK 二进制转码和错误文件下载仍需前端/E2E 后续覆盖。
- Gateway 连接状态通过 NestJS API 支持 mock/Gateway 回写;真实运营商 SMSC 联调仍需运营商测试环境。