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> }) =>
|
||||
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({
|
||||
$transaction: jest.fn(async (callback) =>
|
||||
callback({
|
||||
smsSendTask: {
|
||||
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' }),
|
||||
},
|
||||
})),
|
||||
}),
|
||||
),
|
||||
...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']({
|
||||
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的整数,首版处理动作固定为直接拒绝');
|
||||
}),
|
||||
).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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
}),
|
||||
);
|
||||
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([
|
||||
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({
|
||||
await expect(
|
||||
service.evaluateTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '13800000002'],
|
||||
})).resolves.toEqual(expect.objectContaining({ status: 'rejected' }));
|
||||
}),
|
||||
).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([{
|
||||
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({
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
OR: [
|
||||
{ reviewTaskId: 'review-task-1' },
|
||||
{ batchTask: { riskTaskId: 'review-task-1' } },
|
||||
],
|
||||
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,7 +510,8 @@ describe('RiskReviewService', () => {
|
||||
|
||||
it('does not include CMPP or HTTP tasks in client task frequency control', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.riskRule.findMany.mockResolvedValue([{
|
||||
prisma.riskRule.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'rule-frequency',
|
||||
code: 'TASK_CREATE_FREQUENCY',
|
||||
name: '短时间任务创建频控',
|
||||
@@ -467,23 +519,28 @@ describe('RiskReviewService', () => {
|
||||
thresholdValue: 1,
|
||||
action: 'manual_review',
|
||||
priority: 30,
|
||||
}]);
|
||||
},
|
||||
]);
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
|
||||
await expect(service.evaluateTask({
|
||||
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({
|
||||
}),
|
||||
).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 }));
|
||||
}),
|
||||
).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
|
||||
config:
|
||||
data.config === undefined
|
||||
? undefined
|
||||
: this.normalizeRuleConfig(rule.code, data.config) as Prisma.InputJsonValue,
|
||||
: ((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 ? {
|
||||
...(!status
|
||||
? {
|
||||
OR: [{ status: 'pending_review' }, { reviewedById: { not: null } }],
|
||||
}
|
||||
: {}),
|
||||
...(status === 'pending_review'
|
||||
? {
|
||||
OR: [
|
||||
{ status: 'pending_review' },
|
||||
{ reviewedById: { not: null } },
|
||||
],
|
||||
} : {}),
|
||||
...(status === 'pending_review' ? {
|
||||
OR: [
|
||||
{ sourceType: { not: 'cmpp_template_mismatch' } },
|
||||
{ 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({
|
||||
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 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,25 +470,29 @@ 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) => ({
|
||||
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) => {
|
||||
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 ?? {});
|
||||
@@ -488,15 +509,8 @@ export class RiskReviewService {
|
||||
}
|
||||
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));
|
||||
@@ -511,7 +525,8 @@ export class RiskReviewService {
|
||||
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
@@ -1,19 +1,33 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, 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 { randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
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 { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.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 type { SendJob, RoutedChannel } from './send-chain.contracts';
|
||||
import {
|
||||
SEND_QUEUE,
|
||||
GATEWAY_SUBMIT_QUEUE,
|
||||
GATEWAY_SUBMIT_STREAM,
|
||||
GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS,
|
||||
BULLMQ_PRIORITY,
|
||||
normalizeCarrier,
|
||||
normalizeQueuePriority,
|
||||
getPositiveConfigInteger,
|
||||
getNonNegativeConfigInteger,
|
||||
isNationalChannel,
|
||||
composeUpstreamSrcId,
|
||||
bullmqConnection,
|
||||
selectChannelCandidate,
|
||||
} from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
type PendingSendBatchItem = {
|
||||
@@ -34,6 +48,8 @@ export class SendGatewaySubmitService {
|
||||
private sendQueueMetricsTimer?: ReturnType<typeof setInterval>;
|
||||
private submitOutboxTimer?: ReturnType<typeof setInterval>;
|
||||
private submitOutboxRunning = false;
|
||||
private nightReviewRecoveryTimer?: ReturnType<typeof setInterval>;
|
||||
private nightReviewRecovering = false;
|
||||
private readonly submitOutboxLeaseOwner = `send-worker-${process.pid}-${randomUUID()}`;
|
||||
private sendWorkerInFlight = 0;
|
||||
private sendWorkerConfiguredSlots = 0;
|
||||
@@ -56,6 +72,7 @@ export class SendGatewaySubmitService {
|
||||
) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.nightReviewRecoveryTimer) clearInterval(this.nightReviewRecoveryTimer);
|
||||
if (this.sendQueueMetricsTimer) clearInterval(this.sendQueueMetricsTimer);
|
||||
if (this.submitOutboxTimer) clearInterval(this.submitOutboxTimer);
|
||||
if (this.sendBatchTimer) clearTimeout(this.sendBatchTimer);
|
||||
@@ -67,7 +84,13 @@ export class SendGatewaySubmitService {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -90,17 +113,20 @@ export class SendGatewaySubmitService {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) {
|
||||
async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) {
|
||||
if (preparedMessage) {
|
||||
// CMPP内部任务在当前请求内刚完成持久化且不暴露取消入口,可安全复用已知ID;
|
||||
// 普通批量任务仍走下方查询路径,以保留取消检查和多消息枚举语义。
|
||||
const queuePriority = normalizeQueuePriority(preparedMessage.queuePriority);
|
||||
await this.facade.getSendQueue().add('send-message', { messageRecordId: preparedMessage.messageRecordId }, {
|
||||
await this.facade.getSendQueue().add(
|
||||
'send-message',
|
||||
{ messageRecordId: preparedMessage.messageRecordId },
|
||||
{
|
||||
jobId: preparedMessage.messageRecordId,
|
||||
attempts: 3,
|
||||
priority: BULLMQ_PRIORITY[queuePriority],
|
||||
});
|
||||
},
|
||||
);
|
||||
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
|
||||
return { taskId, enqueued: 1 };
|
||||
}
|
||||
@@ -119,23 +145,29 @@ async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: stri
|
||||
const queue = this.facade.getSendQueue();
|
||||
for (const message of messages) {
|
||||
const queuePriority = normalizeQueuePriority(message.queuePriority);
|
||||
await queue.add('send-message', { messageRecordId: message.id }, {
|
||||
await queue.add(
|
||||
'send-message',
|
||||
{ messageRecordId: message.id },
|
||||
{
|
||||
jobId: message.id,
|
||||
attempts: 3,
|
||||
priority: BULLMQ_PRIORITY[queuePriority],
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
|
||||
return { taskId, enqueued: messages.length };
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
startWorker() {
|
||||
if (this.worker) {
|
||||
return { status: 'already_started' };
|
||||
}
|
||||
this.nightReviewRecoveryTimer = setInterval(() => void this.recoverNightReviews(), 15_000);
|
||||
const connection = bullmqConnection();
|
||||
const configuredConcurrency = Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20);
|
||||
this.sendWorkerConfiguredSlots = Number.isInteger(configuredConcurrency) && configuredConcurrency > 0 ? configuredConcurrency : 20;
|
||||
this.sendWorkerConfiguredSlots =
|
||||
Number.isInteger(configuredConcurrency) && configuredConcurrency > 0 ? configuredConcurrency : 20;
|
||||
this.metrics?.setSendWorkerSlots(this.sendWorkerConfiguredSlots, this.sendWorkerInFlight);
|
||||
this.worker = new Worker<SendJob>(
|
||||
SEND_QUEUE,
|
||||
@@ -167,7 +199,7 @@ startWorker() {
|
||||
return { status: 'started' };
|
||||
}
|
||||
|
||||
startSubmitOutboxPublisher() {
|
||||
startSubmitOutboxPublisher() {
|
||||
if (this.submitOutboxTimer) return { status: 'already_started' };
|
||||
if (!this.submitOutboxEnabled()) return { status: 'disabled' };
|
||||
void this.publishSubmitOutboxBatch();
|
||||
@@ -227,10 +259,12 @@ startSubmitOutboxPublisher() {
|
||||
return new Map([[jobs[0].messageRecordId, await this.processSendJob(jobs[0])]]);
|
||||
}
|
||||
const ids = [...new Set(jobs.map((job) => job.messageRecordId))];
|
||||
const messages = await this.measureSendStage('message_load', () => this.prisma.smsMessageRecord.findMany({
|
||||
const messages = await this.measureSendStage('message_load', () =>
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const messageById = new Map(messages.map((message) => [message.id, message]));
|
||||
const results = new Map<string, unknown>();
|
||||
const businessMessages = messages.filter((message) => {
|
||||
@@ -239,21 +273,31 @@ startSubmitOutboxPublisher() {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) as Array<typeof messages[number] & { tenantId: string; batchTaskId: string }>;
|
||||
}) as Array<(typeof messages)[number] & { tenantId: string; batchTaskId: string }>;
|
||||
for (const id of ids) if (!messageById.has(id)) results.set(id, { skipped: true });
|
||||
if (businessMessages.length === 0) return results;
|
||||
|
||||
const { planned, failed } = await this.planRoutesBatch(businessMessages);
|
||||
const held = await this.riskReview.guardNightSending(businessMessages.map((message) => message.id));
|
||||
for (const id of held) results.set(id, { submitted: false, status: 'pending_review', messageRecordId: id });
|
||||
for (const taskId of new Set(
|
||||
businessMessages.filter((message) => held.has(message.id)).map((message) => message.batchTaskId),
|
||||
))
|
||||
await this.facade.refreshTaskProgress(taskId);
|
||||
const { planned, failed } = await this.planRoutesBatch(businessMessages.filter((message) => !held.has(message.id)));
|
||||
if (failed.length > 0) await this.failRouteBatch(failed, results);
|
||||
if (planned.length === 0) return results;
|
||||
|
||||
await Promise.all(planned.map(({ routed }) => (
|
||||
this.facade.waitForChannelRateLimit(routed.channel.id, routed.channel.rateLimitPerSecond)
|
||||
)));
|
||||
await Promise.all(
|
||||
planned.map(({ routed }) =>
|
||||
this.facade.waitForChannelRateLimit(routed.channel.id, routed.channel.rateLimitPerSecond),
|
||||
),
|
||||
);
|
||||
const sessionByChannel = new Map<string, string>();
|
||||
await Promise.all([...new Set(planned.map(({ routed }) => routed.channel.id))].map(async (channelId) => {
|
||||
await Promise.all(
|
||||
[...new Set(planned.map(({ routed }) => routed.channel.id))].map(async (channelId) => {
|
||||
sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId));
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const prepared = planned.map(({ message, routed }) => {
|
||||
const submitId = `SUB-${randomUUID()}`;
|
||||
const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension);
|
||||
@@ -266,7 +310,8 @@ startSubmitOutboxPublisher() {
|
||||
};
|
||||
});
|
||||
const writeOutbox = this.submitOutboxEnabled();
|
||||
await this.measureSendStage('submit_transaction', () => this.prisma.$transaction(async (tx) => {
|
||||
await this.measureSendStage('submit_transaction', () =>
|
||||
this.prisma.$transaction(async (tx) => {
|
||||
await tx.smsSubmitRecord.createMany({
|
||||
data: prepared.map(({ message, routed, submitId, sessionId }) => ({
|
||||
id: randomUUID(),
|
||||
@@ -283,10 +328,14 @@ startSubmitOutboxPublisher() {
|
||||
costAmountCents: moneyToNumber(routed.channel.unitPrice) * Math.max(1, message.billingUnits ?? 1),
|
||||
})),
|
||||
});
|
||||
const updates = Prisma.join(prepared.map(({ message, routed, submitId }) => Prisma.sql`(
|
||||
const updates = Prisma.join(
|
||||
prepared.map(
|
||||
({ message, routed, submitId }) => Prisma.sql`(
|
||||
${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text,
|
||||
${routed.province ?? null}::text, ${submitId}::text
|
||||
)`));
|
||||
)`,
|
||||
),
|
||||
);
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE "SmsMessageRecord" AS message
|
||||
SET "channelId" = updates."channelId",
|
||||
@@ -305,45 +354,73 @@ startSubmitOutboxPublisher() {
|
||||
if (writeOutbox) {
|
||||
await tx.gatewaySubmitOutbox.createMany({
|
||||
data: prepared.map(({ message, routed, submitId, command }) => ({
|
||||
id: randomUUID(), submitId, messageRecordId: message.id,
|
||||
channelId: routed.channel.id, payload: command as Prisma.InputJsonValue,
|
||||
id: randomUUID(),
|
||||
submitId,
|
||||
messageRecordId: message.id,
|
||||
channelId: routed.channel.id,
|
||||
payload: command as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
if (!this.submitOutboxPublishEnabled()) {
|
||||
await Promise.all(prepared.map(({ command }) => this.facade.publishGatewaySubmitCommand(command)));
|
||||
}
|
||||
await this.refreshTaskProgressBatch(prepared.map(({ message }) => message));
|
||||
for (const { message, routed, submitId } of prepared) {
|
||||
results.set(message.id, {
|
||||
submitted: true, messageRecordId: message.id, channelId: routed.channel.id, attempt: 0, submitId,
|
||||
submitted: true,
|
||||
messageRecordId: message.id,
|
||||
channelId: routed.channel.id,
|
||||
attempt: 0,
|
||||
submitId,
|
||||
});
|
||||
this.metrics?.recordSendWorkerResult('completed');
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async planRoutesBatch<T extends {
|
||||
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null;
|
||||
templateId?: string | null; signatureId?: string | null; phoneNumber: string;
|
||||
carrier?: string | null; province?: string | null;
|
||||
private async planRoutesBatch<
|
||||
T extends {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
phoneNumber: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
template?: { signature?: { id?: string | null } | null } | null;
|
||||
signature?: { id?: string | null } | null;
|
||||
}>(messages: T[]) {
|
||||
},
|
||||
>(messages: T[]) {
|
||||
const unresolvedPhones = messages.filter((message) => !message.carrier).map((message) => message.phoneNumber);
|
||||
const provinces = await this.measureSendStage('phone_routing', () => this.phoneRouting.identifyProvinces(unresolvedPhones));
|
||||
const routeInputs = await Promise.all(messages.map(async (message) => ({
|
||||
const provinces = await this.measureSendStage('phone_routing', () =>
|
||||
this.phoneRouting.identifyProvinces(unresolvedPhones),
|
||||
);
|
||||
const routeInputs = await Promise.all(
|
||||
messages.map(async (message) => ({
|
||||
message,
|
||||
carrier: message.carrier ? normalizeCarrier(message.carrier) : normalizeCarrier(await this.phoneRouting.identifyCarrier(message.phoneNumber)),
|
||||
province: message.carrier ? message.province ?? null : provinces.get(message.phoneNumber) ?? null,
|
||||
carrier: message.carrier
|
||||
? normalizeCarrier(message.carrier)
|
||||
: normalizeCarrier(await this.phoneRouting.identifyCarrier(message.phoneNumber)),
|
||||
province: message.carrier ? (message.province ?? null) : (provinces.get(message.phoneNumber) ?? null),
|
||||
signatureId: message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null,
|
||||
})));
|
||||
})),
|
||||
);
|
||||
const valid = routeInputs.filter((input) => input.message.applicationId && input.signatureId);
|
||||
const signatures = [...new Set(valid.map((input) => input.signatureId as string))];
|
||||
const routes = valid.length === 0 ? [] : await this.measureSendStage('route_lookup', () => this.prisma.channelRouteRule.findMany({
|
||||
const routes =
|
||||
valid.length === 0
|
||||
? []
|
||||
: await this.measureSendStage('route_lookup', () =>
|
||||
this.prisma.channelRouteRule.findMany({
|
||||
where: {
|
||||
status: 'active', channelId: null, province: null,
|
||||
status: 'active',
|
||||
channelId: null,
|
||||
province: null,
|
||||
OR: valid.map((input) => ({
|
||||
tenantId: input.message.tenantId,
|
||||
applicationId: input.message.applicationId,
|
||||
@@ -358,7 +435,9 @@ startSubmitOutboxPublisher() {
|
||||
channel: {
|
||||
include: {
|
||||
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
|
||||
reportTasks: { where: { signatureId: { in: signatures }, reportType: 'signature', status: 'approved' } },
|
||||
reportTasks: {
|
||||
where: { signatureId: { in: signatures }, reportType: 'signature', status: 'approved' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -368,8 +447,9 @@ startSubmitOutboxPublisher() {
|
||||
},
|
||||
},
|
||||
orderBy: { priority: 'asc' },
|
||||
}));
|
||||
const routeByKey = new Map<string, typeof routes[number]>();
|
||||
}),
|
||||
);
|
||||
const routeByKey = new Map<string, (typeof routes)[number]>();
|
||||
for (const route of routes) {
|
||||
const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`;
|
||||
if (!routeByKey.has(key)) routeByKey.set(key, route);
|
||||
@@ -394,11 +474,17 @@ startSubmitOutboxPublisher() {
|
||||
failed.push({ message: input.message, reason: '企业应用绑定的通道组已停用或运营商不一致' });
|
||||
continue;
|
||||
}
|
||||
const approvedItems = route.group.items.filter((item) => item.channel.status === 'active'
|
||||
&& item.channel.connectionStates.length > 0
|
||||
&& item.channel.reportTasks.some((task) => task.signatureId === input.signatureId
|
||||
&& (task.carrier === input.carrier
|
||||
|| (process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel'))));
|
||||
const approvedItems = route.group.items.filter(
|
||||
(item) =>
|
||||
item.channel.status === 'active' &&
|
||||
item.channel.connectionStates.length > 0 &&
|
||||
item.channel.reportTasks.some(
|
||||
(task) =>
|
||||
task.signatureId === input.signatureId &&
|
||||
(task.carrier === input.carrier ||
|
||||
(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')),
|
||||
),
|
||||
);
|
||||
const selected = selectChannelCandidate(approvedItems, {
|
||||
carrier: input.carrier,
|
||||
province: input.province,
|
||||
@@ -414,8 +500,10 @@ startSubmitOutboxPublisher() {
|
||||
message: input.message,
|
||||
routed: {
|
||||
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
|
||||
carrier: input.carrier, province: input.province,
|
||||
groupId: route.groupId, groupName: route.group.name,
|
||||
carrier: input.carrier,
|
||||
province: input.province,
|
||||
groupId: route.groupId,
|
||||
groupName: route.group.name,
|
||||
routeScope: isNationalChannel(selected) ? 'national' : 'province',
|
||||
},
|
||||
});
|
||||
@@ -423,43 +511,67 @@ startSubmitOutboxPublisher() {
|
||||
return { planned, failed };
|
||||
}
|
||||
|
||||
private async failRouteBatch<T extends {
|
||||
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null;
|
||||
messageId: string; phoneNumber: string; amountCents: bigint; billingUnits: number;
|
||||
cmppSubmitSequenceId?: string | null; cmppSubmitGroupMessageId?: string | null;
|
||||
private async failRouteBatch<
|
||||
T extends {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
amountCents: bigint;
|
||||
billingUnits: number;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
|
||||
}>(failed: Array<{ message: T; reason: string }>, results: Map<string, unknown>) {
|
||||
const values = Prisma.join(failed.map(({ message, reason }) => Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text)`));
|
||||
},
|
||||
>(failed: Array<{ message: T; reason: string }>, results: Map<string, unknown>) {
|
||||
const values = Prisma.join(
|
||||
failed.map(({ message, reason }) => Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text)`),
|
||||
);
|
||||
await this.prisma.$executeRaw(Prisma.sql`
|
||||
UPDATE "SmsMessageRecord" AS message
|
||||
SET status = 'failed', "errorMessage" = failures.reason, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
|
||||
FROM (VALUES ${values}) AS failures(id, reason)
|
||||
WHERE message.id = failures.id AND message.status = 'queued'
|
||||
`);
|
||||
await Promise.all(failed.map(async ({ message, reason }) => {
|
||||
await Promise.all(
|
||||
failed.map(async ({ message, reason }) => {
|
||||
await this.releaseMessageReservation(message, reason);
|
||||
if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, 'ROUTE', reason);
|
||||
else await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
|
||||
this.metrics?.recordSendWorkerResult('failed');
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshTaskProgressBatch(messages: Array<{
|
||||
batchTaskId: string; batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
|
||||
}>) {
|
||||
const singleCmppIds = [...new Set(messages
|
||||
private async refreshTaskProgressBatch(
|
||||
messages: Array<{
|
||||
batchTaskId: string;
|
||||
batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
|
||||
}>,
|
||||
) {
|
||||
const singleCmppIds = [
|
||||
...new Set(
|
||||
messages
|
||||
.filter((message) => message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1)
|
||||
.map((message) => message.batchTaskId))];
|
||||
.map((message) => message.batchTaskId),
|
||||
),
|
||||
];
|
||||
if (singleCmppIds.length > 0) {
|
||||
await this.prisma.smsBatchTask.updateMany({
|
||||
where: { id: { in: singleCmppIds }, sourceType: 'cmpp', phoneTotal: 1 },
|
||||
data: singleMessageTaskProgress('submit_queued'),
|
||||
});
|
||||
}
|
||||
const otherTaskIds = [...new Set(messages
|
||||
const otherTaskIds = [
|
||||
...new Set(
|
||||
messages
|
||||
.filter((message) => !singleCmppIds.includes(message.batchTaskId))
|
||||
.map((message) => message.batchTaskId))];
|
||||
.map((message) => message.batchTaskId),
|
||||
),
|
||||
];
|
||||
await Promise.all(otherTaskIds.map((taskId) => this.facade.refreshTaskProgress(taskId)));
|
||||
}
|
||||
|
||||
@@ -470,15 +582,21 @@ startSubmitOutboxPublisher() {
|
||||
if (totalFinished) return;
|
||||
totalFinished = true;
|
||||
if (totalStartedAt != null) {
|
||||
this.metrics?.finishSendWorkerStage(totalStartedAt, 'total', result === 'completed' ? 'success' : result === 'skipped' ? 'skipped' : 'error');
|
||||
this.metrics?.finishSendWorkerStage(
|
||||
totalStartedAt,
|
||||
'total',
|
||||
result === 'completed' ? 'success' : result === 'skipped' ? 'skipped' : 'error',
|
||||
);
|
||||
}
|
||||
this.metrics?.recordSendWorkerResult(result);
|
||||
};
|
||||
try {
|
||||
const message = await this.measureSendStage('message_load', () => this.prisma.smsMessageRecord.findUnique({
|
||||
const message = await this.measureSendStage('message_load', () =>
|
||||
this.prisma.smsMessageRecord.findUnique({
|
||||
where: { id: job.messageRecordId },
|
||||
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
|
||||
}));
|
||||
}),
|
||||
);
|
||||
if (!message || message.status !== 'queued') {
|
||||
finish('skipped');
|
||||
return { skipped: true };
|
||||
@@ -488,6 +606,14 @@ startSubmitOutboxPublisher() {
|
||||
return { skipped: true, reason: 'standalone channel test message' };
|
||||
}
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
// Keep persistence failures outside the route-failure catch: retry the job,
|
||||
// never turn a failed risk check into a submission or a silent route rejection.
|
||||
const held = await this.riskReview.guardNightSending([message.id]);
|
||||
if (held.has(message.id)) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
finish('skipped');
|
||||
return { submitted: false, status: 'pending_review', messageRecordId: message.id };
|
||||
}
|
||||
try {
|
||||
const routed = await this.facade.selectChannelForMessage(businessMessage);
|
||||
const result = await this.facade.submitMessageToGateway(businessMessage, routed, 0);
|
||||
@@ -543,13 +669,16 @@ startSubmitOutboxPublisher() {
|
||||
) {
|
||||
const channel = routed.channel;
|
||||
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
|
||||
await this.measureSendStage('rate_limit', () => this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond));
|
||||
await this.measureSendStage('rate_limit', () =>
|
||||
this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond),
|
||||
);
|
||||
const submitId = `SUB-${randomUUID()}`;
|
||||
const sessionId = await this.getOpenSubmitSessionId(channel.id);
|
||||
const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId);
|
||||
const writeOutbox = this.submitOutboxEnabled();
|
||||
try {
|
||||
await this.measureSendStage('submit_transaction', () => this.prisma.$transaction(async (tx) => {
|
||||
await this.measureSendStage('submit_transaction', () =>
|
||||
this.prisma.$transaction(async (tx) => {
|
||||
await tx.smsSubmitRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
@@ -577,7 +706,10 @@ startSubmitOutboxPublisher() {
|
||||
submitStatus: 'queued',
|
||||
receiptStatus: null,
|
||||
errorCode: null,
|
||||
errorMessage: attempt > 0 ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined,
|
||||
errorMessage:
|
||||
attempt > 0
|
||||
? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道`
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
if (writeOutbox) {
|
||||
@@ -590,33 +722,34 @@ startSubmitOutboxPublisher() {
|
||||
},
|
||||
});
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
if (retryOfSubmitRecordId) {
|
||||
this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({
|
||||
this.logger.log(
|
||||
`sms_retry_claim_acquired ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId,
|
||||
channelId: channel.id,
|
||||
})}`);
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
retryOfSubmitRecordId
|
||||
&& error instanceof Prisma.PrismaClientKnownRequestError
|
||||
&& error.code === 'P2002'
|
||||
) {
|
||||
if (retryOfSubmitRecordId && error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { retryOfSubmitRecordId },
|
||||
});
|
||||
if (existingRetry) {
|
||||
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
|
||||
this.logger.warn(
|
||||
`sms_retry_claim_reused ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId: existingRetry.submitId,
|
||||
channelId: existingRetry.channelId,
|
||||
})}`);
|
||||
})}`,
|
||||
);
|
||||
return {
|
||||
submitted: false,
|
||||
duplicateRetry: true,
|
||||
@@ -632,18 +765,28 @@ startSubmitOutboxPublisher() {
|
||||
if (!this.submitOutboxPublishEnabled()) {
|
||||
await this.measureSendStage('gateway_stream_publish', () => this.facade.publishGatewaySubmitCommand(command));
|
||||
}
|
||||
await this.measureSendStage('task_progress', () => this.facade.refreshTaskProgress(
|
||||
await this.measureSendStage('task_progress', () =>
|
||||
this.facade.refreshTaskProgress(
|
||||
message.batchTaskId,
|
||||
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'submit_queued' : undefined,
|
||||
));
|
||||
),
|
||||
);
|
||||
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
|
||||
}
|
||||
|
||||
private buildGatewaySubmitCommand(
|
||||
message: {
|
||||
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null;
|
||||
templateId?: string | null; messageId: string; phoneNumber: string; content: string;
|
||||
billingUnits: number; queuePriority?: string | null; applicationExtension?: string | null;
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuePriority?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
template?: { signature?: { name?: string | null } | null } | null;
|
||||
signature?: { name?: string | null } | null;
|
||||
},
|
||||
@@ -681,7 +824,8 @@ startSubmitOutboxPublisher() {
|
||||
groupId: routed.groupId,
|
||||
},
|
||||
cmpp: {
|
||||
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
|
||||
serviceId:
|
||||
channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
|
||||
? String(channel.config.serviceId)
|
||||
: 'SMS',
|
||||
srcId: upstreamSrcId,
|
||||
@@ -702,15 +846,21 @@ startSubmitOutboxPublisher() {
|
||||
connectionWarmupSeconds: getNonNegativeConfigInteger(channel.config, 'connectionWarmupSeconds', 30),
|
||||
connectionDrainTimeoutSeconds: getPositiveConfigInteger(channel.config, 'connectionDrainTimeoutSeconds', 60),
|
||||
submitResponseTimeoutSeconds: getPositiveConfigInteger(channel.config, 'submitResponseTimeoutSeconds', 60),
|
||||
connectionFailureCooldownSeconds: getPositiveConfigInteger(channel.config, 'connectionFailureCooldownSeconds', 30),
|
||||
connectionFailureCooldownSeconds: getPositiveConfigInteger(
|
||||
channel.config,
|
||||
'connectionFailureCooldownSeconds',
|
||||
30,
|
||||
),
|
||||
},
|
||||
retry: { attempt, maxAttempts: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
private submitOutboxEnabled() {
|
||||
return process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED === 'true'
|
||||
|| process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED === 'true';
|
||||
return (
|
||||
process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED === 'true' ||
|
||||
process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
private submitOutboxPublishEnabled() {
|
||||
@@ -723,7 +873,9 @@ startSubmitOutboxPublisher() {
|
||||
try {
|
||||
const batchSize = Math.min(500, getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_BATCH_SIZE', 64));
|
||||
const leaseSeconds = Math.min(300, getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_LEASE_SECONDS', 30));
|
||||
const rows = await this.prisma.$queryRaw<Array<{ id: string; submitId: string; payload: Prisma.JsonValue }>>(Prisma.sql`
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
Array<{ id: string; submitId: string; payload: Prisma.JsonValue }>
|
||||
>(Prisma.sql`
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM "GatewaySubmitOutbox"
|
||||
@@ -749,9 +901,13 @@ startSubmitOutboxPublisher() {
|
||||
const results = this.submitOutboxPublishEnabled()
|
||||
? await this.publishGatewaySubmitCommandBatch(rows)
|
||||
: rows.map((row) => ({ row, streamEntryId: `shadow:${row.submitId}` }));
|
||||
const succeeded = results.filter((result): result is { row: typeof rows[number]; streamEntryId: string } => 'streamEntryId' in result);
|
||||
const succeeded = results.filter(
|
||||
(result): result is { row: (typeof rows)[number]; streamEntryId: string } => 'streamEntryId' in result,
|
||||
);
|
||||
if (succeeded.length > 0) {
|
||||
const values = Prisma.join(succeeded.map(({ row, streamEntryId }) => Prisma.sql`(${row.id}, ${streamEntryId})`));
|
||||
const values = Prisma.join(
|
||||
succeeded.map(({ row, streamEntryId }) => Prisma.sql`(${row.id}, ${streamEntryId})`),
|
||||
);
|
||||
await this.prisma.$executeRaw(Prisma.sql`
|
||||
UPDATE "GatewaySubmitOutbox" AS outbox
|
||||
SET status = 'published',
|
||||
@@ -783,11 +939,15 @@ startSubmitOutboxPublisher() {
|
||||
WHERE id = ${row.id} AND "leaseOwner" = ${this.submitOutboxLeaseOwner}
|
||||
`);
|
||||
} catch (recordError) {
|
||||
this.logger.error(`gateway_submit_outbox_failure_record_failed ${recordError instanceof Error ? recordError.message : String(recordError)}`);
|
||||
this.logger.error(
|
||||
`gateway_submit_outbox_failure_record_failed ${recordError instanceof Error ? recordError.message : String(recordError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`gateway_submit_outbox_publish_failed ${error instanceof Error ? error.message : String(error)}`);
|
||||
this.logger.error(
|
||||
`gateway_submit_outbox_publish_failed ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.submitOutboxRunning = false;
|
||||
}
|
||||
@@ -795,10 +955,9 @@ startSubmitOutboxPublisher() {
|
||||
|
||||
private async publishGatewaySubmitCommandBatch(
|
||||
rows: Array<{ id: string; submitId: string; payload: Prisma.JsonValue }>,
|
||||
): Promise<Array<
|
||||
{ row: typeof rows[number]; streamEntryId: string }
|
||||
| { row: typeof rows[number]; error: unknown }
|
||||
>> {
|
||||
): Promise<
|
||||
Array<{ row: (typeof rows)[number]; streamEntryId: string } | { row: (typeof rows)[number]; error: unknown }>
|
||||
> {
|
||||
const redis = this.facade.getRedis();
|
||||
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
|
||||
const script = `local existing = redis.call('GET', KEYS[2])
|
||||
@@ -821,13 +980,26 @@ return streamId`;
|
||||
if (!replies || replies.length !== rows.length) {
|
||||
return rows.map((row) => ({ row, error: new Error('Redis Outbox pipeline result count mismatch') }));
|
||||
}
|
||||
return replies.map(([error, value], index) => error
|
||||
return replies.map(([error, value], index) =>
|
||||
error
|
||||
? { row: rows[index], error }
|
||||
: { row: rows[index], streamEntryId: typeof value === 'string' ? value : String(value ?? '') });
|
||||
: { row: rows[index], streamEntryId: typeof value === 'string' ? value : String(value ?? '') },
|
||||
);
|
||||
}
|
||||
|
||||
async selectChannelForMessage(
|
||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
||||
async selectChannelForMessage(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
phoneNumber: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
template?: { signature?: { id?: string | null } | null } | null;
|
||||
signature?: { id?: string | null } | null;
|
||||
},
|
||||
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||
): Promise<RoutedChannel> {
|
||||
if (!message.applicationId) {
|
||||
@@ -836,7 +1008,7 @@ async selectChannelForMessage(
|
||||
const hasPersistedRouting = Boolean(message.carrier);
|
||||
const [carrier, province] = await this.measureSendStage('phone_routing', async () => {
|
||||
const resolved = hasPersistedRouting
|
||||
? [normalizeCarrier(message.carrier), message.province ?? null] as const
|
||||
? ([normalizeCarrier(message.carrier), message.province ?? null] as const)
|
||||
: await Promise.all([
|
||||
this.facade.identifyCarrier(message.phoneNumber),
|
||||
this.facade.identifyProvince(message.phoneNumber),
|
||||
@@ -849,14 +1021,13 @@ async selectChannelForMessage(
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
const signatureId = await this.measureSendStage('signature_candidates', () => this.facade.resolveMessageSignatureId(message));
|
||||
const signatureId = await this.measureSendStage('signature_candidates', () =>
|
||||
this.facade.resolveMessageSignatureId(message),
|
||||
);
|
||||
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
|
||||
const route = await this.measureSendStage('route_lookup', () => this.facade.findApplicationRoute(
|
||||
message.tenantId,
|
||||
message.applicationId ?? undefined,
|
||||
carrier,
|
||||
signatureId,
|
||||
));
|
||||
const route = await this.measureSendStage('route_lookup', () =>
|
||||
this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, carrier, signatureId),
|
||||
);
|
||||
const excluded = new Set(options.excludeChannelIds ?? []);
|
||||
const approvedChannelIds = new Set(route.group.items.map((item) => item.channelId));
|
||||
const selected = selectChannelCandidate(route.group.items, {
|
||||
@@ -880,7 +1051,7 @@ async selectChannelForMessage(
|
||||
};
|
||||
}
|
||||
|
||||
private async measureSendStage<T>(stage: SendWorkerStage, operation: () => Promise<T>): Promise<T> {
|
||||
private async measureSendStage<T>(stage: SendWorkerStage, operation: () => Promise<T>): Promise<T> {
|
||||
const startedAt = this.metrics?.beginSendWorkerStage();
|
||||
try {
|
||||
const result = await operation();
|
||||
@@ -890,12 +1061,14 @@ private async measureSendStage<T>(stage: SendWorkerStage, operation: () => Promi
|
||||
if (startedAt != null) this.metrics?.finishSendWorkerStage(startedAt, stage, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshSendQueueMetrics() {
|
||||
private async refreshSendQueueMetrics() {
|
||||
if (!this.metrics) return;
|
||||
try {
|
||||
const counts = await this.facade.getSendQueue().getJobCounts('wait', 'active', 'completed', 'failed', 'delayed', 'prioritized');
|
||||
const counts = await this.facade
|
||||
.getSendQueue()
|
||||
.getJobCounts('wait', 'active', 'completed', 'failed', 'delayed', 'prioritized');
|
||||
const mappings: Array<[SendWorkerQueueState, number]> = [
|
||||
['waiting', counts.wait ?? 0],
|
||||
['active', counts.active ?? 0],
|
||||
@@ -912,9 +1085,14 @@ private async refreshSendQueueMetrics() {
|
||||
} catch (error) {
|
||||
this.logger.warn(`send_queue_metrics_refresh_failed ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
|
||||
async findApplicationRoute(
|
||||
tenantId: string,
|
||||
applicationId: string | undefined,
|
||||
carrier: string,
|
||||
signatureId?: string,
|
||||
) {
|
||||
const approvedChannelWhere = signatureId
|
||||
? {
|
||||
status: 'active',
|
||||
@@ -971,15 +1149,15 @@ async findApplicationRoute(tenantId: string, applicationId: string | undefined,
|
||||
return route;
|
||||
}
|
||||
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber));
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
return this.phoneRouting.identifyProvince(phoneNumber);
|
||||
}
|
||||
|
||||
async ensureSignatureReportedForChannel(
|
||||
async ensureSignatureReportedForChannel(
|
||||
message: {
|
||||
id: string;
|
||||
templateId?: string | null;
|
||||
@@ -1008,14 +1186,22 @@ async ensureSignatureReportedForChannel(
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
async resolveMessageSignatureId(message: {
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
template?: { signature?: { id?: string | null } | null } | null;
|
||||
signature?: { id?: string | null } | null;
|
||||
}) {
|
||||
const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null;
|
||||
if (direct || !message.templateId) return direct;
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } });
|
||||
const template = await this.prisma.smsTemplate.findUnique({
|
||||
where: { id: message.templateId },
|
||||
include: { signature: true },
|
||||
});
|
||||
return template?.signature?.id ?? null;
|
||||
}
|
||||
|
||||
async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
const redis = this.facade.getRedis();
|
||||
for (;;) {
|
||||
const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`;
|
||||
@@ -1030,7 +1216,7 @@ async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
|
||||
async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
|
||||
if (knownSingleMessageStatus) {
|
||||
const direct = await this.prisma.smsBatchTask.updateMany({
|
||||
where: { id: batchTaskId, sourceType: 'cmpp', phoneTotal: 1 },
|
||||
@@ -1053,6 +1239,7 @@ async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string
|
||||
status = CASE
|
||||
WHEN message.status IN ('delivered', 'submit_failed', 'failed', 'timeout') THEN 'finished'
|
||||
WHEN message.status IN ('submit_queued', 'submitted', 'unknown') THEN 'sending'
|
||||
WHEN message.status = 'pending_review' THEN 'pending_review'
|
||||
ELSE 'queued'
|
||||
END,
|
||||
"updatedAt" = (NOW() AT TIME ZONE 'UTC')
|
||||
@@ -1084,7 +1271,7 @@ async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshTaskProgressUntilClean(batchTaskId: string) {
|
||||
private async refreshTaskProgressUntilClean(batchTaskId: string) {
|
||||
do {
|
||||
this.dirtyTaskProgressRefreshes.delete(batchTaskId);
|
||||
const groups = await this.prisma.smsMessageRecord.groupBy({
|
||||
@@ -1101,7 +1288,14 @@ private async refreshTaskProgressUntilClean(batchTaskId: string) {
|
||||
const unknownTotal = count(['unknown']);
|
||||
const timeoutTotal = count(['timeout']);
|
||||
const doneTotal = successTotal + failedTotal + timeoutTotal;
|
||||
const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued';
|
||||
const status =
|
||||
progressTotal > 0 && doneTotal >= progressTotal
|
||||
? 'finished'
|
||||
: submittedTotal > 0
|
||||
? 'sending'
|
||||
: count(['pending_review']) > 0
|
||||
? 'pending_review'
|
||||
: 'queued';
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: batchTaskId },
|
||||
data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status },
|
||||
@@ -1109,21 +1303,35 @@ private async refreshTaskProgressUntilClean(batchTaskId: string) {
|
||||
} while (this.dirtyTaskProgressRefreshes.has(batchTaskId));
|
||||
}
|
||||
|
||||
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||
private async recoverNightReviews() {
|
||||
if (this.nightReviewRecovering) return;
|
||||
this.nightReviewRecovering = true;
|
||||
try {
|
||||
for (const task of await this.riskReview.pendingNightContinuations()) {
|
||||
await this.facade.handleReviewDecision(task.id, task.status, task.reason);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`night_review_continuation_failed ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
this.nightReviewRecovering = false;
|
||||
}
|
||||
}
|
||||
|
||||
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||
if (!this.sendQueue) {
|
||||
this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() });
|
||||
}
|
||||
return this.sendQueue;
|
||||
}
|
||||
|
||||
getGatewayQueue(): Queue {
|
||||
getGatewayQueue(): Queue {
|
||||
if (!this.gatewayQueue) {
|
||||
this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
|
||||
}
|
||||
return this.gatewayQueue;
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
getRedis() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
@@ -1158,15 +1366,23 @@ return streamId`,
|
||||
const cached = this.openSubmitSessionIds.get(channelId);
|
||||
if (cached) return cached;
|
||||
const sessionNo = `OPEN-${channelId}`;
|
||||
const pending = this.prisma.cmppSubmitSession.findUnique({
|
||||
const pending = this.prisma.cmppSubmitSession
|
||||
.findUnique({
|
||||
where: { sessionNo },
|
||||
select: { id: true },
|
||||
}).then((existing) => existing ?? this.prisma.cmppSubmitSession.upsert({
|
||||
})
|
||||
.then(
|
||||
(existing) =>
|
||||
existing ??
|
||||
this.prisma.cmppSubmitSession.upsert({
|
||||
where: { sessionNo },
|
||||
update: {},
|
||||
create: { channelId, sessionNo, submitTotal: 0 },
|
||||
select: { id: true },
|
||||
})).then((session) => session.id).catch((error) => {
|
||||
}),
|
||||
)
|
||||
.then((session) => session.id)
|
||||
.catch((error) => {
|
||||
this.openSubmitSessionIds.delete(channelId);
|
||||
throw error;
|
||||
});
|
||||
@@ -1176,7 +1392,9 @@ return streamId`,
|
||||
}
|
||||
|
||||
function singleMessageTaskProgress(status: string) {
|
||||
const submittedTotal = ['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout'].includes(status) ? 1 : 0;
|
||||
const submittedTotal = ['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout'].includes(status)
|
||||
? 1
|
||||
: 0;
|
||||
const successTotal = status === 'delivered' ? 1 : 0;
|
||||
const failedTotal = ['submit_failed', 'failed'].includes(status) ? 1 : 0;
|
||||
const unknownTotal = status === 'unknown' ? 1 : 0;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2218,3 +2218,7 @@
|
||||
- 添加/编辑通道弹窗不允许设置顺序或优先级;新增全国通道固定追加末尾,编辑保留当前位置,调整只通过编辑页上移/下移,保留撤销功能。
|
||||
- 待选通道隐藏已占用、不支持运营商/地区、已删除的项;编辑当前成员可保留自身,搜索只在可选集合中进行。保留原可配置的停用/暂断连状态及真实提示,不等同发送资格。
|
||||
- 符合发送资格的全国通道按列表顺序首次尝试和补发,跳过已尝试、未报备和不可用通道;省网优先及失败转全国规则保持。优先级仅作为API兼容字段,整数上限时保持相对顺序压紧当前草稿后追加,不将新增项插到前面。
|
||||
|
||||
## 2026-09-07 夜间累计发送量审核
|
||||
|
||||
原“非工作时间大批量营销发送”替换为每企业应用每夜累计所有业务短信的人工审核规则,所有企业应用默认通用5000条,支持应用阈值覆盖;不依赖分类、内容或任务名。CMPP、公开HTTP、客户端合并计数,第5001条及之后待审;分片/内部重试/补发/幂等重试不重复累计。统一在首次发送Worker提交前拦截,含定时任务到期及CMPP快速入队路径。短信审核复用现有字段、号码明细、详情和批量审核,按同应用同内容10秒窗口聚合,窗口关闭后审核;批准只释放该任务消息,不豁免后续发送。时间跨午夜连续,默认21:00至次日08:00(北京时间),夜间改时间待本夜结束生效,阈值修改不清零。详见phase-6-risk-review-plan.md的2026-09-07章节;该章节替代旧营销识别和单任务阈值语义。
|
||||
|
||||
@@ -59,3 +59,13 @@ api/src/risk-review/
|
||||
3. 直接拒绝的任务必须向客户端返回可读原因。
|
||||
4. `npm run verify:phase6` 通过。
|
||||
|
||||
## 2026-09-07 夜间累计发送量审核(替代营销单任务规则)
|
||||
|
||||
- 用户授权实现、提交、推送并发布测试与预生产。复用短信审核页面及其内容聚合、号码明细、通过/驳回/批量审核;不改版审核页面。旧规则编码保留兼容历史命中记录,名称改为“夜间累计发送量审核”,不再判断category、内容或任务名称。
|
||||
- 所有企业应用默认开启,通用阈值5000,应用覆盖优先,停用覆盖回落通用。单位为每应用每夜业务短信数(一个业务消息×一个号码);CMPP、公开HTTP、客户端共用计数。同号码不同业务消息分别累计,长短信分片、重试、补发和重复消费不重复计数。超过阈值的消息待人工审核;其他规则直接拒绝和未进入发送阶段的消息不消耗额度。
|
||||
- 夜间默认Asia/Shanghai 21:00至次日08:00,开始包含、结束不包含,跨午夜不清零。定时任务到期后执行。统一在发送Worker首次提交前拦截,覆盖CMPP批量快速入队和普通入口;HTTP/CMPP入口受理不等于已发送,异步消息状态为准。批量跨阈值按消息分流;失败/审核拒绝不归还夜间额度。
|
||||
- PostgreSQL新增NightSendingWindow与NightSendingReservation,应用锁、消息幂等记录、累计量、审核关联与pending_review状态同事务提交。Redis仅沿用队列,不保存唯一风控计数;数据库失败不得放行。新窗口首次使用从已有首次Submit记录补齐本夜历史数,避免夜间发布或规则启用时额度重置;发布仍核对历史基线和执行计划。
|
||||
- 审核按企业应用、相同原始内容及10秒窗口聚合(不混合不同内容),窗口关闭后沿用短信审核入口。计数维度不按内容拆分。已审核窗口不得再追加;审核批准仅释放该聚合任务绑定的消息,不豁免整晚;重复/相反审核须受控,续发失败可用同一决定重试。夜间结束仍不自动释放待审消息。
|
||||
- 阈值修改不清空计数,不自动释放待审消息;时间配置在当前夜间结束后生效,界面说明延迟生效。批量任务已有部分正常发送时,保留部分发送进度并标记存在待审核,不覆盖整批消息状态。审核与入队失败不得吞错,续发使用消息ID幂等队列任务。
|
||||
- 权限沿用管理员风控配置/短信审核入口;应用必须从真实消息与企业关联取得,不能信任客户端自报企业、时间或分类;应用覆盖必须验证对象存在。历史审核记录不重写、不自动重投。回退须先停发送Worker并保留新待审及计数事实,旧版本不能继续绕过新夜间门禁。
|
||||
- 验收覆盖阈值边界、多入口/多实例并发、跨午夜、应用隔离、幂等、重启、配置覆盖/变更、历史初始化、相同内容聚合、审核范围与并发、定时任务和数据库失败;使用隔离PostgreSQL/Redis证明持久化,不发送真实短信。前后端全量测试、类型/构建/质量门禁、两环境真实API与三尺寸页面验收分别留证。
|
||||
|
||||
@@ -5202,3 +5202,19 @@ npm run verify:phase8
|
||||
| TC-REPORT-WPS-007 | 上述残留节点仍被另一单元格DISPIMG引用 | 两种模式均拒绝,提示实际损坏单元格及工作表、重新插入/清空建议;不得误报相邻正常图片,不进行部分业务落库 |
|
||||
| TC-REPORT-WPS-008 | 已引用图片ID重复,或图片relationship缺失/重复/External,或包内媒体缺失 | 明确拒绝并提示工作表及单元格;不后写覆盖重复ID、不读取外部图片、不静默跳过真实缺失图片 |
|
||||
| TC-REPORT-WPS-009 | 真实故障文件及仅清空损坏单元格的内存副本,保留残留cellImage节点 | 原文件准确定位N7;内存副本33张有效图片位置与独立XML解析结果一致,提交模式33张SHA256全部匹配;原始文件保持不变,不据此冒充线上导入成功 |
|
||||
|
||||
## 2026-09-07 夜间累计发送量审核(替代TC-RISK-006及旧营销口径)
|
||||
|
||||
| 用例 | 场景与预期 |
|
||||
|---|---|
|
||||
| TC-RISK-NIGHT-001 | 同应用CMPP/HTTP/客户端逐条混合发送;累计4999、5000正常处理,5001及之后待人工审核;不受分类、正文或任务名影响。 |
|
||||
| TC-RISK-NIGHT-002 | 多实例并发和批量跨阈值;仅额度内消息允许正常提交,超量消息无Gateway Submit;其他企业/应用独立计数。 |
|
||||
| TC-RISK-NIGHT-003 | 21:00包含、08:00不包含,午夜不清零;白天创建的夜间定时任务在发送阶段检查,白天排队夜间执行同样检查;旧待审不自动释放。 |
|
||||
| TC-RISK-NIGHT-004 | 同业务消息重复消费、长短信分片、重试/补发不重复计数;同号码不同业务消息分别计数;服务/Redis重启不恢复额度。 |
|
||||
| TC-RISK-NIGHT-005 | 通用5000、个性化应用覆盖、停用覆盖回落通用;修改阈值不清零,夜间改时段展示当前/待生效;非法阈值、时区及改为直接拒绝受控400。 |
|
||||
| TC-RISK-NIGHT-006 | 同应用同内容10秒聚合,不同内容分开;号码数量/列表/详情/单个和批量审核沿用现有页面。窗口未关闭不可审核,已审核不可再追加或改相反决定。 |
|
||||
| TC-RISK-NIGHT-007 | 批准只释放关联消息,后续仍待审;审核后的Redis入队失败可由持久恢复记录重试,消息ID稳定去重;并发续发有租约,驳回按现有机制释放冻结并保留原因。 |
|
||||
| TC-RISK-NIGHT-008 | 夜间上线或首次启用从真实首次Submit补齐历史数,多个Submit尝试仅计同一业务短信一次;计数/审核持久化失败回滚且不得放行。 |
|
||||
| TC-RISK-NIGHT-009 | 1600×1000、1366×768、390×844检查风控规则及短信审核,保留筛选、号码列表、详情和批量操作;真实请求、刷新、路由、空态、失败和权限状态分别留证。 |
|
||||
|
||||
自动化入口:api/src/risk-review/night-sending-risk.service.spec.ts、api/src/send-chain/night-sending-gate.spec.ts及tools/testing/verify-night-sending-postgres.mjs。后者使用隔离PostgreSQL schema及独立Redis QA队列,不启动消费者、不调用Gateway、不写业务发送队列;隔离队列与schema仅清理本次随机名称。它验证真实持久化与队列恢复,不能冒充实际运营商发送或公网协议压测。
|
||||
|
||||
@@ -4688,3 +4688,13 @@ git diff --check
|
||||
- 新增14项回归,含metadata/字节两模式×未引用残留及自闭合单元格、实际损坏位置、重复图片ID、媒体缺失、关系缺失/重复/外部。定向17项通过;API全量60套653项通过(测试中的Redis/Prometheus不可用warning来自既有隔离场景)。API生产配置TypeScript/构建、增量Prettier/ESLint与diff检查通过。曾误用基础tsconfig执行含测试文件的全量tsc,因该配置未加载Jest全局类型失败;改按仓库tsconfig.build.json核验生产代码,测试文件由完整ts-jest回归校验,未为此改动既有类型配置。
|
||||
- 当前修复代码在本机读取此前只读取得的真实原始文件:两种模式均准确拒绝N7;只在内存清空N7公式、保留损坏cellImage节点后,两模式读取33张图片且不串位。独立Python ElementTree解析原始OOXML建立期望位置/图片SHA256,字节模式33张全部逐一匹配;原始文件摘要不变,未生成或上传替换业务表格。
|
||||
- 证据在本机%TEMP%/cmpp-wps-diagnosis-20260907:api-full.log、independent-image-hashes.json、real-file-verification.json及受控原始文件。真实客户文件不进入Git。此前浏览器只读状态核验尝试登录返回401,未取得该项浏览器证据;本轮未复试、重置账号或修改服务器。已依据PG持久失败记录、真实MinIO文件、运行解析器与本地修复解析结果完成复现/回归;新版本的线上API/Worker/浏览器验收待另行授权部署后执行,不将本地文件回归称为线上导入成功。
|
||||
|
||||
## 2026-09-07 夜间累计发送量审核(实现与发布前验证)
|
||||
|
||||
- 用户授权实施、提交、推送和测试/预生产发布,并要求复用短信审核。起始main为e281ff8,实时origin/main为f885f0b,暂存空;9份原文档修改及3份未跟踪草稿保护,仅提交本轮精确追加文档。发布同时包含此前本地WPS修复e281ff8。
|
||||
- 只读根因:旧规则只对category=marketing/promo/promotion/营销的单任务号码数判断;CMPP单号码评估和批量快速入队没有跨请求夜间计数。已有短信审核提供CMPP模板不匹配同内容10秒聚合、号码列表/详情/批量操作,适合复用。测试和预生产现场版本均f885f0b;夜间规则均通用5000启用,无个性化覆盖,现场本夜首次Submit均0。测试119509条消息/130769次Submit;预生产本轮基线93243/91563、签名653,不能沿用昨天的旧数字。
|
||||
- 实现:两张夜间计数/幂等表、审核续发租约字段及兼容迁移;所有业务入口在共享发送Worker首次提交前执行应用级夜间门禁,跨午夜连续,第5001条起挂起,保持分片/重试/幂等业务计数。首次初始化从持久首次Submit补齐历史;新增审核记录按应用、原始相同内容、10秒窗口聚合并复用现有UI。审核批准只释放已锁定任务,持久续发记录和15秒扫描恢复入队失败,稳定新jobId避免旧已完成任务吞掉续发。数据库故障向上抛出,不能当路由失败或默认放行。
|
||||
- 规则沿用既有编码/ID和历史记录,改名并去除旧营销计算;应用覆盖/停用回落、强制人工审核、整数阈值、北京时间与夜间时段延期生效。UI只修改口径、说明及审核来源标签,无CSS/Gateway/依赖/余额/通道/客户配置调整。触及的既有文件按现行Prettier门禁规范化,并清理历史拆分遗留的未使用导入及等价控制字符判断;未扩大lint例外。
|
||||
- 本机后端62套665项、前端21套105项回归通过;新夜间单元与发送门禁12项通过,生产类型和构建通过。测试机独立候选、真实PG双实例并发/隔离/午夜/幂等/覆盖/历史初始化/事务回滚及独立Redis审核恢复11组通过;public消息/Submit计数不变,Gateway调用0、业务队列写入0。固定时间在隔离service层注入,不构造真实短信。
|
||||
- 现场真实测试页面三尺寸基线读取规则、编辑取消、短信审核空态/刷新通过,API200、无新增控制台错误,未保存线上业务规则。测试机初期Tailscale离线超时,用户恢复后正常密码认证;不是Git认证问题,未修改SSH配置。当前浏览器技能未提供,按frontend-testing-debugging技能和既有任意浏览器授权使用Playwright/Edge。临时脚本初次将Windows路径URL编码未还原导致截图失败,修正fileURLToPath后通过;隔离验证先修正模板变量表名与BullMQ优先队列计数口径后重跑通过,不作为业务缺陷。
|
||||
- 本机证据%TEMP%/cmpp-night-risk-20260907,测试候选/opt/cmpp-night-candidate-20260907,PG/Redis隔离结果/tmp/night-pg-verification.log。最终门禁及发布后真实页面/服务/队列验收另记下节;尚未执行真实短信发送、供应商压测或实际备份恢复。
|
||||
|
||||
@@ -36,7 +36,12 @@ export type RiskRuleItem = {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
code: 'MAX_PHONES_PER_TASK' | 'NON_WORKING_MARKETING_BULK' | 'TASK_CREATE_FREQUENCY' | 'PHONE_FREQUENCY_24H' | 'PHONE_FREQUENCY_5M';
|
||||
code:
|
||||
| 'MAX_PHONES_PER_TASK'
|
||||
| 'NON_WORKING_MARKETING_BULK'
|
||||
| 'TASK_CREATE_FREQUENCY'
|
||||
| 'PHONE_FREQUENCY_24H'
|
||||
| 'PHONE_FREQUENCY_5M';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
metric: string;
|
||||
@@ -44,7 +49,15 @@ export type RiskRuleItem = {
|
||||
action: 'block' | 'manual_review';
|
||||
status: 'active' | 'inactive';
|
||||
priority: number;
|
||||
config?: { startTime?: string; endTime?: string; timeZone?: string; periodSeconds?: number; alignment?: string } | null;
|
||||
config?: {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
timeZone?: string;
|
||||
periodSeconds?: number;
|
||||
alignment?: string;
|
||||
previousTimeConfig?: { startTime: string; endTime: string };
|
||||
timeConfigEffectiveAt?: string;
|
||||
} | null;
|
||||
application?: { id: string; name: string; tenantId: string; tenant?: { id: string; name: string } } | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -119,7 +132,15 @@ export type DrainageDetectionRule = {
|
||||
export type DrainageDetectionResult = {
|
||||
hasDrainageContent: boolean;
|
||||
drainageDetection: {
|
||||
matches: Array<{ ruleCode: string; ruleName: string; category: string; text: string; normalizedText: string; start: number; end: number }>;
|
||||
matches: Array<{
|
||||
ruleCode: string;
|
||||
ruleName: string;
|
||||
category: string;
|
||||
text: string;
|
||||
normalizedText: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}>;
|
||||
categories: string[];
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const definitions: Array<{ code: RiskRuleItem['code']; label: string; unit: string }> = [
|
||||
{ code: 'MAX_PHONES_PER_TASK', label: '单任务最大号码数', unit: '个号码' },
|
||||
{ code: 'NON_WORKING_MARKETING_BULK', label: '非工作时间大批量营销发送', unit: '个号码' },
|
||||
{ code: 'NON_WORKING_MARKETING_BULK', label: '夜间累计发送量审核', unit: '条业务短信/应用/夜间' },
|
||||
{ code: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' },
|
||||
{ code: 'PHONE_FREQUENCY_24H', label: '单号码24小时发送频次', unit: '条业务短信' },
|
||||
{ code: 'PHONE_FREQUENCY_5M', label: '单号码5分钟发送频次', unit: '条业务短信' },
|
||||
@@ -96,27 +96,32 @@ export function AdminRiskRulesPage() {
|
||||
Promise.all([
|
||||
adminApi.listRiskRules(applicationId || undefined),
|
||||
applications.length === 0 ? adminApi.listEnterpriseApplications() : Promise.resolve(applications),
|
||||
]).then(([nextRules, nextApplications]) => {
|
||||
])
|
||||
.then(([nextRules, nextApplications]) => {
|
||||
setRules(nextRules);
|
||||
setApplications(nextApplications);
|
||||
setError('');
|
||||
}).catch((failure: Error) => setError(failure.message || '风控规则加载失败'));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '风控规则加载失败'));
|
||||
}
|
||||
|
||||
useEffect(load, [applicationId]);
|
||||
|
||||
function loadFrequencyHits(page = hitPage, filters = { phoneNumber: hitPhone, status: hitStatus }) {
|
||||
adminApi.listPhoneFrequencyHits({
|
||||
adminApi
|
||||
.listPhoneFrequencyHits({
|
||||
applicationId: applicationId || undefined,
|
||||
phoneNumber: filters.phoneNumber.trim() || undefined,
|
||||
status: filters.status || undefined,
|
||||
page,
|
||||
pageSize: 20,
|
||||
}).then((result) => {
|
||||
})
|
||||
.then((result) => {
|
||||
setFrequencyHits(result.items);
|
||||
setHitTotal(result.total);
|
||||
setHitPage(result.page);
|
||||
}).catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败'));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -125,16 +130,19 @@ export function AdminRiskRulesPage() {
|
||||
}, [applicationId]);
|
||||
|
||||
function loadWhitelist(page = whitelistPage, filters = { phoneNumber: whitelistPhone, status: whitelistStatus }) {
|
||||
adminApi.listPhoneFrequencyWhitelist({
|
||||
adminApi
|
||||
.listPhoneFrequencyWhitelist({
|
||||
phoneNumber: filters.phoneNumber.trim() || undefined,
|
||||
status: filters.status || undefined,
|
||||
page,
|
||||
pageSize: 20,
|
||||
}).then((result) => {
|
||||
})
|
||||
.then((result) => {
|
||||
setWhitelist(result.items);
|
||||
setWhitelistTotal(result.total);
|
||||
setWhitelistPage(result.page);
|
||||
}).catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败'));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -162,10 +170,15 @@ export function AdminRiskRulesPage() {
|
||||
const phoneFrequencyRule = isPhoneFrequencyRule(editor.code);
|
||||
const body = {
|
||||
thresholdValue,
|
||||
action: phoneFrequencyRule ? 'block' as const : editor.action,
|
||||
action: phoneFrequencyRule
|
||||
? ('block' as const)
|
||||
: editor.code === 'NON_WORKING_MARKETING_BULK'
|
||||
? ('manual_review' as const)
|
||||
: editor.action,
|
||||
status: editor.status,
|
||||
priority: Number(editor.priority) || 100,
|
||||
config: editor.code === 'NON_WORKING_MARKETING_BULK'
|
||||
config:
|
||||
editor.code === 'NON_WORKING_MARKETING_BULK'
|
||||
? { startTime: editor.startTime, endTime: editor.endTime, timeZone: 'Asia/Shanghai' }
|
||||
: undefined,
|
||||
};
|
||||
@@ -261,55 +274,227 @@ export function AdminRiskRulesPage() {
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<RiskRuleItem>> = [
|
||||
{ key: 'name', title: '规则名称', render: (rule) => <div><strong>{rule.name}</strong><small className="table-subline">{rule.description}</small></div> },
|
||||
{ key: 'scope', title: '适用范围', render: (rule) => rule.application ? <div><strong>{rule.application.name}</strong><small className="table-subline">{rule.application.tenant?.name ?? '-'}</small></div> : <Tag tone="info">全局默认</Tag> },
|
||||
{ key: 'threshold', title: '阈值', width: '150px', render: (rule) => `${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}` },
|
||||
{ key: 'time', title: '生效时间', width: '180px', render: (rule) => {
|
||||
{
|
||||
key: 'name',
|
||||
title: '规则名称',
|
||||
render: (rule) => (
|
||||
<div>
|
||||
<strong>{rule.name}</strong>
|
||||
<small className="table-subline">{rule.description}</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
title: '适用范围',
|
||||
render: (rule) =>
|
||||
rule.application ? (
|
||||
<div>
|
||||
<strong>{rule.application.name}</strong>
|
||||
<small className="table-subline">{rule.application.tenant?.name ?? '-'}</small>
|
||||
</div>
|
||||
) : (
|
||||
<Tag tone="info">全局默认</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'threshold',
|
||||
title: '阈值',
|
||||
width: '150px',
|
||||
render: (rule) =>
|
||||
`${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}`,
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
title: '生效时间',
|
||||
width: '180px',
|
||||
render: (rule) => {
|
||||
if (rule.code !== 'NON_WORKING_MARKETING_BULK') return '-';
|
||||
const start = rule.config?.startTime ?? '21:00';
|
||||
const end = rule.config?.endTime ?? '08:00';
|
||||
const pending =
|
||||
rule.config?.timeConfigEffectiveAt && new Date(rule.config.timeConfigEffectiveAt).getTime() > Date.now();
|
||||
if (pending && rule.config?.previousTimeConfig) {
|
||||
const previous = rule.config.previousTimeConfig;
|
||||
return (
|
||||
<span>
|
||||
当前 {previous.startTime}–{previous.startTime > previous.endTime ? '次日' : ''}
|
||||
{previous.endTime};{formatDateTime(rule.config.timeConfigEffectiveAt!)}起使用 {start}–{end}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return `${start}–${start > end ? '次日' : ''}${end}`;
|
||||
} },
|
||||
{ key: 'action', title: '处理动作', width: '120px', render: (rule) => <Tag tone={rule.action === 'block' ? 'danger' : 'warning'}>{rule.action === 'block' ? '直接拒绝' : '人工审核'}</Tag> },
|
||||
{ key: 'status', title: '状态', width: '100px', render: (rule) => <Tag tone={rule.status === 'active' ? 'success' : 'neutral'}>{rule.status === 'active' ? '启用' : '停用'}</Tag> },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
title: '处理动作',
|
||||
width: '120px',
|
||||
render: (rule) => (
|
||||
<Tag tone={rule.action === 'block' ? 'danger' : 'warning'}>
|
||||
{rule.action === 'block' ? '直接拒绝' : '人工审核'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '100px',
|
||||
render: (rule) => (
|
||||
<Tag tone={rule.status === 'active' ? 'success' : 'neutral'}>{rule.status === 'active' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{ key: 'priority', title: '优先级', width: '90px', render: (rule) => rule.priority },
|
||||
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => <Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost">编辑</Button> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
render: (rule) => (
|
||||
<Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost">
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const hitColumns: Array<TableColumn<PhoneFrequencyHit>> = [
|
||||
{ key: 'phone', title: '号码', width: '140px', render: (hit) => <strong>{hit.phoneNumber}</strong> },
|
||||
{ key: 'scope', title: '企业 / 应用', render: (hit) => <div><strong>{hit.tenant.name}</strong><small className="table-subline">{hit.application.name}</small></div> },
|
||||
{ key: 'rule', title: '命中规则', render: (hit) => <div><strong>{hit.ruleName}</strong><small className="table-subline">阈值 {hit.thresholdValue} 条,触发值 {hit.actualValue} 条</small></div> },
|
||||
{ key: 'window', title: '计数周期', width: '250px', render: (hit) => `${formatDateTime(hit.windowStartedAt)} 至 ${formatDateTime(hit.windowEndsAt)}` },
|
||||
{ key: 'status', title: '状态', width: '100px', render: (hit) => hit.releasedAt
|
||||
? <Tag tone="neutral">已解除</Tag>
|
||||
: new Date(hit.windowEndsAt).getTime() <= Date.now()
|
||||
? <Tag tone="warning">已到期</Tag>
|
||||
: <Tag tone="danger">拦截中</Tag> },
|
||||
{
|
||||
key: 'scope',
|
||||
title: '企业 / 应用',
|
||||
render: (hit) => (
|
||||
<div>
|
||||
<strong>{hit.tenant.name}</strong>
|
||||
<small className="table-subline">{hit.application.name}</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rule',
|
||||
title: '命中规则',
|
||||
render: (hit) => (
|
||||
<div>
|
||||
<strong>{hit.ruleName}</strong>
|
||||
<small className="table-subline">
|
||||
阈值 {hit.thresholdValue} 条,触发值 {hit.actualValue} 条
|
||||
</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'window',
|
||||
title: '计数周期',
|
||||
width: '250px',
|
||||
render: (hit) => `${formatDateTime(hit.windowStartedAt)} 至 ${formatDateTime(hit.windowEndsAt)}`,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '100px',
|
||||
render: (hit) =>
|
||||
hit.releasedAt ? (
|
||||
<Tag tone="neutral">已解除</Tag>
|
||||
) : new Date(hit.windowEndsAt).getTime() <= Date.now() ? (
|
||||
<Tag tone="warning">已到期</Tag>
|
||||
) : (
|
||||
<Tag tone="danger">拦截中</Tag>
|
||||
),
|
||||
},
|
||||
{ key: 'createdAt', title: '触发时间', width: '170px', render: (hit) => formatDateTime(hit.createdAt) },
|
||||
{ key: 'actions', title: '操作', width: '110px', align: 'right', render: (hit) => hit.releasedAt
|
||||
? <span className="muted">已清零</span>
|
||||
: <Button icon={<Unlock size={15} />} onClick={() => { setReleaseHit(hit); setReleaseReason(''); }} size="sm" variant="ghost">解除</Button> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (hit) =>
|
||||
hit.releasedAt ? (
|
||||
<span className="muted">已清零</span>
|
||||
) : (
|
||||
<Button
|
||||
icon={<Unlock size={15} />}
|
||||
onClick={() => {
|
||||
setReleaseHit(hit);
|
||||
setReleaseReason('');
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
解除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const whitelistColumns: Array<TableColumn<PhoneFrequencyWhitelistItem>> = [
|
||||
{ key: 'phone', title: '手机号码', width: '145px', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'reason', title: '用途说明', render: (item) => <div><strong>{item.reason}</strong>{item.remark ? <small className="table-subline">{item.remark}</small> : null}</div> },
|
||||
{ key: 'status', title: '状态', width: '90px', render: (item) => <Tag tone={item.status === 'active' ? 'success' : item.status === 'deleted' ? 'danger' : 'neutral'}>{item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'}</Tag> },
|
||||
{ key: 'operator', title: '最后操作人', width: '150px', render: (item) => item.updatedBy.displayName || item.updatedBy.username },
|
||||
{
|
||||
key: 'reason',
|
||||
title: '用途说明',
|
||||
render: (item) => (
|
||||
<div>
|
||||
<strong>{item.reason}</strong>
|
||||
{item.remark ? <small className="table-subline">{item.remark}</small> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '90px',
|
||||
render: (item) => (
|
||||
<Tag tone={item.status === 'active' ? 'success' : item.status === 'deleted' ? 'danger' : 'neutral'}>
|
||||
{item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operator',
|
||||
title: '最后操作人',
|
||||
width: '150px',
|
||||
render: (item) => item.updatedBy.displayName || item.updatedBy.username,
|
||||
},
|
||||
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (item) => formatDateTime(item.updatedAt) },
|
||||
{ key: 'actions', title: '操作', width: '175px', align: 'right', render: (item) => item.status === 'deleted'
|
||||
? <span className="muted">历史记录</span>
|
||||
: <div className="table-actions">
|
||||
<Button icon={<Pencil size={15} />} onClick={() => setWhitelistEditor({
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '175px',
|
||||
align: 'right',
|
||||
render: (item) =>
|
||||
item.status === 'deleted' ? (
|
||||
<span className="muted">历史记录</span>
|
||||
) : (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
icon={<Pencil size={15} />}
|
||||
onClick={() =>
|
||||
setWhitelistEditor({
|
||||
id: item.id,
|
||||
phoneNumber: item.phoneNumber,
|
||||
reason: item.reason,
|
||||
remark: item.remark ?? '',
|
||||
status: item.status === 'active' ? 'active' : 'inactive',
|
||||
})} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => { setDeletingWhitelist(item); setDeleteWhitelistReason(''); }} size="sm" variant="ghost">删除</Button>
|
||||
</div> },
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={15} />}
|
||||
onClick={() => {
|
||||
setDeletingWhitelist(item);
|
||||
setDeleteWhitelistReason('');
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const hitTotalPages = Math.max(1, Math.ceil(hitTotal / 20));
|
||||
@@ -318,13 +503,25 @@ export function AdminRiskRulesPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['安全控制', '风控规则']} /><h1>风控规则</h1><p>维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。</p></div>
|
||||
<div>
|
||||
<Breadcrumb items={['安全控制', '风控规则']} />
|
||||
<h1>风控规则</h1>
|
||||
<p>维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。</p>
|
||||
</div>
|
||||
<div className="page-heading__actions">
|
||||
<Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost">刷新</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}>新增应用覆盖</Button>
|
||||
<Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost">
|
||||
刷新
|
||||
</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}>
|
||||
新增应用覆盖
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
{error ? (
|
||||
<p className="form-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="surface sms-audit-filter ui-filter-row">
|
||||
<Select
|
||||
label="查看范围"
|
||||
@@ -339,17 +536,32 @@ export function AdminRiskRulesPage() {
|
||||
value={applicationId}
|
||||
/>
|
||||
</div>
|
||||
<div className="surface"><Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" /></div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" />
|
||||
</div>
|
||||
<div className="surface">
|
||||
<div className="section-heading">
|
||||
<div><h2>平台级号码频控白名单</h2><p>启用后,该号码在全平台所有企业应用下均不受24小时和5分钟号码频次限制;其他风控规则仍正常执行。</p></div>
|
||||
<div>
|
||||
<h2>平台级号码频控白名单</h2>
|
||||
<p>启用后,该号码在全平台所有企业应用下均不受24小时和5分钟号码频次限制;其他风控规则仍正常执行。</p>
|
||||
</div>
|
||||
<div className="page-heading__actions">
|
||||
<Tag tone="info">{whitelistTotal} 条</Tag>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setWhitelistEditor({ phoneNumber: '', reason: '', remark: '', status: 'active' })}>新增白名单</Button>
|
||||
<Button
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => setWhitelistEditor({ phoneNumber: '', reason: '', remark: '', status: 'active' })}
|
||||
>
|
||||
新增白名单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sms-audit-filter ui-filter-row">
|
||||
<Input label="手机号码" onChange={(event) => setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} />
|
||||
<Input
|
||||
label="手机号码"
|
||||
onChange={(event) => setWhitelistPhone(event.target.value)}
|
||||
placeholder="输入完整或部分号码"
|
||||
value={whitelistPhone}
|
||||
/>
|
||||
<Select
|
||||
label="白名单状态"
|
||||
onChange={(event) => setWhitelistStatus(event.target.value as typeof whitelistStatus)}
|
||||
@@ -362,11 +574,28 @@ export function AdminRiskRulesPage() {
|
||||
value={whitelistStatus}
|
||||
/>
|
||||
<div className="admin-task-filter__actions ui-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}>查询</Button>
|
||||
<Button onClick={() => { setWhitelistPhone(''); setWhitelistStatus(''); loadWhitelist(1, { phoneNumber: '', status: '' }); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setWhitelistPhone('');
|
||||
setWhitelistStatus('');
|
||||
loadWhitelist(1, { phoneNumber: '', status: '' });
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={whitelistColumns} data={whitelist} emptyText="暂无号码频控白名单" pagination={false} rowKey="id" />
|
||||
<Table
|
||||
columns={whitelistColumns}
|
||||
data={whitelist}
|
||||
emptyText="暂无号码频控白名单"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
/>
|
||||
<Pagination
|
||||
nextDisabled={whitelistPage >= whitelistTotalPages}
|
||||
onNext={() => loadWhitelist(Math.min(whitelistTotalPages, whitelistPage + 1))}
|
||||
@@ -380,11 +609,19 @@ export function AdminRiskRulesPage() {
|
||||
</div>
|
||||
<div className="surface">
|
||||
<div className="section-heading">
|
||||
<div><h2>号码频次触发记录</h2><p>按企业应用和号码隔离计数;周期到期自动重新计数,人工解除会立即清零当前周期并保留审计记录。</p></div>
|
||||
<div>
|
||||
<h2>号码频次触发记录</h2>
|
||||
<p>按企业应用和号码隔离计数;周期到期自动重新计数,人工解除会立即清零当前周期并保留审计记录。</p>
|
||||
</div>
|
||||
<Tag tone="warning">{hitTotal} 条</Tag>
|
||||
</div>
|
||||
<div className="sms-audit-filter ui-filter-row">
|
||||
<Input label="手机号码" onChange={(event) => setHitPhone(event.target.value)} placeholder="输入完整或部分号码" value={hitPhone} />
|
||||
<Input
|
||||
label="手机号码"
|
||||
onChange={(event) => setHitPhone(event.target.value)}
|
||||
placeholder="输入完整或部分号码"
|
||||
value={hitPhone}
|
||||
/>
|
||||
<Select
|
||||
label="记录状态"
|
||||
onChange={(event) => setHitStatus(event.target.value as typeof hitStatus)}
|
||||
@@ -397,11 +634,35 @@ export function AdminRiskRulesPage() {
|
||||
value={hitStatus}
|
||||
/>
|
||||
<div className="admin-task-filter__actions ui-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { setHitPage(1); loadFrequencyHits(1); }}>查询</Button>
|
||||
<Button onClick={() => { setHitPhone(''); setHitStatus('active'); setHitPage(1); loadFrequencyHits(1, { phoneNumber: '', status: 'active' }); }} variant="ghost">重置</Button>
|
||||
<Button
|
||||
icon={<Search size={16} />}
|
||||
onClick={() => {
|
||||
setHitPage(1);
|
||||
loadFrequencyHits(1);
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setHitPhone('');
|
||||
setHitStatus('active');
|
||||
setHitPage(1);
|
||||
loadFrequencyHits(1, { phoneNumber: '', status: 'active' });
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={hitColumns} data={frequencyHits} emptyText="暂无号码频次触发记录" pagination={false} rowKey="id" />
|
||||
<Table
|
||||
columns={hitColumns}
|
||||
data={frequencyHits}
|
||||
emptyText="暂无号码频次触发记录"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
/>
|
||||
<Pagination
|
||||
nextDisabled={hitPage >= hitTotalPages}
|
||||
onNext={() => loadFrequencyHits(Math.min(hitTotalPages, hitPage + 1))}
|
||||
@@ -413,72 +674,245 @@ export function AdminRiskRulesPage() {
|
||||
totalPages={hitTotalPages}
|
||||
/>
|
||||
</div>
|
||||
{editor ? <Modal
|
||||
footer={<><Button disabled={saving} onClick={() => setEditor(null)} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button></>}
|
||||
{editor ? (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={saving} onClick={() => setEditor(null)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={() => void save()}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setEditor(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={editor.id ? '编辑风控规则' : '新增企业应用级覆盖'}
|
||||
>
|
||||
<div className="form-grid">
|
||||
{!editor.id ? <Select
|
||||
{!editor.id ? (
|
||||
<Select
|
||||
label="企业应用"
|
||||
onChange={(event) => setEditor({ ...editor, applicationId: event.target.value })}
|
||||
options={[{ label: '请选择企业应用', value: '' }, ...applications.map((application) => ({ label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`, value: application.id }))]}
|
||||
options={[
|
||||
{ label: '请选择企业应用', value: '' },
|
||||
...applications.map((application) => ({
|
||||
label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`,
|
||||
value: application.id,
|
||||
})),
|
||||
]}
|
||||
value={editor.applicationId}
|
||||
/> : null}
|
||||
{!editor.id ? <Select
|
||||
/>
|
||||
) : null}
|
||||
{!editor.id ? (
|
||||
<Select
|
||||
label="规则"
|
||||
onChange={(event) => {
|
||||
const code = event.target.value as RiskRuleItem['code'];
|
||||
const globalRule = rules.find((rule) => !rule.applicationId && rule.code === code);
|
||||
setEditor({ ...editor, code, thresholdValue: String(globalRule?.thresholdValue ?? editor.thresholdValue), action: globalRule?.action ?? editor.action });
|
||||
setEditor({
|
||||
...editor,
|
||||
code,
|
||||
thresholdValue: String(globalRule?.thresholdValue ?? editor.thresholdValue),
|
||||
startTime: globalRule?.config?.startTime ?? '21:00',
|
||||
endTime: globalRule?.config?.endTime ?? '08:00',
|
||||
action: globalRule?.action ?? editor.action,
|
||||
});
|
||||
}}
|
||||
options={definitions.filter((item) => !existingCodes.has(item.code) || item.code === editor.code).map((item) => ({ label: item.label, value: item.code }))}
|
||||
options={definitions
|
||||
.filter((item) => !existingCodes.has(item.code) || item.code === editor.code)
|
||||
.map((item) => ({ label: item.label, value: item.code }))}
|
||||
value={editor.code}
|
||||
/> : <Input disabled label="规则" value={definitions.find((item) => item.code === editor.code)?.label ?? editor.code} />}
|
||||
<Input label={`阈值(${definitions.find((item) => item.code === editor.code)?.unit ?? ''})`} min={isPhoneFrequencyRule(editor.code) ? '1' : '0'} onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} />
|
||||
<Select disabled={isPhoneFrequencyRule(editor.code)} label="处理动作" onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })} options={isPhoneFrequencyRule(editor.code) ? [{ label: '直接拒绝(首版固定)', value: 'block' }] : [{ label: '直接拒绝', value: 'block' }, { label: '进入人工审核', value: 'manual_review' }]} value={isPhoneFrequencyRule(editor.code) ? 'block' : editor.action} />
|
||||
<Select label="状态" onChange={(event) => setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} />
|
||||
<Input label="优先级" min="1" onChange={(event) => setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} />
|
||||
{editor.code === 'NON_WORKING_MARKETING_BULK' ? <>
|
||||
<Input label="非工作时间开始" onChange={(event) => setEditor({ ...editor, startTime: event.target.value })} type="time" value={editor.startTime} />
|
||||
<Input label="非工作时间结束" onChange={(event) => setEditor({ ...editor, endTime: event.target.value })} type="time" value={editor.endTime} />
|
||||
</> : null}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
label="规则"
|
||||
value={definitions.find((item) => item.code === editor.code)?.label ?? editor.code}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={`阈值(${definitions.find((item) => item.code === editor.code)?.unit ?? ''})`}
|
||||
min={isPhoneFrequencyRule(editor.code) ? '1' : '0'}
|
||||
onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })}
|
||||
type="number"
|
||||
value={editor.thresholdValue}
|
||||
/>
|
||||
<Select
|
||||
disabled={isPhoneFrequencyRule(editor.code) || editor.code === 'NON_WORKING_MARKETING_BULK'}
|
||||
label="处理动作"
|
||||
onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })}
|
||||
options={
|
||||
isPhoneFrequencyRule(editor.code)
|
||||
? [{ label: '直接拒绝(首版固定)', value: 'block' }]
|
||||
: [
|
||||
{ label: '直接拒绝', value: 'block' },
|
||||
{ label: '进入人工审核', value: 'manual_review' },
|
||||
]
|
||||
}
|
||||
value={
|
||||
isPhoneFrequencyRule(editor.code)
|
||||
? 'block'
|
||||
: editor.code === 'NON_WORKING_MARKETING_BULK'
|
||||
? 'manual_review'
|
||||
: editor.action
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })}
|
||||
options={[
|
||||
{ label: '启用', value: 'active' },
|
||||
{ label: '停用', value: 'inactive' },
|
||||
]}
|
||||
value={editor.status}
|
||||
/>
|
||||
<Input
|
||||
label="优先级"
|
||||
min="1"
|
||||
onChange={(event) => setEditor({ ...editor, priority: event.target.value })}
|
||||
type="number"
|
||||
value={editor.priority}
|
||||
/>
|
||||
{editor.code === 'NON_WORKING_MARKETING_BULK' ? (
|
||||
<>
|
||||
<Input
|
||||
label="夜间开始(北京时间)"
|
||||
onChange={(event) => setEditor({ ...editor, startTime: event.target.value })}
|
||||
type="time"
|
||||
value={editor.startTime}
|
||||
/>
|
||||
<Input
|
||||
label="夜间结束(北京时间)"
|
||||
onChange={(event) => setEditor({ ...editor, endTime: event.target.value })}
|
||||
type="time"
|
||||
value={editor.endTime}
|
||||
/>
|
||||
<p className="muted">
|
||||
每个应用跨CMPP、HTTP和客户端累计所有业务短信,超过阈值进入短信审核。同一夜间跨零点连续计数,阈值修改不清零;夜间调整时段在本夜结束后生效,待审核短信仍需人工处理。
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal> : null}
|
||||
{whitelistEditor ? <Modal
|
||||
footer={<><Button disabled={whitelistSaving} onClick={() => setWhitelistEditor(null)} variant="ghost">取消</Button><Button disabled={whitelistSaving} onClick={() => void saveWhitelist()}>{whitelistSaving ? '保存中…' : '保存'}</Button></>}
|
||||
</Modal>
|
||||
) : null}
|
||||
{whitelistEditor ? (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={whitelistSaving} onClick={() => setWhitelistEditor(null)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={whitelistSaving} onClick={() => void saveWhitelist()}>
|
||||
{whitelistSaving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setWhitelistEditor(null)}
|
||||
open
|
||||
title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'}
|
||||
>
|
||||
<div className="form-grid">
|
||||
<Input label="手机号码" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })} placeholder="中国大陆11位手机号码" value={whitelistEditor.phoneNumber} />
|
||||
<Select label="状态" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, status: event.target.value as WhitelistEditorState['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={whitelistEditor.status} />
|
||||
<Input
|
||||
label="手机号码"
|
||||
onChange={(event) => setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })}
|
||||
placeholder="中国大陆11位手机号码"
|
||||
value={whitelistEditor.phoneNumber}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) =>
|
||||
setWhitelistEditor({ ...whitelistEditor, status: event.target.value as WhitelistEditorState['status'] })
|
||||
}
|
||||
options={[
|
||||
{ label: '启用', value: 'active' },
|
||||
{ label: '停用', value: 'inactive' },
|
||||
]}
|
||||
value={whitelistEditor.status}
|
||||
/>
|
||||
</div>
|
||||
<Textarea label="用途说明" maxLength={200} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, reason: event.target.value })} placeholder="必填,说明该号码为何需要豁免频控" rows={3} value={whitelistEditor.reason} />
|
||||
<Textarea label="备注" maxLength={500} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, remark: event.target.value })} placeholder="选填" rows={3} value={whitelistEditor.remark} />
|
||||
<p className="muted">新增启用、启停切换或修改号码时,会清零相关号码在所有企业应用下的当前频控计数,并解除尚未到期的频控命中。</p>
|
||||
</Modal> : null}
|
||||
{deletingWhitelist ? <Modal
|
||||
footer={<><Button disabled={whitelistSaving} onClick={() => setDeletingWhitelist(null)} variant="ghost">取消</Button><Button disabled={whitelistSaving} onClick={() => void confirmDeleteWhitelist()}>{whitelistSaving ? '处理中…' : '删除并清零'}</Button></>}
|
||||
<Textarea
|
||||
label="用途说明"
|
||||
maxLength={200}
|
||||
onChange={(event) => setWhitelistEditor({ ...whitelistEditor, reason: event.target.value })}
|
||||
placeholder="必填,说明该号码为何需要豁免频控"
|
||||
rows={3}
|
||||
value={whitelistEditor.reason}
|
||||
/>
|
||||
<Textarea
|
||||
label="备注"
|
||||
maxLength={500}
|
||||
onChange={(event) => setWhitelistEditor({ ...whitelistEditor, remark: event.target.value })}
|
||||
placeholder="选填"
|
||||
rows={3}
|
||||
value={whitelistEditor.remark}
|
||||
/>
|
||||
<p className="muted">
|
||||
新增启用、启停切换或修改号码时,会清零相关号码在所有企业应用下的当前频控计数,并解除尚未到期的频控命中。
|
||||
</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
{deletingWhitelist ? (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={whitelistSaving} onClick={() => setDeletingWhitelist(null)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={whitelistSaving} onClick={() => void confirmDeleteWhitelist()}>
|
||||
{whitelistSaving ? '处理中…' : '删除并清零'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setDeletingWhitelist(null)}
|
||||
open
|
||||
title="删除号码频控白名单"
|
||||
>
|
||||
<p>删除号码 <strong>{deletingWhitelist.phoneNumber}</strong> 的平台级频控豁免,并清零该号码在所有企业应用下的当前频控计数。历史记录和审计日志会保留。</p>
|
||||
<Textarea label="删除原因" maxLength={500} onChange={(event) => setDeleteWhitelistReason(event.target.value)} placeholder="请填写删除原因" rows={4} value={deleteWhitelistReason} />
|
||||
</Modal> : null}
|
||||
{releaseHit ? <Modal
|
||||
footer={<><Button disabled={releasing} onClick={() => setReleaseHit(null)} variant="ghost">取消</Button><Button disabled={releasing} onClick={() => void confirmRelease()}>{releasing ? '处理中…' : '解除并清零'}</Button></>}
|
||||
<p>
|
||||
删除号码 <strong>{deletingWhitelist.phoneNumber}</strong>{' '}
|
||||
的平台级频控豁免,并清零该号码在所有企业应用下的当前频控计数。历史记录和审计日志会保留。
|
||||
</p>
|
||||
<Textarea
|
||||
label="删除原因"
|
||||
maxLength={500}
|
||||
onChange={(event) => setDeleteWhitelistReason(event.target.value)}
|
||||
placeholder="请填写删除原因"
|
||||
rows={4}
|
||||
value={deleteWhitelistReason}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
{releaseHit ? (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={releasing} onClick={() => setReleaseHit(null)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={releasing} onClick={() => void confirmRelease()}>
|
||||
{releasing ? '处理中…' : '解除并清零'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setReleaseHit(null)}
|
||||
open
|
||||
title="解除号码频控"
|
||||
>
|
||||
<p>将解除号码 <strong>{releaseHit.phoneNumber}</strong> 在应用“{releaseHit.application.name}”下的当前拦截,并将该规则当前周期计数清零。历史触发记录仍会保留。</p>
|
||||
<Textarea label="解除原因" onChange={(event) => setReleaseReason(event.target.value)} placeholder="请填写人工解除原因" rows={4} value={releaseReason} />
|
||||
</Modal> : null}
|
||||
<p>
|
||||
将解除号码 <strong>{releaseHit.phoneNumber}</strong> 在应用“{releaseHit.application.name}
|
||||
”下的当前拦截,并将该规则当前周期计数清零。历史触发记录仍会保留。
|
||||
</p>
|
||||
<Textarea
|
||||
label="解除原因"
|
||||
onChange={(event) => setReleaseReason(event.target.value)}
|
||||
placeholder="请填写人工解除原因"
|
||||
rows={4}
|
||||
value={releaseReason}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
CarrierTag,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
Textarea,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -17,11 +31,13 @@ const statusTone: Record<string, 'warning' | 'success' | 'danger'> = {
|
||||
};
|
||||
|
||||
function sourceLabel(sourceType?: string) {
|
||||
if (sourceType === 'night_sending_bulk') return '夜间超量聚合';
|
||||
return sourceType === 'cmpp_template_mismatch' ? 'CMPP模板不匹配聚合' : '风控审核';
|
||||
}
|
||||
|
||||
function messageStatusLabel(status: string) {
|
||||
return {
|
||||
return (
|
||||
{
|
||||
pending_review: '待人工审核',
|
||||
queued: '已入队',
|
||||
scheduled: '等待定时发送',
|
||||
@@ -31,7 +47,8 @@ function messageStatusLabel(status: string) {
|
||||
failed: '回执失败',
|
||||
rejected: '已拒绝',
|
||||
timeout: '超时',
|
||||
}[status] ?? status;
|
||||
}[status] ?? status
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSmsAuditPage() {
|
||||
@@ -56,14 +73,17 @@ export function AdminSmsAuditPage() {
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
adminApi.listRiskReviewTasks({
|
||||
adminApi
|
||||
.listRiskReviewTasks({
|
||||
status: status === 'all' ? undefined : status,
|
||||
submittedAtFrom: submittedDateRange.start,
|
||||
submittedAtTo: submittedDateRange.end,
|
||||
})
|
||||
.then((items) => {
|
||||
setRecords(items);
|
||||
setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id && item.status === 'pending_review')));
|
||||
setSelectedIds((current) =>
|
||||
current.filter((id) => items.some((item) => item.id === id && item.status === 'pending_review')),
|
||||
);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '短信审核任务加载失败'));
|
||||
@@ -74,8 +94,11 @@ export function AdminSmsAuditPage() {
|
||||
}, [status, submittedDateRange.end, submittedDateRange.start]);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
const matchesKeyword = !keyword || [record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
|
||||
() =>
|
||||
records.filter((record) => {
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
[record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
|
||||
return matchesKeyword;
|
||||
}),
|
||||
[keyword, records],
|
||||
@@ -83,7 +106,8 @@ export function AdminSmsAuditPage() {
|
||||
|
||||
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
|
||||
if (!target) return;
|
||||
adminApi.listRiskReviewTaskMessages(target.id, { phone: phoneKeyword || undefined, page, pageSize })
|
||||
adminApi
|
||||
.listRiskReviewTaskMessages(target.id, { phone: phoneKeyword || undefined, page, pageSize })
|
||||
.then(setPhoneData)
|
||||
.catch((failure: Error) => setError(failure.message || '审核号码列表加载失败'));
|
||||
}
|
||||
@@ -131,9 +155,29 @@ export function AdminSmsAuditPage() {
|
||||
const columns: Array<TableColumn<RiskReviewTask>> = [
|
||||
{
|
||||
key: 'selection',
|
||||
title: <input aria-label="全选当前筛选结果" checked={allSelected} disabled={selectableIds.length === 0} onChange={(event) => setSelectedIds(event.target.checked ? selectableIds : [])} type="checkbox" />,
|
||||
title: (
|
||||
<input
|
||||
aria-label="全选当前筛选结果"
|
||||
checked={allSelected}
|
||||
disabled={selectableIds.length === 0}
|
||||
onChange={(event) => setSelectedIds(event.target.checked ? selectableIds : [])}
|
||||
type="checkbox"
|
||||
/>
|
||||
),
|
||||
width: '54px',
|
||||
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
|
||||
render: (record) => (
|
||||
<input
|
||||
aria-label={`选择审核任务${record.taskNo}`}
|
||||
checked={selectedIds.includes(record.id)}
|
||||
disabled={record.status !== 'pending_review'}
|
||||
onChange={(event) =>
|
||||
setSelectedIds((current) =>
|
||||
event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id),
|
||||
)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tenantApplication',
|
||||
@@ -147,7 +191,12 @@ export function AdminSmsAuditPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'content', title: '短信内容', width: '440px', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{
|
||||
key: 'content',
|
||||
title: '短信内容',
|
||||
width: '440px',
|
||||
render: (record) => <span className="table-long-text">{record.content}</span>,
|
||||
},
|
||||
{
|
||||
key: 'submittedSource',
|
||||
title: '提交时间 / 审核来源',
|
||||
@@ -155,7 +204,9 @@ export function AdminSmsAuditPage() {
|
||||
render: (record) => (
|
||||
<div className="sms-audit-cell-stack">
|
||||
<time dateTime={record.createdAt}>{formatDateTime(record.createdAt)}</time>
|
||||
<Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag>
|
||||
<Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>
|
||||
{sourceLabel(record.sourceType)}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -165,7 +216,15 @@ export function AdminSmsAuditPage() {
|
||||
width: '160px',
|
||||
render: (record) => (
|
||||
<div className="sms-audit-cell-stack">
|
||||
<button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">
|
||||
<button
|
||||
className="table-link"
|
||||
onClick={() => {
|
||||
setPhoneTarget(record);
|
||||
setPhoneKeyword('');
|
||||
setPhonePage(1);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · 查看列表
|
||||
</button>
|
||||
<Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>
|
||||
@@ -179,11 +238,19 @@ export function AdminSmsAuditPage() {
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="audit-actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost">详情</Button>
|
||||
{record.status === 'pending_review' ? <>
|
||||
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success">通过</Button>
|
||||
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button>
|
||||
</> : null}
|
||||
<Button icon={<Eye size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost">
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'pending_review' ? (
|
||||
<>
|
||||
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success">
|
||||
通过
|
||||
</Button>
|
||||
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -207,17 +274,47 @@ export function AdminSmsAuditPage() {
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<Input label="审核任务号/短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入审核任务号、短信内容或审核原因" value={keyword} />
|
||||
<Input
|
||||
label="审核任务号/短信内容"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="请输入审核任务号、短信内容或审核原因"
|
||||
value={keyword}
|
||||
/>
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="audit-filter-actions ui-filter-actions">
|
||||
<Button icon={<Search size={17} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setSubmittedDateRange({}); setStatus('pending_review'); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={17} />} onClick={loadData}>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setSubmittedDateRange({});
|
||||
setStatus('pending_review');
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sms-bulk-actions">
|
||||
<span>已选择 {selectedIds.length} 条待审核任务</span>
|
||||
<Button disabled={selectedIds.length === 0} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success">通过已选</Button>
|
||||
<Button disabled={selectedIds.length === 0} icon={<X size={16} />} onClick={() => setRejectTarget('batch')} variant="danger">驳回已选</Button>
|
||||
<Button
|
||||
disabled={selectedIds.length === 0}
|
||||
icon={<Check size={16} />}
|
||||
onClick={() => setApproveTarget('batch')}
|
||||
variant="success"
|
||||
>
|
||||
通过已选
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectedIds.length === 0}
|
||||
icon={<X size={16} />}
|
||||
onClick={() => setRejectTarget('batch')}
|
||||
variant="danger"
|
||||
>
|
||||
驳回已选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,47 +323,155 @@ export function AdminSmsAuditPage() {
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setApproveTarget(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={() => approveTarget === 'batch' ? void approveBatch() : approveTarget ? void approveRecord(approveTarget) : undefined} variant="success">确认通过</Button>
|
||||
<Button onClick={() => setApproveTarget(null)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
approveTarget === 'batch'
|
||||
? void approveBatch()
|
||||
: approveTarget
|
||||
? void approveRecord(approveTarget)
|
||||
: undefined
|
||||
}
|
||||
variant="success"
|
||||
>
|
||||
确认通过
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
onClose={() => setApproveTarget(null)}
|
||||
open={Boolean(approveTarget)}
|
||||
title="确认通过"
|
||||
>
|
||||
<p>{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
|
||||
<p>
|
||||
{approveTarget === 'batch'
|
||||
? `确认通过已选择的 ${selectedIds.length} 条待审核任务?`
|
||||
: '确认通过该短信审核任务?'}
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}>关闭</Button>} onClose={() => setDetailTarget(null)} open title="短信审核详情">
|
||||
{detailTarget ? (
|
||||
<Modal
|
||||
footer={<Button onClick={() => setDetailTarget(null)}>关闭</Button>}
|
||||
onClose={() => setDetailTarget(null)}
|
||||
open
|
||||
title="短信审核详情"
|
||||
>
|
||||
<div className="detail-grid">
|
||||
<div><span>审核任务号</span><strong>{detailTarget.taskNo}</strong></div>
|
||||
<div><span>关联批量任务号</span><strong>{detailTarget.batchTask?.taskNo ?? '-'}</strong></div>
|
||||
<div><span>发送企业</span><strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong></div>
|
||||
<div><span>企业应用</span><strong>{detailTarget.application?.name ?? detailTarget.applicationId ?? '-'}</strong></div>
|
||||
<div><span>提交时间</span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
|
||||
<div><span>审核人员(用户名)</span><strong>{detailTarget.reviewedBy?.username || '-'}</strong></div>
|
||||
<div><span>审核时间</span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
|
||||
<div className="detail-grid__wide"><span>审核原因</span><strong>{detailTarget.reviewReason || '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>驳回原因</span><strong>{detailTarget.rejectReason || '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>风控命中</span><strong>{detailTarget.riskHits?.map((item) => item.reason).join(';') || '-'}</strong></div>
|
||||
<div>
|
||||
<span>审核任务号</span>
|
||||
<strong>{detailTarget.taskNo}</strong>
|
||||
</div>
|
||||
</Modal> : null}
|
||||
<div>
|
||||
<span>关联批量任务号</span>
|
||||
<strong>{detailTarget.batchTask?.taskNo ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送企业</span>
|
||||
<strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>企业应用</span>
|
||||
<strong>{detailTarget.application?.name ?? detailTarget.applicationId ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>提交时间</span>
|
||||
<strong>{formatDateTime(detailTarget.createdAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>审核人员(用户名)</span>
|
||||
<strong>{detailTarget.reviewedBy?.username || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>审核时间</span>
|
||||
<strong>{formatDateTime(detailTarget.reviewedAt)}</strong>
|
||||
</div>
|
||||
<div className="detail-grid__wide">
|
||||
<span>审核原因</span>
|
||||
<strong>{detailTarget.reviewReason || '-'}</strong>
|
||||
</div>
|
||||
<div className="detail-grid__wide">
|
||||
<span>驳回原因</span>
|
||||
<strong>{detailTarget.rejectReason || '-'}</strong>
|
||||
</div>
|
||||
<div className="detail-grid__wide">
|
||||
<span>风控命中</span>
|
||||
<strong>{detailTarget.riskHits?.map((item) => item.reason).join(';') || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}>关闭</Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · 审核任务号 ${phoneTarget.taskNo}`}>
|
||||
{phoneTarget ? (
|
||||
<Modal
|
||||
footer={<Button onClick={() => setPhoneTarget(null)}>关闭</Button>}
|
||||
onClose={() => setPhoneTarget(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={`号码列表 · 审核任务号 ${phoneTarget.taskNo}`}
|
||||
>
|
||||
<div className="page-stack">
|
||||
<div className="ui-filter-row">
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
|
||||
<Select label="每页条数" onChange={(event) => { setPhonePageSize(Number(event.target.value)); setPhonePage(1); }} options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]} value={String(phonePageSize)} />
|
||||
<div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}>查询</Button></div>
|
||||
<Input
|
||||
label="手机号码"
|
||||
onChange={(event) => setPhoneKeyword(event.target.value)}
|
||||
placeholder="输入完整或部分号码"
|
||||
value={phoneKeyword}
|
||||
/>
|
||||
<Select
|
||||
label="每页条数"
|
||||
onChange={(event) => {
|
||||
setPhonePageSize(Number(event.target.value));
|
||||
setPhonePage(1);
|
||||
}}
|
||||
options={[
|
||||
{ label: '10条/页', value: '10' },
|
||||
{ label: '20条/页', value: '20' },
|
||||
{ label: '50条/页', value: '50' },
|
||||
]}
|
||||
value={String(phonePageSize)}
|
||||
/>
|
||||
<div className="audit-filter-actions ui-filter-actions">
|
||||
<Button
|
||||
icon={<Search size={16} />}
|
||||
onClick={() => {
|
||||
setPhonePage(1);
|
||||
loadPhones(phoneTarget, 1, phonePageSize);
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => item.carrier ? <CarrierTag carrier={item.carrier} /> : '-' },
|
||||
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'pending_review' ? 'warning' : item.status === 'failed' || item.status === 'submit_failed' ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
|
||||
{
|
||||
key: 'carrier',
|
||||
title: '运营商',
|
||||
render: (item) => (item.carrier ? <CarrierTag carrier={item.carrier} /> : '-'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '短信记录状态',
|
||||
render: (item) => (
|
||||
<Tag
|
||||
tone={
|
||||
item.status === 'pending_review'
|
||||
? 'warning'
|
||||
: item.status === 'failed' || item.status === 'submit_failed'
|
||||
? 'danger'
|
||||
: 'info'
|
||||
}
|
||||
>
|
||||
{messageStatusLabel(item.status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={phoneData.items}
|
||||
emptyText="暂无号码记录"
|
||||
@@ -283,21 +488,35 @@ export function AdminSmsAuditPage() {
|
||||
totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))}
|
||||
/>
|
||||
</div>
|
||||
</Modal> : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setRejectTarget(null)} variant="ghost">取消</Button>
|
||||
<Button disabled={!rejectReason.trim()} onClick={() => rejectTarget === 'batch' ? void rejectBatch() : void rejectRecord()} variant="danger">确认驳回</Button>
|
||||
<Button onClick={() => setRejectTarget(null)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!rejectReason.trim()}
|
||||
onClick={() => (rejectTarget === 'batch' ? void rejectBatch() : void rejectRecord())}
|
||||
variant="danger"
|
||||
>
|
||||
确认驳回
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
onClose={() => setRejectTarget(null)}
|
||||
open={Boolean(rejectTarget)}
|
||||
title={rejectTarget === 'batch' ? '批量驳回' : '确认驳回'}
|
||||
>
|
||||
{rejectTarget === 'batch' ? <p>将驳回已选择的 {selectedIds.length} 条待审核任务。</p> : null}
|
||||
<Textarea label="驳回原因" onChange={(event) => setRejectReason(event.target.value)} rows={4} value={rejectReason} />
|
||||
<Textarea
|
||||
label="驳回原因"
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
rows={4}
|
||||
value={rejectReason}
|
||||
/>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// Isolated PostgreSQL and Redis verification. Never calls a Gateway or business queue.
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import pg from '../../api/node_modules/pg/lib/index.js';
|
||||
import prisma from '../../api/node_modules/@prisma/client/default.js';
|
||||
import adapter from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
|
||||
import night from '../../api/dist/risk-review/night-sending-risk.service.js';
|
||||
import risk from '../../api/dist/risk-review/risk-review.service.js';
|
||||
import continuation from '../../api/dist/send-chain/send-review-continuation.service.js';
|
||||
import bullmq from '../../api/node_modules/bullmq/dist/cjs/index.js';
|
||||
|
||||
const connectionString = process.env.QA_DATABASE_URL || process.env.DATABASE_URL;
|
||||
if (!connectionString) throw Error('QA_DATABASE_URL is required');
|
||||
const schema = `qa_night_${randomUUID().replaceAll('-', '')}`;
|
||||
assert.match(schema, /^qa_night_[a-f0-9]{32}$/);
|
||||
const admin = new pg.Client({ connectionString });
|
||||
const clients = [],
|
||||
checks = [];
|
||||
let queue;
|
||||
await admin.connect();
|
||||
try {
|
||||
const before = (
|
||||
await admin.query(
|
||||
'SELECT (SELECT count(*) FROM public."SmsMessageRecord") messages,(SELECT count(*) FROM public."SmsSubmitRecord") submits',
|
||||
)
|
||||
).rows[0];
|
||||
await admin.query(`CREATE SCHEMA "${schema}"`);
|
||||
for (const table of [
|
||||
'Tenant',
|
||||
'SmsApplication',
|
||||
'SmsBatchTask',
|
||||
'SmsMessageRecord',
|
||||
'SmsSubmitRecord',
|
||||
'SmsSendTask',
|
||||
'RiskRule',
|
||||
'RiskHitRecord',
|
||||
'User',
|
||||
'SmsTemplate',
|
||||
'TemplateVariable',
|
||||
'SmsSignature',
|
||||
'SensitiveWord',
|
||||
]) {
|
||||
await admin.query(`CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`);
|
||||
}
|
||||
await admin.query(`SET search_path TO "${schema}"`);
|
||||
// LIKE also copies these columns after the release; recreate them only inside
|
||||
// this isolated schema so the forward migration is tested on every run.
|
||||
await admin.query(
|
||||
'ALTER TABLE "SmsSendTask" DROP COLUMN IF EXISTS "continuationLeaseOwner", DROP COLUMN IF EXISTS "continuationLeaseExpiresAt"',
|
||||
);
|
||||
await admin.query(
|
||||
readFileSync(
|
||||
new URL('../../api/prisma/migrations/20260907143000_night_sending_risk/migration.sql', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const pool = new pg.Pool({ connectionString, max: 4, options: `-c search_path=${schema} -c timezone=UTC` });
|
||||
clients.push(
|
||||
new prisma.PrismaClient({ adapter: new adapter.PrismaPg(pool, { schema, disposeExternalPool: true }) }),
|
||||
);
|
||||
}
|
||||
const [db, db2] = clients;
|
||||
assert.equal((await db.$queryRawUnsafe('SELECT current_schema() AS name'))[0].name, schema);
|
||||
for (const tenantId of ['t1', 't2'])
|
||||
await db.tenant.create({ data: { id: tenantId, code: tenantId, name: tenantId } });
|
||||
for (const [id, tenantId] of [
|
||||
['a1', 't1'],
|
||||
['a2', 't1'],
|
||||
['a3', 't1'],
|
||||
['b1', 't2'],
|
||||
])
|
||||
await db.smsApplication.create({
|
||||
data: { id, tenantId, name: id, cmppAccount: id, cmppEnterpriseCode: 'qa', secretHash: 'isolated-unused' },
|
||||
});
|
||||
const ruleData = {
|
||||
code: night.NIGHT_RULE_CODE,
|
||||
name: '夜间累计发送量审核',
|
||||
metric: 'nightSendingCount',
|
||||
thresholdValue: 5000,
|
||||
action: 'manual_review',
|
||||
status: 'active',
|
||||
config: { startTime: '21:00', endTime: '08:00', timeZone: 'Asia/Shanghai' },
|
||||
};
|
||||
const global = await db.riskRule.create({ data: { ...ruleData, id: 'global' } });
|
||||
const now = new Date('2026-09-06T14:00:01Z');
|
||||
const window = night.nightWindow(now, night.nightClock(null));
|
||||
await db.nightSendingWindow.create({
|
||||
data: {
|
||||
id: `a1:${window.windowStartedAt.toISOString()}`,
|
||||
tenantId: 't1',
|
||||
applicationId: 'a1',
|
||||
...window,
|
||||
count: 4990,
|
||||
baselineCount: 4990,
|
||||
},
|
||||
});
|
||||
let sequence = 0;
|
||||
async function message(app = 'a1', content = '【测试】通知验证码和任意内容', sourceType = 'cmpp', extra = {}) {
|
||||
const id = `m${++sequence}`,
|
||||
tenantId = app === 'b1' ? 't2' : 't1';
|
||||
await db.smsBatchTask.create({
|
||||
data: {
|
||||
id: `task-${id}`,
|
||||
taskNo: `task-${id}`,
|
||||
tenantId,
|
||||
applicationId: app,
|
||||
content,
|
||||
phoneTotal: 1,
|
||||
sourceType,
|
||||
status: 'queued',
|
||||
},
|
||||
});
|
||||
return db.smsMessageRecord.create({
|
||||
data: {
|
||||
id,
|
||||
messageId: id,
|
||||
tenantId,
|
||||
applicationId: app,
|
||||
batchTaskId: `task-${id}`,
|
||||
phoneNumber: '13800000001',
|
||||
content,
|
||||
status: 'queued',
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
const services = [new night.NightSendingRiskService(db), new night.NightSendingRiskService(db2)];
|
||||
const messages = [];
|
||||
for (let i = 0; i < 20; i++) messages.push(await message('a1', undefined, ['cmpp', 'api', 'client'][i % 3]));
|
||||
const results = await Promise.all(messages.map((m, i) => services[i % 2].guard([m.id], now)));
|
||||
assert.equal(
|
||||
results.reduce((sum, result) => sum + result.size, 0),
|
||||
10,
|
||||
);
|
||||
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a1' } })).count, 5010);
|
||||
assert.equal(await db.nightSendingReservation.count(), 20);
|
||||
const held = await db.smsMessageRecord.findMany({ where: { status: 'pending_review' } });
|
||||
const tasks = await db.smsSendTask.findMany();
|
||||
assert.equal(tasks.length, 1);
|
||||
assert.equal(tasks[0].phoneTotal, 10);
|
||||
assert.equal(tasks[0].uniquePhoneTotal, 1);
|
||||
checks.push('mixed sources + 2 instances concurrent boundary 4990->5010; same content aggregation');
|
||||
await Promise.all(messages.map((m, i) => services[i % 2].guard([m.id], now)));
|
||||
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a1' } })).count, 5010);
|
||||
checks.push('duplicate jobs are idempotent');
|
||||
const other = await message('a1', '另一种内容');
|
||||
await services[0].guard([other.id], now);
|
||||
assert.equal(await db.smsSendTask.count(), 2);
|
||||
const midnight = await message();
|
||||
await services[1].guard([midnight.id], new Date('2026-09-06T16:00:00Z'));
|
||||
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a1' } })).count, 5012);
|
||||
checks.push('different content separate review; midnight retains application total');
|
||||
const a2 = await message('a2'),
|
||||
b1 = await message('b1');
|
||||
assert.equal((await services[0].guard([a2.id, b1.id], now)).size, 0);
|
||||
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a2' } })).count, 1);
|
||||
checks.push('application and tenant isolation');
|
||||
const override = await db.riskRule.create({
|
||||
data: { ...ruleData, applicationId: 'a2', tenantId: 't1', thresholdValue: 1 },
|
||||
});
|
||||
const overrideMessage = await message('a2');
|
||||
assert.equal((await services[0].guard([overrideMessage.id], now)).size, 1);
|
||||
await db.riskRule.update({ where: { id: override.id }, data: { status: 'inactive' } });
|
||||
const fallback = await message('a2');
|
||||
assert.equal((await services[0].guard([fallback.id], now)).size, 0);
|
||||
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a2' } })).count, 3);
|
||||
checks.push('individual threshold override + disable restores global without clearing count');
|
||||
const day = await message('b1');
|
||||
assert.equal((await services[0].guard([day.id], new Date('2026-09-07T04:00:00Z'))).size, 0);
|
||||
assert.equal(await db.nightSendingReservation.count({ where: { messageRecordId: day.id } }), 0);
|
||||
await services[0].guard([day.id], new Date('2026-09-07T13:00:00Z'));
|
||||
assert.equal(await db.nightSendingReservation.count({ where: { messageRecordId: day.id } }), 1);
|
||||
checks.push('daytime queue + nighttime execution/scheduled task evaluated at dispatch');
|
||||
const retry = await message('a1', undefined, 'cmpp', { submitId: 'already-submitted', billingUnits: 3 });
|
||||
await services[0].guard([retry.id], now);
|
||||
assert.equal(await db.nightSendingReservation.count({ where: { messageRecordId: retry.id } }), 0);
|
||||
checks.push('retry/segments do not consume new allowance');
|
||||
const legacy = await message('a3', '旧版本本夜首次提交', 'api', { status: 'submitted', submitId: 'legacy' });
|
||||
const previousDay = await message('a3', '昨日首次提交本夜重试', 'cmpp', {
|
||||
status: 'submitted',
|
||||
submitId: 'previous',
|
||||
});
|
||||
for (const [id, messageRecordId, createdAt] of [
|
||||
['legacy-first', legacy.id, now],
|
||||
['legacy-retry', legacy.id, now],
|
||||
['previous-first', previousDay.id, new Date(now.getTime() - 86_400_000)],
|
||||
['previous-retry', previousDay.id, now],
|
||||
])
|
||||
await db.smsSubmitRecord.create({
|
||||
data: { id, submitId: id, messageRecordId, channelId: 'isolated-unused', createdAt },
|
||||
});
|
||||
const newAfterDeploy = await message('a3');
|
||||
await services[0].guard([newAfterDeploy.id], now);
|
||||
const initialized = await db.nightSendingWindow.findFirst({ where: { applicationId: 'a3' } });
|
||||
assert.equal(initialized.baselineCount, 1);
|
||||
assert.equal(initialized.count, 2);
|
||||
checks.push('midnight deployment bootstrap counts first business submissions, not retry attempts');
|
||||
const review = new risk.RiskReviewService(db);
|
||||
const changed = await review.updateRule(global.id, {
|
||||
thresholdValue: 1,
|
||||
config: { startTime: '23:00', endTime: '07:00', timeZone: 'Asia/Shanghai' },
|
||||
});
|
||||
assert.equal(changed.thresholdValue, 1);
|
||||
await assert.rejects(review.updateRule(global.id, { thresholdValue: 1.5 }));
|
||||
await assert.rejects(review.updateRule(global.id, { action: 'block' }));
|
||||
await assert.rejects(
|
||||
review.updateRule(global.id, { config: { startTime: '21:00', endTime: '08:00', timeZone: 'UTC' } }),
|
||||
);
|
||||
checks.push('rule write validation and forced manual review');
|
||||
await review.approveTask(tasks[0].id, { reason: '隔离审核' });
|
||||
await review.approveTask(tasks[0].id, { reason: '重复请求' });
|
||||
await assert.rejects(review.rejectTask(tasks[0].id, { reason: '相反决定' }));
|
||||
const redis = new URL(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
|
||||
queue = new bullmq.Queue(schema, {
|
||||
connection: { host: redis.hostname, port: Number(redis.port || 6379), password: redis.password || undefined },
|
||||
});
|
||||
let failQueue = true;
|
||||
const facade = {
|
||||
getSendQueue: () => ({
|
||||
add: (...args) => {
|
||||
if (failQueue) throw Error('isolated injected queue outage');
|
||||
return queue.add(...args);
|
||||
},
|
||||
}),
|
||||
refreshTaskProgress: async () => {},
|
||||
};
|
||||
const resumes = new continuation.SendReviewContinuationService(db, {}, {}, {}, {}, facade, {
|
||||
releaseMessageReservation: async () => {},
|
||||
recordCmppFailureReceipt: async () => {},
|
||||
});
|
||||
await assert.rejects(resumes.handleReviewDecision(tasks[0].id, 'approved', '隔离审核'));
|
||||
assert((await review.pendingNightContinuations()).some((t) => t.id === tasks[0].id));
|
||||
failQueue = false;
|
||||
await Promise.all([
|
||||
resumes.handleReviewDecision(tasks[0].id, 'approved', '隔离审核'),
|
||||
resumes.handleReviewDecision(tasks[0].id, 'approved', '隔离审核'),
|
||||
]);
|
||||
const queued = await queue.getJobCounts('wait', 'prioritized');
|
||||
assert.equal(queued.wait + queued.prioritized, 10);
|
||||
assert.equal(await db.nightSendingReservation.count({ where: { reviewTaskId: tasks[0].id, continuedAt: null } }), 0);
|
||||
for (const m of held) assert.equal((await services[0].guard([m.id], now)).size, 0);
|
||||
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: other.id } })).status, 'pending_review');
|
||||
checks.push('review idempotency/conflict + real Redis recovery + approved range only');
|
||||
const invalid = await message('a2');
|
||||
await admin.query(
|
||||
`ALTER TABLE "${schema}"."NightSendingReservation" ADD CONSTRAINT injected_failure CHECK ("messageRecordId" <> '${invalid.id}')`,
|
||||
);
|
||||
const countBefore = (await db.nightSendingWindow.findFirst({ where: { applicationId: 'a2' } })).count;
|
||||
await assert.rejects(services[0].guard([invalid.id], now));
|
||||
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a2' } })).count, countBefore);
|
||||
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: invalid.id } })).status, 'queued');
|
||||
checks.push('transaction failure rolls back counter and review atomically');
|
||||
const after = (
|
||||
await admin.query(
|
||||
'SELECT (SELECT count(*) FROM public."SmsMessageRecord") messages,(SELECT count(*) FROM public."SmsSubmitRecord") submits',
|
||||
)
|
||||
).rows[0];
|
||||
assert.deepEqual(after, before);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: checks.length,
|
||||
checks,
|
||||
schema,
|
||||
publicBefore: before,
|
||||
publicAfter: after,
|
||||
businessQueueWrites: 0,
|
||||
gatewayCalls: 0,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (queue) {
|
||||
await queue.obliterate({ force: true });
|
||||
await queue.close();
|
||||
}
|
||||
for (const client of clients) await client.$disconnect();
|
||||
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
||||
await admin.end();
|
||||
}
|
||||
Reference in New Issue
Block a user