This commit is contained in:
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user