perf: batch CMPP inbound workflow processing

This commit is contained in:
hectorzhao
2026-08-21 09:44:51 +08:00
parent 57192b7586
commit 52028b9bbd
15 changed files with 662 additions and 7 deletions
@@ -63,6 +63,15 @@ export interface PhoneFrequencyRejection {
reason: string;
}
export interface PhoneFrequencyBatchReservation {
tenantId: string;
applicationId: string;
phoneNumber: string;
reservationKey: string;
sourceType?: string;
requestedAt?: Date;
}
@Injectable()
export class PhoneFrequencyService {
constructor(
@@ -180,6 +189,141 @@ export class PhoneFrequencyService {
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
}
/**
* Reserve independent one-phone Inbox items in bounded database batches. The
* reservation rows and counters commit in the same transaction, so reclaiming
* any subset replays its original decision. Duplicate phones intentionally use
* the established single-item path because their within-batch threshold order
* is business-significant.
*/
async reserveBatch(items: PhoneFrequencyBatchReservation[]) {
const results = new Map<string, Map<string, PhoneFrequencyRejection>>();
if (items.length === 0) return results;
const reservationKeys = items.map((item) => item.reservationKey.trim());
if (reservationKeys.some((key) => !key) || new Set(reservationKeys).size !== reservationKeys.length) {
throw new BadRequestException('号码频控批次幂等键为空或重复');
}
const groups = new Map<string, PhoneFrequencyBatchReservation[]>();
for (const item of items) {
const key = `${item.tenantId}:${item.applicationId}`;
const group = groups.get(key) ?? [];
group.push({ ...item, phoneNumber: item.phoneNumber.trim(), reservationKey: item.reservationKey.trim() });
groups.set(key, group);
}
for (const group of groups.values()) {
const uniquePhones = new Set(group.map((item) => item.phoneNumber));
if (uniquePhones.size !== group.length) {
for (const item of group) {
results.set(item.reservationKey, await this.reserve(
item.tenantId,
item.applicationId,
[item.phoneNumber],
item.sourceType,
item.requestedAt ?? new Date(),
item.reservationKey,
));
}
continue;
}
await this.riskReview.ensureDefaultRules();
const rules = await this.effectiveRules(group[0].applicationId);
const groupResults = await this.prisma.$transaction(async (tx) => {
const output = new Map<string, Map<string, PhoneFrequencyRejection>>();
const existing = await tx.phoneFrequencyReservation.findMany({
where: { reservationKey: { in: group.map((item) => item.reservationKey) } },
});
const existingByKey = new Map(existing.map((item) => [item.reservationKey, item]));
const missing: PhoneFrequencyBatchReservation[] = [];
for (const item of group) {
const replay = existingByKey.get(item.reservationKey);
if (!replay) {
missing.push(item);
continue;
}
if (replay.tenantId !== item.tenantId || replay.applicationId !== item.applicationId) {
throw new BadRequestException('号码频控幂等键已用于另一笔预留');
}
output.set(item.reservationKey, frequencyRejectionsFromJson(replay.result));
}
if (missing.length === 0) return output;
const whitelistedPhones = await this.findActiveWhitelistedPhones(tx, missing.map((item) => item.phoneNumber));
const controlled = missing.filter((item) => !whitelistedPhones.has(item.phoneNumber));
const rejectedByPhone = new Map<string, PhoneFrequencyRejection>();
for (const rule of rules) {
const byWindow = new Map<string, { startAt: Date; endAt: Date; items: PhoneFrequencyBatchReservation[] }>();
for (const item of controlled) {
const window = fixedShanghaiWindow(item.requestedAt ?? new Date(), readPeriodSeconds(rule));
const key = `${window.startAt.toISOString()}:${window.endAt.toISOString()}`;
const bucket = byWindow.get(key) ?? { ...window, items: [] };
bucket.items.push(item);
byWindow.set(key, bucket);
}
for (const bucket of byWindow.values()) {
const states = await this.upsertStates(tx, {
tenantId: group[0].tenantId,
applicationId: group[0].applicationId,
phones: bucket.items.map((item) => item.phoneNumber),
rule,
window: { startAt: bucket.startAt, endAt: bucket.endAt },
});
const newTriggers = states.filter((state) => state.activeHitId === null && state.count > rule.thresholdValue);
const hitByStateId = new Map<string, string>();
if (newTriggers.length > 0) {
await tx.phoneFrequencyHit.createMany({
data: newTriggers.map((state) => {
const hitId = randomUUID();
hitByStateId.set(state.id, hitId);
return {
id: hitId,
tenantId: group[0].tenantId,
applicationId: group[0].applicationId,
ruleId: rule.id,
ruleCode: rule.code,
ruleName: rule.name,
phoneNumber: state.phoneNumber,
thresholdValue: Math.floor(rule.thresholdValue),
actualValue: state.count,
windowStartedAt: state.windowStartedAt,
windowEndsAt: state.windowEndsAt,
generation: state.generation,
action: 'block',
sourceType: bucket.items[0]?.sourceType,
};
}),
});
await this.attachActiveHits(tx, hitByStateId);
}
for (const state of states) {
if (state.activeHitId === null && state.count <= rule.thresholdValue) continue;
const reason = `${rule.name}命中:本周期最多${Math.floor(rule.thresholdValue)}条,当前第${state.count}条,周期${formatWindow(state.windowStartedAt, state.windowEndsAt)}`;
const previous = rejectedByPhone.get(state.phoneNumber);
rejectedByPhone.set(state.phoneNumber, {
code: 'PHONE_FREQUENCY_LIMIT',
reason: previous ? `${previous.reason}${reason}` : reason,
});
}
}
}
await tx.phoneFrequencyReservation.createMany({
data: missing.map((item) => {
const rejection = rejectedByPhone.get(item.phoneNumber);
const result = rejection ? [{ phoneNumber: item.phoneNumber, ...rejection }] : [];
output.set(item.reservationKey, frequencyRejectionsFromJson(result));
return {
reservationKey: item.reservationKey,
tenantId: item.tenantId,
applicationId: item.applicationId,
result,
};
}),
});
return output;
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
for (const [key, value] of groupResults) results.set(key, value);
}
return results;
}
async listHits(query: PhoneFrequencyHitQuery) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20)));