509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
import { RiskReviewService } from './risk-review.service';
|
|
|
|
function createPrismaMock(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
riskRule: {
|
|
count: jest.fn().mockResolvedValue(5),
|
|
findFirst: jest.fn().mockResolvedValue({ id: 'default-rule' }),
|
|
findUnique: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
globalBlacklist: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
enterpriseBlacklist: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
sensitiveWord: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
user: {
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'user-1' }),
|
|
},
|
|
smsApplication: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
},
|
|
smsTemplate: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
smsSendTask: {
|
|
create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
|
|
Promise.resolve({ id: 'risk-task-1', ...data }),
|
|
),
|
|
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' }),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
count: jest.fn().mockResolvedValue(0),
|
|
},
|
|
smsBatchTask: {
|
|
count: jest.fn().mockResolvedValue(0),
|
|
},
|
|
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('shares read-only rule inputs across an approved CMPP evaluation batch', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
const results = await service.evaluateTasksBatch([
|
|
{ tenantId: 'tenant-1', applicationId: 'app-1', content: '【测试】验证码000001', phones: ['13800000001'], sourceType: 'cmpp' },
|
|
{ tenantId: 'tenant-1', applicationId: 'app-1', content: '【测试】验证码000002', phones: ['13800000002'], sourceType: 'cmpp' },
|
|
]);
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results.every((result) => result.status === 'approved')).toBe(true);
|
|
expect(prisma.riskRule.findMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.smsBatchTask.count).not.toHaveBeenCalled();
|
|
expect(prisma.smsSendTask.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('coalesces concurrent default-rule checks and reuses the short completeness cache', async () => {
|
|
const prisma = createPrismaMock();
|
|
let releaseCount: ((count: number) => void) | undefined;
|
|
prisma.riskRule.count.mockReturnValue(new Promise((resolve) => { releaseCount = resolve; }));
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
const first = service.ensureDefaultRules();
|
|
const second = service.ensureDefaultRules();
|
|
releaseCount?.(5);
|
|
await Promise.all([first, second]);
|
|
await service.ensureDefaultRules();
|
|
|
|
expect(prisma.riskRule.count).toHaveBeenCalledTimes(1);
|
|
expect(prisma.riskRule.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears a failed default-rule check so the next request can retry', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.riskRule.count
|
|
.mockRejectedValueOnce(new Error('database unavailable'))
|
|
.mockResolvedValueOnce(5);
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
await expect(service.ensureDefaultRules()).rejects.toThrow('database unavailable');
|
|
await expect(service.ensureDefaultRules()).resolves.toBeUndefined();
|
|
|
|
expect(prisma.riskRule.count).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('falls back to per-rule recovery when the completeness count finds a missing default', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.riskRule.count.mockResolvedValue(4);
|
|
prisma.riskRule.findFirst.mockImplementation(({ where }: { where: { code: string } }) => (
|
|
Promise.resolve(where.code === 'PHONE_FREQUENCY_5M' ? null : { id: `rule-${where.code}` })
|
|
));
|
|
const service = new RiskReviewService(prisma as never);
|
|
service.createRule = jest.fn().mockResolvedValue({ id: 'restored-rule' }) as never;
|
|
|
|
await service.ensureDefaultRules();
|
|
|
|
expect(prisma.riskRule.findFirst).toHaveBeenCalledTimes(5);
|
|
expect(service.createRule).toHaveBeenCalledTimes(1);
|
|
expect(service.createRule).toHaveBeenCalledWith(expect.objectContaining({ code: 'PHONE_FREQUENCY_5M' }));
|
|
});
|
|
|
|
it('keeps phone-frequency periods fixed and rejects manual-review actions', () => {
|
|
const service = new RiskReviewService(createPrismaMock() as never);
|
|
|
|
expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 }))
|
|
.toThrow('号码频次周期首版固定为24小时自然日或5分钟,不允许修改');
|
|
expect(() => service['validateRuleInput']({
|
|
code: 'PHONE_FREQUENCY_24H',
|
|
thresholdValue: 10,
|
|
action: 'manual_review',
|
|
})).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝');
|
|
});
|
|
|
|
it('includes the sending enterprise and application in SMS review rows', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.smsSendTask.findMany.mockResolvedValue([]);
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
await service.listTasks(undefined, 'pending_review');
|
|
|
|
expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
include: expect.objectContaining({
|
|
tenant: { select: { id: true, name: true } },
|
|
application: { select: { id: true, name: true } },
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('filters SMS review tasks by their submission time', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.smsSendTask.findMany.mockResolvedValue([]);
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
await service.listTasks(undefined, 'pending_review', '2026-08-01', '2026-08-03');
|
|
|
|
expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
createdAt: {
|
|
gte: new Date('2026-08-01T00:00:00+08:00'),
|
|
lte: new Date('2026-08-03T23:59:59.999+08:00'),
|
|
},
|
|
}),
|
|
}));
|
|
});
|
|
|
|
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('batch rejects unique tasks with one required rejection reason', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.smsSendTask.findUnique.mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve({
|
|
id: where.id,
|
|
status: 'pending_review',
|
|
reviewReason: '命中风控',
|
|
}));
|
|
prisma.smsSendTask.update.mockImplementation(({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => Promise.resolve({
|
|
id: where.id,
|
|
...data,
|
|
riskHits: [],
|
|
}));
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
await expect(service.rejectTasks({ ids: ['task-1', 'task-2', 'task-1'], reason: '批量人工拒绝' })).resolves.toEqual([
|
|
expect.objectContaining({ id: 'task-1', status: 'rejected', rejectReason: '批量人工拒绝' }),
|
|
expect.objectContaining({ id: 'task-2', status: 'rejected', rejectReason: '批量人工拒绝' }),
|
|
]);
|
|
expect(prisma.smsSendTask.update).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('requires task ids and a reason for batch rejection', async () => {
|
|
const service = new RiskReviewService(createPrismaMock() as never);
|
|
|
|
await expect(service.rejectTasks({ ids: [], reason: '拒绝' })).rejects.toThrow('At least one SMS send task id is required');
|
|
await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow('Batch rejection reason is required');
|
|
});
|
|
|
|
it('rejects tasks over the effective max phone rule threshold', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.riskRule.findMany.mockResolvedValue([
|
|
{
|
|
id: 'rule-max',
|
|
code: 'MAX_PHONES_PER_TASK',
|
|
name: '单任务最大号码数',
|
|
metric: 'phoneTotal',
|
|
thresholdValue: 2,
|
|
action: 'block',
|
|
priority: 10,
|
|
},
|
|
]);
|
|
prisma.smsSendTask.findUnique.mockResolvedValue({
|
|
id: 'risk-task-1',
|
|
status: 'rejected',
|
|
riskHits: [{ ruleCode: 'MAX_PHONES_PER_TASK' }],
|
|
});
|
|
|
|
const service = new RiskReviewService(prisma as never);
|
|
const result = await service.evaluateTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '13800000002', '13800000003'],
|
|
});
|
|
|
|
expect(result.canSubmit).toBe(false);
|
|
expect(result.status).toBe('rejected');
|
|
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({ ruleCode: 'MAX_PHONES_PER_TASK', actualValue: 3, action: 'block' })],
|
|
});
|
|
});
|
|
|
|
it('uses an application rule with the same code instead of the global default', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.riskRule.findMany.mockResolvedValue([
|
|
{
|
|
id: 'rule-global',
|
|
applicationId: null,
|
|
code: 'MAX_PHONES_PER_TASK',
|
|
name: '单任务最大号码数',
|
|
metric: 'phoneTotal',
|
|
thresholdValue: 100000,
|
|
action: 'block',
|
|
priority: 10,
|
|
},
|
|
{
|
|
id: 'rule-app',
|
|
applicationId: 'app-1',
|
|
code: 'MAX_PHONES_PER_TASK',
|
|
name: '单任务最大号码数',
|
|
metric: 'phoneTotal',
|
|
thresholdValue: 1,
|
|
action: 'block',
|
|
priority: 10,
|
|
},
|
|
]);
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
await expect(service.evaluateTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '13800000002'],
|
|
})).resolves.toEqual(expect.objectContaining({ status: 'rejected' }));
|
|
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
|
|
data: [expect.objectContaining({ ruleId: 'rule-app', thresholdValue: 1 })],
|
|
});
|
|
});
|
|
|
|
it('paginates real phone records through both direct and batch review-task relations', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1' });
|
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{
|
|
id: 'message-1',
|
|
phoneNumber: '13800000001',
|
|
province: '上海',
|
|
carrier: 'mobile',
|
|
status: 'pending_review',
|
|
}]);
|
|
prisma.smsMessageRecord.count.mockResolvedValue(1);
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
await expect(service.listTaskMessages('review-task-1', '138', 1, 20)).resolves.toEqual({
|
|
items: [expect.objectContaining({ phoneNumber: '13800000001' })],
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 20,
|
|
});
|
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: {
|
|
OR: [
|
|
{ reviewTaskId: 'review-task-1' },
|
|
{ batchTask: { riskTaskId: 'review-task-1' } },
|
|
],
|
|
phoneNumber: { contains: '138' },
|
|
},
|
|
skip: 0,
|
|
take: 20,
|
|
}));
|
|
});
|
|
|
|
it('does not create an audit task for automatic approval', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.riskRule.findMany.mockResolvedValue([]);
|
|
|
|
const service = new RiskReviewService(prisma as never);
|
|
const result = await service.evaluateTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: 'hello',
|
|
phones: ['13800000001', '13800000001', '13800000002'],
|
|
});
|
|
|
|
expect(result).toEqual(expect.objectContaining({ status: 'approved', canSubmit: true, task: null }));
|
|
expect(prisma.smsSendTask.create).not.toHaveBeenCalled();
|
|
expect(prisma.globalBlacklist.findMany).not.toHaveBeenCalled();
|
|
expect(prisma.enterpriseBlacklist.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps template variable validation as deterministic rejection instead of a configurable rule', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
|
id: 'tpl-1',
|
|
category: 'notice',
|
|
variables: [{ name: 'code', required: true }],
|
|
});
|
|
prisma.riskRule.findMany.mockResolvedValue([]);
|
|
|
|
const service = new RiskReviewService(prisma as never);
|
|
const result = await service.evaluateTask({
|
|
tenantId: 'tenant-1',
|
|
templateId: 'tpl-1',
|
|
content: '验证码 ${code}',
|
|
phones: ['13800000001', 'not-a-phone'],
|
|
variables: { extra: 'value' },
|
|
});
|
|
|
|
expect(result.status).toBe('rejected');
|
|
expect(prisma.smsSendTask.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
illegalRatio: 0.5,
|
|
variableIssues: {
|
|
variables: expect.arrayContaining([
|
|
{ type: 'missing_required_variable', name: 'code' },
|
|
{ type: 'unexpected_variable', name: 'extra' },
|
|
]),
|
|
content: expect.arrayContaining([
|
|
expect.objectContaining({ ruleCode: 'TEMPLATE_VARIABLE_INVALID', action: 'block' }),
|
|
]),
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('marks non-working marketing bulk and frequent task creation for manual review', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.smsBatchTask.count.mockResolvedValue(10);
|
|
prisma.riskRule.findMany.mockResolvedValue([
|
|
{
|
|
id: 'rule-night',
|
|
code: 'NON_WORKING_MARKETING_BULK',
|
|
name: '非工作时间大批量营销发送',
|
|
metric: 'nonWorkingMarketingPhones',
|
|
thresholdValue: 2,
|
|
action: 'manual_review',
|
|
priority: 50,
|
|
},
|
|
{
|
|
id: 'rule-frequency',
|
|
code: 'TASK_CREATE_FREQUENCY',
|
|
name: '短时间任务创建频控',
|
|
metric: 'recentTaskCount',
|
|
thresholdValue: 10,
|
|
action: 'manual_review',
|
|
priority: 60,
|
|
},
|
|
]);
|
|
|
|
const service = new RiskReviewService(prisma as never);
|
|
const result = await service.evaluateTask({
|
|
tenantId: 'tenant-1',
|
|
category: 'marketing',
|
|
content: 'promo',
|
|
phones: ['13800000001', '13800000002', '13800000003'],
|
|
requestedAt: '2026-07-01T22:00:00+08:00',
|
|
applicationId: 'app-1',
|
|
sourceType: 'client',
|
|
});
|
|
|
|
expect(result.status).toBe('pending_review');
|
|
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({ ruleCode: 'NON_WORKING_MARKETING_BULK', actualValue: 3 }),
|
|
expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 10 }),
|
|
]),
|
|
});
|
|
expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({
|
|
where: {
|
|
applicationId: 'app-1',
|
|
sourceType: 'client',
|
|
createdAt: { gte: expect.any(Date) },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does not include CMPP or HTTP tasks in client task frequency control', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.riskRule.findMany.mockResolvedValue([{
|
|
id: 'rule-frequency',
|
|
code: 'TASK_CREATE_FREQUENCY',
|
|
name: '短时间任务创建频控',
|
|
metric: 'recentTaskCount',
|
|
thresholdValue: 1,
|
|
action: 'manual_review',
|
|
priority: 30,
|
|
}]);
|
|
const service = new RiskReviewService(prisma as never);
|
|
|
|
await expect(service.evaluateTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: 'hello',
|
|
phones: ['10000000000'],
|
|
sourceType: 'cmpp',
|
|
})).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null }));
|
|
await expect(service.evaluateTask({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
content: 'hello',
|
|
phones: ['10000000000'],
|
|
sourceType: 'api',
|
|
})).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null }));
|
|
expect(prisma.smsBatchTask.count).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|