feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { apiKeys, apiLogs } from '~/drizzle/schema'
|
||||
import { and, count, desc, eq, gte, like, lte, or, sql } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* API访问日志服务
|
||||
* 提供日志记录、查询和统计功能
|
||||
*/
|
||||
export class ApiLogService {
|
||||
/**
|
||||
* 记录API访问日志
|
||||
*/
|
||||
static async logAccess(logData: {
|
||||
apiKeyId: string | null
|
||||
endpoint: string
|
||||
method: string
|
||||
ipAddress: string
|
||||
userAgent: string
|
||||
statusCode: number
|
||||
responseTimeMs: number
|
||||
requestBody?: string | null
|
||||
responseBody?: string | null
|
||||
errorMessage?: string
|
||||
}) {
|
||||
try {
|
||||
console.log(`[ApiLogService] 记录API访问日志:`, {
|
||||
endpoint: logData.endpoint,
|
||||
method: logData.method,
|
||||
statusCode: logData.statusCode,
|
||||
responseTimeMs: logData.responseTimeMs,
|
||||
hasError: !!logData.errorMessage
|
||||
})
|
||||
|
||||
await db.insert(apiLogs).values({
|
||||
apiKeyId: logData.apiKeyId,
|
||||
endpoint: logData.endpoint,
|
||||
method: logData.method,
|
||||
ipAddress: logData.ipAddress,
|
||||
userAgent: logData.userAgent,
|
||||
statusCode: logData.statusCode,
|
||||
responseTimeMs: logData.responseTimeMs,
|
||||
requestBody: logData.requestBody,
|
||||
responseBody: logData.responseBody,
|
||||
errorMessage: logData.errorMessage
|
||||
})
|
||||
|
||||
console.log(`[ApiLogService] API访问日志记录成功`)
|
||||
} catch (error) {
|
||||
// 静默处理日志记录错误,避免影响主要业务流程
|
||||
console.error('[ApiLogService] API访问日志记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询API访问日志
|
||||
*/
|
||||
static async getLogs(options: {
|
||||
page?: number
|
||||
limit?: number
|
||||
apiKeyId?: string
|
||||
endpoint?: string
|
||||
method?: string
|
||||
statusCode?: number
|
||||
ipAddress?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
search?: string
|
||||
}) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
apiKeyId,
|
||||
endpoint,
|
||||
method,
|
||||
statusCode,
|
||||
ipAddress,
|
||||
startDate,
|
||||
endDate,
|
||||
search
|
||||
} = options
|
||||
|
||||
const offset = (page - 1) * limit
|
||||
const whereConditions = []
|
||||
|
||||
// 构建查询条件
|
||||
if (apiKeyId) {
|
||||
whereConditions.push(eq(apiLogs.apiKeyId, apiKeyId))
|
||||
}
|
||||
|
||||
if (endpoint) {
|
||||
whereConditions.push(like(apiLogs.endpoint, `%${endpoint}%`))
|
||||
}
|
||||
|
||||
if (method) {
|
||||
whereConditions.push(eq(apiLogs.method, method.toUpperCase()))
|
||||
}
|
||||
|
||||
if (statusCode) {
|
||||
whereConditions.push(eq(apiLogs.statusCode, statusCode))
|
||||
}
|
||||
|
||||
if (ipAddress) {
|
||||
whereConditions.push(like(apiLogs.ipAddress, `%${ipAddress}%`))
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
whereConditions.push(gte(apiLogs.createdAt, new Date(startDate)))
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
const endDateTime = new Date(endDate)
|
||||
endDateTime.setHours(23, 59, 59, 999)
|
||||
whereConditions.push(lte(apiLogs.createdAt, endDateTime))
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereConditions.push(
|
||||
or(
|
||||
like(apiLogs.endpoint, `%${search}%`),
|
||||
like(apiLogs.ipAddress, `%${search}%`),
|
||||
like(apiLogs.userAgent, `%${search}%`),
|
||||
like(apiLogs.errorMessage, `%${search}%`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? whereConditions.length === 1
|
||||
? whereConditions[0]
|
||||
: and(...whereConditions)
|
||||
: undefined
|
||||
|
||||
// 查询日志数据
|
||||
const logsData = await db
|
||||
.select({
|
||||
id: apiLogs.id,
|
||||
apiKeyId: apiLogs.apiKeyId,
|
||||
apiKeyName: apiKeys.name,
|
||||
endpoint: apiLogs.endpoint,
|
||||
method: apiLogs.method,
|
||||
ipAddress: apiLogs.ipAddress,
|
||||
userAgent: apiLogs.userAgent,
|
||||
statusCode: apiLogs.statusCode,
|
||||
responseTimeMs: apiLogs.responseTimeMs,
|
||||
requestBody: apiLogs.requestBody,
|
||||
responseBody: apiLogs.responseBody,
|
||||
errorMessage: apiLogs.errorMessage,
|
||||
createdAt: apiLogs.createdAt
|
||||
})
|
||||
.from(apiLogs)
|
||||
.leftJoin(apiKeys, eq(apiLogs.apiKeyId, apiKeys.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(apiLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
// 查询总数
|
||||
const totalResult = await db.select({ count: count() }).from(apiLogs).where(whereClause)
|
||||
|
||||
const total = totalResult[0]?.count || 0
|
||||
|
||||
return {
|
||||
logs: logsData,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API访问统计信息
|
||||
*/
|
||||
static async getStats(
|
||||
options: {
|
||||
apiKeyId?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
groupBy?: 'hour' | 'day' | 'month'
|
||||
} = {}
|
||||
) {
|
||||
const { apiKeyId, startDate, endDate, groupBy = 'day' } = options
|
||||
const whereConditions = []
|
||||
|
||||
if (apiKeyId) {
|
||||
whereConditions.push(eq(apiLogs.apiKeyId, apiKeyId))
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
whereConditions.push(gte(apiLogs.createdAt, new Date(startDate)))
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
const endDateTime = new Date(endDate)
|
||||
endDateTime.setHours(23, 59, 59, 999)
|
||||
whereConditions.push(lte(apiLogs.createdAt, endDateTime))
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? whereConditions.length === 1
|
||||
? whereConditions[0]
|
||||
: and(...whereConditions)
|
||||
: undefined
|
||||
|
||||
// 基础统计
|
||||
const basicStats = await db
|
||||
.select({
|
||||
totalRequests: count(),
|
||||
successRequests: count(
|
||||
sql`CASE WHEN ${apiLogs.statusCode} >= 200 AND ${apiLogs.statusCode} < 300 THEN 1 END`
|
||||
),
|
||||
errorRequests: count(sql`CASE WHEN ${apiLogs.statusCode} >= 400 THEN 1 END`),
|
||||
avgResponseTime: sql<number>`ROUND(AVG(${apiLogs.responseTimeMs}), 2)`,
|
||||
maxResponseTime: sql<number>`MAX(${apiLogs.responseTimeMs})`,
|
||||
minResponseTime: sql<number>`MIN(${apiLogs.responseTimeMs})`
|
||||
})
|
||||
.from(apiLogs)
|
||||
.where(whereClause)
|
||||
|
||||
// 按状态码统计
|
||||
const statusCodeStats = await db
|
||||
.select({
|
||||
statusCode: apiLogs.statusCode,
|
||||
count: count()
|
||||
})
|
||||
.from(apiLogs)
|
||||
.where(whereClause)
|
||||
.groupBy(apiLogs.statusCode)
|
||||
.orderBy(desc(count()))
|
||||
|
||||
// 按端点统计
|
||||
const endpointStats = await db
|
||||
.select({
|
||||
endpoint: apiLogs.endpoint,
|
||||
count: count(),
|
||||
avgResponseTime: sql<number>`ROUND(AVG(${apiLogs.responseTimeMs}), 2)`
|
||||
})
|
||||
.from(apiLogs)
|
||||
.where(whereClause)
|
||||
.groupBy(apiLogs.endpoint)
|
||||
.orderBy(desc(count()))
|
||||
.limit(10)
|
||||
|
||||
// 按API Key统计
|
||||
const apiKeyStats = await db
|
||||
.select({
|
||||
apiKeyId: apiLogs.apiKeyId,
|
||||
apiKeyName: apiKeys.name,
|
||||
count: count(),
|
||||
avgResponseTime: sql<number>`ROUND(AVG(${apiLogs.responseTimeMs}), 2)`
|
||||
})
|
||||
.from(apiLogs)
|
||||
.leftJoin(apiKeys, eq(apiLogs.apiKeyId, apiKeys.id))
|
||||
.where(whereClause)
|
||||
.groupBy(apiLogs.apiKeyId, apiKeys.name)
|
||||
.orderBy(desc(count()))
|
||||
.limit(10)
|
||||
|
||||
// 按时间分组统计
|
||||
let timeGroupFormat: string
|
||||
switch (groupBy) {
|
||||
case 'hour':
|
||||
timeGroupFormat = 'YYYY-MM-DD HH24:00:00'
|
||||
break
|
||||
case 'month':
|
||||
timeGroupFormat = 'YYYY-MM-01'
|
||||
break
|
||||
default:
|
||||
timeGroupFormat = 'YYYY-MM-DD'
|
||||
}
|
||||
|
||||
const timeStats = await db
|
||||
.select({
|
||||
time: sql<string>`TO_CHAR(${apiLogs.createdAt}, ${sql.raw(`'${timeGroupFormat}'`)})`
|
||||
})
|
||||
.from(apiLogs)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`TO_CHAR(${apiLogs.createdAt}, ${sql.raw(`'${timeGroupFormat}'`)})`)
|
||||
.orderBy(sql`TO_CHAR(${apiLogs.createdAt}, ${sql.raw(`'${timeGroupFormat}'`)})`)
|
||||
|
||||
return {
|
||||
basic: basicStats[0] || {
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
errorRequests: 0,
|
||||
avgResponseTime: 0,
|
||||
maxResponseTime: 0,
|
||||
minResponseTime: 0
|
||||
},
|
||||
statusCodes: statusCodeStats,
|
||||
endpoints: endpointStats,
|
||||
apiKeys: apiKeyStats,
|
||||
timeline: timeStats
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门端点统计
|
||||
*/
|
||||
static async getPopularEndpoints(
|
||||
options: {
|
||||
limit?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
} = {}
|
||||
) {
|
||||
const { limit = 10, startDate, endDate } = options
|
||||
const whereConditions = []
|
||||
|
||||
if (startDate) {
|
||||
whereConditions.push(gte(apiLogs.createdAt, new Date(startDate)))
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
const endDateTime = new Date(endDate)
|
||||
endDateTime.setHours(23, 59, 59, 999)
|
||||
whereConditions.push(lte(apiLogs.createdAt, endDateTime))
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? whereConditions.length === 1
|
||||
? whereConditions[0]
|
||||
: and(...whereConditions)
|
||||
: undefined
|
||||
|
||||
return await db
|
||||
.select({
|
||||
endpoint: apiLogs.endpoint,
|
||||
method: apiLogs.method,
|
||||
totalRequests: count(),
|
||||
successRequests: count(
|
||||
sql`CASE WHEN ${apiLogs.statusCode} >= 200 AND ${apiLogs.statusCode} < 300 THEN 1 END`
|
||||
),
|
||||
errorRequests: count(sql`CASE WHEN ${apiLogs.statusCode} >= 400 THEN 1 END`),
|
||||
avgResponseTime: sql<number>`ROUND(AVG(${apiLogs.responseTimeMs}), 2)`,
|
||||
lastAccessed: sql<Date>`MAX(${apiLogs.createdAt})`
|
||||
})
|
||||
.from(apiLogs)
|
||||
.where(whereClause)
|
||||
.groupBy(apiLogs.endpoint, apiLogs.method)
|
||||
.orderBy(desc(count()))
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误日志统计
|
||||
*/
|
||||
static async getErrorStats(
|
||||
options: {
|
||||
limit?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
} = {}
|
||||
) {
|
||||
const { limit = 10, startDate, endDate } = options
|
||||
const whereConditions = [sql`${apiLogs.statusCode} >= 400`]
|
||||
|
||||
if (startDate) {
|
||||
whereConditions.push(gte(apiLogs.createdAt, new Date(startDate)))
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
const endDateTime = new Date(endDate)
|
||||
endDateTime.setHours(23, 59, 59, 999)
|
||||
whereConditions.push(lte(apiLogs.createdAt, endDateTime))
|
||||
}
|
||||
|
||||
const whereClause = and(...whereConditions)
|
||||
|
||||
return await db
|
||||
.select({
|
||||
errorMessage: apiLogs.errorMessage,
|
||||
statusCode: apiLogs.statusCode,
|
||||
endpoint: apiLogs.endpoint,
|
||||
count: count(),
|
||||
lastOccurred: sql<Date>`MAX(${apiLogs.createdAt})`
|
||||
})
|
||||
.from(apiLogs)
|
||||
.where(whereClause)
|
||||
.groupBy(apiLogs.errorMessage, apiLogs.statusCode, apiLogs.endpoint)
|
||||
.orderBy(desc(count()))
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期日志
|
||||
*/
|
||||
static async cleanupOldLogs(daysToKeep: number = 90) {
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep)
|
||||
|
||||
try {
|
||||
const result = await db.delete(apiLogs).where(lte(apiLogs.createdAt, cutoffDate))
|
||||
|
||||
console.log(`Cleaned up API logs older than ${daysToKeep} days`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup old API logs:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
backupHistory,
|
||||
systemSettings,
|
||||
apiKeys,
|
||||
apiKeyPermissions,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
collaborationLogs,
|
||||
emailTemplates,
|
||||
notifications,
|
||||
notificationSettings,
|
||||
playTimes,
|
||||
requestTimes,
|
||||
schedules,
|
||||
semesters,
|
||||
songBlacklists,
|
||||
songCollaborators,
|
||||
songReplayRequests,
|
||||
songs,
|
||||
users,
|
||||
userIdentities,
|
||||
userStatusLogs,
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { uploadToS3 } from '~~/server/utils/s3Client'
|
||||
import { desc, eq, lt, sql } from 'drizzle-orm'
|
||||
|
||||
/** 外部服务调用超时(毫秒) */
|
||||
const UPLOAD_TIMEOUT = 120_000
|
||||
|
||||
/** 并发互斥锁,防止同时触发多次备份 */
|
||||
let backupRunning = false
|
||||
|
||||
/** 获取备份锁,若已在执行则抛出 409 */
|
||||
export function acquireBackupLock(): void {
|
||||
if (backupRunning) {
|
||||
throw createApiError(409, SERVER_ERROR_CODES.BACKUP_FAILED, '备份任务正在执行中,请稍后再试')
|
||||
}
|
||||
backupRunning = true
|
||||
}
|
||||
|
||||
/** 释放备份锁 */
|
||||
export function releaseBackupLock(): void {
|
||||
backupRunning = false
|
||||
}
|
||||
|
||||
/** 自动备份配置结构 */
|
||||
export interface AutoBackupConfig {
|
||||
methods: {
|
||||
s3: {
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
bucket: string
|
||||
region: string
|
||||
pathPrefix: string
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
}
|
||||
webdav: {
|
||||
enabled: boolean
|
||||
url: string
|
||||
username: string
|
||||
password: string
|
||||
path: string
|
||||
}
|
||||
telegram: {
|
||||
enabled: boolean
|
||||
botToken: string
|
||||
chatId: string
|
||||
}
|
||||
email: {
|
||||
enabled: boolean
|
||||
recipient: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取自动备份配置 */
|
||||
export async function getAutoBackupConfig(): Promise<AutoBackupConfig | null> {
|
||||
const [row] = await db
|
||||
.select({ autoBackupConfig: systemSettings.autoBackupConfig })
|
||||
.from(systemSettings)
|
||||
.limit(1)
|
||||
|
||||
if (!row?.autoBackupConfig) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(row.autoBackupConfig)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查自动备份是否启用 */
|
||||
export async function isAutoBackupEnabled(): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ autoBackupEnabled: systemSettings.autoBackupEnabled })
|
||||
.from(systemSettings)
|
||||
.limit(1)
|
||||
|
||||
return row?.autoBackupEnabled === true
|
||||
}
|
||||
|
||||
/** 导出数据库备份数据 */
|
||||
export async function exportBackupData(): Promise<{ json: string; filename: string; metadata: { totalRecords: number } }> {
|
||||
const backupData = {
|
||||
metadata: {
|
||||
version: '1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
backupType: 'auto',
|
||||
description: `自动备份 - ${new Date().toISOString()}`,
|
||||
tables: [] as Array<{ name: string; description: string; recordCount: number }>,
|
||||
totalRecords: 0
|
||||
},
|
||||
data: {} as Record<string, any>
|
||||
}
|
||||
|
||||
const tablesToBackup: Record<string, { query: () => Promise<any[]>; description: string }> = {
|
||||
users: { query: () => db.select().from(users), description: '用户数据' },
|
||||
songs: { query: () => db.select().from(songs), description: '歌曲数据' },
|
||||
schedules: { query: () => db.select().from(schedules), description: '排期数据' },
|
||||
playTimes: { query: () => db.select().from(playTimes), description: '播出时段' },
|
||||
requestTimes: { query: () => db.select().from(requestTimes), description: '请求时段' },
|
||||
semesters: { query: () => db.select().from(semesters), description: '学期数据' },
|
||||
notifications: { query: () => db.select().from(notifications), description: '通知数据' },
|
||||
notificationSettings: { query: () => db.select().from(notificationSettings), description: '通知设置' },
|
||||
songBlacklists: { query: () => db.select().from(songBlacklists), description: '歌曲黑名单' },
|
||||
votes: { query: () => db.select().from(votes), description: '投票数据' },
|
||||
cardCodes: { query: () => db.select().from(cardCodes), description: '点歌券数据' },
|
||||
cardCodeRedeemLogs: { query: () => db.select().from(cardCodeRedeemLogs), description: '点歌券日志' },
|
||||
songCollaborators: { query: () => db.select().from(songCollaborators), description: '联合投稿人' },
|
||||
collaborationLogs: { query: () => db.select().from(collaborationLogs), description: '联合投稿审计日志' },
|
||||
songReplayRequests: { query: () => db.select().from(songReplayRequests), description: '歌曲重播申请' },
|
||||
userStatusLogs: { query: () => db.select().from(userStatusLogs), description: '用户状态变更日志' },
|
||||
userIdentities: { query: () => db.select().from(userIdentities), description: '第三方身份关联' },
|
||||
apiKeys: { query: () => db.select().from(apiKeys), description: 'API密钥' },
|
||||
apiKeyPermissions: { query: () => db.select().from(apiKeyPermissions), description: 'API密钥权限' },
|
||||
apiLogs: { query: () => db.select().from(apiLogs), description: 'API访问日志' },
|
||||
emailTemplates: { query: () => db.select().from(emailTemplates), description: '邮件模板' },
|
||||
systemSettings: {
|
||||
query: async () => {
|
||||
const rows = await db.select().from(systemSettings)
|
||||
// 脱敏:移除敏感凭据字段,避免备份文件泄露密钥
|
||||
const SENSITIVE_FIELDS = [
|
||||
'smtpPassword', 'oauthStateSecret', 'githubClientSecret',
|
||||
'casdoorClientSecret', 'googleClientSecret', 'customOAuthClientSecret',
|
||||
'turnstileSecretKey', 'aggregateOAuthAppKey', 'autoBackupConfig'
|
||||
]
|
||||
return rows.map(row => {
|
||||
const entries = Object.entries(row).filter(([k]) => !SENSITIVE_FIELDS.includes(k))
|
||||
return Object.fromEntries(entries)
|
||||
})
|
||||
},
|
||||
description: '系统设置(已脱敏)'
|
||||
}
|
||||
}
|
||||
|
||||
let totalRecords = 0
|
||||
|
||||
for (const [tableName, { query, description }] of Object.entries(tablesToBackup)) {
|
||||
try {
|
||||
const data = await query()
|
||||
backupData.data[tableName] = data
|
||||
backupData.metadata.tables.push({ name: tableName, description, recordCount: data.length })
|
||||
totalRecords += data.length
|
||||
} catch (error) {
|
||||
console.warn(`备份表 ${tableName} 失败,已跳过:`, error)
|
||||
backupData.metadata.tables.push({ name: tableName, description: `${description} (跳过)`, recordCount: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
backupData.metadata.totalRecords = totalRecords
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `auto-backup-${timestamp}.json`
|
||||
|
||||
return { json: JSON.stringify(backupData, null, 2), filename, metadata: backupData.metadata }
|
||||
}
|
||||
|
||||
/** 上传到 S3 */
|
||||
async function doS3Upload(config: AutoBackupConfig['methods']['s3'], data: string, filename: string): Promise<void> {
|
||||
const prefix = (config.pathPrefix || '').replace(/\/+$/, '')
|
||||
const key = prefix ? `${prefix}/${filename}` : filename
|
||||
await uploadToS3(config.endpoint, config.bucket, config.region, config.accessKey, config.secretKey, key, data)
|
||||
console.log(`S3 上传完成: ${filename}`)
|
||||
}
|
||||
|
||||
/** 规范化路径,去除首尾空格和多余斜杠 */
|
||||
function normalizePath(p: string): string {
|
||||
return (p || '').trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
/** 递归创建 WebDAV 目录 */
|
||||
async function ensureWebDAVDir(baseUrl: string, auth: string, dirPath: string): Promise<void> {
|
||||
const normalized = normalizePath(dirPath)
|
||||
if (!normalized) return
|
||||
|
||||
const segments = normalized.split('/')
|
||||
let currentPath = baseUrl.replace(/\/$/, '')
|
||||
|
||||
for (const segment of segments) {
|
||||
currentPath += `/${segment}`
|
||||
const response = await fetch(currentPath, {
|
||||
method: 'MKCOL',
|
||||
headers: { Authorization: `Basic ${auth}` },
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
|
||||
})
|
||||
// 201 Created = 成功,405 = 目录已存在
|
||||
if (response.status === 409) {
|
||||
throw new Error(`WebDAV 父目录不存在,无法创建: ${currentPath}`)
|
||||
}
|
||||
if (!response.ok && response.status !== 405) {
|
||||
throw new Error(`创建 WebDAV 目录失败: ${currentPath} (${response.status})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传到 WebDAV */
|
||||
async function doWebDAVUpload(config: AutoBackupConfig['methods']['webdav'], data: string, filename: string): Promise<void> {
|
||||
const baseUrl = config.url.replace(/\/$/, '')
|
||||
const dirPath = normalizePath(config.path)
|
||||
const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64')
|
||||
|
||||
// 确保目录存在
|
||||
await ensureWebDAVDir(baseUrl, auth, dirPath)
|
||||
|
||||
const filePath = dirPath ? `${baseUrl}/${dirPath}/${filename}` : `${baseUrl}/${filename}`
|
||||
const response = await fetch(filePath, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: data,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV 上传失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`WebDAV 上传完成: ${filename}`)
|
||||
}
|
||||
|
||||
/** 导出 ensureWebDAVDir 供测试端点使用 */
|
||||
export { ensureWebDAVDir }
|
||||
|
||||
/** 通过 Telegram Bot 发送 */
|
||||
async function doTelegramSend(config: AutoBackupConfig['methods']['telegram'], data: string, filename: string): Promise<void> {
|
||||
const formData = new FormData()
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
formData.append('chat_id', config.chatId)
|
||||
formData.append('document', blob, filename)
|
||||
formData.append('caption', `VoiceHub Auto Backup - ${new Date().toISOString()}`)
|
||||
|
||||
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendDocument`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
|
||||
})
|
||||
|
||||
const result = await response.json() as any
|
||||
if (!result.ok) {
|
||||
throw new Error(`Telegram 发送失败: ${result.description}`)
|
||||
}
|
||||
|
||||
console.log(`Telegram 发送完成: ${filename}`)
|
||||
}
|
||||
|
||||
/** 通过邮件发送 */
|
||||
async function doEmailSend(config: AutoBackupConfig['methods']['email'], data: string, filename: string): Promise<void> {
|
||||
const { getSystemSettingsCached } = await import('~~/server/utils/system-settings-helper')
|
||||
const settings = await getSystemSettingsCached()
|
||||
const nodemailer = await import('nodemailer').then(m => m.default || m)
|
||||
|
||||
if (!settings || !settings.smtpHost || !settings.smtpUsername || !settings.smtpPassword) {
|
||||
throw createApiError(500, SERVER_ERROR_CODES.SMTP_NOT_CONFIGURED, '邮件服务未配置,无法发送邮件备份')
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: settings.smtpHost,
|
||||
port: settings.smtpPort || 587,
|
||||
secure: settings.smtpSecure || false,
|
||||
auth: {
|
||||
user: settings.smtpUsername,
|
||||
pass: settings.smtpPassword
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: `"${settings.smtpFromName || 'VoiceHub'}" <${settings.smtpFromEmail || settings.smtpUsername}>`,
|
||||
to: config.recipient,
|
||||
subject: `VoiceHub 自动备份 - ${new Date().toISOString()}`,
|
||||
text: '数据库自动备份文件见附件。',
|
||||
attachments: [{
|
||||
filename,
|
||||
content: data,
|
||||
contentType: 'application/json'
|
||||
}]
|
||||
})
|
||||
|
||||
console.log(`邮件发送完成: ${filename} -> ${config.recipient}`)
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** 准备备份:校验、导出数据、写入初始记录,返回上下文供后续上传使用 */
|
||||
export async function prepareBackup(triggeredBy: string = 'api'): Promise<{
|
||||
historyId: number
|
||||
json: string
|
||||
filename: string
|
||||
metadata: { totalRecords: number }
|
||||
backupSize: number
|
||||
config: AutoBackupConfig
|
||||
enabledMethods: Array<{ key: string; name: string; fn: () => Promise<void> }>
|
||||
}> {
|
||||
const enabled = await isAutoBackupEnabled()
|
||||
if (!enabled) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.BACKUP_DISABLED, '自动备份未启用')
|
||||
}
|
||||
|
||||
const config = await getAutoBackupConfig()
|
||||
if (!config) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.BACKUP_NOT_CONFIGURED, '自动备份未配置')
|
||||
}
|
||||
|
||||
const { json, filename, metadata } = await exportBackupData()
|
||||
|
||||
const methods = [
|
||||
{ key: 's3' as const, name: 'S3', fn: () => doS3Upload(config.methods.s3, json, filename) },
|
||||
{ key: 'webdav' as const, name: 'WebDAV', fn: () => doWebDAVUpload(config.methods.webdav, json, filename) },
|
||||
{ key: 'telegram' as const, name: 'Telegram', fn: () => doTelegramSend(config.methods.telegram, json, filename) },
|
||||
{ key: 'email' as const, name: 'Email', fn: () => doEmailSend(config.methods.email, json, filename) }
|
||||
]
|
||||
|
||||
const enabledMethods = methods.filter(({ key }) => config.methods[key].enabled)
|
||||
|
||||
if (enabledMethods.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NO_BACKUP_METHOD_ENABLED, '没有启用任何备份方式')
|
||||
}
|
||||
|
||||
// 写入初始记录(error 为 null 表示未完成),确保超时中断也有历史
|
||||
const backupSize = Buffer.byteLength(json)
|
||||
const initialResults = enabledMethods.map(({ name }) => ({
|
||||
method: name,
|
||||
success: false,
|
||||
error: null
|
||||
}))
|
||||
const [inserted] = await db.insert(backupHistory).values({
|
||||
filename,
|
||||
totalRecords: metadata.totalRecords,
|
||||
backupSize,
|
||||
success: false,
|
||||
methods: JSON.stringify(initialResults),
|
||||
triggeredBy
|
||||
}).returning({ id: backupHistory.id })
|
||||
|
||||
if (!inserted) {
|
||||
throw createApiError(500, SERVER_ERROR_CODES.BACKUP_FAILED, '写入备份记录失败')
|
||||
}
|
||||
|
||||
console.log(`备份初始记录已写入: ${filename} (id=${inserted.id})`)
|
||||
|
||||
return {
|
||||
historyId: inserted.id,
|
||||
json,
|
||||
filename,
|
||||
metadata,
|
||||
backupSize,
|
||||
config,
|
||||
enabledMethods
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行上传并更新历史记录 */
|
||||
export async function executeUploads(prepared: {
|
||||
historyId: number
|
||||
json: string
|
||||
filename: string
|
||||
config: AutoBackupConfig
|
||||
enabledMethods: Array<{ key: string; name: string; fn: () => Promise<void> }>
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
results: Array<{ method: string; success: boolean; error?: string }>
|
||||
}> {
|
||||
const { historyId, filename, enabledMethods } = prepared
|
||||
|
||||
// 串行化 DB 更新,避免并行写入时的竞态条件
|
||||
let updateChain: Promise<void> = Promise.resolve()
|
||||
|
||||
function updateMethodResult(index: number, result: { method: string; success: boolean; error?: string }) {
|
||||
const prev = updateChain
|
||||
updateChain = prev.then(async () => {
|
||||
const [record] = await db
|
||||
.select({ methods: backupHistory.methods })
|
||||
.from(backupHistory)
|
||||
.where(eq(backupHistory.id, historyId))
|
||||
if (!record) return
|
||||
const methods = JSON.parse(record.methods)
|
||||
methods[index] = result
|
||||
await db.update(backupHistory)
|
||||
.set({ methods: JSON.stringify(methods) })
|
||||
.where(eq(backupHistory.id, historyId))
|
||||
}).catch(err => {
|
||||
console.error(`更新备份方法结果失败 (index=${index}):`, err)
|
||||
})
|
||||
return updateChain
|
||||
}
|
||||
|
||||
// 并行上传,每个完成后立即更新对应方法的结果
|
||||
const tasks = enabledMethods.map(async ({ name, fn }, index) => {
|
||||
try {
|
||||
await fn()
|
||||
await updateMethodResult(index, { method: name, success: true })
|
||||
return { method: name, success: true }
|
||||
} catch (error: any) {
|
||||
console.error(`${name} 备份失败:`, error)
|
||||
const errMsg = error.message || String(error) || 'Unknown error'
|
||||
await updateMethodResult(index, { method: name, success: false, error: errMsg })
|
||||
return { method: name, success: false, error: errMsg }
|
||||
}
|
||||
})
|
||||
|
||||
const results: Array<{ method: string; success: boolean; error?: string }> = await Promise.all(tasks)
|
||||
|
||||
const overallSuccess = results.some(r => r.success)
|
||||
|
||||
// 更新整体成功状态
|
||||
await db.update(backupHistory)
|
||||
.set({ success: overallSuccess })
|
||||
.where(eq(backupHistory.id, historyId))
|
||||
|
||||
// 自动清理 30 天前的历史记录
|
||||
cleanupOldHistory(30).catch(err => console.error('自动清理备份历史失败:', err))
|
||||
|
||||
console.log(`备份历史已更新: ${filename}`)
|
||||
|
||||
return {
|
||||
success: overallSuccess,
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行一次完整的自动备份(prepareBackup + executeUploads) */
|
||||
export async function executeAutoBackup(triggeredBy: string = 'api'): Promise<{
|
||||
success: boolean
|
||||
results: Array<{ method: string; success: boolean; error?: string }>
|
||||
}> {
|
||||
acquireBackupLock()
|
||||
try {
|
||||
const prepared = await prepareBackup(triggeredBy)
|
||||
return await executeUploads(prepared)
|
||||
} finally {
|
||||
releaseBackupLock()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取备份历史列表 */
|
||||
export async function getBackupHistory(limit: number = 50): Promise<Array<{
|
||||
id: number
|
||||
createdAt: Date
|
||||
filename: string
|
||||
totalRecords: number
|
||||
backupSize: number
|
||||
methods: Array<{ method: string; success: boolean; error?: string }>
|
||||
success: boolean
|
||||
triggeredBy: string | null
|
||||
}>> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(backupHistory)
|
||||
.orderBy(desc(backupHistory.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
methods: JSON.parse(row.methods)
|
||||
}))
|
||||
}
|
||||
|
||||
/** 清理备份历史记录 */
|
||||
export async function cleanupOldHistory(retentionDays: number = 30): Promise<number> {
|
||||
if (retentionDays <= 0) {
|
||||
const result = await db.select({ cnt: sql<number>`count(*)::int` }).from(backupHistory)
|
||||
const count = result[0]?.cnt ?? 0
|
||||
if (count > 0) {
|
||||
await db.delete(backupHistory)
|
||||
console.log(`清理了全部 ${count} 条备份历史记录`)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
|
||||
const result = await db
|
||||
.select({ cnt: sql<number>`count(*)::int` })
|
||||
.from(backupHistory)
|
||||
.where(lt(backupHistory.createdAt, cutoff))
|
||||
const count = result[0]?.cnt ?? 0
|
||||
if (count > 0) {
|
||||
await db.delete(backupHistory).where(lt(backupHistory.createdAt, cutoff))
|
||||
console.log(`清理了 ${count} 条过期备份历史记录`)
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { createError } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { cardCodes, songs } from '~/drizzle/schema'
|
||||
|
||||
export const deleteCardCodesByIds = async (ids: number[]) => {
|
||||
const normalizedIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)))
|
||||
if (!normalizedIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const existingRows = await tx
|
||||
.select({ id: cardCodes.id })
|
||||
.from(cardCodes)
|
||||
.where(inArray(cardCodes.id, normalizedIds))
|
||||
.for('update')
|
||||
|
||||
const existingIds = existingRows.map((row) => row.id)
|
||||
if (!existingIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const referencedSongs = await tx
|
||||
.select({ id: songs.id })
|
||||
.from(songs)
|
||||
.where(inArray(songs.cardCodeId, existingIds))
|
||||
.limit(1)
|
||||
|
||||
if (referencedSongs.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '已关联投稿的点歌券不能删除,请改为作废'
|
||||
})
|
||||
}
|
||||
|
||||
return await tx.delete(cardCodes).where(inArray(cardCodes.id, existingIds)).returning()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { createError } from 'h3'
|
||||
import { cardCodeRedeemLogs, cardCodes } from '~/drizzle/schema'
|
||||
|
||||
type CardCodeTransitionContext = {
|
||||
songId: number
|
||||
cardCodeId: number | null | undefined
|
||||
operatorId: number
|
||||
at?: Date
|
||||
}
|
||||
|
||||
const getTransitionTime = (at?: Date) => at || new Date()
|
||||
|
||||
const readCardCode = async (tx: any, cardCodeId: number) => {
|
||||
const rows = await tx.select().from(cardCodes).where(eq(cardCodes.id, cardCodeId)).limit(1)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
export const redeemCardCodeForSchedule = async (
|
||||
tx: any,
|
||||
{ songId, cardCodeId, operatorId, at }: CardCodeTransitionContext
|
||||
) => {
|
||||
if (!cardCodeId) return { changed: false, reason: 'NO_CARD_CODE' }
|
||||
|
||||
const redeemedAt = getTransitionTime(at)
|
||||
const redeemResult = await tx
|
||||
.update(cardCodes)
|
||||
.set({
|
||||
status: 'REDEEMED',
|
||||
redeemedBy: operatorId,
|
||||
redeemedAt,
|
||||
lockedBy: null,
|
||||
lockedAt: null,
|
||||
updatedAt: redeemedAt
|
||||
})
|
||||
.where(and(eq(cardCodes.id, cardCodeId), inArray(cardCodes.status, ['AVAILABLE', 'LOCKED'])))
|
||||
.returning({ id: cardCodes.id, code: cardCodes.code, redeemedAt: cardCodes.redeemedAt })
|
||||
|
||||
if (redeemResult.length > 0) {
|
||||
const redeemedCardCode = redeemResult[0]
|
||||
await tx.insert(cardCodeRedeemLogs).values({
|
||||
cardCodeId: redeemedCardCode.id,
|
||||
codeSnapshot: redeemedCardCode.code,
|
||||
redeemedBy: operatorId,
|
||||
redeemedAt: redeemedCardCode.redeemedAt || redeemedAt,
|
||||
source: 'SCHEDULE_AUTO',
|
||||
songId
|
||||
})
|
||||
return { changed: true, status: 'REDEEMED' }
|
||||
}
|
||||
|
||||
const current = await readCardCode(tx, cardCodeId)
|
||||
if (!current) {
|
||||
throw createError({ statusCode: 400, message: '关联的点歌券不存在,无法发布排期' })
|
||||
}
|
||||
if (current.status === 'REDEEMED') {
|
||||
const existingLogs = await tx
|
||||
.select({ id: cardCodeRedeemLogs.id })
|
||||
.from(cardCodeRedeemLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(cardCodeRedeemLogs.cardCodeId, cardCodeId),
|
||||
eq(cardCodeRedeemLogs.songId, songId),
|
||||
eq(cardCodeRedeemLogs.source, 'SCHEDULE_AUTO')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingLogs.length > 0) {
|
||||
return { changed: false, status: 'REDEEMED' }
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '该点歌券已被其他歌曲核销,无法用于此歌曲排期'
|
||||
})
|
||||
}
|
||||
|
||||
// 作废券继续排期会让财务/发放记录失真,因此这里让事务失败。
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: `点歌券当前状态为 ${current.status},无法完成排期核销`
|
||||
})
|
||||
}
|
||||
|
||||
export const restoreCardCodeAfterScheduleRemoval = async (
|
||||
tx: any,
|
||||
{ songId, cardCodeId, operatorId, at }: CardCodeTransitionContext
|
||||
) => {
|
||||
if (!cardCodeId) return { changed: false, reason: 'NO_CARD_CODE' }
|
||||
|
||||
const restoredAt = getTransitionTime(at)
|
||||
const current = await readCardCode(tx, cardCodeId)
|
||||
if (!current) return { changed: false, reason: 'MISSING_CARD_CODE' }
|
||||
if (current.status !== 'REDEEMED') {
|
||||
return { changed: false, status: current.status }
|
||||
}
|
||||
|
||||
const restoreResult = await tx
|
||||
.update(cardCodes)
|
||||
.set({
|
||||
status: 'LOCKED',
|
||||
lockedBy: current.lockedBy || operatorId,
|
||||
lockedAt: current.lockedAt || restoredAt,
|
||||
redeemedBy: null,
|
||||
redeemedAt: null,
|
||||
updatedAt: restoredAt
|
||||
})
|
||||
.where(and(eq(cardCodes.id, cardCodeId), eq(cardCodes.status, 'REDEEMED')))
|
||||
.returning({ id: cardCodes.id, code: cardCodes.code })
|
||||
|
||||
if (restoreResult.length === 0) {
|
||||
return { changed: false, reason: 'CONCURRENT_CHANGE' }
|
||||
}
|
||||
|
||||
await tx.insert(cardCodeRedeemLogs).values({
|
||||
cardCodeId,
|
||||
codeSnapshot: restoreResult[0].code,
|
||||
redeemedBy: operatorId,
|
||||
redeemedAt: restoredAt,
|
||||
source: 'SCHEDULE_REMOVE',
|
||||
songId
|
||||
})
|
||||
|
||||
return { changed: true, status: 'LOCKED' }
|
||||
}
|
||||
|
||||
export const releaseCardCodeAfterSongWithdrawal = async (
|
||||
tx: any,
|
||||
{ songId, cardCodeId, operatorId, at }: CardCodeTransitionContext
|
||||
) => {
|
||||
if (!cardCodeId) return { changed: false, reason: 'NO_CARD_CODE' }
|
||||
|
||||
const releasedAt = getTransitionTime(at)
|
||||
const current = await readCardCode(tx, cardCodeId)
|
||||
if (!current) return { changed: false, reason: 'MISSING_CARD_CODE' }
|
||||
if (!['LOCKED', 'REDEEMED'].includes(current.status)) {
|
||||
return { changed: false, status: current.status }
|
||||
}
|
||||
|
||||
const releaseResult = await tx
|
||||
.update(cardCodes)
|
||||
.set({
|
||||
status: 'AVAILABLE',
|
||||
lockedBy: null,
|
||||
lockedAt: null,
|
||||
redeemedBy: null,
|
||||
redeemedAt: null,
|
||||
updatedAt: releasedAt
|
||||
})
|
||||
.where(and(eq(cardCodes.id, cardCodeId), inArray(cardCodes.status, ['LOCKED', 'REDEEMED'])))
|
||||
.returning({ id: cardCodes.id, code: cardCodes.code })
|
||||
|
||||
if (releaseResult.length === 0) {
|
||||
return { changed: false, reason: 'CONCURRENT_CHANGE' }
|
||||
}
|
||||
|
||||
await tx.insert(cardCodeRedeemLogs).values({
|
||||
cardCodeId,
|
||||
codeSnapshot: releaseResult[0].code,
|
||||
redeemedBy: operatorId,
|
||||
redeemedAt: releasedAt,
|
||||
source: 'WITHDRAW',
|
||||
songId
|
||||
})
|
||||
|
||||
return { changed: true, status: 'AVAILABLE' }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { fetchSongDuration } from '~~/server/utils/songDurationFetcher'
|
||||
import {
|
||||
decideDurationOutcome,
|
||||
type SongDurationDecision
|
||||
} from '~~/server/utils/song-duration-policy'
|
||||
|
||||
/** 后台批量补齐的并发数 */
|
||||
const BACKFILL_BATCH_SIZE = 5
|
||||
|
||||
/**
|
||||
* 投稿后处理时长:缺失则按音源补齐,已有则校验一致性
|
||||
* 仅返回决策,写库由调用方负责
|
||||
*/
|
||||
export async function reconcileSongDurationOnSubmit(
|
||||
songId: number,
|
||||
platform: string | null,
|
||||
musicId: string | null,
|
||||
storedDurationSeconds: number | null
|
||||
): Promise<SongDurationDecision> {
|
||||
if (!platform || !musicId) {
|
||||
return { outcome: 'nocheck', durationSeconds: storedDurationSeconds }
|
||||
}
|
||||
|
||||
try {
|
||||
const actualDuration = await fetchSongDuration(platform, musicId)
|
||||
const decision = decideDurationOutcome(storedDurationSeconds, actualDuration)
|
||||
if (decision.outcome === 'clear') {
|
||||
console.log(
|
||||
`[投稿时长] #${songId} 时长不符 (提交: ${storedDurationSeconds}s, API: ${actualDuration}s),清除durationSeconds`
|
||||
)
|
||||
}
|
||||
return decision
|
||||
} catch (error) {
|
||||
// 外部接口异常时保留原值,不因为后台问题影响用户投稿
|
||||
console.error(`[投稿时长] #${songId} 处理失败:`, error)
|
||||
return { outcome: 'nocheck', durationSeconds: storedDurationSeconds }
|
||||
}
|
||||
}
|
||||
|
||||
export interface SongDurationBackfillRow {
|
||||
id: number
|
||||
musicPlatform: string | null
|
||||
musicId: string | null
|
||||
}
|
||||
|
||||
type BackfillableRow = SongDurationBackfillRow & { musicPlatform: string; musicId: string }
|
||||
|
||||
/** 批量补齐缺失时长,单条失败不影响其余 */
|
||||
export async function backfillMissingSongDurations(rows: SongDurationBackfillRow[], label: string): Promise<void> {
|
||||
const targets = rows.filter(
|
||||
(row): row is BackfillableRow => Boolean(row.musicPlatform) && Boolean(row.musicId)
|
||||
)
|
||||
|
||||
for (let i = 0; i < targets.length; i += BACKFILL_BATCH_SIZE) {
|
||||
const batch = targets.slice(i, i + BACKFILL_BATCH_SIZE)
|
||||
await Promise.all(
|
||||
batch.map(async (row) => {
|
||||
try {
|
||||
const duration = await fetchSongDuration(row.musicPlatform, row.musicId)
|
||||
if (duration === null) {
|
||||
console.warn(`[${label}] 歌曲 #${row.id} 未获取到有效时长`)
|
||||
return
|
||||
}
|
||||
await db.update(songs).set({ durationSeconds: duration }).where(eq(songs.id, row.id))
|
||||
} catch (error) {
|
||||
console.warn(`[${label}] 歌曲 #${row.id} 时长补齐失败:`, error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notificationSettings, users } from '~/drizzle/schema'
|
||||
import { and, eq, inArray, isNotNull, ne } from 'drizzle-orm'
|
||||
import { getSiteTitle } from '~~/server/utils/siteUtils'
|
||||
|
||||
/**
|
||||
* 发送 MeoW 通知
|
||||
*/
|
||||
async function sendMeowNotification(
|
||||
meowNickname: string,
|
||||
title: string,
|
||||
message: string,
|
||||
url?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// URL 编码参数
|
||||
const encodedNickname = encodeURIComponent(meowNickname)
|
||||
const encodedTitle = encodeURIComponent(title)
|
||||
const encodedMessage = encodeURIComponent(message)
|
||||
|
||||
// 构建请求 URL
|
||||
let meowUrl = `https://api.chuckfang.com/${encodedNickname}/${encodedTitle}/${encodedMessage}`
|
||||
|
||||
if (url) {
|
||||
meowUrl += `?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
const response = await fetch(meowUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'VoiceHub-Notification-Service'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
console.log('MeoW 通知发送成功:', result)
|
||||
return true
|
||||
} else {
|
||||
console.error('MeoW 通知发送失败:', response.status, response.statusText)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知时出错:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户发送 MeoW 通知(如果已绑定)
|
||||
*/
|
||||
export async function sendMeowNotificationToUser(
|
||||
userId: number,
|
||||
notificationTitle: string,
|
||||
notificationMessage: string,
|
||||
url?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// 获取用户信息
|
||||
const userResult = await db
|
||||
.select({
|
||||
name: users.name,
|
||||
meowNickname: users.meowNickname,
|
||||
id: users.id
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
const user = userResult[0]
|
||||
|
||||
// 获取用户通知设置
|
||||
let userNotificationSettings = null
|
||||
if (user) {
|
||||
const settingsResult = await db
|
||||
.select({
|
||||
enabled: notificationSettings.enabled
|
||||
})
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, userId))
|
||||
.limit(1)
|
||||
userNotificationSettings = settingsResult[0]
|
||||
}
|
||||
|
||||
// 检查用户是否绑定了 MeoW 账号
|
||||
if (!user?.meowNickname) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查用户是否启用了通知
|
||||
if (userNotificationSettings && !userNotificationSettings.enabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否有其他用户绑定了相同的 MeoW ID
|
||||
const usersWithSameMeowId = await db
|
||||
.select({
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
.where(and(eq(users.meowNickname, user.meowNickname!), ne(users.id, userId)))
|
||||
|
||||
// 获取站点标题
|
||||
const siteTitle = await getSiteTitle()
|
||||
|
||||
// 构建完整的通知标题
|
||||
const fullTitle = `${notificationTitle} | ${siteTitle}通知推送`
|
||||
|
||||
// 如果有多个用户绑定了相同的 MeoW ID,在消息中加上用户名备注
|
||||
let enhancedMessage = notificationMessage
|
||||
if (usersWithSameMeowId.length > 0) {
|
||||
// 检查消息是否以"您"开头,如果是则替换,否则添加前缀
|
||||
if (notificationMessage.startsWith('您')) {
|
||||
enhancedMessage = `您(${user.name})${notificationMessage.substring(1)}`
|
||||
} else {
|
||||
enhancedMessage = `您(${user.name})${notificationMessage}`
|
||||
}
|
||||
}
|
||||
|
||||
// 发送 MeoW 通知
|
||||
return await sendMeowNotification(user.meowNickname, fullTitle, enhancedMessage, url)
|
||||
} catch (error) {
|
||||
console.error('发送用户 MeoW 通知失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送 MeoW 通知
|
||||
*/
|
||||
export async function sendBatchMeowNotifications(
|
||||
userIds: number[],
|
||||
notificationTitle: string,
|
||||
notificationMessage: string,
|
||||
url?: string
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
let success = 0
|
||||
let failed = 0
|
||||
|
||||
// 获取所有绑定了 MeoW 的用户
|
||||
const usersWithMeow = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
meowNickname: users.meowNickname
|
||||
})
|
||||
.from(users)
|
||||
.where(and(inArray(users.id, userIds), isNotNull(users.meowNickname)))
|
||||
|
||||
// 获取这些用户的通知设置
|
||||
const userNotificationSettingsMap = new Map()
|
||||
if (usersWithMeow.length > 0) {
|
||||
const settingsResults = await db
|
||||
.select({
|
||||
userId: notificationSettings.userId,
|
||||
enabled: notificationSettings.enabled
|
||||
})
|
||||
.from(notificationSettings)
|
||||
.where(
|
||||
inArray(
|
||||
notificationSettings.userId,
|
||||
usersWithMeow.map((u) => u.id)
|
||||
)
|
||||
)
|
||||
|
||||
settingsResults.forEach((setting) => {
|
||||
userNotificationSettingsMap.set(setting.userId, setting)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取站点标题
|
||||
const siteTitle = await getSiteTitle()
|
||||
const fullTitle = `${notificationTitle} | ${siteTitle}通知推送`
|
||||
|
||||
// 统计每个 MeoW ID 绑定的用户数量
|
||||
const meowIdCounts = new Map<string, number>()
|
||||
usersWithMeow.forEach((user) => {
|
||||
const count = meowIdCounts.get(user.meowNickname!) || 0
|
||||
meowIdCounts.set(user.meowNickname!, count + 1)
|
||||
})
|
||||
|
||||
// 并发发送通知(限制并发数)
|
||||
const batchSize = 5
|
||||
for (let i = 0; i < usersWithMeow.length; i += batchSize) {
|
||||
const batch = usersWithMeow.slice(i, i + batchSize)
|
||||
|
||||
const promises = batch.map(async (user) => {
|
||||
// 检查用户是否启用了通知
|
||||
const userSettings = userNotificationSettingsMap.get(user.id)
|
||||
if (userSettings && !userSettings.enabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果该 MeoW ID 绑定了多个用户,在消息中加上用户名备注
|
||||
let enhancedMessage = notificationMessage
|
||||
const userCount = meowIdCounts.get(user.meowNickname!)
|
||||
if (userCount && userCount > 1) {
|
||||
// 检查消息是否以"您"开头,如果是则替换,否则添加前缀
|
||||
if (notificationMessage.startsWith('您')) {
|
||||
enhancedMessage = `您(${user.name})${notificationMessage.substring(1)}`
|
||||
} else {
|
||||
enhancedMessage = `您(${user.name})${notificationMessage}`
|
||||
}
|
||||
}
|
||||
|
||||
return await sendMeowNotification(user.meowNickname!, fullTitle, enhancedMessage, url)
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
success++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { success, failed }
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
notifications,
|
||||
notificationSettings,
|
||||
playTimes,
|
||||
schedules,
|
||||
songCollaborators,
|
||||
songs,
|
||||
users,
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { and, eq, gte, inArray } from 'drizzle-orm'
|
||||
import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService'
|
||||
import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService'
|
||||
import { formatDateTime, getBeijingTime } from '~/utils/timeUtils'
|
||||
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
|
||||
import {
|
||||
createNotificationSenderSnapshot,
|
||||
resolveNotificationSource,
|
||||
shouldDeliverSystemNotification,
|
||||
type NotificationSenderInput
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* 创建联合投稿邀请通知
|
||||
*/
|
||||
export async function createCollaborationInvitationNotification(
|
||||
inviterId: number,
|
||||
inviteeId: number,
|
||||
songId: number,
|
||||
songTitle: string
|
||||
) {
|
||||
try {
|
||||
const inviter = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, inviterId))
|
||||
.limit(1)
|
||||
.then((res) => res[0])
|
||||
const message = `用户 ${inviter?.name || '未知用户'} 邀请您共同投稿歌曲《${songTitle}》。`
|
||||
|
||||
// 创建通知
|
||||
const notificationResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId: inviteeId,
|
||||
type: 'COLLABORATION_INVITE',
|
||||
message,
|
||||
songId
|
||||
})
|
||||
.returning()
|
||||
|
||||
// 发送外部通知(MeoW, Email等)
|
||||
try {
|
||||
await sendMeowNotificationToUser(inviteeId, '收到联合投稿邀请', message)
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
await sendEmailNotificationToUser(
|
||||
inviteeId,
|
||||
'收到联合投稿邀请',
|
||||
message,
|
||||
undefined,
|
||||
'notification.collaborationInvite',
|
||||
{
|
||||
inviterName: inviter?.name || '未知用户',
|
||||
songTitle
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('发送邮件通知失败:', error)
|
||||
}
|
||||
|
||||
return notificationResult[0]
|
||||
} catch (error) {
|
||||
console.error('创建联合投稿邀请通知失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建联合投稿回复通知
|
||||
*/
|
||||
export async function createCollaborationResponseNotification(
|
||||
inviteeId: number,
|
||||
inviterId: number,
|
||||
songTitle: string,
|
||||
accepted: boolean
|
||||
) {
|
||||
try {
|
||||
const invitee = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, inviteeId))
|
||||
.limit(1)
|
||||
.then((res) => res[0])
|
||||
const actionText = accepted ? '接受' : '拒绝'
|
||||
const message = `用户 ${invitee?.name || '未知用户'} ${actionText}了您的歌曲《${songTitle}》的联合投稿邀请。`
|
||||
|
||||
// 创建通知
|
||||
const notificationResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId: inviterId,
|
||||
type: 'COLLABORATION_RESPONSE',
|
||||
message
|
||||
// songId // 这里可能不需要songId,或者需要传进来
|
||||
})
|
||||
.returning()
|
||||
|
||||
return notificationResult[0]
|
||||
} catch (error) {
|
||||
console.error('创建联合投稿回复通知失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建歌曲被选中的通知
|
||||
*/
|
||||
export async function createSongSelectedNotification(
|
||||
userId: number,
|
||||
songId: number,
|
||||
songInfo: {
|
||||
title: string
|
||||
artist: string
|
||||
playDate: Date
|
||||
}
|
||||
) {
|
||||
try {
|
||||
// 获取系统设置,检查是否启用播出时段功能
|
||||
const systemConfig = await getSystemSettingsCached()
|
||||
const isPlayTimeEnabled = systemConfig?.enablePlayTimeSelection || false
|
||||
|
||||
// 获取排期对应的播出时段
|
||||
const scheduleResult = await db
|
||||
.select({
|
||||
id: schedules.id,
|
||||
songId: schedules.songId,
|
||||
playDate: schedules.playDate,
|
||||
playTimeId: schedules.playTimeId,
|
||||
playTime: {
|
||||
id: playTimes.id,
|
||||
name: playTimes.name,
|
||||
startTime: playTimes.startTime,
|
||||
endTime: playTimes.endTime
|
||||
}
|
||||
})
|
||||
.from(schedules)
|
||||
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
|
||||
.where(and(eq(schedules.songId, songId), eq(schedules.playDate, songInfo.playDate)))
|
||||
.limit(1)
|
||||
const schedule = scheduleResult[0]
|
||||
|
||||
// 获取用户通知设置
|
||||
const settingsResult = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, userId))
|
||||
.limit(1)
|
||||
const settings = settingsResult[0]
|
||||
|
||||
// 如果用户关闭了此类通知,则不发送
|
||||
if (settings && !settings.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建通知,根据播出时段功能启用状态决定显示内容
|
||||
let message = `您投稿的歌曲《${songInfo.title}》已被安排播放,播放日期:${formatDate(songInfo.playDate)}。`
|
||||
|
||||
// 只有在启用播出时段功能且有播出时段信息时,才添加播出时段详情
|
||||
if (isPlayTimeEnabled && schedule?.playTime) {
|
||||
let timeInfo = ''
|
||||
|
||||
// 根据开始和结束时间的情况,格式化显示
|
||||
if (schedule.playTime.startTime && schedule.playTime.endTime) {
|
||||
timeInfo = `(${schedule.playTime.startTime}-${schedule.playTime.endTime})`
|
||||
} else if (schedule.playTime.startTime) {
|
||||
timeInfo = `(开始时间:${schedule.playTime.startTime})`
|
||||
} else if (schedule.playTime.endTime) {
|
||||
timeInfo = `(结束时间:${schedule.playTime.endTime})`
|
||||
}
|
||||
|
||||
message += `播出时段:${schedule.playTime.name}${timeInfo ? ' ' + timeInfo : ''}。`
|
||||
}
|
||||
|
||||
let notification
|
||||
try {
|
||||
const notificationResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId,
|
||||
type: 'SONG_SELECTED',
|
||||
message,
|
||||
songId
|
||||
})
|
||||
.returning()
|
||||
notification = notificationResult[0]
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 同步发送 MeoW 通知
|
||||
try {
|
||||
await sendMeowNotificationToUser(userId, '收到新选中', message)
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
await sendEmailNotificationToUser(
|
||||
userId,
|
||||
'歌曲被选中',
|
||||
message,
|
||||
undefined,
|
||||
'notification.songSelected',
|
||||
{
|
||||
songTitle: songInfo.title,
|
||||
playDate: formatDate(songInfo.playDate),
|
||||
playTimeName: isPlayTimeEnabled && schedule?.playTime ? schedule.playTime.name : '',
|
||||
playTimeRange:
|
||||
isPlayTimeEnabled &&
|
||||
schedule?.playTime &&
|
||||
(schedule.playTime.startTime || schedule.playTime.endTime)
|
||||
? `${schedule.playTime.startTime || ''}${schedule.playTime.startTime && schedule.playTime.endTime ? '-' : ''}${schedule.playTime.endTime || ''}`
|
||||
: ''
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('发送邮件通知失败:', error)
|
||||
}
|
||||
|
||||
return notification
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建重播申请已安排排期的通知(发送给重播申请人)
|
||||
*/
|
||||
export async function createReplaySongSelectedNotification(
|
||||
userId: number,
|
||||
songId: number,
|
||||
songInfo: {
|
||||
title: string
|
||||
artist: string
|
||||
playDate: Date
|
||||
},
|
||||
scheduleId?: number
|
||||
) {
|
||||
try {
|
||||
// 获取系统设置,检查是否启用播出时段功能
|
||||
const systemConfig = await getSystemSettingsCached()
|
||||
const isPlayTimeEnabled = systemConfig?.enablePlayTimeSelection || false
|
||||
|
||||
// 优先按排期 ID 精确查询,避免同日同歌多排期时取错时段
|
||||
const scheduleResult = await db
|
||||
.select({
|
||||
id: schedules.id,
|
||||
playTime: {
|
||||
id: playTimes.id,
|
||||
name: playTimes.name,
|
||||
startTime: playTimes.startTime,
|
||||
endTime: playTimes.endTime
|
||||
}
|
||||
})
|
||||
.from(schedules)
|
||||
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
|
||||
.where(
|
||||
scheduleId
|
||||
? eq(schedules.id, scheduleId)
|
||||
: and(eq(schedules.songId, songId), eq(schedules.playDate, songInfo.playDate))
|
||||
)
|
||||
.limit(1)
|
||||
const schedule = scheduleResult[0]
|
||||
|
||||
// 获取用户通知设置
|
||||
const settingsResult = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, userId))
|
||||
.limit(1)
|
||||
const settings = settingsResult[0]
|
||||
|
||||
if (settings && !settings.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
let message = `您申请重播的歌曲《${songInfo.title}》已安排播放,播放日期:${formatDate(songInfo.playDate)}。`
|
||||
|
||||
if (isPlayTimeEnabled && schedule?.playTime) {
|
||||
let timeInfo = ''
|
||||
if (schedule.playTime.startTime && schedule.playTime.endTime) {
|
||||
timeInfo = `(${schedule.playTime.startTime}-${schedule.playTime.endTime})`
|
||||
} else if (schedule.playTime.startTime) {
|
||||
timeInfo = `(开始时间:${schedule.playTime.startTime})`
|
||||
} else if (schedule.playTime.endTime) {
|
||||
timeInfo = `(结束时间:${schedule.playTime.endTime})`
|
||||
}
|
||||
message += `播出时段:${schedule.playTime.name}${timeInfo ? ' ' + timeInfo : ''}。`
|
||||
}
|
||||
|
||||
let notification
|
||||
try {
|
||||
const notificationResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId,
|
||||
type: 'SONG_SELECTED',
|
||||
message,
|
||||
songId
|
||||
})
|
||||
.returning()
|
||||
notification = notificationResult[0]
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMeowNotificationToUser(userId, '重播已安排', message)
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
await sendEmailNotificationToUser(
|
||||
userId,
|
||||
'重播申请已安排',
|
||||
message,
|
||||
undefined,
|
||||
'notification.replaySongSelected',
|
||||
{
|
||||
songTitle: songInfo.title,
|
||||
playDate: formatDate(songInfo.playDate),
|
||||
playTimeName: isPlayTimeEnabled && schedule?.playTime ? schedule.playTime.name : ''
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('发送邮件通知失败:', error)
|
||||
}
|
||||
|
||||
return notification
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期为 yyyy-MM-dd 格式(北京时间)
|
||||
function formatDate(date: Date): string {
|
||||
return formatDateTime(date, 'YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建歌曲已播放的通知
|
||||
*/
|
||||
export async function createSongPlayedNotification(songId: number) {
|
||||
try {
|
||||
// 获取歌曲信息
|
||||
const songResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
|
||||
const song = songResult[0]
|
||||
|
||||
if (!song) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取用户通知设置
|
||||
const settingsResult = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, song.requesterId))
|
||||
.limit(1)
|
||||
const settings = settingsResult[0]
|
||||
|
||||
// 如果用户关闭了此类通知,则不发送
|
||||
if (settings && !settings.songPlayedEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建通知
|
||||
const message = `您投稿的歌曲《${song.title}》已播放。`
|
||||
|
||||
// 获取所有关联用户(投稿人 + 联合投稿人)
|
||||
const userIdsToNotify = [song.requesterId]
|
||||
|
||||
// 获取联合投稿人
|
||||
const collaborators = await db
|
||||
.select()
|
||||
.from(songCollaborators)
|
||||
.where(and(eq(songCollaborators.songId, songId), eq(songCollaborators.status, 'ACCEPTED')))
|
||||
|
||||
collaborators.forEach((c) => {
|
||||
if (!userIdsToNotify.includes(c.userId)) {
|
||||
userIdsToNotify.push(c.userId)
|
||||
}
|
||||
})
|
||||
|
||||
const notificationsCreated = []
|
||||
|
||||
for (const targetUserId of userIdsToNotify) {
|
||||
try {
|
||||
// 获取用户通知设置
|
||||
const settingsResult = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, targetUserId))
|
||||
.limit(1)
|
||||
const settings = settingsResult[0]
|
||||
|
||||
// 如果用户关闭了此类通知,则不发送
|
||||
if (settings && !settings.songPlayedEnabled) {
|
||||
continue
|
||||
}
|
||||
|
||||
const userMessage =
|
||||
targetUserId === song.requesterId
|
||||
? message
|
||||
: `您参与联合投稿的歌曲《${song.title}》已播放。`
|
||||
|
||||
const notificationResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId: targetUserId,
|
||||
type: 'SONG_PLAYED',
|
||||
message: userMessage,
|
||||
songId: songId
|
||||
})
|
||||
.returning()
|
||||
notificationsCreated.push(notificationResult[0])
|
||||
|
||||
// 同步发送 MeoW 通知
|
||||
try {
|
||||
await sendMeowNotificationToUser(targetUserId, '歌曲已播放', userMessage)
|
||||
} catch (error) {
|
||||
console.error(`发送 MeoW 通知失败 (User: ${targetUserId}):`, error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
await sendEmailNotificationToUser(
|
||||
targetUserId,
|
||||
'歌曲已播放',
|
||||
userMessage,
|
||||
undefined,
|
||||
'notification.songPlayed',
|
||||
{
|
||||
songTitle: song.title
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`发送邮件通知失败 (User: ${targetUserId}):`, error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`处理播放通知失败 (User: ${targetUserId}):`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return notificationsCreated.length > 0 ? notificationsCreated[0] : null
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建歌曲获得投票的通知
|
||||
*/
|
||||
export async function createSongVotedNotification(songId: number, voterId: number) {
|
||||
try {
|
||||
// 获取歌曲信息
|
||||
const songResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
|
||||
const song = songResult[0]
|
||||
|
||||
if (!song) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取歌曲的投票信息
|
||||
const songVotes = await db.select().from(votes).where(eq(votes.songId, songId))
|
||||
|
||||
// 获取投票用户信息
|
||||
const voterResult = await db.select().from(users).where(eq(users.id, voterId)).limit(1)
|
||||
const voter = voterResult[0]
|
||||
|
||||
if (!voter) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取用户通知设置
|
||||
const settingsResult = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, song.requesterId))
|
||||
.limit(1)
|
||||
const settings = settingsResult[0]
|
||||
|
||||
// 如果用户关闭了此类通知,则不发送
|
||||
if (settings && !settings.songVotedEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 不要给自己投票发通知
|
||||
if (song.requesterId === voterId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查投票阈值
|
||||
const threshold = settings?.songVotedThreshold || 1
|
||||
if (songVotes.length % threshold !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const message = `您投稿的歌曲《${song.title}》获得了一个新的投票,当前共有 ${songVotes.length} 个投票。`
|
||||
|
||||
// 防重复通知:检查最近5分钟内是否有相同歌曲的投票通知
|
||||
const fiveMinutesAgo = new Date(getBeijingTime().getTime() - 5 * 60 * 1000)
|
||||
|
||||
const existingNotificationResult = await db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.userId, song.requesterId),
|
||||
eq(notifications.type, 'SONG_VOTED'),
|
||||
eq(notifications.songId, songId),
|
||||
eq(notifications.read, false),
|
||||
gte(notifications.createdAt, fiveMinutesAgo)
|
||||
)
|
||||
)
|
||||
.orderBy(notifications.createdAt)
|
||||
.limit(1)
|
||||
const existingNotification = existingNotificationResult[0]
|
||||
|
||||
let notification
|
||||
if (existingNotification) {
|
||||
// 更新现有通知
|
||||
const updateResult = await db
|
||||
.update(notifications)
|
||||
.set({
|
||||
message
|
||||
})
|
||||
.where(eq(notifications.id, existingNotification.id))
|
||||
.returning()
|
||||
notification = updateResult[0]
|
||||
} else {
|
||||
// 创建新通知
|
||||
const createResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId: song.requesterId,
|
||||
type: 'SONG_VOTED',
|
||||
message,
|
||||
songId: songId
|
||||
})
|
||||
.returning()
|
||||
notification = createResult[0]
|
||||
}
|
||||
|
||||
// 同步发送 MeoW 通知
|
||||
try {
|
||||
await sendMeowNotificationToUser(song.requesterId, '收到新投票', message)
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
await sendEmailNotificationToUser(
|
||||
song.requesterId,
|
||||
'收到新投票',
|
||||
message,
|
||||
undefined,
|
||||
'notification.songVoted',
|
||||
{
|
||||
songTitle: song.title,
|
||||
votesCount: songVotes.length
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('发送邮件通知失败:', error)
|
||||
}
|
||||
|
||||
return notification
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建歌曲驳回通知
|
||||
*/
|
||||
export async function createSongRejectedNotification(
|
||||
userId: number,
|
||||
songInfo: { title: string; artist: string },
|
||||
reason: string
|
||||
) {
|
||||
try {
|
||||
// 获取用户通知设置
|
||||
const settingsResult = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, userId))
|
||||
.limit(1)
|
||||
const settings = settingsResult[0]
|
||||
|
||||
// 如果用户关闭了通知,则不发送
|
||||
if (settings && !settings.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建通知消息
|
||||
const message = `您投稿的歌曲《${songInfo.title} - ${songInfo.artist}》已被管理员驳回。驳回原因:${reason}`
|
||||
|
||||
// 创建站内通知
|
||||
let notification
|
||||
try {
|
||||
const notificationResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId,
|
||||
type: 'SONG_REJECTED',
|
||||
message
|
||||
})
|
||||
.returning()
|
||||
notification = notificationResult[0]
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 同步发送 MeoW 通知
|
||||
try {
|
||||
await sendMeowNotificationToUser(userId, '歌曲被驳回', message)
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
await sendEmailNotificationToUser(
|
||||
userId,
|
||||
'歌曲被驳回',
|
||||
message,
|
||||
undefined,
|
||||
'notification.songRejected',
|
||||
{
|
||||
songTitle: `${songInfo.title} - ${songInfo.artist}`,
|
||||
reason
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('发送邮件通知失败:', error)
|
||||
}
|
||||
|
||||
return notification
|
||||
} catch (err) {
|
||||
console.error('创建歌曲驳回通知失败:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSubmissionNoteClearedNotification(
|
||||
userIds: number[],
|
||||
songInfo: { title: string; artist: string },
|
||||
reason?: string
|
||||
) {
|
||||
try {
|
||||
const uniqueUserIds = [
|
||||
...new Set(userIds.filter((userId) => Number.isInteger(userId) && userId > 0))
|
||||
]
|
||||
|
||||
if (uniqueUserIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const title = '歌曲备注已被管理员清空'
|
||||
const content = reason?.trim()
|
||||
? `您投稿歌曲《${songInfo.title} - ${songInfo.artist}》的备注已被管理员清空。原因:${reason.trim()}`
|
||||
: `您投稿歌曲《${songInfo.title} - ${songInfo.artist}》的备注已被管理员清空。`
|
||||
|
||||
return await createBatchSystemNotifications(uniqueUserIds, title, content)
|
||||
} catch (error) {
|
||||
console.error('创建歌曲备注清空通知失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建系统通知
|
||||
*/
|
||||
export async function createSystemNotification(
|
||||
userId: number,
|
||||
title: string,
|
||||
content: string,
|
||||
important = false,
|
||||
sender: NotificationSenderInput | null = null
|
||||
) {
|
||||
try {
|
||||
const batchId = randomUUID()
|
||||
const senderSnapshot = createNotificationSenderSnapshot(sender)
|
||||
const source = resolveNotificationSource(sender)
|
||||
// 获取用户通知设置
|
||||
const settingsResult = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, userId))
|
||||
.limit(1)
|
||||
const settings = settingsResult[0]
|
||||
|
||||
// 如果用户关闭了通知,则不发送
|
||||
if (!shouldDeliverSystemNotification(important, settings?.enabled)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建通知
|
||||
const notificationResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId: userId,
|
||||
type: 'SYSTEM_NOTICE',
|
||||
batchId,
|
||||
source,
|
||||
...senderSnapshot,
|
||||
title,
|
||||
message: content,
|
||||
important
|
||||
})
|
||||
.returning()
|
||||
const notification = notificationResult[0]
|
||||
|
||||
// 同步发送 MeoW 通知
|
||||
try {
|
||||
await sendMeowNotificationToUser(userId, title, content)
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
await sendEmailNotificationToUser(userId, title, content)
|
||||
} catch (error) {
|
||||
console.error('发送邮件通知失败:', error)
|
||||
}
|
||||
|
||||
return notification
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建系统通知
|
||||
*/
|
||||
export async function createBatchSystemNotifications(
|
||||
userIds: number[],
|
||||
title: string,
|
||||
content: string,
|
||||
important = false,
|
||||
sender: NotificationSenderInput | null = null
|
||||
) {
|
||||
try {
|
||||
if (!userIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const batchId = randomUUID()
|
||||
const senderSnapshot = createNotificationSenderSnapshot(sender)
|
||||
const source = resolveNotificationSource(sender)
|
||||
|
||||
// 获取用户通知设置
|
||||
const userSettings = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(inArray(notificationSettings.userId, userIds))
|
||||
|
||||
// 构建用户ID到通知设置的映射
|
||||
const settingsMap = new Map()
|
||||
userSettings.forEach((setting) => {
|
||||
settingsMap.set(setting.userId, setting)
|
||||
})
|
||||
|
||||
// 准备要创建的通知数据
|
||||
const notificationsToCreate = []
|
||||
|
||||
for (const userId of userIds) {
|
||||
const settings = settingsMap.get(userId)
|
||||
|
||||
// 如果用户关闭了通知,则不发送
|
||||
if (!shouldDeliverSystemNotification(important, settings?.enabled)) {
|
||||
continue
|
||||
}
|
||||
|
||||
notificationsToCreate.push({
|
||||
userId,
|
||||
type: 'SYSTEM_NOTICE',
|
||||
batchId,
|
||||
source,
|
||||
...senderSnapshot,
|
||||
title,
|
||||
message: content,
|
||||
important
|
||||
})
|
||||
}
|
||||
|
||||
// 如果没有可发送的通知,直接返回
|
||||
if (notificationsToCreate.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 批量创建通知
|
||||
const createdNotifications = await db
|
||||
.insert(notifications)
|
||||
.values(notificationsToCreate)
|
||||
.returning()
|
||||
const notificationCount = { count: createdNotifications.length }
|
||||
|
||||
// 同步发送 MeoW 通知
|
||||
let meowResults = { success: 0, failed: 0 }
|
||||
try {
|
||||
meowResults = await sendBatchMeowNotifications(userIds, title, content)
|
||||
} catch (error) {
|
||||
console.error('批量发送 MeoW 通知失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
let emailResults = { success: 0, failed: 0 }
|
||||
try {
|
||||
emailResults = await sendBatchEmailNotifications(userIds, title, content)
|
||||
} catch (error) {
|
||||
console.error('批量发送邮件通知失败:', error)
|
||||
}
|
||||
|
||||
return {
|
||||
count: notificationCount.count,
|
||||
total: userIds.length,
|
||||
meowNotifications: meowResults,
|
||||
emailNotifications: emailResults
|
||||
}
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建重播申请拒绝通知
|
||||
*/
|
||||
export async function createReplayRequestRejectedNotification(
|
||||
userId: number,
|
||||
songInfo: { title: string; artist: string }
|
||||
) {
|
||||
try {
|
||||
// 获取用户通知设置
|
||||
const settingsResult = await db
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, userId))
|
||||
.limit(1)
|
||||
const settings = settingsResult[0]
|
||||
|
||||
// 如果用户关闭了通知,则不发送
|
||||
if (settings && !settings.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建通知消息
|
||||
const message = `您的重播申请《${songInfo.title}》已被管理员拒绝。`
|
||||
|
||||
// 创建站内通知
|
||||
let notification
|
||||
try {
|
||||
const notificationResult = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
userId,
|
||||
type: 'REPLAY_REJECTED',
|
||||
message
|
||||
})
|
||||
.returning()
|
||||
notification = notificationResult[0]
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 同步发送 MeoW 通知
|
||||
try {
|
||||
await sendMeowNotificationToUser(userId, '重播申请已拒绝', message)
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
await sendEmailNotificationToUser(userId, '重播申请已拒绝', message)
|
||||
} catch (error) {
|
||||
console.error('发送邮件通知失败:', error)
|
||||
}
|
||||
|
||||
return notification
|
||||
} catch (err) {
|
||||
console.error('创建重播申请拒绝通知失败:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
import { getAggregateOAuthLoginTypesOrDefault } from '~~/server/utils/oauth-providers'
|
||||
|
||||
export type { OAuthProvider } from '~~/server/utils/oauth-providers'
|
||||
export {
|
||||
SUPPORTED_OAUTH_PROVIDERS,
|
||||
isSupportedOAuthProvider
|
||||
} from '~~/server/utils/oauth-providers'
|
||||
|
||||
export interface ProviderRuntimeConfig {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
endpoint?: string
|
||||
authorizeUrl?: string
|
||||
tokenUrl?: string
|
||||
userInfoUrl?: string
|
||||
scope?: string
|
||||
displayName?: string
|
||||
userIdField?: string
|
||||
usernameField?: string
|
||||
nameField?: string
|
||||
emailField?: string
|
||||
avatarField?: string
|
||||
loginType?: string
|
||||
loginTypes?: string[]
|
||||
}
|
||||
|
||||
const getSettings = async () => {
|
||||
const settingsResult = await db.select().from(systemSettings).limit(1)
|
||||
return settingsResult[0] || null
|
||||
}
|
||||
|
||||
export const isOAuthProviderEnabled = async (provider: OAuthProvider): Promise<boolean> => {
|
||||
const settings = await getSettings()
|
||||
if (!settings) return false
|
||||
|
||||
if (provider === 'github') return !!settings.githubOAuthEnabled
|
||||
if (provider === 'casdoor') return !!settings.casdoorOAuthEnabled
|
||||
if (provider === 'google') return !!settings.googleOAuthEnabled
|
||||
if (provider === 'oauth2') return !!settings.customOAuthEnabled
|
||||
if (provider === 'aggregate') return !!settings.aggregateOAuthEnabled
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const getProviderRuntimeConfig = async (
|
||||
provider: OAuthProvider
|
||||
): Promise<ProviderRuntimeConfig> => {
|
||||
const settings = await getSettings()
|
||||
|
||||
if (!settings) return {}
|
||||
|
||||
if (provider === 'github') {
|
||||
return {
|
||||
clientId: settings.githubClientId || undefined,
|
||||
clientSecret: settings.githubClientSecret || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'casdoor') {
|
||||
return {
|
||||
clientId: settings.casdoorClientId || undefined,
|
||||
clientSecret: settings.casdoorClientSecret || undefined,
|
||||
endpoint: settings.casdoorServerUrl || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'oauth2') {
|
||||
return {
|
||||
clientId: settings.customOAuthClientId || undefined,
|
||||
clientSecret: settings.customOAuthClientSecret || undefined,
|
||||
authorizeUrl: settings.customOAuthAuthorizeUrl || undefined,
|
||||
tokenUrl: settings.customOAuthTokenUrl || undefined,
|
||||
userInfoUrl: settings.customOAuthUserInfoUrl || undefined,
|
||||
scope: settings.customOAuthScope || undefined,
|
||||
displayName: settings.customOAuthDisplayName || undefined,
|
||||
userIdField: settings.customOAuthUserIdField || undefined,
|
||||
usernameField: settings.customOAuthUsernameField || undefined,
|
||||
nameField: settings.customOAuthNameField || undefined,
|
||||
emailField: settings.customOAuthEmailField || undefined,
|
||||
avatarField: settings.customOAuthAvatarField || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'aggregate') {
|
||||
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
|
||||
return {
|
||||
clientId: settings.aggregateOAuthAppId || undefined,
|
||||
clientSecret: settings.aggregateOAuthAppKey || undefined,
|
||||
loginTypes,
|
||||
endpoint: settings.aggregateOAuthEndpoint || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: settings.googleClientId || undefined,
|
||||
clientSecret: settings.googleClientSecret || undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const getOAuthProviderDisplayName = async (provider: OAuthProvider): Promise<string> => {
|
||||
if (provider !== 'oauth2') {
|
||||
if (provider === 'github') return 'GitHub'
|
||||
if (provider === 'casdoor') return 'Casdoor'
|
||||
if (provider === 'google') return 'Google'
|
||||
if (provider === 'aggregate') return '聚合登陆'
|
||||
}
|
||||
|
||||
const settings = await getSettings()
|
||||
return settings?.customOAuthDisplayName || '第三方 OAuth'
|
||||
}
|
||||
|
||||
export const getOAuthBaseConfig = async (): Promise<{
|
||||
stateSecret?: string
|
||||
redirectUriTemplate?: string
|
||||
}> => {
|
||||
const settings = await getSettings()
|
||||
if (!settings) {
|
||||
return {
|
||||
stateSecret: undefined,
|
||||
redirectUriTemplate: undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stateSecret: settings.oauthStateSecret || undefined,
|
||||
redirectUriTemplate: settings.oauthRedirectUri || undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { getRequestHeader } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { passwordAuditLogs, passwordRateLimits } from '~/drizzle/schema'
|
||||
import { lte, sql } from 'drizzle-orm'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
|
||||
export const PASSWORD_AUDIT_ACTIONS = {
|
||||
INITIAL_PASSWORD: 'INITIAL_PASSWORD',
|
||||
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
|
||||
RESET_PASSWORD: 'RESET_PASSWORD'
|
||||
} as const
|
||||
|
||||
const PASSWORD_RATE_WINDOW_SECONDS = 10 * 60
|
||||
const PASSWORD_IP_LIMIT_MULTIPLIER = 20
|
||||
|
||||
export async function consumePasswordRateLimit(
|
||||
userId: number,
|
||||
ipAddress: string,
|
||||
action: string,
|
||||
limit: number
|
||||
): Promise<{ allowed: boolean; retryAfterSeconds: number }> {
|
||||
const now = new Date()
|
||||
const resetAt = new Date(now.getTime() + PASSWORD_RATE_WINDOW_SECONDS * 1000)
|
||||
const keys = [`user:${action}:${userId}`, `ip:${action}:${ipAddress}`]
|
||||
const comparisonNow = sql.param(now, passwordRateLimits.resetAt)
|
||||
|
||||
const buckets = await db.transaction(async (tx) => {
|
||||
const values: Array<{ count: number; resetAt: Date }> = []
|
||||
for (const key of keys) {
|
||||
// 使用 Node 统一生成的时间,并通过时间列编码器传入 SQL,避免数据库时区影响比较结果。
|
||||
const [result] = await tx
|
||||
.insert(passwordRateLimits)
|
||||
.values({ key, count: 1, resetAt })
|
||||
.onConflictDoUpdate({
|
||||
target: passwordRateLimits.key,
|
||||
set: {
|
||||
count: sql<number>`CASE
|
||||
WHEN ${passwordRateLimits.resetAt} <= ${comparisonNow} THEN 1
|
||||
ELSE ${passwordRateLimits.count} + 1
|
||||
END`,
|
||||
resetAt: sql<Date>`CASE
|
||||
WHEN ${passwordRateLimits.resetAt} <= ${comparisonNow} THEN excluded."resetAt"
|
||||
ELSE ${passwordRateLimits.resetAt}
|
||||
END`
|
||||
}
|
||||
})
|
||||
.returning({
|
||||
count: passwordRateLimits.count,
|
||||
resetAt: passwordRateLimits.resetAt
|
||||
})
|
||||
|
||||
values.push({
|
||||
count: result?.count ?? 1,
|
||||
resetAt: result?.resetAt ?? resetAt
|
||||
})
|
||||
}
|
||||
|
||||
// 惰性清理过期键,避免攻击者通过大量伪造 IP 使限流表无限增长。
|
||||
if (Math.random() < 0.02) {
|
||||
await tx.delete(passwordRateLimits).where(lte(passwordRateLimits.resetAt, now))
|
||||
}
|
||||
|
||||
return values
|
||||
})
|
||||
|
||||
const [userBucket, ipBucket] = buckets
|
||||
const exceededBuckets = [
|
||||
userBucket && userBucket.count > limit ? userBucket : null,
|
||||
ipBucket && ipBucket.count > limit * PASSWORD_IP_LIMIT_MULTIPLIER ? ipBucket : null
|
||||
].filter((bucket): bucket is { count: number; resetAt: Date } => !!bucket)
|
||||
const retryAfterSeconds = exceededBuckets.length
|
||||
? Math.max(
|
||||
1,
|
||||
...exceededBuckets.map((bucket) =>
|
||||
Math.ceil((new Date(bucket.resetAt).getTime() - now.getTime()) / 1000)
|
||||
)
|
||||
)
|
||||
: 0
|
||||
|
||||
return {
|
||||
allowed: exceededBuckets.length === 0,
|
||||
retryAfterSeconds
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordPasswordAudit(
|
||||
event: any,
|
||||
userId: number,
|
||||
action: string,
|
||||
success: boolean,
|
||||
failureReason?: string,
|
||||
actorId?: number | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.insert(passwordAuditLogs).values({
|
||||
userId,
|
||||
actorId: actorId ?? null,
|
||||
action,
|
||||
success,
|
||||
ipAddress: getClientIP(event),
|
||||
userAgent: getRequestHeader(event, 'user-agent') || null,
|
||||
failureReason: failureReason || null
|
||||
})
|
||||
} catch (error) {
|
||||
// 审计失败不应覆盖密码操作结果,但必须保留服务端告警便于补偿。
|
||||
console.error('[PasswordAudit] 写入密码审计日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function getPasswordAuditContext(event: any) {
|
||||
return {
|
||||
ipAddress: getClientIP(event),
|
||||
userAgent: getRequestHeader(event, 'user-agent') || null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
import {
|
||||
RedisStateUnavailableError,
|
||||
setStore,
|
||||
getStore,
|
||||
delStore,
|
||||
incrStore
|
||||
} from '~~/server/utils/captchaStore'
|
||||
import { createSystemNotification } from './notificationService'
|
||||
import { sendMeowNotificationToUser } from './meowNotificationService'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getServerTimestamp, getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
// 账户锁定信息接口
|
||||
interface AccountLockInfo {
|
||||
failedAttempts: number
|
||||
lockedUntil: Date | null
|
||||
lastAttemptTime: Date
|
||||
}
|
||||
|
||||
// IP监控信息接口
|
||||
interface IPMonitorInfo {
|
||||
attemptedAccounts: Set<string>
|
||||
firstAttemptTime: Date
|
||||
lastAttemptTime: Date
|
||||
}
|
||||
|
||||
// IP黑名单信息接口
|
||||
interface IPBlockInfo {
|
||||
blockedUntil: Date
|
||||
reason: string
|
||||
blockedTime: Date
|
||||
}
|
||||
|
||||
// 内存存储
|
||||
const ipMonitor = new Map<string, IPMonitorInfo>()
|
||||
const ipBlacklist = new Map<string, IPBlockInfo>()
|
||||
const accountIpSwitchMonitor = new Map<
|
||||
string,
|
||||
{ ipMap: Map<string, number>; windowStart: number }
|
||||
>()
|
||||
const userBlockUntil = new Map<number, Date>()
|
||||
const songVoteWindow = new Map<number, number[]>()
|
||||
const songProtectUntil = new Map<number, Date>()
|
||||
const songVoteIpBuckets = new Map<number, Map<string, number>>()
|
||||
const userVoteStats = new Map<
|
||||
number,
|
||||
{ emaPerMin: number; lastUpdate: number; windowTimestamps: number[] }
|
||||
>()
|
||||
|
||||
// 通知限流存储,记录最近发送的通知,避免重复发送
|
||||
const notificationRateLimit = new Map<string, Date>()
|
||||
const NOTIFICATION_RATE_LIMIT_MINUTES = 5 // 同一类型通知5分钟内只发送一次
|
||||
const fallbackLoginFailures = new Map<string, { count: number; expiresAt: number }>()
|
||||
const fallbackAccountLocks = new Map<string, number>()
|
||||
const REDIS_FALLBACK_LOG_INTERVAL_MS = 60 * 1000
|
||||
let lastRedisFallbackLogAt = 0
|
||||
|
||||
// 配置常量
|
||||
const SECURITY_CONFIG = {
|
||||
MAX_FAILED_ATTEMPTS: 5,
|
||||
LOCK_DURATION_MINUTES: 10,
|
||||
IP_MONITOR_WINDOW_MINUTES: 10,
|
||||
IP_MAX_DIFFERENT_ACCOUNTS: 3,
|
||||
IP_BLOCK_DURATION_MINUTES: 10
|
||||
}
|
||||
|
||||
const RISK_CONTROL = {
|
||||
IP_SWITCH_WINDOW_MS: 5 * 60 * 1000,
|
||||
IP_SWITCH_THRESHOLD: 3,
|
||||
IP_SWITCH_BLOCK_MINUTES: 10,
|
||||
USER_BLOCK_MINUTES: 10,
|
||||
SONG_VOTE_PROTECT_WINDOW_MS: 60 * 60 * 1000,
|
||||
SONG_VOTE_PROTECT_THRESHOLD: 10,
|
||||
SONG_VOTE_PROTECT_DURATION_MS: 10 * 60 * 1000,
|
||||
USER_BASELINE_MIN_PER_MIN: 0.5,
|
||||
USER_BASELINE_MULTIPLIER: 3,
|
||||
EMA_ALPHA: 0.2,
|
||||
SONG_IP_DOMINANCE_PERCENT: 0.6,
|
||||
SONG_IP_MIN_SAMPLE: 8
|
||||
}
|
||||
|
||||
const handleRedisStateUnavailable = (error: unknown) => {
|
||||
if (!(error instanceof RedisStateUnavailableError)) throw error
|
||||
|
||||
const now = getServerTimestamp()
|
||||
if (now - lastRedisFallbackLogAt >= REDIS_FALLBACK_LOG_INTERVAL_MS) {
|
||||
console.warn('[Security] Redis 暂不可用,登录失败计数临时回退到当前实例内存')
|
||||
lastRedisFallbackLogAt = now
|
||||
}
|
||||
}
|
||||
|
||||
const getFallbackFailureCount = (username: string) => {
|
||||
const record = fallbackLoginFailures.get(username)
|
||||
if (!record) return 0
|
||||
if (record.expiresAt <= getServerTimestamp()) {
|
||||
fallbackLoginFailures.delete(username)
|
||||
return 0
|
||||
}
|
||||
return record.count
|
||||
}
|
||||
|
||||
const incrementFallbackFailureCount = (username: string) => {
|
||||
const now = getServerTimestamp()
|
||||
if (fallbackLoginFailures.size >= 10000) {
|
||||
for (const [key, record] of fallbackLoginFailures.entries()) {
|
||||
if (record.expiresAt <= now) fallbackLoginFailures.delete(key)
|
||||
}
|
||||
if (fallbackLoginFailures.size >= 10000 && !fallbackLoginFailures.has(username)) {
|
||||
const oldestKey = fallbackLoginFailures.keys().next().value
|
||||
if (oldestKey !== undefined) fallbackLoginFailures.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
const current = fallbackLoginFailures.get(username)
|
||||
const count = current && current.expiresAt > now ? current.count + 1 : 1
|
||||
fallbackLoginFailures.set(username, {
|
||||
count,
|
||||
expiresAt: current && current.expiresAt > now ? current.expiresAt : now + 30 * 60 * 1000
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
const getFallbackLockedUntil = (username: string) => {
|
||||
const lockedUntil = fallbackAccountLocks.get(username) || 0
|
||||
if (lockedUntil <= getServerTimestamp()) {
|
||||
fallbackAccountLocks.delete(username)
|
||||
return 0
|
||||
}
|
||||
return lockedUntil
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的锁定记录
|
||||
*/
|
||||
function cleanupExpiredLocks() {
|
||||
const now = getServerDate()
|
||||
|
||||
for (const [username, record] of fallbackLoginFailures.entries()) {
|
||||
if (record.expiresAt <= now.getTime()) fallbackLoginFailures.delete(username)
|
||||
}
|
||||
for (const [username, lockedUntil] of fallbackAccountLocks.entries()) {
|
||||
if (lockedUntil <= now.getTime()) fallbackAccountLocks.delete(username)
|
||||
}
|
||||
|
||||
// 清理过期的IP监控记录
|
||||
for (const [ip, monitorInfo] of ipMonitor.entries()) {
|
||||
const windowStart = new Date(
|
||||
now.getTime() - SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES * 60 * 1000
|
||||
)
|
||||
if (monitorInfo.firstAttemptTime < windowStart) {
|
||||
ipMonitor.delete(ip)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期的IP黑名单记录
|
||||
for (const [ip, blockInfo] of ipBlacklist.entries()) {
|
||||
if (blockInfo.blockedUntil <= now) {
|
||||
ipBlacklist.delete(ip)
|
||||
console.log(`IP ${ip} 已从黑名单中移除`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [username, monitor] of accountIpSwitchMonitor.entries()) {
|
||||
const cutoff = getServerTimestamp() - RISK_CONTROL.IP_SWITCH_WINDOW_MS
|
||||
for (const [ip, ts] of monitor.ipMap.entries()) {
|
||||
if (ts < cutoff) {
|
||||
monitor.ipMap.delete(ip)
|
||||
}
|
||||
}
|
||||
if (monitor.ipMap.size === 0) {
|
||||
accountIpSwitchMonitor.delete(username)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [songId, timestamps] of songVoteWindow.entries()) {
|
||||
const cutoff = getServerTimestamp() - RISK_CONTROL.SONG_VOTE_PROTECT_WINDOW_MS
|
||||
const filtered = timestamps.filter((t) => t >= cutoff)
|
||||
if (filtered.length) {
|
||||
songVoteWindow.set(songId, filtered)
|
||||
} else {
|
||||
songVoteWindow.delete(songId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [userId, stats] of userVoteStats.entries()) {
|
||||
const cutoff = getServerTimestamp() - 10 * 60 * 1000
|
||||
stats.windowTimestamps = stats.windowTimestamps.filter((t) => t >= cutoff)
|
||||
if (
|
||||
stats.windowTimestamps.length === 0 &&
|
||||
getServerTimestamp() - stats.lastUpdate > 60 * 60 * 1000
|
||||
) {
|
||||
userVoteStats.delete(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否被锁定
|
||||
*/
|
||||
export async function isAccountLocked(username: string): Promise<boolean> {
|
||||
const lockKey = `account_lock:${username}`
|
||||
let redisLockedUntil = 0
|
||||
try {
|
||||
const lockInfoStr = await getStore(lockKey)
|
||||
if (lockInfoStr) {
|
||||
const parsed = parseInt(lockInfoStr, 10)
|
||||
if (Number.isFinite(parsed)) redisLockedUntil = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
handleRedisStateUnavailable(error)
|
||||
}
|
||||
|
||||
return Math.max(redisLockedUntil, getFallbackLockedUntil(username)) > getServerTimestamp()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户锁定剩余时间(分钟)
|
||||
*/
|
||||
export async function getAccountLockRemainingTime(username: string): Promise<number> {
|
||||
const lockKey = `account_lock:${username}`
|
||||
let redisLockedUntil = 0
|
||||
try {
|
||||
const lockInfoStr = await getStore(lockKey)
|
||||
if (lockInfoStr) {
|
||||
const parsed = parseInt(lockInfoStr, 10)
|
||||
if (Number.isFinite(parsed)) redisLockedUntil = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
handleRedisStateUnavailable(error)
|
||||
}
|
||||
|
||||
const now = getServerTimestamp()
|
||||
const lockedUntil = Math.max(redisLockedUntil, getFallbackLockedUntil(username))
|
||||
if (lockedUntil <= now) return 0
|
||||
|
||||
return Math.ceil((lockedUntil - now) / (1000 * 60))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查IP是否被限制
|
||||
*/
|
||||
export function isIPBlocked(ip: string): boolean {
|
||||
cleanupExpiredLocks()
|
||||
|
||||
const blockInfo = ipBlacklist.get(ip)
|
||||
if (!blockInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
return blockInfo.blockedUntil > getServerDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取IP限制剩余时间(分钟)
|
||||
*/
|
||||
export function getIPBlockRemainingTime(ip: string): number {
|
||||
const blockInfo = ipBlacklist.get(ip)
|
||||
if (!blockInfo) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const now = getServerDate()
|
||||
if (blockInfo.blockedUntil <= now) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.ceil((blockInfo.blockedUntil.getTime() - now.getTime()) / (1000 * 60))
|
||||
}
|
||||
|
||||
/**
|
||||
* 将IP加入黑名单
|
||||
*/
|
||||
function blockIP(ip: string, reason: string): void {
|
||||
const now = getServerDate()
|
||||
const blockedUntil = new Date(
|
||||
now.getTime() + SECURITY_CONFIG.IP_BLOCK_DURATION_MINUTES * 60 * 1000
|
||||
)
|
||||
|
||||
ipBlacklist.set(ip, {
|
||||
blockedUntil,
|
||||
reason,
|
||||
blockedTime: now
|
||||
})
|
||||
|
||||
console.log(
|
||||
`IP ${ip} 已被加入黑名单,限制时长 ${SECURITY_CONFIG.IP_BLOCK_DURATION_MINUTES} 分钟,原因:${reason}`
|
||||
)
|
||||
}
|
||||
|
||||
export function blockUser(userId: number, minutes: number = RISK_CONTROL.USER_BLOCK_MINUTES): void {
|
||||
const until = new Date(getServerTimestamp() + minutes * 60 * 1000)
|
||||
userBlockUntil.set(userId, until)
|
||||
}
|
||||
|
||||
export function isUserBlocked(userId: number): boolean {
|
||||
const until = userBlockUntil.get(userId)
|
||||
if (!until) return false
|
||||
return until > getServerDate()
|
||||
}
|
||||
|
||||
export function getUserBlockRemainingTime(userId: number): number {
|
||||
const until = userBlockUntil.get(userId)
|
||||
if (!until) return 0
|
||||
const now = getServerTimestamp()
|
||||
if (until.getTime() <= now) return 0
|
||||
return Math.ceil((until.getTime() - now) / (1000 * 60))
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录失败
|
||||
*/
|
||||
export async function recordLoginFailure(username: string, ip: string): Promise<void> {
|
||||
const failKey = `login_fail:${username}`
|
||||
const lockKey = `account_lock:${username}`
|
||||
let failedAttempts: number | null = null
|
||||
|
||||
try {
|
||||
// 增加失败计数,有效期为 30 分钟
|
||||
failedAttempts = Math.max(await incrStore(failKey, 30 * 60), getFallbackFailureCount(username))
|
||||
|
||||
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
|
||||
const lockedUntil = getServerTimestamp() + SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60 * 1000
|
||||
await setStore(lockKey, lockedUntil.toString(), SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60)
|
||||
}
|
||||
} catch (error) {
|
||||
handleRedisStateUnavailable(error)
|
||||
failedAttempts = Math.max(failedAttempts || 0, incrementFallbackFailureCount(username))
|
||||
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
|
||||
fallbackAccountLocks.set(
|
||||
username,
|
||||
getServerTimestamp() + SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60 * 1000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
|
||||
console.log(
|
||||
`账户 ${username} 因连续 ${SECURITY_CONFIG.MAX_FAILED_ATTEMPTS} 次登录失败被锁定 ${SECURITY_CONFIG.LOCK_DURATION_MINUTES} 分钟`
|
||||
)
|
||||
}
|
||||
|
||||
// 记录IP监控信息
|
||||
recordIPAttempt(ip, username)
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录成功登录(清除失败记录)
|
||||
*/
|
||||
export async function recordLoginSuccess(username: string, ip: string): Promise<void> {
|
||||
const failKey = `login_fail:${username}`
|
||||
const lockKey = `account_lock:${username}`
|
||||
try {
|
||||
await Promise.all([delStore(failKey), delStore(lockKey)])
|
||||
} catch (error) {
|
||||
handleRedisStateUnavailable(error)
|
||||
}
|
||||
fallbackLoginFailures.delete(username)
|
||||
fallbackAccountLocks.delete(username)
|
||||
|
||||
// 记录IP监控信息(成功登录也需要监控)
|
||||
recordIPAttempt(ip, username)
|
||||
}
|
||||
|
||||
export function recordAccountIpLogin(username: string, ip: string): boolean {
|
||||
const now = getServerTimestamp()
|
||||
let monitor = accountIpSwitchMonitor.get(username)
|
||||
if (!monitor) {
|
||||
monitor = { ipMap: new Map<string, number>(), windowStart: now }
|
||||
accountIpSwitchMonitor.set(username, monitor)
|
||||
}
|
||||
const cutoff = now - RISK_CONTROL.IP_SWITCH_WINDOW_MS
|
||||
for (const [k, ts] of monitor.ipMap.entries()) {
|
||||
if (ts < cutoff) monitor.ipMap.delete(k)
|
||||
}
|
||||
monitor.ipMap.set(ip, now)
|
||||
const exceeded = monitor.ipMap.size > RISK_CONTROL.IP_SWITCH_THRESHOLD
|
||||
if (exceeded) {
|
||||
blockIP(ip, '账号短期内多IP登录超限')
|
||||
triggerAccountIpSwitchAlert(username, Array.from(monitor.ipMap.keys()))
|
||||
}
|
||||
return exceeded
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录IP登录尝试
|
||||
*/
|
||||
function recordIPAttempt(ip: string, username: string): void {
|
||||
const now = getServerDate()
|
||||
const windowStart = new Date(
|
||||
now.getTime() - SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES * 60 * 1000
|
||||
)
|
||||
|
||||
let monitorInfo = ipMonitor.get(ip)
|
||||
if (!monitorInfo) {
|
||||
monitorInfo = {
|
||||
attemptedAccounts: new Set<string>(),
|
||||
firstAttemptTime: now,
|
||||
lastAttemptTime: now
|
||||
}
|
||||
ipMonitor.set(ip, monitorInfo)
|
||||
}
|
||||
|
||||
// 如果监控窗口已过期,重置记录
|
||||
if (monitorInfo.firstAttemptTime < windowStart) {
|
||||
monitorInfo.attemptedAccounts.clear()
|
||||
monitorInfo.firstAttemptTime = now
|
||||
}
|
||||
|
||||
monitorInfo.attemptedAccounts.add(username)
|
||||
monitorInfo.lastAttemptTime = now
|
||||
|
||||
// 检查是否触发异常行为警报
|
||||
if (monitorInfo.attemptedAccounts.size > SECURITY_CONFIG.IP_MAX_DIFFERENT_ACCOUNTS) {
|
||||
triggerSecurityAlert(ip, Array.from(monitorInfo.attemptedAccounts))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发安全警报
|
||||
*/
|
||||
async function triggerSecurityAlert(ip: string, attemptedAccounts: string[]): Promise<void> {
|
||||
try {
|
||||
const now = getServerDate()
|
||||
const alertTitle = '安全警报:检测到异常登录行为'
|
||||
const alertContent = `
|
||||
检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
|
||||
异常IP:${ip}
|
||||
时间窗口:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内
|
||||
尝试登录账户数:${attemptedAccounts.length}
|
||||
涉及账户:${attemptedAccounts.join(', ')}
|
||||
|
||||
建议立即检查该IP的登录活动并采取必要的安全措施。
|
||||
`.trim()
|
||||
|
||||
// Meow通知内容,包含完整的涉及账户信息
|
||||
const meowAlertContent = `
|
||||
检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
|
||||
异常IP:${ip}
|
||||
时间窗口:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内
|
||||
尝试登录账户数:${attemptedAccounts.length}
|
||||
涉及账户:${attemptedAccounts.join(', ')}
|
||||
|
||||
建议立即检查该IP的登录活动并采取必要的安全措施。
|
||||
`.trim()
|
||||
|
||||
console.log(
|
||||
`安全警报:IP ${ip} 在 ${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES} 分钟内尝试登录 ${attemptedAccounts.length} 个不同账户`
|
||||
)
|
||||
|
||||
// 将触发警报的IP加入黑名单
|
||||
blockIP(
|
||||
ip,
|
||||
`异常登录行为:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内尝试登录${attemptedAccounts.length}个不同账户`
|
||||
)
|
||||
|
||||
// 获取所有超级管理员
|
||||
const superAdmins = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
meowNickname: users.meowNickname
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.role, 'SUPER_ADMIN'))
|
||||
|
||||
// 向所有超级管理员发送站内信
|
||||
for (const admin of superAdmins) {
|
||||
try {
|
||||
await createSystemNotification(admin.id, alertTitle, alertContent)
|
||||
console.log(`已向超级管理员 ${admin.name} 发送安全警报站内信`)
|
||||
|
||||
// 如果管理员绑定了Meow推送,同时发送推送通知
|
||||
if (admin.meowNickname) {
|
||||
const success = await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
|
||||
|
||||
if (success) {
|
||||
console.log(`已向超级管理员 ${admin.name} 发送Meow推送警报`)
|
||||
} else {
|
||||
console.log(`向超级管理员 ${admin.name} 发送Meow推送警报失败`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`向超级管理员 ${admin.name} 发送安全警报失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置该IP的监控记录,避免重复警报
|
||||
ipMonitor.delete(ip)
|
||||
} catch (error) {
|
||||
console.error('触发安全警报时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerAccountIpSwitchAlert(username: string, ips: string[]): Promise<void> {
|
||||
try {
|
||||
const now = getServerDate()
|
||||
const alertTitle = '安全警报:账号短期内多IP登录'
|
||||
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
|
||||
账号:${username}
|
||||
时间窗口:${Math.floor(RISK_CONTROL.IP_SWITCH_WINDOW_MS / 60000)}分钟内
|
||||
涉及IP数:${ips.length}
|
||||
|
||||
建议立即检查该账号的登录活动并采取必要的安全措施。`
|
||||
const meowAlertContent = alertContent
|
||||
const superAdmins = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
meowNickname: users.meowNickname
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.role, 'SUPER_ADMIN'))
|
||||
for (const admin of superAdmins) {
|
||||
try {
|
||||
await createSystemNotification(admin.id, alertTitle, alertContent)
|
||||
if (admin.meowNickname) {
|
||||
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('触发账号IP切换警报时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function ipBucketOf(ip: string): string {
|
||||
const parts = ip.split('.')
|
||||
if (parts.length === 4) return `${parts[0]}.${parts[1]}.${parts[2]}`
|
||||
return ip
|
||||
}
|
||||
|
||||
export function isSongProtected(songId: number): boolean {
|
||||
const until = songProtectUntil.get(songId)
|
||||
if (!until) return false
|
||||
return until > getServerDate()
|
||||
}
|
||||
|
||||
export function getSongProtectRemainingSeconds(songId: number): number {
|
||||
const until = songProtectUntil.get(songId)
|
||||
if (!until) return 0
|
||||
const now = getServerTimestamp()
|
||||
if (until.getTime() <= now) return 0
|
||||
return Math.ceil((until.getTime() - now) / 1000)
|
||||
}
|
||||
|
||||
export function recordSongVote(songId: number, ip: string, userId: number): boolean {
|
||||
const now = getServerTimestamp()
|
||||
const arr = songVoteWindow.get(songId) || []
|
||||
const cutoff = now - RISK_CONTROL.SONG_VOTE_PROTECT_WINDOW_MS
|
||||
const filtered = arr.filter((t) => t >= cutoff)
|
||||
filtered.push(now)
|
||||
songVoteWindow.set(songId, filtered)
|
||||
const buckets = songVoteIpBuckets.get(songId) || new Map<string, number>()
|
||||
const b = ipBucketOf(ip)
|
||||
buckets.set(b, (buckets.get(b) || 0) + 1)
|
||||
songVoteIpBuckets.set(songId, buckets)
|
||||
if (filtered.length > RISK_CONTROL.SONG_VOTE_PROTECT_THRESHOLD) {
|
||||
songProtectUntil.set(songId, new Date(now + RISK_CONTROL.SONG_VOTE_PROTECT_DURATION_MS))
|
||||
triggerSongVoteBurstAlert(songId, filtered.length, buckets)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function recordUserVoteActivity(userId: number, songTitle?: string): { anomaly: boolean } {
|
||||
const now = getServerTimestamp()
|
||||
let stats = userVoteStats.get(userId)
|
||||
if (!stats) {
|
||||
stats = { emaPerMin: 0, lastUpdate: now, windowTimestamps: [] }
|
||||
userVoteStats.set(userId, stats)
|
||||
}
|
||||
|
||||
// 更新时间窗口,保留最近10分钟的投票记录
|
||||
const cutoff = now - 10 * 60 * 1000
|
||||
stats.windowTimestamps = stats.windowTimestamps.filter((t) => t >= cutoff)
|
||||
stats.windowTimestamps.push(now)
|
||||
|
||||
// 计算当前10分钟窗口内的平均速率,而不仅仅是两次投票的间隔
|
||||
// 如果只有一个投票记录,无法计算速率(时间跨度为0),且单个投票不构成"速率",直接视为0
|
||||
let currentRate = 0
|
||||
if (stats.windowTimestamps.length > 1) {
|
||||
const windowDurationMin = Math.max(1e-6, (now - Math.min(...stats.windowTimestamps)) / 60000)
|
||||
currentRate = stats.windowTimestamps.length / windowDurationMin
|
||||
}
|
||||
|
||||
// 调整EMA_ALPHA值为0.1,使EMA更平滑,减少短期波动影响
|
||||
const smoothedEmaAlpha = 0.1
|
||||
stats.emaPerMin = smoothedEmaAlpha * currentRate + (1 - smoothedEmaAlpha) * stats.emaPerMin
|
||||
stats.lastUpdate = now
|
||||
|
||||
// 动态调整阈值:基础阈值 + EMA的动态倍数
|
||||
// 当EMA较低时,使用较高倍数;当EMA较高时,使用较低倍数,避免误报
|
||||
const baseThreshold = 5 // 基础阈值:每分钟5票
|
||||
const dynamicMultiplier = Math.max(2, 5 - Math.min(3, stats.emaPerMin / 100)) // 动态倍数:2-5倍
|
||||
const threshold = Math.max(baseThreshold, stats.emaPerMin * dynamicMultiplier)
|
||||
|
||||
// 只有当当前速率显著超过阈值(1.5倍)时,才触发异常
|
||||
const anomaly = currentRate > threshold * 1.5
|
||||
if (anomaly) triggerVoteAnomalyAlert(userId, stats.emaPerMin, currentRate, songTitle)
|
||||
return { anomaly }
|
||||
}
|
||||
|
||||
async function triggerSongVoteBurstAlert(
|
||||
songId: number,
|
||||
count: number,
|
||||
buckets: Map<string, number>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const now = getServerDate()
|
||||
const top = Array.from(buckets.entries()).sort((a, b) => b[1] - a[1])[0]
|
||||
const alertTitle = '风险告警:歌曲投票短时激增'
|
||||
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
|
||||
歌曲ID:${songId}
|
||||
窗口内票数:${count}
|
||||
主导IP段:${top ? `${top[0]}.* (${top[1]})` : '无'}
|
||||
|
||||
已启动临时保护,暂停投票10分钟。`
|
||||
const meowAlertContent = alertContent
|
||||
const superAdmins = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
meowNickname: users.meowNickname
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.role, 'SUPER_ADMIN'))
|
||||
for (const admin of superAdmins) {
|
||||
try {
|
||||
await createSystemNotification(admin.id, alertTitle, alertContent)
|
||||
if (admin.meowNickname) {
|
||||
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('触发歌曲投票激增警报时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查通知是否可以发送(限流控制)
|
||||
function canSendNotification(key: string): boolean {
|
||||
const now = getServerDate()
|
||||
const lastSent = notificationRateLimit.get(key)
|
||||
if (
|
||||
lastSent &&
|
||||
now.getTime() - lastSent.getTime() < NOTIFICATION_RATE_LIMIT_MINUTES * 60 * 1000
|
||||
) {
|
||||
return false
|
||||
}
|
||||
notificationRateLimit.set(key, now)
|
||||
return true
|
||||
}
|
||||
|
||||
interface AnomalyEvent {
|
||||
userId: number
|
||||
ema: number
|
||||
rate: number
|
||||
songTitle?: string
|
||||
time: Date
|
||||
}
|
||||
|
||||
const pendingAnomalies: AnomalyEvent[] = []
|
||||
let anomalyAggregationTimer: NodeJS.Timeout | null = null
|
||||
const ANOMALY_AGGREGATION_WINDOW_MS = 60 * 1000 // 1分钟聚合窗口
|
||||
|
||||
async function flushAnomalyAggregation() {
|
||||
if (pendingAnomalies.length === 0) {
|
||||
anomalyAggregationTimer = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const count = pendingAnomalies.length
|
||||
// 统计涉及的歌曲
|
||||
const songStats = new Map<string, number>()
|
||||
pendingAnomalies.forEach((e) => {
|
||||
const title = e.songTitle || '未知歌曲'
|
||||
songStats.set(title, (songStats.get(title) || 0) + 1)
|
||||
})
|
||||
|
||||
// 按次数排序,取前5
|
||||
const topSongs = Array.from(songStats.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([title, c]) => `- ${title} (${c}人)`)
|
||||
.join('\n')
|
||||
|
||||
const alertTitle = '风险告警:检测到多名用户异常投票(汇总)'
|
||||
const alertContent = `在过去 1 分钟内,除已通知的用户外,还检测到 ${count} 名用户存在异常投票行为。
|
||||
|
||||
涉及主要歌曲:
|
||||
${topSongs}
|
||||
|
||||
请关注后台日志或进行相关处理。`
|
||||
|
||||
const meowAlertContent = alertContent
|
||||
const superAdmins = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
meowNickname: users.meowNickname
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.role, 'SUPER_ADMIN'))
|
||||
|
||||
for (const admin of superAdmins) {
|
||||
try {
|
||||
await createSystemNotification(admin.id, alertTitle, alertContent)
|
||||
if (admin.meowNickname) {
|
||||
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送异常投票汇总通知失败:', error)
|
||||
} finally {
|
||||
// 清空队列和定时器
|
||||
pendingAnomalies.length = 0
|
||||
anomalyAggregationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerVoteAnomalyAlert(
|
||||
userId: number,
|
||||
ema: number,
|
||||
rate: number,
|
||||
songTitle?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 通知限流,同一用户的投票异常通知5分钟内只发送一次
|
||||
const notificationKey = `vote_anomaly_${userId}`
|
||||
if (!canSendNotification(notificationKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在聚合窗口期,则加入缓冲区
|
||||
if (anomalyAggregationTimer) {
|
||||
pendingAnomalies.push({
|
||||
userId,
|
||||
ema,
|
||||
rate,
|
||||
songTitle,
|
||||
time: getServerDate()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 否则,立即发送第一条,并开启聚合窗口
|
||||
const now = getServerDate()
|
||||
const alertTitle = '风险告警:检测到异常投票速率'
|
||||
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}\n用户ID:${userId}\nEMA基线:${ema.toFixed(2)} 次/分钟\n当前速率:${rate.toFixed(2)} 次/分钟\n${songTitle ? `涉及歌曲:${songTitle}\n` : ''}\n提示:该用户的投票行为异常,已触发限流机制。`
|
||||
const meowAlertContent = alertContent
|
||||
const superAdmins = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
meowNickname: users.meowNickname
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.role, 'SUPER_ADMIN'))
|
||||
for (const admin of superAdmins) {
|
||||
try {
|
||||
await createSystemNotification(admin.id, alertTitle, alertContent)
|
||||
if (admin.meowNickname) {
|
||||
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 启动聚合定时器
|
||||
anomalyAggregationTimer = setTimeout(flushAnomalyAggregation, ANOMALY_AGGREGATION_WINDOW_MS)
|
||||
} catch (error) {
|
||||
console.error('触发投票异常警报时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全统计信息
|
||||
*/
|
||||
export async function getSecurityStats() {
|
||||
cleanupExpiredLocks()
|
||||
|
||||
return {
|
||||
lockedAccounts: 0,
|
||||
monitoredIPs: ipMonitor.size,
|
||||
blockedIPs: ipBlacklist.size,
|
||||
config: SECURITY_CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户当前的登录失败次数(用于判断是否需要图形验证码)
|
||||
*/
|
||||
export async function getLoginFailureCount(username: string): Promise<number> {
|
||||
const failKey = `login_fail:${username}`
|
||||
try {
|
||||
const val = await getStore(failKey)
|
||||
const redisCount = val ? parseInt(val, 10) : 0
|
||||
return Math.max(Number.isFinite(redisCount) ? redisCount : 0, getFallbackFailureCount(username))
|
||||
} catch (error) {
|
||||
handleRedisStateUnavailable(error)
|
||||
return getFallbackFailureCount(username)
|
||||
}
|
||||
}
|
||||
|
||||
// 定期清理过期记录(每5分钟执行一次)
|
||||
setInterval(cleanupExpiredLocks, 5 * 60 * 1000)
|
||||
@@ -0,0 +1,813 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { emailTemplates, users } from '~/drizzle/schema'
|
||||
import { and, eq, isNotNull } from 'drizzle-orm'
|
||||
import { getSiteTitle } from '~~/server/utils/siteUtils'
|
||||
import { formatIPForEmail } from '~~/server/utils/ip-utils'
|
||||
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
|
||||
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
|
||||
const TRUSTED_HTML_TEMPLATE_KEYS = new Set(['contentBlock'])
|
||||
const EMAIL_REQUEST_SOURCE_LABEL = 'This email was requested from:'
|
||||
|
||||
const escapeHtml = (value: unknown): string => {
|
||||
return String(value).replace(/[&<>"']/g, (character) => HTML_ESCAPE_MAP[character] ?? character)
|
||||
}
|
||||
|
||||
const normalizeEmailActionUrl = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string' || !value.trim()) return undefined
|
||||
|
||||
try {
|
||||
const url = new URL(value.trim())
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
|
||||
if (url.username || url.password) return undefined
|
||||
return url.href
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeEmailSubject = (value: unknown): string => {
|
||||
return String(value ?? '')
|
||||
.replaceAll('\r', ' ')
|
||||
.replaceAll('\n', ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP邮件服务
|
||||
*/
|
||||
export class SmtpService {
|
||||
private static instance: SmtpService
|
||||
public transporter: nodemailer.Transporter | null = null
|
||||
public smtpConfig: any = null
|
||||
/** 基础邮件模板结构 */
|
||||
private baseTemplate = `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; background: #f9f9f9; padding: 20px;">
|
||||
<div style="background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #333; margin: 0;">{{fromName}}</h1>
|
||||
<p style="color: #666; margin: 5px 0 0 0;">{{headerSubtitle}}</p>
|
||||
</div>
|
||||
|
||||
{{#if title}}
|
||||
<h2 style="color: #333; margin-bottom: 20px;">{{title}}</h2>
|
||||
{{/if}}
|
||||
|
||||
<div style="color: #555; line-height: 1.6; margin-bottom: 30px;">
|
||||
{{contentBlock}}
|
||||
</div>
|
||||
|
||||
{{#if actionUrl}}
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{actionUrl}}" style="display: inline-block; background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">{{actionText}}</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||
|
||||
<p style="color: #666; font-size: 12px; text-align: center;">
|
||||
此邮件由系统自动发送,请勿回复。<br>
|
||||
如有疑问,请联系管理员。{{#if ipAddress}}<br><br>This email was requested from: <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">{{ipAddress}}</span>{{/if}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
/** 内容块模板 */
|
||||
private contentBlocks: Record<string, string> = {
|
||||
verification: `
|
||||
<p>您好,{{name}}!</p>
|
||||
<p>您正在验证邮箱:<strong>{{email}}</strong></p>
|
||||
<p>请在{{expiresInMinutes}}分钟内输入以下验证码完成验证:</p>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<h2 style="letter-spacing: 4px; color: #007bff; background: #f8f9fa; padding: 15px; border-radius: 4px; display: inline-block;">{{code}}</h2>
|
||||
</div>
|
||||
<p style="color:#888">若非本人操作,请忽略本邮件。</p>
|
||||
`,
|
||||
generic: `
|
||||
<div style="white-space: pre-wrap;">{{message}}</div>
|
||||
`,
|
||||
songSelected: `
|
||||
<p>您投稿的歌曲《{{songTitle}}》已被安排播放。</p>
|
||||
<p>播放日期:<strong>{{playDate}}</strong></p>
|
||||
{{#if playTimeName}}
|
||||
<p>播出时段:<strong>{{playTimeName}}</strong>{{#if playTimeRange}}({{playTimeRange}}){{/if}}</p>
|
||||
{{/if}}
|
||||
`,
|
||||
songPlayed: `
|
||||
<p>您投稿的歌曲《{{songTitle}}》已播放。</p>
|
||||
`,
|
||||
replaySongSelected: `
|
||||
<p>您申请重播的歌曲《{{songTitle}}》已安排播放。</p>
|
||||
<p>播放日期:<strong>{{playDate}}</strong></p>
|
||||
{{#if playTimeName}}
|
||||
<p>播出时段:<strong>{{playTimeName}}</strong></p>
|
||||
{{/if}}
|
||||
`,
|
||||
songVoted: `
|
||||
<p>您投稿的歌曲《{{songTitle}}》获得了新的投票。</p>
|
||||
<p>当前共有 <strong>{{votesCount}}</strong> 个投票。</p>
|
||||
`,
|
||||
songRejected: `
|
||||
<p>您投稿的歌曲《{{songTitle}}》已被管理员驳回。</p>
|
||||
{{#if reason}}
|
||||
<p>驳回原因:<strong>{{reason}}</strong></p>
|
||||
{{/if}}
|
||||
`,
|
||||
collaborationInvite: `
|
||||
<p>用户 <strong>{{inviterName}}</strong> 邀请您共同投稿歌曲《{{songTitle}}》。</p>
|
||||
`
|
||||
}
|
||||
|
||||
/** 模板配置 */
|
||||
private builtinTemplates: Record<
|
||||
string,
|
||||
{
|
||||
name: string
|
||||
subject: string
|
||||
contentType: string
|
||||
headerSubtitle: string
|
||||
actionText?: string
|
||||
}
|
||||
> = {
|
||||
'verification.code': {
|
||||
name: '邮箱验证码',
|
||||
subject: '邮箱验证码 | {{siteTitle}}',
|
||||
contentType: 'verification',
|
||||
headerSubtitle: '邮箱验证'
|
||||
},
|
||||
'register': {
|
||||
name: '注册通知',
|
||||
subject: '{{title}} | {{siteTitle}}通知推送',
|
||||
contentType: 'generic',
|
||||
headerSubtitle: '注册通知'
|
||||
},
|
||||
'register-approved': {
|
||||
name: '注册审核通过',
|
||||
subject: '{{title}} | {{siteTitle}}通知推送',
|
||||
contentType: 'generic',
|
||||
headerSubtitle: '注册审核通知'
|
||||
},
|
||||
'register-rejected': {
|
||||
name: '注册审核拒绝',
|
||||
subject: '{{title}} | {{siteTitle}}通知推送',
|
||||
contentType: 'generic',
|
||||
headerSubtitle: '注册审核通知'
|
||||
},
|
||||
'notification.generic': {
|
||||
name: '通用通知',
|
||||
subject: '{{title}} | {{siteTitle}}通知推送',
|
||||
contentType: 'generic',
|
||||
headerSubtitle: '通知推送',
|
||||
actionText: '查看详情'
|
||||
},
|
||||
'notification.songSelected': {
|
||||
name: '歌曲被选中',
|
||||
subject: '收到新选中 | {{siteTitle}}通知推送',
|
||||
contentType: 'songSelected',
|
||||
headerSubtitle: '通知推送'
|
||||
},
|
||||
'notification.songPlayed': {
|
||||
name: '歌曲已播放',
|
||||
subject: '歌曲已播放 | {{siteTitle}}通知推送',
|
||||
contentType: 'songPlayed',
|
||||
headerSubtitle: '通知推送'
|
||||
},
|
||||
'notification.replaySongSelected': {
|
||||
name: '重播申请已安排',
|
||||
subject: '重播已安排 | {{siteTitle}}通知推送',
|
||||
contentType: 'replaySongSelected',
|
||||
headerSubtitle: '通知推送'
|
||||
},
|
||||
'notification.songVoted': {
|
||||
name: '收到新投票',
|
||||
subject: '收到新投票 | {{siteTitle}}通知推送',
|
||||
contentType: 'songVoted',
|
||||
headerSubtitle: '通知推送'
|
||||
},
|
||||
'notification.songRejected': {
|
||||
name: '歌曲被驳回',
|
||||
subject: '歌曲被驳回 | {{siteTitle}}通知推送',
|
||||
contentType: 'songRejected',
|
||||
headerSubtitle: '通知推送'
|
||||
},
|
||||
'notification.collaborationInvite': {
|
||||
name: '收到联合投稿邀请',
|
||||
subject: '收到联合投稿邀请 | {{siteTitle}}通知推送',
|
||||
contentType: 'collaborationInvite',
|
||||
headerSubtitle: '通知推送'
|
||||
}
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SmtpService {
|
||||
if (!SmtpService.instance) {
|
||||
SmtpService.instance = new SmtpService()
|
||||
// 首次创建实例时,尝试初始化SMTP配置(异步执行,不阻塞)
|
||||
SmtpService.instance.initializeSmtpConfig().catch((error) => {
|
||||
console.log('SmtpService实例创建时初始化配置失败:', error.message)
|
||||
})
|
||||
}
|
||||
return SmtpService.instance
|
||||
}
|
||||
|
||||
// 暴露内置模板(只读)
|
||||
getBuiltinTemplates(): Record<string, { name: string; subject: string; html: string }> {
|
||||
const templates: Record<string, { name: string; subject: string; html: string }> = {}
|
||||
|
||||
for (const [key, config] of Object.entries(this.builtinTemplates)) {
|
||||
const contentBlock = this.contentBlocks[config.contentType] || ''
|
||||
const html = this.renderString(
|
||||
this.baseTemplate,
|
||||
{
|
||||
contentBlock,
|
||||
headerSubtitle: config.headerSubtitle,
|
||||
actionText: config.actionText || '查看详情'
|
||||
},
|
||||
{ rawKeys: TRUSTED_HTML_TEMPLATE_KEYS }
|
||||
)
|
||||
|
||||
templates[key] = {
|
||||
name: config.name,
|
||||
subject: config.subject,
|
||||
html
|
||||
}
|
||||
}
|
||||
|
||||
return templates
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化SMTP配置
|
||||
* 仅当配置为空或显式要求强制刷新时才执行
|
||||
*/
|
||||
async initializeSmtpConfig(forceRefresh: boolean = false): Promise<boolean> {
|
||||
if (!forceRefresh && this.transporter) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getSystemSettingsCached()
|
||||
|
||||
if (!settings || !settings.smtpEnabled || !settings.smtpHost) {
|
||||
this.smtpConfig = null
|
||||
this.transporter = null
|
||||
return false
|
||||
}
|
||||
|
||||
const port = settings.smtpPort || 587
|
||||
const secure = settings.smtpSecure || false
|
||||
|
||||
this.smtpConfig = {
|
||||
host: settings.smtpHost,
|
||||
port: port,
|
||||
secure: secure,
|
||||
auth:
|
||||
settings.smtpUsername && settings.smtpPassword
|
||||
? {
|
||||
user: settings.smtpUsername,
|
||||
pass: settings.smtpPassword
|
||||
}
|
||||
: undefined,
|
||||
fromEmail: settings.smtpFromEmail || settings.smtpUsername,
|
||||
fromName: settings.smtpFromName || '校园广播站'
|
||||
}
|
||||
|
||||
// 创建transporter配置
|
||||
const transporterConfig: any = {
|
||||
host: this.smtpConfig.host,
|
||||
port: this.smtpConfig.port,
|
||||
secure: this.smtpConfig.secure,
|
||||
auth: this.smtpConfig.auth
|
||||
}
|
||||
|
||||
// 根据端口和安全设置调整配置
|
||||
const isDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
if (port === 587 && !secure) {
|
||||
// STARTTLS - 端口587通常使用STARTTLS
|
||||
transporterConfig.requireTLS = true
|
||||
transporterConfig.tls = {
|
||||
// 仅在开发环境中跳过证书校验,生产环境必须校验
|
||||
rejectUnauthorized: !isDevelopment
|
||||
}
|
||||
} else if (port === 465) {
|
||||
// SSL/TLS - 端口465必须使用SSL
|
||||
transporterConfig.secure = true
|
||||
} else if (port === 25) {
|
||||
// 通常不加密
|
||||
transporterConfig.secure = false
|
||||
transporterConfig.tls = {
|
||||
// 仅在开发环境中跳过证书校验
|
||||
rejectUnauthorized: !isDevelopment
|
||||
}
|
||||
}
|
||||
|
||||
// 创建transporter
|
||||
this.transporter = nodemailer.createTransport(transporterConfig)
|
||||
|
||||
// 验证SMTP连接
|
||||
await this.transporter.verify()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('初始化SMTP配置失败:', error instanceof Error ? error.message : '未知错误')
|
||||
this.smtpConfig = null
|
||||
this.transporter = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件
|
||||
*/
|
||||
async sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
htmlContent: string,
|
||||
textContent?: string,
|
||||
ipAddress?: string
|
||||
): Promise<boolean> {
|
||||
// 确保配置已初始化
|
||||
if (!(await this.ensureInitialized())) {
|
||||
throw new Error('SMTP配置未初始化或无效')
|
||||
}
|
||||
|
||||
// 兼容直接调用 sendMail 的邮件;模板已渲染来源 IP 时不重复追加
|
||||
let finalHtml = htmlContent
|
||||
if (ipAddress && !htmlContent.includes(EMAIL_REQUEST_SOURCE_LABEL)) {
|
||||
const formattedIP = escapeHtml(formatIPForEmail(ipAddress))
|
||||
finalHtml = htmlContent.replace(
|
||||
/(<p[^>]*style="[^"]*text-align: center[^"]*"[^>]*>.*?此邮件由系统自动发送,请勿回复。.*?<\/p>)/s,
|
||||
(footer) =>
|
||||
footer.replace(
|
||||
'此邮件由系统自动发送,请勿回复。',
|
||||
`此邮件由系统自动发送,请勿回复。<br><br>${EMAIL_REQUEST_SOURCE_LABEL} <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">${formattedIP}</span>`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: {
|
||||
name: this.smtpConfig.fromName,
|
||||
address: this.smtpConfig.fromEmail
|
||||
},
|
||||
to,
|
||||
subject,
|
||||
html: finalHtml,
|
||||
text: textContent || finalHtml.replace(/<[^>]*>/g, '') // 简单的HTML转文本
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.transporter!.sendMail(mailOptions)
|
||||
console.log(`邮件发送成功: ${result.messageId}`)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
// 自动重试机制:如果是因为发件人地址不匹配导致的553/501错误,尝试使用认证用户作为发件人
|
||||
if (
|
||||
(error.responseCode === 553 || error.responseCode === 501) &&
|
||||
this.smtpConfig.auth?.user
|
||||
) {
|
||||
console.warn(
|
||||
`SMTP发件人地址不匹配 (${error.responseCode}),正在尝试使用认证用户重试发送...`
|
||||
)
|
||||
try {
|
||||
// 这里可以安全地使用 mailOptions,因为它在 try 块外部定义
|
||||
const retryMailOptions = {
|
||||
...mailOptions,
|
||||
from: {
|
||||
...mailOptions.from,
|
||||
address: this.smtpConfig.auth.user
|
||||
}
|
||||
}
|
||||
const result = await this.transporter!.sendMail(retryMailOptions)
|
||||
console.log(`重试发送成功: ${result.messageId}`)
|
||||
return true
|
||||
} catch (retryError) {
|
||||
console.error('重试发送失败:', retryError)
|
||||
throw error // 如果重试也失败,抛出原始错误
|
||||
}
|
||||
}
|
||||
|
||||
console.error('发送邮件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板:优先使用自定义模板,否则回退到内置模板
|
||||
*/
|
||||
async renderTemplate(
|
||||
key: string,
|
||||
data: Record<string, any>
|
||||
): Promise<{ subject: string; html: string }> {
|
||||
const builtin = this.builtinTemplates[key]
|
||||
let subject = builtin?.subject || ''
|
||||
let html = ''
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq(emailTemplates.key, key))
|
||||
.limit(1)
|
||||
const custom = rows[0]
|
||||
if (custom) {
|
||||
subject = custom.subject
|
||||
html = custom.html
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略读取失败,走内置
|
||||
}
|
||||
|
||||
let mergedData: Record<string, any>
|
||||
|
||||
// 如果没有自定义模板,使用内置模板系统
|
||||
if (!html && builtin) {
|
||||
mergedData = await this.prepareTemplateData(data)
|
||||
const contentBlock = this.renderString(
|
||||
this.contentBlocks[builtin.contentType] || '',
|
||||
mergedData
|
||||
)
|
||||
const templateData = {
|
||||
...mergedData,
|
||||
contentBlock,
|
||||
headerSubtitle: builtin.headerSubtitle,
|
||||
actionText: builtin.actionText || '查看详情'
|
||||
}
|
||||
html = this.renderString(this.baseTemplate, templateData, {
|
||||
rawKeys: TRUSTED_HTML_TEMPLATE_KEYS
|
||||
})
|
||||
} else {
|
||||
mergedData = await this.prepareTemplateData(data)
|
||||
html = this.renderString(html, mergedData)
|
||||
}
|
||||
|
||||
return {
|
||||
subject: normalizeEmailSubject(
|
||||
this.renderString(subject, mergedData, { escapeValues: false })
|
||||
),
|
||||
html
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染并发送模板
|
||||
*/
|
||||
async renderAndSend(
|
||||
to: string,
|
||||
key: string,
|
||||
data: Record<string, any>,
|
||||
ipAddress?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// 确保配置已初始化
|
||||
if (!(await this.ensureInitialized())) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 格式化IP地址用于模板渲染(统一处理)
|
||||
const formattedIP = ipAddress ? formatIPForEmail(ipAddress) : undefined
|
||||
const templateData = { ...data, ipAddress: formattedIP }
|
||||
|
||||
const { subject, html } = await this.renderTemplate(key, templateData)
|
||||
if (!subject || !html) {
|
||||
// 若模板缺失,退回到简单包装(传入已格式化的IP)
|
||||
const mergedData = await this.prepareTemplateData(templateData)
|
||||
const fallbackHtml = this.generateEmailTemplate(
|
||||
data.title || data.fallbackTitle || '通知',
|
||||
data.message || data.fallbackMessage || '',
|
||||
data.actionUrl,
|
||||
formattedIP
|
||||
)
|
||||
const fallbackSubject = normalizeEmailSubject(
|
||||
`${data.title || data.fallbackTitle || '通知'} | ${mergedData.siteTitle}通知推送`
|
||||
)
|
||||
return await this.sendMail(to, fallbackSubject, fallbackHtml, undefined, ipAddress)
|
||||
}
|
||||
return await this.sendMail(to, subject, html, undefined, ipAddress)
|
||||
} catch (error) {
|
||||
console.error('渲染并发送邮件失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试SMTP连接
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; message: string; detail?: string }> {
|
||||
if (!this.transporter) {
|
||||
try {
|
||||
const initialized = await this.initializeSmtpConfig()
|
||||
if (!initialized) {
|
||||
return { success: false, message: 'SMTP配置无效或未启用' }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: '初始化SMTP配置失败',
|
||||
detail: error instanceof Error ? error.message : '未知错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transporter!.verify()
|
||||
return { success: true, message: 'SMTP连接测试成功' }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'SMTP连接测试失败',
|
||||
detail: error instanceof Error ? error.message : '未知错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送测试邮件
|
||||
*/
|
||||
async sendTestEmail(
|
||||
to: string,
|
||||
ipAddress?: string
|
||||
): Promise<{ success: boolean; message: string; detail?: string }> {
|
||||
try {
|
||||
const formattedIP = ipAddress ? formatIPForEmail(ipAddress) : undefined
|
||||
const templateData = await this.prepareTemplateData({ ipAddress: formattedIP })
|
||||
const subject = normalizeEmailSubject(`测试邮件 | ${templateData.siteTitle}通知推送`)
|
||||
const htmlContent = this.generateEmailTemplate(
|
||||
'测试邮件',
|
||||
'这是一封来自校园广播站系统的测试邮件。<br>如果您收到这封邮件,说明SMTP配置已经正确设置。',
|
||||
undefined,
|
||||
formattedIP,
|
||||
true
|
||||
)
|
||||
|
||||
const success = await this.sendMail(to, subject, htmlContent, undefined, ipAddress)
|
||||
return {
|
||||
success,
|
||||
message: success ? '测试邮件发送成功' : '测试邮件发送失败'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: '测试邮件发送失败',
|
||||
detail: error instanceof Error ? error.message : '未知错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成邮件HTML模板
|
||||
*/
|
||||
generateEmailTemplate(
|
||||
title: string,
|
||||
content: string,
|
||||
actionUrl?: string,
|
||||
formattedIP?: string,
|
||||
trustedHtmlContent = false
|
||||
): string {
|
||||
const safeFromName = escapeHtml(this.smtpConfig?.fromName || '校园广播站')
|
||||
const safeTitle = escapeHtml(title)
|
||||
const safeContent = trustedHtmlContent ? content : escapeHtml(content)
|
||||
const safeActionUrl = normalizeEmailActionUrl(actionUrl)
|
||||
const safeFormattedIP = formattedIP ? escapeHtml(formattedIP) : ''
|
||||
|
||||
return `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; background: #f9f9f9; padding: 20px;">
|
||||
<div style="background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #333; margin: 0;">${safeFromName}</h1>
|
||||
<p style="color: #666; margin: 5px 0 0 0;">通知推送</p>
|
||||
</div>
|
||||
|
||||
<h2 style="color: #333; margin-bottom: 20px;">${safeTitle}</h2>
|
||||
|
||||
<div style="color: #555; line-height: 1.6; margin-bottom: 30px; white-space: pre-wrap;">
|
||||
${safeContent}
|
||||
</div>
|
||||
|
||||
${
|
||||
safeActionUrl
|
||||
? `
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${escapeHtml(safeActionUrl)}" style="display: inline-block; background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">查看详情</a>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||
|
||||
<p style="color: #666; font-size: 12px; text-align: center;">
|
||||
此邮件由系统自动发送,请勿回复。<br>
|
||||
如有疑问,请联系管理员。${safeFormattedIP ? `<br><br>This email was requested from: <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">${safeFormattedIP}</span>` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保SMTP配置已初始化
|
||||
*/
|
||||
private async ensureInitialized(): Promise<boolean> {
|
||||
if (!this.transporter) {
|
||||
return await this.initializeSmtpConfig()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 基本占位符渲染:用 {{var}} 替换,支持 {{#if var}}...{{/if}} 嵌套条件
|
||||
*/
|
||||
private renderString(
|
||||
tpl: string,
|
||||
data: Record<string, any>,
|
||||
options: { escapeValues?: boolean; rawKeys?: ReadonlySet<string> } = {}
|
||||
): string {
|
||||
// 递归处理嵌套的 if 块
|
||||
const processIfBlocks = (template: string): string => {
|
||||
let hasChanges = true
|
||||
let result = template
|
||||
|
||||
while (hasChanges) {
|
||||
hasChanges = false
|
||||
// 从最内层开始处理,匹配不包含嵌套{{#if}}的if块
|
||||
result = result.replace(
|
||||
/\{\{#if\s+([a-zA-Z0-9_\.]+)\}\}((?:(?!\{\{#if)[\s\S])*?)\{\{\/if\}\}/g,
|
||||
(match, key, inner) => {
|
||||
const v = key
|
||||
.split('.')
|
||||
.reduce((acc: any, k: string) => (acc ? acc[k] : undefined), data)
|
||||
hasChanges = true
|
||||
return v ? inner : ''
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 处理所有if块
|
||||
tpl = processIfBlocks(tpl)
|
||||
|
||||
// 处理变量
|
||||
tpl = tpl.replace(/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/g, (_, key) => {
|
||||
const v = key.split('.').reduce((acc: any, k: string) => (acc ? acc[k] : undefined), data)
|
||||
if (v == null) return ''
|
||||
|
||||
const renderedValue = String(v)
|
||||
if (options.escapeValues === false || options.rawKeys?.has(key)) return renderedValue
|
||||
return escapeHtml(renderedValue)
|
||||
})
|
||||
|
||||
return tpl
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备模板渲染数据
|
||||
*/
|
||||
private async prepareTemplateData(data: Record<string, any>): Promise<Record<string, any>> {
|
||||
const siteTitle = await getSiteTitle()
|
||||
return {
|
||||
...data,
|
||||
fromName: this.smtpConfig?.fromName || '校园广播站',
|
||||
siteTitle,
|
||||
actionUrl: normalizeEmailActionUrl(data.actionUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件通知给用户
|
||||
*/
|
||||
export async function sendEmailNotificationToUser(
|
||||
userId: number,
|
||||
notificationTitle: string,
|
||||
notificationMessage: string,
|
||||
url?: string,
|
||||
templateKey?: string,
|
||||
templateData?: Record<string, any>,
|
||||
ipAddress?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const smtpService = SmtpService.getInstance()
|
||||
|
||||
// 获取用户信息
|
||||
const userResult = await db
|
||||
.select({
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
emailVerified: users.emailVerified
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
const user = userResult[0]
|
||||
|
||||
// 仅检查用户是否有邮箱且已验证
|
||||
if (!user?.email || !user.emailVerified) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用指定模板,否则回退通用模板
|
||||
if (templateKey) {
|
||||
return await smtpService.renderAndSend(
|
||||
user.email,
|
||||
templateKey,
|
||||
{
|
||||
fallbackTitle: notificationTitle,
|
||||
fallbackMessage: notificationMessage,
|
||||
message: notificationMessage,
|
||||
actionUrl: url,
|
||||
...(templateData || {})
|
||||
},
|
||||
ipAddress
|
||||
)
|
||||
}
|
||||
return await smtpService.renderAndSend(
|
||||
user.email,
|
||||
'notification.generic',
|
||||
{
|
||||
title: notificationTitle,
|
||||
message: notificationMessage,
|
||||
actionUrl: url
|
||||
},
|
||||
ipAddress
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('发送邮件通知失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送邮件通知
|
||||
*/
|
||||
export async function sendBatchEmailNotifications(
|
||||
userIds: number[],
|
||||
notificationTitle: string,
|
||||
notificationMessage: string,
|
||||
url?: string,
|
||||
ipAddress?: string
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
let success = 0
|
||||
let failed = 0
|
||||
|
||||
const smtpService = SmtpService.getInstance()
|
||||
|
||||
// 获取有邮箱且已验证的用户
|
||||
const usersWithEmail = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email
|
||||
})
|
||||
.from(users)
|
||||
.where(and(eq(users.emailVerified, true), isNotNull(users.email)))
|
||||
|
||||
// 并发发送邮件(限制并发数)
|
||||
const batchSize = 5
|
||||
const targetUsers = usersWithEmail.filter((user) => userIds.includes(user.id))
|
||||
|
||||
for (let i = 0; i < targetUsers.length; i += batchSize) {
|
||||
const batch = targetUsers.slice(i, i + batchSize)
|
||||
const promises = batch.map(async (user) => {
|
||||
// 确保 email 不为 null
|
||||
if (!user.email) {
|
||||
return false
|
||||
}
|
||||
const emailSuccess = await smtpService.renderAndSend(
|
||||
user.email,
|
||||
'notification.generic',
|
||||
{
|
||||
title: notificationTitle,
|
||||
message: notificationMessage,
|
||||
actionUrl: url
|
||||
},
|
||||
ipAddress
|
||||
)
|
||||
return emailSuccess
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
success++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { success, failed }
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import {
|
||||
collaborationLogs,
|
||||
db,
|
||||
playTimes,
|
||||
requestTimes,
|
||||
schedules,
|
||||
semesters,
|
||||
cardCodes,
|
||||
songCollaborators,
|
||||
songs,
|
||||
users
|
||||
} from '~/drizzle/db'
|
||||
import { and, eq, gte, gt, inArray, lt, lte, or, sql } from 'drizzle-orm'
|
||||
import { createError } from 'h3'
|
||||
import { reconcileSongDurationOnSubmit } from '~~/server/services/durationValidationService'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
|
||||
import {
|
||||
isCardCodeLimitBypassActive,
|
||||
isLimitReached
|
||||
} from '~~/server/utils/submissionLimit'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getBeijingTimeISOString } from '~/utils/timeUtils'
|
||||
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
import { SERVER_ERROR_CODES, SONG_DURATION_MAX_SECONDS, SONG_DURATION_MIN_SECONDS, SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
|
||||
import { normalizeForMatch } from '~~/server/utils/song-name-normalize'
|
||||
import { resolveSubmissionRestrictionPolicy } from '~~/server/utils/submission-restriction-policy'
|
||||
import { z } from 'zod'
|
||||
|
||||
type SongRequestUser = {
|
||||
id: number
|
||||
role: string
|
||||
}
|
||||
|
||||
const songRequestBodySchema = z.object({
|
||||
title: z.string().trim().min(1, '歌曲名称不能为空').max(200, '歌曲名称不能超过200个字符'),
|
||||
artist: z.string().trim().min(1, '艺术家不能为空').max(200, '艺术家不能超过200个字符'),
|
||||
cover: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1000, '封面地址不能超过1000个字符')
|
||||
// 封面必须是完整 http(s) 链接,防止音源返回 pic_id 等片段被误存导致封面无法显示
|
||||
.refine((value) => value === '' || /^https?:\/\//i.test(value), '封面地址必须以 http(s):// 开头')
|
||||
.optional()
|
||||
.nullable(),
|
||||
musicPlatform: z.string().trim().max(50, '音乐平台标识不能超过50个字符').optional().nullable(),
|
||||
musicId: z.string().trim().max(200, '音乐 ID 不能超过200个字符').optional().nullable(),
|
||||
bilibiliCid: z.string().trim().max(100, 'Bilibili CID 不能超过100个字符').optional().nullable(),
|
||||
bilibiliPage: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
playUrl: z.string().trim().max(2000, '播放链接不能超过2000个字符').optional().nullable(),
|
||||
durationSeconds: z.number().int().min(SONG_DURATION_MIN_SECONDS, '时长不能为负数').max(SONG_DURATION_MAX_SECONDS, '时长不能超过2小时').optional().nullable(),
|
||||
submissionNote: z.string().trim().max(300, '备注留言不能超过300个字符').optional().nullable(),
|
||||
submissionNotePublic: z.boolean().optional(),
|
||||
preferredPlayTimeId: z.preprocess(
|
||||
(value) => value === null || value === undefined || value === '' ? null : Number(value),
|
||||
z.number().int().positive('播出时段 ID 无效').nullable()
|
||||
).optional(),
|
||||
cardCode: z.string().trim().max(100, 'CARD_CODE_TOO_LONG').optional().nullable(),
|
||||
collaborators: z.array(z.union([z.string(), z.number()])).max(20, '联合投稿人不能超过20个').optional()
|
||||
})
|
||||
|
||||
export async function requestSongForUser(event: any, user: SongRequestUser, body: any) {
|
||||
const parsedBody = songRequestBodySchema.safeParse(body || {})
|
||||
if (!parsedBody.success) {
|
||||
const issues = parsedBody.error.issues || []
|
||||
const cardCodeIssue = issues.find((issue) => String(issue.message).startsWith('CARD_CODE_'))
|
||||
if (cardCodeIssue) {
|
||||
throw createApiError(400, cardCodeIssue.message, 'Invalid request card')
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: issues.length
|
||||
? `请求参数验证失败:${issues.map((issue) => issue.message).join(', ')}`
|
||||
: '请求参数验证失败'
|
||||
})
|
||||
}
|
||||
|
||||
const requestBody = parsedBody.data
|
||||
|
||||
try {
|
||||
// 标准化后再比较,避免同一首歌因标点或空格差异绕过重复检查。
|
||||
const normalizedTitle = normalizeForMatch(requestBody.title)
|
||||
const normalizedArtist = normalizeForMatch(requestBody.artist)
|
||||
|
||||
const currentSemester = await getCurrentSemesterName()
|
||||
|
||||
const systemSettingsData = await getSystemSettingsCached()
|
||||
const isAdmin = ['SUPER_ADMIN', 'ADMIN', 'SONG_ADMIN'].includes(user.role)
|
||||
const restrictionPolicy = resolveSubmissionRestrictionPolicy(systemSettingsData)
|
||||
const semesterRestrictionOn = restrictionPolicy.mode === 'semester' && !isAdmin
|
||||
|
||||
const isBilibili =
|
||||
requestBody.musicPlatform === 'bilibili' ||
|
||||
String(requestBody.musicId || '').startsWith('BV') ||
|
||||
String(requestBody.musicId || '').startsWith('av')
|
||||
|
||||
// 仅在重复投稿限制开启且未配置冷却时长时,沿用同一学期内同一首歌只能投稿一次的规则;管理员始终豁免。
|
||||
if (isBilibili && requestBody.musicId && semesterRestrictionOn) {
|
||||
let fullMusicId = String(requestBody.musicId)
|
||||
const bvId = fullMusicId.split(':')[0]
|
||||
|
||||
if (requestBody.bilibiliCid) {
|
||||
const musicIdParts = [bvId, requestBody.bilibiliCid]
|
||||
if (requestBody.bilibiliPage && Number(requestBody.bilibiliPage) > 1) {
|
||||
musicIdParts.push(String(requestBody.bilibiliPage))
|
||||
}
|
||||
fullMusicId = musicIdParts.join(':')
|
||||
}
|
||||
|
||||
const existingSongs = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
musicId: songs.musicId,
|
||||
played: songs.played
|
||||
})
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
eq(songs.semester, currentSemester),
|
||||
eq(songs.musicPlatform, 'bilibili'),
|
||||
eq(songs.musicId, fullMusicId)
|
||||
)
|
||||
)
|
||||
|
||||
if (existingSongs.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `《${requestBody.title}》已经在列表中,不能重复投稿`
|
||||
})
|
||||
}
|
||||
} else if (semesterRestrictionOn) {
|
||||
const allSongs = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
semester: songs.semester,
|
||||
played: songs.played
|
||||
})
|
||||
.from(songs)
|
||||
.where(eq(songs.semester, currentSemester))
|
||||
|
||||
const matchingSongs = allSongs.filter((song) => {
|
||||
const songTitle = normalizeForMatch(song.title)
|
||||
const songArtist = normalizeForMatch(song.artist)
|
||||
return songTitle === normalizedTitle && songArtist === normalizedArtist
|
||||
})
|
||||
|
||||
if (matchingSongs.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `《${requestBody.title}》已经在列表中,不能重复投稿`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 重复投稿限制:同一首歌 / 同一歌手在排期后 N 小时内不可再次投稿
|
||||
if (restrictionPolicy.mode === 'window' && !isAdmin) {
|
||||
const now = getServerDate()
|
||||
const { sameSongHours, sameArtistHours, scope } = restrictionPolicy
|
||||
const maxHours = Math.max(sameSongHours || 0, sameArtistHours || 0)
|
||||
const cutoff = new Date(now.getTime() - maxHours * 3600000)
|
||||
|
||||
// 按 scope 构建 where 子句(显式短路,避免依赖 and(undefined) 的版本行为)
|
||||
const scopeFilter = scope === 'self' ? eq(songs.requesterId, user.id) : undefined
|
||||
const whereClause = and(
|
||||
eq(schedules.isDraft, false),
|
||||
or(
|
||||
and(lte(schedules.playDate, now), gte(schedules.playDate, cutoff)),
|
||||
and(
|
||||
gt(schedules.playDate, now),
|
||||
or(gte(schedules.publishedAt, cutoff), gte(schedules.createdAt, cutoff))
|
||||
)
|
||||
),
|
||||
...(scopeFilter ? [scopeFilter] : [])
|
||||
)
|
||||
const scheduledSongs = await db
|
||||
.select({
|
||||
sPlayDate: schedules.playDate,
|
||||
sCreatedAt: schedules.createdAt,
|
||||
sPublishedAt: schedules.publishedAt,
|
||||
songTitle: songs.title,
|
||||
songArtist: songs.artist,
|
||||
songRequesterId: songs.requesterId
|
||||
})
|
||||
.from(schedules)
|
||||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(whereClause)
|
||||
|
||||
for (const scheduled of scheduledSongs) {
|
||||
const playDate = scheduled.sPlayDate instanceof Date ? scheduled.sPlayDate : new Date(scheduled.sPlayDate)
|
||||
const createdAt = scheduled.sCreatedAt instanceof Date ? scheduled.sCreatedAt : new Date(scheduled.sCreatedAt)
|
||||
const publishedAt = scheduled.sPublishedAt
|
||||
? (scheduled.sPublishedAt instanceof Date ? scheduled.sPublishedAt : new Date(scheduled.sPublishedAt))
|
||||
: null
|
||||
// 已播放歌曲:窗口从播放时间起算;未播放歌曲:窗口从排期时间起算
|
||||
const windowStart = playDate.getTime() <= now.getTime()
|
||||
? playDate.getTime()
|
||||
: (publishedAt || createdAt).getTime()
|
||||
const songWindowMs = (windowStart + (sameSongHours || 0) * 3600000) - now.getTime()
|
||||
const artistWindowMs = (windowStart + (sameArtistHours || 0) * 3600000) - now.getTime()
|
||||
const songWindowActive = (sameSongHours || 0) > 0 && songWindowMs > 0
|
||||
const artistWindowActive = (sameArtistHours || 0) > 0 && artistWindowMs > 0
|
||||
|
||||
if (!songWindowActive && !artistWindowActive) continue
|
||||
|
||||
const scheduledTitleNorm = normalizeForMatch(scheduled.songTitle || '')
|
||||
const scheduledArtistNorm = normalizeForMatch(scheduled.songArtist || '')
|
||||
|
||||
if (songWindowActive && scheduledTitleNorm === normalizedTitle && scheduledArtistNorm === normalizedArtist) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.SONG_RESTRICTION_SAME_SONG,
|
||||
'同一首歌在排期后一段时间内不能重复投稿',
|
||||
{ params: [sameSongHours, requestBody.title] }
|
||||
)
|
||||
}
|
||||
|
||||
if (artistWindowActive && scheduledArtistNorm === normalizedArtist) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.SONG_RESTRICTION_SAME_ARTIST,
|
||||
'同一歌手在排期后一段时间内不能重复投稿',
|
||||
{ params: [sameArtistHours, scheduled.songArtist] }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '投稿功能已关闭'
|
||||
})
|
||||
}
|
||||
|
||||
let hitRequestTime: any = null
|
||||
if (systemSettingsData?.enableRequestTimeLimitation && !isAdmin) {
|
||||
const currentTime = getBeijingTimeISOString()
|
||||
|
||||
const hitRequestTimeResult = await db
|
||||
.select()
|
||||
.from(requestTimes)
|
||||
.where(
|
||||
and(
|
||||
lte(requestTimes.startTime, currentTime),
|
||||
gt(requestTimes.endTime, currentTime),
|
||||
eq(requestTimes.enabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
hitRequestTime = hitRequestTimeResult[0]
|
||||
|
||||
if (!hitRequestTime) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '当前不在投稿开放时段'
|
||||
})
|
||||
}
|
||||
|
||||
if (hitRequestTime.expected > 0 && hitRequestTime.accepted >= hitRequestTime.expected) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: `当前时段投稿名额已满(${hitRequestTime.accepted}/${hitRequestTime.expected})`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let effectiveLimit: number | null = null
|
||||
let limitType: 'daily' | 'weekly' | 'monthly' | null = null
|
||||
|
||||
if (systemSettingsData?.enableSubmissionLimit && !isAdmin) {
|
||||
const dailyLimit = systemSettingsData.dailySubmissionLimit
|
||||
const weeklyLimit = systemSettingsData.weeklySubmissionLimit
|
||||
const monthlyLimit = systemSettingsData.monthlySubmissionLimit
|
||||
|
||||
if (dailyLimit !== null && dailyLimit !== undefined) {
|
||||
effectiveLimit = dailyLimit
|
||||
limitType = 'daily'
|
||||
} else if (weeklyLimit !== null && weeklyLimit !== undefined) {
|
||||
effectiveLimit = weeklyLimit
|
||||
limitType = 'weekly'
|
||||
} else if (monthlyLimit !== null && monthlyLimit !== undefined) {
|
||||
effectiveLimit = monthlyLimit
|
||||
limitType = 'monthly'
|
||||
}
|
||||
|
||||
if (effectiveLimit === 0) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '投稿功能已关闭'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 强制使用点歌券仅在点歌券功能启用时生效
|
||||
if (
|
||||
systemSettingsData?.requireCardCodeForRequests &&
|
||||
systemSettingsData?.enableCardCodeRequests &&
|
||||
!isAdmin
|
||||
) {
|
||||
const providedCardCode = requestBody.cardCode ? requestBody.cardCode.trim().toUpperCase() : ''
|
||||
if (!providedCardCode) {
|
||||
throw createApiError(
|
||||
403,
|
||||
'CARD_CODE_REQUIRED_FOR_SITE',
|
||||
'This site requires a valid request card to submit songs'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isCardCodeEnabled = systemSettingsData?.enableCardCodeRequests === true
|
||||
const excludeCardCodeRequestsFromLimit = isCardCodeLimitBypassActive(systemSettingsData)
|
||||
if (requestBody.cardCode && requestBody.cardCode.trim() && !isCardCodeEnabled && !isAdmin) {
|
||||
throw createApiError(400, 'CARD_CODE_DISABLED', 'Request card submissions are not enabled')
|
||||
}
|
||||
|
||||
let preferredPlayTime = null
|
||||
if (requestBody.preferredPlayTimeId) {
|
||||
if (!systemSettingsData?.enablePlayTimeSelection) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '播出时段选择功能未启用'
|
||||
})
|
||||
}
|
||||
|
||||
const playTimeResult = await db
|
||||
.select()
|
||||
.from(playTimes)
|
||||
.where(and(eq(playTimes.id, requestBody.preferredPlayTimeId), eq(playTimes.enabled, true)))
|
||||
.limit(1)
|
||||
preferredPlayTime = playTimeResult[0]
|
||||
|
||||
if (!preferredPlayTime) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '选择的播出时段不存在或未启用'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rawSubmissionNote = requestBody.submissionNote || ''
|
||||
const submissionNote =
|
||||
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
|
||||
// 公开留言审核:开关开启时投稿不立即公开,进入待审后由管理员通过
|
||||
const noteRequiresApproval = systemSettingsData?.submissionNoteRequiresApproval === true
|
||||
const wantsPublic = submissionNote !== null ? requestBody.submissionNotePublic !== false : false
|
||||
const submissionNotePublic = noteRequiresApproval ? false : wantsPublic
|
||||
// 仅用户勾选公开的留言进入审核,私密留言只供管理员查看
|
||||
const submissionNotePublicStatus =
|
||||
noteRequiresApproval && submissionNote !== null && wantsPublic
|
||||
? SUBMISSION_NOTE_STATUS.PENDING
|
||||
: null
|
||||
|
||||
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
|
||||
|
||||
const song = await db.transaction(async (tx) => {
|
||||
let providedCardCodeId: number | null = null
|
||||
const providedCardCode = requestBody.cardCode ? requestBody.cardCode.trim().toUpperCase() : ''
|
||||
|
||||
if (providedCardCode) {
|
||||
const codeRows = await tx
|
||||
.select()
|
||||
.from(cardCodes)
|
||||
.where(eq(cardCodes.code, providedCardCode))
|
||||
.limit(1)
|
||||
|
||||
const found = codeRows[0]
|
||||
if (!found || found.status !== 'AVAILABLE') {
|
||||
throw createApiError(
|
||||
400,
|
||||
'CARD_CODE_INVALID_OR_USED',
|
||||
'Request card is invalid or already used'
|
||||
)
|
||||
}
|
||||
|
||||
const lockResult = await tx
|
||||
.update(cardCodes)
|
||||
.set({ status: 'LOCKED', lockedBy: user.id, lockedAt: new Date() })
|
||||
.where(and(eq(cardCodes.id, found.id), eq(cardCodes.status, 'AVAILABLE')))
|
||||
.returning()
|
||||
|
||||
if (lockResult.length === 0) {
|
||||
throw createApiError(
|
||||
400,
|
||||
'CARD_CODE_LOCKED_OR_UNAVAILABLE',
|
||||
'Request card is locked or unavailable'
|
||||
)
|
||||
}
|
||||
|
||||
providedCardCodeId = found.id
|
||||
}
|
||||
|
||||
if (
|
||||
systemSettingsData?.enableSubmissionLimit &&
|
||||
!isAdmin &&
|
||||
!(excludeCardCodeRequestsFromLimit && providedCardCodeId) &&
|
||||
effectiveLimit &&
|
||||
effectiveLimit > 0 &&
|
||||
limitType
|
||||
) {
|
||||
// 同一用户的限额检查必须串行,否则并发请求可能读取到相同计数并同时越过上限。
|
||||
await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.for('update')
|
||||
|
||||
if (
|
||||
await isLimitReached(tx as any, user.id, limitType, effectiveLimit, {
|
||||
excludeCardCodeRequests: excludeCardCodeRequestsFromLimit
|
||||
})
|
||||
) {
|
||||
const labelMap: Record<string, string> = { daily: '每日', weekly: '每周', monthly: '每月' }
|
||||
const timeMap: Record<string, string> = { daily: '今日', weekly: '本周', monthly: '本月' }
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `${labelMap[limitType]}投稿限额为${effectiveLimit}首,您${timeMap[limitType]}已达到限额`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (hitRequestTime) {
|
||||
const latestRequestTimeResult = await tx
|
||||
.select()
|
||||
.from(requestTimes)
|
||||
.where(eq(requestTimes.id, hitRequestTime.id))
|
||||
.limit(1)
|
||||
const latestRequestTime = latestRequestTimeResult[0]
|
||||
|
||||
if (!latestRequestTime || !latestRequestTime.enabled) {
|
||||
throw createError({ statusCode: 403, message: '投稿时段已失效' })
|
||||
}
|
||||
|
||||
const updateResult = await tx
|
||||
.update(requestTimes)
|
||||
.set({
|
||||
accepted: sql`${requestTimes.accepted} + 1`
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(requestTimes.id, hitRequestTime.id),
|
||||
latestRequestTime.expected > 0
|
||||
? lt(requestTimes.accepted, latestRequestTime.expected)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
throw createError({ statusCode: 403, message: '当前时段投稿名额已满' })
|
||||
}
|
||||
}
|
||||
|
||||
let finalMusicId = requestBody.musicId ? String(requestBody.musicId) : null
|
||||
|
||||
if (isBilibili) {
|
||||
const bvId = finalMusicId?.split(':')[0]
|
||||
if (bvId) {
|
||||
const musicIdParts = [bvId]
|
||||
if (requestBody.bilibiliCid) {
|
||||
musicIdParts.push(requestBody.bilibiliCid)
|
||||
if (requestBody.bilibiliPage && Number(requestBody.bilibiliPage) > 1) {
|
||||
musicIdParts.push(String(requestBody.bilibiliPage))
|
||||
}
|
||||
}
|
||||
finalMusicId = musicIdParts.join(':')
|
||||
}
|
||||
}
|
||||
|
||||
const songResult = await tx
|
||||
.insert(songs)
|
||||
.values({
|
||||
title: requestBody.title,
|
||||
artist: requestBody.artist,
|
||||
requesterId: user.id,
|
||||
preferredPlayTimeId: preferredPlayTime?.id || null,
|
||||
semester: currentSemester,
|
||||
cover: requestBody.cover || null,
|
||||
musicPlatform: isBilibili ? 'bilibili' : requestBody.musicPlatform || null,
|
||||
musicId: finalMusicId,
|
||||
cardCodeId: providedCardCodeId || null,
|
||||
playUrl: requestBody.playUrl || null,
|
||||
durationSeconds: requestBody.durationSeconds || null,
|
||||
submissionNote,
|
||||
submissionNotePublic,
|
||||
submissionNotePublicStatus,
|
||||
hitRequestId: hitRequestTime?.id || null
|
||||
})
|
||||
.returning()
|
||||
const newSong = songResult[0]
|
||||
if (!newSong) {
|
||||
throw createError({ statusCode: 500, message: '点歌失败,请稍后重试' })
|
||||
}
|
||||
|
||||
if (
|
||||
requestBody.collaborators &&
|
||||
Array.isArray(requestBody.collaborators) &&
|
||||
requestBody.collaborators.length > 0
|
||||
) {
|
||||
const collaboratorIds = requestBody.collaborators.map((id: any) => Number(id)) as number[]
|
||||
const uniqueCollaboratorIds = [...new Set<number>(collaboratorIds)].filter(
|
||||
(id) => !isNaN(id) && id !== user.id
|
||||
)
|
||||
|
||||
if (uniqueCollaboratorIds.length > 0) {
|
||||
const validUsers = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(inArray(users.id, uniqueCollaboratorIds))
|
||||
|
||||
const validUserIds = new Set(validUsers.map((item) => item.id))
|
||||
|
||||
for (const collaboratorId of uniqueCollaboratorIds) {
|
||||
if (!validUserIds.has(collaboratorId)) continue
|
||||
|
||||
try {
|
||||
const existingCollab = await tx
|
||||
.select()
|
||||
.from(songCollaborators)
|
||||
.where(
|
||||
and(
|
||||
eq(songCollaborators.songId, newSong.id),
|
||||
eq(songCollaborators.userId, collaboratorId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingCollab.length > 0) continue
|
||||
|
||||
const collabResult = await tx
|
||||
.insert(songCollaborators)
|
||||
.values({
|
||||
songId: newSong.id,
|
||||
userId: collaboratorId,
|
||||
status: 'PENDING'
|
||||
})
|
||||
.returning()
|
||||
|
||||
const collab = collabResult[0]
|
||||
if (!collab) continue
|
||||
|
||||
await tx.insert(collaborationLogs).values({
|
||||
collaboratorId: collab.id,
|
||||
action: 'INVITE',
|
||||
operatorId: user.id,
|
||||
ipAddress: getClientIP(event)
|
||||
})
|
||||
|
||||
notificationsToSend.push({
|
||||
userId: collaboratorId,
|
||||
songId: newSong.id,
|
||||
songTitle: newSong.title
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`邀请用户 ${collaboratorId} 失败:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newSong
|
||||
})
|
||||
|
||||
// 投稿后立即补齐或校验歌曲时长(不阻塞请求响应)
|
||||
const submitDuration = song.durationSeconds
|
||||
const submitPlatform = song.musicPlatform
|
||||
const submitMusicId = song.musicId
|
||||
if (submitPlatform && submitMusicId) {
|
||||
const durationValidationTask = (async () => {
|
||||
try {
|
||||
const decision = await reconcileSongDurationOnSubmit(song.id, submitPlatform, submitMusicId, submitDuration)
|
||||
if (decision.outcome === 'fill' || decision.outcome === 'clear') {
|
||||
await db.update(songs).set({ durationSeconds: decision.durationSeconds }).where(eq(songs.id, song.id))
|
||||
if (decision.outcome === 'fill') {
|
||||
console.log(`[投稿时长] #${song.id} 已补齐时长 ${decision.durationSeconds}s`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[投稿时长] 后台任务 #${song.id} 异常:`, err)
|
||||
}
|
||||
})()
|
||||
if (typeof event.waitUntil === 'function') {
|
||||
event.waitUntil(durationValidationTask)
|
||||
} else {
|
||||
durationValidationTask.catch((err) => console.error(`[投稿时长] 后台任务 #${song.id} 异常:`, err))
|
||||
}
|
||||
}
|
||||
|
||||
for (const notification of notificationsToSend) {
|
||||
try {
|
||||
await createCollaborationInvitationNotification(
|
||||
user.id,
|
||||
notification.userId,
|
||||
notification.songId,
|
||||
notification.songTitle
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`发送邀请通知给用户 ${notification.userId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return song
|
||||
} catch (error: any) {
|
||||
console.error('点歌失败:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
} else if (error.message === '未设置活跃学期') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '系统未设置当前活跃学期,请联系管理员'
|
||||
})
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '点歌失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentSemesterName() {
|
||||
try {
|
||||
const currentSemesterResult = await db
|
||||
.select()
|
||||
.from(semesters)
|
||||
.where(eq(semesters.isActive, true))
|
||||
.limit(1)
|
||||
const currentSemester = currentSemesterResult[0]
|
||||
|
||||
if (currentSemester) {
|
||||
return currentSemester.name
|
||||
}
|
||||
|
||||
throw new Error('未设置活跃学期')
|
||||
} catch (error) {
|
||||
console.error('获取当前学期失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { passwordAuditLogs, users } from '~/drizzle/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
interface PasswordAuditContext {
|
||||
action: string
|
||||
ipAddress: string
|
||||
userAgent: string | null
|
||||
actorId?: number | null
|
||||
}
|
||||
|
||||
interface UpdateUserPasswordOptions {
|
||||
forceReset?: boolean
|
||||
expectedTokenVersion?: number
|
||||
auditContext?: PasswordAuditContext
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户密码
|
||||
* 包含:加密密码、更新密码修改时间
|
||||
* @param userId 用户ID
|
||||
* @param newPassword 新密码
|
||||
* @param options 密码更新选项,可用 tokenVersion 做并发条件更新
|
||||
*/
|
||||
export async function updateUserPassword(
|
||||
userId: number,
|
||||
newPassword: string,
|
||||
options: UpdateUserPasswordOptions = {}
|
||||
): Promise<{ passwordChangedAt: Date; tokenVersion: number }> {
|
||||
// 1. 加密新密码
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 12)
|
||||
|
||||
// 2. 更新数据库
|
||||
const passwordChangedAt = getBeijingTime()
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const result = await tx
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedNewPassword,
|
||||
passwordChangedAt,
|
||||
forcePasswordChange: options.forceReset ?? false,
|
||||
tokenVersion: sql`${users.tokenVersion} + 1`
|
||||
})
|
||||
.where(
|
||||
options.expectedTokenVersion === undefined
|
||||
? eq(users.id, userId)
|
||||
: and(eq(users.id, userId), eq(users.tokenVersion, options.expectedTokenVersion))
|
||||
)
|
||||
.returning({
|
||||
passwordChangedAt: users.passwordChangedAt,
|
||||
tokenVersion: users.tokenVersion
|
||||
})
|
||||
|
||||
const updatedUser = result[0]
|
||||
if (!updatedUser) {
|
||||
if (options.expectedTokenVersion !== undefined) {
|
||||
throw createApiError(409, 'AUTH_PASSWORD_STATE_CHANGED', '密码状态已变化,请重新验证后再试')
|
||||
}
|
||||
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
|
||||
}
|
||||
|
||||
return updatedUser
|
||||
})
|
||||
|
||||
// 3. 审计日志在事务外写入,失败不影响密码修改主流程
|
||||
if (options.auditContext) {
|
||||
try {
|
||||
await db.insert(passwordAuditLogs).values({
|
||||
userId,
|
||||
actorId: options.auditContext.actorId ?? null,
|
||||
action: options.auditContext.action,
|
||||
success: true,
|
||||
ipAddress: options.auditContext.ipAddress,
|
||||
userAgent: options.auditContext.userAgent,
|
||||
failureReason: null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[PasswordAudit] 写入密码审计日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passwordChangedAt: updated.passwordChangedAt || passwordChangedAt,
|
||||
tokenVersion: updated.tokenVersion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user