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
+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 {