Files
Henry_Du bd8df1f026
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知)
- requireSongAdmin 三角色校验 + createApiError 统一错误包装
- scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤)
- submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic
- 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
2026-09-06 17:03:20 +08:00

172 lines
5.5 KiB
TypeScript

import { randomInt, timingSafeEqual } from 'node:crypto'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { getRedisClient, buildRedisKey } from '~~/server/utils/redis'
const EMAIL_CODE_TTL_MS = 5 * 60 * 1000
const EMAIL_CODE_COOLDOWN_MS = 60 * 1000
const EMAIL_CODE_MAX_ATTEMPTS = 5
const EMAIL_CODE_DAILY_LIMIT = 5
const EMAIL_CODE_DAILY_WINDOW_MS = 24 * 60 * 60 * 1000
type EmailCodeEntry = {
code: string
expiresAt: number
createdAt: number
attempts: number
}
const emailCodes = new Map<string, EmailCodeEntry>()
const dailySendCounts = new Map<string, { count: number; dayKey: string }>()
const normalizeEmail = (email: string) => email.trim().toLowerCase()
const getDayKey = () => {
const now = new Date(getServerTimestamp())
return `${now.getUTCFullYear()}-${now.getUTCMonth() + 1}-${now.getUTCDate()}`
}
const codeRedisKey = (email: string) => buildRedisKey('email-code', 'code', normalizeEmail(email))
const attemptsRedisKey = (email: string) => buildRedisKey('email-code', 'attempts', normalizeEmail(email))
const cooldownRedisKey = (email: string) => buildRedisKey('email-code', 'cooldown', normalizeEmail(email))
const dailyRedisKey = (email: string) => buildRedisKey('email-code', 'daily', normalizeEmail(email), getDayKey())
const VERIFY_EMAIL_CODE_SCRIPT = `
local stored = redis.call('GET', KEYS[1])
if not stored then
return { 0, 0 }
end
local attempts = redis.call('INCR', KEYS[2])
if attempts == 1 then
redis.call('EXPIRE', KEYS[2], 300)
end
if ARGV[1] == stored then
redis.call('DEL', KEYS[1], KEYS[2])
return { 1, attempts }
end
if attempts >= tonumber(ARGV[2]) then
redis.call('DEL', KEYS[1], KEYS[2])
return { 2, attempts }
end
return { 0, attempts }
`
const safeCodeEqual = (a: string, b: string): boolean => {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
export function generateEmailCode(): string {
return String(randomInt(100000, 1000000))
}
export async function storeEmailCode(email: string, code: string): Promise<void> {
const normalized = normalizeEmail(email)
const now = getServerTimestamp()
emailCodes.set(normalized, {
code,
expiresAt: now + EMAIL_CODE_TTL_MS,
createdAt: now,
attempts: 0
})
const dayKey = getDayKey()
const record = dailySendCounts.get(normalized)
if (!record || record.dayKey !== dayKey) {
dailySendCounts.set(normalized, { count: 1, dayKey })
} else {
record.count++
}
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = getRedisClient()
if (!client) return
const ttlSeconds = Math.ceil(EMAIL_CODE_TTL_MS / 1000)
await client.set(codeRedisKey(email), code, { EX: ttlSeconds })
await client.set(cooldownRedisKey(email), '1', {
EX: Math.ceil(EMAIL_CODE_COOLDOWN_MS / 1000)
})
const dailyKey = dailyRedisKey(email)
const dailyCount = await client.incr(dailyKey)
if (dailyCount === 1) {
await client.expire(dailyKey, Math.ceil(EMAIL_CODE_DAILY_WINDOW_MS / 1000))
}
} catch (error) {
console.error('[EmailCode] Redis 写入失败,已回退内存存储:', error)
}
}
export async function isEmailCodeCooldownActive(email: string): Promise<boolean> {
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = getRedisClient()
if (client) {
return Boolean(await client.exists(cooldownRedisKey(email)))
}
} catch (error) {
console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error)
}
const entry = emailCodes.get(normalizeEmail(email))
return Boolean(entry && getServerTimestamp() - entry.createdAt < EMAIL_CODE_COOLDOWN_MS)
}
export async function isEmailCodeDailyLimitReached(email: string): Promise<boolean> {
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = getRedisClient()
if (client) {
const count = await client.get(dailyRedisKey(email))
return Number(count || 0) >= EMAIL_CODE_DAILY_LIMIT
}
} catch (error) {
console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error)
}
const normalized = normalizeEmail(email)
const dayKey = getDayKey()
const record = dailySendCounts.get(normalized)
return Boolean(record && record.dayKey === dayKey && record.count >= EMAIL_CODE_DAILY_LIMIT)
}
export async function verifyEmailCode(email: string, code: string): Promise<boolean> {
const trimmedCode = code.trim()
let client
try {
client = getRedisClient()
if (client) {
const result = await client.eval(VERIFY_EMAIL_CODE_SCRIPT, {
keys: [codeRedisKey(email), attemptsRedisKey(email)],
arguments: [trimmedCode, String(EMAIL_CODE_MAX_ATTEMPTS)]
})
if (Array.isArray(result)) {
return Number(result[0]) === 1
}
}
} catch (error) {
console.error('[EmailCode] Redis 校验失败,已回退内存校验:', error)
}
const normalized = normalizeEmail(email)
const entry = emailCodes.get(normalized)
if (!entry) return false
if (getServerTimestamp() > entry.expiresAt) {
emailCodes.delete(normalized)
return false
}
if (safeCodeEqual(entry.code, trimmedCode)) {
emailCodes.delete(normalized)
return true
}
entry.attempts++
if (entry.attempts >= EMAIL_CODE_MAX_ATTEMPTS) {
emailCodes.delete(normalized)
}
return false
}
export function cleanupExpiredEmailCodes(): void {
const now = getServerTimestamp()
for (const [email, entry] of emailCodes) {
if (now > entry.expiresAt) emailCodes.delete(email)
}
}