691 lines
26 KiB
TypeScript
691 lines
26 KiB
TypeScript
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;
|
||
}
|
||
|
||
@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<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);
|
||
if (rules.length === 0) return new Map<string, PhoneFrequencyRejection>();
|
||
|
||
return this.prisma.$transaction(async (tx) => {
|
||
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) 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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
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<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
|
||
`);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|