feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
@@ -0,0 +1,690 @@
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;
}