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; export interface PhoneFrequencyRejection { code: 'PHONE_FREQUENCY_LIMIT'; reason: string; } @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(), ) { if (!applicationId) return new Map(); const normalizedPhones = [...new Set(phones.map((phone) => phone.trim()).filter(Boolean))].sort(); if (normalizedPhones.length === 0) return new Map(); await this.riskReview.ensureDefaultRules(); const rules = await this.effectiveRules(applicationId); if (rules.length === 0) return new Map(); return this.prisma.$transaction(async (tx) => { const rejected = new Map(); // 平台级白名单只截断号码频控链路;调用 reserve 之前已执行的格式、黑名单等校验不受影响。 const whitelistedPhones = await this.findActiveWhitelistedPhones(tx, normalizedPhones); const controlledPhones = normalizedPhones.filter((phone) => !whitelistedPhones.has(phone)); if (controlledPhones.length === 0) 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(); 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, }); } } } return rejected; }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); } 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>(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(); 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 { 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(); 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(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) { 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 `); } } 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 : {}; 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(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; }