feat: 按企业应用累计夜间短信并复用聚合审核
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-07 22:55:29 +08:00
parent e281ff853b
commit 633ba59775
18 changed files with 4279 additions and 1767 deletions
@@ -0,0 +1,41 @@
import { nightClock, nightWindow } from './night-sending-risk.service';
describe('night sending window', () => {
const config = nightClock(null);
it.each([
['2026-09-07T20:59:59+08:00', false],
['2026-09-07T21:00:00+08:00', true],
['2026-09-08T00:00:00+08:00', true],
['2026-09-08T07:59:59+08:00', true],
['2026-09-08T08:00:00+08:00', false],
])('uses Shanghai boundaries at %s', (time, active) => {
const window = nightWindow(new Date(time), config);
expect(Boolean(window)).toBe(active);
if (window) {
expect(window.windowStartedAt.toISOString()).toBe('2026-09-07T13:00:00.000Z');
expect(window.windowEndsAt.toISOString()).toBe('2026-09-08T00:00:00.000Z');
}
});
it('handles a same-day configured period', () => {
const clock = nightClock({ startTime: '01:00', endTime: '06:00' });
expect(nightWindow(new Date('2026-09-08T03:00:00+08:00'), clock)?.windowEndsAt.toISOString()).toBe(
'2026-09-07T22:00:00.000Z',
);
});
it('retains the previous clock until the current night ends', () => {
const config = {
startTime: '23:00',
endTime: '07:00',
previousTimeConfig: { startTime: '21:00', endTime: '08:00' },
timeConfigEffectiveAt: '2026-09-08T00:00:00Z',
};
expect(nightClock(config, new Date('2026-09-07T14:00:00Z')).startTime).toBe('21:00');
expect(nightClock(config, new Date('2026-09-08T00:00:00Z')).startTime).toBe('23:00');
});
it.each([
{ startTime: '25:00', endTime: '08:00' },
{ startTime: '21:00', endTime: '21:00' },
])('rejects invalid clock %j', (clock) => {
expect(() => nightWindow(new Date(), nightClock(clock))).toThrow();
});
});
@@ -0,0 +1,258 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma, RiskRule } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
export const NIGHT_RULE_CODE = 'NON_WORKING_MARKETING_BULK';
export const NIGHT_REVIEW_SOURCE = 'night_sending_bulk';
const REVIEW_WINDOW_MS = 10_000;
const DAY_MS = 86_400_000;
type NightClock = { startTime: string; endTime: string; timeZone: string };
export function nightClock(config: unknown, now = new Date()): NightClock {
const value = config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
if (
typeof value.timeConfigEffectiveAt === 'string' &&
new Date(value.timeConfigEffectiveAt) > now &&
value.previousTimeConfig
) {
return nightClock(value.previousTimeConfig, now);
}
return {
startTime: typeof value.startTime === 'string' ? value.startTime : '21:00',
endTime: typeof value.endTime === 'string' ? value.endTime : '08:00',
timeZone: 'Asia/Shanghai',
};
}
export function nightWindow(now: Date, clock: NightClock) {
const minute = (value: string) => {
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new BadRequestException('夜间时间配置无效');
const [h, m] = value.split(':').map(Number);
return h * 60 + m;
};
const start = minute(clock.startTime),
end = minute(clock.endTime);
if (start === end || !Number.isFinite(now.getTime())) throw new BadRequestException('夜间时间配置无效');
const local = new Date(now.getTime() + 8 * 3600_000);
const minutes = local.getUTCHours() * 60 + local.getUTCMinutes();
const active = start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
if (!active) return null;
const midnight = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate()) - 8 * 3600_000;
const startDay = midnight - (start > end && minutes < end ? DAY_MS : 0);
return {
windowStartedAt: new Date(startDay + start * 60_000),
windowEndsAt: new Date(startDay + (end + (start > end ? 1440 : 0)) * 60_000),
};
}
/** Persistent gate for initial business-message dispatch, shared by every transport. */
export class NightSendingRiskService {
constructor(private readonly prisma: PrismaService) {}
async guard(messageIds: string[], now = new Date()) {
const held = new Set(messageIds);
if (!messageIds.length) return held;
const owners = await this.prisma.smsMessageRecord.findMany({
where: {
id: { in: [...new Set(messageIds)] },
status: 'queued',
tenantId: { not: null },
applicationId: { not: null },
},
select: { id: true, applicationId: true },
});
const applications = [...new Set(owners.map((message) => message.applicationId!))].sort();
for (const applicationId of applications) {
const ids = owners.filter((message) => message.applicationId === applicationId).map((message) => message.id);
for (let offset = 0; offset < ids.length; offset += 250) {
const blocked = await this.guardApplication(applicationId, ids.slice(offset, offset + 250), now);
for (const id of ids.slice(offset, offset + 250)) if (!blocked.includes(id)) held.delete(id);
}
}
return held;
}
private async guardApplication(applicationId: string, ids: string[], now: Date) {
return this.prisma.$transaction(
async (tx) => {
// Shared by all API/worker instances; lock before reading counters or decisions.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'night-sending:' + applicationId}, 0))`;
const application = await tx.smsApplication.findUniqueOrThrow({
where: { id: applicationId },
select: { tenantId: true },
});
const tenantId = application.tenantId;
const messages = await tx.smsMessageRecord.findMany({
where: { id: { in: ids }, applicationId, tenantId, status: 'queued' },
orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }],
});
const rules = await tx.riskRule.findMany({
where: {
code: NIGHT_RULE_CODE,
status: 'active',
OR: [{ applicationId: null }, { applicationId, tenantId }],
},
orderBy: { createdAt: 'asc' },
});
const rule =
rules.find((item) => item.applicationId === applicationId) ??
rules.find((item) => item.applicationId === null);
const reservations = await tx.nightSendingReservation.findMany({ where: { messageRecordId: { in: ids } } });
const byMessage = new Map(reservations.map((item) => [item.messageRecordId, item]));
let window = await tx.nightSendingWindow.findFirst({
where: { applicationId, tenantId, windowStartedAt: { lte: now }, windowEndsAt: { gt: now } },
orderBy: { windowStartedAt: 'desc' },
});
const period = window ?? (rule ? nightWindow(now, nightClock(rule.config, now)) : null);
const held: string[] = ids.filter((id) => !messages.some((message) => message.id === id));
for (const message of messages) {
const previous = byMessage.get(message.id);
if (previous) {
if (previous.tenantId !== tenantId || previous.applicationId !== applicationId)
throw new Error('Night sending reservation owner mismatch');
if (previous.reviewTaskId) {
const task = await tx.smsSendTask.findUniqueOrThrow({ where: { id: previous.reviewTaskId } });
if (task.status !== 'approved') held.push(message.id);
}
continue;
}
// Already submitted business messages and retries never occupy a new allowance.
if (message.submitId || !rule || !period) continue;
if (!Number.isSafeInteger(rule.thresholdValue) || rule.thresholdValue < 0)
throw new Error('夜间累计阈值必须是非负整数');
if (!window) {
// Bootstrap only once per application/night, including first deployment mid-night.
// First Submit attempt is durable even when the supplier rejects or has no receipt.
const [baseline] = await tx.$queryRaw<Array<{ count: number }>>(Prisma.sql`
SELECT COUNT(*)::int AS count FROM "SmsMessageRecord" m
WHERE m."applicationId" = ${applicationId} AND m."tenantId" = ${tenantId}
AND EXISTS (SELECT 1 FROM "SmsSubmitRecord" s WHERE s."messageRecordId" = m.id
AND s."createdAt" >= ${period.windowStartedAt} AND s."createdAt" < ${period.windowEndsAt})
AND NOT EXISTS (SELECT 1 FROM "SmsSubmitRecord" s WHERE s."messageRecordId" = m.id AND s."createdAt" < ${period.windowStartedAt})
`);
window = await tx.nightSendingWindow.create({
data: {
id: `${applicationId}:${period.windowStartedAt.toISOString()}`,
tenantId,
applicationId,
windowStartedAt: period.windowStartedAt,
windowEndsAt: period.windowEndsAt,
count: baseline.count,
baselineCount: baseline.count,
},
});
}
window = await tx.nightSendingWindow.update({ where: { id: window.id }, data: { count: { increment: 1 } } });
let reviewTaskId: string | undefined;
if (window.count > rule.thresholdValue) {
reviewTaskId = await this.aggregate(tx, message, rule, window, now);
held.push(message.id);
}
await tx.nightSendingReservation.create({
data: {
messageRecordId: message.id,
tenantId,
applicationId,
windowId: window.id,
sequence: window.count,
thresholdValue: rule.thresholdValue,
reviewTaskId,
},
});
}
return held;
},
{ maxWait: 15_000, timeout: 30_000 },
);
}
private async aggregate(
tx: Prisma.TransactionClient,
message: {
id: string;
tenantId: string | null;
applicationId: string | null;
batchTaskId: string | null;
content: string;
phoneNumber: string;
},
rule: RiskRule,
night: { id: string; count: number; windowStartedAt: Date; windowEndsAt: Date },
now: Date,
) {
const contentHash = createHash('sha256').update(message.content).digest('hex');
const windowStartedAt = new Date(Math.floor(now.getTime() / REVIEW_WINDOW_MS) * REVIEW_WINDOW_MS);
const windowEndsAt = new Date(Math.min(windowStartedAt.getTime() + REVIEW_WINDOW_MS, night.windowEndsAt.getTime()));
const aggregationKey = createHash('sha256')
.update(`${NIGHT_REVIEW_SOURCE}|${night.id}|${contentHash}|${windowStartedAt.toISOString()}`)
.digest('hex');
// Serialize with review decisions. A request waiting at a window boundary must
// never append to an already reviewed aggregation.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'sms-review-aggregation:' + aggregationKey}, 0))`;
const existing = await tx.smsSendTask.findUnique({ where: { aggregationKey } });
if (existing && existing.status !== 'pending_review') throw new Error('审核聚合已关闭,请重试当前消息');
const reason = `夜间累计发送量审核命中,阈值 ${rule.thresholdValue} 条,本夜累计 ${night.count} 条;所有入口和内容合并计数`;
const uniqueIncrement =
existing &&
(await tx.smsMessageRecord.count({ where: { reviewTaskId: existing.id, phoneNumber: message.phoneNumber } }))
? 0
: 1;
const task = await tx.smsSendTask.upsert({
where: { aggregationKey },
create: {
tenantId: message.tenantId!,
applicationId: message.applicationId!,
taskNo: `NIGHT-${randomUUID()}`,
sourceType: NIGHT_REVIEW_SOURCE,
aggregationKey,
contentHash,
windowStartedAt,
windowEndsAt,
content: message.content,
phoneTotal: 1,
uniquePhoneTotal: 1,
status: 'pending_review',
riskDecision: 'manual_review',
reviewReason: reason,
variableIssues: {
nightStartedAt: night.windowStartedAt.toISOString(),
nightEndsAt: night.windowEndsAt.toISOString(),
},
riskHits: {
create: {
tenantId: message.tenantId!,
ruleId: rule.id,
ruleCode: NIGHT_RULE_CODE,
ruleName: '夜间累计发送量审核',
thresholdValue: rule.thresholdValue,
actualValue: night.count,
action: 'manual_review',
reason,
},
},
},
update: { phoneTotal: { increment: 1 }, uniquePhoneTotal: { increment: uniqueIncrement }, reviewReason: reason },
});
if (existing)
await tx.riskHitRecord.updateMany({
where: { taskId: task.id, ruleCode: NIGHT_RULE_CODE },
data: { actualValue: night.count, reason },
});
await tx.smsMessageRecord.update({
where: { id: message.id },
data: {
status: 'pending_review',
reviewTaskId: task.id,
errorCode: 'NIGHT_SENDING_REVIEW',
errorMessage: reason,
},
});
if (message.batchTaskId)
await tx.smsBatchTask.update({
where: { id: message.batchTaskId },
data: { auditStatus: 'pending_review', reviewReason: reason },
});
return task.id;
}
}
+166 -109
View File
@@ -30,13 +30,19 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
findMany: jest.fn().mockResolvedValue([]),
},
smsSendTask: {
create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'risk-task-1', ...data }),
),
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 })),
upsert: jest
.fn()
.mockImplementation(({ create }: { create: Record<string, unknown> }) =>
Promise.resolve({ id: 'review-task-1', ...create }),
),
},
smsMessageRecord: {
update: jest.fn().mockResolvedValue({ id: 'message-1' }),
@@ -51,14 +57,20 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
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' }),
},
})),
$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,
};
}
@@ -69,8 +81,20 @@ describe('RiskReviewService', () => {
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' },
{
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);
@@ -84,7 +108,11 @@ describe('RiskReviewService', () => {
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; }));
prisma.riskRule.count.mockReturnValue(
new Promise((resolve) => {
releaseCount = resolve;
}),
);
const service = new RiskReviewService(prisma as never);
const first = service.ensureDefaultRules();
@@ -99,9 +127,7 @@ describe('RiskReviewService', () => {
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);
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');
@@ -113,9 +139,9 @@ describe('RiskReviewService', () => {
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}` })
));
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;
@@ -129,13 +155,16 @@ describe('RiskReviewService', () => {
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的整数,首版处理动作固定为直接拒绝');
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 () => {
@@ -145,18 +174,22 @@ describe('RiskReviewService', () => {
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 } },
expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
}),
}),
}));
);
});
it('adds the associated batch task number to SMS review rows', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findMany.mockResolvedValue([{ id: 'review-task-1', taskNo: 'REVIEW-001' }]);
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'batch-1', taskNo: 'BATCH-001', riskTaskId: 'review-task-1' }]);
prisma.smsBatchTask.findMany.mockResolvedValue([
{ id: 'batch-1', taskNo: 'BATCH-001', riskTaskId: 'review-task-1' },
]);
const service = new RiskReviewService(prisma as never);
await expect(service.listTasks(undefined, 'pending_review')).resolves.toEqual([
@@ -175,14 +208,16 @@ describe('RiskReviewService', () => {
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'),
},
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 () => {
@@ -224,30 +259,41 @@ describe('RiskReviewService', () => {
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: [],
}));
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: '批量人工拒绝' }),
]);
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');
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 () => {
@@ -310,12 +356,14 @@ describe('RiskReviewService', () => {
]);
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' }));
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 })],
});
@@ -324,13 +372,15 @@ describe('RiskReviewService', () => {
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.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);
@@ -340,17 +390,16 @@ describe('RiskReviewService', () => {
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,
}));
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 () => {
@@ -406,7 +455,7 @@ describe('RiskReviewService', () => {
});
});
it('marks non-working marketing bulk and frequent task creation for manual review', async () => {
it('defers night volume to persisted-message dispatch while retaining task frequency review', async () => {
const prisma = createPrismaMock();
prisma.smsBatchTask.count.mockResolvedValue(10);
prisma.riskRule.findMany.mockResolvedValue([
@@ -443,11 +492,13 @@ describe('RiskReviewService', () => {
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 }),
]),
data: expect.arrayContaining([expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 10 })]),
});
expect(
prisma.riskHitRecord.createMany.mock.calls
.flatMap((call) => call[0].data)
.some((hit: { ruleCode: string }) => hit.ruleCode === 'NON_WORKING_MARKETING_BULK'),
).toBe(false);
expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({
where: {
applicationId: 'app-1',
@@ -459,31 +510,37 @@ describe('RiskReviewService', () => {
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,
}]);
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 }));
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();
});
+192 -132
View File
@@ -3,6 +3,13 @@ import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
import {
NIGHT_REVIEW_SOURCE,
NIGHT_RULE_CODE,
NightSendingRiskService,
nightClock,
nightWindow,
} from './night-sending-risk.service';
export interface CreateRiskRuleDto {
tenantId?: string;
@@ -71,9 +78,9 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [
},
{
code: 'NON_WORKING_MARKETING_BULK',
name: '非工作时间大批量营销发送',
description: '营销任务在非工作时间且号码数超过阈值进入人工审核。',
metric: 'nonWorkingMarketingPhones',
name: '夜间累计发送量审核',
description: '同一企业应用在夜间累计业务短信超过阈值进入人工审核,所有入口与内容合并计数。',
metric: 'nightSendingCount',
thresholdValue: 5000,
action: 'manual_review',
priority: 20,
@@ -118,6 +125,11 @@ export class RiskReviewService {
constructor(private readonly prisma: PrismaService) {}
async guardNightSending(messageIds: string[], now = new Date()) {
await this.ensureDefaultRules();
return new NightSendingRiskService(this.prisma).guard(messageIds, now);
}
async listRules(applicationId?: string) {
await this.ensureDefaultRules();
return this.prisma.riskRule.findMany({
@@ -152,10 +164,15 @@ export class RiskReviewService {
description: definition.description,
metric: definition.metric!,
thresholdValue: data.thresholdValue,
action: isPhoneFrequencyRule(data.code) ? 'block' : data.action ?? 'manual_review',
action: isPhoneFrequencyRule(data.code)
? 'block'
: data.code === NIGHT_RULE_CODE
? 'manual_review'
: (data.action ?? 'manual_review'),
status: data.status ?? 'active',
priority: data.priority ?? definition.priority ?? 100,
config: this.normalizeRuleConfig(data.code, data.config ?? definition.config) as Prisma.InputJsonValue | undefined,
config: (await this.ruleConfigForSave(data.code, data.config ?? definition.config, data.applicationId)) as
Prisma.InputJsonValue | undefined,
},
include: {
application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } },
@@ -182,9 +199,15 @@ export class RiskReviewService {
action: data.action,
status: data.status,
priority: data.priority,
config: data.config === undefined
? undefined
: this.normalizeRuleConfig(rule.code, data.config) as Prisma.InputJsonValue,
config:
data.config === undefined
? undefined
: ((await this.ruleConfigForSave(
rule.code,
data.config,
rule.applicationId ?? undefined,
rule.config,
)) as Prisma.InputJsonValue),
},
include: {
application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } },
@@ -209,18 +232,19 @@ export class RiskReviewService {
status,
createdAt: shanghaiDateRange(submittedAtFrom, submittedAtTo),
...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}),
...(!status ? {
OR: [
{ status: 'pending_review' },
{ reviewedById: { not: null } },
],
} : {}),
...(status === 'pending_review' ? {
OR: [
{ sourceType: { not: 'cmpp_template_mismatch' } },
{ windowEndsAt: { lte: new Date() } },
],
} : {}),
...(!status
? {
OR: [{ status: 'pending_review' }, { reviewedById: { not: null } }],
}
: {}),
...(status === 'pending_review'
? {
OR: [
{ sourceType: { notIn: ['cmpp_template_mismatch', NIGHT_REVIEW_SOURCE] } },
{ windowEndsAt: { lte: new Date() } },
],
}
: {}),
},
include: {
riskHits: true,
@@ -231,11 +255,15 @@ export class RiskReviewService {
},
orderBy: { createdAt: 'desc' },
});
const batchTasks = tasks.length ? await this.prisma.smsBatchTask.findMany({
where: { riskTaskId: { in: tasks.map((task) => task.id) } },
select: { id: true, taskNo: true, riskTaskId: true },
}) : [];
const batchByRiskTaskId = new Map(batchTasks.map((task) => [task.riskTaskId, { id: task.id, taskNo: task.taskNo }]));
const batchTasks = tasks.length
? await this.prisma.smsBatchTask.findMany({
where: { riskTaskId: { in: tasks.map((task) => task.id) } },
select: { id: true, taskNo: true, riskTaskId: true },
})
: [];
const batchByRiskTaskId = new Map(
batchTasks.map((task) => [task.riskTaskId, { id: task.id, taskNo: task.taskNo }]),
);
return tasks.map((task) => ({ ...task, batchTask: batchByRiskTaskId.get(task.id) ?? null }));
}
@@ -251,10 +279,7 @@ export class RiskReviewService {
const normalizedPage = Math.max(1, Math.floor(page || 1));
const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(pageSize || 20)));
const where: Prisma.SmsMessageRecordWhereInput = {
OR: [
{ reviewTaskId: taskId },
{ batchTask: { riskTaskId: taskId } },
],
OR: [{ reviewTaskId: taskId }, { batchTask: { riskTaskId: taskId } }],
...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}),
};
const [items, total] = await Promise.all([
@@ -366,16 +391,8 @@ export class RiskReviewService {
reason: formatTemplateVariableIssueReason(variableIssues),
});
}
const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date();
const nonWorkingRule = rules.find((rule) => rule.code === 'NON_WORKING_MARKETING_BULK');
const nonWorkingMarketingPhones =
isMarketing(data.category ?? template?.category)
&& isNonWorkingTime(requestedAt, readNonWorkingConfig(nonWorkingRule?.config))
? phoneTotal
: 0;
const hits = this.evaluateRules(rules, {
phoneTotal,
nonWorkingMarketingPhones,
recentTaskCount,
});
hits.push(...contentIssues.map(contentIssueToHit));
@@ -453,65 +470,63 @@ export class RiskReviewService {
// instead of silently weakening its foreign-key validation in the fast path.
return Promise.all(items.map((item) => this.evaluateTask(item)));
}
const applicationIds = [...new Set(items.map((item) => item.applicationId).filter((id): id is string => Boolean(id)))];
const applicationIds = [
...new Set(items.map((item) => item.applicationId).filter((id): id is string => Boolean(id))),
];
const templateIds = [...new Set(items.map((item) => item.templateId).filter((id): id is string => Boolean(id)))];
const [templates, sensitiveWords, applicationInputs] = await Promise.all([
templateIds.length
? this.prisma.smsTemplate.findMany({ where: { id: { in: templateIds } }, include: { variables: true } })
: Promise.resolve([]),
this.prisma.sensitiveWord.findMany({ where: { status: 'active' }, select: { word: true, level: true } }),
Promise.all(applicationIds.map(async (applicationId) => ({
applicationId,
rules: await this.effectiveRules(applicationId),
recentTaskCount: await this.countRecentClientTasks(applicationId, 'cmpp'),
}))),
Promise.all(
applicationIds.map(async (applicationId) => ({
applicationId,
rules: await this.effectiveRules(applicationId),
recentTaskCount: await this.countRecentClientTasks(applicationId, 'cmpp'),
})),
),
]);
const templateById = new Map(templates.map((template) => [template.id, template]));
const inputsByApplication = new Map(applicationInputs.map((entry) => [entry.applicationId, entry]));
return Promise.all(items.map(async (data) => {
const phones = data.phones ?? [];
const uniquePhones = [...new Set(phones)];
const phoneTotal = phones.length;
const template = data.templateId ? templateById.get(data.templateId) : undefined;
const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {});
const contentIssues = evaluateContent(data.content, sensitiveWords);
if (variableIssues.length > 0) {
contentIssues.push({
ruleCode: 'TEMPLATE_VARIABLE_INVALID',
ruleName: '模板变量校验失败',
thresholdValue: 0,
actualValue: variableIssues.length,
action: 'block',
reason: formatTemplateVariableIssueReason(variableIssues),
return Promise.all(
items.map(async (data) => {
const phones = data.phones ?? [];
const phoneTotal = phones.length;
const template = data.templateId ? templateById.get(data.templateId) : undefined;
const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {});
const contentIssues = evaluateContent(data.content, sensitiveWords);
if (variableIssues.length > 0) {
contentIssues.push({
ruleCode: 'TEMPLATE_VARIABLE_INVALID',
ruleName: '模板变量校验失败',
thresholdValue: 0,
actualValue: variableIssues.length,
action: 'block',
reason: formatTemplateVariableIssueReason(variableIssues),
});
}
const applicationInput = data.applicationId ? inputsByApplication.get(data.applicationId) : undefined;
const rules = applicationInput?.rules ?? [];
const hits = this.evaluateRules(rules, {
phoneTotal,
recentTaskCount: applicationInput?.recentTaskCount ?? 0,
});
}
const applicationInput = data.applicationId ? inputsByApplication.get(data.applicationId) : undefined;
const rules = applicationInput?.rules ?? [];
const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date();
const nonWorkingRule = rules.find((rule) => rule.code === 'NON_WORKING_MARKETING_BULK');
const nonWorkingMarketingPhones = isMarketing(data.category ?? template?.category)
&& isNonWorkingTime(requestedAt, readNonWorkingConfig(nonWorkingRule?.config))
? phoneTotal
: 0;
const hits = this.evaluateRules(rules, {
phoneTotal,
nonWorkingMarketingPhones,
recentTaskCount: applicationInput?.recentTaskCount ?? 0,
});
hits.push(...contentIssues.map(contentIssueToHit));
const decision = decideRiskAction(hits);
if (decision.status !== 'approved') {
return this.evaluateTask(data);
}
return {
canSubmit: true,
status: decision.status,
riskDecision: decision.riskDecision,
reason: hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null,
task: null,
};
}));
hits.push(...contentIssues.map(contentIssueToHit));
const decision = decideRiskAction(hits);
if (decision.status !== 'approved') {
return this.evaluateTask(data);
}
return {
canSubmit: true,
status: decision.status,
riskDecision: decision.riskDecision,
reason: hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null,
task: null,
};
}),
);
}
async approveTask(taskId: string, data: ReviewSmsTaskDto) {
@@ -519,6 +534,7 @@ export class RiskReviewService {
if (!task) {
throw new NotFoundException('SMS send task not found');
}
if (task.sourceType === NIGHT_REVIEW_SOURCE) return this.reviewNightTask(taskId, 'approved', data);
this.assertAggregationWindowClosed(task);
return this.prisma.smsSendTask.update({
where: { id: taskId },
@@ -539,6 +555,7 @@ export class RiskReviewService {
if (!task) {
throw new NotFoundException('SMS send task not found');
}
if (task.sourceType === NIGHT_REVIEW_SOURCE) return this.reviewNightTask(taskId, 'rejected', data);
this.assertAggregationWindowClosed(task);
const reason = data.reason ?? task.reviewReason ?? '审核拒绝';
return this.prisma.smsSendTask.update({
@@ -611,11 +628,48 @@ 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()) {
if (
['cmpp_template_mismatch', NIGHT_REVIEW_SOURCE].includes(task.sourceType ?? '') &&
task.windowEndsAt &&
task.windowEndsAt.getTime() > Date.now()
) {
throw new BadRequestException('聚合窗口尚未关闭,请在窗口结束后审核');
}
}
private async reviewNightTask(taskId: string, decision: 'approved' | 'rejected', data: ReviewSmsTaskDto) {
return this.prisma.$transaction(async (tx) => {
const snapshot = await tx.smsSendTask.findUniqueOrThrow({ where: { id: taskId } });
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'sms-review-aggregation:' + snapshot.aggregationKey}, 0))`;
const task = await tx.smsSendTask.findUniqueOrThrow({ where: { id: taskId } });
this.assertAggregationWindowClosed(task);
if (task.status === decision) return task;
if (task.status !== 'pending_review') throw new BadRequestException('该任务已审核,不能更改审核决定');
return tx.smsSendTask.update({
where: { id: taskId },
data: {
status: decision,
riskDecision: decision === 'approved' ? 'allow' : 'block',
reviewReason: decision === 'approved' ? (data.reason ?? task.reviewReason) : task.reviewReason,
rejectReason: decision === 'rejected' ? (data.reason ?? '审核拒绝') : null,
reviewedById: data.reviewerId,
reviewedAt: new Date(),
},
include: { riskHits: true, reviewedBy: { select: { id: true, username: true, displayName: true } } },
});
});
}
async pendingNightContinuations() {
return this.prisma.$queryRaw<Array<{ id: string; status: 'approved' | 'rejected'; reason: string }>>`
SELECT t.id, t.status, COALESCE(t."rejectReason", t."reviewReason", '运营审核') AS reason
FROM "SmsSendTask" t WHERE t."sourceType" = 'night_sending_bulk' AND t.status IN ('approved','rejected')
AND (t."continuationLeaseExpiresAt" IS NULL OR t."continuationLeaseExpiresAt" < (NOW() AT TIME ZONE 'UTC'))
AND EXISTS (SELECT 1 FROM "NightSendingReservation" r WHERE r."reviewTaskId" = t.id AND r."continuedAt" IS NULL)
ORDER BY t."reviewedAt", t.id LIMIT 20
`;
}
private async effectiveRules(applicationId?: string) {
const rules = await this.prisma.riskRule.findMany({
where: {
@@ -626,6 +680,8 @@ export class RiskReviewService {
});
const byCode = new Map<string, (typeof rules)[number]>();
for (const rule of rules) {
// Night volume is checked once per persisted business message by the send Worker.
if (rule.code === NIGHT_RULE_CODE) continue;
if (rule.applicationId || !byCode.has(rule.code)) {
byCode.set(rule.code, rule);
}
@@ -651,7 +707,6 @@ export class RiskReviewService {
rules: Awaited<ReturnType<RiskReviewService['effectiveRules']>>,
metrics: {
phoneTotal: number;
nonWorkingMarketingPhones: number;
recentTaskCount: number;
},
): RuleEvaluation[] {
@@ -659,9 +714,7 @@ export class RiskReviewService {
for (const rule of rules) {
const threshold = rule.thresholdValue;
const actualValue = metricValue(rule.metric, metrics);
const shouldHit = rule.code === 'TASK_CREATE_FREQUENCY'
? actualValue >= threshold
: actualValue > threshold;
const shouldHit = rule.code === 'TASK_CREATE_FREQUENCY' ? actualValue >= threshold : actualValue > threshold;
if (!shouldHit) {
continue;
}
@@ -687,8 +740,16 @@ export class RiskReviewService {
throw new BadRequestException('风控阈值必须是大于等于0的有效数字');
}
if (
isPhoneFrequencyRule(data.code)
&& (!Number.isInteger(data.thresholdValue) || data.thresholdValue < 1 || data.action === 'manual_review')
data.code === NIGHT_RULE_CODE &&
(!Number.isSafeInteger(data.thresholdValue) ||
data.thresholdValue > 2_147_483_647 ||
(data.action && data.action !== 'manual_review'))
) {
throw new BadRequestException('夜间累计阈值必须是非负整数,处理动作固定为人工审核');
}
if (
isPhoneFrequencyRule(data.code) &&
(!Number.isInteger(data.thresholdValue) || data.thresholdValue < 1 || data.action === 'manual_review')
) {
throw new BadRequestException('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝');
}
@@ -735,6 +796,7 @@ export class RiskReviewService {
const startTime = String(config?.startTime ?? '21:00');
const endTime = String(config?.endTime ?? '08:00');
const timeZone = String(config?.timeZone ?? 'Asia/Shanghai');
if (timeZone !== 'Asia/Shanghai') throw new BadRequestException('夜间发送规则使用北京时间');
if (!isClockTime(startTime) || !isClockTime(endTime) || startTime === endTime) {
throw new BadRequestException('非工作时间必须是两个不同的 HH:mm 时间');
}
@@ -745,6 +807,36 @@ export class RiskReviewService {
}
return { startTime, endTime, timeZone };
}
private async ruleConfigForSave(
code: string,
config?: Record<string, unknown>,
applicationId?: string,
oldConfig?: unknown,
) {
const normalized = this.normalizeRuleConfig(code, config);
if (code !== NIGHT_RULE_CODE || !normalized) return normalized;
const previous =
oldConfig ??
(applicationId
? (
await this.prisma.riskRule.findFirst({
where: { code, applicationId: null, status: 'active' },
})
)?.config
: undefined);
const now = new Date();
const previousClock = nightClock(previous, now);
const active = nightWindow(now, previousClock);
if (active && (normalized.startTime !== previousClock.startTime || normalized.endTime !== previousClock.endTime)) {
return {
...normalized,
previousTimeConfig: previousClock,
timeConfigEffectiveAt: active.windowEndsAt.toISOString(),
};
}
return normalized;
}
}
function isPhoneFrequencyRule(code: string) {
@@ -762,53 +854,16 @@ function isBasicMobileNumber(phone: string) {
return /^1\d{10}$/.test(phone);
}
function isMarketing(category?: string | null) {
return ['marketing', 'promo', 'promotion', '营销'].includes((category ?? '').toLowerCase());
}
function readNonWorkingConfig(config: Prisma.JsonValue | null | undefined) {
const value = config && typeof config === 'object' && !Array.isArray(config)
? config as Record<string, Prisma.JsonValue>
: {};
return {
startTime: typeof value.startTime === 'string' ? value.startTime : '21:00',
endTime: typeof value.endTime === 'string' ? value.endTime : '08:00',
timeZone: typeof value.timeZone === 'string' ? value.timeZone : 'Asia/Shanghai',
};
}
function jsonObject(config: Prisma.JsonValue | null | undefined) {
return config && typeof config === 'object' && !Array.isArray(config)
? config as Record<string, unknown>
? (config as Record<string, unknown>)
: undefined;
}
function isNonWorkingTime(date: Date, config: { startTime: string; endTime: string; timeZone: string }) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: config.timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? 0);
const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? 0);
const current = hour * 60 + minute;
const start = clockMinutes(config.startTime);
const end = clockMinutes(config.endTime);
return start < end
? current >= start && current < end
: current >= start || current < end;
}
function isClockTime(value: string) {
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value);
}
function clockMinutes(value: string) {
const [hour, minute] = value.split(':').map(Number);
return hour * 60 + minute;
}
function evaluateTemplateVariables(
templateVariables: Array<{ name: string; required: boolean }>,
content: string,
@@ -833,13 +888,18 @@ function formatTemplateVariableIssueReason(issues: Array<{ type: string; name: s
const details = [
missing.length > 0 ? `缺少必填变量:${missing.join('、')}` : '',
extra.length > 0 ? `包含模板未定义变量:${extra.join('、')}` : '',
].filter(Boolean).join('');
]
.filter(Boolean)
.join('');
return `模板变量校验失败(${details}),本次提交拒绝`;
}
function evaluateContent(content: string, sensitiveWords: Array<{ word: string; level: string }>) {
const issues: RuleEvaluation[] = [];
const controlMatches = [...content].filter((char) => /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(char));
const controlMatches = [...content].filter((char) => {
const code = char.codePointAt(0)!;
return code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127;
});
if (controlMatches.length > 0) {
issues.push({
ruleCode: 'CONTENT_CONTROL_CHAR',
@@ -0,0 +1,60 @@
import { SendGatewaySubmitService } from './send-gateway-submit.service';
describe('shared dispatch night gate', () => {
function setup() {
const message = {
id: 'm1',
status: 'queued',
tenantId: 't1',
applicationId: 'a1',
batchTaskId: 'b1',
content: '任意内容',
};
const prisma = {
smsMessageRecord: {
findUnique: jest.fn().mockResolvedValue(message),
findMany: jest.fn().mockResolvedValue([message, { ...message, id: 'm2' }]),
update: jest.fn(),
},
};
const risk = { guardNightSending: jest.fn().mockResolvedValue(new Set(['m1'])) };
const facade = {
refreshTaskProgress: jest.fn(),
selectChannelForMessage: jest.fn(),
submitMessageToGateway: jest.fn(),
};
const sut = new SendGatewaySubmitService(
prisma as never,
{} as never,
risk as never,
{} as never,
{} as never,
facade as never,
{} as never,
);
return { sut, prisma, risk, facade };
}
it('holds single-message jobs before any channel or Submit operation', async () => {
const { sut, facade } = setup();
expect(await sut.processSendJob({ messageRecordId: 'm1' })).toMatchObject({
status: 'pending_review',
submitted: false,
});
expect(facade.selectChannelForMessage).not.toHaveBeenCalled();
expect(facade.submitMessageToGateway).not.toHaveBeenCalled();
});
it('partitions the fast worker batch by persistent gate decisions', async () => {
const { sut } = setup();
const route = jest.spyOn(sut as never, 'planRoutesBatch').mockResolvedValue({ planned: [], failed: [] } as never);
const result = await sut['processSendJobBatch']([{ messageRecordId: 'm1' }, { messageRecordId: 'm2' }]);
expect(result.get('m1')).toMatchObject({ status: 'pending_review' });
expect(route).toHaveBeenCalledWith([expect.objectContaining({ id: 'm2' })]);
});
it('propagates database failures without submitting or misclassifying them as routing failures', async () => {
const { sut, risk, facade, prisma } = setup();
risk.guardNightSending.mockRejectedValue(new Error('database unavailable'));
await expect(sut.processSendJob({ messageRecordId: 'm1' })).rejects.toThrow('database unavailable');
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled();
expect(facade.submitMessageToGateway).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,14 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { createHash, randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { BadRequestException, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import { BULLMQ_PRIORITY, normalizeQueuePriority } from './send-chain.helpers';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
/**
@@ -32,7 +28,13 @@ export class SendReviewContinuationService {
) {}
private releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) {
return this.callbacks.releaseMessageReservation(message, remark);
@@ -55,21 +57,18 @@ export class SendReviewContinuationService {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
const reviewTask = await this.prisma.smsSendTask.findUnique({
where: { id: reviewTaskId },
});
if (!reviewTask) {
return { reviewTaskId, decision, affected: 0 };
}
if (reviewTask.sourceType === 'night_sending_bulk') return this.continueNightReview(reviewTaskId, decision, reason);
const messageRecords = await this.prisma.smsMessageRecord.findMany({
where: {
status: 'pending_review',
OR: [
{ reviewTaskId },
{ batchTask: { riskTaskId: reviewTaskId } },
],
OR: [{ reviewTaskId }, { batchTask: { riskTaskId: reviewTaskId } }],
},
include: { batchTask: true },
});
@@ -106,4 +105,94 @@ async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejecte
}
return { reviewTaskId, decision, affected: messageRecords.length };
}
private async continueNightReview(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
const owner = randomUUID();
const acquired = await this.prisma.$queryRaw<Array<{ id: string }>>`
UPDATE "SmsSendTask" SET "continuationLeaseOwner"=${owner},
"continuationLeaseExpiresAt"=(NOW() AT TIME ZONE 'UTC') + INTERVAL '5 minutes'
WHERE id=${reviewTaskId} AND status=${decision}
AND ("continuationLeaseExpiresAt" IS NULL OR "continuationLeaseExpiresAt" < (NOW() AT TIME ZONE 'UTC'))
RETURNING id
`;
if (!acquired.length) return { reviewTaskId, decision, affected: 0 };
try {
return await this.continueLockedNightReview(reviewTaskId, decision, reason, owner);
} finally {
await this.prisma.smsSendTask.updateMany({
where: { id: reviewTaskId, continuationLeaseOwner: owner },
data: { continuationLeaseOwner: null, continuationLeaseExpiresAt: null },
});
}
}
private async continueLockedNightReview(
reviewTaskId: string,
decision: 'approved' | 'rejected',
reason: string,
owner: string,
) {
const task = await this.prisma.smsSendTask.findUniqueOrThrow({ where: { id: reviewTaskId } });
if (task.status !== decision) throw new BadRequestException('审核决定不一致');
const reservations = await this.prisma.nightSendingReservation.findMany({
where: { reviewTaskId, continuedAt: null },
take: 100,
orderBy: { messageRecordId: 'asc' },
});
for (const reservation of reservations) {
const renewed = await this.prisma.smsSendTask.updateMany({
where: { id: reviewTaskId, continuationLeaseOwner: owner },
data: { continuationLeaseExpiresAt: new Date(Date.now() + 300_000) },
});
if (renewed.count !== 1) throw new Error('夜间审核续发租约已转移');
const message = await this.prisma.smsMessageRecord.findUniqueOrThrow({
where: { id: reservation.messageRecordId },
include: { batchTask: true },
});
if (!message.tenantId || !message.applicationId || !message.batchTaskId || message.reviewTaskId !== reviewTaskId)
throw new Error('夜间审核消息关联无效');
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
if (decision === 'approved') {
await this.prisma.smsMessageRecord.updateMany({
where: { id: message.id, status: 'pending_review' },
data: { status: 'queued', errorCode: null, errorMessage: null },
});
// A held initial job may already be completed. Use a distinct stable job ID
// so an approved message is resumed once; crashes can safely repeat add().
if (['pending_review', 'queued'].includes(message.status))
await this.facade.getSendQueue().add(
'send-message',
{ messageRecordId: message.id },
{
jobId: `${message.id}-night-${reviewTaskId}`,
attempts: 3,
priority: BULLMQ_PRIORITY[normalizeQueuePriority(message.queuePriority)],
},
);
} else if (['pending_review', 'failed'].includes(message.status)) {
await this.releaseMessageReservation(businessMessage, '夜间累计发送人工审核驳回释放冻结');
await this.recordCmppFailureReceipt(businessMessage, 'REVIEW_REJECTED', reason);
await this.prisma.smsMessageRecord.updateMany({
where: { id: message.id, status: 'pending_review' },
data: { status: 'rejected', submitStatus: 'rejected', errorCode: 'REVIEW_REJECTED', errorMessage: reason },
});
}
await this.prisma.nightSendingReservation.update({
where: { messageRecordId: message.id },
data: { continuedAt: new Date() },
});
const pending = await this.prisma.smsMessageRecord.count({
where: { batchTaskId: message.batchTaskId, status: 'pending_review' },
});
await this.prisma.smsBatchTask.update({
where: { id: message.batchTaskId },
data: {
auditStatus: pending ? 'pending_review' : decision,
reviewReason: reason,
},
});
await this.facade.refreshTaskProgress(message.batchTaskId);
}
return { reviewTaskId, decision, affected: reservations.length };
}
}