fix: harden admin and CMPP delivery workflows
This commit is contained in:
@@ -94,6 +94,7 @@ function createPrismaMock() {
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
|
||||
}),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
smsSignature: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }),
|
||||
@@ -571,6 +572,90 @@ describe('SendChainService', () => {
|
||||
expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches an inbound CMPP message against configured template variables and passes extracted values to risk review', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue(null);
|
||||
prisma.smsTemplate.findMany.mockResolvedValue([{
|
||||
id: 'tpl-code',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
content: '【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。',
|
||||
auditStatus: 'approved',
|
||||
signature: { id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting' },
|
||||
}]);
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '18821203795',
|
||||
content: '【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
|
||||
|
||||
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', content: { contains: '${' } },
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ templateId: 'tpl-code', status: 'validating' }),
|
||||
});
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
templateId: 'tpl-code',
|
||||
variables: { code: '715021' },
|
||||
}));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues template-mismatched CMPP content when the application uses direct send', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
templateMismatchMode: 'direct_send',
|
||||
customerUnitPrice: 3,
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue(null);
|
||||
prisma.smsTemplate.findMany.mockResolvedValue([]);
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'sig-1',
|
||||
name: '【航天信息信诺网】',
|
||||
auditStatus: 'approved',
|
||||
reportStatus: 'reporting',
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '18821203795',
|
||||
content: '【航天信息信诺网】未配置模板但允许直接发送',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
|
||||
|
||||
expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', name: '【航天信息信诺网】', auditStatus: 'approved' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
content: '【航天信息信诺网】未配置模板但允许直接发送',
|
||||
}));
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.not.objectContaining({ templateId: expect.any(String) }));
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: { status: 'queued', signatureId: 'sig-1' },
|
||||
});
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
expect(prisma.smsReceiptRecord.create).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({
|
||||
@@ -1602,6 +1687,29 @@ describe('SendChainService', () => {
|
||||
|
||||
it('requeues downstream delivery through real gateway control path', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
||||
id: 'delivery-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
status: 'failed',
|
||||
retryCount: 3,
|
||||
manualRetryCount: 1,
|
||||
lastError: 'downstream client is not connected',
|
||||
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
||||
application: { cmppAccount: '100001' },
|
||||
});
|
||||
prisma.cmppDownstreamDelivery.update.mockResolvedValueOnce({
|
||||
id: 'delivery-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
manualRetryCount: 2,
|
||||
});
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
|
||||
await service.requeueDownstreamDelivery('delivery-1');
|
||||
@@ -1624,12 +1732,41 @@ describe('SendChainService', () => {
|
||||
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: expect.any(Date),
|
||||
acknowledgedAt: null,
|
||||
ackResult: null,
|
||||
ackMessageId: null,
|
||||
deliveredAt: null,
|
||||
}),
|
||||
}));
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
detail: expect.objectContaining({
|
||||
previousStatus: 'failed',
|
||||
previousRetryCount: 3,
|
||||
manualRetryCount: 2,
|
||||
lastRetriedAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects manual requeue while downstream acknowledgement is pending', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service['postGatewayControl'] = jest.fn();
|
||||
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
||||
id: 'delivery-1',
|
||||
status: 'awaiting_ack',
|
||||
payload: { account: '100001' },
|
||||
application: { cmppAccount: '100001' },
|
||||
});
|
||||
|
||||
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投');
|
||||
expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled();
|
||||
expect(service['postGatewayControl']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports batch requeue of downstream deliveries', async () => {
|
||||
|
||||
@@ -1263,6 +1263,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'awaiting_ack') {
|
||||
throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投');
|
||||
}
|
||||
const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null;
|
||||
if (!payload) {
|
||||
throw new BadRequestException('下游投递记录缺少可重放 payload');
|
||||
@@ -1277,30 +1280,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`);
|
||||
}
|
||||
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: delivery.tenantId,
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: delivery.id,
|
||||
detail: {
|
||||
deliveryType: delivery.deliveryType,
|
||||
applicationId: delivery.applicationId,
|
||||
messageId: delivery.messageId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requestPayload = {
|
||||
deliveryId: delivery.id,
|
||||
account: String(payload.account ?? delivery.application?.cmppAccount ?? ''),
|
||||
...payload,
|
||||
};
|
||||
await this.prisma.cmppDownstreamDelivery.update({
|
||||
const retriedAt = new Date();
|
||||
const requeued = await this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
data: {
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: retriedAt,
|
||||
nextRetryAt: null,
|
||||
sentAt: null,
|
||||
acknowledgedAt: null,
|
||||
@@ -1313,6 +1305,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: delivery.tenantId,
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: delivery.id,
|
||||
detail: {
|
||||
deliveryType: delivery.deliveryType,
|
||||
applicationId: delivery.applicationId,
|
||||
messageId: delivery.messageId,
|
||||
previousStatus: delivery.status,
|
||||
previousRetryCount: delivery.retryCount,
|
||||
manualRetryCount: requeued.manualRetryCount,
|
||||
lastRetriedAt: retriedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
@@ -1649,6 +1658,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
|
||||
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||
const unitPrice = application.customerUnitPrice ?? 0;
|
||||
const queuePriority = normalizeQueuePriority(application.queuePriority);
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
@@ -1707,6 +1717,57 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
await this.recordCmppFailureReceipt(message, code, reason);
|
||||
};
|
||||
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: options.templateId,
|
||||
content: data.content,
|
||||
variables: options.templateId ? templateVariables : undefined,
|
||||
phones: [data.phoneNumber],
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
return;
|
||||
}
|
||||
if (risk.status === 'pending_review') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'pending_review', signatureId: options.signatureId },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
return;
|
||||
}
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 入站短信冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'queued', signatureId: options.signatureId },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
|
||||
});
|
||||
await this.enqueueBatchTask(task.id);
|
||||
};
|
||||
if (application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
await reject('ACCOUNT', '企业或短信应用已停用');
|
||||
} else if (!application.interfaceEnabled) {
|
||||
@@ -1765,6 +1826,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!template && application.templateMismatchMode === 'direct_send') {
|
||||
const signature = await this.resolveInboundSignatureCandidate(application.id, data.content);
|
||||
if (!signature) {
|
||||
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||
} else {
|
||||
await queueAfterRiskChecks({ signatureId: signature.id });
|
||||
}
|
||||
} else if (!template) {
|
||||
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
|
||||
} else if (template.auditStatus !== 'approved') {
|
||||
@@ -1772,46 +1840,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
} else if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||
await reject('SIGNATURE', '短信签名尚未审核通过');
|
||||
} else {
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: template.id,
|
||||
content: data.content,
|
||||
phones: [data.phoneNumber],
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
} else if (risk.status === 'pending_review') {
|
||||
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'pending_review' } });
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
|
||||
});
|
||||
} else {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
} else {
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 入站短信冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'queued' } });
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
|
||||
});
|
||||
await this.enqueueBatchTask(task.id);
|
||||
}
|
||||
}
|
||||
await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id });
|
||||
}
|
||||
return {
|
||||
accepted: true,
|
||||
@@ -2160,8 +2189,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
return this.prisma.smsTemplate.findFirst({
|
||||
private async resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
const exact = await this.prisma.smsTemplate.findFirst({
|
||||
where: {
|
||||
applicationId,
|
||||
content,
|
||||
@@ -2169,6 +2198,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
if (exact) return exact;
|
||||
const variableTemplates = await this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
applicationId,
|
||||
content: { contains: '${' },
|
||||
},
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null;
|
||||
}
|
||||
|
||||
private resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||
@@ -2902,6 +2941,45 @@ function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
}
|
||||
|
||||
function matchTemplateContent(templateContent: string, actualContent: string) {
|
||||
if (templateContent === actualContent) {
|
||||
return {} as Record<string, string>;
|
||||
}
|
||||
const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g;
|
||||
const names: string[] = [];
|
||||
let cursor = 0;
|
||||
let pattern = '^';
|
||||
for (const match of templateContent.matchAll(tokenPattern)) {
|
||||
const index = match.index ?? 0;
|
||||
pattern += escapeRegularExpression(templateContent.slice(cursor, index));
|
||||
pattern += '([\\s\\S]+?)';
|
||||
names.push(match[1]);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`;
|
||||
const matched = new RegExp(pattern, 'u').exec(actualContent);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
const variables: Record<string, string> = {};
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const name = names[index];
|
||||
const value = matched[index + 1];
|
||||
if (variables[name] !== undefined && variables[name] !== value) {
|
||||
return null;
|
||||
}
|
||||
variables[name] = value;
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
function escapeRegularExpression(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) {
|
||||
const itemProvince = normalizeRegion(item.province);
|
||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||
|
||||
Reference in New Issue
Block a user