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

This commit is contained in:
hectorzhao
2026-09-07 22:55:29 +08:00
parent e281ff853b
commit 633ba59775
18 changed files with 4279 additions and 1767 deletions
@@ -0,0 +1,38 @@
CREATE TABLE "NightSendingWindow" (
"id" TEXT PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"windowStartedAt" TIMESTAMP(3) NOT NULL,
"windowEndsAt" TIMESTAMP(3) NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
"baselineCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL
);
CREATE UNIQUE INDEX "NightSendingWindow_applicationId_windowStartedAt_key" ON "NightSendingWindow"("applicationId", "windowStartedAt");
CREATE INDEX "NightSendingWindow_applicationId_windowEndsAt_idx" ON "NightSendingWindow"("applicationId", "windowEndsAt");
CREATE TABLE "NightSendingReservation" (
"messageRecordId" TEXT PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"windowId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"thresholdValue" INTEGER NOT NULL,
"reviewTaskId" TEXT,
"continuedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "NightSendingReservation_applicationId_windowId_idx" ON "NightSendingReservation"("applicationId", "windowId");
CREATE INDEX "NightSendingReservation_reviewTaskId_idx" ON "NightSendingReservation"("reviewTaskId");
ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseOwner" TEXT;
ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseExpiresAt" TIMESTAMP(3);
-- Preserve rule IDs and thresholds for application overrides and historical hits.
UPDATE "RiskRule" SET "name" = '夜间累计发送量审核',
"description" = '同一企业应用在夜间累计业务短信超过阈值后进入人工审核,所有入口与内容合并计数。',
"metric" = 'nightSendingCount', "action" = 'manual_review',
"config" = COALESCE("config", '{}'::jsonb) || '{"timeZone":"Asia/Shanghai"}'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'NON_WORKING_MARKETING_BULK';
-- The newly approved policy applies by default to every application.
UPDATE "RiskRule" SET "status" = 'active', "updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'NON_WORKING_MARKETING_BULK' AND "applicationId" IS NULL AND "status" <> 'deleted';
+32
View File
@@ -1511,6 +1511,36 @@ model ReportReceiptImport {
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade) 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 { model RiskRule {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String? tenantId String?
@@ -1563,6 +1593,8 @@ model SmsSendTask {
createdById String? createdById String?
reviewedById String? reviewedById String?
reviewedAt DateTime? reviewedAt DateTime?
continuationLeaseOwner String?
continuationLeaseExpiresAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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;
}
}
+109 -52
View File
@@ -30,13 +30,19 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
}, },
smsSendTask: { 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 }), Promise.resolve({ id: 'risk-task-1', ...data }),
), ),
findUnique: jest.fn().mockResolvedValue({ id: 'risk-task-1', riskHits: [] }), findUnique: jest.fn().mockResolvedValue({ id: 'risk-task-1', riskHits: [] }),
update: jest.fn(), update: jest.fn(),
findMany: 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: { smsMessageRecord: {
update: jest.fn().mockResolvedValue({ id: 'message-1' }), update: jest.fn().mockResolvedValue({ id: 'message-1' }),
@@ -51,14 +57,20 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
createMany: jest.fn(), createMany: jest.fn(),
findMany: jest.fn(), findMany: jest.fn(),
}, },
$transaction: jest.fn(async (callback) => callback({ $transaction: jest.fn(async (callback) =>
callback({
smsSendTask: { 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: { smsMessageRecord: {
update: jest.fn().mockResolvedValue({ id: 'message-1' }), update: jest.fn().mockResolvedValue({ id: 'message-1' }),
}, },
})), }),
),
...overrides, ...overrides,
}; };
} }
@@ -69,8 +81,20 @@ describe('RiskReviewService', () => {
const service = new RiskReviewService(prisma as never); const service = new RiskReviewService(prisma as never);
const results = await service.evaluateTasksBatch([ 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); expect(results).toHaveLength(2);
@@ -84,7 +108,11 @@ describe('RiskReviewService', () => {
it('coalesces concurrent default-rule checks and reuses the short completeness cache', async () => { it('coalesces concurrent default-rule checks and reuses the short completeness cache', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
let releaseCount: ((count: number) => void) | undefined; 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 service = new RiskReviewService(prisma as never);
const first = service.ensureDefaultRules(); const first = service.ensureDefaultRules();
@@ -99,9 +127,7 @@ describe('RiskReviewService', () => {
it('clears a failed default-rule check so the next request can retry', async () => { it('clears a failed default-rule check so the next request can retry', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.riskRule.count prisma.riskRule.count.mockRejectedValueOnce(new Error('database unavailable')).mockResolvedValueOnce(5);
.mockRejectedValueOnce(new Error('database unavailable'))
.mockResolvedValueOnce(5);
const service = new RiskReviewService(prisma as never); const service = new RiskReviewService(prisma as never);
await expect(service.ensureDefaultRules()).rejects.toThrow('database unavailable'); 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 () => { it('falls back to per-rule recovery when the completeness count finds a missing default', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.riskRule.count.mockResolvedValue(4); prisma.riskRule.count.mockResolvedValue(4);
prisma.riskRule.findFirst.mockImplementation(({ where }: { where: { code: string } }) => ( prisma.riskRule.findFirst.mockImplementation(({ where }: { where: { code: string } }) =>
Promise.resolve(where.code === 'PHONE_FREQUENCY_5M' ? null : { id: `rule-${where.code}` }) Promise.resolve(where.code === 'PHONE_FREQUENCY_5M' ? null : { id: `rule-${where.code}` }),
)); );
const service = new RiskReviewService(prisma as never); const service = new RiskReviewService(prisma as never);
service.createRule = jest.fn().mockResolvedValue({ id: 'restored-rule' }) 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', () => { it('keeps phone-frequency periods fixed and rejects manual-review actions', () => {
const service = new RiskReviewService(createPrismaMock() as never); const service = new RiskReviewService(createPrismaMock() as never);
expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 })) expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 })).toThrow(
.toThrow('号码频次周期首版固定为24小时自然日或5分钟,不允许修改'); '号码频次周期首版固定为24小时自然日或5分钟,不允许修改',
expect(() => service['validateRuleInput']({ );
expect(() =>
service['validateRuleInput']({
code: 'PHONE_FREQUENCY_24H', code: 'PHONE_FREQUENCY_24H',
thresholdValue: 10, thresholdValue: 10,
action: 'manual_review', action: 'manual_review',
})).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝'); }),
).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝');
}); });
it('includes the sending enterprise and application in SMS review rows', async () => { it('includes the sending enterprise and application in SMS review rows', async () => {
@@ -145,18 +174,22 @@ describe('RiskReviewService', () => {
await service.listTasks(undefined, 'pending_review'); await service.listTasks(undefined, 'pending_review');
expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({ include: expect.objectContaining({
tenant: { select: { id: true, name: true } }, tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } }, application: { select: { id: true, name: true } },
}), }),
})); }),
);
}); });
it('adds the associated batch task number to SMS review rows', async () => { it('adds the associated batch task number to SMS review rows', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsSendTask.findMany.mockResolvedValue([{ id: 'review-task-1', taskNo: 'REVIEW-001' }]); 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); const service = new RiskReviewService(prisma as never);
await expect(service.listTasks(undefined, 'pending_review')).resolves.toEqual([ 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'); 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({ where: expect.objectContaining({
createdAt: { createdAt: {
gte: new Date('2026-08-01T00:00:00+08:00'), gte: new Date('2026-08-01T00:00:00+08:00'),
lte: new Date('2026-08-03T23:59:59.999+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 () => { 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 () => { it('batch rejects unique tasks with one required rejection reason', async () => {
const prisma = createPrismaMock(); 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, id: where.id,
status: 'pending_review', status: 'pending_review',
reviewReason: '命中风控', 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, id: where.id,
...data, ...data,
riskHits: [], riskHits: [],
})); }),
);
const service = new RiskReviewService(prisma as never); 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-1', status: 'rejected', rejectReason: '批量人工拒绝' }),
expect.objectContaining({ id: 'task-2', status: 'rejected', rejectReason: '批量人工拒绝' }), expect.objectContaining({ id: 'task-2', status: 'rejected', rejectReason: '批量人工拒绝' }),
]); ],
);
expect(prisma.smsSendTask.update).toHaveBeenCalledTimes(2); expect(prisma.smsSendTask.update).toHaveBeenCalledTimes(2);
}); });
it('requires task ids and a reason for batch rejection', async () => { it('requires task ids and a reason for batch rejection', async () => {
const service = new RiskReviewService(createPrismaMock() as never); 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: [], reason: '拒绝' })).rejects.toThrow(
await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow('Batch rejection reason is required'); '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 () => { it('rejects tasks over the effective max phone rule threshold', async () => {
@@ -310,12 +356,14 @@ describe('RiskReviewService', () => {
]); ]);
const service = new RiskReviewService(prisma as never); const service = new RiskReviewService(prisma as never);
await expect(service.evaluateTask({ await expect(
service.evaluateTask({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
content: 'hello', content: 'hello',
phones: ['13800000001', '13800000002'], phones: ['13800000001', '13800000002'],
})).resolves.toEqual(expect.objectContaining({ status: 'rejected' })); }),
).resolves.toEqual(expect.objectContaining({ status: 'rejected' }));
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ ruleId: 'rule-app', thresholdValue: 1 })], 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 () => { it('paginates real phone records through both direct and batch review-task relations', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1' }); prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1' });
prisma.smsMessageRecord.findMany.mockResolvedValue([{ prisma.smsMessageRecord.findMany.mockResolvedValue([
{
id: 'message-1', id: 'message-1',
phoneNumber: '13800000001', phoneNumber: '13800000001',
province: '上海', province: '上海',
carrier: 'mobile', carrier: 'mobile',
status: 'pending_review', status: 'pending_review',
}]); },
]);
prisma.smsMessageRecord.count.mockResolvedValue(1); prisma.smsMessageRecord.count.mockResolvedValue(1);
const service = new RiskReviewService(prisma as never); const service = new RiskReviewService(prisma as never);
@@ -340,17 +390,16 @@ describe('RiskReviewService', () => {
page: 1, page: 1,
pageSize: 20, pageSize: 20,
}); });
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { where: {
OR: [ OR: [{ reviewTaskId: 'review-task-1' }, { batchTask: { riskTaskId: 'review-task-1' } }],
{ reviewTaskId: 'review-task-1' },
{ batchTask: { riskTaskId: 'review-task-1' } },
],
phoneNumber: { contains: '138' }, phoneNumber: { contains: '138' },
}, },
skip: 0, skip: 0,
take: 20, take: 20,
})); }),
);
}); });
it('does not create an audit task for automatic approval', async () => { 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(); const prisma = createPrismaMock();
prisma.smsBatchTask.count.mockResolvedValue(10); prisma.smsBatchTask.count.mockResolvedValue(10);
prisma.riskRule.findMany.mockResolvedValue([ prisma.riskRule.findMany.mockResolvedValue([
@@ -443,11 +492,13 @@ describe('RiskReviewService', () => {
expect(result.status).toBe('pending_review'); expect(result.status).toBe('pending_review');
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([ data: expect.arrayContaining([expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 10 })]),
expect.objectContaining({ ruleCode: 'NON_WORKING_MARKETING_BULK', actualValue: 3 }),
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({ expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({
where: { where: {
applicationId: 'app-1', applicationId: 'app-1',
@@ -459,7 +510,8 @@ describe('RiskReviewService', () => {
it('does not include CMPP or HTTP tasks in client task frequency control', async () => { it('does not include CMPP or HTTP tasks in client task frequency control', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.riskRule.findMany.mockResolvedValue([{ prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-frequency', id: 'rule-frequency',
code: 'TASK_CREATE_FREQUENCY', code: 'TASK_CREATE_FREQUENCY',
name: '短时间任务创建频控', name: '短时间任务创建频控',
@@ -467,23 +519,28 @@ describe('RiskReviewService', () => {
thresholdValue: 1, thresholdValue: 1,
action: 'manual_review', action: 'manual_review',
priority: 30, priority: 30,
}]); },
]);
const service = new RiskReviewService(prisma as never); const service = new RiskReviewService(prisma as never);
await expect(service.evaluateTask({ await expect(
service.evaluateTask({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
content: 'hello', content: 'hello',
phones: ['10000000000'], phones: ['10000000000'],
sourceType: 'cmpp', 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', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
content: 'hello', content: 'hello',
phones: ['10000000000'], phones: ['10000000000'],
sourceType: 'api', sourceType: 'api',
})).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); }),
).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null }));
expect(prisma.smsBatchTask.count).not.toHaveBeenCalled(); expect(prisma.smsBatchTask.count).not.toHaveBeenCalled();
}); });
+151 -91
View File
@@ -3,6 +3,13 @@ import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { shanghaiDateRange } from '../common/shanghai-date-range'; 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 { export interface CreateRiskRuleDto {
tenantId?: string; tenantId?: string;
@@ -71,9 +78,9 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [
}, },
{ {
code: 'NON_WORKING_MARKETING_BULK', code: 'NON_WORKING_MARKETING_BULK',
name: '非工作时间大批量营销发送', name: '夜间累计发送量审核',
description: '营销任务在非工作时间且号码数超过阈值进入人工审核。', description: '同一企业应用在夜间累计业务短信超过阈值进入人工审核,所有入口与内容合并计数。',
metric: 'nonWorkingMarketingPhones', metric: 'nightSendingCount',
thresholdValue: 5000, thresholdValue: 5000,
action: 'manual_review', action: 'manual_review',
priority: 20, priority: 20,
@@ -118,6 +125,11 @@ export class RiskReviewService {
constructor(private readonly prisma: PrismaService) {} 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) { async listRules(applicationId?: string) {
await this.ensureDefaultRules(); await this.ensureDefaultRules();
return this.prisma.riskRule.findMany({ return this.prisma.riskRule.findMany({
@@ -152,10 +164,15 @@ export class RiskReviewService {
description: definition.description, description: definition.description,
metric: definition.metric!, metric: definition.metric!,
thresholdValue: data.thresholdValue, 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', status: data.status ?? 'active',
priority: data.priority ?? definition.priority ?? 100, 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: { include: {
application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } }, application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } },
@@ -182,9 +199,15 @@ export class RiskReviewService {
action: data.action, action: data.action,
status: data.status, status: data.status,
priority: data.priority, priority: data.priority,
config: data.config === undefined config:
data.config === undefined
? 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: { include: {
application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } }, application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } },
@@ -209,18 +232,19 @@ export class RiskReviewService {
status, status,
createdAt: shanghaiDateRange(submittedAtFrom, submittedAtTo), createdAt: shanghaiDateRange(submittedAtFrom, submittedAtTo),
...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}), ...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}),
...(!status ? { ...(!status
? {
OR: [{ status: 'pending_review' }, { reviewedById: { not: null } }],
}
: {}),
...(status === 'pending_review'
? {
OR: [ OR: [
{ status: 'pending_review' }, { sourceType: { notIn: ['cmpp_template_mismatch', NIGHT_REVIEW_SOURCE] } },
{ reviewedById: { not: null } },
],
} : {}),
...(status === 'pending_review' ? {
OR: [
{ sourceType: { not: 'cmpp_template_mismatch' } },
{ windowEndsAt: { lte: new Date() } }, { windowEndsAt: { lte: new Date() } },
], ],
} : {}), }
: {}),
}, },
include: { include: {
riskHits: true, riskHits: true,
@@ -231,11 +255,15 @@ export class RiskReviewService {
}, },
orderBy: { createdAt: 'desc' }, 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) } }, where: { riskTaskId: { in: tasks.map((task) => task.id) } },
select: { id: true, taskNo: true, riskTaskId: true }, 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 })); 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 normalizedPage = Math.max(1, Math.floor(page || 1));
const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(pageSize || 20))); const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(pageSize || 20)));
const where: Prisma.SmsMessageRecordWhereInput = { const where: Prisma.SmsMessageRecordWhereInput = {
OR: [ OR: [{ reviewTaskId: taskId }, { batchTask: { riskTaskId: taskId } }],
{ reviewTaskId: taskId },
{ batchTask: { riskTaskId: taskId } },
],
...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}), ...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}),
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
@@ -366,16 +391,8 @@ export class RiskReviewService {
reason: formatTemplateVariableIssueReason(variableIssues), 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, { const hits = this.evaluateRules(rules, {
phoneTotal, phoneTotal,
nonWorkingMarketingPhones,
recentTaskCount, recentTaskCount,
}); });
hits.push(...contentIssues.map(contentIssueToHit)); hits.push(...contentIssues.map(contentIssueToHit));
@@ -453,25 +470,29 @@ export class RiskReviewService {
// instead of silently weakening its foreign-key validation in the fast path. // instead of silently weakening its foreign-key validation in the fast path.
return Promise.all(items.map((item) => this.evaluateTask(item))); 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 templateIds = [...new Set(items.map((item) => item.templateId).filter((id): id is string => Boolean(id)))];
const [templates, sensitiveWords, applicationInputs] = await Promise.all([ const [templates, sensitiveWords, applicationInputs] = await Promise.all([
templateIds.length templateIds.length
? this.prisma.smsTemplate.findMany({ where: { id: { in: templateIds } }, include: { variables: true } }) ? this.prisma.smsTemplate.findMany({ where: { id: { in: templateIds } }, include: { variables: true } })
: Promise.resolve([]), : Promise.resolve([]),
this.prisma.sensitiveWord.findMany({ where: { status: 'active' }, select: { word: true, level: true } }), 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, applicationId,
rules: await this.effectiveRules(applicationId), rules: await this.effectiveRules(applicationId),
recentTaskCount: await this.countRecentClientTasks(applicationId, 'cmpp'), recentTaskCount: await this.countRecentClientTasks(applicationId, 'cmpp'),
}))), })),
),
]); ]);
const templateById = new Map(templates.map((template) => [template.id, template])); const templateById = new Map(templates.map((template) => [template.id, template]));
const inputsByApplication = new Map(applicationInputs.map((entry) => [entry.applicationId, entry])); 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 phones = data.phones ?? [];
const uniquePhones = [...new Set(phones)];
const phoneTotal = phones.length; const phoneTotal = phones.length;
const template = data.templateId ? templateById.get(data.templateId) : undefined; const template = data.templateId ? templateById.get(data.templateId) : undefined;
const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {}); 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 applicationInput = data.applicationId ? inputsByApplication.get(data.applicationId) : undefined;
const rules = applicationInput?.rules ?? []; 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, { const hits = this.evaluateRules(rules, {
phoneTotal, phoneTotal,
nonWorkingMarketingPhones,
recentTaskCount: applicationInput?.recentTaskCount ?? 0, recentTaskCount: applicationInput?.recentTaskCount ?? 0,
}); });
hits.push(...contentIssues.map(contentIssueToHit)); hits.push(...contentIssues.map(contentIssueToHit));
@@ -511,7 +525,8 @@ export class RiskReviewService {
reason: hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null, reason: hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null,
task: null, task: null,
}; };
})); }),
);
} }
async approveTask(taskId: string, data: ReviewSmsTaskDto) { async approveTask(taskId: string, data: ReviewSmsTaskDto) {
@@ -519,6 +534,7 @@ export class RiskReviewService {
if (!task) { if (!task) {
throw new NotFoundException('SMS send task not found'); throw new NotFoundException('SMS send task not found');
} }
if (task.sourceType === NIGHT_REVIEW_SOURCE) return this.reviewNightTask(taskId, 'approved', data);
this.assertAggregationWindowClosed(task); this.assertAggregationWindowClosed(task);
return this.prisma.smsSendTask.update({ return this.prisma.smsSendTask.update({
where: { id: taskId }, where: { id: taskId },
@@ -539,6 +555,7 @@ export class RiskReviewService {
if (!task) { if (!task) {
throw new NotFoundException('SMS send task not found'); throw new NotFoundException('SMS send task not found');
} }
if (task.sourceType === NIGHT_REVIEW_SOURCE) return this.reviewNightTask(taskId, 'rejected', data);
this.assertAggregationWindowClosed(task); this.assertAggregationWindowClosed(task);
const reason = data.reason ?? task.reviewReason ?? '审核拒绝'; const reason = data.reason ?? task.reviewReason ?? '审核拒绝';
return this.prisma.smsSendTask.update({ return this.prisma.smsSendTask.update({
@@ -611,11 +628,48 @@ export class RiskReviewService {
} }
private assertAggregationWindowClosed(task: { sourceType?: string | null; windowEndsAt?: Date | null }) { 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('聚合窗口尚未关闭,请在窗口结束后审核'); 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) { private async effectiveRules(applicationId?: string) {
const rules = await this.prisma.riskRule.findMany({ const rules = await this.prisma.riskRule.findMany({
where: { where: {
@@ -626,6 +680,8 @@ export class RiskReviewService {
}); });
const byCode = new Map<string, (typeof rules)[number]>(); const byCode = new Map<string, (typeof rules)[number]>();
for (const rule of rules) { 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)) { if (rule.applicationId || !byCode.has(rule.code)) {
byCode.set(rule.code, rule); byCode.set(rule.code, rule);
} }
@@ -651,7 +707,6 @@ export class RiskReviewService {
rules: Awaited<ReturnType<RiskReviewService['effectiveRules']>>, rules: Awaited<ReturnType<RiskReviewService['effectiveRules']>>,
metrics: { metrics: {
phoneTotal: number; phoneTotal: number;
nonWorkingMarketingPhones: number;
recentTaskCount: number; recentTaskCount: number;
}, },
): RuleEvaluation[] { ): RuleEvaluation[] {
@@ -659,9 +714,7 @@ export class RiskReviewService {
for (const rule of rules) { for (const rule of rules) {
const threshold = rule.thresholdValue; const threshold = rule.thresholdValue;
const actualValue = metricValue(rule.metric, metrics); const actualValue = metricValue(rule.metric, metrics);
const shouldHit = rule.code === 'TASK_CREATE_FREQUENCY' const shouldHit = rule.code === 'TASK_CREATE_FREQUENCY' ? actualValue >= threshold : actualValue > threshold;
? actualValue >= threshold
: actualValue > threshold;
if (!shouldHit) { if (!shouldHit) {
continue; continue;
} }
@@ -687,8 +740,16 @@ export class RiskReviewService {
throw new BadRequestException('风控阈值必须是大于等于0的有效数字'); throw new BadRequestException('风控阈值必须是大于等于0的有效数字');
} }
if ( if (
isPhoneFrequencyRule(data.code) data.code === NIGHT_RULE_CODE &&
&& (!Number.isInteger(data.thresholdValue) || data.thresholdValue < 1 || data.action === 'manual_review') (!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的整数,首版处理动作固定为直接拒绝'); throw new BadRequestException('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝');
} }
@@ -735,6 +796,7 @@ export class RiskReviewService {
const startTime = String(config?.startTime ?? '21:00'); const startTime = String(config?.startTime ?? '21:00');
const endTime = String(config?.endTime ?? '08:00'); const endTime = String(config?.endTime ?? '08:00');
const timeZone = String(config?.timeZone ?? 'Asia/Shanghai'); const timeZone = String(config?.timeZone ?? 'Asia/Shanghai');
if (timeZone !== 'Asia/Shanghai') throw new BadRequestException('夜间发送规则使用北京时间');
if (!isClockTime(startTime) || !isClockTime(endTime) || startTime === endTime) { if (!isClockTime(startTime) || !isClockTime(endTime) || startTime === endTime) {
throw new BadRequestException('非工作时间必须是两个不同的 HH:mm 时间'); throw new BadRequestException('非工作时间必须是两个不同的 HH:mm 时间');
} }
@@ -745,6 +807,36 @@ export class RiskReviewService {
} }
return { startTime, endTime, timeZone }; 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) { function isPhoneFrequencyRule(code: string) {
@@ -762,53 +854,16 @@ function isBasicMobileNumber(phone: string) {
return /^1\d{10}$/.test(phone); 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) { function jsonObject(config: Prisma.JsonValue | null | undefined) {
return config && typeof config === 'object' && !Array.isArray(config) return config && typeof config === 'object' && !Array.isArray(config)
? config as Record<string, unknown> ? (config as Record<string, unknown>)
: undefined; : 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) { function isClockTime(value: string) {
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value); 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( function evaluateTemplateVariables(
templateVariables: Array<{ name: string; required: boolean }>, templateVariables: Array<{ name: string; required: boolean }>,
content: string, content: string,
@@ -833,13 +888,18 @@ function formatTemplateVariableIssueReason(issues: Array<{ type: string; name: s
const details = [ const details = [
missing.length > 0 ? `缺少必填变量:${missing.join('、')}` : '', missing.length > 0 ? `缺少必填变量:${missing.join('、')}` : '',
extra.length > 0 ? `包含模板未定义变量:${extra.join('、')}` : '', extra.length > 0 ? `包含模板未定义变量:${extra.join('、')}` : '',
].filter(Boolean).join(''); ]
.filter(Boolean)
.join('');
return `模板变量校验失败(${details}),本次提交拒绝`; return `模板变量校验失败(${details}),本次提交拒绝`;
} }
function evaluateContent(content: string, sensitiveWords: Array<{ word: string; level: string }>) { function evaluateContent(content: string, sensitiveWords: Array<{ word: string; level: string }>) {
const issues: RuleEvaluation[] = []; 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) { if (controlMatches.length > 0) {
issues.push({ issues.push({
ruleCode: 'CONTENT_CONTROL_CHAR', 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
+337 -119
View File
@@ -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 { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis'; import IORedis from 'ioredis';
import { createHash, randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises'; import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money'; import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service'; import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service'; import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.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 type { SendJob, 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 {
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'; import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
type PendingSendBatchItem = { type PendingSendBatchItem = {
@@ -34,6 +48,8 @@ export class SendGatewaySubmitService {
private sendQueueMetricsTimer?: ReturnType<typeof setInterval>; private sendQueueMetricsTimer?: ReturnType<typeof setInterval>;
private submitOutboxTimer?: ReturnType<typeof setInterval>; private submitOutboxTimer?: ReturnType<typeof setInterval>;
private submitOutboxRunning = false; private submitOutboxRunning = false;
private nightReviewRecoveryTimer?: ReturnType<typeof setInterval>;
private nightReviewRecovering = false;
private readonly submitOutboxLeaseOwner = `send-worker-${process.pid}-${randomUUID()}`; private readonly submitOutboxLeaseOwner = `send-worker-${process.pid}-${randomUUID()}`;
private sendWorkerInFlight = 0; private sendWorkerInFlight = 0;
private sendWorkerConfiguredSlots = 0; private sendWorkerConfiguredSlots = 0;
@@ -56,6 +72,7 @@ export class SendGatewaySubmitService {
) {} ) {}
async onModuleDestroy() { async onModuleDestroy() {
if (this.nightReviewRecoveryTimer) clearInterval(this.nightReviewRecoveryTimer);
if (this.sendQueueMetricsTimer) clearInterval(this.sendQueueMetricsTimer); if (this.sendQueueMetricsTimer) clearInterval(this.sendQueueMetricsTimer);
if (this.submitOutboxTimer) clearInterval(this.submitOutboxTimer); if (this.submitOutboxTimer) clearInterval(this.submitOutboxTimer);
if (this.sendBatchTimer) clearTimeout(this.sendBatchTimer); if (this.sendBatchTimer) clearTimeout(this.sendBatchTimer);
@@ -67,7 +84,13 @@ export class SendGatewaySubmitService {
} }
private releaseMessageReservation( 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, remark: string,
) { ) {
return this.callbacks.releaseMessageReservation(message, remark); return this.callbacks.releaseMessageReservation(message, remark);
@@ -90,17 +113,20 @@ export class SendGatewaySubmitService {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); 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) { if (preparedMessage) {
// CMPP内部任务在当前请求内刚完成持久化且不暴露取消入口,可安全复用已知ID; // CMPP内部任务在当前请求内刚完成持久化且不暴露取消入口,可安全复用已知ID;
// 普通批量任务仍走下方查询路径,以保留取消检查和多消息枚举语义。 // 普通批量任务仍走下方查询路径,以保留取消检查和多消息枚举语义。
const queuePriority = normalizeQueuePriority(preparedMessage.queuePriority); 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, jobId: preparedMessage.messageRecordId,
attempts: 3, attempts: 3,
priority: BULLMQ_PRIORITY[queuePriority], priority: BULLMQ_PRIORITY[queuePriority],
}); },
);
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } }); await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
return { taskId, enqueued: 1 }; return { taskId, enqueued: 1 };
} }
@@ -119,11 +145,15 @@ async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: stri
const queue = this.facade.getSendQueue(); const queue = this.facade.getSendQueue();
for (const message of messages) { for (const message of messages) {
const queuePriority = normalizeQueuePriority(message.queuePriority); const queuePriority = normalizeQueuePriority(message.queuePriority);
await queue.add('send-message', { messageRecordId: message.id }, { await queue.add(
'send-message',
{ messageRecordId: message.id },
{
jobId: message.id, jobId: message.id,
attempts: 3, attempts: 3,
priority: BULLMQ_PRIORITY[queuePriority], priority: BULLMQ_PRIORITY[queuePriority],
}); },
);
} }
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } }); await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
return { taskId, enqueued: messages.length }; return { taskId, enqueued: messages.length };
@@ -133,9 +163,11 @@ startWorker() {
if (this.worker) { if (this.worker) {
return { status: 'already_started' }; return { status: 'already_started' };
} }
this.nightReviewRecoveryTimer = setInterval(() => void this.recoverNightReviews(), 15_000);
const connection = bullmqConnection(); const connection = bullmqConnection();
const configuredConcurrency = Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20); 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.metrics?.setSendWorkerSlots(this.sendWorkerConfiguredSlots, this.sendWorkerInFlight);
this.worker = new Worker<SendJob>( this.worker = new Worker<SendJob>(
SEND_QUEUE, SEND_QUEUE,
@@ -227,10 +259,12 @@ startSubmitOutboxPublisher() {
return new Map([[jobs[0].messageRecordId, await this.processSendJob(jobs[0])]]); return new Map([[jobs[0].messageRecordId, await this.processSendJob(jobs[0])]]);
} }
const ids = [...new Set(jobs.map((job) => job.messageRecordId))]; 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 } }, where: { id: { in: ids } },
include: { batchTask: true, template: { include: { signature: true } }, signature: true }, include: { batchTask: true, template: { include: { signature: true } }, signature: true },
})); }),
);
const messageById = new Map(messages.map((message) => [message.id, message])); const messageById = new Map(messages.map((message) => [message.id, message]));
const results = new Map<string, unknown>(); const results = new Map<string, unknown>();
const businessMessages = messages.filter((message) => { const businessMessages = messages.filter((message) => {
@@ -239,21 +273,31 @@ startSubmitOutboxPublisher() {
return false; return false;
} }
return true; 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 }); for (const id of ids) if (!messageById.has(id)) results.set(id, { skipped: true });
if (businessMessages.length === 0) return results; 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 (failed.length > 0) await this.failRouteBatch(failed, results);
if (planned.length === 0) return results; if (planned.length === 0) return results;
await Promise.all(planned.map(({ routed }) => ( await Promise.all(
this.facade.waitForChannelRateLimit(routed.channel.id, routed.channel.rateLimitPerSecond) planned.map(({ routed }) =>
))); this.facade.waitForChannelRateLimit(routed.channel.id, routed.channel.rateLimitPerSecond),
),
);
const sessionByChannel = new Map<string, string>(); 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)); sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId));
})); }),
);
const prepared = planned.map(({ message, routed }) => { const prepared = planned.map(({ message, routed }) => {
const submitId = `SUB-${randomUUID()}`; const submitId = `SUB-${randomUUID()}`;
const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension); const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension);
@@ -266,7 +310,8 @@ startSubmitOutboxPublisher() {
}; };
}); });
const writeOutbox = this.submitOutboxEnabled(); 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({ await tx.smsSubmitRecord.createMany({
data: prepared.map(({ message, routed, submitId, sessionId }) => ({ data: prepared.map(({ message, routed, submitId, sessionId }) => ({
id: randomUUID(), id: randomUUID(),
@@ -283,10 +328,14 @@ startSubmitOutboxPublisher() {
costAmountCents: moneyToNumber(routed.channel.unitPrice) * Math.max(1, message.billingUnits ?? 1), 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, ${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text,
${routed.province ?? null}::text, ${submitId}::text ${routed.province ?? null}::text, ${submitId}::text
)`)); )`,
),
);
await tx.$executeRaw(Prisma.sql` await tx.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message UPDATE "SmsMessageRecord" AS message
SET "channelId" = updates."channelId", SET "channelId" = updates."channelId",
@@ -305,45 +354,73 @@ startSubmitOutboxPublisher() {
if (writeOutbox) { if (writeOutbox) {
await tx.gatewaySubmitOutbox.createMany({ await tx.gatewaySubmitOutbox.createMany({
data: prepared.map(({ message, routed, submitId, command }) => ({ data: prepared.map(({ message, routed, submitId, command }) => ({
id: randomUUID(), submitId, messageRecordId: message.id, id: randomUUID(),
channelId: routed.channel.id, payload: command as Prisma.InputJsonValue, submitId,
messageRecordId: message.id,
channelId: routed.channel.id,
payload: command as Prisma.InputJsonValue,
})), })),
}); });
} }
})); }),
);
if (!this.submitOutboxPublishEnabled()) { if (!this.submitOutboxPublishEnabled()) {
await Promise.all(prepared.map(({ command }) => this.facade.publishGatewaySubmitCommand(command))); await Promise.all(prepared.map(({ command }) => this.facade.publishGatewaySubmitCommand(command)));
} }
await this.refreshTaskProgressBatch(prepared.map(({ message }) => message)); await this.refreshTaskProgressBatch(prepared.map(({ message }) => message));
for (const { message, routed, submitId } of prepared) { for (const { message, routed, submitId } of prepared) {
results.set(message.id, { 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'); this.metrics?.recordSendWorkerResult('completed');
} }
return results; return results;
} }
private async planRoutesBatch<T extends { private async planRoutesBatch<
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null; T extends {
templateId?: string | null; signatureId?: string | null; phoneNumber: string; id: string;
carrier?: string | null; province?: string | null; 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; template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null; signature?: { id?: string | null } | null;
}>(messages: T[]) { },
>(messages: T[]) {
const unresolvedPhones = messages.filter((message) => !message.carrier).map((message) => message.phoneNumber); const unresolvedPhones = messages.filter((message) => !message.carrier).map((message) => message.phoneNumber);
const provinces = await this.measureSendStage('phone_routing', () => this.phoneRouting.identifyProvinces(unresolvedPhones)); const provinces = await this.measureSendStage('phone_routing', () =>
const routeInputs = await Promise.all(messages.map(async (message) => ({ this.phoneRouting.identifyProvinces(unresolvedPhones),
);
const routeInputs = await Promise.all(
messages.map(async (message) => ({
message, message,
carrier: message.carrier ? normalizeCarrier(message.carrier) : normalizeCarrier(await this.phoneRouting.identifyCarrier(message.phoneNumber)), carrier: message.carrier
province: message.carrier ? message.province ?? null : provinces.get(message.phoneNumber) ?? null, ? 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, signatureId: message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null,
}))); })),
);
const valid = routeInputs.filter((input) => input.message.applicationId && input.signatureId); const valid = routeInputs.filter((input) => input.message.applicationId && input.signatureId);
const signatures = [...new Set(valid.map((input) => input.signatureId as string))]; 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: { where: {
status: 'active', channelId: null, province: null, status: 'active',
channelId: null,
province: null,
OR: valid.map((input) => ({ OR: valid.map((input) => ({
tenantId: input.message.tenantId, tenantId: input.message.tenantId,
applicationId: input.message.applicationId, applicationId: input.message.applicationId,
@@ -358,7 +435,9 @@ startSubmitOutboxPublisher() {
channel: { channel: {
include: { include: {
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } }, 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' }, orderBy: { priority: 'asc' },
})); }),
const routeByKey = new Map<string, typeof routes[number]>(); );
const routeByKey = new Map<string, (typeof routes)[number]>();
for (const route of routes) { for (const route of routes) {
const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`; const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`;
if (!routeByKey.has(key)) routeByKey.set(key, route); if (!routeByKey.has(key)) routeByKey.set(key, route);
@@ -394,11 +474,17 @@ startSubmitOutboxPublisher() {
failed.push({ message: input.message, reason: '企业应用绑定的通道组已停用或运营商不一致' }); failed.push({ message: input.message, reason: '企业应用绑定的通道组已停用或运营商不一致' });
continue; continue;
} }
const approvedItems = route.group.items.filter((item) => item.channel.status === 'active' const approvedItems = route.group.items.filter(
&& item.channel.connectionStates.length > 0 (item) =>
&& item.channel.reportTasks.some((task) => task.signatureId === input.signatureId item.channel.status === 'active' &&
&& (task.carrier === input.carrier item.channel.connectionStates.length > 0 &&
|| (process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')))); 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, { const selected = selectChannelCandidate(approvedItems, {
carrier: input.carrier, carrier: input.carrier,
province: input.province, province: input.province,
@@ -414,8 +500,10 @@ startSubmitOutboxPublisher() {
message: input.message, message: input.message,
routed: { routed: {
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) }, channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier: input.carrier, province: input.province, carrier: input.carrier,
groupId: route.groupId, groupName: route.group.name, province: input.province,
groupId: route.groupId,
groupName: route.group.name,
routeScope: isNationalChannel(selected) ? 'national' : 'province', routeScope: isNationalChannel(selected) ? 'national' : 'province',
}, },
}); });
@@ -423,43 +511,67 @@ startSubmitOutboxPublisher() {
return { planned, failed }; return { planned, failed };
} }
private async failRouteBatch<T extends { private async failRouteBatch<
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null; T extends {
messageId: string; phoneNumber: string; amountCents: bigint; billingUnits: number; id: string;
cmppSubmitSequenceId?: string | null; cmppSubmitGroupMessageId?: string | null; 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; 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` await this.prisma.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message UPDATE "SmsMessageRecord" AS message
SET status = 'failed', "errorMessage" = failures.reason, "updatedAt" = (NOW() AT TIME ZONE 'UTC') SET status = 'failed', "errorMessage" = failures.reason, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${values}) AS failures(id, reason) FROM (VALUES ${values}) AS failures(id, reason)
WHERE message.id = failures.id AND message.status = 'queued' 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); await this.releaseMessageReservation(message, reason);
if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, 'ROUTE', reason); if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, 'ROUTE', reason);
else await this.facade.refreshTaskProgress(message.batchTaskId); else await this.facade.refreshTaskProgress(message.batchTaskId);
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason }); results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
this.metrics?.recordSendWorkerResult('failed'); this.metrics?.recordSendWorkerResult('failed');
})); }),
);
} }
private async refreshTaskProgressBatch(messages: Array<{ private async refreshTaskProgressBatch(
batchTaskId: string; batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null; messages: Array<{
}>) { batchTaskId: string;
const singleCmppIds = [...new Set(messages batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
}>,
) {
const singleCmppIds = [
...new Set(
messages
.filter((message) => message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1) .filter((message) => message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1)
.map((message) => message.batchTaskId))]; .map((message) => message.batchTaskId),
),
];
if (singleCmppIds.length > 0) { if (singleCmppIds.length > 0) {
await this.prisma.smsBatchTask.updateMany({ await this.prisma.smsBatchTask.updateMany({
where: { id: { in: singleCmppIds }, sourceType: 'cmpp', phoneTotal: 1 }, where: { id: { in: singleCmppIds }, sourceType: 'cmpp', phoneTotal: 1 },
data: singleMessageTaskProgress('submit_queued'), data: singleMessageTaskProgress('submit_queued'),
}); });
} }
const otherTaskIds = [...new Set(messages const otherTaskIds = [
...new Set(
messages
.filter((message) => !singleCmppIds.includes(message.batchTaskId)) .filter((message) => !singleCmppIds.includes(message.batchTaskId))
.map((message) => message.batchTaskId))]; .map((message) => message.batchTaskId),
),
];
await Promise.all(otherTaskIds.map((taskId) => this.facade.refreshTaskProgress(taskId))); await Promise.all(otherTaskIds.map((taskId) => this.facade.refreshTaskProgress(taskId)));
} }
@@ -470,15 +582,21 @@ startSubmitOutboxPublisher() {
if (totalFinished) return; if (totalFinished) return;
totalFinished = true; totalFinished = true;
if (totalStartedAt != null) { 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); this.metrics?.recordSendWorkerResult(result);
}; };
try { 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 }, where: { id: job.messageRecordId },
include: { batchTask: true, template: { include: { signature: true } }, signature: true }, include: { batchTask: true, template: { include: { signature: true } }, signature: true },
})); }),
);
if (!message || message.status !== 'queued') { if (!message || message.status !== 'queued') {
finish('skipped'); finish('skipped');
return { skipped: true }; return { skipped: true };
@@ -488,6 +606,14 @@ startSubmitOutboxPublisher() {
return { skipped: true, reason: 'standalone channel test message' }; return { skipped: true, reason: 'standalone channel test message' };
} }
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; 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 { try {
const routed = await this.facade.selectChannelForMessage(businessMessage); const routed = await this.facade.selectChannelForMessage(businessMessage);
const result = await this.facade.submitMessageToGateway(businessMessage, routed, 0); const result = await this.facade.submitMessageToGateway(businessMessage, routed, 0);
@@ -543,13 +669,16 @@ startSubmitOutboxPublisher() {
) { ) {
const channel = routed.channel; const channel = routed.channel;
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension); 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 submitId = `SUB-${randomUUID()}`;
const sessionId = await this.getOpenSubmitSessionId(channel.id); const sessionId = await this.getOpenSubmitSessionId(channel.id);
const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId); const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId);
const writeOutbox = this.submitOutboxEnabled(); const writeOutbox = this.submitOutboxEnabled();
try { 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({ await tx.smsSubmitRecord.create({
data: { data: {
tenantId: message.tenantId, tenantId: message.tenantId,
@@ -577,7 +706,10 @@ startSubmitOutboxPublisher() {
submitStatus: 'queued', submitStatus: 'queued',
receiptStatus: null, receiptStatus: null,
errorCode: null, errorCode: null,
errorMessage: attempt > 0 ? `${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined, errorMessage:
attempt > 0
? `${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道`
: undefined,
}, },
}); });
if (writeOutbox) { if (writeOutbox) {
@@ -590,33 +722,34 @@ startSubmitOutboxPublisher() {
}, },
}); });
} }
})); }),
);
if (retryOfSubmitRecordId) { if (retryOfSubmitRecordId) {
this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({ this.logger.log(
`sms_retry_claim_acquired ${JSON.stringify({
messageId: message.messageId, messageId: message.messageId,
messageRecordId: message.id, messageRecordId: message.id,
retryOfSubmitRecordId, retryOfSubmitRecordId,
submitId, submitId,
channelId: channel.id, channelId: channel.id,
})}`); })}`,
);
} }
} catch (error) { } catch (error) {
if ( if (retryOfSubmitRecordId && error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
retryOfSubmitRecordId
&& error instanceof Prisma.PrismaClientKnownRequestError
&& error.code === 'P2002'
) {
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({ const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
where: { retryOfSubmitRecordId }, where: { retryOfSubmitRecordId },
}); });
if (existingRetry) { if (existingRetry) {
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({ this.logger.warn(
`sms_retry_claim_reused ${JSON.stringify({
messageId: message.messageId, messageId: message.messageId,
messageRecordId: message.id, messageRecordId: message.id,
retryOfSubmitRecordId, retryOfSubmitRecordId,
submitId: existingRetry.submitId, submitId: existingRetry.submitId,
channelId: existingRetry.channelId, channelId: existingRetry.channelId,
})}`); })}`,
);
return { return {
submitted: false, submitted: false,
duplicateRetry: true, duplicateRetry: true,
@@ -632,18 +765,28 @@ startSubmitOutboxPublisher() {
if (!this.submitOutboxPublishEnabled()) { if (!this.submitOutboxPublishEnabled()) {
await this.measureSendStage('gateway_stream_publish', () => this.facade.publishGatewaySubmitCommand(command)); 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.batchTaskId,
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'submit_queued' : undefined, message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'submit_queued' : undefined,
)); ),
);
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt }; return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
} }
private buildGatewaySubmitCommand( private buildGatewaySubmitCommand(
message: { message: {
id: string; tenantId: string; batchTaskId: string; applicationId?: string | null; id: string;
templateId?: string | null; messageId: string; phoneNumber: string; content: string; tenantId: string;
billingUnits: number; queuePriority?: string | null; applicationExtension?: string | null; 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; template?: { signature?: { name?: string | null } | null } | null;
signature?: { name?: string | null } | null; signature?: { name?: string | null } | null;
}, },
@@ -681,7 +824,8 @@ startSubmitOutboxPublisher() {
groupId: routed.groupId, groupId: routed.groupId,
}, },
cmpp: { 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) ? String(channel.config.serviceId)
: 'SMS', : 'SMS',
srcId: upstreamSrcId, srcId: upstreamSrcId,
@@ -702,15 +846,21 @@ startSubmitOutboxPublisher() {
connectionWarmupSeconds: getNonNegativeConfigInteger(channel.config, 'connectionWarmupSeconds', 30), connectionWarmupSeconds: getNonNegativeConfigInteger(channel.config, 'connectionWarmupSeconds', 30),
connectionDrainTimeoutSeconds: getPositiveConfigInteger(channel.config, 'connectionDrainTimeoutSeconds', 60), connectionDrainTimeoutSeconds: getPositiveConfigInteger(channel.config, 'connectionDrainTimeoutSeconds', 60),
submitResponseTimeoutSeconds: getPositiveConfigInteger(channel.config, 'submitResponseTimeoutSeconds', 60), submitResponseTimeoutSeconds: getPositiveConfigInteger(channel.config, 'submitResponseTimeoutSeconds', 60),
connectionFailureCooldownSeconds: getPositiveConfigInteger(channel.config, 'connectionFailureCooldownSeconds', 30), connectionFailureCooldownSeconds: getPositiveConfigInteger(
channel.config,
'connectionFailureCooldownSeconds',
30,
),
}, },
retry: { attempt, maxAttempts: 1 }, retry: { attempt, maxAttempts: 1 },
}; };
} }
private submitOutboxEnabled() { private submitOutboxEnabled() {
return process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED === 'true' return (
|| process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED === 'true'; process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED === 'true' ||
process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED === 'true'
);
} }
private submitOutboxPublishEnabled() { private submitOutboxPublishEnabled() {
@@ -723,7 +873,9 @@ startSubmitOutboxPublisher() {
try { try {
const batchSize = Math.min(500, getPositiveConfigInteger(process.env, 'SEND_SUBMIT_OUTBOX_BATCH_SIZE', 64)); 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 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 ( WITH candidates AS (
SELECT id SELECT id
FROM "GatewaySubmitOutbox" FROM "GatewaySubmitOutbox"
@@ -749,9 +901,13 @@ startSubmitOutboxPublisher() {
const results = this.submitOutboxPublishEnabled() const results = this.submitOutboxPublishEnabled()
? await this.publishGatewaySubmitCommandBatch(rows) ? await this.publishGatewaySubmitCommandBatch(rows)
: rows.map((row) => ({ row, streamEntryId: `shadow:${row.submitId}` })); : 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) { 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` await this.prisma.$executeRaw(Prisma.sql`
UPDATE "GatewaySubmitOutbox" AS outbox UPDATE "GatewaySubmitOutbox" AS outbox
SET status = 'published', SET status = 'published',
@@ -783,11 +939,15 @@ startSubmitOutboxPublisher() {
WHERE id = ${row.id} AND "leaseOwner" = ${this.submitOutboxLeaseOwner} WHERE id = ${row.id} AND "leaseOwner" = ${this.submitOutboxLeaseOwner}
`); `);
} catch (recordError) { } 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) { } 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 { } finally {
this.submitOutboxRunning = false; this.submitOutboxRunning = false;
} }
@@ -795,10 +955,9 @@ startSubmitOutboxPublisher() {
private async publishGatewaySubmitCommandBatch( private async publishGatewaySubmitCommandBatch(
rows: Array<{ id: string; submitId: string; payload: Prisma.JsonValue }>, rows: Array<{ id: string; submitId: string; payload: Prisma.JsonValue }>,
): Promise<Array< ): Promise<
{ row: typeof rows[number]; streamEntryId: string } Array<{ row: (typeof rows)[number]; streamEntryId: string } | { row: (typeof rows)[number]; error: unknown }>
| { row: typeof rows[number]; error: unknown } > {
>> {
const redis = this.facade.getRedis(); const redis = this.facade.getRedis();
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM; const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
const script = `local existing = redis.call('GET', KEYS[2]) const script = `local existing = redis.call('GET', KEYS[2])
@@ -821,13 +980,26 @@ return streamId`;
if (!replies || replies.length !== rows.length) { if (!replies || replies.length !== rows.length) {
return rows.map((row) => ({ row, error: new Error('Redis Outbox pipeline result count mismatch') })); 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], error }
: { row: rows[index], streamEntryId: typeof value === 'string' ? value : String(value ?? '') }); : { row: rows[index], streamEntryId: typeof value === 'string' ? value : String(value ?? '') },
);
} }
async selectChannelForMessage( 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 }, 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[] } = {}, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> { ): Promise<RoutedChannel> {
if (!message.applicationId) { if (!message.applicationId) {
@@ -836,7 +1008,7 @@ async selectChannelForMessage(
const hasPersistedRouting = Boolean(message.carrier); const hasPersistedRouting = Boolean(message.carrier);
const [carrier, province] = await this.measureSendStage('phone_routing', async () => { const [carrier, province] = await this.measureSendStage('phone_routing', async () => {
const resolved = hasPersistedRouting const resolved = hasPersistedRouting
? [normalizeCarrier(message.carrier), message.province ?? null] as const ? ([normalizeCarrier(message.carrier), message.province ?? null] as const)
: await Promise.all([ : await Promise.all([
this.facade.identifyCarrier(message.phoneNumber), this.facade.identifyCarrier(message.phoneNumber),
this.facade.identifyProvince(message.phoneNumber), this.facade.identifyProvince(message.phoneNumber),
@@ -849,14 +1021,13 @@ async selectChannelForMessage(
} }
return resolved; 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('短信签名未配置,无法选择已报备通道'); if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
const route = await this.measureSendStage('route_lookup', () => this.facade.findApplicationRoute( const route = await this.measureSendStage('route_lookup', () =>
message.tenantId, this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, carrier, signatureId),
message.applicationId ?? undefined, );
carrier,
signatureId,
));
const excluded = new Set(options.excludeChannelIds ?? []); const excluded = new Set(options.excludeChannelIds ?? []);
const approvedChannelIds = new Set(route.group.items.map((item) => item.channelId)); const approvedChannelIds = new Set(route.group.items.map((item) => item.channelId));
const selected = selectChannelCandidate(route.group.items, { const selected = selectChannelCandidate(route.group.items, {
@@ -895,7 +1066,9 @@ private async measureSendStage<T>(stage: SendWorkerStage, operation: () => Promi
private async refreshSendQueueMetrics() { private async refreshSendQueueMetrics() {
if (!this.metrics) return; if (!this.metrics) return;
try { 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]> = [ const mappings: Array<[SendWorkerQueueState, number]> = [
['waiting', counts.wait ?? 0], ['waiting', counts.wait ?? 0],
['active', counts.active ?? 0], ['active', counts.active ?? 0],
@@ -914,7 +1087,12 @@ private async refreshSendQueueMetrics() {
} }
} }
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 const approvedChannelWhere = signatureId
? { ? {
status: 'active', status: 'active',
@@ -1008,10 +1186,18 @@ 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; const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null;
if (direct || !message.templateId) return direct; 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; return template?.signature?.id ?? null;
} }
@@ -1053,6 +1239,7 @@ async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string
status = CASE status = CASE
WHEN message.status IN ('delivered', 'submit_failed', 'failed', 'timeout') THEN 'finished' WHEN message.status IN ('delivered', 'submit_failed', 'failed', 'timeout') THEN 'finished'
WHEN message.status IN ('submit_queued', 'submitted', 'unknown') THEN 'sending' WHEN message.status IN ('submit_queued', 'submitted', 'unknown') THEN 'sending'
WHEN message.status = 'pending_review' THEN 'pending_review'
ELSE 'queued' ELSE 'queued'
END, END,
"updatedAt" = (NOW() AT TIME ZONE 'UTC') "updatedAt" = (NOW() AT TIME ZONE 'UTC')
@@ -1101,7 +1288,14 @@ private async refreshTaskProgressUntilClean(batchTaskId: string) {
const unknownTotal = count(['unknown']); const unknownTotal = count(['unknown']);
const timeoutTotal = count(['timeout']); const timeoutTotal = count(['timeout']);
const doneTotal = successTotal + failedTotal + timeoutTotal; 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({ await this.prisma.smsBatchTask.update({
where: { id: batchTaskId }, where: { id: batchTaskId },
data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status }, data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status },
@@ -1109,6 +1303,20 @@ private async refreshTaskProgressUntilClean(batchTaskId: string) {
} while (this.dirtyTaskProgressRefreshes.has(batchTaskId)); } while (this.dirtyTaskProgressRefreshes.has(batchTaskId));
} }
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'> { getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
if (!this.sendQueue) { if (!this.sendQueue) {
this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() }); this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() });
@@ -1158,15 +1366,23 @@ return streamId`,
const cached = this.openSubmitSessionIds.get(channelId); const cached = this.openSubmitSessionIds.get(channelId);
if (cached) return cached; if (cached) return cached;
const sessionNo = `OPEN-${channelId}`; const sessionNo = `OPEN-${channelId}`;
const pending = this.prisma.cmppSubmitSession.findUnique({ const pending = this.prisma.cmppSubmitSession
.findUnique({
where: { sessionNo }, where: { sessionNo },
select: { id: true }, select: { id: true },
}).then((existing) => existing ?? this.prisma.cmppSubmitSession.upsert({ })
.then(
(existing) =>
existing ??
this.prisma.cmppSubmitSession.upsert({
where: { sessionNo }, where: { sessionNo },
update: {}, update: {},
create: { channelId, sessionNo, submitTotal: 0 }, create: { channelId, sessionNo, submitTotal: 0 },
select: { id: true }, select: { id: true },
})).then((session) => session.id).catch((error) => { }),
)
.then((session) => session.id)
.catch((error) => {
this.openSubmitSessionIds.delete(channelId); this.openSubmitSessionIds.delete(channelId);
throw error; throw error;
}); });
@@ -1176,7 +1392,9 @@ return streamId`,
} }
function singleMessageTaskProgress(status: string) { 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 successTotal = status === 'delivered' ? 1 : 0;
const failedTotal = ['submit_failed', 'failed'].includes(status) ? 1 : 0; const failedTotal = ['submit_failed', 'failed'].includes(status) ? 1 : 0;
const unknownTotal = status === 'unknown' ? 1 : 0; const unknownTotal = status === 'unknown' ? 1 : 0;
@@ -1,18 +1,14 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { randomUUID } from 'node:crypto';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { createHash, randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service'; 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 { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service'; import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.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'; import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
/** /**
@@ -32,7 +28,13 @@ export class SendReviewContinuationService {
) {} ) {}
private releaseMessageReservation( 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, remark: string,
) { ) {
return this.callbacks.releaseMessageReservation(message, remark); return this.callbacks.releaseMessageReservation(message, remark);
@@ -55,7 +57,6 @@ export class SendReviewContinuationService {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); 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({ const reviewTask = await this.prisma.smsSendTask.findUnique({
where: { id: reviewTaskId }, where: { id: reviewTaskId },
@@ -63,13 +64,11 @@ async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejecte
if (!reviewTask) { if (!reviewTask) {
return { reviewTaskId, decision, affected: 0 }; return { reviewTaskId, decision, affected: 0 };
} }
if (reviewTask.sourceType === 'night_sending_bulk') return this.continueNightReview(reviewTaskId, decision, reason);
const messageRecords = await this.prisma.smsMessageRecord.findMany({ const messageRecords = await this.prisma.smsMessageRecord.findMany({
where: { where: {
status: 'pending_review', status: 'pending_review',
OR: [ OR: [{ reviewTaskId }, { batchTask: { riskTaskId: reviewTaskId } }],
{ reviewTaskId },
{ batchTask: { riskTaskId: reviewTaskId } },
],
}, },
include: { batchTask: true }, include: { batchTask: true },
}); });
@@ -106,4 +105,94 @@ async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejecte
} }
return { reviewTaskId, decision, affected: messageRecords.length }; 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兼容字段,整数上限时保持相对顺序压紧当前草稿后追加,不将新增项插到前面。 - 符合发送资格的全国通道按列表顺序首次尝试和补发,跳过已尝试、未报备和不可用通道;省网优先及失败转全国规则保持。优先级仅作为API兼容字段,整数上限时保持相对顺序压紧当前草稿后追加,不将新增项插到前面。
## 2026-09-07 夜间累计发送量审核
原“非工作时间大批量营销发送”替换为每企业应用每夜累计所有业务短信的人工审核规则,所有企业应用默认通用5000条,支持应用阈值覆盖;不依赖分类、内容或任务名。CMPP、公开HTTP、客户端合并计数,第5001条及之后待审;分片/内部重试/补发/幂等重试不重复累计。统一在首次发送Worker提交前拦截,含定时任务到期及CMPP快速入队路径。短信审核复用现有字段、号码明细、详情和批量审核,按同应用同内容10秒窗口聚合,窗口关闭后审核;批准只释放该任务消息,不豁免后续发送。时间跨午夜连续,默认21:00至次日08:00(北京时间),夜间改时间待本夜结束生效,阈值修改不清零。详见phase-6-risk-review-plan.md的2026-09-07章节;该章节替代旧营销识别和单任务阈值语义。
+10
View File
@@ -59,3 +59,13 @@ api/src/risk-review/
3. 直接拒绝的任务必须向客户端返回可读原因。 3. 直接拒绝的任务必须向客户端返回可读原因。
4. `npm run verify:phase6` 通过。 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与三尺寸页面验收分别留证。
+16
View File
@@ -5202,3 +5202,19 @@ npm run verify:phase8
| TC-REPORT-WPS-007 | 上述残留节点仍被另一单元格DISPIMG引用 | 两种模式均拒绝,提示实际损坏单元格及工作表、重新插入/清空建议;不得误报相邻正常图片,不进行部分业务落库 | | TC-REPORT-WPS-007 | 上述残留节点仍被另一单元格DISPIMG引用 | 两种模式均拒绝,提示实际损坏单元格及工作表、重新插入/清空建议;不得误报相邻正常图片,不进行部分业务落库 |
| TC-REPORT-WPS-008 | 已引用图片ID重复,或图片relationship缺失/重复/External,或包内媒体缺失 | 明确拒绝并提示工作表及单元格;不后写覆盖重复ID、不读取外部图片、不静默跳过真实缺失图片 | | TC-REPORT-WPS-008 | 已引用图片ID重复,或图片relationship缺失/重复/External,或包内媒体缺失 | 明确拒绝并提示工作表及单元格;不后写覆盖重复ID、不读取外部图片、不静默跳过真实缺失图片 |
| TC-REPORT-WPS-009 | 真实故障文件及仅清空损坏单元格的内存副本,保留残留cellImage节点 | 原文件准确定位N7;内存副本33张有效图片位置与独立XML解析结果一致,提交模式33张SHA256全部匹配;原始文件保持不变,不据此冒充线上导入成功 | | 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仅清理本次随机名称。它验证真实持久化与队列恢复,不能冒充实际运营商发送或公网协议压测。
+10
View File
@@ -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回归校验,未为此改动既有类型配置。 - 新增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张全部逐一匹配;原始文件摘要不变,未生成或上传替换业务表格。 - 当前修复代码在本机读取此前只读取得的真实原始文件:两种模式均准确拒绝N7;只在内存清空N7公式、保留损坏cellImage节点后,两模式读取33张图片且不串位。独立Python ElementTree解析原始OOXML建立期望位置/图片SHA256,字节模式33张全部逐一匹配;原始文件摘要不变,未生成或上传替换业务表格。
- 证据在本机%TEMP%/cmpp-wps-diagnosis-20260907api-full.log、independent-image-hashes.json、real-file-verification.json及受控原始文件。真实客户文件不进入Git。此前浏览器只读状态核验尝试登录返回401,未取得该项浏览器证据;本轮未复试、重置账号或修改服务器。已依据PG持久失败记录、真实MinIO文件、运行解析器与本地修复解析结果完成复现/回归;新版本的线上API/Worker/浏览器验收待另行授权部署后执行,不将本地文件回归称为线上导入成功。 - 证据在本机%TEMP%/cmpp-wps-diagnosis-20260907api-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-20260907PG/Redis隔离结果/tmp/night-pg-verification.log。最终门禁及发布后真实页面/服务/队列验收另记下节;尚未执行真实短信发送、供应商压测或实际备份恢复。
+24 -3
View File
@@ -36,7 +36,12 @@ export type RiskRuleItem = {
id: string; id: string;
tenantId?: string | null; tenantId?: string | null;
applicationId?: 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; name: string;
description?: string | null; description?: string | null;
metric: string; metric: string;
@@ -44,7 +49,15 @@ export type RiskRuleItem = {
action: 'block' | 'manual_review'; action: 'block' | 'manual_review';
status: 'active' | 'inactive'; status: 'active' | 'inactive';
priority: number; 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; application?: { id: string; name: string; tenantId: string; tenant?: { id: string; name: string } } | null;
updatedAt: string; updatedAt: string;
}; };
@@ -119,7 +132,15 @@ export type DrainageDetectionRule = {
export type DrainageDetectionResult = { export type DrainageDetectionResult = {
hasDrainageContent: boolean; hasDrainageContent: boolean;
drainageDetection: { 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[]; categories: string[];
truncated: boolean; truncated: boolean;
}; };
+526 -92
View File
@@ -23,7 +23,7 @@ import { formatDateTime } from '@/utils/dateTime';
const definitions: Array<{ code: RiskRuleItem['code']; label: string; unit: string }> = [ const definitions: Array<{ code: RiskRuleItem['code']; label: string; unit: string }> = [
{ code: 'MAX_PHONES_PER_TASK', label: '单任务最大号码数', unit: '个号码' }, { 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: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' },
{ code: 'PHONE_FREQUENCY_24H', label: '单号码24小时发送频次', unit: '条业务短信' }, { code: 'PHONE_FREQUENCY_24H', label: '单号码24小时发送频次', unit: '条业务短信' },
{ code: 'PHONE_FREQUENCY_5M', label: '单号码5分钟发送频次', unit: '条业务短信' }, { code: 'PHONE_FREQUENCY_5M', label: '单号码5分钟发送频次', unit: '条业务短信' },
@@ -96,27 +96,32 @@ export function AdminRiskRulesPage() {
Promise.all([ Promise.all([
adminApi.listRiskRules(applicationId || undefined), adminApi.listRiskRules(applicationId || undefined),
applications.length === 0 ? adminApi.listEnterpriseApplications() : Promise.resolve(applications), applications.length === 0 ? adminApi.listEnterpriseApplications() : Promise.resolve(applications),
]).then(([nextRules, nextApplications]) => { ])
.then(([nextRules, nextApplications]) => {
setRules(nextRules); setRules(nextRules);
setApplications(nextApplications); setApplications(nextApplications);
setError(''); setError('');
}).catch((failure: Error) => setError(failure.message || '风控规则加载失败')); })
.catch((failure: Error) => setError(failure.message || '风控规则加载失败'));
} }
useEffect(load, [applicationId]); useEffect(load, [applicationId]);
function loadFrequencyHits(page = hitPage, filters = { phoneNumber: hitPhone, status: hitStatus }) { function loadFrequencyHits(page = hitPage, filters = { phoneNumber: hitPhone, status: hitStatus }) {
adminApi.listPhoneFrequencyHits({ adminApi
.listPhoneFrequencyHits({
applicationId: applicationId || undefined, applicationId: applicationId || undefined,
phoneNumber: filters.phoneNumber.trim() || undefined, phoneNumber: filters.phoneNumber.trim() || undefined,
status: filters.status || undefined, status: filters.status || undefined,
page, page,
pageSize: 20, pageSize: 20,
}).then((result) => { })
.then((result) => {
setFrequencyHits(result.items); setFrequencyHits(result.items);
setHitTotal(result.total); setHitTotal(result.total);
setHitPage(result.page); setHitPage(result.page);
}).catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败')); })
.catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败'));
} }
useEffect(() => { useEffect(() => {
@@ -125,16 +130,19 @@ export function AdminRiskRulesPage() {
}, [applicationId]); }, [applicationId]);
function loadWhitelist(page = whitelistPage, filters = { phoneNumber: whitelistPhone, status: whitelistStatus }) { function loadWhitelist(page = whitelistPage, filters = { phoneNumber: whitelistPhone, status: whitelistStatus }) {
adminApi.listPhoneFrequencyWhitelist({ adminApi
.listPhoneFrequencyWhitelist({
phoneNumber: filters.phoneNumber.trim() || undefined, phoneNumber: filters.phoneNumber.trim() || undefined,
status: filters.status || undefined, status: filters.status || undefined,
page, page,
pageSize: 20, pageSize: 20,
}).then((result) => { })
.then((result) => {
setWhitelist(result.items); setWhitelist(result.items);
setWhitelistTotal(result.total); setWhitelistTotal(result.total);
setWhitelistPage(result.page); setWhitelistPage(result.page);
}).catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败')); })
.catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败'));
} }
useEffect(() => { useEffect(() => {
@@ -162,10 +170,15 @@ export function AdminRiskRulesPage() {
const phoneFrequencyRule = isPhoneFrequencyRule(editor.code); const phoneFrequencyRule = isPhoneFrequencyRule(editor.code);
const body = { const body = {
thresholdValue, 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, status: editor.status,
priority: Number(editor.priority) || 100, 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' } ? { startTime: editor.startTime, endTime: editor.endTime, timeZone: 'Asia/Shanghai' }
: undefined, : undefined,
}; };
@@ -261,55 +274,227 @@ export function AdminRiskRulesPage() {
} }
const columns: Array<TableColumn<RiskRuleItem>> = [ 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: 'name',
{ key: 'threshold', title: '阈值', width: '150px', render: (rule) => `${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}` }, title: '规则名称',
{ key: 'time', title: '生效时间', width: '180px', render: (rule) => { 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 '-'; if (rule.code !== 'NON_WORKING_MARKETING_BULK') return '-';
const start = rule.config?.startTime ?? '21:00'; const start = rule.config?.startTime ?? '21:00';
const end = rule.config?.endTime ?? '08: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}`; 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: '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>> = [ const hitColumns: Array<TableColumn<PhoneFrequencyHit>> = [
{ key: 'phone', title: '号码', width: '140px', render: (hit) => <strong>{hit.phoneNumber}</strong> }, { 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: 'scope',
{ key: 'window', title: '计数周期', width: '250px', render: (hit) => `${formatDateTime(hit.windowStartedAt)}${formatDateTime(hit.windowEndsAt)}` }, title: '企业 / 应用',
{ key: 'status', title: '状态', width: '100px', render: (hit) => hit.releasedAt render: (hit) => (
? <Tag tone="neutral"></Tag> <div>
: new Date(hit.windowEndsAt).getTime() <= Date.now() <strong>{hit.tenant.name}</strong>
? <Tag tone="warning"></Tag> <small className="table-subline">{hit.application.name}</small>
: <Tag tone="danger"></Tag> }, </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: 'createdAt', title: '触发时间', width: '170px', render: (hit) => formatDateTime(hit.createdAt) },
{ key: 'actions', title: '操作', width: '110px', align: 'right', render: (hit) => hit.releasedAt {
? <span className="muted"></span> key: 'actions',
: <Button icon={<Unlock size={15} />} onClick={() => { setReleaseHit(hit); setReleaseReason(''); }} size="sm" variant="ghost"></Button> }, 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>> = [ const whitelistColumns: Array<TableColumn<PhoneFrequencyWhitelistItem>> = [
{ key: 'phone', title: '手机号码', width: '145px', render: (item) => <strong>{item.phoneNumber}</strong> }, { 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: 'reason',
{ key: 'operator', title: '最后操作人', width: '150px', render: (item) => item.updatedBy.displayName || item.updatedBy.username }, 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: '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> key: 'actions',
: <div className="table-actions"> title: '操作',
<Button icon={<Pencil size={15} />} onClick={() => setWhitelistEditor({ 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, id: item.id,
phoneNumber: item.phoneNumber, phoneNumber: item.phoneNumber,
reason: item.reason, reason: item.reason,
remark: item.remark ?? '', remark: item.remark ?? '',
status: item.status === 'active' ? 'active' : 'inactive', 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)); const hitTotalPages = Math.max(1, Math.ceil(hitTotal / 20));
@@ -318,13 +503,25 @@ export function AdminRiskRulesPage() {
return ( return (
<section className="page-stack"> <section className="page-stack">
<div className="page-heading"> <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"> <div className="page-heading__actions">
<Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost"></Button> <Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost">
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}></Button>
</Button>
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}>
</Button>
</div> </div>
</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"> <div className="surface sms-audit-filter ui-filter-row">
<Select <Select
label="查看范围" label="查看范围"
@@ -339,17 +536,32 @@ export function AdminRiskRulesPage() {
value={applicationId} value={applicationId}
/> />
</div> </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="surface">
<div className="section-heading"> <div className="section-heading">
<div><h2></h2><p>245</p></div> <div>
<h2></h2>
<p>245</p>
</div>
<div className="page-heading__actions"> <div className="page-heading__actions">
<Tag tone="info">{whitelistTotal} </Tag> <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> </div>
<div className="sms-audit-filter ui-filter-row"> <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 <Select
label="白名单状态" label="白名单状态"
onChange={(event) => setWhitelistStatus(event.target.value as typeof whitelistStatus)} onChange={(event) => setWhitelistStatus(event.target.value as typeof whitelistStatus)}
@@ -362,11 +574,28 @@ export function AdminRiskRulesPage() {
value={whitelistStatus} value={whitelistStatus}
/> />
<div className="admin-task-filter__actions ui-filter-actions"> <div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}></Button> <Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}>
<Button onClick={() => { setWhitelistPhone(''); setWhitelistStatus(''); loadWhitelist(1, { phoneNumber: '', status: '' }); }} variant="ghost"></Button>
</Button>
<Button
onClick={() => {
setWhitelistPhone('');
setWhitelistStatus('');
loadWhitelist(1, { phoneNumber: '', status: '' });
}}
variant="ghost"
>
</Button>
</div> </div>
</div> </div>
<Table columns={whitelistColumns} data={whitelist} emptyText="暂无号码频控白名单" pagination={false} rowKey="id" /> <Table
columns={whitelistColumns}
data={whitelist}
emptyText="暂无号码频控白名单"
pagination={false}
rowKey="id"
/>
<Pagination <Pagination
nextDisabled={whitelistPage >= whitelistTotalPages} nextDisabled={whitelistPage >= whitelistTotalPages}
onNext={() => loadWhitelist(Math.min(whitelistTotalPages, whitelistPage + 1))} onNext={() => loadWhitelist(Math.min(whitelistTotalPages, whitelistPage + 1))}
@@ -380,11 +609,19 @@ export function AdminRiskRulesPage() {
</div> </div>
<div className="surface"> <div className="surface">
<div className="section-heading"> <div className="section-heading">
<div><h2></h2><p></p></div> <div>
<h2></h2>
<p></p>
</div>
<Tag tone="warning">{hitTotal} </Tag> <Tag tone="warning">{hitTotal} </Tag>
</div> </div>
<div className="sms-audit-filter ui-filter-row"> <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 <Select
label="记录状态" label="记录状态"
onChange={(event) => setHitStatus(event.target.value as typeof hitStatus)} onChange={(event) => setHitStatus(event.target.value as typeof hitStatus)}
@@ -397,11 +634,35 @@ export function AdminRiskRulesPage() {
value={hitStatus} value={hitStatus}
/> />
<div className="admin-task-filter__actions ui-filter-actions"> <div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={() => { setHitPage(1); loadFrequencyHits(1); }}></Button> <Button
<Button onClick={() => { setHitPhone(''); setHitStatus('active'); setHitPage(1); loadFrequencyHits(1, { phoneNumber: '', status: 'active' }); }} variant="ghost"></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>
</div> </div>
<Table columns={hitColumns} data={frequencyHits} emptyText="暂无号码频次触发记录" pagination={false} rowKey="id" /> <Table
columns={hitColumns}
data={frequencyHits}
emptyText="暂无号码频次触发记录"
pagination={false}
rowKey="id"
/>
<Pagination <Pagination
nextDisabled={hitPage >= hitTotalPages} nextDisabled={hitPage >= hitTotalPages}
onNext={() => loadFrequencyHits(Math.min(hitTotalPages, hitPage + 1))} onNext={() => loadFrequencyHits(Math.min(hitTotalPages, hitPage + 1))}
@@ -413,72 +674,245 @@ export function AdminRiskRulesPage() {
totalPages={hitTotalPages} totalPages={hitTotalPages}
/> />
</div> </div>
{editor ? <Modal {editor ? (
footer={<><Button disabled={saving} onClick={() => setEditor(null)} variant="ghost"></Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button></>} <Modal
footer={
<>
<Button disabled={saving} onClick={() => setEditor(null)} variant="ghost">
</Button>
<Button disabled={saving} onClick={() => void save()}>
{saving ? '保存中…' : '保存'}
</Button>
</>
}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
open open
size="xl" size="xl"
title={editor.id ? '编辑风控规则' : '新增企业应用级覆盖'} title={editor.id ? '编辑风控规则' : '新增企业应用级覆盖'}
> >
<div className="form-grid"> <div className="form-grid">
{!editor.id ? <Select {!editor.id ? (
<Select
label="企业应用" label="企业应用"
onChange={(event) => setEditor({ ...editor, applicationId: event.target.value })} 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} value={editor.applicationId}
/> : null} />
{!editor.id ? <Select ) : null}
{!editor.id ? (
<Select
label="规则" label="规则"
onChange={(event) => { onChange={(event) => {
const code = event.target.value as RiskRuleItem['code']; const code = event.target.value as RiskRuleItem['code'];
const globalRule = rules.find((rule) => !rule.applicationId && rule.code === 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} 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} /> <Input
<Select label="状态" onChange={(event) => setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} /> disabled
<Input label="优先级" min="1" onChange={(event) => setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} /> label="规则"
{editor.code === 'NON_WORKING_MARKETING_BULK' ? <> value={definitions.find((item) => item.code === editor.code)?.label ?? editor.code}
<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
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">
CMPPHTTP和客户端累计所有业务短信
</p>
</>
) : null}
</div> </div>
</Modal> : null} </Modal>
{whitelistEditor ? <Modal ) : null}
footer={<><Button disabled={whitelistSaving} onClick={() => setWhitelistEditor(null)} variant="ghost"></Button><Button disabled={whitelistSaving} onClick={() => void saveWhitelist()}>{whitelistSaving ? '保存中…' : '保存'}</Button></>} {whitelistEditor ? (
<Modal
footer={
<>
<Button disabled={whitelistSaving} onClick={() => setWhitelistEditor(null)} variant="ghost">
</Button>
<Button disabled={whitelistSaving} onClick={() => void saveWhitelist()}>
{whitelistSaving ? '保存中…' : '保存'}
</Button>
</>
}
onClose={() => setWhitelistEditor(null)} onClose={() => setWhitelistEditor(null)}
open open
title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'} title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'}
> >
<div className="form-grid"> <div className="form-grid">
<Input label="手机号码" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })} placeholder="中国大陆11位手机号码" value={whitelistEditor.phoneNumber} /> <Input
<Select label="状态" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, status: event.target.value as WhitelistEditorState['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={whitelistEditor.status} /> 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> </div>
<Textarea label="用途说明" maxLength={200} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, reason: event.target.value })} placeholder="必填,说明该号码为何需要豁免频控" rows={3} value={whitelistEditor.reason} /> <Textarea
<Textarea label="备注" maxLength={500} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, remark: event.target.value })} placeholder="选填" rows={3} value={whitelistEditor.remark} /> label="用途说明"
<p className="muted"></p> maxLength={200}
</Modal> : null} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, reason: event.target.value })}
{deletingWhitelist ? <Modal placeholder="必填,说明该号码为何需要豁免频控"
footer={<><Button disabled={whitelistSaving} onClick={() => setDeletingWhitelist(null)} variant="ghost"></Button><Button disabled={whitelistSaving} onClick={() => void confirmDeleteWhitelist()}>{whitelistSaving ? '处理中…' : '删除并清零'}</Button></>} 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)} onClose={() => setDeletingWhitelist(null)}
open open
title="删除号码频控白名单" title="删除号码频控白名单"
> >
<p> <strong>{deletingWhitelist.phoneNumber}</strong> </p> <p>
<Textarea label="删除原因" maxLength={500} onChange={(event) => setDeleteWhitelistReason(event.target.value)} placeholder="请填写删除原因" rows={4} value={deleteWhitelistReason} /> <strong>{deletingWhitelist.phoneNumber}</strong>{' '}
</Modal> : null}
{releaseHit ? <Modal </p>
footer={<><Button disabled={releasing} onClick={() => setReleaseHit(null)} variant="ghost"></Button><Button disabled={releasing} onClick={() => void confirmRelease()}>{releasing ? '处理中…' : '解除并清零'}</Button></>} <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)} onClose={() => setReleaseHit(null)}
open open
title="解除号码频控" title="解除号码频控"
> >
<p> <strong>{releaseHit.phoneNumber}</strong> {releaseHit.application.name}</p> <p>
<Textarea label="解除原因" onChange={(event) => setReleaseReason(event.target.value)} placeholder="请填写人工解除原因" rows={4} value={releaseReason} /> <strong>{releaseHit.phoneNumber}</strong> {releaseHit.application.name}
</Modal> : null}
</p>
<Textarea
label="解除原因"
onChange={(event) => setReleaseReason(event.target.value)}
placeholder="请填写人工解除原因"
rows={4}
value={releaseReason}
/>
</Modal>
) : null}
</section> </section>
); );
} }
+271 -52
View File
@@ -1,7 +1,21 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react'; import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi'; 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'; import { formatDateTime } from '@/utils/dateTime';
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
@@ -17,11 +31,13 @@ const statusTone: Record<string, 'warning' | 'success' | 'danger'> = {
}; };
function sourceLabel(sourceType?: string) { function sourceLabel(sourceType?: string) {
if (sourceType === 'night_sending_bulk') return '夜间超量聚合';
return sourceType === 'cmpp_template_mismatch' ? 'CMPP模板不匹配聚合' : '风控审核'; return sourceType === 'cmpp_template_mismatch' ? 'CMPP模板不匹配聚合' : '风控审核';
} }
function messageStatusLabel(status: string) { function messageStatusLabel(status: string) {
return { return (
{
pending_review: '待人工审核', pending_review: '待人工审核',
queued: '已入队', queued: '已入队',
scheduled: '等待定时发送', scheduled: '等待定时发送',
@@ -31,7 +47,8 @@ function messageStatusLabel(status: string) {
failed: '回执失败', failed: '回执失败',
rejected: '已拒绝', rejected: '已拒绝',
timeout: '超时', timeout: '超时',
}[status] ?? status; }[status] ?? status
);
} }
export function AdminSmsAuditPage() { export function AdminSmsAuditPage() {
@@ -56,14 +73,17 @@ export function AdminSmsAuditPage() {
} }
function loadData() { function loadData() {
adminApi.listRiskReviewTasks({ adminApi
.listRiskReviewTasks({
status: status === 'all' ? undefined : status, status: status === 'all' ? undefined : status,
submittedAtFrom: submittedDateRange.start, submittedAtFrom: submittedDateRange.start,
submittedAtTo: submittedDateRange.end, submittedAtTo: submittedDateRange.end,
}) })
.then((items) => { .then((items) => {
setRecords(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(''); setError('');
}) })
.catch((failure: Error) => setError(failure.message || '短信审核任务加载失败')); .catch((failure: Error) => setError(failure.message || '短信审核任务加载失败'));
@@ -74,8 +94,11 @@ export function AdminSmsAuditPage() {
}, [status, submittedDateRange.end, submittedDateRange.start]); }, [status, submittedDateRange.end, submittedDateRange.start]);
const filteredRecords = useMemo( 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; return matchesKeyword;
}), }),
[keyword, records], [keyword, records],
@@ -83,7 +106,8 @@ export function AdminSmsAuditPage() {
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) { function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
if (!target) return; if (!target) return;
adminApi.listRiskReviewTaskMessages(target.id, { phone: phoneKeyword || undefined, page, pageSize }) adminApi
.listRiskReviewTaskMessages(target.id, { phone: phoneKeyword || undefined, page, pageSize })
.then(setPhoneData) .then(setPhoneData)
.catch((failure: Error) => setError(failure.message || '审核号码列表加载失败')); .catch((failure: Error) => setError(failure.message || '审核号码列表加载失败'));
} }
@@ -131,9 +155,29 @@ export function AdminSmsAuditPage() {
const columns: Array<TableColumn<RiskReviewTask>> = [ const columns: Array<TableColumn<RiskReviewTask>> = [
{ {
key: 'selection', 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', 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', key: 'tenantApplication',
@@ -147,7 +191,12 @@ export function AdminSmsAuditPage() {
</div> </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', key: 'submittedSource',
title: '提交时间 / 审核来源', title: '提交时间 / 审核来源',
@@ -155,7 +204,9 @@ export function AdminSmsAuditPage() {
render: (record) => ( render: (record) => (
<div className="sms-audit-cell-stack"> <div className="sms-audit-cell-stack">
<time dateTime={record.createdAt}>{formatDateTime(record.createdAt)}</time> <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> </div>
), ),
}, },
@@ -165,7 +216,15 @@ export function AdminSmsAuditPage() {
width: '160px', width: '160px',
render: (record) => ( render: (record) => (
<div className="sms-audit-cell-stack"> <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')} · {(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} ·
</button> </button>
<Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag> <Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>
@@ -179,11 +238,19 @@ export function AdminSmsAuditPage() {
align: 'right', align: 'right',
render: (record) => ( render: (record) => (
<div className="audit-actions"> <div className="audit-actions">
<Button icon={<Eye size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost"></Button> <Button icon={<Eye size={15} />} onClick={() => setDetailTarget(record)} size="sm" variant="ghost">
{record.status === 'pending_review' ? <>
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success"></Button> </Button>
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button> {record.status === 'pending_review' ? (
</> : null} <>
<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> </div>
), ),
}, },
@@ -207,17 +274,47 @@ export function AdminSmsAuditPage() {
]} ]}
value={status} 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} /> <DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="audit-filter-actions ui-filter-actions"> <div className="audit-filter-actions ui-filter-actions">
<Button icon={<Search size={17} />} onClick={loadData}></Button> <Button icon={<Search size={17} />} onClick={loadData}>
<Button onClick={() => { setKeyword(''); setSubmittedDateRange({}); setStatus('pending_review'); }} variant="ghost"></Button>
</Button>
<Button
onClick={() => {
setKeyword('');
setSubmittedDateRange({});
setStatus('pending_review');
}}
variant="ghost"
>
</Button>
</div> </div>
</div> </div>
<div className="sms-bulk-actions"> <div className="sms-bulk-actions">
<span> {selectedIds.length} </span> <span> {selectedIds.length} </span>
<Button disabled={selectedIds.length === 0} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button> <Button
<Button disabled={selectedIds.length === 0} icon={<X size={16} />} onClick={() => setRejectTarget('batch')} variant="danger"></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>
</div> </div>
@@ -226,47 +323,155 @@ export function AdminSmsAuditPage() {
</div> </div>
<Modal <Modal
footer={( footer={
<> <>
<Button onClick={() => setApproveTarget(null)} variant="ghost"></Button> <Button onClick={() => setApproveTarget(null)} variant="ghost">
<Button onClick={() => approveTarget === 'batch' ? void approveBatch() : approveTarget ? void approveRecord(approveTarget) : undefined} variant="success"></Button>
</Button>
<Button
onClick={() =>
approveTarget === 'batch'
? void approveBatch()
: approveTarget
? void approveRecord(approveTarget)
: undefined
}
variant="success"
>
</Button>
</> </>
)} }
onClose={() => setApproveTarget(null)} onClose={() => setApproveTarget(null)}
open={Boolean(approveTarget)} open={Boolean(approveTarget)}
title="确认通过" title="确认通过"
> >
<p>{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}</p> <p>
{approveTarget === 'batch'
? `确认通过已选择的 ${selectedIds.length} 条待审核任务?`
: '确认通过该短信审核任务?'}
</p>
</Modal> </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 className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div> <div>
<div><span></span><strong>{detailTarget.batchTask?.taskNo ?? '-'}</strong></div> <span></span>
<div><span></span><strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong></div> <strong>{detailTarget.taskNo}</strong>
<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> </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="page-stack">
<div className="ui-filter-row"> <div className="ui-filter-row">
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} /> <Input
<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)} /> label="手机号码"
<div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}></Button></div> 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> </div>
<Table <Table
columns={[ columns={[
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> }, { key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' }, { 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} data={phoneData.items}
emptyText="暂无号码记录" emptyText="暂无号码记录"
@@ -283,21 +488,35 @@ export function AdminSmsAuditPage() {
totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))} totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))}
/> />
</div> </div>
</Modal> : null} </Modal>
) : null}
<Modal <Modal
footer={( footer={
<> <>
<Button onClick={() => setRejectTarget(null)} variant="ghost"></Button> <Button onClick={() => setRejectTarget(null)} variant="ghost">
<Button disabled={!rejectReason.trim()} onClick={() => rejectTarget === 'batch' ? void rejectBatch() : void rejectRecord()} variant="danger"></Button>
</Button>
<Button
disabled={!rejectReason.trim()}
onClick={() => (rejectTarget === 'batch' ? void rejectBatch() : void rejectRecord())}
variant="danger"
>
</Button>
</> </>
)} }
onClose={() => setRejectTarget(null)} onClose={() => setRejectTarget(null)}
open={Boolean(rejectTarget)} open={Boolean(rejectTarget)}
title={rejectTarget === 'batch' ? '批量驳回' : '确认驳回'} title={rejectTarget === 'batch' ? '批量驳回' : '确认驳回'}
> >
{rejectTarget === 'batch' ? <p> {selectedIds.length} </p> : null} {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> </Modal>
</section> </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();
}