Files
lislgosms/api/src/risk-review/phone-frequency.service.ts
T

879 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from './risk-review.service';
const PHONE_FREQUENCY_RULE_CODES = ['PHONE_FREQUENCY_24H', 'PHONE_FREQUENCY_5M'] as const;
const FREQUENCY_WRITE_CHUNK_SIZE = 1000;
type FrequencyRule = {
id: string;
applicationId: string | null;
code: string;
name: string;
thresholdValue: number;
action: string;
priority: number;
config: unknown;
};
type FrequencyStateRow = {
id: string;
phoneNumber: string;
count: number;
generation: number;
activeHitId: string | null;
windowStartedAt: Date;
windowEndsAt: Date;
};
export interface PhoneFrequencyHitQuery {
tenantId?: string;
applicationId?: string;
phoneNumber?: string;
status?: 'active' | 'expired' | 'released';
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}
export interface PhoneFrequencyWhitelistQuery {
phoneNumber?: string;
keyword?: string;
status?: 'active' | 'inactive' | 'deleted';
updatedAtFrom?: string;
updatedAtTo?: string;
page?: number;
pageSize?: number;
}
export interface CreatePhoneFrequencyWhitelistDto {
phoneNumber: string;
reason: string;
remark?: string;
status?: 'active' | 'inactive';
}
export type UpdatePhoneFrequencyWhitelistDto = Partial<CreatePhoneFrequencyWhitelistDto>;
export interface PhoneFrequencyRejection {
code: 'PHONE_FREQUENCY_LIMIT';
reason: string;
}
export interface PhoneFrequencyBatchReservation {
tenantId: string;
applicationId: string;
phoneNumber: string;
reservationKey: string;
sourceType?: string;
requestedAt?: Date;
}
@Injectable()
export class PhoneFrequencyService {
constructor(
private readonly prisma: PrismaService,
private readonly riskReview: RiskReviewService,
) {}
/**
* 为一次初始业务短信提交占用频次。调用方只传尚未被格式或黑名单拒绝的号码;
* 长短信分片、通道重试和补发不会进入这里,因此同一业务号码只计一次。
*/
async reserve(
tenantId: string,
applicationId: string | undefined,
phones: string[],
sourceType?: string,
requestedAt = new Date(),
reservationKey?: string,
) {
if (!applicationId) return new Map<string, PhoneFrequencyRejection>();
const normalizedPhones = [...new Set(phones.map((phone) => phone.trim()).filter(Boolean))].sort();
if (normalizedPhones.length === 0) return new Map<string, PhoneFrequencyRejection>();
await this.riskReview.ensureDefaultRules();
const rules = await this.effectiveRules(applicationId);
const normalizedReservationKey = reservationKey?.trim();
return this.prisma.$transaction(async (tx) => {
if (normalizedReservationKey) {
// Frequency counters and the reservation result commit together. Retrying a reclaimed
// Inbox item therefore returns the original decision without incrementing either window.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'phone-frequency:' + normalizedReservationKey}, 0))`;
const existingReservation = await tx.phoneFrequencyReservation.findUnique({
where: { reservationKey: normalizedReservationKey },
});
if (existingReservation) {
if (existingReservation.tenantId !== tenantId || existingReservation.applicationId !== applicationId) {
throw new BadRequestException('号码频控幂等键已用于另一笔预留');
}
return frequencyRejectionsFromJson(existingReservation.result);
}
}
const rejected = new Map<string, PhoneFrequencyRejection>();
// 平台级白名单只截断号码频控链路;调用 reserve 之前已执行的格式、黑名单等校验不受影响。
const whitelistedPhones = await this.findActiveWhitelistedPhones(tx, normalizedPhones);
const controlledPhones = normalizedPhones.filter((phone) => !whitelistedPhones.has(phone));
if (controlledPhones.length === 0 || rules.length === 0) {
if (normalizedReservationKey) {
await tx.phoneFrequencyReservation.create({
data: { reservationKey: normalizedReservationKey, tenantId, applicationId, result: [] },
});
}
return rejected;
}
for (const rule of rules) {
const window = fixedShanghaiWindow(requestedAt, readPeriodSeconds(rule));
// 分块限制 SQL 参数数量,但两条规则的全部分块仍在同一事务中提交或回滚。
for (const phoneChunk of chunks(controlledPhones, FREQUENCY_WRITE_CHUNK_SIZE)) {
const states = await this.upsertStates(tx, {
tenantId,
applicationId,
phones: phoneChunk,
rule,
window,
});
const newTriggers = states.filter((state) => state.activeHitId === null && state.count > rule.thresholdValue);
const hitByStateId = new Map<string, string>();
if (newTriggers.length > 0) {
const hitRows = newTriggers.map((state) => {
const hitId = randomUUID();
hitByStateId.set(state.id, hitId);
return {
id: hitId,
tenantId,
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,
};
});
await tx.phoneFrequencyHit.createMany({ data: hitRows });
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 existing = rejected.get(state.phoneNumber);
rejected.set(state.phoneNumber, {
code: 'PHONE_FREQUENCY_LIMIT',
reason: existing ? `${existing.reason}${reason}` : reason,
});
}
}
}
if (normalizedReservationKey) {
await tx.phoneFrequencyReservation.create({
data: {
reservationKey: normalizedReservationKey,
tenantId,
applicationId,
result: [...rejected.entries()].map(([phoneNumber, rejection]) => ({ phoneNumber, ...rejection })),
},
});
}
return rejected;
}, { 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)));
const now = new Date();
if (query.status && !['active', 'expired', 'released'].includes(query.status)) {
throw new BadRequestException('号码频次触发记录状态无效');
}
const createdAtFrom = parseOptionalDate(query.createdAtFrom, '开始时间');
const createdAtTo = parseOptionalDate(query.createdAtTo, '结束时间');
if (createdAtFrom && createdAtTo && createdAtFrom > createdAtTo) {
throw new BadRequestException('开始时间不能晚于结束时间');
}
const where: Prisma.PhoneFrequencyHitWhereInput = {
tenantId: query.tenantId,
applicationId: query.applicationId,
phoneNumber: query.phoneNumber?.trim() ? { contains: query.phoneNumber.trim() } : undefined,
createdAt: createdAtFrom || createdAtTo ? {
gte: createdAtFrom,
lte: createdAtTo,
} : undefined,
...(query.status === 'active' ? { releasedAt: null, windowEndsAt: { gt: now } } : {}),
...(query.status === 'expired' ? { releasedAt: null, windowEndsAt: { lte: now } } : {}),
...(query.status === 'released' ? { releasedAt: { not: null } } : {}),
};
const [items, total] = await Promise.all([
this.prisma.phoneFrequencyHit.findMany({
where,
include: {
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
releasedBy: { select: { id: true, username: true, displayName: true } },
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.phoneFrequencyHit.count({ where }),
]);
return { items, total, page, pageSize };
}
async releaseHit(hitId: string, reviewerId: string | undefined, reason?: string) {
const normalizedReason = reason?.trim();
if (!reviewerId) throw new BadRequestException('解除操作需要有效的运营登录会话');
if (!normalizedReason) throw new BadRequestException('解除并清零时必须填写原因');
return this.prisma.$transaction(async (tx) => {
// 与并发 reserve 串行:解除先锁住当前活跃状态,再同时清零计数和断开命中关联。
const [lockedState] = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
SELECT state.id
FROM "PhoneFrequencyState" state
WHERE state."activeHitId" = ${hitId}
FOR UPDATE
`);
const hit = await tx.phoneFrequencyHit.findUnique({ where: { id: hitId } });
if (!hit) throw new NotFoundException('号码频次触发记录不存在');
if (hit.releasedAt) {
return tx.phoneFrequencyHit.findUnique({
where: { id: hitId },
include: { tenant: true, application: true, releasedBy: true },
});
}
const releasedAt = new Date();
if (lockedState) {
await tx.phoneFrequencyState.update({
where: { id: lockedState.id },
data: {
count: 0,
generation: { increment: 1 },
activeHitId: null,
},
});
}
const released = await tx.phoneFrequencyHit.update({
where: { id: hitId },
data: {
releasedAt,
releasedById: reviewerId,
releaseReason: normalizedReason,
},
include: {
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
releasedBy: { select: { id: true, username: true, displayName: true } },
},
});
await tx.operationLog.create({
data: {
tenantId: hit.tenantId,
userId: reviewerId,
action: 'phone_frequency.release',
resource: 'phone_frequency_hit',
resourceId: hitId,
detail: {
applicationId: hit.applicationId,
phoneNumber: hit.phoneNumber,
ruleCode: hit.ruleCode,
countReset: Boolean(lockedState),
reason: normalizedReason,
} as Prisma.InputJsonValue,
},
});
return released;
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
}
async listWhitelist(query: PhoneFrequencyWhitelistQuery) {
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)));
if (query.status && !['active', 'inactive', 'deleted'].includes(query.status)) {
throw new BadRequestException('号码频控白名单状态无效');
}
const updatedAtFrom = parseOptionalDate(query.updatedAtFrom, '开始时间');
const updatedAtTo = parseOptionalDate(query.updatedAtTo, '结束时间');
if (updatedAtFrom && updatedAtTo && updatedAtFrom > updatedAtTo) {
throw new BadRequestException('开始时间不能晚于结束时间');
}
const keyword = query.keyword?.trim();
const phoneNumber = query.phoneNumber?.trim();
const where: Prisma.PhoneFrequencyWhitelistWhereInput = {
status: query.status ?? { not: 'deleted' },
phoneNumber: phoneNumber ? { contains: phoneNumber } : undefined,
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
OR: keyword ? [
{ phoneNumber: { contains: keyword } },
{ reason: { contains: keyword, mode: 'insensitive' } },
{ remark: { contains: keyword, mode: 'insensitive' } },
] : undefined,
};
const include = {
createdBy: { select: { id: true, username: true, displayName: true } },
updatedBy: { select: { id: true, username: true, displayName: true } },
} as const;
const [items, total] = await Promise.all([
this.prisma.phoneFrequencyWhitelist.findMany({
where,
include,
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.phoneFrequencyWhitelist.count({ where }),
]);
return { items, total, page, pageSize };
}
async createWhitelist(data: CreatePhoneFrequencyWhitelistDto, operatorId: string | undefined) {
const normalized = normalizeWhitelistInput(data, false);
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
return this.prisma.$transaction(async (tx) => {
const existing = await tx.phoneFrequencyWhitelist.findUnique({
where: { phoneNumber: normalized.phoneNumber },
});
if (existing && existing.status !== 'deleted') {
throw new BadRequestException('该号码已存在于号码频控白名单');
}
const entry = existing
? await tx.phoneFrequencyWhitelist.update({
where: { id: existing.id },
data: {
...normalized,
deletedAt: null,
updatedById: operatorId,
},
})
: await tx.phoneFrequencyWhitelist.create({
data: {
...normalized,
createdById: operatorId,
updatedById: operatorId,
},
});
const reset = normalized.status === 'active'
? await this.resetFrequencyStates(tx, [normalized.phoneNumber], operatorId, '号码加入平台级频控白名单')
: { stateCount: 0, hitCount: 0 };
await tx.operationLog.create({
data: {
userId: operatorId,
action: existing ? 'phone_frequency_whitelist.restore' : 'phone_frequency_whitelist.create',
resource: 'phone_frequency_whitelist',
resourceId: entry.id,
detail: {
after: normalized,
reset,
} as Prisma.InputJsonValue,
},
});
return tx.phoneFrequencyWhitelist.findUnique({
where: { id: entry.id },
include: whitelistUserInclude,
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
}
async updateWhitelist(
id: string,
data: UpdatePhoneFrequencyWhitelistDto,
operatorId: string | undefined,
) {
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
if (!data || Object.keys(data).length === 0) throw new BadRequestException('没有需要修改的白名单字段');
const normalized = normalizeWhitelistInput(data, true);
return this.prisma.$transaction(async (tx) => {
await tx.$queryRaw(Prisma.sql`
SELECT id FROM "PhoneFrequencyWhitelist" WHERE id = ${id} FOR UPDATE
`);
const existing = await tx.phoneFrequencyWhitelist.findUnique({ where: { id } });
if (!existing || existing.status === 'deleted') {
throw new NotFoundException('号码频控白名单记录不存在');
}
const nextPhone = normalized.phoneNumber ?? existing.phoneNumber;
const nextStatus = normalized.status ?? existing.status;
if (nextPhone !== existing.phoneNumber) {
const duplicate = await tx.phoneFrequencyWhitelist.findUnique({ where: { phoneNumber: nextPhone } });
if (duplicate && duplicate.id !== id) {
throw new BadRequestException(
duplicate.status === 'deleted'
? '该号码存在已删除的白名单历史记录,请直接重新新增该号码以恢复记录'
: '该号码已存在于号码频控白名单',
);
}
}
const shouldReset = nextPhone !== existing.phoneNumber || nextStatus !== existing.status;
const reset = shouldReset
? await this.resetFrequencyStates(
tx,
[existing.phoneNumber, nextPhone],
operatorId,
'平台级频控白名单号码或状态发生变更',
)
: { stateCount: 0, hitCount: 0 };
const entry = await tx.phoneFrequencyWhitelist.update({
where: { id },
data: {
...normalized,
updatedById: operatorId,
},
include: whitelistUserInclude,
});
await tx.operationLog.create({
data: {
userId: operatorId,
action: 'phone_frequency_whitelist.update',
resource: 'phone_frequency_whitelist',
resourceId: id,
detail: {
before: whitelistAuditValue(existing),
after: whitelistAuditValue(entry),
reset,
} as Prisma.InputJsonValue,
},
});
return entry;
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
}
async deleteWhitelist(id: string, operatorId: string | undefined, reason?: string) {
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
const normalizedReason = reason?.trim();
if (!normalizedReason) throw new BadRequestException('删除白名单时必须填写原因');
return this.prisma.$transaction(async (tx) => {
await tx.$queryRaw(Prisma.sql`
SELECT id FROM "PhoneFrequencyWhitelist" WHERE id = ${id} FOR UPDATE
`);
const existing = await tx.phoneFrequencyWhitelist.findUnique({ where: { id } });
if (!existing || existing.status === 'deleted') {
throw new NotFoundException('号码频控白名单记录不存在');
}
const reset = await this.resetFrequencyStates(
tx,
[existing.phoneNumber],
operatorId,
`删除平台级频控白名单:${normalizedReason}`,
);
const entry = await tx.phoneFrequencyWhitelist.update({
where: { id },
data: {
status: 'deleted',
deletedAt: new Date(),
updatedById: operatorId,
},
include: whitelistUserInclude,
});
await tx.operationLog.create({
data: {
userId: operatorId,
action: 'phone_frequency_whitelist.delete',
resource: 'phone_frequency_whitelist',
resourceId: id,
detail: {
phoneNumber: existing.phoneNumber,
reason: normalizedReason,
reset,
} as Prisma.InputJsonValue,
},
});
return entry;
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
}
private async findActiveWhitelistedPhones(tx: Prisma.TransactionClient, phones: string[]) {
const result = new Set<string>();
for (const phoneChunk of chunks(phones, FREQUENCY_WRITE_CHUNK_SIZE)) {
const rows = await tx.phoneFrequencyWhitelist.findMany({
where: { phoneNumber: { in: phoneChunk }, status: 'active', deletedAt: null },
select: { phoneNumber: true },
});
for (const row of rows) result.add(row.phoneNumber);
}
return result;
}
private async resetFrequencyStates(
tx: Prisma.TransactionClient,
phones: string[],
operatorId: string,
releaseReason: string,
) {
// 白名单状态变化按号码跨应用清零;历史命中保留,只解除当前仍与状态关联的活跃命中。
const normalizedPhones = [...new Set(phones)].sort();
const states = await tx.phoneFrequencyState.findMany({
where: { phoneNumber: { in: normalizedPhones } },
select: { id: true, activeHitId: true },
});
const activeHitIds = states.flatMap((state) => state.activeHitId ? [state.activeHitId] : []);
const releasedAt = new Date();
const released = activeHitIds.length > 0
? await tx.phoneFrequencyHit.updateMany({
where: { id: { in: activeHitIds }, releasedAt: null },
data: { releasedAt, releasedById: operatorId, releaseReason },
})
: { count: 0 };
const reset = states.length > 0
? await tx.phoneFrequencyState.updateMany({
where: { id: { in: states.map((state) => state.id) } },
data: { count: 0, generation: { increment: 1 }, activeHitId: null },
})
: { count: 0 };
return { stateCount: reset.count, hitCount: released.count };
}
private async effectiveRules(applicationId: string): Promise<FrequencyRule[]> {
const rules = await this.prisma.riskRule.findMany({
where: {
status: 'active',
code: { in: [...PHONE_FREQUENCY_RULE_CODES] },
OR: [{ applicationId: null }, { applicationId }],
},
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
const byCode = new Map<string, FrequencyRule>();
for (const rule of rules) {
if (rule.applicationId || !byCode.has(rule.code)) byCode.set(rule.code, rule);
}
return [...byCode.values()].sort((left, right) => left.priority - right.priority);
}
private upsertStates(
tx: Prisma.TransactionClient,
input: {
tenantId: string;
applicationId: string;
phones: string[];
rule: FrequencyRule;
window: { startAt: Date; endAt: Date };
},
) {
const values = input.phones.map((phone) => Prisma.sql`(${randomUUID()}, ${phone})`);
// ON CONFLICT 对同一应用、规则、号码取得行锁,保证并发越过阈值时只有一个首次命中者。
return tx.$queryRaw<FrequencyStateRow[]>(Prisma.sql`
WITH input("id", "phoneNumber") AS (
VALUES ${Prisma.join(values)}
)
INSERT INTO "PhoneFrequencyState" (
"id", "tenantId", "applicationId", "ruleId", "ruleCode", "phoneNumber",
"windowStartedAt", "windowEndsAt", "count", "generation", "createdAt", "updatedAt"
)
SELECT
input.id, ${input.tenantId}, ${input.applicationId}, ${input.rule.id}, ${input.rule.code},
input."phoneNumber", ${input.window.startAt}, ${input.window.endAt}, 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM input
ON CONFLICT ("applicationId", "ruleCode", "phoneNumber")
DO UPDATE SET
"ruleId" = EXCLUDED."ruleId",
"windowStartedAt" = EXCLUDED."windowStartedAt",
"windowEndsAt" = EXCLUDED."windowEndsAt",
"count" = CASE
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN 1
WHEN "PhoneFrequencyState"."activeHitId" IS NOT NULL THEN "PhoneFrequencyState"."count"
ELSE "PhoneFrequencyState"."count" + 1
END,
"generation" = CASE
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN 0
ELSE "PhoneFrequencyState"."generation"
END,
"activeHitId" = CASE
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN NULL
ELSE "PhoneFrequencyState"."activeHitId"
END,
"updatedAt" = CURRENT_TIMESTAMP
RETURNING
"id", "phoneNumber", "count", "generation", "activeHitId", "windowStartedAt", "windowEndsAt"
`);
}
private async attachActiveHits(tx: Prisma.TransactionClient, hitByStateId: Map<string, string>) {
if (hitByStateId.size === 0) return;
const values = [...hitByStateId].map(([stateId, hitId]) => Prisma.sql`(${stateId}, ${hitId})`);
await tx.$executeRaw(Prisma.sql`
UPDATE "PhoneFrequencyState" state
SET "activeHitId" = updates."hitId", "updatedAt" = CURRENT_TIMESTAMP
FROM (VALUES ${Prisma.join(values)}) AS updates("stateId", "hitId")
WHERE state.id = updates."stateId"
AND state."activeHitId" IS NULL
`);
}
}
function frequencyRejectionsFromJson(value: Prisma.JsonValue) {
const result = new Map<string, PhoneFrequencyRejection>();
if (!Array.isArray(value)) return result;
for (const item of value) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const phoneNumber = typeof item.phoneNumber === 'string' ? item.phoneNumber : '';
const reason = typeof item.reason === 'string' ? item.reason : '';
if (phoneNumber && reason) result.set(phoneNumber, { code: 'PHONE_FREQUENCY_LIMIT', reason });
}
return result;
}
const whitelistUserInclude = {
createdBy: { select: { id: true, username: true, displayName: true } },
updatedBy: { select: { id: true, username: true, displayName: true } },
} as const;
function normalizeWhitelistInput(
input: CreatePhoneFrequencyWhitelistDto | UpdatePhoneFrequencyWhitelistDto,
partial: boolean,
) {
const result: {
phoneNumber?: string;
reason?: string;
remark?: string | null;
status?: 'active' | 'inactive';
} = {};
if (!partial || input.phoneNumber !== undefined) {
const phoneNumber = normalizeMainlandPhone(input.phoneNumber);
if (!phoneNumber) throw new BadRequestException('请输入有效的中国大陆11位手机号码');
result.phoneNumber = phoneNumber;
}
if (!partial || input.reason !== undefined) {
const reason = input.reason?.trim();
if (!reason) throw new BadRequestException('白名单用途说明不能为空');
if (reason.length > 200) throw new BadRequestException('白名单用途说明不能超过200个字符');
result.reason = reason;
}
if (input.remark !== undefined) {
const remark = input.remark?.trim() ?? '';
if (remark.length > 500) throw new BadRequestException('白名单备注不能超过500个字符');
result.remark = remark || null;
}
const status = input.status ?? (partial ? undefined : 'active');
if (status !== undefined && !['active', 'inactive'].includes(status)) {
throw new BadRequestException('白名单状态无效');
}
if (status) result.status = status;
return result as {
phoneNumber: string;
reason: string;
remark?: string | null;
status: 'active' | 'inactive';
};
}
function normalizeMainlandPhone(value: string | undefined) {
const compact = value?.trim().replace(/[\s-]/g, '') ?? '';
const withoutCountryCode = compact.startsWith('+86')
? compact.slice(3)
: compact.startsWith('86') && compact.length === 13
? compact.slice(2)
: compact;
return /^1\d{10}$/.test(withoutCountryCode) ? withoutCountryCode : undefined;
}
function whitelistAuditValue(entry: {
phoneNumber: string;
status: string;
reason: string;
remark: string | null;
deletedAt: Date | null;
}) {
return {
phoneNumber: entry.phoneNumber,
status: entry.status,
reason: entry.reason,
remark: entry.remark,
deletedAt: entry.deletedAt?.toISOString() ?? null,
};
}
function readPeriodSeconds(rule: FrequencyRule) {
const config = rule.config && typeof rule.config === 'object' && !Array.isArray(rule.config)
? rule.config as Record<string, unknown>
: {};
const fallback = rule.code === 'PHONE_FREQUENCY_24H' ? 24 * 60 * 60 : 5 * 60;
const value = Number(config.periodSeconds ?? fallback);
return Number.isInteger(value) && value >= 60 && value <= 24 * 60 * 60 && 24 * 60 * 60 % value === 0
? value
: fallback;
}
export function fixedShanghaiWindow(value: Date, periodSeconds: number) {
const shanghaiOffsetMs = 8 * 60 * 60 * 1000;
const shifted = value.getTime() + shanghaiOffsetMs;
const dayMs = 24 * 60 * 60 * 1000;
const localDayStart = Math.floor(shifted / dayMs) * dayMs;
const periodMs = periodSeconds * 1000;
const localWindowStart = localDayStart + Math.floor((shifted - localDayStart) / periodMs) * periodMs;
return {
startAt: new Date(localWindowStart - shanghaiOffsetMs),
endAt: new Date(localWindowStart - shanghaiOffsetMs + periodMs),
};
}
function formatWindow(startAt: Date, endAt: Date) {
const formatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatter.format(startAt)}${formatter.format(endAt)}`;
}
function chunks<T>(items: T[], size: number) {
const result: T[][] = [];
for (let index = 0; index < items.length; index += size) {
result.push(items.slice(index, index + size));
}
return result;
}
function parseOptionalDate(value: string | undefined, label: string) {
if (!value) return undefined;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException(`${label}格式无效`);
}
return parsed;
}