feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+100 -2
View File
@@ -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({