feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -111,6 +111,7 @@ function createPrismaMock() {
|
||||
},
|
||||
smsSendTask: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
|
||||
},
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockResolvedValue(task),
|
||||
@@ -374,10 +375,19 @@ function createService(
|
||||
reviewReason: '企业应用已配置模板不匹配进入人工审核',
|
||||
}),
|
||||
} as unknown as RiskReviewService;
|
||||
const service = new SendChainService(prisma as never, billing, riskReview, openApi as never);
|
||||
const phoneFrequency = {
|
||||
reserve: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
const service = new SendChainService(
|
||||
prisma as never,
|
||||
billing,
|
||||
riskReview,
|
||||
phoneFrequency as never,
|
||||
openApi as never,
|
||||
);
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
||||
return { service, prisma, billing, riskReview };
|
||||
return { service, prisma, billing, riskReview, phoneFrequency };
|
||||
}
|
||||
|
||||
describe('SendChainService', () => {
|
||||
@@ -451,6 +461,94 @@ describe('SendChainService', () => {
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('rejects only phones that hit application frequency rules and excludes them from billing', async () => {
|
||||
const { service, prisma, billing, phoneFrequency } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
phoneFrequency.reserve.mockResolvedValue(new Map([
|
||||
['13800000002', {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
||||
}],
|
||||
]));
|
||||
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
totalBillingUnits: 1,
|
||||
unitPrice: 3,
|
||||
amountCents: 3,
|
||||
});
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '13800000002'],
|
||||
});
|
||||
|
||||
expect(phoneFrequency.reserve).toHaveBeenCalledWith(
|
||||
'tenant-1',
|
||||
'app-1',
|
||||
['13800000001', '13800000002'],
|
||||
'client',
|
||||
);
|
||||
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ phoneCount: 1 }));
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
|
||||
expect.objectContaining({
|
||||
phoneNumber: '13800000002',
|
||||
status: 'submit_failed',
|
||||
submitStatus: 'rejected',
|
||||
errorCode: 'PHONE_FREQUENCY_LIMIT',
|
||||
amountCents: 0,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('marks a batch and its pending review task rejected when every phone hits frequency rules', async () => {
|
||||
const { service, prisma, riskReview, phoneFrequency } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
status: 'pending_review',
|
||||
reason: '命中人工审核规则',
|
||||
task: { id: 'review-task-1' },
|
||||
});
|
||||
phoneFrequency.reserve.mockResolvedValue(new Map([
|
||||
['13800000001', {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
||||
}],
|
||||
]));
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
});
|
||||
|
||||
expect(prisma.smsSendTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'review-task-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'rejected',
|
||||
riskDecision: 'block',
|
||||
reviewReason: null,
|
||||
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
status: 'rejected',
|
||||
auditStatus: 'rejected',
|
||||
reviewReason: null,
|
||||
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the review task id on every message waiting for manual review', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user