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,38 @@
CREATE TABLE "NightSendingWindow" (
"id" TEXT PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"windowStartedAt" TIMESTAMP(3) NOT NULL,
"windowEndsAt" TIMESTAMP(3) NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
"baselineCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL
);
CREATE UNIQUE INDEX "NightSendingWindow_applicationId_windowStartedAt_key" ON "NightSendingWindow"("applicationId", "windowStartedAt");
CREATE INDEX "NightSendingWindow_applicationId_windowEndsAt_idx" ON "NightSendingWindow"("applicationId", "windowEndsAt");
CREATE TABLE "NightSendingReservation" (
"messageRecordId" TEXT PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"windowId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"thresholdValue" INTEGER NOT NULL,
"reviewTaskId" TEXT,
"continuedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "NightSendingReservation_applicationId_windowId_idx" ON "NightSendingReservation"("applicationId", "windowId");
CREATE INDEX "NightSendingReservation_reviewTaskId_idx" ON "NightSendingReservation"("reviewTaskId");
ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseOwner" TEXT;
ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseExpiresAt" TIMESTAMP(3);
-- Preserve rule IDs and thresholds for application overrides and historical hits.
UPDATE "RiskRule" SET "name" = '夜间累计发送量审核',
"description" = '同一企业应用在夜间累计业务短信超过阈值后进入人工审核,所有入口与内容合并计数。',
"metric" = 'nightSendingCount', "action" = 'manual_review',
"config" = COALESCE("config", '{}'::jsonb) || '{"timeZone":"Asia/Shanghai"}'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'NON_WORKING_MARKETING_BULK';
-- The newly approved policy applies by default to every application.
UPDATE "RiskRule" SET "status" = 'active', "updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'NON_WORKING_MARKETING_BULK' AND "applicationId" IS NULL AND "status" <> 'deleted';
+32
View File
@@ -1511,6 +1511,36 @@ model ReportReceiptImport {
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
}
model NightSendingWindow {
id String @id
tenantId String
applicationId String
windowStartedAt DateTime
windowEndsAt DateTime
count Int @default(0)
baselineCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([applicationId, windowStartedAt])
@@index([applicationId, windowEndsAt])
}
model NightSendingReservation {
messageRecordId String @id
tenantId String
applicationId String
windowId String
sequence Int
thresholdValue Int
reviewTaskId String?
continuedAt DateTime?
createdAt DateTime @default(now())
@@index([applicationId, windowId])
@@index([reviewTaskId])
}
model RiskRule {
id String @id @default(cuid())
tenantId String?
@@ -1563,6 +1593,8 @@ model SmsSendTask {
createdById String?
reviewedById String?
reviewedAt DateTime?
continuationLeaseOwner String?
continuationLeaseExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -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 };
}
}