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
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])
}
+2
View File
@@ -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;
}
}
+22
View File
@@ -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();
+59
View File
@@ -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,
});
});
});
+82
View File
@@ -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' });
+8 -2
View File
@@ -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');
});
});
+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 { 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);
}
}
+161 -3
View File
@@ -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 () => {
+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 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();
});
});
+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 { 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