feat: aggregate cmpp template mismatch reviews

This commit is contained in:
hectorzhao
2026-07-12 13:25:19 +08:00
parent c8b6d0aa7c
commit 8b6ec92f4f
17 changed files with 523 additions and 38 deletions
@@ -166,6 +166,17 @@ function createPrismaMock() {
}
describe('OperationsService', () => {
it('keeps task progress limited to client-created batch tasks', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.listBatchTasks({ tenantId: 'tenant-1', status: 'queued' });
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' },
}));
});
it('filters send-chain messages by tenant, application, channel, content, date, task, phone, and status', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
+1 -1
View File
@@ -78,7 +78,7 @@ export class OperationsService {
listBatchTasks(query: { tenantId?: string; status?: string }) {
return this.prisma.smsBatchTask.findMany({
where: { tenantId: query.tenantId, status: query.status },
where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' },
include: { apiRequests: true },
orderBy: { createdAt: 'desc' },
});
@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SendChainService } from '../send-chain/send-chain.service';
import {
CreateRiskRuleDto,
ReviewSmsTaskDto,
@@ -9,7 +10,7 @@ import {
@ApiTags('risk-review')
@Controller('admin/risk-review')
export class AdminRiskReviewController {
constructor(private readonly riskReview: RiskReviewService) {}
constructor(private readonly riskReview: RiskReviewService, private readonly sendChain: SendChainService) {}
@Get('rules')
listRules(@Query('tenantId') tenantId?: string) {
@@ -37,13 +38,16 @@ export class AdminRiskReviewController {
}
@Post('tasks/:id/approve')
approveTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) {
return this.riskReview.approveTask(taskId, body);
async approveTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) {
const task = await this.riskReview.approveTask(taskId, body);
await this.sendChain.handleReviewDecision(taskId, 'approved', body.reason ?? '运营审核通过');
return task;
}
@Post('tasks/:id/reject')
rejectTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) {
return this.riskReview.rejectTask(taskId, body);
async rejectTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) {
const task = await this.riskReview.rejectTask(taskId, body);
await this.sendChain.handleReviewDecision(taskId, 'rejected', body.reason ?? task.rejectReason ?? '运营审核驳回');
return task;
}
}
+3 -3
View File
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AdminRiskReviewController } from './admin-risk-review.controller';
import { ClientRiskReviewController } from './client-risk-review.controller';
import { RiskReviewService } from './risk-review.service';
import { SendChainModule } from '../send-chain/send-chain.module';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, forwardRef(() => SendChainModule)],
controllers: [AdminRiskReviewController, ClientRiskReviewController],
providers: [RiskReviewService],
exports: [RiskReviewService],
})
export class RiskReviewModule {}
@@ -33,16 +33,65 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
findUnique: jest.fn().mockResolvedValue({ id: 'risk-task-1', riskHits: [] }),
update: jest.fn(),
findMany: jest.fn(),
upsert: jest.fn().mockImplementation(({ create }: { create: Record<string, unknown> }) => Promise.resolve({ id: 'review-task-1', ...create })),
},
smsMessageRecord: {
update: jest.fn().mockResolvedValue({ id: 'message-1' }),
},
riskHitRecord: {
createMany: jest.fn(),
findMany: jest.fn(),
},
$transaction: jest.fn(async (callback) => callback({
smsSendTask: {
upsert: jest.fn().mockImplementation(({ create }: { create: Record<string, unknown> }) => Promise.resolve({ id: 'review-task-1', ...create })),
},
smsMessageRecord: {
update: jest.fn().mockResolvedValue({ id: 'message-1' }),
},
})),
...overrides,
};
}
describe('RiskReviewService', () => {
it('groups identical CMPP template mismatches into a deterministic short review window', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findUnique.mockResolvedValue({
id: 'review-task-1',
sourceType: 'cmpp_template_mismatch',
phoneTotal: 2,
_count: { messageRecords: 2 },
riskHits: [],
});
const service = new RiskReviewService(prisma as never);
const result = await service.aggregateTemplateMismatch({
tenantId: 'tenant-1',
applicationId: 'app-1',
account: '100001',
messageRecordId: 'message-1',
signatureId: 'sig-1',
content: '【签名】同一审核内容',
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(result).toEqual(expect.objectContaining({ sourceType: 'cmpp_template_mismatch', phoneTotal: 2 }));
});
it('does not allow an aggregated review task to be decided before its window closes', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findUnique.mockResolvedValue({
id: 'review-task-1',
sourceType: 'cmpp_template_mismatch',
windowEndsAt: new Date(Date.now() + 10_000),
});
const service = new RiskReviewService(prisma as never);
await expect(service.approveTask('review-task-1', { reason: '通过' })).rejects.toThrow('聚合窗口尚未关闭');
expect(prisma.smsSendTask.update).not.toHaveBeenCalled();
});
it('rejects tasks over the application max phone threshold', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', maxPhonesPerTask: 2 });
+80 -2
View File
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateRiskRuleDto {
@@ -33,6 +33,15 @@ export interface ReviewSmsTaskDto {
reason?: string;
}
export interface AggregateTemplateMismatchDto {
tenantId: string;
applicationId: string;
account: string;
messageRecordId: string;
signatureId: string;
content: string;
}
interface RuleEvaluation {
ruleId?: string;
ruleCode: string;
@@ -153,8 +162,14 @@ export class RiskReviewService {
where: {
tenantId,
status,
...(status === 'pending_review' ? {
OR: [
{ sourceType: { not: 'cmpp_template_mismatch' } },
{ windowEndsAt: { lte: new Date() } },
],
} : {}),
},
include: { riskHits: true },
include: { riskHits: true, _count: { select: { messageRecords: true } } },
orderBy: { createdAt: 'desc' },
});
}
@@ -163,6 +178,61 @@ export class RiskReviewService {
return this.listTasks(undefined, 'pending_review');
}
async aggregateTemplateMismatch(data: AggregateTemplateMismatchDto) {
const normalizedContent = data.content.replace(/\r\n/g, '\n').trim();
const contentHash = createHash('sha256').update(normalizedContent, 'utf8').digest('hex');
const windowMs = Math.max(1_000, Number(process.env.CMPP_TEMPLATE_REVIEW_WINDOW_MS ?? 10_000));
const now = new Date();
const windowStartedAt = new Date(Math.floor(now.getTime() / windowMs) * windowMs);
const windowEndsAt = new Date(windowStartedAt.getTime() + windowMs);
const aggregationKey = createHash('sha256')
.update(`${data.applicationId}|${data.account}|${contentHash}|${windowStartedAt.toISOString()}`, 'utf8')
.digest('hex');
const task = await this.prisma.$transaction(async (tx) => {
const aggregated = await tx.smsSendTask.upsert({
where: { aggregationKey },
update: {
phoneTotal: { increment: 1 },
uniquePhoneTotal: { increment: 1 },
},
create: {
tenantId: data.tenantId,
applicationId: data.applicationId,
taskNo: `CMPP-REVIEW-${windowStartedAt.getTime()}-${aggregationKey.slice(0, 8)}`,
sourceType: 'cmpp_template_mismatch',
aggregationKey,
contentHash,
windowStartedAt,
windowEndsAt,
content: normalizedContent,
phoneTotal: 1,
uniquePhoneTotal: 1,
status: 'pending_review',
riskDecision: 'manual_review',
reviewReason: '企业应用已配置模板不匹配进入人工审核',
variableIssues: {
sourceType: 'cmpp',
account: data.account,
aggregationWindowMs: windowMs,
} as Prisma.InputJsonValue,
},
});
await tx.smsMessageRecord.update({
where: { id: data.messageRecordId },
data: {
reviewTaskId: aggregated.id,
signatureId: data.signatureId,
status: 'pending_review',
},
});
return aggregated;
});
return this.prisma.smsSendTask.findUnique({
where: { id: task.id },
include: { riskHits: true, _count: { select: { messageRecords: true } } },
});
}
async evaluateTask(data: EvaluateSmsTaskDto) {
await this.ensureDefaultRules();
if (data.createdById) {
@@ -261,6 +331,7 @@ export class RiskReviewService {
if (!task) {
throw new NotFoundException('SMS send task not found');
}
this.assertAggregationWindowClosed(task);
return this.prisma.smsSendTask.update({
where: { id: taskId },
data: {
@@ -280,6 +351,7 @@ export class RiskReviewService {
if (!task) {
throw new NotFoundException('SMS send task not found');
}
this.assertAggregationWindowClosed(task);
const reason = data.reason ?? task.reviewReason ?? '审核拒绝';
return this.prisma.smsSendTask.update({
where: { id: taskId },
@@ -306,6 +378,12 @@ export class RiskReviewService {
}
}
private assertAggregationWindowClosed(task: { sourceType?: string | null; windowEndsAt?: Date | null }) {
if (task.sourceType === 'cmpp_template_mismatch' && task.windowEndsAt && task.windowEndsAt.getTime() > Date.now()) {
throw new BadRequestException('聚合窗口尚未关闭,请在窗口结束后审核');
}
}
private async effectiveRules(tenantId: string) {
const rules = await this.prisma.riskRule.findMany({
where: {
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto, SendChainService } from './send-chain.service';
@@ -25,21 +25,28 @@ export class ClientSendChainController {
@Get('batch-tasks')
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
return this.sendChain.listBatchTasks(tenantId, status);
return this.sendChain.listBatchTasks(requireTenantId(tenantId), status, 'client');
}
@Get('batch-tasks/:id')
getBatchTask(@Param('id') taskId: string) {
return this.sendChain.getBatchTask(taskId);
getBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.getBatchTask(taskId, requireTenantId(tenantId), 'client');
}
@Get('batch-tasks/:id/messages')
listTaskMessages(@Param('id') taskId: string) {
return this.sendChain.listMessages({ taskId });
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.listClientTaskMessages(taskId, requireTenantId(tenantId));
}
@Post('batch-tasks/:id/cancel')
cancelBatchTask(@Param('id') taskId: string) {
return this.sendChain.cancelBatchTask(taskId);
cancelBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.cancelBatchTask(taskId, requireTenantId(tenantId), 'client');
}
}
function requireTenantId(tenantId?: string) {
if (!tenantId) {
throw new BadRequestException('Tenant context is required');
}
return tenantId;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { BillingModule } from '../billing/billing.module';
import { PrismaModule } from '../prisma/prisma.module';
import { RiskReviewModule } from '../risk-review/risk-review.module';
@@ -9,7 +9,7 @@ import { GatewayEventsController } from './gateway-events.controller';
import { SendChainService } from './send-chain.service';
@Module({
imports: [PrismaModule, BillingModule, RiskReviewModule, SmsConfigModule],
imports: [PrismaModule, BillingModule, forwardRef(() => RiskReviewModule), SmsConfigModule],
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
providers: [SendChainService],
exports: [SendChainService],
+105 -2
View File
@@ -95,9 +95,16 @@ function createPrismaMock() {
signature: { auditStatus: 'approved', reportStatus: 'approved' },
}),
},
smsSignature: {
findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'approved' }),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
},
smsBatchTask: {
create: jest.fn().mockResolvedValue(task),
findUnique: jest.fn().mockResolvedValue(task),
findFirst: jest.fn().mockResolvedValue(task),
findMany: jest.fn(),
update: jest.fn().mockResolvedValue(task),
},
@@ -296,6 +303,10 @@ function createService(prisma = createPrismaMock()) {
reason: null,
task: { id: 'risk-task-1' },
}),
aggregateTemplateMismatch: jest.fn().mockResolvedValue({
id: 'review-task-1',
reviewReason: '企业应用已配置模板不匹配进入人工审核',
}),
} as unknown as RiskReviewService;
const service = new SendChainService(prisma as never, billing, riskReview);
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
@@ -372,7 +383,7 @@ describe('SendChainService', () => {
it('cancels scheduled tasks before dispatch', async () => {
const { service, prisma } = createService();
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'scheduled' });
prisma.smsBatchTask.findFirst.mockResolvedValue({ id: 'task-1', status: 'scheduled' });
await service.cancelBatchTask('task-1');
@@ -386,6 +397,30 @@ describe('SendChainService', () => {
});
});
it('lists only client-created batch tasks for task progress', async () => {
const { service, prisma } = createService();
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-client', tenantId: 'tenant-1', sourceType: 'client' }]);
prisma.smsMessageRecord.groupBy.mockResolvedValue([]);
await service.listBatchTasks('tenant-1', 'queued');
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' },
}));
});
it('does not expose CMPP internal tasks through client task detail or messages', async () => {
const { service, prisma } = createService();
prisma.smsBatchTask.findFirst.mockResolvedValue(null);
await expect(service.getBatchTask('task-cmpp', 'tenant-1', 'client')).rejects.toThrow('SMS batch task not found');
await expect(service.listClientTaskMessages('task-cmpp', 'tenant-1')).rejects.toThrow('SMS batch task not found');
expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'task-cmpp', tenantId: 'tenant-1', sourceType: 'client' },
}));
expect(prisma.smsMessageRecord.findMany).not.toHaveBeenCalled();
});
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
const { service, prisma } = createService();
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
@@ -507,7 +542,7 @@ describe('SendChainService', () => {
});
it('records an unreported CMPP message and returns success before delivering the template failure receipt', async () => {
const { service, prisma } = createService();
const { service, prisma, riskReview } = createService();
prisma.smsTemplate.findFirst.mockResolvedValue(null);
await expect(service.submitInboundMessage({
@@ -524,6 +559,74 @@ describe('SendChainService', () => {
'/downstream/receipt',
expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE' }),
);
expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled();
});
it('aggregates template-mismatched CMPP messages only when the application uses manual review', async () => {
const { service, prisma, riskReview } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'active',
interfaceEnabled: true,
templateMismatchMode: 'manual_review',
customerUnitPrice: 3,
queuePriority: 'normal',
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
prisma.smsTemplate.findFirst.mockResolvedValue(null);
await expect(service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: '【签名】未匹配模板的内容',
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
expect(riskReview.aggregateTemplateMismatch).toHaveBeenCalledWith(expect.objectContaining({
applicationId: 'app-1',
account: '100001',
messageRecordId: 'record-1',
signatureId: 'sig-1',
}));
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
where: { id: 'task-1' },
data: expect.objectContaining({ status: 'pending_review', riskTaskId: 'review-task-1', auditStatus: 'pending' }),
});
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
});
it('fans an approved aggregated review task back into each internal CMPP batch', async () => {
const { service, prisma } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsSendTask.findUnique.mockResolvedValue({
id: 'review-task-1',
messageRecords: [{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
batchTaskId: 'task-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
amountCents: 3,
billingUnits: 1,
batchTask: { id: 'task-1', sourceType: 'cmpp' },
}],
});
await expect(service.handleReviewDecision('review-task-1', 'approved', '审核通过')).resolves.toEqual({
reviewTaskId: 'review-task-1',
decision: 'approved',
affected: 1,
});
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
where: { id: 'record-1' },
data: { status: 'queued', errorCode: null, errorMessage: null },
});
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('previews imported phone files with duplicate, invalid, blacklist, and variable errors', async () => {
+141 -10
View File
@@ -331,9 +331,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.getBatchTask(task.id);
}
async listBatchTasks(tenantId?: string, status?: string) {
async listBatchTasks(tenantId?: string, status?: string, sourceType = 'client') {
const tasks = await this.prisma.smsBatchTask.findMany({
where: { tenantId, status },
where: { tenantId, status, sourceType },
include: {
tenant: true,
application: true,
@@ -355,11 +355,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}));
}
getBatchTask(taskId: string) {
return this.prisma.smsBatchTask.findUnique({
where: { id: taskId },
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
const task = await this.prisma.smsBatchTask.findFirst({
where: { id: taskId, tenantId, sourceType },
include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } },
});
if (!task) {
throw new NotFoundException('SMS batch task not found');
}
return task;
}
async listClientTaskMessages(taskId: string, tenantId: string) {
await this.getBatchTask(taskId, tenantId, 'client');
return this.listMessages({ tenantId, taskId });
}
listMessages(query: {
@@ -506,8 +515,8 @@ 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 } });
async cancelBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, tenantId, sourceType } });
if (!task) {
throw new NotFoundException('SMS batch task not found');
}
@@ -524,6 +533,50 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
const reviewTask = await this.prisma.smsSendTask.findUnique({
where: { id: reviewTaskId },
include: {
messageRecords: {
where: { status: 'pending_review' },
include: { batchTask: true },
},
},
});
if (!reviewTask || reviewTask.messageRecords.length === 0) {
return { reviewTaskId, decision, affected: 0 };
}
const batchTaskIds = new Set<string>();
for (const message of reviewTask.messageRecords) {
if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue;
if (decision === 'approved') {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'queued', errorCode: null, errorMessage: null },
});
await this.prisma.smsBatchTask.update({
where: { id: message.batchTaskId },
data: { status: 'ready', auditStatus: 'approved', reviewReason: reason, rejectReason: null },
});
batchTaskIds.add(message.batchTaskId);
} else {
await this.releaseMessageReservation(
message as typeof message & { tenantId: string; batchTaskId: string },
'模板不匹配人工审核驳回释放冻结',
);
await this.prisma.smsBatchTask.update({
where: { id: message.batchTaskId },
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
});
await this.recordCmppFailureReceipt(message, 'REVIEW_REJECTED', reason);
}
}
for (const batchTaskId of batchTaskIds) {
await this.enqueueBatchTask(batchTaskId);
}
return { reviewTaskId, decision, affected: reviewTask.messageRecords.length };
}
async terminateBatchTask(taskId: string) {
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
if (!task) {
@@ -611,7 +664,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async processSendJob(job: SendJob) {
const message = await this.prisma.smsMessageRecord.findUnique({
where: { id: job.messageRecordId },
include: { batchTask: true, template: { include: { signature: true } } },
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
});
if (!message || message.status !== 'queued') {
return { skipped: true };
@@ -1513,6 +1566,60 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await reject('INTERFACE', '短信应用 CMPP 接口已停用');
} else if (application.tenant.certificationStatus !== 'approved') {
await reject('CERT', '企业认证未通过');
} else if (!template && application.templateMismatchMode === 'manual_review') {
const signature = await this.resolveInboundSignatureCandidate(application.id, data.content);
if (!signature) {
await reject('SIGNATURE', '短信内容未识别到已审核且已报备的签名');
} else {
const risk = await this.riskReview.evaluateTask({
tenantId: application.tenantId,
applicationId: application.id,
content: data.content,
phones: [data.phoneNumber],
});
if (risk.status === 'rejected') {
await reject('RISK', risk.reason || '短信被风控拒绝');
} else {
const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足');
} else {
if (billing.amountCents + billing.totalBillingUnits > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 模板不匹配待审核短信冻结',
});
}
const reviewTask = risk.status === 'pending_review' && risk.task
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id)
: await this.riskReview.aggregateTemplateMismatch({
tenantId: application.tenantId,
applicationId: application.id,
account: data.account,
messageRecordId: message.id,
signatureId: signature.id,
content: data.content,
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: {
status: 'pending_review',
riskTaskId: reviewTask?.id,
auditStatus: 'pending',
reviewReason: reviewTask?.reviewReason ?? '模板不匹配,等待人工审核',
},
});
}
}
}
} else if (!template) {
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
} else if (template.auditStatus !== 'approved') {
@@ -1616,6 +1723,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
billingUnits: number;
queuePriority?: string | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
signature?: { id?: string | null; name?: string | null } | null;
},
routed: RoutedChannel,
attempt: number,
@@ -1668,7 +1776,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
queuePriority: normalizeQueuePriority(message.queuePriority),
phoneNumber: message.phoneNumber,
content: message.content,
signature: message.template?.signature?.name ?? 'SMS',
signature: message.template?.signature?.name ?? message.signature?.name ?? 'SMS',
templateId: message.templateId ?? 'unknown',
billingUnits: message.billingUnits,
route: {
@@ -1897,6 +2005,28 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
private resolveInboundSignatureCandidate(applicationId: string, content: string) {
const match = content.match(/^【([^】]+)】/);
if (!match?.[1]) return null;
return this.prisma.smsSignature.findFirst({
where: {
applicationId,
name: match[1],
auditStatus: 'approved',
reportStatus: 'approved',
},
orderBy: { updatedAt: 'desc' },
});
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string) {
await this.prisma.smsMessageRecord.update({
where: { id: messageRecordId },
data: { reviewTaskId, signatureId, status: 'pending_review' },
});
return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } });
}
private async recordCmppFailureReceipt(
message: {
id: string;
@@ -2102,10 +2232,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
id: string;
templateId?: string | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
signature?: { id?: string | null; name?: string | null } | null;
},
channelId: string,
) {
let signatureId = message.template?.signature?.id ?? null;
let signatureId = message.template?.signature?.id ?? message.signature?.id ?? null;
if (!signatureId && message.templateId) {
const template = await this.prisma.smsTemplate.findUnique({
where: { id: message.templateId },