初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { schedules, songs, users } from '~/drizzle/schema'
import { desc, eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const query = getQuery(event)
const limit = parseInt(query.limit as string) || 10
try {
// 获取最近的活动记录
const activities: Array<{
id: string
type: string
title: string
description: string
createdAt: Date
}> = []
// 获取最近的歌曲活动
const recentSongs = await db
.select({
id: songs.id,
title: songs.title,
createdAt: songs.createdAt,
requesterName: users.name,
requesterUsername: users.username
})
.from(songs)
.leftJoin(users, eq(songs.requesterId, users.id))
.orderBy(desc(songs.createdAt))
.limit(5)
recentSongs.forEach((song) => {
activities.push({
id: `song-${song.id}`,
type: 'song',
title: '新歌曲投稿',
description: `${song.requesterName || song.requesterUsername || '用户'} 投稿了《${song.title}》`,
createdAt: song.createdAt
})
})
// 移除了新用户注册活动记录
// 获取最近的排期活动
const recentSchedules = await db
.select({
id: schedules.id,
createdAt: schedules.createdAt,
songTitle: songs.title,
songArtist: songs.artist
})
.from(schedules)
.leftJoin(songs, eq(schedules.songId, songs.id))
.orderBy(desc(schedules.createdAt))
.limit(3)
recentSchedules.forEach((schedule) => {
activities.push({
id: `schedule-${schedule.id}`,
type: 'schedule',
title: '排期更新',
description: `《${schedule.songTitle}》已安排到播放排期`,
createdAt: schedule.createdAt
})
})
// 按时间排序并限制数量
const sortedActivities = activities
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit)
return {
activities: sortedActivities,
total: sortedActivities.length
}
} catch (error) {
console.error('获取活动记录失败:', error)
throw createError({
statusCode: 500,
message: '获取活动记录失败'
})
}
})
+79
View File
@@ -0,0 +1,79 @@
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
import { eq } from 'drizzle-orm'
/**
* 删除API Key
* DELETE /api/admin/api-keys/[id]
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以管理API Key'
})
}
const apiKeyId = getRouterParam(event, 'id')
if (!apiKeyId) {
throw createError({
statusCode: 400,
statusMessage: 'API Key ID不能为空'
})
}
try {
// 检查API Key是否存在
const existingApiKey = await db
.select({
id: apiKeys.id,
name: apiKeys.name
})
.from(apiKeys)
.where(eq(apiKeys.id, apiKeyId))
.limit(1)
if (existingApiKey.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'API Key不存在'
})
}
const apiKeyName = existingApiKey[0].name
// 开始事务删除
await db.transaction(async (tx) => {
// 删除API Key权限
await tx.delete(apiKeyPermissions).where(eq(apiKeyPermissions.apiKeyId, apiKeyId))
// 删除API访问日志(可选,根据业务需求决定是否保留历史日志)
// 这里我们选择保留日志记录,只删除API Key本身
// await tx.delete(apiLogs)
// .where(eq(apiLogs.apiKeyId, apiKeyId))
// 删除API Key
await tx.delete(apiKeys).where(eq(apiKeys.id, apiKeyId))
})
return {
success: true,
message: `API Key "${apiKeyName}" 删除成功`,
data: {
id: apiKeyId,
name: apiKeyName
}
}
} catch (error: any) {
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: `删除API Key失败: ${error.message}`
})
}
})
+92
View File
@@ -0,0 +1,92 @@
import { apiKeyPermissions, apiKeys, db, users } from '~/drizzle/db'
import { eq } from 'drizzle-orm'
/**
* 获取API Key详情
* GET /api/admin/api-keys/[id]
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以管理API Key'
})
}
const apiKeyId = getRouterParam(event, 'id')
if (!apiKeyId) {
throw createError({
statusCode: 400,
statusMessage: 'API Key ID不能为空'
})
}
try {
// 获取API Key基本信息
const apiKeyResult = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
description: apiKeys.description,
keyPrefix: apiKeys.keyPrefix,
isActive: apiKeys.isActive,
expiresAt: apiKeys.expiresAt,
usageCount: apiKeys.usageCount,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
updatedAt: apiKeys.updatedAt,
createdBy: apiKeys.createdByUserId,
creatorName: users.name
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.createdByUserId, users.id))
.where(eq(apiKeys.id, apiKeyId))
.limit(1)
const apiKey = apiKeyResult[0]
if (!apiKey) {
throw createError({
statusCode: 404,
statusMessage: 'API Key不存在'
})
}
// 获取API Key权限
const permissionsResult = await db
.select({
permission: apiKeyPermissions.permission
})
.from(apiKeyPermissions)
.where(eq(apiKeyPermissions.apiKeyId, apiKeyId))
const permissions = permissionsResult.map((p) => p.permission)
// 计算状态
const isExpired = apiKey.expiresAt ? new Date() > apiKey.expiresAt : false
const status = !apiKey.isActive ? 'inactive' : isExpired ? 'expired' : 'active'
return {
success: true,
data: {
...apiKey,
permissions,
isExpired,
status
}
}
} catch (error: any) {
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: `获取API Key详情失败: ${error.message}`
})
}
})
+163
View File
@@ -0,0 +1,163 @@
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { getBeijingTime } from '~/utils/timeUtils'
/**
* 更新API Key
* PUT /api/admin/api-keys/[id]
*/
// 请求体验证schema
const updateApiKeySchema = z.object({
name: z
.string()
.min(1, 'API Key名称不能为空')
.max(100, 'API Key名称不能超过100个字符')
.optional(),
description: z.string().max(500, '描述不能超过500个字符').optional().nullable(),
isActive: z.boolean().optional(),
expiresAt: z.string().optional().nullable(),
permissions: z
.array(z.enum(['schedules:read', 'songs:read']))
.min(1, '至少需要选择一个权限')
.optional()
})
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以管理API Key'
})
}
const apiKeyId = getRouterParam(event, 'id')
if (!apiKeyId) {
throw createError({
statusCode: 400,
statusMessage: 'API Key ID不能为空'
})
}
try {
// 验证请求体
const body = await readBody(event)
const validatedData = updateApiKeySchema.parse(body)
// 检查API Key是否存在
const existingApiKey = await db
.select({ id: apiKeys.id })
.from(apiKeys)
.where(eq(apiKeys.id, apiKeyId))
.limit(1)
if (existingApiKey.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'API Key不存在'
})
}
// 处理过期时间
let expiresAt = undefined
if ('expiresAt' in validatedData) {
if (validatedData.expiresAt === null) {
expiresAt = null
} else if (validatedData.expiresAt) {
try {
// 处理预设选项格式 (3d, 7d, 30d, 60d, 90d)
if (validatedData.expiresAt.match(/^\d+d$/)) {
const days = parseInt(validatedData.expiresAt.replace('d', ''))
expiresAt = getBeijingTime()
expiresAt.setDate(expiresAt.getDate() + days)
} else {
// 处理传统的日期时间格式(向后兼容)
expiresAt = new Date(validatedData.expiresAt)
// 验证日期是否有效
if (isNaN(expiresAt.getTime())) {
throw createError({
statusCode: 400,
statusMessage: '无效的过期时间格式'
})
}
// 验证过期时间不能是过去的时间
if (expiresAt <= getBeijingTime()) {
throw createError({
statusCode: 400,
statusMessage: '过期时间不能是过去的时间'
})
}
}
} catch (error: any) {
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 400,
statusMessage: '无效的过期时间格式'
})
}
}
}
// 开始事务
const result = await db.transaction(async (tx) => {
// 准备更新数据
const updateData: any = {}
if (validatedData.name !== undefined) updateData.name = validatedData.name
if ('description' in validatedData) updateData.description = validatedData.description
if (validatedData.isActive !== undefined) updateData.isActive = validatedData.isActive
if ('expiresAt' in validatedData) updateData.expiresAt = expiresAt
// 更新API Key基本信息
await tx.update(apiKeys).set(updateData).where(eq(apiKeys.id, apiKeyId))
// 更新权限(如果提供了权限数据)
if (validatedData.permissions) {
// 删除现有权限
await tx.delete(apiKeyPermissions).where(eq(apiKeyPermissions.apiKeyId, apiKeyId))
// 插入新权限
const permissionValues = validatedData.permissions.map((permission) => ({
apiKeyId,
permission
}))
await tx.insert(apiKeyPermissions).values(permissionValues)
}
return { apiKeyId }
})
return {
success: true,
message: 'API Key更新成功',
data: {
id: result.apiKeyId
}
}
} catch (error: any) {
if (error.statusCode) {
throw error
}
// 处理Zod验证错误
if (error.name === 'ZodError') {
throw createError({
statusCode: 400,
statusMessage: `请求参数验证失败: ${error.errors.map((e: any) => e.message).join(', ')}`
})
}
throw createError({
statusCode: 500,
statusMessage: `更新API Key失败: ${error.message}`
})
}
})
+199
View File
@@ -0,0 +1,199 @@
import { apiKeyPermissions, apiKeys, db, users } from '~/drizzle/db'
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
/**
* 获取API Key列表
* GET /api/admin/api-keys
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以管理API Key'
})
}
const query = getQuery(event)
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 20
const search = (query.search as string) || ''
const status = query.status as string // 'active', 'inactive', 'expired'
const offset = (page - 1) * limit
try {
// 构建查询条件
const whereConditions = []
if (search) {
whereConditions.push(
sql`${apiKeys.name} ILIKE ${`%${search}%`} OR ${apiKeys.description} ILIKE ${`%${search}%`}`
)
}
if (status === 'active') {
whereConditions.push(
and(
eq(apiKeys.isActive, true),
sql`(${apiKeys.expiresAt} IS NULL OR ${apiKeys.expiresAt} > NOW())`
)
)
} else if (status === 'inactive') {
whereConditions.push(eq(apiKeys.isActive, false))
} else if (status === 'expired') {
whereConditions.push(
and(
eq(apiKeys.isActive, true),
sql`${apiKeys.expiresAt} IS NOT NULL AND ${apiKeys.expiresAt} <= NOW()`
)
)
}
const whereClause =
whereConditions.length > 0
? whereConditions.length === 1
? whereConditions[0]
: and(...whereConditions)
: undefined
// 获取API Key列表
const apiKeysList = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
description: apiKeys.description,
keyPrefix: apiKeys.keyPrefix,
isActive: apiKeys.isActive,
expiresAt: apiKeys.expiresAt,
usageCount: apiKeys.usageCount,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
updatedAt: apiKeys.updatedAt,
createdByUserId: apiKeys.createdByUserId,
creatorName: users.name
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.createdByUserId, users.id))
.where(whereClause)
.orderBy(desc(apiKeys.createdAt))
.limit(limit)
.offset(offset)
// 获取总数
const totalResult = await db.select({ count: count() }).from(apiKeys).where(whereClause)
const total = totalResult[0]?.count || 0
// 获取每个API Key的权限
const apiKeyIds = apiKeysList.map((key) => key.id).filter((id) => id != null)
let permissions = []
if (apiKeyIds.length > 0) {
try {
permissions = await db
.select({
apiKeyId: apiKeyPermissions.apiKeyId,
permission: apiKeyPermissions.permission
})
.from(apiKeyPermissions)
.where(inArray(apiKeyPermissions.apiKeyId, apiKeyIds))
// 确保permissions是数组
if (!Array.isArray(permissions)) {
permissions = []
}
} catch (permError) {
console.error('获取权限数据失败:', permError)
permissions = []
}
}
// 组织权限数据
const permissionsMap: Record<string, string[]> = {}
try {
if (permissions && Array.isArray(permissions) && permissions.length > 0) {
for (const perm of permissions) {
if (
perm &&
typeof perm === 'object' &&
perm.hasOwnProperty('apiKeyId') &&
perm.hasOwnProperty('permission') &&
perm.apiKeyId != null &&
perm.permission != null
) {
const apiKeyId = String(perm.apiKeyId)
const permission = String(perm.permission)
if (apiKeyId && permission) {
if (!permissionsMap[apiKeyId]) {
permissionsMap[apiKeyId] = []
}
permissionsMap[apiKeyId].push(permission)
}
}
}
}
} catch (mapError) {
console.error('组织权限数据失败:', mapError)
// permissionsMap已经初始化为空对象,继续执行
}
// 组合结果
let result = []
try {
if (Array.isArray(apiKeysList)) {
result = apiKeysList
.map((key) => {
// 确保key对象存在且有必要的属性
if (!key || typeof key !== 'object' || key.id == null) {
return null
}
try {
const keyId = String(key.id)
const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null
const now = new Date()
return {
...key,
permissions: permissionsMap[keyId] || [],
isExpired: expiresAt ? now > expiresAt : false,
status: !key.isActive
? 'inactive'
: expiresAt && now > expiresAt
? 'expired'
: 'active'
}
} catch (keyError) {
console.error('处理API Key数据失败:', keyError, key)
return null
}
})
.filter(Boolean) // 过滤掉null值
}
} catch (resultError) {
console.error('组合结果失败:', resultError)
result = []
}
return {
success: true,
data: {
items: result,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
}
}
} catch (error: any) {
throw createError({
statusCode: 500,
statusMessage: `获取API Key列表失败: ${error.message}`
})
}
})
+161
View File
@@ -0,0 +1,161 @@
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
import crypto from 'crypto'
import { z } from 'zod'
import { getBeijingTime } from '~/utils/timeUtils'
/**
* 创建API Key
* POST /api/admin/api-keys
*/
// 请求体验证schema
const createApiKeySchema = z.object({
name: z.string().min(1, 'API Key名称不能为空').max(100, 'API Key名称不能超过100个字符'),
description: z
.union([z.string().max(500, '描述不能超过500个字符'), z.null(), z.undefined()])
.optional(),
expiresAt: z.union([z.string(), z.null(), z.undefined()]).optional(),
permissions: z.array(z.enum(['schedules:read', 'songs:read'])).min(1, '至少需要选择一个权限')
})
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以管理API Key'
})
}
try {
// 验证请求体
const body = await readBody(event)
const validatedData = createApiKeySchema.parse(body)
// 生成API Key
const apiKey = generateApiKey()
const keyPrefix = apiKey.substring(0, 10) // vhub_xxxxx
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex')
// 处理过期时间
let expiresAt: Date | null = null
if (
validatedData.expiresAt &&
typeof validatedData.expiresAt === 'string' &&
validatedData.expiresAt.trim() !== ''
) {
try {
// 处理预设选项格式 (3d, 7d, 30d, 60d, 90d)
if (validatedData.expiresAt.match(/^\d+d$/)) {
const days = parseInt(validatedData.expiresAt.replace('d', ''))
expiresAt = getBeijingTime()
expiresAt.setDate(expiresAt.getDate() + days)
} else {
// 处理传统的日期时间格式(向后兼容)
expiresAt = new Date(validatedData.expiresAt)
// 验证日期是否有效
if (isNaN(expiresAt.getTime())) {
throw createError({
statusCode: 400,
statusMessage: '无效的过期时间格式'
})
}
// 验证过期时间不能是过去的时间
if (expiresAt <= getBeijingTime()) {
throw createError({
statusCode: 400,
statusMessage: '过期时间不能是过去的时间'
})
}
}
} catch (error: any) {
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 400,
statusMessage: '无效的过期时间格式'
})
}
}
// 开始事务
const result = await db.transaction(async (tx) => {
// 插入API Key记录
const apiKeyResult = await tx
.insert(apiKeys)
.values({
name: validatedData.name,
description: validatedData.description || null,
keyPrefix,
keyHash,
isActive: true,
expiresAt,
usageCount: 0,
createdByUserId: user.id
})
.returning({ id: apiKeys.id })
const apiKeyId = apiKeyResult[0].id
// 插入权限记录
const permissionValues = validatedData.permissions.map((permission) => ({
apiKeyId,
permission
}))
await tx.insert(apiKeyPermissions).values(permissionValues)
return {
id: apiKeyId,
apiKey, // 只在创建时返回完整的API Key
name: validatedData.name,
description: validatedData.description,
keyPrefix,
isActive: true,
expiresAt,
permissions: validatedData.permissions,
usageCount: 0,
createdBy: user.id,
creatorName: user.name
}
})
return {
success: true,
message: 'API Key创建成功',
data: result
}
} catch (error: any) {
if (error.statusCode) {
throw error
}
// 处理Zod验证错误
if (error.name === 'ZodError') {
throw createError({
statusCode: 400,
statusMessage: `请求参数验证失败: ${error.errors.map((e: any) => e.message).join(', ')}`
})
}
throw createError({
statusCode: 500,
statusMessage: `创建API Key失败: ${error.message}`
})
}
})
/**
* 生成API Key
* 格式: vhub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (37字符)
*/
function generateApiKey(): string {
const prefix = 'vhub_'
const randomBytes = crypto.randomBytes(16).toString('hex') // 32字符
return prefix + randomBytes
}
+71
View File
@@ -0,0 +1,71 @@
import { ApiLogService } from '~~/server/services/apiLogService'
/**
* 获取API访问日志
* GET /api/admin/api-keys/logs
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以查看API日志
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以查看API日志'
})
}
const query = getQuery(event)
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 50
const apiKeyId = query.apiKeyId as string
const endpoint = query.endpoint as string
const method = query.method as string
const statusCode = query.statusCode ? parseInt(query.statusCode as string) : undefined
const startDate = query.startDate as string
const endDate = query.endDate as string
const ipAddress = query.ipAddress as string
const sortBy = (query.sortBy as string) || 'createdAt'
const sortOrder = (query.sortOrder as string) || 'desc'
const offset = (page - 1) * limit
try {
// 使用ApiLogService获取日志
const result = await ApiLogService.getLogs({
apiKeyId,
endpoint,
method: method?.toUpperCase(),
statusCode,
ipAddress,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
page,
limit,
sortBy,
sortOrder
})
// 获取统计信息
const stats = await ApiLogService.getStats({
apiKeyId,
endpoint,
method: method?.toUpperCase(),
statusCode,
ipAddress,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined
})
return {
logs: result.logs,
pagination: result.pagination,
stats: stats.basic
}
} catch (error) {
console.error('获取API访问日志失败:', error)
throw createError({
statusCode: 500,
statusMessage: '获取API访问日志失败'
})
}
})
+175
View File
@@ -0,0 +1,175 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { db } from '~/drizzle/db'
import {
apiKeyPermissions,
apiKeys,
apiLogs,
collaborationLogs,
emailTemplates,
notifications,
notificationSettings,
playTimes,
requestTimes,
schedules,
semesters,
songBlacklists,
songCollaborators,
songReplayRequests,
songs,
systemSettings,
userIdentities,
users,
userStatusLogs,
votes
} from '~/drizzle/schema'
import { eq, inArray, isNull, notInArray, or } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
// 只有超级管理员可以清空数据库
if (user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以清空数据库'
})
}
try {
const body = await readBody(event)
const finalizeTempUser = body?.finalizeTempUser === true
const overwriteSuperAdmin = body?.overwriteSuperAdmin === true
const hasSuperAdminInBackup = body?.hasSuperAdminInBackup === true
const shouldOverwriteSuperAdmin = overwriteSuperAdmin && hasSuperAdminInBackup
if (finalizeTempUser) {
const currentUserId = Number(user.id)
const currentUserApiKeys = await db
.select({ id: apiKeys.id })
.from(apiKeys)
.where(eq(apiKeys.createdByUserId, currentUserId))
const currentUserApiKeyIds = currentUserApiKeys.map((item) => item.id)
if (currentUserApiKeyIds.length > 0) {
await db.delete(apiLogs).where(inArray(apiLogs.apiKeyId, currentUserApiKeyIds))
await db
.delete(apiKeyPermissions)
.where(inArray(apiKeyPermissions.apiKeyId, currentUserApiKeyIds))
}
await db.delete(apiKeys).where(eq(apiKeys.createdByUserId, currentUserId))
await db.delete(notifications).where(eq(notifications.userId, currentUserId))
await db.delete(notificationSettings).where(eq(notificationSettings.userId, currentUserId))
await db.delete(userStatusLogs).where(eq(userStatusLogs.userId, currentUserId))
await db.delete(userIdentities).where(eq(userIdentities.userId, currentUserId))
await db.delete(users).where(eq(users.id, currentUserId))
return {
success: true,
message: '临时管理员账户已清理',
finalized: true
}
}
let preservedSuperAdminIds: number[] = []
let temporaryPreservedUserId: number | null = null
if (!shouldOverwriteSuperAdmin) {
const preservedUsers = await db
.select({ id: users.id })
.from(users)
.where(or(eq(users.role, 'SUPER_ADMIN'), eq(users.id, 1)))
preservedSuperAdminIds = preservedUsers.map((item) => item.id)
} else {
temporaryPreservedUserId = Number(user.id)
}
console.log('清空现有数据...')
if (shouldOverwriteSuperAdmin || preservedSuperAdminIds.length === 0) {
await db.delete(apiLogs)
await db.delete(apiKeyPermissions)
await db.delete(apiKeys)
await db.delete(notifications)
await db.delete(notificationSettings)
await db.delete(collaborationLogs)
await db.delete(songCollaborators)
await db.delete(songReplayRequests)
await db.delete(schedules)
await db.delete(votes)
await db.delete(userStatusLogs)
await db.delete(emailTemplates)
await db.delete(songBlacklists)
await db.delete(userIdentities)
await db.delete(songs)
await db.delete(playTimes)
await db.delete(semesters)
await db.delete(requestTimes)
await db.delete(systemSettings)
if (temporaryPreservedUserId) {
await db.delete(users).where(notInArray(users.id, [temporaryPreservedUserId]))
} else {
await db.delete(users)
}
} else {
const preservedApiKeys = await db
.select({ id: apiKeys.id })
.from(apiKeys)
.where(inArray(apiKeys.createdByUserId, preservedSuperAdminIds))
const preservedApiKeyIds = preservedApiKeys.map((item) => item.id)
if (preservedApiKeyIds.length > 0) {
await db
.delete(apiLogs)
.where(or(isNull(apiLogs.apiKeyId), notInArray(apiLogs.apiKeyId, preservedApiKeyIds)))
await db
.delete(apiKeyPermissions)
.where(notInArray(apiKeyPermissions.apiKeyId, preservedApiKeyIds))
} else {
await db.delete(apiLogs)
await db.delete(apiKeyPermissions)
}
await db.delete(apiKeys).where(notInArray(apiKeys.createdByUserId, preservedSuperAdminIds))
await db.delete(notifications).where(notInArray(notifications.userId, preservedSuperAdminIds))
await db
.delete(notificationSettings)
.where(notInArray(notificationSettings.userId, preservedSuperAdminIds))
await db.delete(collaborationLogs)
await db.delete(songCollaborators)
await db.delete(songReplayRequests)
await db.delete(schedules)
await db.delete(votes)
await db.delete(userStatusLogs).where(notInArray(userStatusLogs.userId, preservedSuperAdminIds))
await db.delete(emailTemplates)
await db.delete(songBlacklists)
await db.delete(userIdentities).where(notInArray(userIdentities.userId, preservedSuperAdminIds))
await db.delete(songs)
await db.delete(playTimes)
await db.delete(semesters)
await db.delete(requestTimes)
await db.delete(systemSettings)
await db.delete(users).where(notInArray(users.id, preservedSuperAdminIds))
}
console.log('✅ 现有数据已清空')
return {
success: true,
message: '数据已清空',
shouldOverwriteSuperAdmin,
preservedSuperAdminIds,
temporaryPreservedUserId
}
} catch (error) {
console.error('清空数据失败:', error)
throw createError({
statusCode: 500,
statusMessage: '清空数据失败: ' + error.message
})
}
})
@@ -0,0 +1,71 @@
import { createError, defineEventHandler, getRouterParam } from 'h3'
import { promises as fs } from 'fs'
import path from 'path'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
const filename = getRouterParam(event, 'filename')
if (!filename) {
throw createError({
statusCode: 400,
statusMessage: '请指定文件名'
})
}
// 验证文件名安全性
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
statusMessage: '无效的文件名'
})
}
// 验证文件扩展名
if (!filename.endsWith('.json')) {
throw createError({
statusCode: 400,
statusMessage: '只能删除JSON格式的备份文件'
})
}
const backupDir = path.join(process.cwd(), 'backups')
const filepath = path.join(backupDir, filename)
// 检查文件是否存在
try {
await fs.access(filepath)
} catch {
throw createError({
statusCode: 404,
statusMessage: '备份文件不存在'
})
}
// 删除文件
await fs.unlink(filepath)
console.log(`管理员 ${user.username} 删除了备份文件: ${filename}`)
return {
success: true,
message: '备份文件删除成功',
filename
}
} catch (error) {
console.error('删除备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || '删除备份文件失败'
})
}
})
+84
View File
@@ -0,0 +1,84 @@
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { promises as fs } from 'fs'
import path from 'path'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
const query = getQuery(event)
const { filename } = query
if (!filename || typeof filename !== 'string') {
throw createError({
statusCode: 400,
statusMessage: '缺少文件名参数'
})
}
// 安全检查:确保文件名不包含路径遍历字符
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
statusMessage: '无效的文件名'
})
}
// 检查文件是否为备份文件
if (
!filename.endsWith('.json') ||
(!filename.startsWith('database-backup-') &&
!filename.startsWith('users-backup-') &&
!filename.startsWith('uploaded-'))
) {
throw createError({
statusCode: 400,
statusMessage: '无效的备份文件'
})
}
const backupDir = path.join(process.cwd(), 'backups')
const filepath = path.join(backupDir, filename)
try {
// 检查文件是否存在
await fs.access(filepath)
// 读取文件内容
const fileContent = await fs.readFile(filepath, 'utf8')
const fileStats = await fs.stat(filepath)
// 设置响应头
setHeader(event, 'Content-Type', 'application/json')
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
setHeader(event, 'Content-Length', fileStats.size.toString())
setHeader(event, 'Cache-Control', 'no-cache')
return fileContent
} catch (error) {
if (error.code === 'ENOENT') {
throw createError({
statusCode: 404,
statusMessage: '备份文件不存在'
})
}
throw createError({
statusCode: 500,
statusMessage: '读取备份文件失败'
})
}
} catch (error) {
console.error('下载备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || '下载备份文件失败'
})
}
})
@@ -0,0 +1,76 @@
import { createError, defineEventHandler, getRouterParam, setHeader } from 'h3'
import { promises as fs } from 'fs'
import path from 'path'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
const filename = getRouterParam(event, 'filename')
if (!filename) {
throw createError({
statusCode: 400,
statusMessage: '请指定文件名'
})
}
// 验证文件名安全性
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
statusMessage: '无效的文件名'
})
}
// 验证文件扩展名
if (!filename.endsWith('.json')) {
throw createError({
statusCode: 400,
statusMessage: '只能下载JSON格式的备份文件'
})
}
const backupDir = path.join(process.cwd(), 'backups')
const filepath = path.join(backupDir, filename)
// 检查文件是否存在
try {
await fs.access(filepath)
} catch {
throw createError({
statusCode: 404,
statusMessage: '备份文件不存在'
})
}
// 获取文件信息
const stats = await fs.stat(filepath)
// 设置响应头
setHeader(event, 'Content-Type', 'application/json')
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
setHeader(event, 'Content-Length', stats.size.toString())
setHeader(event, 'Cache-Control', 'no-cache')
// 读取并返回文件内容
const fileContent = await fs.readFile(filepath)
console.log(`管理员 ${user.username} 下载了备份文件: ${filename}`)
return fileContent
} catch (error) {
console.error('下载备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || '下载备份文件失败'
})
}
})
+539
View File
@@ -0,0 +1,539 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { db } from '~/drizzle/db'
import {
apiKeys,
apiKeyPermissions,
apiLogs,
collaborationLogs,
emailTemplates,
notifications,
notificationSettings,
playTimes,
requestTimes,
schedules,
semesters,
songBlacklists,
songCollaborators,
songReplayRequests,
songs,
systemSettings,
users,
userIdentities,
userStatusLogs,
votes
} from '~/drizzle/schema'
import { promises as fs } from 'fs'
import path from 'path'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
const body = await readBody(event)
const { tables = 'all', includeSystemData = true } = body
console.log('开始创建数据库备份...')
// 创建备份数据对象
const backupData = {
metadata: {
version: '1.0',
timestamp: new Date().toISOString(),
creator: user.username,
description: '',
backupType: 'custom',
tables: [],
totalRecords: 0
},
data: {}
}
// 定义要备份的表和对应的查询
const tablesToBackup = {
users: {
query: async () => {
const usersData = await db.select().from(users)
const settingsData = await db.select().from(notificationSettings)
// 手动关联通知设置
return usersData.map((user) => ({
...user,
notificationSettings: settingsData.filter((setting) => setting.userId === user.id)
}))
},
description: '用户数据'
},
songs: {
query: async () => {
const songsData = await db.select().from(songs)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
const votesData = await db.select().from(votes)
const schedulesData = await db.select().from(schedules)
const playTimesData = await db.select().from(playTimes)
// 手动关联数据
return songsData.map((song) => ({
...song,
requester: usersData.find((user) => user.id === song.requesterId),
votes: votesData
.filter((vote) => vote.songId === song.id)
.map((vote) => ({
...vote,
user: usersData.find((user) => user.id === vote.userId)
})),
schedules: schedulesData.filter((schedule) => schedule.songId === song.id),
preferredPlayTime: playTimesData.find((pt) => pt.id === song.preferredPlayTimeId)
}))
},
description: '歌曲数据'
},
votes: {
query: async () => {
const votesData = await db.select().from(votes)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
const songsData = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist
})
.from(songs)
return votesData.map((vote) => ({
...vote,
user: usersData.find((user) => user.id === vote.userId),
song: songsData.find((song) => song.id === vote.songId)
}))
},
description: '投票数据'
},
schedules: {
query: async () => {
const schedulesData = await db.select().from(schedules)
const songsData = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist
})
.from(songs)
const playTimesData = await db.select().from(playTimes)
return schedulesData.map((schedule) => ({
...schedule,
song: songsData.find((song) => song.id === schedule.songId),
playTime: playTimesData.find((pt) => pt.id === schedule.playTimeId)
}))
},
description: '排期数据'
},
notifications: {
query: async () => {
const notificationsData = await db.select().from(notifications)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
const songsData = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist
})
.from(songs)
return notificationsData.map((notification) => ({
...notification,
user: usersData.find((user) => user.id === notification.userId),
song: notification.songId
? songsData.find((song) => song.id === notification.songId)
: null
}))
},
description: '通知数据'
},
notificationSettings: {
query: async () => {
const settingsData = await db.select().from(notificationSettings)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
return settingsData.map((setting) => ({
...setting,
user: usersData.find((user) => user.id === setting.userId)
}))
},
description: '通知设置'
},
playTimes: {
query: () => db.select().from(playTimes),
description: '播出时段'
},
semesters: {
query: () => db.select().from(semesters),
description: '学期数据'
},
songBlacklist: {
query: () => db.select().from(songBlacklists),
description: '歌曲黑名单'
},
userStatusLogs: {
query: async () => {
const statusLogsData = await db.select().from(userStatusLogs)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
return statusLogsData.map((log) => ({
...log,
user: usersData.find((user) => user.id === log.userId)
}))
},
description: '用户状态变更日志'
},
userIdentities: {
query: async () => {
const identitiesData = await db.select().from(userIdentities)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
return identitiesData.map((identity) => ({
...identity,
user: usersData.find((user) => user.id === identity.userId)
}))
},
description: '第三方身份关联'
},
songCollaborators: {
query: async () => {
const collaboratorsData = await db.select().from(songCollaborators)
return collaboratorsData
},
description: '联合投稿人'
},
collaborationLogs: {
query: async () => {
const logsData = await db.select().from(collaborationLogs)
return logsData
},
description: '联合投稿审计日志'
},
songReplayRequests: {
query: async () => {
const requestsData = await db.select().from(songReplayRequests)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
const songsData = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist
})
.from(songs)
return requestsData.map((req) => ({
...req,
user: usersData.find((user) => user.id === req.userId),
song: songsData.find((song) => song.id === req.songId)
}))
},
description: '歌曲重播申请'
},
apiKeys: {
query: async () => {
const apiKeysData = await db.select().from(apiKeys)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
const permissionsData = await db.select().from(apiKeyPermissions)
return apiKeysData.map((key) => ({
...key,
createdByUser: usersData.find((user) => user.id === key.createdByUserId),
permissions: permissionsData.filter((perm) => perm.apiKeyId === key.id)
}))
},
description: 'API密钥'
},
apiKeyPermissions: {
query: () => db.select().from(apiKeyPermissions),
description: 'API密钥权限'
},
apiLogs: {
query: async () => {
const logsData = await db.select().from(apiLogs)
const apiKeysData = await db
.select({
id: apiKeys.id,
name: apiKeys.name
})
.from(apiKeys)
return logsData.map((log) => ({
...log,
apiKey: log.apiKeyId ? apiKeysData.find((key) => key.id === log.apiKeyId) : null
}))
},
description: 'API访问日志'
},
emailTemplates: {
query: async () => {
const templatesData = await db.select().from(emailTemplates)
const usersData = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
return templatesData.map((template) => ({
...template,
updatedByUser: template.updatedByUserId
? usersData.find((user) => user.id === template.updatedByUserId)
: null
}))
},
description: '邮件模板'
},
requestTimes: {
query: () => db.select().from(requestTimes),
description: '请求时段'
}
}
// 如果包含系统数据,添加系统设置表
if (includeSystemData) {
tablesToBackup.systemSettings = {
query: () => db.select().from(systemSettings),
description: '系统设置'
}
}
// 根据请求的表进行备份
let tablesToProcess
if (tables === 'all') {
tablesToProcess = Object.keys(tablesToBackup)
} else if (tables === 'users') {
// 仅备份用户相关数据
tablesToProcess = ['users', 'notificationSettings', 'userStatusLogs', 'userIdentities']
// 如果包含系统数据,也添加到处理列表中
if (includeSystemData) {
tablesToProcess.push('systemSettings')
}
} else if (Array.isArray(tables)) {
tablesToProcess = tables
} else {
tablesToProcess = [tables]
}
if (
includeSystemData &&
tablesToBackup.systemSettings &&
!tablesToProcess.includes('systemSettings')
) {
tablesToProcess.push('systemSettings')
}
if (tablesToProcess.length === 1 && tablesToProcess[0] === 'systemSettings') {
backupData.metadata.backupType = 'system'
backupData.metadata.description = `系统配置备份 - ${new Date().toLocaleString('zh-CN')}`
} else if (tables === 'users') {
backupData.metadata.backupType = 'users'
backupData.metadata.description = `用户数据备份 - ${new Date().toLocaleString('zh-CN')}`
} else if (tables === 'all') {
backupData.metadata.backupType = 'full'
backupData.metadata.description = `完整数据库备份 - ${new Date().toLocaleString('zh-CN')}`
} else if (tables === 'songs') {
backupData.metadata.backupType = 'songs'
backupData.metadata.description = `歌曲数据备份 - ${new Date().toLocaleString('zh-CN')}`
} else {
backupData.metadata.description = `自定义数据库备份 - ${new Date().toLocaleString('zh-CN')}`
}
let totalRecords = 0
for (const tableName of tablesToProcess) {
if (!tablesToBackup[tableName]) {
console.warn(`未知的表名: ${tableName}`)
continue
}
try {
console.log(`备份表: ${tableName}`)
const tableData = await tablesToBackup[tableName].query()
backupData.data[tableName] = tableData
backupData.metadata.tables.push({
name: tableName,
description: tablesToBackup[tableName].description,
recordCount: tableData.length
})
totalRecords += tableData.length
console.log(`✅ ${tableName}: ${tableData.length} 条记录`)
} catch (error) {
// 检查是否为表不存在错误
// Postgres 错误代码 42P01: undefined_table (表不存在)
const isTableMissing =
error.code === '42P01' ||
(error.message && error.message.includes('does not exist')) ||
(error.statusMessage && error.statusMessage.includes('does not exist'))
if (isTableMissing) {
console.warn(`⚠️ 表 ${tableName} 不存在,跳过备份`)
// 记录一个空的条目,以便知道跳过了
backupData.metadata.tables.push({
name: tableName,
description: `${tablesToBackup[tableName].description} (表不存在,已跳过)`,
recordCount: 0,
skipped: true
})
continue
}
console.error(`备份表 ${tableName} 失败:`, error)
throw createError({
statusCode: 500,
statusMessage: `备份表 ${tableName} 失败: ${error.message}`
})
}
}
backupData.metadata.totalRecords = totalRecords
// 生成备份文件名(用于下载)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
let filePrefix = 'database-backup'
if (tables === 'users') {
filePrefix = includeSystemData ? 'users-system-backup' : 'users-backup'
} else if (tablesToProcess.length === 1 && tablesToProcess[0] === 'systemSettings') {
filePrefix = 'system-settings-backup'
} else if (tables === 'songs') {
filePrefix = includeSystemData ? 'songs-system-backup' : 'songs-backup'
}
const filename = `${filePrefix}-${timestamp}.json`
console.log(`✅ 备份完成: ${filename}`)
console.log(`📊 总计备份 ${totalRecords} 条记录`)
// 检测运行环境
const isVercel = process.env.VERCEL || process.env.VERCEL_ENV
const isNetlify = process.env.NETLIFY
const isServerless = isVercel || isNetlify
if (isServerless) {
// 在无服务器环境中,直接返回备份数据供前端下载
console.log('🌐 检测到无服务器环境,直接返回备份数据')
// 计算数据大小(估算)
const dataSize = JSON.stringify(backupData).length
return {
success: true,
message: '数据库备份创建成功',
backup: {
filename,
data: backupData,
size: dataSize,
metadata: backupData.metadata,
downloadMode: 'direct' // 标识为直接下载模式
}
}
} else {
// 在传统服务器环境中,保存到文件系统
try {
const backupDir = path.join(process.cwd(), 'backups')
try {
await fs.access(backupDir)
} catch {
await fs.mkdir(backupDir, { recursive: true })
}
const filepath = path.join(backupDir, filename)
await fs.writeFile(filepath, JSON.stringify(backupData, null, 2), 'utf8')
return {
success: true,
message: '数据库备份创建成功',
backup: {
filename,
filepath,
size: (await fs.stat(filepath)).size,
metadata: backupData.metadata,
downloadMode: 'file' // 标识为文件下载模式
}
}
} catch (fsError) {
console.warn('文件系统操作失败,回退到直接返回模式:', fsError.message)
// 如果文件系统操作失败,回退到直接返回模式
const dataSize = JSON.stringify(backupData).length
return {
success: true,
message: '数据库备份创建成功(直接下载模式)',
backup: {
filename,
data: backupData,
size: dataSize,
metadata: backupData.metadata,
downloadMode: 'direct'
}
}
}
}
} catch (error) {
console.error('创建数据库备份失败:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || '创建数据库备份失败'
})
}
})
+155
View File
@@ -0,0 +1,155 @@
import { createError, defineEventHandler } from 'h3'
import { promises as fs } from 'fs'
import path from 'path'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
// 检测运行环境
const isVercel = process.env.VERCEL || process.env.VERCEL_ENV
const isNetlify = process.env.NETLIFY
const isServerless = isVercel || isNetlify
if (isServerless) {
// 在无服务器环境中,无法持久化存储文件,返回空列表
console.log('🌐 检测到无服务器环境,备份文件无法持久化存储')
return {
success: true,
backups: [],
total: 0,
message: '无服务器环境中备份文件无法持久化存储,请使用直接下载功能'
}
}
const backupDir = path.join(process.cwd(), 'backups')
// 检查备份目录是否存在
try {
await fs.access(backupDir)
} catch {
// 如果目录不存在,尝试创建它
try {
await fs.mkdir(backupDir, { recursive: true })
} catch (mkdirError) {
console.warn('无法创建备份目录,可能在只读文件系统中:', mkdirError.message)
return {
success: true,
backups: [],
total: 0,
message: '文件系统只读,备份文件无法持久化存储'
}
}
return {
success: true,
backups: [],
total: 0
}
}
// 读取备份目录中的文件
const files = await fs.readdir(backupDir)
const backupFiles = files.filter(
(file) =>
file.endsWith('.json') &&
(file.startsWith('database-backup-') ||
file.startsWith('users-backup-') ||
file.startsWith('users-system-backup-') ||
file.startsWith('songs-backup-') ||
file.startsWith('songs-system-backup-') ||
file.startsWith('system-settings-backup-') ||
file.startsWith('uploaded-'))
)
const backups = []
for (const filename of backupFiles) {
try {
const filepath = path.join(backupDir, filename)
const stats = await fs.stat(filepath)
// 尝试读取备份文件的元数据
let metadata = null
try {
const fileContent = await fs.readFile(filepath, 'utf8')
const backupData = JSON.parse(fileContent)
if (backupData.metadata) {
metadata = backupData.metadata
} else if (backupData.users) {
// 兼容旧格式的用户备份文件
metadata = {
version: '0.1',
timestamp: backupData.timestamp || stats.mtime.toISOString(),
creator: 'system',
description: '用户数据备份(旧格式)',
tables: [
{ name: 'users', recordCount: backupData.totalUsers || backupData.users.length }
],
totalRecords: backupData.totalUsers || backupData.users.length
}
}
} catch (parseError) {
console.warn(`无法解析备份文件 ${filename}:`, parseError.message)
}
// 判断备份类型
let type = 'users' // 默认为用户备份
if (filename.startsWith('database-backup-')) {
type = 'full'
} else if (
filename.startsWith('users-backup-') ||
filename.startsWith('users-system-backup-')
) {
type = 'users'
} else if (filename.startsWith('songs-backup-') || filename.startsWith('songs-system-backup-')) {
type = 'songs'
} else if (filename.startsWith('system-settings-backup-')) {
type = 'system'
} else if (filename.startsWith('uploaded-')) {
// 对于上传的文件,根据元数据判断类型
if (metadata) {
if (metadata.backupType) {
type = metadata.backupType
} else if (metadata.tables && metadata.tables.length > 1) {
type = 'full'
}
}
}
backups.push({
filename,
size: stats.size,
createdAt: stats.mtime,
metadata,
type,
isValid: metadata !== null
})
} catch (error) {
console.error(`处理备份文件 ${filename} 失败:`, error)
}
}
// 按创建时间倒序排列
backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
return {
success: true,
backups,
total: backups.length
}
} catch (error) {
console.error('获取备份文件列表失败:', error)
throw createError({
statusCode: 500,
statusMessage: '获取备份文件列表失败'
})
}
})
@@ -0,0 +1,967 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { db } from '~/drizzle/db'
import {
notificationSettings,
notifications,
playTimes,
schedules,
semesters,
songBlacklists,
songs,
systemSettings,
userIdentities,
users,
userStatusLogs,
votes
} from '~/drizzle/schema'
import { and, eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
const body = await readBody(event)
const {
tableName,
records,
mappings,
mode = 'merge',
overwriteSuperAdmin = false,
hasSuperAdminInBackup = false
} = body
if (overwriteSuperAdmin && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '仅超级管理员可以覆盖超级管理员账号数据'
})
}
if (!tableName || !records || !Array.isArray(records)) {
throw createError({
statusCode: 400,
statusMessage: '无效的请求参数'
})
}
// 转换映射对象为Map
const userIdMapping = new Map(
Object.entries(mappings?.users || {}).map(([k, v]) => [Number(k), Number(v)])
)
const songIdMapping = new Map(
Object.entries(mappings?.songs || {}).map(([k, v]) => [Number(k), Number(v)])
)
const preservedSuperAdminIds = new Set(
(mappings?.meta?.preservedSuperAdminIds || []).map((id) => Number(id))
)
const temporaryPreservedUserId = mappings?.meta?.temporaryPreservedUserId
? Number(mappings.meta.temporaryPreservedUserId)
: null
const shouldOverwriteSuperAdmin = overwriteSuperAdmin && hasSuperAdminInBackup
const newMappings = {
users: {},
songs: {}
}
const stats = {
processed: 0,
created: 0,
updated: 0,
errors: 0,
warnings: []
}
console.log(`处理批次: ${tableName}, ${records.length} 条记录`)
for (const record of records) {
try {
await db.transaction(async (tx) => {
switch (tableName) {
case 'users': {
const buildUserData = (includePassword = false) => {
const userData: any = {}
const userFields = [
'username',
'name',
'grade',
'class',
'role',
'email',
'emailVerified',
'lastLoginIp',
'meowNickname',
'forcePasswordChange',
'status',
'statusChangedBy'
]
const dateFields = [
'createdAt',
'updatedAt',
'lastLogin',
'passwordChangedAt',
'meowBoundAt',
'statusChangedAt'
]
userFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
if (field === 'role') {
if (!['USER', 'TEACHER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(record[field])) {
userData[field] = 'USER'
} else {
userData[field] = record[field]
}
} else {
userData[field] = record[field]
}
}
})
dateFields.forEach((field) => {
if (record.hasOwnProperty(field) && record[field]) {
userData[field] = new Date(record[field])
} else if (record.hasOwnProperty(field)) {
userData[field] = null
}
})
if (includePassword && record.hasOwnProperty('password')) {
userData.password = record.password
}
return userData
}
let createdUser
if (mode === 'merge') {
const existingUser = await tx.query.users.findFirst({
where: eq(users.username, record.username)
})
if (existingUser) {
createdUser = (
await tx
.update(users)
.set(buildUserData(false))
.where(eq(users.username, record.username))
.returning()
)[0]
stats.updated++
} else {
try {
const existingUserWithId = await tx.query.users.findFirst({
where: eq(users.id, record.id)
})
if (existingUserWithId) {
createdUser = (
await tx.insert(users).values(buildUserData(true)).returning()
)[0]
} else {
createdUser = (
await tx
.insert(users)
.values({
...buildUserData(true),
id: record.id
})
.returning()
)[0]
}
} catch (error) {
console.warn(`用户 ${record.username} 使用原始ID创建失败,使用自动生成ID`)
createdUser = (await tx.insert(users).values(buildUserData(true)).returning())[0]
}
stats.created++
}
} else {
if (
shouldOverwriteSuperAdmin &&
temporaryPreservedUserId &&
Number(record.id) === temporaryPreservedUserId
) {
createdUser = (await tx.insert(users).values(buildUserData(true)).returning())[0]
stats.created++
if (record.id && createdUser?.id) {
newMappings.users[record.id] = createdUser.id
}
break
}
const isBackupSuperAdminRecord =
record.role === 'SUPER_ADMIN' || preservedSuperAdminIds.has(Number(record.id))
if (!shouldOverwriteSuperAdmin && isBackupSuperAdminRecord) {
if (record.id) {
const existingUserWithId = await tx.query.users.findFirst({
where: eq(users.id, record.id)
})
if (existingUserWithId) {
newMappings.users[record.id] = existingUserWithId.id
}
}
stats.updated++
break
}
const existingUserWithId = await tx.query.users.findFirst({
where: eq(users.id, record.id)
})
if (existingUserWithId) {
createdUser = (
await tx
.update(users)
.set(buildUserData(true))
.where(eq(users.id, record.id))
.returning()
)[0]
stats.updated++
} else {
createdUser = (
await tx
.insert(users)
.values({
...buildUserData(true),
id: record.id
})
.returning()
)[0]
stats.created++
}
}
if (record.id && createdUser.id) {
newMappings.users[record.id] = createdUser.id
}
break
}
case 'userIdentities': {
let validIdentityUserId = record.userId
if (record.userId) {
const mappedUserId = userIdMapping.get(record.userId)
if (mappedUserId) {
validIdentityUserId = mappedUserId
} else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.userId)
})
if (!userExists) {
console.warn(`身份关联记录的用户ID ${record.userId} 不存在,跳过`)
return
}
}
} else {
return
}
const identityData = {
userId: validIdentityUserId,
provider: record.provider,
providerUserId: record.providerUserId,
providerUsername: record.providerUsername,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
if (!shouldOverwriteSuperAdmin && preservedSuperAdminIds.has(Number(validIdentityUserId))) {
stats.updated++
break
}
if (mode === 'merge') {
await tx
.insert(userIdentities)
.values(identityData)
.onConflictDoUpdate({
target: [userIdentities.provider, userIdentities.providerUserId],
set: identityData
})
stats.updated++
} else if (record.id) {
const existingIdentityWithId = await tx.query.userIdentities.findFirst({
where: eq(userIdentities.id, record.id)
})
if (existingIdentityWithId) {
await tx
.update(userIdentities)
.set(identityData)
.where(eq(userIdentities.id, record.id))
stats.updated++
} else {
await tx.insert(userIdentities).values({
...identityData,
id: record.id
})
stats.created++
}
} else {
await tx.insert(userIdentities).values(identityData)
stats.created++
}
break
}
case 'userStatusLogs': {
let validUserId = record.userId
if (record.userId) {
const mappedUserId = userIdMapping.get(record.userId)
if (mappedUserId) {
validUserId = mappedUserId
} else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.userId)
})
if (!userExists) {
console.warn(`用户状态日志的用户ID ${record.userId} 不存在,跳过`)
return
}
}
} else {
return
}
const userStatusLogData = {
userId: validUserId,
previousStatus: record.previousStatus || null,
newStatus: record.newStatus,
reason: record.reason || null,
changedBy: record.changedBy || null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
if (mode === 'merge') {
await tx.insert(userStatusLogs).values(userStatusLogData)
stats.created++
} else {
if (record.id) {
const existingLog = await tx.query.userStatusLogs.findFirst({
where: eq(userStatusLogs.id, record.id)
})
if (existingLog) {
await tx
.update(userStatusLogs)
.set(userStatusLogData)
.where(eq(userStatusLogs.id, record.id))
stats.updated++
} else {
await tx.insert(userStatusLogs).values({
...userStatusLogData,
id: record.id
})
stats.created++
}
} else {
await tx.insert(userStatusLogs).values(userStatusLogData)
stats.created++
}
}
break
}
case 'songs': {
let validRequesterId = record.requesterId
let validPreferredPlayTimeId = record.preferredPlayTimeId
if (record.requesterId) {
const mappedUserId = userIdMapping.get(record.requesterId)
if (mappedUserId) {
validRequesterId = mappedUserId
} else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.requesterId)
})
if (!userExists) {
console.warn(`歌曲请求者ID ${record.requesterId} 不存在,跳过`)
return
}
}
} else {
return
}
if (record.preferredPlayTimeId) {
const playTimeExists = await tx.query.playTimes.findFirst({
where: eq(playTimes.id, record.preferredPlayTimeId)
})
if (!playTimeExists) {
validPreferredPlayTimeId = null
}
}
const songData: any = {
requesterId: validRequesterId,
preferredPlayTimeId: validPreferredPlayTimeId,
played: record.hasOwnProperty('played') ? record.played : false
}
const songFields = [
'title',
'artist',
'semester',
'cover',
'musicPlatform',
'musicId',
'submissionNote',
'submissionNotePublic'
]
songFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
songData[field] = record[field]
}
})
if (record.playedAt) songData.playedAt = new Date(record.playedAt)
else songData.playedAt = null
if (record.createdAt) songData.createdAt = new Date(record.createdAt)
else songData.createdAt = new Date()
if (record.updatedAt) songData.updatedAt = new Date(record.updatedAt)
else songData.updatedAt = new Date()
let createdSong
if (mode === 'merge') {
const existingSong = await tx.query.songs.findFirst({
where: and(eq(songs.title, record.title), eq(songs.artist, record.artist))
})
if (existingSong) {
createdSong = (
await tx
.update(songs)
.set(songData)
.where(eq(songs.id, existingSong.id))
.returning()
)[0]
stats.updated++
} else {
try {
const existingSongWithId = await tx.query.songs.findFirst({
where: eq(songs.id, record.id)
})
if (existingSongWithId) {
createdSong = (await tx.insert(songs).values(songData).returning())[0]
} else {
createdSong = (
await tx
.insert(songs)
.values({
...songData,
id: record.id
})
.returning()
)[0]
}
} catch (error) {
createdSong = (await tx.insert(songs).values(songData).returning())[0]
}
stats.created++
}
} else {
const existingSongWithId = await tx.query.songs.findFirst({
where: eq(songs.id, record.id)
})
if (existingSongWithId) {
createdSong = (
await tx.update(songs).set(songData).where(eq(songs.id, record.id)).returning()
)[0]
stats.updated++
} else {
createdSong = (
await tx
.insert(songs)
.values({
...songData,
id: record.id
})
.returning()
)[0]
stats.created++
}
}
if (record.id && createdSong.id) {
newMappings.songs[record.id] = createdSong.id
}
break
}
case 'playTimes': {
const playTimeData: any = {}
const playTimeFields = ['name', 'startTime', 'endTime', 'description']
playTimeFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
playTimeData[field] = record[field]
}
})
playTimeData.enabled = record.hasOwnProperty('enabled') ? record.enabled : true
if (mode === 'merge') {
const existingPlayTime = await tx.query.playTimes.findFirst({
where: eq(playTimes.name, record.name)
})
if (existingPlayTime) {
await tx
.update(playTimes)
.set(playTimeData)
.where(eq(playTimes.id, existingPlayTime.id))
stats.updated++
} else {
await tx.insert(playTimes).values(playTimeData)
stats.created++
}
} else {
const existingPlayTimeWithId = await tx.query.playTimes.findFirst({
where: eq(playTimes.id, record.id)
})
if (existingPlayTimeWithId) {
await tx.update(playTimes).set(playTimeData).where(eq(playTimes.id, record.id))
stats.updated++
} else {
await tx.insert(playTimes).values({
...playTimeData,
id: record.id
})
stats.created++
}
}
break
}
case 'semesters': {
const semesterData: any = {}
const semesterFields = ['name']
semesterFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
semesterData[field] = record[field]
}
})
semesterData.isActive = record.hasOwnProperty('isActive') ? record.isActive : false
if (record.createdAt) semesterData.createdAt = new Date(record.createdAt)
if (mode === 'merge') {
const existingSemester = await tx.query.semesters.findFirst({
where: eq(semesters.name, semesterData.name)
})
if (existingSemester) {
await tx
.update(semesters)
.set(semesterData)
.where(eq(semesters.id, existingSemester.id))
stats.updated++
} else {
await tx.insert(semesters).values(semesterData)
stats.created++
}
} else {
const existing = await tx.query.semesters.findFirst({
where: eq(semesters.id, record.id)
})
if (existing) {
await tx.update(semesters).set(semesterData).where(eq(semesters.id, record.id))
stats.updated++
} else {
await tx.insert(semesters).values({ ...semesterData, id: record.id })
stats.created++
}
}
break
}
case 'systemSettings': {
const systemSettingsData: any = {}
const fields = [
'enablePlayTimeSelection',
'siteTitle',
'siteLogoUrl',
'schoolLogoHomeUrl',
'schoolLogoPrintUrl',
'siteDescription',
'submissionGuidelines',
'icpNumber',
'gonganNumber',
'enableSubmissionLimit',
'dailySubmissionLimit',
'weeklySubmissionLimit',
'monthlySubmissionLimit',
'showBlacklistKeywords',
'hideStudentInfo',
'enableReplayRequests',
'enableCollaborativeSubmission',
'enableSubmissionRemarks',
'enableRequestTimeLimitation',
'requestTimeLimitation',
'forceBlockAllRequests',
'smtpEnabled',
'smtpHost',
'smtpPort',
'smtpSecure',
'smtpUsername',
'smtpPassword',
'smtpFromEmail',
'smtpFromName'
]
fields.forEach((field) => {
if (record.hasOwnProperty(field)) systemSettingsData[field] = record[field]
})
if (mode === 'merge') {
const existing = await tx.query.systemSettings.findFirst()
if (existing) {
await tx
.update(systemSettings)
.set(systemSettingsData)
.where(eq(systemSettings.id, existing.id))
stats.updated++
} else {
await tx.insert(systemSettings).values(systemSettingsData)
stats.created++
}
} else {
const existing = await tx.query.systemSettings.findFirst({
where: eq(systemSettings.id, record.id)
})
if (existing) {
await tx
.update(systemSettings)
.set(systemSettingsData)
.where(eq(systemSettings.id, record.id))
stats.updated++
} else {
await tx.insert(systemSettings).values({ ...systemSettingsData, id: record.id })
stats.created++
}
}
break
}
case 'schedules': {
if (!record.songId || !record.playDate) return
let validSongId = record.songId
const mappedSongId = songIdMapping.get(record.songId)
if (mappedSongId) {
validSongId = mappedSongId
} else {
const songExists = await tx.query.songs.findFirst({
where: eq(songs.id, record.songId)
})
if (!songExists) return
}
let validPlayTimeId = record.playTimeId
if (record.playTimeId) {
const ptExists = await tx.query.playTimes.findFirst({
where: eq(playTimes.id, record.playTimeId)
})
if (!ptExists) validPlayTimeId = null
}
const scheduleData: any = {
songId: validSongId,
playDate: new Date(record.playDate),
playTimeId: validPlayTimeId,
played: record.hasOwnProperty('played') ? record.played : false,
sequence: record.hasOwnProperty('sequence') ? record.sequence : 1
}
if (record.publishedAt) scheduleData.publishedAt = new Date(record.publishedAt)
if (record.isDraft !== undefined) scheduleData.isDraft = record.isDraft
if (mode === 'merge') {
const existing = await tx.query.schedules.findFirst({
where: and(
eq(schedules.songId, validSongId),
eq(schedules.playDate, scheduleData.playDate)
)
})
if (existing) {
await tx.update(schedules).set(scheduleData).where(eq(schedules.id, existing.id))
stats.updated++
} else {
await tx.insert(schedules).values(scheduleData)
stats.created++
}
} else {
const existing = await tx.query.schedules.findFirst({
where: eq(schedules.id, record.id)
})
if (existing) {
await tx.update(schedules).set(scheduleData).where(eq(schedules.id, record.id))
stats.updated++
} else {
await tx.insert(schedules).values({ ...scheduleData, id: record.id })
stats.created++
}
}
break
}
case 'notificationSettings': {
let validUserId = record.userId
if (record.userId) {
const mappedUserId = userIdMapping.get(record.userId)
if (mappedUserId) validUserId = mappedUserId
else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.userId)
})
if (!userExists) return
}
}
const notificationSettingsData: any = { userId: validUserId }
const fields = ['refreshInterval', 'songVotedThreshold', 'meowUserId']
fields.forEach((field) => {
if (record.hasOwnProperty(field)) notificationSettingsData[field] = record[field]
})
notificationSettingsData.enabled = record.hasOwnProperty('enabled')
? record.enabled
: true
notificationSettingsData.songRequestEnabled = record.hasOwnProperty(
'songRequestEnabled'
)
? record.songRequestEnabled
: true
notificationSettingsData.songVotedEnabled = record.hasOwnProperty('songVotedEnabled')
? record.songVotedEnabled
: true
notificationSettingsData.songPlayedEnabled = record.hasOwnProperty('songPlayedEnabled')
? record.songPlayedEnabled
: true
if (!notificationSettingsData.refreshInterval)
notificationSettingsData.refreshInterval = 60
if (!notificationSettingsData.songVotedThreshold)
notificationSettingsData.songVotedThreshold = 1
notificationSettingsData.createdAt = record.createdAt
? new Date(record.createdAt)
: new Date()
notificationSettingsData.updatedAt = record.updatedAt
? new Date(record.updatedAt)
: new Date()
if (mode === 'merge') {
const existing = await tx.query.notificationSettings.findFirst({
where: eq(notificationSettings.userId, validUserId)
})
if (existing) {
await tx
.update(notificationSettings)
.set(notificationSettingsData)
.where(eq(notificationSettings.id, existing.id))
stats.updated++
} else {
await tx.insert(notificationSettings).values(notificationSettingsData)
stats.created++
}
} else {
const existing = await tx.query.notificationSettings.findFirst({
where: eq(notificationSettings.id, record.id)
})
if (existing) {
await tx
.update(notificationSettings)
.set(notificationSettingsData)
.where(eq(notificationSettings.id, record.id))
stats.updated++
} else {
await tx
.insert(notificationSettings)
.values({ ...notificationSettingsData, id: record.id })
stats.created++
}
}
break
}
case 'notifications': {
let validUserId = record.userId
if (record.userId) {
const mappedUserId = userIdMapping.get(record.userId)
if (mappedUserId) validUserId = mappedUserId
else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.userId)
})
if (!userExists) return
}
}
const notificationData: any = { userId: validUserId }
const fields = ['title', 'message', 'type']
fields.forEach((field) => {
if (record.hasOwnProperty(field)) notificationData[field] = record[field]
})
notificationData.read = record.hasOwnProperty('read') ? record.read : false
notificationData.createdAt = record.createdAt ? new Date(record.createdAt) : new Date()
notificationData.updatedAt = record.updatedAt ? new Date(record.updatedAt) : new Date()
if (mode === 'merge') {
const existing = await tx.query.notifications.findFirst({
where: and(
eq(notifications.userId, validUserId),
eq(notifications.type, record.type),
eq(notifications.message, record.message)
)
})
if (existing) {
await tx
.update(notifications)
.set(notificationData)
.where(eq(notifications.id, existing.id))
stats.updated++
} else {
await tx.insert(notifications).values(notificationData)
stats.created++
}
} else {
const existing = await tx.query.notifications.findFirst({
where: eq(notifications.id, record.id)
})
if (existing) {
await tx
.update(notifications)
.set(notificationData)
.where(eq(notifications.id, record.id))
stats.updated++
} else {
await tx.insert(notifications).values({ ...notificationData, id: record.id })
stats.created++
}
}
break
}
case 'songBlacklist': {
let validCreatedBy = record.createdBy
if (record.createdBy) {
const mappedUserId = userIdMapping.get(record.createdBy)
if (mappedUserId) validCreatedBy = mappedUserId
else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.createdBy)
})
if (!userExists) validCreatedBy = null
}
}
const blacklistData: any = {
type: record.type || 'KEYWORD',
value: record.value || '',
reason: record.reason || null,
isActive: record.hasOwnProperty('isActive') ? record.isActive : true,
createdBy: validCreatedBy,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date(),
updatedAt: record.updatedAt ? new Date(record.updatedAt) : new Date()
}
if (mode === 'merge') {
const existing = await tx.query.songBlacklists.findFirst({
where: and(
eq(songBlacklists.type, blacklistData.type),
eq(songBlacklists.value, blacklistData.value)
)
})
if (existing) {
await tx
.update(songBlacklists)
.set(blacklistData)
.where(eq(songBlacklists.id, existing.id))
stats.updated++
} else {
await tx.insert(songBlacklists).values(blacklistData)
stats.created++
}
} else {
const existing = await tx.query.songBlacklists.findFirst({
where: eq(songBlacklists.id, record.id)
})
if (existing) {
await tx
.update(songBlacklists)
.set(blacklistData)
.where(eq(songBlacklists.id, record.id))
stats.updated++
} else {
await tx.insert(songBlacklists).values({ ...blacklistData, id: record.id })
stats.created++
}
}
break
}
case 'votes': {
let validUserId = record.userId
if (record.userId) {
const mappedUserId = userIdMapping.get(record.userId)
if (mappedUserId) validUserId = mappedUserId
else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.userId)
})
if (!userExists) return
}
} else return
let validSongId = record.songId
if (record.songId) {
const mappedSongId = songIdMapping.get(record.songId)
if (mappedSongId) validSongId = mappedSongId
else {
const songExists = await tx.query.songs.findFirst({
where: eq(songs.id, record.songId)
})
if (!songExists) return
}
} else return
const voteData: any = {
userId: validUserId,
songId: validSongId,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
// 对于投票,merge和replace模式下都需要检查重复,因为联合主键或唯一约束
const existing = await tx.query.votes.findFirst({
where: and(eq(votes.userId, validUserId), eq(votes.songId, validSongId))
})
if (existing) {
await tx.update(votes).set(voteData).where(eq(votes.id, existing.id))
stats.updated++
} else {
await tx.insert(votes).values(voteData)
stats.created++
}
break
}
}
})
stats.processed++
} catch (error) {
console.error(`处理记录失败 (${tableName}):`, error)
stats.errors++
stats.warnings.push(`记录处理失败: ${error.message}`)
}
}
return {
success: true,
newMappings,
stats
}
})
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
import { createError, defineEventHandler } from 'h3'
import { promises as fs } from 'fs'
import path from 'path'
import formidable from 'formidable'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
// 创建备份目录
const backupDir = path.join(process.cwd(), 'backups')
try {
await fs.access(backupDir)
} catch {
await fs.mkdir(backupDir, { recursive: true })
}
// 解析上传的文件
const form = formidable({
uploadDir: backupDir,
keepExtensions: true,
maxFileSize: 100 * 1024 * 1024, // 100MB
filter: ({ mimetype, originalFilename }) => {
// 只允许JSON文件
return (
mimetype === 'application/json' ||
(originalFilename && originalFilename.endsWith('.json'))
)
}
})
const [fields, files] = await form.parse(event.node.req)
if (!files.file || !files.file[0]) {
throw createError({
statusCode: 400,
statusMessage: '请选择要上传的备份文件'
})
}
const uploadedFile = files.file[0]
// 验证文件内容
let backupData
try {
const fileContent = await fs.readFile(uploadedFile.filepath, 'utf8')
backupData = JSON.parse(fileContent)
} catch (error) {
// 删除无效文件
await fs.unlink(uploadedFile.filepath)
throw createError({
statusCode: 400,
statusMessage: '备份文件格式无效'
})
}
// 验证备份文件结构
if (!backupData.metadata && !backupData.users) {
await fs.unlink(uploadedFile.filepath)
throw createError({
statusCode: 400,
statusMessage: '不是有效的备份文件'
})
}
// 生成新的文件名
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const originalName = uploadedFile.originalFilename || 'backup.json'
const fileExtension = path.extname(originalName)
const baseName = path.basename(originalName, fileExtension)
const newFilename = `uploaded-${baseName}-${timestamp}${fileExtension}`
const newFilepath = path.join(backupDir, newFilename)
// 移动文件到新位置
await fs.rename(uploadedFile.filepath, newFilepath)
// 更新备份文件的元数据
if (backupData.metadata) {
// 新格式备份文件
backupData.metadata.uploadedBy = user.username
backupData.metadata.uploadedAt = new Date().toISOString()
backupData.metadata.originalFilename = originalName
backupData.metadata.creator = user.username // 确保创建者信息正确
} else if (backupData.users) {
// 旧格式备份文件,创建元数据
backupData.metadata = {
version: '0.1',
timestamp: backupData.timestamp || new Date().toISOString(),
creator: user.username,
uploadedBy: user.username,
uploadedAt: new Date().toISOString(),
originalFilename: originalName,
description: '用户数据备份(上传)',
tables: [{ name: 'users', recordCount: backupData.totalUsers || backupData.users.length }],
totalRecords: backupData.totalUsers || backupData.users.length
}
}
// 重新保存文件
await fs.writeFile(newFilepath, JSON.stringify(backupData, null, 2), 'utf8')
console.log(`管理员 ${user.username} 上传了备份文件: ${newFilename}`)
// 获取文件信息
const stats = await fs.stat(newFilepath)
return {
success: true,
message: '备份文件上传成功',
backup: {
filename: newFilename,
originalFilename: originalName,
size: stats.size,
uploadedBy: user.username,
uploadedAt: new Date().toISOString(),
metadata: backupData.metadata || null
}
}
} catch (error) {
console.error('上传备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || '上传备份文件失败'
})
}
})
+67
View File
@@ -0,0 +1,67 @@
import { createError, defineEventHandler, getRouterParam } from 'h3'
import { db } from '~/drizzle/db'
import { songBlacklists } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const id = parseInt(getRouterParam(event, 'id'))
if (!id) {
throw createError({
statusCode: 400,
message: '无效的ID'
})
}
try {
// 检查黑名单项是否存在
const blacklistItemResult = await db
.select()
.from(songBlacklists)
.where(eq(songBlacklists.id, id))
.limit(1)
const blacklistItem = blacklistItemResult[0]
if (!blacklistItem) {
throw createError({
statusCode: 404,
message: '黑名单项不存在'
})
}
// 删除黑名单项
await db.delete(songBlacklists).where(eq(songBlacklists.id, id))
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('黑名单删除后歌曲缓存已清除')
} catch (cacheError) {
console.warn('清除歌曲缓存失败:', cacheError)
}
return {
success: true,
message: '黑名单项删除成功'
}
} catch (error) {
if (error.statusCode) {
throw error
}
console.error('删除黑名单项失败:', error)
throw createError({
statusCode: 500,
message: '删除黑名单项失败'
})
}
})
+82
View File
@@ -0,0 +1,82 @@
import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { songBlacklists } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const id = parseInt(getRouterParam(event, 'id'))
if (!id) {
throw createError({
statusCode: 400,
message: '无效的ID'
})
}
const body = await readBody(event)
const { isActive, reason } = body
try {
// 检查黑名单项是否存在
const existingItemResult = await db
.select()
.from(songBlacklists)
.where(eq(songBlacklists.id, id))
.limit(1)
if (existingItemResult.length === 0) {
throw createError({
statusCode: 404,
message: '黑名单项不存在'
})
}
// 更新黑名单项
const updateData = {}
if (typeof isActive === 'boolean') {
updateData.isActive = isActive
}
if (reason !== undefined) {
updateData.reason = reason?.trim() || null
}
const updatedItem = await db
.update(songBlacklists)
.set(updateData)
.where(eq(songBlacklists.id, id))
.returning()
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('黑名单更新后歌曲缓存已清除')
} catch (cacheError) {
console.warn('清除歌曲缓存失败:', cacheError)
}
return {
success: true,
message: '黑名单项更新成功',
item: updatedItem[0]
}
} catch (error) {
if (error.statusCode) {
throw error
}
console.error('更新黑名单项失败:', error)
throw createError({
statusCode: 500,
message: '更新黑名单项失败'
})
}
})
+63
View File
@@ -0,0 +1,63 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { songBlacklists } from '~/drizzle/schema'
import { and, count, desc, eq, ilike } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const query = getQuery(event)
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 20
const type = query.type as string
const search = query.search as string
try {
// 构建查询条件
const whereConditions = []
if (type) {
whereConditions.push(eq(songBlacklists.type, type))
}
if (search) {
whereConditions.push(ilike(songBlacklists.value, `%${search}%`))
}
const whereClause = whereConditions.length > 0 ? and(...whereConditions) : undefined
// 获取总数
const totalResult = await db.select({ count: count() }).from(songBlacklists).where(whereClause)
const total = totalResult[0].count
// 获取黑名单列表
const blacklist = await db
.select()
.from(songBlacklists)
.where(whereClause)
.orderBy(desc(songBlacklists.createdAt))
.limit(limit)
.offset((page - 1) * limit)
return {
blacklist,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
}
} catch (error) {
console.error('获取黑名单失败:', error)
throw createError({
statusCode: 500,
message: '获取黑名单失败'
})
}
})
+86
View File
@@ -0,0 +1,86 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { songBlacklists } from '~/drizzle/schema'
import { and, eq } from 'drizzle-orm'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const body = await readBody(event)
const { type, value, reason } = body
// 验证输入
if (!type || !value) {
throw createError({
statusCode: 400,
message: '类型和值不能为空'
})
}
if (!['SONG', 'KEYWORD'].includes(type)) {
throw createError({
statusCode: 400,
message: '无效的黑名单类型'
})
}
try {
// 检查是否已存在
const existing = await db
.select()
.from(songBlacklists)
.where(and(eq(songBlacklists.type, type), eq(songBlacklists.value, value.trim())))
.limit(1)
if (existing.length > 0) {
throw createError({
statusCode: 409,
message: '该项目已在黑名单中'
})
}
// 创建黑名单项
const blacklistItem = await db
.insert(songBlacklists)
.values({
type,
value: value.trim(),
reason: reason?.trim() || null,
createdBy: user.id,
isActive: true
})
.returning()
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('黑名单添加后歌曲缓存已清除')
} catch (cacheError) {
console.warn('清除歌曲缓存失败:', cacheError)
}
return {
success: true,
message: '黑名单项添加成功',
item: blacklistItem[0]
}
} catch (error) {
if (error.statusCode) {
throw error
}
console.error('添加黑名单项失败:', error)
throw createError({
statusCode: 500,
message: '添加黑名单项失败'
})
}
})
+28
View File
@@ -0,0 +1,28 @@
import { eq } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { changelogEntries } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '只有管理员才能删除更新日志' })
}
const id = Number(getRouterParam(event, 'id'))
if (!Number.isFinite(id) || id <= 0) {
throw createError({ statusCode: 400, message: '无效的 id' })
}
const rows = await db.delete(changelogEntries).where(eq(changelogEntries.id, id)).returning()
if (!rows[0]) {
throw createError({ statusCode: 404, message: '更新日志不存在' })
}
return { success: true }
})
+69
View File
@@ -0,0 +1,69 @@
import { eq } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { changelogEntries } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '只有管理员才能更新更新日志' })
}
const id = Number(getRouterParam(event, 'id'))
if (!Number.isFinite(id) || id <= 0) {
throw createError({ statusCode: 400, message: '无效的 id' })
}
const body = await readBody(event)
const updateData: any = { updatedAt: new Date() }
if (body?.version !== undefined) {
if (body.version !== null && typeof body.version !== 'string') {
throw createError({ statusCode: 400, message: 'version 必须是字符串' })
}
updateData.version = (body.version || '').trim()
}
if (body?.title !== undefined) {
if (body.title !== null && typeof body.title !== 'string') {
throw createError({ statusCode: 400, message: 'title 必须是字符串' })
}
updateData.title = (body.title || '').trim()
}
if (body?.content !== undefined) {
if (body.content !== null && typeof body.content !== 'string') {
throw createError({ statusCode: 400, message: 'content 必须是字符串' })
}
updateData.content = (body.content || '').trim()
}
if (body?.published !== undefined) {
if (typeof body.published !== 'boolean') {
throw createError({ statusCode: 400, message: 'published 必须是布尔值' })
}
updateData.published = body.published
updateData.publishedAt = body.published ? new Date() : null
}
if (Object.keys(updateData).length <= 1) {
throw createError({ statusCode: 400, message: '没有可更新的字段' })
}
const rows = await db
.update(changelogEntries)
.set(updateData)
.where(eq(changelogEntries.id, id))
.returning()
if (!rows[0]) {
throw createError({ statusCode: 404, message: '更新日志不存在' })
}
return rows[0]
})
+17
View File
@@ -0,0 +1,17 @@
import { desc } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { changelogEntries } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '只有管理员才能查看更新日志' })
}
return db.select().from(changelogEntries).orderBy(desc(changelogEntries.createdAt))
})
+44
View File
@@ -0,0 +1,44 @@
import { db } from '~/drizzle/db'
import { changelogEntries } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '只有管理员才能创建更新日志' })
}
const body = await readBody(event)
const version = typeof body?.version === 'string' ? body.version.trim() : ''
const title = typeof body?.title === 'string' ? body.title.trim() : ''
const content = typeof body?.content === 'string' ? body.content.trim() : ''
const published = body?.published === true
if (!version) {
throw createError({ statusCode: 400, message: 'version 不能为空' })
}
if (!title) {
throw createError({ statusCode: 400, message: 'title 不能为空' })
}
if (!content) {
throw createError({ statusCode: 400, message: 'content 不能为空' })
}
const rows = await db
.insert(changelogEntries)
.values({
version,
title,
content,
published,
publishedAt: published ? new Date() : null,
createdBy: user.id
})
.returning()
return rows[0]
})
+34
View File
@@ -0,0 +1,34 @@
import { verifyAdminAuth } from '~~/server/utils/auth'
import { databaseManager } from '~~/server/utils/database-manager'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
await verifyAdminAuth(event)
// 清理过期会话
const cleanedCount = await databaseManager.cleanupExpiredSessions()
// 清除健康检查缓存以确保下次检查获取最新状态
databaseManager.clearHealthCheckCache()
return {
success: true,
message: `Successfully cleaned up ${cleanedCount} expired sessions`,
cleanedCount,
timestamp: new Date().toISOString()
}
} catch (error) {
if (error instanceof Error && error.message.includes('认证')) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized'
})
}
throw createError({
statusCode: 500,
statusMessage: error instanceof Error ? error.message : 'Cleanup failed'
})
}
})
@@ -0,0 +1,34 @@
import { verifyAdminAuth } from '~~/server/utils/auth'
import { databaseManager } from '~~/server/utils/database-manager'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const authResult = await verifyAdminAuth(event)
if (!authResult.success) {
throw createError({
statusCode: 401,
statusMessage: authResult.message
})
}
// 使用改进的数据库管理器获取性能指标
const performanceMetrics = await databaseManager.getPerformanceMetrics()
return {
...performanceMetrics,
timestamp: new Date().toISOString()
}
} catch (error: any) {
if (error.statusCode === 401) {
throw error
}
console.error('Database performance API error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error'
})
}
})
@@ -0,0 +1,34 @@
import { verifyAdminAuth } from '~~/server/utils/auth'
import { databaseManager } from '~~/server/utils/database-manager'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const authResult = await verifyAdminAuth(event)
if (!authResult.success) {
throw createError({
statusCode: 401,
statusMessage: authResult.message
})
}
// 使用改进的数据库管理器获取连接池状态
const poolStatus = await databaseManager.getConnectionPoolStatus()
return {
...poolStatus,
timestamp: new Date().toISOString()
}
} catch (error: any) {
if (error.statusCode === 401) {
throw error
}
console.error('Database pool status API error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error'
})
}
})
+254
View File
@@ -0,0 +1,254 @@
import { createError, defineEventHandler } from 'h3'
import { db } from '~/drizzle/db'
import { CacheService } from '../../../services/cacheService'
import {
apiKeyPermissions,
apiKeys,
apiLogs,
collaborationLogs,
emailTemplates,
notificationSettings,
notifications,
playTimes,
requestTimes,
schedules,
semesters,
songBlacklists,
songCollaborators,
songReplayRequests,
songs,
systemSettings,
userIdentities,
users,
userStatusLogs,
votes
} from '~/drizzle/schema'
import { ne, sql } from 'drizzle-orm'
// 重置所有表的自增序列
async function resetAutoIncrementSequences() {
const tables = [
{ name: 'users', dbName: 'User' },
{ name: 'songs', dbName: 'Song' },
{ name: 'votes', dbName: 'Vote' },
{ name: 'schedules', dbName: 'Schedule' },
{ name: 'notifications', dbName: 'Notification' },
{ name: 'notificationSettings', dbName: 'NotificationSettings' },
{ name: 'playTimes', dbName: 'PlayTime' },
{ name: 'semesters', dbName: 'Semester' },
{ name: 'systemSettings', dbName: 'SystemSettings' },
{ name: 'songBlacklists', dbName: 'SongBlacklist' },
{ name: 'userStatusLogs', dbName: 'user_status_logs' },
{ name: 'userIdentities', dbName: 'UserIdentity' },
{ name: 'requestTimes', dbName: 'RequestTime' },
{ name: 'songReplayRequests', dbName: 'song_replay_requests' },
{ name: 'emailTemplates', dbName: 'EmailTemplate' }
]
const results = []
for (const table of tables) {
try {
// 获取序列名称
const sequenceNameResult = await db.execute(
sql.raw(`SELECT pg_get_serial_sequence('"${table.dbName}"', 'id') as sequence_name`)
)
const sequenceName = (sequenceNameResult as any)[0]?.sequence_name
if (sequenceName) {
// 重置序列值到 1
await db.execute(sql.raw(`ALTER SEQUENCE ${sequenceName} RESTART WITH 1`))
results.push({ table: table.name, success: true })
} else {
results.push({ table: table.name, success: false, message: 'Sequence not found' })
}
} catch (error) {
console.warn(`重置 ${table.name} 表序列失败: ${error.message}`)
results.push({ table: table.name, success: false, error: error.message })
}
}
return results
}
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
const resetResults = {
success: true,
message: '数据库重置完成',
details: {
tablesCleared: 0,
recordsDeleted: 0,
sequencesReset: 0,
errors: [],
preservedData: []
}
}
try {
// 使用事务确保数据一致性
await db.transaction(async (tx) => {
// 按照外键依赖顺序删除数据
// 1. API 日志与权限 (依赖于 API 密钥)
const deletedApiLogs = await tx.delete(apiLogs).returning({ id: apiLogs.id })
resetResults.details.recordsDeleted += deletedApiLogs.length
resetResults.details.tablesCleared++
const deletedApiKeyPermissions = await tx
.delete(apiKeyPermissions)
.returning({ id: apiKeyPermissions.id })
resetResults.details.recordsDeleted += deletedApiKeyPermissions.length
resetResults.details.tablesCleared++
// 2. API 密钥 (依赖于用户)
const deletedApiKeys = await tx.delete(apiKeys).returning({ id: apiKeys.id })
resetResults.details.recordsDeleted += deletedApiKeys.length
resetResults.details.tablesCleared++
// 3. 通知与设置 (依赖于用户、歌曲)
const deletedNotifications = await tx
.delete(notifications)
.returning({ id: notifications.id })
resetResults.details.recordsDeleted += deletedNotifications.length
resetResults.details.tablesCleared++
const deletedNotificationSettings = await tx
.delete(notificationSettings)
.returning({ id: notificationSettings.id })
resetResults.details.recordsDeleted += deletedNotificationSettings.length
resetResults.details.tablesCleared++
// 4. 协作日志 (依赖于歌曲协作者)
const deletedCollaborationLogs = await tx
.delete(collaborationLogs)
.returning({ id: collaborationLogs.id })
resetResults.details.recordsDeleted += deletedCollaborationLogs.length
resetResults.details.tablesCleared++
// 5. 歌曲协作者 (依赖于歌曲、用户)
const deletedSongCollaborators = await tx
.delete(songCollaborators)
.returning({ id: songCollaborators.id })
resetResults.details.recordsDeleted += deletedSongCollaborators.length
resetResults.details.tablesCleared++
// 6. 歌曲重播申请 (依赖于歌曲、用户)
const deletedReplayRequests = await tx
.delete(songReplayRequests)
.returning({ id: songReplayRequests.id })
resetResults.details.recordsDeleted += deletedReplayRequests.length
resetResults.details.tablesCleared++
// 7. 排期 (依赖于歌曲、播出时段)
const deletedSchedules = await tx.delete(schedules).returning({ id: schedules.id })
resetResults.details.recordsDeleted += deletedSchedules.length
resetResults.details.tablesCleared++
// 8. 投票 (依赖于歌曲、用户)
const deletedVotes = await tx.delete(votes).returning({ id: votes.id })
resetResults.details.recordsDeleted += deletedVotes.length
resetResults.details.tablesCleared++
// 9. 歌曲 (依赖于用户、播出时段)
const deletedSongs = await tx.delete(songs).returning({ id: songs.id })
resetResults.details.recordsDeleted += deletedSongs.length
resetResults.details.tablesCleared++
// 10. 黑名单 (依赖于用户 [创建者])
const deletedBlacklists = await tx
.delete(songBlacklists)
.returning({ id: songBlacklists.id })
resetResults.details.recordsDeleted += deletedBlacklists.length
resetResults.details.tablesCleared++
// 11. 用户状态日志 (依赖于用户)
const deletedStatusLogs = await tx
.delete(userStatusLogs)
.returning({ id: userStatusLogs.id })
resetResults.details.recordsDeleted += deletedStatusLogs.length
resetResults.details.tablesCleared++
// 12. 第三方身份关联 (依赖于用户)
const deletedUserIdentities = await tx
.delete(userIdentities)
.returning({ id: userIdentities.id })
resetResults.details.recordsDeleted += deletedUserIdentities.length
resetResults.details.tablesCleared++
// 13. 邮件模板 (依赖于用户 [更新者])
const deletedEmailTemplates = await tx
.delete(emailTemplates)
.returning({ id: emailTemplates.id })
resetResults.details.recordsDeleted += deletedEmailTemplates.length
resetResults.details.tablesCleared++
// 14. 用户 (除自己外)
const deletedUsers = await tx
.delete(users)
.where(ne(users.id, user.id))
.returning({ id: users.id })
resetResults.details.recordsDeleted += deletedUsers.length
resetResults.details.tablesCleared++
resetResults.details.preservedData.push(`保留当前管理员账户: ${user.name}`)
// 15. 播出时段
const deletedPlayTimes = await tx.delete(playTimes).returning({ id: playTimes.id })
resetResults.details.recordsDeleted += deletedPlayTimes.length
resetResults.details.tablesCleared++
// 16. 学期
const deletedSemesters = await tx.delete(semesters).returning({ id: semesters.id })
resetResults.details.recordsDeleted += deletedSemesters.length
resetResults.details.tablesCleared++
// 17. 请求时段
const deletedRequestTimes = await tx.delete(requestTimes).returning({ id: requestTimes.id })
resetResults.details.recordsDeleted += deletedRequestTimes.length
resetResults.details.tablesCleared++
// 18. 系统设置
const deletedSystemSettings = await tx
.delete(systemSettings)
.returning({ id: systemSettings.id })
resetResults.details.recordsDeleted += deletedSystemSettings.length
resetResults.details.tablesCleared++
})
// 重置自增序列
const sequenceResults = await resetAutoIncrementSequences()
resetResults.details.sequencesReset = sequenceResults.filter((r) => r.success).length
// 清除缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearAllCache()
} catch (error) {
console.warn('清除缓存失败:', error)
resetResults.details.warnings = resetResults.details.warnings || []
resetResults.details.warnings.push('清除缓存失败')
}
} catch (error) {
console.error('重置数据库失败:', error)
throw createError({
statusCode: 500,
statusMessage: `重置数据库失败: ${error.message}`
})
}
return resetResults
} catch (error) {
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || '服务器内部错误'
})
}
})
+57
View File
@@ -0,0 +1,57 @@
import { verifyAdminAuth } from '~~/server/utils/auth'
import { databaseManager } from '~~/server/utils/database-manager'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const authResult = await verifyAdminAuth(event)
if (!authResult.success) {
throw createError({
statusCode: 401,
statusMessage: authResult.message
})
}
// 使用改进的数据库管理器获取状态
const healthCheck = await databaseManager.healthCheck()
// 根据延迟确定状态
let status = 'error'
let message = 'Database connection failed'
if (healthCheck.status) {
if (healthCheck.latency < 100) {
status = 'healthy'
message = 'Database connection is healthy'
} else if (healthCheck.latency < 500) {
status = 'slow'
message = 'Database connection is slow'
} else {
status = 'error'
message = 'Database connection timeout'
}
} else {
message = healthCheck.error || 'Database connection failed'
}
return {
success: healthCheck.status,
status: status,
message: message,
responseTime: healthCheck.latency,
timestamp: healthCheck.timestamp.toISOString(),
error: healthCheck.error
}
} catch (error: any) {
if (error.statusCode === 401) {
throw error
}
console.error('Database status API error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error'
})
}
})
+69
View File
@@ -0,0 +1,69 @@
import { createError, defineEventHandler } from 'h3'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { count, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
try {
const status = {
connected: false,
tables: {
user: false,
song: false,
vote: false,
schedule: false,
notification: false
},
userCount: 0
}
// 检查数据库连接
try {
await db.execute(sql`SELECT 1`)
status.connected = true
} catch (error) {
console.error('数据库连接失败:', error)
return { success: false, status, error: '数据库连接失败' }
}
// 检查各个表是否存在
const tables = ['user', 'song', 'vote', 'schedule', 'notification']
for (const tableName of tables) {
try {
const result = await db.execute(sql`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = ${tableName}
)
`)
status.tables[tableName] = result[0]?.exists || false
} catch (error) {
console.error(`检查表 ${tableName} 失败:`, error)
status.tables[tableName] = false
}
}
// 如果表存在,获取记录数
if (status.tables.user) {
try {
const userCountResult = await db.select({ count: count() }).from(users)
status.userCount = userCountResult[0]?.count || 0
} catch (error) {
console.error('获取用户数量失败:', error)
}
}
return {
success: true,
status
}
} catch (error) {
console.error('检查数据库状态失败:', error)
throw createError({
statusCode: 500,
statusMessage: '检查数据库状态失败: ' + error.message
})
}
})
@@ -0,0 +1,21 @@
import { db } from '~/drizzle/db'
import { emailTemplates } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
if (getMethod(event) !== 'DELETE') {
throw createError({ statusCode: 405, message: '方法不被允许' })
}
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未授权访问' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
const query = getQuery(event)
const key = (query.key || '').toString()
if (!key) throw createError({ statusCode: 400, message: '缺少模板 key' })
await db.delete(emailTemplates).where(eq(emailTemplates.key, key))
return { success: true }
})
@@ -0,0 +1,51 @@
import { db } from '~/drizzle/db'
import { emailTemplates } from '~/drizzle/schema'
import { SmtpService } from '~~/server/services/smtpService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未授权访问' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
// 自定义模板
const custom = await db.select().from(emailTemplates)
// 内置模板来自 smtpService
const smtp = SmtpService.getInstance()
const builtin = Object.entries(smtp.getBuiltinTemplates()).map(([key, t]: any) => ({
key,
name: t.name,
subject: t.subject,
html: t.html,
isBuiltin: true
}))
// 自定义模板覆盖标记
const merged = builtin.map((b) => {
const override = custom.find((c) => c.key === b.key)
return {
...b,
isOverridden: !!override,
overriddenAt: override?.updatedAt,
overriddenByUserId: override?.updatedByUserId
}
})
// 也返回额外的自定义模板(非内置键)
const extra = custom
.filter((c) => !builtin.find((b) => b.key === c.key))
.map((c) => ({
key: c.key,
name: c.name,
subject: c.subject,
html: c.html,
isBuiltin: false,
isOverridden: true,
overriddenAt: c.updatedAt,
overriddenByUserId: c.updatedByUserId
}))
return { templates: [...merged, ...extra] }
})
@@ -0,0 +1,37 @@
import { db } from '~/drizzle/db'
import { emailTemplates } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
if (getMethod(event) !== 'POST') {
throw createError({ statusCode: 405, message: '方法不被允许' })
}
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未授权访问' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
const body = await readBody(event)
const { key, name, subject, html } = body || {}
if (!key || !name || !subject || !html) {
throw createError({ statusCode: 400, message: '缺少必要字段:key/name/subject/html' })
}
// 根据 key 进行更新或插入
const exist = await db.select().from(emailTemplates).where(eq(emailTemplates.key, key)).limit(1)
if (exist.length) {
const res = await db
.update(emailTemplates)
.set({ name, subject, html, updatedAt: new Date(), updatedByUserId: user.id })
.where(eq(emailTemplates.key, key))
.returning()
return { success: true, template: res[0] }
} else {
const res = await db
.insert(emailTemplates)
.values({ key, name, subject, html, updatedByUserId: user.id })
.returning()
return { success: true, template: res[0] }
}
})
@@ -0,0 +1,22 @@
import { SmtpService } from '~~/server/services/smtpService'
export default defineEventHandler(async (event) => {
if (getMethod(event) !== 'POST') {
throw createError({ statusCode: 405, message: '方法不被允许' })
}
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未授权访问' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
const body = await readBody(event)
const { key, data } = body || {}
if (!key) throw createError({ statusCode: 400, message: '缺少模板 key' })
const smtp = SmtpService.getInstance()
await smtp.initializeSmtpConfig()
const rendered = await smtp.renderTemplate(key, data || {})
return { success: true, ...rendered }
})
@@ -0,0 +1,27 @@
import { db } from '~/drizzle/db'
import { expectedPlayDates } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN')) {
throw createError({ statusCode: 403, message: '权限不足' })
}
const idParam = event.context.params?.id
if (!idParam) {
throw createError({ statusCode: 400, message: '无效的ID' })
}
const id = Number(idParam)
if (isNaN(id)) {
throw createError({ statusCode: 400, message: '无效的ID' })
}
try {
await db.delete(expectedPlayDates).where(eq(expectedPlayDates.id, id))
return { success: true }
} catch (e: any) {
throw createError({ statusCode: 500, message: e.message || '删除失败' })
}
})
@@ -0,0 +1,95 @@
import { db } from '~/drizzle/db'
import { expectedPlayDates, songs } from '~/drizzle/schema'
import { desc, eq, and } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const method = event.node.req.method
const user = event.context.user
if (!user || (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN')) {
throw createError({ statusCode: 403, message: '权限不足' })
}
if (method === 'GET') {
const dates = await db.select().from(expectedPlayDates).orderBy(desc(expectedPlayDates.date))
const result = []
for (const d of dates) {
const dateObj = new Date(d.date)
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekday = weekdays[dateObj.getDay()]
let availableSeqs: number[] = []
if (d.availableSequences) {
availableSeqs = d.availableSequences.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n))
}
const takenSongs = await db.select({
sequence: songs.expectedPlaySequence,
title: songs.title,
artist: songs.artist,
requesterId: songs.requesterId
})
.from(songs)
.where(
and(
eq(songs.expectedPlayDate, d.date),
eq(songs.played, false)
)
)
const takenSeqs = new Set(takenSongs.map(s => s.sequence).filter(seq => seq !== null))
const takenDetails: Record<number, any> = {}
takenSongs.forEach(s => {
if (s.sequence !== null) {
takenDetails[s.sequence] = { title: s.title, artist: s.artist }
}
})
const freeSeqs = availableSeqs.filter(seq => !takenSeqs.has(seq))
result.push({
...d,
weekday,
allSequences: availableSeqs.sort((a, b) => a - b),
takenSequences: Array.from(takenSeqs).sort((a, b) => a - b),
takenDetails,
freeSequences: freeSeqs.sort((a, b) => a - b),
isFull: freeSeqs.length === 0
})
}
return result
}
if (method === 'POST') {
const body = await readBody(event)
const { date, availableSequences } = body
if (!date || !availableSequences) {
throw createError({ statusCode: 400, message: '日期和序号不能为空' })
}
try {
// 检查是否存在
const existing = await db.select().from(expectedPlayDates).where(eq(expectedPlayDates.date, date))
if (existing.length > 0) {
// 更新
const result = await db.update(expectedPlayDates)
.set({ availableSequences })
.where(eq(expectedPlayDates.date, date))
.returning()
return result[0]
} else {
// 新增
const result = await db.insert(expectedPlayDates)
.values({ date, availableSequences })
.returning()
return result[0]
}
} catch (e: any) {
throw createError({ statusCode: 500, message: e.message || '保存失败' })
}
}
})
+201
View File
@@ -0,0 +1,201 @@
import { db } from '~/drizzle/db'
import { sql } from 'drizzle-orm'
// 修复单个表的序列
async function fixTableSequence(table: string, dbTableName: string) {
try {
// 获取表的最大ID
const maxIdResult = await db.execute(sql.raw(`SELECT MAX(id) as max_id FROM "${dbTableName}"`))
const maxId = Number((maxIdResult as any)[0]?.max_id || 0)
if (maxId === 0) {
return {
success: true,
table: table,
message: `表 ${table} 为空,无需修复序列`,
data: {
table: table,
maxId: 0,
skipped: true
}
}
}
// 获取序列名称
const sequenceNameResult = await db.execute(
sql.raw(`SELECT pg_get_serial_sequence('"${dbTableName}"', 'id') as sequence_name`)
)
const sequenceName = (sequenceNameResult as any)[0]?.sequence_name
if (!sequenceName) {
return {
success: false,
table: table,
error: `无法找到表 ${table}.id 的序列`
}
}
// 获取当前序列值
const currentSeqResult = await db.execute(sql.raw(`SELECT last_value FROM ${sequenceName}`))
const currentSeqValue = Number((currentSeqResult as any)[0]?.last_value || 0)
// 重置序列值到最大ID + 1
const newSequenceValue = maxId + 1
// 检查序列是否已经正确
if (currentSeqValue >= newSequenceValue) {
return {
success: true,
table: table,
message: `表 ${table} 序列值已正确,无需修复`,
data: {
table: table,
maxId: maxId,
currentSequenceValue: currentSeqValue,
alreadyCorrect: true
}
}
}
await db.execute(sql.raw(`SELECT setval('${sequenceName}', ${newSequenceValue})`))
return {
success: true,
table: table,
message: `表 ${table} 的序列已成功重置 (${currentSeqValue} → ${newSequenceValue})`,
data: {
table: table,
maxId: maxId,
oldSequenceValue: currentSeqValue,
newSequenceValue: newSequenceValue,
sequenceName: sequenceName
}
}
} catch (error) {
return {
success: false,
table: table,
error: error instanceof Error ? error.message : '未知错误'
}
}
}
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '需要管理员权限'
})
}
const body = await readBody(event)
const { table } = body
// 支持的表列表和表名映射
const supportedTables = [
'Song',
'User',
'UserIdentity',
'UserStatusLog',
'Vote',
'Schedule',
'Notification',
'NotificationSettings',
'PlayTime',
'Semester',
'SystemSettings',
'SongBlacklist',
'SongReplayRequest',
'RequestTime',
'EmailTemplate'
]
// Prisma模型名到数据库表名的映射
const tableNameMap: Record<string, string> = {
Song: 'Song',
User: 'User',
UserIdentity: 'UserIdentity',
UserStatusLog: 'user_status_logs',
Vote: 'Vote',
Schedule: 'Schedule',
Notification: 'Notification',
NotificationSettings: 'NotificationSettings',
PlayTime: 'PlayTime',
Semester: 'Semester',
SystemSettings: 'SystemSettings',
SongBlacklist: 'SongBlacklist',
SongReplayRequest: 'song_replay_requests',
RequestTime: 'RequestTime',
EmailTemplate: 'EmailTemplate'
}
// 如果没有指定表名或指定为'all',则修复所有表
if (!table || table === 'all') {
const results = []
let hasError = false
let fixedCount = 0
let skippedCount = 0
let errorCount = 0
for (const tableName of supportedTables) {
try {
const result = await fixTableSequence(tableName, tableNameMap[tableName])
results.push(result)
if (result.success) {
if (result.data?.skipped || result.data?.alreadyCorrect) {
skippedCount++
} else {
fixedCount++
}
} else {
hasError = true
errorCount++
}
} catch (error) {
const errorResult = {
success: false,
table: tableName,
error: error instanceof Error ? error.message : '未知错误'
}
results.push(errorResult)
hasError = true
errorCount++
}
}
return {
success: !hasError,
message: hasError ? `序列修复完成,但有 ${errorCount} 个表失败` : `所有表序列修复完成`,
summary: {
total: supportedTables.length,
fixed: fixedCount,
skipped: skippedCount,
errors: errorCount
},
results: results
}
}
if (!supportedTables.includes(table)) {
return {
success: false,
error: `不支持的表名。支持的表: ${supportedTables.join(', ')}, 或使用 'all' 修复所有表`
}
}
const dbTableName = tableNameMap[table]
// 使用辅助函数修复单个表
return await fixTableSequence(table, dbTableName)
} catch (error) {
console.error('Fix sequence error:', error)
return {
success: false,
error: error instanceof Error ? error.message : '未知错误'
}
}
})
+179
View File
@@ -0,0 +1,179 @@
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { and, eq, or } from 'drizzle-orm'
import {
createBatchSystemNotifications,
createSystemNotification
} from '../../../services/notificationService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户是否为管理员
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员可以发送系统通知'
})
}
try {
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 获取请求数据
const body = await readBody(event)
const { title, message, content, scope, filter, userId, type } = body
// 处理单个用户通知(用于权限变更等系统通知)
if (userId && title && (message || content)) {
const notificationContent = message || content
const result = await createSystemNotification(userId, title, notificationContent, clientIP)
if (result) {
return {
success: true,
message: '通知发送成功',
sentCount: 1,
totalUsers: 1
}
} else {
throw createError({
statusCode: 500,
message: '发送通知失败'
})
}
}
// 验证批量通知请求数据
if (!title || !content) {
throw createError({
statusCode: 400,
message: '通知标题和内容不能为空'
})
}
if (!scope || !['ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'].includes(scope)) {
throw createError({
statusCode: 400,
message: '无效的通知范围'
})
}
// 查询符合条件的用户IDs
let userIds: number[] = []
if (scope === 'ALL') {
// 查询所有用户
const allUsers = await db.select({ id: users.id }).from(users)
userIds = allUsers.map((user) => user.id)
} else if (scope === 'GRADE') {
// 按年级查询
if (!filter?.grade) {
throw createError({
statusCode: 400,
message: '年级不能为空'
})
}
const gradeUsers = await db
.select({ id: users.id })
.from(users)
.where(eq(users.grade, filter.grade))
userIds = gradeUsers.map((user) => user.id)
} else if (scope === 'CLASS') {
// 按单个班级查询
if (!filter?.grade || !filter?.class) {
throw createError({
statusCode: 400,
message: '年级和班级不能为空'
})
}
const classUsers = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.grade, filter.grade), eq(users.class, filter.class)))
userIds = classUsers.map((user) => user.id)
} else if (scope === 'MULTI_CLASS') {
// 按多个班级查询
if (!filter?.classes || !Array.isArray(filter.classes) || filter.classes.length === 0) {
throw createError({
statusCode: 400,
message: '未选择任何班级'
})
}
// 构建查询条件,使用OR连接多个班级条件
const whereConditions = filter.classes.map((cls: { grade: string; class: string }) =>
and(eq(users.grade, cls.grade), eq(users.class, cls.class))
)
const multiClassUsers = await db
.select({ id: users.id })
.from(users)
.where(or(...whereConditions))
userIds = multiClassUsers.map((user) => user.id)
} else if (scope === 'SPECIFIC_USERS') {
// 指定用户
if (!filter?.userIds || !Array.isArray(filter.userIds) || filter.userIds.length === 0) {
throw createError({
statusCode: 400,
message: '未选择任何用户'
})
}
userIds = filter.userIds
}
if (userIds.length === 0) {
return {
success: true,
message: '没有找到符合条件的用户',
sentCount: 0,
totalUsers: 0
}
}
// 使用通知服务批量发送通知
const result = await createBatchSystemNotifications(userIds, title, content, clientIP)
if (!result) {
throw new Error('发送通知失败')
}
// 处理结果
let sentCount = 0
let totalUsers = userIds.length
// 判断result类型
if (Array.isArray(result)) {
sentCount = result.length
} else if (result && typeof result === 'object' && 'count' in result) {
sentCount = result.count
totalUsers = result.total || userIds.length
}
return {
success: true,
message: '通知发送成功',
sentCount,
totalUsers
}
} catch (error: any) {
console.error('发送通知失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '发送通知失败'
})
}
})
+274
View File
@@ -0,0 +1,274 @@
import { db } from '~/drizzle/db'
import { CacheService } from '../../../services/cacheService'
import { playTimes, schedules, songs } from '~/drizzle/schema'
import { and, count, eq, ne } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能访问播出时段'
})
}
// 获取播出时段ID
const id = parseInt(event.context.params?.id || '0')
if (!id) {
throw createError({
statusCode: 400,
message: '播出时段ID不正确'
})
}
// 根据请求方法执行不同操作
const method = event.method
if (method === 'GET') {
try {
// 获取指定播出时段
const playTimeResult = await db.select().from(playTimes).where(eq(playTimes.id, id)).limit(1)
const playTime = playTimeResult[0]
if (!playTime) {
throw createError({
statusCode: 404,
message: '找不到指定的播出时段'
})
}
return playTime
} catch (error) {
console.error('获取播出时段失败:', error)
throw createError({
statusCode: 500,
message: '获取播出时段失败'
})
}
} else if (method === 'PUT') {
// 更新播出时段
const body = await readBody(event)
// 验证必填字段
if (!body.name) {
throw createError({
statusCode: 400,
message: '时段名称不能为空'
})
}
// 验证时间格式(如果提供)
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/
if (body.startTime && !timeRegex.test(body.startTime)) {
throw createError({
statusCode: 400,
message: '开始时间格式不正确,应为HH:MM格式'
})
}
if (body.endTime && !timeRegex.test(body.endTime)) {
throw createError({
statusCode: 400,
message: '结束时间格式不正确,应为HH:MM格式'
})
}
// 验证时间顺序(仅当两者都提供时)
if (body.startTime && body.endTime && body.startTime >= body.endTime) {
throw createError({
statusCode: 400,
message: '开始时间必须早于结束时间'
})
}
try {
// 检查名称是否已存在(排除当前ID)
const existingPlayTimeResult = await db
.select()
.from(playTimes)
.where(and(eq(playTimes.name, body.name), ne(playTimes.id, id)))
.limit(1)
const existingPlayTime = existingPlayTimeResult[0]
if (existingPlayTime) {
throw createError({
statusCode: 400,
message: '播出时段名称已存在,请使用其他名称'
})
}
// 更新播出时段
const updatedPlayTimeResult = await db
.update(playTimes)
.set({
name: body.name,
startTime: body.startTime || null,
endTime: body.endTime || null,
description: body.description ?? null,
enabled: body.enabled !== undefined ? body.enabled : true
})
.where(eq(playTimes.id, id))
.returning()
const updatedPlayTime = updatedPlayTimeResult[0]
// 清除相关缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSchedulesCache()
await cacheService.clearPlayTimesCache()
console.log('[Cache] 排期缓存已清除(播放时间更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return updatedPlayTime
} catch (error: any) {
console.error('更新播出时段失败:', error)
// 如果是我们自定义的错误,直接抛出
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '更新播出时段失败'
})
}
} else if (method === 'PATCH') {
// 部分更新(主要用于启用/禁用)
const body = await readBody(event)
try {
// 如果要更新名称,先检查是否存在重名
if (body.name !== undefined) {
// 检查名称是否已存在(排除当前ID)
const existingPlayTimeResult = await db
.select()
.from(playTimes)
.where(and(eq(playTimes.name, body.name), ne(playTimes.id, id)))
.limit(1)
const existingPlayTime = existingPlayTimeResult[0]
if (existingPlayTime) {
throw createError({
statusCode: 400,
message: '播出时段名称已存在,请使用其他名称'
})
}
}
// 更新播出时段
const updateData: any = {}
if (body.name !== undefined) updateData.name = body.name
if (body.startTime !== undefined) updateData.startTime = body.startTime
if (body.endTime !== undefined) updateData.endTime = body.endTime
if (body.description !== undefined) updateData.description = body.description
if (body.enabled !== undefined) updateData.enabled = body.enabled
const updatedPlayTimeResult = await db
.update(playTimes)
.set(updateData)
.where(eq(playTimes.id, id))
.returning()
const updatedPlayTime = updatedPlayTimeResult[0]
// 清除相关缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSchedulesCache()
await cacheService.clearPlayTimesCache()
console.log('[Cache] 排期缓存已清除(播放时间部分更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return updatedPlayTime
} catch (error: any) {
console.error('部分更新播出时段失败:', error)
// 如果是我们自定义的错误,直接抛出
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '部分更新播出时段失败'
})
}
} else if (method === 'DELETE') {
try {
// 检查该播出时段是否有关联的歌曲或排期
const songsCountResult = await db
.select({ count: count() })
.from(songs)
.where(eq(songs.preferredPlayTimeId, id))
const songsCount = songsCountResult[0].count
const schedulesCountResult = await db
.select({ count: count() })
.from(schedules)
.where(eq(schedules.playTimeId, id))
const schedulesCount = schedulesCountResult[0].count
// 删除该播出时段
const deletedPlayTimeResult = await db
.delete(playTimes)
.where(eq(playTimes.id, id))
.returning()
const deletedPlayTime = deletedPlayTimeResult[0]
// 如果有关联的歌曲或排期,将它们的playTimeId设为null
if (songsCount > 0) {
await db
.update(songs)
.set({ preferredPlayTimeId: null })
.where(eq(songs.preferredPlayTimeId, id))
}
if (schedulesCount > 0) {
await db.update(schedules).set({ playTimeId: null }).where(eq(schedules.playTimeId, id))
}
// 清除相关缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSchedulesCache()
await cacheService.clearPlayTimesCache()
if (songsCount > 0) {
await cacheService.clearSongsCache()
}
console.log('[Cache] 缓存已清除(播放时间删除)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
message: '播出时段已成功删除',
affected: { songs: songsCount, schedules: schedulesCount }
}
} catch (error) {
console.error('删除播出时段失败:', error)
throw createError({
statusCode: 500,
message: '删除播出时段失败'
})
}
} else {
throw createError({
statusCode: 405,
message: '不支持的请求方法'
})
}
})
+111
View File
@@ -0,0 +1,111 @@
import { db } from '~/drizzle/db'
import { playTimes } from '~/drizzle/schema'
import { ilike } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能添加播出时段'
})
}
// 获取请求体
const body = await readBody(event)
// 验证必填字段
if (!body.name) {
throw createError({
statusCode: 400,
message: '时段名称不能为空'
})
}
// 验证时间格式(如果提供了时间)
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/
if (body.startTime && !timeRegex.test(body.startTime)) {
throw createError({
statusCode: 400,
message: '开始时间格式不正确,应为HH:MM格式'
})
}
if (body.endTime && !timeRegex.test(body.endTime)) {
throw createError({
statusCode: 400,
message: '结束时间格式不正确,应为HH:MM格式'
})
}
// 验证时间顺序(仅当两者都提供时)
if (body.startTime && body.endTime && body.startTime >= body.endTime) {
throw createError({
statusCode: 400,
message: '开始时间必须早于结束时间'
})
}
try {
// 检查名称是否已存在
const existingPlayTimeResult = await db
.select()
.from(playTimes)
.where(ilike(playTimes.name, body.name))
.limit(1)
const existingPlayTime = existingPlayTimeResult[0]
if (existingPlayTime) {
throw createError({
statusCode: 400,
message: '播出时段名称已存在,请使用其他名称'
})
}
// 创建新的播出时段
const newPlayTimeResult = await db
.insert(playTimes)
.values({
name: body.name,
startTime: body.startTime || null,
endTime: body.endTime || null,
description: body.description || null,
enabled: body.enabled !== undefined ? body.enabled : true
})
.returning()
const newPlayTime = newPlayTimeResult[0]
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
await cache.deletePattern('playtimes:*')
console.log('[Cache] 排期和播放时间缓存已清除(创建播放时间)')
} catch (cacheError) {
console.warn('清除缓存失败:', cacheError)
}
return newPlayTime
} catch (error: any) {
console.error('创建播出时段失败:', error)
// 如果是我们自定义的错误,直接抛出
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '创建播出时段失败'
})
}
})
+52
View File
@@ -0,0 +1,52 @@
import { asc, db, desc, playTimes } from '~/drizzle/db'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能管理播出时段'
})
}
try {
// 尝试从缓存获取播放时间数据
const cacheService = CacheService.getInstance()
const cachedPlayTimes = await cacheService.getPlayTimes()
if (cachedPlayTimes) {
return cachedPlayTimes
}
// 缓存中没有,从数据库获取所有播出时段
const playTimesResult = await db
.select()
.from(playTimes)
.orderBy(desc(playTimes.enabled), asc(playTimes.startTime))
// 将数据存入缓存
try {
await cacheService.setPlayTimes(playTimesResult)
} catch (cacheError) {
console.warn('缓存播放时间数据失败:', cacheError)
}
return playTimesResult
} catch (error) {
console.error('获取播出时段失败:', error)
throw createError({
statusCode: 500,
message: '获取播出时段失败'
})
}
})
@@ -0,0 +1,136 @@
import { db, songs, songReplayRequests, users, semesters } from '~/drizzle/db'
import { desc, eq, sql, and, or } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 1. 检查权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
// 获取查询参数
const query = getQuery(event)
const status = (query.status as string) || 'PENDING' // PENDING, FULFILLED, REJECTED, ALL
const semesterName = query.semester as string
// 构建查询条件
const conditions = []
if (status !== 'ALL') {
conditions.push(eq(songReplayRequests.status, status as any))
}
if (semesterName && semesterName !== 'all') {
conditions.push(eq(songs.semester, semesterName))
}
// 2. 查询
// 获取歌曲信息及重播申请数量,同时关联用户信息
const requestsQuery = db
.select({
song: songs,
requester: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
},
requestCount: sql<number>`count(${songReplayRequests.id})`
.mapWith(Number)
.as('request_count'),
lastRequestedAt: sql<string>`max(${songReplayRequests.createdAt})`.as('last_requested_at')
})
.from(songs)
.innerJoin(songReplayRequests, eq(songs.id, songReplayRequests.songId))
.leftJoin(users, eq(songs.requesterId, users.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.groupBy(songs.id, users.id, users.name, users.grade, users.class)
.orderBy(desc(sql`request_count`))
const requests = await requestsQuery
// 获取所有用户姓名,用于处理同名情况
const allUsers = await db
.select({
name: users.name,
grade: users.grade
})
.from(users)
const nameToUsers = new Map()
allUsers.forEach((u) => {
if (u.name) {
if (!nameToUsers.has(u.name)) {
nameToUsers.set(u.name, [])
}
nameToUsers.get(u.name).push(u)
}
})
const formatClassInfo = (userObj: any) => {
if (!userObj) return ''
const parts: string[] = []
if (userObj.grade) parts.push(String(userObj.grade))
if (userObj.class) parts.push(String(userObj.class))
return parts.join(' ').trim()
}
const formatDisplayName = (userObj: any) => {
if (!userObj) return '未知用户'
const name = userObj.name ? String(userObj.name).trim() : ''
const classInfo = formatClassInfo(userObj)
if (!name) return classInfo || '未知用户'
return classInfo ? `${classInfo} ${name}` : name
}
const subConditions = []
if (status !== 'ALL') {
subConditions.push(eq(songReplayRequests.status, status as any))
}
if (semesterName && semesterName !== 'all') {
subConditions.push(eq(songs.semester, semesterName))
}
// 获取详细的重播申请人信息
let requestDetailsQuery = db
.select({
songId: songReplayRequests.songId,
user: {
name: users.name,
grade: users.grade,
class: users.class
},
createdAt: songReplayRequests.createdAt,
status: songReplayRequests.status
})
.from(songReplayRequests)
.innerJoin(users, eq(songReplayRequests.userId, users.id))
.innerJoin(songs, eq(songReplayRequests.songId, songs.id))
.where(subConditions.length > 0 ? and(...subConditions) : undefined)
.orderBy(desc(songReplayRequests.createdAt))
const requestDetails = await requestDetailsQuery
const detailsMap = new Map()
requestDetails.forEach((d) => {
if (!detailsMap.has(d.songId)) detailsMap.set(d.songId, [])
detailsMap.get(d.songId).push({
name: formatDisplayName(d.user),
grade: d.user.grade,
class: d.user.class,
createdAt: d.createdAt,
status: d.status
})
})
return requests.map((item) => ({
...item.song,
requester: formatDisplayName(item.requester),
requesterGrade: item.requester?.grade,
requesterClass: item.requester?.class,
voteCount: item.requestCount,
requestCount: item.requestCount,
lastRequestedAt: item.lastRequestedAt,
requestDetails: detailsMap.get(item.song.id) || []
}))
})
@@ -0,0 +1,56 @@
import { db, songReplayRequests, songs } from '~/drizzle/db'
import { eq, and } from 'drizzle-orm'
import { createReplayRequestRejectedNotification } from '~~/server/services/notificationService'
export default defineEventHandler(async (event) => {
// 检查管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
const body = await readBody(event)
const { songId } = body
if (!songId) {
throw createError({ statusCode: 400, message: '缺少参数' })
}
// 获取歌曲信息用于通知
const song = await db
.select()
.from(songs)
.where(eq(songs.id, songId))
.limit(1)
.then((r) => r[0])
if (!song) {
throw createError({ statusCode: 404, message: '歌曲不存在' })
}
// 查找所有待处理的申请
const pendingRequests = await db
.select()
.from(songReplayRequests)
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.status, 'PENDING')))
// 更新状态
await db
.update(songReplayRequests)
.set({ status: 'REJECTED' })
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.status, 'PENDING')))
// 发送通知
const ipAddress = getRequestIP(event, { xForwardedFor: true })
for (const req of pendingRequests) {
await createReplayRequestRejectedNotification(
req.userId,
{
title: song.title,
artist: song.artist
},
ipAddress
)
}
return { success: true }
})
+212
View File
@@ -0,0 +1,212 @@
import { db } from '~/drizzle/db'
import { requestTimes } from '~/drizzle/schema'
import { and, eq, ne } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能访问投稿开放时段'
})
}
const id = parseInt(event.context.params?.id || '0')
if (!id) {
throw createError({
statusCode: 400,
message: '投稿开放时段ID不正确'
})
}
const method = event.method
if (method === 'GET') {
try {
const requestTimeResult = await db
.select()
.from(requestTimes)
.where(eq(requestTimes.id, id))
.limit(1)
const requestTime = requestTimeResult[0]
if (!requestTime) {
throw createError({
statusCode: 404,
message: '找不到指定的投稿开放时段'
})
}
return requestTime
} catch (error) {
console.error('获取投稿开放时段失败:', error)
throw createError({
statusCode: 500,
message: '获取投稿开放时段失败'
})
}
} else if (method === 'PUT') {
const body = await readBody(event)
if (!body.name) {
throw createError({
statusCode: 400,
message: '时段名称不能为空'
})
}
if (body.startTime && body.endTime && body.startTime >= body.endTime) {
throw createError({
statusCode: 400,
message: '开始时间必须早于结束时间'
})
}
try {
const existingRequestTimeResult = await db
.select()
.from(requestTimes)
.where(and(eq(requestTimes.name, body.name), ne(requestTimes.id, id)))
.limit(1)
const existingRequestTime = existingRequestTimeResult[0]
if (existingRequestTime) {
throw createError({
statusCode: 400,
message: '投稿开放时段名称已存在,请使用其他名称'
})
}
const updatedRequestTimeResult = await db
.update(requestTimes)
.set({
name: body.name,
startTime: body.startTime || null,
endTime: body.endTime || null,
description: body.description ?? null,
enabled: body.enabled !== undefined ? body.enabled : true,
expected: body.expected ?? 0
})
.where(eq(requestTimes.id, id))
.returning()
const updatedRequestTime = updatedRequestTimeResult[0]
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
console.log('[Cache] 排期缓存已清除(播放时间更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return updatedRequestTime
} catch (error: any) {
console.error('更新投稿开放时段失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '更新投稿开放时段失败'
})
}
} else if (method === 'PATCH') {
const body = await readBody(event)
try {
if (body.name !== undefined) {
const existingRequestTimeResult = await db
.select()
.from(requestTimes)
.where(and(eq(requestTimes.name, body.name), ne(requestTimes.id, id)))
.limit(1)
const existingRequestTime = existingRequestTimeResult[0]
if (existingRequestTime) {
throw createError({
statusCode: 400,
message: '投稿开放时段名称已存在,请使用其他名称'
})
}
}
const updateData: any = {}
if (body.name !== undefined) updateData.name = body.name
if (body.startTime !== undefined) updateData.startTime = body.startTime
if (body.endTime !== undefined) updateData.endTime = body.endTime
if (body.description !== undefined) updateData.description = body.description
if (body.enabled !== undefined) updateData.enabled = body.enabled
if (body.expected !== undefined) updateData.expected = body.expected || 0
const updatedRequestTimeResult = await db
.update(requestTimes)
.set(updateData)
.where(eq(requestTimes.id, id))
.returning()
const updatedRequestTime = updatedRequestTimeResult[0]
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
console.log('[Cache] 排期缓存已清除(播放时间部分更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return updatedRequestTime
} catch (error: any) {
console.error('部分更新投稿开放时段失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '部分更新投稿开放时段失败'
})
}
} else if (method === 'DELETE') {
try {
const deletedRequestTimeResult = await db
.delete(requestTimes)
.where(eq(requestTimes.id, id))
.returning()
const deletedRequestTime = deletedRequestTimeResult[0]
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
console.log('[Cache] 缓存已清除(播放时间删除)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
message: '投稿开放时段已成功删除'
}
} catch (error) {
console.error('删除投稿开放时段失败:', error)
throw createError({
statusCode: 500,
message: '删除投稿开放时段失败'
})
}
} else {
throw createError({
statusCode: 405,
message: '不支持的请求方法'
})
}
})
@@ -0,0 +1,108 @@
import { db } from '~/drizzle/db'
import { requestTimes } from '~/drizzle/schema'
import { and, gt, gte, ilike, lt, lte, or } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能添加投稿开放时段'
})
}
const body = await readBody(event)
if (!body.name) {
throw createError({
statusCode: 400,
message: '时段名称不能为空'
})
}
if (body.startTime && body.endTime && body.startTime >= body.endTime) {
throw createError({
statusCode: 400,
message: '开始时间必须早于结束时间'
})
}
try {
const existingRequestTimeResult = await db
.select()
.from(requestTimes)
.where(ilike(requestTimes.name, body.name))
.limit(1)
const existingRequestTime = existingRequestTimeResult[0]
if (existingRequestTime) {
throw createError({
statusCode: 400,
message: '投稿开放时段名称已存在,请使用其他名称'
})
}
const hitRequestTimeResult = await db
.select()
.from(requestTimes)
.where(
or(
and(lte(requestTimes.startTime, body.endTime), gt(requestTimes.endTime, body.endTime)),
and(lte(requestTimes.startTime, body.startTime), gt(requestTimes.endTime, body.startTime))
)
)
.limit(1)
const hitRequestTime = hitRequestTimeResult[0]
if (hitRequestTime) {
throw createError({
statusCode: 400,
message: '投稿开放时段时间冲突,请使用其他时间'
})
}
const newRequestTimeResult = await db
.insert(requestTimes)
.values({
name: body.name,
startTime: body.startTime || null,
endTime: body.endTime || null,
description: body.description || null,
enabled: body.enabled !== undefined ? body.enabled : true,
expected: body.expected || 0,
accepted: body.accepted || 0
})
.returning()
const newRequestTime = newRequestTimeResult[0]
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
await cache.deletePattern('requestTimes:*')
console.log('[Cache] 排期和播放时间缓存已清除(创建播放时间)')
} catch (cacheError) {
console.warn('清除缓存失败:', cacheError)
}
return newRequestTime
} catch (error: any) {
console.error('创建投稿开放时段失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '创建投稿开放时段失败'
})
}
})
+40
View File
@@ -0,0 +1,40 @@
import { asc, db, desc, requestTimes } from '~/drizzle/db'
import { lt } from 'drizzle-orm'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能管理投稿开放时段'
})
}
try {
await db
.update(requestTimes)
.set({ past: true })
.where(lt(requestTimes.endTime, getBeijingTimeISOString()))
const requestTimesResult = await db
.select()
.from(requestTimes)
.orderBy(desc(requestTimes.enabled), asc(requestTimes.startTime))
return requestTimesResult
} catch (error) {
console.error('获取投稿开放时段失败:', error)
throw createError({
statusCode: 500,
message: '获取投稿开放时段失败'
})
}
})
+14
View File
@@ -0,0 +1,14 @@
import { ROLES } from '../../../utils/permissions.js'
export default defineEventHandler((event) => {
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以访问角色配置'
})
}
return ROLES
})
+46
View File
@@ -0,0 +1,46 @@
import { ROLES, saveRolesConfig } from '../../../utils/permissions.js'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以修改角色配置'
})
}
const body = await readBody(event)
if (!body || typeof body !== 'object') {
throw createError({
statusCode: 400,
message: '无效的角色配置数据'
})
}
// 保护 SUPER_ADMIN 的关键权限不被移除
if (body.SUPER_ADMIN) {
const requiredPages = ['roles', 'users', 'site-config']
const hasRequired = requiredPages.every(p => body.SUPER_ADMIN.pages.includes(p))
if (!hasRequired) {
requiredPages.forEach(p => {
if (!body.SUPER_ADMIN.pages.includes(p)) {
body.SUPER_ADMIN.pages.push(p)
}
})
}
body.SUPER_ADMIN.canAccessAdmin = true
}
const success = saveRolesConfig(body)
if (!success) {
throw createError({
statusCode: 500,
message: '保存角色配置失败'
})
}
return { success: true, message: '角色配置已保存' }
})
+317
View File
@@ -0,0 +1,317 @@
import { db } from '~/drizzle/db'
import { playTimes, schedules, songs, users, votes, songReplayRequests } from '~/drizzle/schema'
import { and, asc, count, desc, eq, gte, lte } from 'drizzle-orm'
import { createSongSelectedNotification } from '../../services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有歌曲管理员及以上权限才能创建排期'
})
}
const body = await readBody(event)
if (!body.songId || !body.playDate) {
throw createError({
statusCode: 400,
message: '歌曲ID和播放日期不能为空'
})
}
// 检查是否为草稿模式(默认直接发布)
const isDraft = body.isDraft === true
try {
// 检查歌曲是否存在
const songResult = await db.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createError({
statusCode: 404,
message: '歌曲不存在'
})
}
await db.transaction(async (tx) => {
// 检查是否已经为该歌曲创建过排期,如果有则删除旧的排期
const existingScheduleResult = await tx
.select()
.from(schedules)
.where(eq(schedules.songId, body.songId))
.limit(1)
const existingSchedule = existingScheduleResult[0]
if (existingSchedule) {
// 删除现有排期
await tx.delete(schedules).where(eq(schedules.id, existingSchedule.id))
}
// 获取序号,如果未提供则查找当天最大序号+1
let sequence = body.sequence || 1
if (!body.sequence) {
// 解析输入的日期字符串
const inputDateStr =
typeof body.playDate === 'string' ? body.playDate : body.playDate.toISOString()
// 创建当天的开始和结束时间
const startOfDay = new Date(inputDateStr + 'T00:00:00.000Z')
const endOfDay = new Date(inputDateStr + 'T23:59:59.999Z')
console.log('查询当天排期范围:', {
输入日期: body.playDate,
开始时间: startOfDay.toISOString(),
结束时间: endOfDay.toISOString()
})
const sameDaySchedules = await tx
.select()
.from(schedules)
.where(and(gte(schedules.playDate, startOfDay), lte(schedules.playDate, endOfDay)))
.orderBy(desc(schedules.sequence))
.limit(1)
if (sameDaySchedules.length > 0) {
sequence = (sameDaySchedules[0].sequence || 0) + 1
}
}
// 解析输入的日期字符串,确保日期正确
const inputDateStr =
typeof body.playDate === 'string' ? body.playDate : body.playDate.toISOString()
// 直接使用输入的日期字符串,添加时间部分以避免时区问题
const playDate = new Date(inputDateStr + 'T00:00:00.000Z')
// 创建排期
const scheduleResult = await tx
.insert(schedules)
.values({
songId: body.songId,
playDate: playDate,
sequence: sequence,
playTimeId: body.playTimeId || null,
// 草稿支持:根据参数决定是否为草稿
isDraft: isDraft,
publishedAt: isDraft ? null : new Date()
})
.returning()
const schedule = {
...scheduleResult[0],
song: {
id: song.id,
title: song.title,
artist: song.artist,
requesterId: song.requesterId
}
}
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 只有在非草稿模式下才创建通知
if (!isDraft) {
await createSongSelectedNotification(
schedule.song.requesterId,
schedule.song.id,
{
title: schedule.song.title,
artist: schedule.song.artist,
playDate: schedule.playDate
},
clientIP
)
// 标记该歌曲的所有待处理重播申请为已完成
await tx
.update(songReplayRequests)
.set({ status: 'FULFILLED' })
.where(
and(
eq(songReplayRequests.songId, schedule.song.id),
eq(songReplayRequests.status, 'PENDING')
)
)
}
})
// 清除相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache() // 清除歌曲列表缓存,确保scheduled状态更新
console.log(`[Cache] 排期缓存和歌曲列表缓存已清除(${isDraft ? '保存草稿' : '创建排期'})`)
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
// 重新缓存完整的排期列表
try {
// 获取所有排期数据
const schedulesData = await db
.select({
id: schedules.id,
playDate: schedules.playDate,
sequence: schedules.sequence,
played: schedules.played,
playTimeId: schedules.playTimeId,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId,
songPlayed: songs.played,
songCover: songs.cover,
songMusicPlatform: songs.musicPlatform,
songMusicId: songs.musicId,
songPlayUrl: songs.playUrl,
songSemester: songs.semester,
requesterName: users.name,
requesterGrade: users.grade,
requesterClass: users.class,
playTimeName: playTimes.name,
playTimeStartTime: playTimes.startTime,
playTimeEndTime: playTimes.endTime,
playTimeEnabled: playTimes.enabled
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.innerJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.orderBy(asc(schedules.playDate))
// 获取所有用户的姓名列表,用于检测同名用户
const allUsers = await db
.select({
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
})
.from(users)
// 创建姓名到用户数组的映射
const nameToUsers = new Map()
allUsers.forEach((user) => {
if (user.name) {
if (!nameToUsers.has(user.name)) {
nameToUsers.set(user.name, [])
}
nameToUsers.get(user.name).push(user)
}
})
// 获取每首歌的投票数
const songIds = schedulesData.map((s) => s.songId)
const voteCounts = await Promise.all(
songIds.map(async (songId) => {
const voteCountResult = await db
.select({ count: count() })
.from(votes)
.where(eq(votes.songId, songId))
return { songId, count: voteCountResult[0].count }
})
)
const voteCountMap = new Map(voteCounts.map((v) => [v.songId, v.count]))
// 转换数据格式
const formattedSchedules = schedulesData.map((schedule) => {
const dateOnly = schedule.playDate
// 处理投稿人姓名,如果是同名用户则添加后缀
let requesterName = schedule.requesterName || '未知用户'
const sameNameUsers = nameToUsers.get(requesterName)
if (sameNameUsers && sameNameUsers.length > 1) {
if (schedule.requesterGrade) {
const sameGradeUsers = sameNameUsers.filter(
(u: {
id: number
name: string | null
grade: string | null
class: string | null
}) => u.grade === schedule.requesterGrade
)
if (sameGradeUsers.length > 1 && schedule.requesterClass) {
requesterName = `${requesterName}(${schedule.requesterGrade} ${schedule.requesterClass})`
} else {
requesterName = `${requesterName}(${schedule.requesterGrade})`
}
}
}
return {
id: schedule.id,
playDate: dateOnly.toISOString().split('T')[0],
sequence: schedule.sequence || 1,
played: schedule.played || false,
playTimeId: schedule.playTimeId,
playTime: schedule.playTimeName
? {
id: schedule.playTimeId,
name: schedule.playTimeName,
startTime: schedule.playTimeStartTime,
endTime: schedule.playTimeEndTime,
enabled: schedule.playTimeEnabled
}
: null,
song: {
id: schedule.songId,
title: schedule.songTitle,
artist: schedule.songArtist,
requester: requesterName,
voteCount: voteCountMap.get(schedule.songId) || 0,
played: schedule.songPlayed || false,
cover: schedule.songCover || null,
musicPlatform: schedule.songMusicPlatform || null,
musicId: schedule.songMusicId || null,
playUrl: schedule.songPlayUrl || null,
semester: schedule.songSemester || null
}
}
})
const completeSchedules = formattedSchedules
// 重新缓存完整的排期列表
if (completeSchedules && completeSchedules.length > 0) {
await cacheService.setSchedulesList(completeSchedules)
console.log(`[Cache] 完整排期列表已重新缓存,数量: ${completeSchedules.length}`)
} else {
console.log('[Cache] 没有排期数据需要缓存')
}
} catch (cacheError) {
console.warn('[Cache] 重新缓存完整排期列表失败,但不影响排期创建:', cacheError)
}
return {
...schedule,
isDraft: isDraft,
publishedAt: isDraft ? null : schedule.publishedAt,
message: isDraft ? '排期草稿保存成功' : '排期创建成功,通知已发送'
}
} catch (error: any) {
console.error('创建排期失败:', error)
throw createError({
statusCode: 500,
message: error.message || '创建排期失败'
})
}
})
@@ -0,0 +1,181 @@
import { db } from '~/drizzle/db'
import { playTimes, schedules, songs, songReplayRequests } from '~/drizzle/schema'
import { and, eq, gte, lte, inArray } from 'drizzle-orm'
import { createSongSelectedNotification } from '~~/server/services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有歌曲管理员及以上权限才能发布排期'
})
}
const body = await readBody(event)
// 验证必要参数
if (!body.playDate || !body.songs || !Array.isArray(body.songs)) {
throw createError({
statusCode: 400,
message: '参数无效:需要 playDate 和 songs 数组'
})
}
const clientIP = getClientIP(event)
const startTime = Date.now()
try {
// 解析日期
const inputDateStr =
typeof body.playDate === 'string' ? body.playDate : body.playDate.toISOString()
// 确保使用当天的开始时间(UTC)
const playDate = new Date(inputDateStr + 'T00:00:00.000Z')
const startOfDay = new Date(inputDateStr + 'T00:00:00.000Z')
const endOfDay = new Date(inputDateStr + 'T23:59:59.999Z')
const playTimeId = body.playTimeId ? parseInt(body.playTimeId) : null
// 获取所有涉及的歌曲详情(用于通知)
const songIds = body.songs.map((s: any) => s.songId)
if (songIds.length === 0) {
// 如果列表为空,说明是清空排期,直接执行删除逻辑即可
}
const songDetails =
songIds.length > 0 ? await db.select().from(songs).where(inArray(songs.id, songIds)) : []
const songMap = new Map(songDetails.map((s: any) => [s.id, s]))
// 需要发送通知的列表
const notificationsToSend: Array<{
requesterId: number
songId: number
songInfo: { title: string; artist: string; playDate: Date }
}> = []
// 开始事务
await db.transaction(async (tx) => {
// 1. 构建查询条件:指定日期 + (可选)指定时段
const whereConditions = [
gte(schedules.playDate, startOfDay),
lte(schedules.playDate, endOfDay)
]
if (playTimeId) {
whereConditions.push(eq(schedules.playTimeId, playTimeId))
}
// 2. 查找该时间段内已发布的排期(用于避免重复发送通知)
const existingPublished = await tx
.select({
songId: schedules.songId
})
.from(schedules)
.where(and(...whereConditions, eq(schedules.isDraft, false)))
const existingPublishedSongIds = new Set(existingPublished.map((s) => s.songId))
// 3. 删除该时间段内的所有排期(包括草稿和已发布)
await tx.delete(schedules).where(and(...whereConditions))
// 4. 插入新的排期并处理通知
const publishedAt = new Date()
for (const item of body.songs) {
const song = songMap.get(item.songId)
if (!song) {
console.warn(`找不到歌曲 ID: ${item.songId},跳过排期`)
continue
}
// 插入排期
await tx.insert(schedules).values({
songId: item.songId,
playDate: playDate,
sequence: item.sequence,
playTimeId: playTimeId,
isDraft: false, // 直接发布
publishedAt: publishedAt,
updatedAt: publishedAt
})
// 如果该歌曲之前未在此时间段发布过,则发送通知并更新重播状态
if (!existingPublishedSongIds.has(item.songId)) {
// 添加到通知列表,稍后发送
notificationsToSend.push({
requesterId: song.requesterId,
songId: song.id,
songInfo: {
title: song.title,
artist: song.artist,
playDate: playDate
}
})
// 更新重播申请状态
await tx
.update(songReplayRequests)
.set({ status: 'FULFILLED' })
.where(
and(eq(songReplayRequests.songId, song.id), eq(songReplayRequests.status, 'PENDING'))
)
}
}
})
// 事务成功提交后,清除缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log(`[Cache] 排期缓存和歌曲列表缓存已清除(批量发布排期)`)
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
// 异步发送通知(不阻塞响应)
Promise.allSettled(
notificationsToSend.map((n) =>
createSongSelectedNotification(n.requesterId, n.songId, n.songInfo, clientIP)
)
)
.then((results) => {
const successCount = results.filter((r) => r.status === 'fulfilled').length
console.log(
`[Notification] 批量发布通知发送完成: ${successCount}/${notificationsToSend.length} 成功`
)
})
.catch((err) => {
console.error('[Notification] 批量发送通知时发生未捕获错误:', err)
})
console.log(`[Performance] 批量发布排期耗时: ${Date.now() - startTime}ms`)
return {
success: true,
message: '排期发布成功'
}
} catch (error: any) {
console.error('批量发布排期失败:', {
error: error.message,
stack: error.stack,
userId: user.id,
ip: clientIP
})
throw createError({
statusCode: 500,
message: error.message || '发布排期失败'
})
}
})
@@ -0,0 +1,92 @@
import { db } from '~/drizzle/db'
import { schedules, songReplayRequests } from '~/drizzle/schema'
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
function isValidDateString(dateStr: unknown) {
if (typeof dateStr !== 'string') return false
const trimmed = dateStr.trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return false
const date = new Date(trimmed + 'T00:00:00.000Z')
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === trimmed
}
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '权限不足'
})
}
const body = await readBody(event)
const playDate = body?.playDate
const playTimeId = body?.playTimeId
const songIds = body?.songIds
if (!isValidDateString(playDate)) {
throw createError({ statusCode: 400, message: '播放日期格式必须为YYYY-MM-DD' })
}
if (!Array.isArray(songIds) || !songIds.every((id) => typeof id === 'number' && id > 0)) {
throw createError({ statusCode: 400, message: '歌曲ID列表不能为空且必须为正整数数组' })
}
const playTimeFilterEnabled = playTimeId !== undefined && playTimeId !== null
if (playTimeFilterEnabled && (typeof playTimeId !== 'number' || playTimeId <= 0)) {
throw createError({ statusCode: 400, message: '播放时间ID必须是正整数' })
}
const startOfDay = new Date(playDate + 'T00:00:00.000Z')
const endOfDay = new Date(playDate + 'T23:59:59.999Z')
const result = await db.transaction(async (tx) => {
const whereBase = and(gte(schedules.playDate, startOfDay), lte(schedules.playDate, endOfDay))
const whereCondition = playTimeFilterEnabled
? and(whereBase, eq(schedules.playTimeId, playTimeId))
: whereBase
const existingSchedules = await tx
.select({ id: schedules.id, songId: schedules.songId })
.from(schedules)
.where(whereCondition)
const existingSongIds = existingSchedules.map((s) => s.songId).filter(Boolean) as number[]
await tx.delete(schedules).where(whereCondition)
if (existingSongIds.length > 0) {
await tx
.update(songReplayRequests)
.set({ status: 'PENDING', updatedAt: new Date() })
.where(
and(inArray(songReplayRequests.songId, existingSongIds), eq(songReplayRequests.status, 'FULFILLED'))
)
}
if (songIds.length === 0) {
return { deletedCount: existingSchedules.length, insertedCount: 0 }
}
const rows = songIds.map((songId, index) => ({
songId,
playDate: startOfDay,
sequence: index + 1,
playTimeId: playTimeFilterEnabled ? playTimeId : null,
isDraft: true,
publishedAt: null
}))
const inserted = await tx.insert(schedules).values(rows).returning({ id: schedules.id })
return { deletedCount: existingSchedules.length, insertedCount: inserted.length }
})
await Promise.allSettled([cacheService.clearSchedulesCache(), cacheService.clearSongsCache()])
return {
success: true,
...result
}
})
+246
View File
@@ -0,0 +1,246 @@
import { db } from '~/drizzle/db'
import { playTimes, schedules, songs, songReplayRequests } from '~/drizzle/schema'
import { and, desc, eq, gte, lte } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
import { getClientIP } from '~~/server/utils/ip-utils'
// 输入验证函数
function validateInput(body: any) {
const errors: string[] = []
// 验证songId
if (!body.songId) {
errors.push('歌曲ID不能为空')
} else if (typeof body.songId !== 'number' || body.songId <= 0) {
errors.push('歌曲ID必须是正整数')
}
// 验证playDate
if (!body.playDate) {
errors.push('播放日期不能为空')
} else {
const dateStr = typeof body.playDate === 'string' ? body.playDate : body.playDate.toString()
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
if (!dateRegex.test(dateStr)) {
errors.push('播放日期格式必须为YYYY-MM-DD')
} else {
const date = new Date(dateStr)
if (isNaN(date.getTime())) {
errors.push('播放日期格式无效')
}
}
}
// 验证sequence(可选)
if (body.sequence !== undefined && body.sequence !== null) {
if (typeof body.sequence !== 'number' || body.sequence <= 0) {
errors.push('序号必须是正整数')
}
}
// 验证playTimeId(可选)
if (body.playTimeId !== undefined && body.playTimeId !== null) {
if (typeof body.playTimeId !== 'number' || body.playTimeId <= 0) {
errors.push('播放时间ID必须是正整数')
}
}
return errors
}
export default defineEventHandler(async (event) => {
const startTime = Date.now()
const clientIP = getClientIP(event)
try {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '权限不足'
})
}
const body = await readBody(event)
// 输入验证
const validationErrors = validateInput(body)
if (validationErrors.length > 0) {
throw createError({
statusCode: 400,
message: validationErrors.join('; ')
})
}
// 使用数据库事务确保数据一致性
const result = await db.transaction(async (tx) => {
// 检查歌曲是否存在
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createError({
statusCode: 404,
message: '歌曲不存在'
})
}
// 检查播放时间ID是否有效(如果提供)
if (body.playTimeId) {
const playTimeResult = await tx
.select()
.from(playTimes)
.where(eq(playTimes.id, body.playTimeId))
.limit(1)
if (playTimeResult.length === 0) {
throw createError({
statusCode: 404,
message: '播放时间不存在'
})
}
}
// 检查是否已经为该歌曲创建过排期或草稿,如果有则删除旧的
const existingScheduleResult = await tx
.select()
.from(schedules)
.where(eq(schedules.songId, body.songId))
.limit(1)
const existingSchedule = existingScheduleResult[0]
if (existingSchedule) {
// 删除现有排期或草稿
await tx.delete(schedules).where(eq(schedules.id, existingSchedule.id))
// 恢复该歌曲的重播申请状态为 PENDING
// 只恢复状态为 FULFILLED 的申请
const updatedRequests = await tx
.update(songReplayRequests)
.set({
status: 'PENDING',
updatedAt: new Date()
})
.where(
and(
eq(songReplayRequests.songId, body.songId),
eq(songReplayRequests.status, 'FULFILLED')
)
)
.returning()
if (updatedRequests.length > 0) {
console.log(
`恢复了 ${updatedRequests.length} 个重播申请状态为 PENDING(草稿保存时删除旧排期)`
)
}
}
// 获取序号,如果未提供则查找当天最大序号+1
let sequence = body.sequence || 1
if (!body.sequence) {
// 解析输入的日期字符串
const inputDateStr =
typeof body.playDate === 'string' ? body.playDate : body.playDate.toISOString()
// 创建当天的开始和结束时间
const startOfDay = new Date(inputDateStr + 'T00:00:00.000Z')
const endOfDay = new Date(inputDateStr + 'T23:59:59.999Z')
// 查找当天的所有排期和草稿(包括草稿状态的)
const sameDaySchedules = await tx
.select()
.from(schedules)
.where(and(gte(schedules.playDate, startOfDay), lte(schedules.playDate, endOfDay)))
.orderBy(desc(schedules.sequence))
.limit(1)
if (sameDaySchedules.length > 0) {
sequence = (sameDaySchedules[0].sequence || 0) + 1
}
}
// 解析输入的日期字符串,确保日期正确
const inputDateStr =
typeof body.playDate === 'string' ? body.playDate : body.playDate.toISOString()
// 直接使用输入的日期字符串,添加时间部分以避免时区问题
const playDate = new Date(inputDateStr + 'T00:00:00.000Z')
// 创建草稿排期
const scheduleResult = await tx
.insert(schedules)
.values({
songId: body.songId,
playDate: playDate,
sequence: sequence,
playTimeId: body.playTimeId || null,
isDraft: true, // 标记为草稿
publishedAt: null // 草稿状态下没有发布时间
})
.returning()
return {
schedule: scheduleResult[0],
song: {
id: song.id,
title: song.title,
artist: song.artist,
requesterId: song.requesterId
}
}
})
// 清除相关缓存(在事务外执行,避免影响事务性能)
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(保存草稿)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
// 缓存清除失败不应该影响主要操作的成功
}
const responseData = {
...result.schedule,
song: result.song,
isDraft: true,
publishedAt: null,
message: '排期草稿保存成功'
}
console.log(`[Performance] 保存排期草稿耗时: ${Date.now() - startTime}ms`)
return responseData
} catch (error: any) {
console.error('保存排期草稿失败:', {
error: error.message,
stack: error.stack,
userId: event.context.user?.id,
ip: clientIP,
duration: Date.now() - startTime
})
// 根据错误类型返回适当的错误信息
if (error.statusCode) {
throw error // 重新抛出已经格式化的错误
}
// 对于未知错误,返回具体的错误信息
throw createError({
statusCode: 500,
message: error.message || '服务器内部错误,请稍后重试'
})
}
})
+397
View File
@@ -0,0 +1,397 @@
import { db } from '~/drizzle/db'
import {
playTimes,
schedules,
songs,
users,
votes,
songCollaborators,
songReplayRequests
} from '~/drizzle/schema'
import { and, asc, count, eq, gte, lt, inArray, desc } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户身份验证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有歌曲管理员及以上权限才能访问完整排期数据'
})
}
const query = getQuery(event)
const { date, playTimeId, includeDrafts = 'true' } = query
try {
// 构建查询条件
const conditions = []
// 日期过滤
if (date) {
const targetDate = new Date(date as string)
const startOfDay = new Date(
targetDate.getFullYear(),
targetDate.getMonth(),
targetDate.getDate()
)
const endOfDay = new Date(
targetDate.getFullYear(),
targetDate.getMonth(),
targetDate.getDate() + 1
)
conditions.push(and(gte(schedules.playDate, startOfDay), lt(schedules.playDate, endOfDay)))
}
// 播放时间过滤
if (playTimeId) {
conditions.push(eq(schedules.playTimeId, parseInt(playTimeId as string)))
}
// 草稿包含过滤 - 默认包含草稿和已发布的排期
if (includeDrafts === 'false') {
conditions.push(eq(schedules.isDraft, false))
} else if (includeDrafts === 'only') {
conditions.push(eq(schedules.isDraft, true))
}
// 如果 includeDrafts === 'true' 或未指定,则包含两者(无需过滤)
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
// 获取完整的排期数据,包括草稿
const schedulesData = await db
.select({
id: schedules.id,
createdAt: schedules.createdAt,
updatedAt: schedules.updatedAt,
playDate: schedules.playDate,
sequence: schedules.sequence,
played: schedules.played,
playTimeId: schedules.playTimeId,
isDraft: schedules.isDraft,
publishedAt: schedules.publishedAt,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId,
songPlayed: songs.played,
songCover: songs.cover,
songMusicPlatform: songs.musicPlatform,
songMusicId: songs.musicId,
songSemester: songs.semester,
songCreatedAt: songs.createdAt,
requesterName: users.name,
requesterGrade: users.grade,
requesterClass: users.class,
playTimeName: playTimes.name,
playTimeStartTime: playTimes.startTime,
playTimeEndTime: playTimes.endTime,
playTimeEnabled: playTimes.enabled
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.leftJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(whereCondition)
.orderBy(asc(schedules.playDate), asc(schedules.sequence), asc(schedules.createdAt))
// 获取所有用户用于姓名消歧
const allUsers = await db
.select({
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
})
.from(users)
// 创建姓名到用户的映射用于消歧
const nameToUsers = new Map()
allUsers.forEach((user) => {
if (user.name) {
if (!nameToUsers.has(user.name)) {
nameToUsers.set(user.name, [])
}
nameToUsers.get(user.name).push(user)
}
})
// 获取每首歌的投票数
const songIds = schedulesData.map((s) => s.songId)
const voteCounts =
songIds.length > 0
? await Promise.all(
songIds.map(async (songId) => {
const voteCountResult = await db
.select({ count: count() })
.from(votes)
.where(eq(votes.songId, songId))
return { songId, count: voteCountResult[0].count }
})
)
: []
const voteCountMap = new Map(voteCounts.map((v) => [v.songId, v.count]))
// 获取联合投稿人信息
const collaboratorsMap = new Map()
if (songIds.length > 0) {
const collaboratorsData = await db
.select({
songId: songCollaborators.songId,
user: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
}
})
.from(songCollaborators)
.leftJoin(users, eq(songCollaborators.userId, users.id))
.where(
and(inArray(songCollaborators.songId, songIds), eq(songCollaborators.status, 'ACCEPTED'))
)
collaboratorsData.forEach((c) => {
if (!collaboratorsMap.has(c.songId)) {
collaboratorsMap.set(c.songId, [])
}
if (c.user) {
collaboratorsMap.get(c.songId).push(c.user)
}
})
}
// 获取重播申请信息
const replayRequestCountsMap = new Map()
const replayRequestersMap = new Map()
if (songIds.length > 0) {
// 获取每首歌的重播申请数量(统计 PENDING 和 FULFILLED 状态)
const replayCountsData = await db
.select({
songId: songReplayRequests.songId,
count: count(songReplayRequests.id)
})
.from(songReplayRequests)
.where(
and(
inArray(songReplayRequests.songId, songIds),
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
)
)
.groupBy(songReplayRequests.songId)
replayCountsData.forEach((r) => {
replayRequestCountsMap.set(r.songId, r.count)
})
// 获取重播申请人列表(前5个)
const replayRequestersData = await db
.select({
songId: songReplayRequests.songId,
user: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
},
status: songReplayRequests.status,
createdAt: songReplayRequests.createdAt
})
.from(songReplayRequests)
.innerJoin(users, eq(songReplayRequests.userId, users.id))
.where(
and(
inArray(songReplayRequests.songId, songIds),
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
)
)
.orderBy(desc(songReplayRequests.createdAt))
replayRequestersData.forEach((r) => {
if (!replayRequestersMap.has(r.songId)) {
replayRequestersMap.set(r.songId, [])
}
// 只保留前5个
if (replayRequestersMap.get(r.songId).length < 5) {
replayRequestersMap.get(r.songId).push({
id: r.user.id,
name: r.user.name || '未知用户',
grade: r.user.grade,
class: r.user.class,
status: r.status
})
}
})
}
// 格式化响应数据
const formattedSchedules = schedulesData.map((schedule) => {
const dateOnly = schedule.playDate
// 处理投稿人显示:班级 + 姓名
let requesterName = schedule.requesterName || 'Unknown User'
const requesterClassParts: string[] = []
if (schedule.requesterGrade) requesterClassParts.push(String(schedule.requesterGrade))
if (schedule.requesterClass) requesterClassParts.push(String(schedule.requesterClass))
const requesterClassInfo = requesterClassParts.join(' ').trim()
if (requesterClassInfo && requesterName) {
requesterName = `${requesterClassInfo} ${requesterName}`
} else if (requesterClassInfo) {
requesterName = requesterClassInfo
}
const formatClassInfo = (userObj: any) => {
if (!userObj) return ''
const parts: string[] = []
if (userObj.grade) parts.push(String(userObj.grade))
if (userObj.class) parts.push(String(userObj.class))
return parts.join(' ').trim()
}
const formatDisplayName = (userObj: any) => {
if (!userObj) return '未知用户'
const name = userObj.name ? String(userObj.name).trim() : ''
const classInfo = formatClassInfo(userObj)
if (!name) return classInfo || '未知用户'
return classInfo ? `${classInfo} ${name}` : name
}
const collaborators = collaboratorsMap.get(schedule.songId) || []
const formattedCollaborators = collaborators.map((c: any) => ({
id: c.id,
name: c.name,
displayName: formatDisplayName(c),
grade: c.grade,
class: c.class
}))
// 获取重播申请信息
const replayRequestCount = replayRequestCountsMap.get(schedule.songId) || 0
const replayRequesters = replayRequestersMap.get(schedule.songId) || []
const formattedReplayRequesters = replayRequesters.map((r: any) => ({
id: r.id,
name: r.name,
displayName: formatDisplayName(r),
grade: r.grade,
class: r.class,
status: r.status
}))
// 判断是否为重播:有重播申请(PENDING 或 FULFILLED)
const isReplaySong = replayRequestCount > 0
return {
id: schedule.id,
createdAt: schedule.createdAt,
updatedAt: schedule.updatedAt,
playDate: dateOnly.toISOString().split('T')[0],
sequence: schedule.sequence || 1,
played: schedule.played || false,
playTimeId: schedule.playTimeId,
// 草稿状态信息
isDraft: schedule.isDraft || false,
publishedAt: schedule.publishedAt,
status: schedule.isDraft ? 'draft' : 'published',
playTime: schedule.playTimeName
? {
id: schedule.playTimeId,
name: schedule.playTimeName,
startTime: schedule.playTimeStartTime,
endTime: schedule.playTimeEndTime,
enabled: schedule.playTimeEnabled
}
: null,
song: {
id: schedule.songId,
title: schedule.songTitle,
artist: schedule.songArtist,
requester: requesterName,
requesterId: schedule.songRequesterId,
requesterGrade: schedule.requesterGrade || null,
requesterClass: schedule.requesterClass || null,
collaborators: formattedCollaborators,
voteCount: voteCountMap.get(schedule.songId) || 0,
played: schedule.songPlayed || false,
cover: schedule.songCover || null,
musicPlatform: schedule.songMusicPlatform || null,
musicId: schedule.songMusicId || null,
semester: schedule.songSemester || null,
createdAt: schedule.songCreatedAt,
// 重播申请信息
replayRequestCount: isReplaySong ? replayRequestCount : 0,
replayRequesters: isReplaySong ? formattedReplayRequesters : [],
isReplay: isReplaySong // 只有已播放过且有重播申请的才标记为重播
}
}
})
// 按日期分组以便更好地组织
const groupedByDate = {}
formattedSchedules.forEach((schedule) => {
const date = schedule.playDate
if (!groupedByDate[date]) {
groupedByDate[date] = {
date,
schedules: [],
totalCount: 0,
draftCount: 0,
publishedCount: 0
}
}
groupedByDate[date].schedules.push(schedule)
groupedByDate[date].totalCount++
if (schedule.isDraft) {
groupedByDate[date].draftCount++
} else {
groupedByDate[date].publishedCount++
}
})
// 转换为数组并按日期排序
const dateGroups = Object.values(groupedByDate).sort(
(a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime()
)
// 汇总统计
const summary = {
totalSchedules: formattedSchedules.length,
draftCount: formattedSchedules.filter((s) => s.isDraft).length,
publishedCount: formattedSchedules.filter((s) => !s.isDraft).length,
dateRange: {
startDate: dateGroups.length > 0 ? dateGroups[0].date : null,
endDate: dateGroups.length > 0 ? dateGroups[dateGroups.length - 1].date : null
},
playTimeFilter: playTimeId ? parseInt(playTimeId as string) : null,
includeDrafts: includeDrafts as string
}
return {
success: true,
data: {
summary,
schedules: formattedSchedules,
groupedByDate: dateGroups
}
}
} catch (error: any) {
console.error('获取完整排期数据失败:', error)
throw createError({
statusCode: 500,
message: error.message || '获取完整排期数据失败'
})
}
})
+137
View File
@@ -0,0 +1,137 @@
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { schedules, songs } from '~/drizzle/schema'
import { cacheService } from '~~/server/services/cacheService'
import { createSystemNotification } from '~~/server/services/notificationService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '需要歌曲管理员及以上权限'
})
}
const body = await readBody(event)
const fromDate = typeof body?.fromDate === 'string' ? body.fromDate.trim() : ''
const toDate = typeof body?.toDate === 'string' ? body.toDate.trim() : ''
if (fromDate === toDate) {
throw createError({
statusCode: 400,
message: '目标日期不能与当前日期相同'
})
}
const fromStart = new Date(`${fromDate}T00:00:00.000Z`)
const fromEnd = new Date(`${fromDate}T23:59:59.999Z`)
const toPlayDate = new Date(`${toDate}T00:00:00.000Z`)
const toStart = new Date(`${toDate}T00:00:00.000Z`)
const toEnd = new Date(`${toDate}T23:59:59.999Z`)
if (
Number.isNaN(fromStart.getTime()) ||
Number.isNaN(fromEnd.getTime()) ||
Number.isNaN(toPlayDate.getTime()) ||
Number.isNaN(toStart.getTime()) ||
Number.isNaN(toEnd.getTime()) ||
fromStart.toISOString().split('T')[0] !== fromDate ||
toPlayDate.toISOString().split('T')[0] !== toDate
) {
throw createError({
statusCode: 400,
message: '日期无效,请使用 YYYY-MM-DD 格式并确保日期有效'
})
}
try {
const clientIP = getClientIP(event)
const moveResult = await db.transaction(async (tx) => {
const existingOnToDate = await tx
.select({ id: schedules.id })
.from(schedules)
.where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd)))
.limit(1)
if (existingOnToDate.length > 0) {
throw createError({
statusCode: 409,
message: '目标日期已存在排期,无法迁移。请先清空目标日期的排期。'
})
}
const sourceSchedules = await tx
.select({
id: schedules.id,
songId: schedules.songId,
requesterId: songs.requesterId,
songTitle: songs.title
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.where(and(gte(schedules.playDate, fromStart), lte(schedules.playDate, fromEnd)))
if (sourceSchedules.length === 0) {
return {
movedCount: 0,
movedSongs: []
}
}
const scheduleIds = sourceSchedules.map((item) => item.id)
const updateTime = new Date()
const movedSchedules = await tx
.update(schedules)
.set({
playDate: toPlayDate,
updatedAt: updateTime
})
.where(inArray(schedules.id, scheduleIds))
.returning({
id: schedules.id
})
return {
movedCount: movedSchedules.length,
movedSongs: sourceSchedules
}
})
const notificationsToSend = moveResult.movedSongs.map((item) => {
const message = `您投稿的歌曲《${item.songTitle}》原定于 ${fromDate} 播放,已调整至 ${toDate}。`
return createSystemNotification(item.requesterId, '排期调整通知', message, clientIP)
})
if (notificationsToSend.length > 0) {
Promise.allSettled(notificationsToSend).catch((error) => {
console.error('[Notification] 发送排期迁移通知失败:', error)
})
}
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(迁移排期日期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
fromDate,
toDate,
movedCount: moveResult.movedCount
}
} catch (error: any) {
if (error?.statusCode === 409) {
throw error
}
console.error('迁移排期日期失败:', error)
throw createError({
statusCode: 500,
message: error.message || '迁移排期日期失败'
})
}
})
+291
View File
@@ -0,0 +1,291 @@
import { db } from '~/drizzle/db'
import { playTimes, schedules, songs, users, votes, songReplayRequests } from '~/drizzle/schema'
import { and, asc, count, eq } from 'drizzle-orm'
import { createSongSelectedNotification } from '~~/server/services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
import { getBeijingTimestamp } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有歌曲管理员及以上权限才能发布排期'
})
}
const body = await readBody(event)
if (!body.scheduleId) {
throw createError({
statusCode: 400,
message: '排期ID不能为空'
})
}
try {
// 检查草稿是否存在
const draftResult = await db
.select({
id: schedules.id,
songId: schedules.songId,
playDate: schedules.playDate,
sequence: schedules.sequence,
playTimeId: schedules.playTimeId,
isDraft: schedules.isDraft,
publishedAt: schedules.publishedAt,
song: {
id: songs.id,
title: songs.title,
artist: songs.artist,
requesterId: songs.requesterId
}
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.where(
and(
eq(schedules.id, body.scheduleId),
eq(schedules.isDraft, true) // 确保是草稿状态
)
)
.limit(1)
const draft = draftResult[0]
if (!draft) {
throw createError({
statusCode: 404,
message: '找不到指定的排期草稿'
})
}
// 更新草稿为已发布状态
const publishedAt = new Date(getBeijingTimestamp())
const publishResult = await db
.update(schedules)
.set({
isDraft: false,
publishedAt: publishedAt,
updatedAt: publishedAt
})
.where(eq(schedules.id, body.scheduleId))
.returning()
const publishedSchedule = publishResult[0]
if (!publishedSchedule) {
throw createError({
statusCode: 500,
message: '发布排期失败'
})
}
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 发布后发送通知(这是与草稿保存的主要区别)
await createSongSelectedNotification(
draft.song.requesterId,
draft.song.id,
{
title: draft.song.title,
artist: draft.song.artist,
playDate: draft.playDate
},
clientIP
)
// 将该歌曲的所有待处理重播申请标记为已完成
const updatedRequests = await db
.update(songReplayRequests)
.set({
status: 'FULFILLED',
updatedAt: new Date()
})
.where(
and(eq(songReplayRequests.songId, draft.song.id), eq(songReplayRequests.status, 'PENDING'))
)
.returning()
if (updatedRequests.length > 0) {
console.log(`发布排期:将 ${updatedRequests.length} 个重播申请标记为 FULFILLED`)
}
// 清除相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(发布排期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
// 重新缓存完整的排期列表
try {
// 获取所有已发布的排期数据
const schedulesData = await db
.select({
id: schedules.id,
playDate: schedules.playDate,
sequence: schedules.sequence,
played: schedules.played,
playTimeId: schedules.playTimeId,
isDraft: schedules.isDraft,
publishedAt: schedules.publishedAt,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId,
songPlayed: songs.played,
songCover: songs.cover,
songMusicPlatform: songs.musicPlatform,
songMusicId: songs.musicId,
songSemester: songs.semester,
requesterName: users.name,
requesterGrade: users.grade,
requesterClass: users.class,
playTimeName: playTimes.name,
playTimeStartTime: playTimes.startTime,
playTimeEndTime: playTimes.endTime,
playTimeEnabled: playTimes.enabled
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.innerJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(eq(schedules.isDraft, false)) // 只缓存已发布的排期
.orderBy(asc(schedules.playDate))
// 获取所有用户的姓名列表,用于检测同名用户
const allUsers = await db
.select({
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
})
.from(users)
// 创建姓名到用户数组的映射
const nameToUsers = new Map()
allUsers.forEach((user) => {
if (user.name) {
if (!nameToUsers.has(user.name)) {
nameToUsers.set(user.name, [])
}
nameToUsers.get(user.name).push(user)
}
})
// 获取每首歌的投票数
const songIds = schedulesData.map((s) => s.songId)
const voteCounts = await Promise.all(
songIds.map(async (songId) => {
const voteCountResult = await db
.select({ count: count() })
.from(votes)
.where(eq(votes.songId, songId))
return { songId, count: voteCountResult[0].count }
})
)
const voteCountMap = new Map(voteCounts.map((v) => [v.songId, v.count]))
// 转换数据格式
const formattedSchedules = schedulesData.map((schedule) => {
const dateOnly = schedule.playDate
// 处理投稿人姓名,如果是同名用户则添加后缀
let requesterName = schedule.requesterName || '未知用户'
const sameNameUsers = nameToUsers.get(requesterName)
if (sameNameUsers && sameNameUsers.length > 1) {
if (schedule.requesterGrade) {
const sameGradeUsers = sameNameUsers.filter(
(u: {
id: number
name: string | null
grade: string | null
class: string | null
}) => u.grade === schedule.requesterGrade
)
if (sameGradeUsers.length > 1 && schedule.requesterClass) {
requesterName = `${requesterName}(${schedule.requesterGrade} ${schedule.requesterClass})`
} else {
requesterName = `${requesterName}(${schedule.requesterGrade})`
}
}
}
return {
id: schedule.id,
playDate: dateOnly.toISOString().split('T')[0],
sequence: schedule.sequence || 1,
played: schedule.played || false,
playTimeId: schedule.playTimeId,
isDraft: schedule.isDraft,
publishedAt: schedule.publishedAt,
playTime: schedule.playTimeName
? {
id: schedule.playTimeId,
name: schedule.playTimeName,
startTime: schedule.playTimeStartTime,
endTime: schedule.playTimeEndTime,
enabled: schedule.playTimeEnabled
}
: null,
song: {
id: schedule.songId,
title: schedule.songTitle,
artist: schedule.songArtist,
requester: requesterName,
voteCount: voteCountMap.get(schedule.songId) || 0,
played: schedule.songPlayed || false,
cover: schedule.songCover || null,
musicPlatform: schedule.songMusicPlatform || null,
musicId: schedule.songMusicId || null,
semester: schedule.songSemester || null
}
}
})
const completeSchedules = formattedSchedules
// 重新缓存完整的排期列表
if (completeSchedules && completeSchedules.length > 0) {
await cacheService.setSchedulesList(completeSchedules)
console.log(`[Cache] 已发布排期列表已重新缓存,数量: ${completeSchedules.length}`)
} else {
console.log('[Cache] 没有已发布排期数据需要缓存')
}
} catch (cacheError) {
console.warn('[Cache] 重新缓存完整排期列表失败,但不影响排期发布:', cacheError)
}
return {
...draft,
isDraft: false,
publishedAt: publishedAt,
message: '排期发布成功,通知已发送'
}
} catch (error: any) {
console.error('发布排期失败:', error)
throw createError({
statusCode: 500,
message: error.message || '发布排期失败'
})
}
})
+115
View File
@@ -0,0 +1,115 @@
import { db, eq, schedules, songs, songReplayRequests, and } from '~/drizzle/db'
import { cacheService } from '~~/server/services/cacheService'
export default defineEventHandler(async (event) => {
// 验证管理员权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '需要歌曲管理员及以上权限'
})
}
try {
const body = await readBody(event)
const { scheduleId } = body
if (!scheduleId) {
throw createError({
statusCode: 400,
statusMessage: '缺少排期ID'
})
}
const scheduleIdNumber = Number(scheduleId)
console.log(`准备删除排期 ID=${scheduleIdNumber}`)
// 先检查排期是否存在,并获取歌曲ID
const existingSchedule = await db
.select({
id: schedules.id,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist
})
.from(schedules)
.leftJoin(songs, eq(schedules.songId, songs.id))
.where(eq(schedules.id, scheduleIdNumber))
.limit(1)
.then((rows) => rows[0])
if (!existingSchedule) {
console.log(`排期不存在 ID=${scheduleIdNumber}`)
return {
success: false,
message: '排期不存在或已被删除'
}
}
console.log(`找到排期 ID=${scheduleIdNumber}, 歌曲=${existingSchedule.songTitle || '未知歌曲'}`)
// 删除排期
const deletedSchedule = await db
.delete(schedules)
.where(eq(schedules.id, scheduleIdNumber))
.returning()
console.log(`成功删除排期 ID=${scheduleIdNumber}`)
// 恢复该歌曲的重播申请状态为 PENDING
// 只恢复状态不是 PENDING 的申请(可能是 FULFILLED 或其他状态)
if (existingSchedule.songId) {
const updatedRequests = await db
.update(songReplayRequests)
.set({
status: 'PENDING',
updatedAt: new Date()
})
.where(
and(
eq(songReplayRequests.songId, existingSchedule.songId),
// 不恢复已拒绝的申请
eq(songReplayRequests.status, 'FULFILLED')
)
)
.returning()
if (updatedRequests.length > 0) {
console.log(`恢复了 ${updatedRequests.length} 个重播申请状态为 PENDING`)
}
}
// 清除相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache() // 清除歌曲列表缓存,确保scheduled状态更新
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(移除排期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
schedule: deletedSchedule
}
} catch (error: any) {
console.error('移除排期失败:', error)
// 处理数据库特定错误
if (error.message?.includes('not found') || error.message?.includes('does not exist')) {
return {
success: false,
message: '排期不存在或已被删除'
}
}
// 确保返回一个成功=false的响应,而不是抛出错误
return {
success: false,
message: error.message || '移除排期失败',
error: error.code || 'UNKNOWN_ERROR'
}
}
})
@@ -0,0 +1,57 @@
import { db, eq } from '~/drizzle/db'
import { schedules } from '~/drizzle/schema'
import { cacheService } from '~~/server/services/cacheService'
export default defineEventHandler(async (event) => {
// 验证用户认证和权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '需要歌曲管理员及以上权限'
})
}
try {
const body = await readBody(event)
const { schedules } = body
if (!schedules || !Array.isArray(schedules) || schedules.length === 0) {
throw createError({
statusCode: 400,
statusMessage: '缺少排期数据'
})
}
// 批量更新排期顺序
const results = await Promise.all(
schedules.map(async (item) => {
return db
.update(schedules)
.set({ sequence: item.sequence })
.where(eq(schedules.id, Number(item.id)))
})
)
// 清除排期相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(更新排期顺序)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
count: results.length
}
} catch (error: any) {
console.error('更新排期顺序失败:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || '更新排期顺序失败'
})
}
})
+63
View File
@@ -0,0 +1,63 @@
import { db, eq, semesters, songs } from '~/drizzle/db'
export default defineEventHandler(async (event) => {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '需要管理员权限'
})
}
// 获取学期ID
const semesterId = parseInt(getRouterParam(event, 'id') || '0')
if (!semesterId || isNaN(semesterId)) {
throw createError({
statusCode: 400,
statusMessage: '无效的学期ID'
})
}
// 检查学期是否存在
const semesterResult = await db
.select()
.from(semesters)
.where(eq(semesters.id, semesterId))
.limit(1)
const semester = semesterResult[0]
if (!semester) {
throw createError({
statusCode: 404,
statusMessage: '学期不存在'
})
}
// 检查是否为当前活跃学期
if (semester.isActive) {
throw createError({
statusCode: 400,
statusMessage: '不能删除当前活跃的学期'
})
}
// 检查是否有关联的歌曲
const songCountResult = await db.select().from(songs).where(eq(songs.semester, semester.name))
const songCount = songCountResult.length
if (songCount > 0) {
throw createError({
statusCode: 400,
statusMessage: `该学期下还有 ${songCount} 首歌曲,无法删除`
})
}
// 删除学期
await db.delete(semesters).where(eq(semesters.id, semesterId))
return {
success: true,
message: '学期删除成功'
}
})
+27
View File
@@ -0,0 +1,27 @@
import { db } from '~/drizzle/db'
import { semesters } from '~/drizzle/schema'
import { desc } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '需要登录才能访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能管理学期'
})
}
// 获取所有学期,按创建时间倒序排列
const semestersList = await db.select().from(semesters).orderBy(desc(semesters.createdAt))
return semestersList
})
+71
View File
@@ -0,0 +1,71 @@
import { db } from '~/drizzle/db'
import { semesters } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '需要登录才能访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能管理学期'
})
}
const body = await readBody(event)
if (!body.name) {
throw createError({
statusCode: 400,
message: '学期名称不能为空'
})
}
// 检查学期名称是否已存在
const existingSemesterResult = await db
.select()
.from(semesters)
.where(eq(semesters.name, body.name))
.limit(1)
const existingSemester = existingSemesterResult[0]
if (existingSemester) {
throw createError({
statusCode: 400,
message: '该学期名称已存在'
})
}
// 检查是否是第一个学期,如果是,强制设为活跃
const allSemestersCount = await db.select({ id: semesters.id }).from(semesters).limit(1)
let shouldBeActive = body.isActive
if (allSemestersCount.length === 0) {
shouldBeActive = true
}
// 如果设置为活跃学期,先将其他学期设为非活跃
if (shouldBeActive) {
await db.update(semesters).set({ isActive: false }).where(eq(semesters.isActive, true))
}
// 创建新学期
const semesterResult = await db
.insert(semesters)
.values({
name: body.name,
isActive: shouldBeActive || false
})
.returning()
const semester = semesterResult[0]
return semester
})
@@ -0,0 +1,56 @@
import { db } from '~/drizzle/db'
import { semesters } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '需要登录才能访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能管理学期'
})
}
const body = await readBody(event)
if (!body.semesterId) {
throw createError({
statusCode: 400,
message: '学期ID不能为空'
})
}
// 检查学期是否存在
const semester = await db
.select()
.from(semesters)
.where(eq(semesters.id, body.semesterId))
.limit(1)
if (semester.length === 0) {
throw createError({
statusCode: 404,
message: '学期不存在'
})
}
// 使用事务确保数据一致性
await db.transaction(async (tx) => {
// 先将所有学期设为非活跃
await tx.update(semesters).set({ isActive: false }).where(eq(semesters.isActive, true))
// 设置指定学期为活跃
await tx.update(semesters).set({ isActive: true }).where(eq(semesters.id, body.semesterId))
})
return { message: '活跃学期设置成功' }
})
+36
View File
@@ -0,0 +1,36 @@
import { SmtpService } from '~~/server/services/smtpService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能重载SMTP配置'
})
}
try {
const smtpService = SmtpService.getInstance()
const initialized = await smtpService.initializeSmtpConfig()
return {
success: true,
initialized,
message: initialized ? 'SMTP配置已重载' : 'SMTP未启用或配置不完整,已清空当前SMTP实例'
}
} catch (error) {
return {
success: false,
message: 'SMTP配置重载失败',
detail: error instanceof Error ? error.message : String(error)
}
}
})
@@ -0,0 +1,100 @@
import { SmtpService } from '~~/server/services/smtpService'
export default defineEventHandler(async (event) => {
// 检查请求方法
if (getMethod(event) !== 'POST') {
throw createError({
statusCode: 405,
message: '方法不被允许'
})
}
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能测试SMTP连接'
})
}
try {
const body = await readBody(event)
// 验证配置
if (!body.smtpEnabled) {
return {
success: false,
message: 'SMTP服务未启用'
}
}
if (!body.smtpHost || !body.smtpUsername || !body.smtpPassword) {
return {
success: false,
message: '请填写完整的SMTP配置信息'
}
}
// 创建临时的SMTP服务实例进行测试
const smtpService = SmtpService.getInstance()
// 临时设置配置进行测试
const originalConfig = smtpService.smtpConfig
const originalTransporter = smtpService.transporter
try {
// 如果密码是掩码,则使用原始配置中的密码
const testPassword =
body.smtpPassword === '****************'
? originalConfig?.auth?.pass || body.smtpPassword
: body.smtpPassword
// 设置测试配置
smtpService.smtpConfig = {
host: body.smtpHost,
port: body.smtpPort || 587,
secure: body.smtpSecure || false,
auth: {
user: body.smtpUsername,
pass: testPassword
},
fromEmail: body.smtpFromEmail || body.smtpUsername,
fromName: body.smtpFromName || '校园广播站'
}
// 创建测试transporter
const nodemailer = await import('nodemailer')
smtpService.transporter = nodemailer.default.createTransport({
host: smtpService.smtpConfig.host,
port: smtpService.smtpConfig.port,
secure: smtpService.smtpConfig.secure,
auth: smtpService.smtpConfig.auth
})
// 测试连接
const result = await smtpService.testConnection()
return result
} finally {
// 恢复原始配置
smtpService.smtpConfig = originalConfig
smtpService.transporter = originalTransporter
}
} catch (error) {
console.error('测试SMTP连接失败:', error)
return {
success: false,
message: '测试连接失败',
detail: error instanceof Error ? error.message : String(error)
}
}
})
+111
View File
@@ -0,0 +1,111 @@
import { SmtpService } from '~~/server/services/smtpService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查请求方法
if (getMethod(event) !== 'POST') {
throw createError({
statusCode: 405,
message: '方法不被允许'
})
}
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能发送测试邮件'
})
}
try {
const body = await readBody(event)
// 验证配置
if (!body.smtpEnabled) {
return {
success: false,
message: 'SMTP服务未启用'
}
}
if (!body.smtpHost || !body.smtpUsername || !body.smtpPassword) {
return {
success: false,
message: '请填写完整的SMTP配置信息'
}
}
if (!body.testEmail) {
return {
success: false,
message: '请输入测试邮箱地址'
}
}
// 创建临时的SMTP服务实例进行测试
const smtpService = SmtpService.getInstance()
// 临时设置配置进行测试
const originalConfig = smtpService.smtpConfig
const originalTransporter = smtpService.transporter
try {
// 如果密码是掩码,则使用原始配置中的密码
const testPassword =
body.smtpPassword === '****************'
? originalConfig?.auth?.pass || body.smtpPassword
: body.smtpPassword
// 设置测试配置
smtpService.smtpConfig = {
host: body.smtpHost,
port: body.smtpPort || 587,
secure: body.smtpSecure || false,
auth: {
user: body.smtpUsername,
pass: testPassword
},
fromEmail: body.smtpFromEmail || body.smtpUsername,
fromName: body.smtpFromName || '校园广播站'
}
// 创建测试transporter
const nodemailer = await import('nodemailer')
smtpService.transporter = nodemailer.default.createTransport({
host: smtpService.smtpConfig.host,
port: smtpService.smtpConfig.port,
secure: smtpService.smtpConfig.secure,
auth: smtpService.smtpConfig.auth
})
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 发送测试邮件
const result = await smtpService.sendTestEmail(body.testEmail, clientIP)
return result
} finally {
// 恢复原始配置
smtpService.smtpConfig = originalConfig
smtpService.transporter = originalTransporter
}
} catch (error) {
console.error('发送测试邮件失败:', error)
return {
success: false,
message: '发送测试邮件失败',
detail: error instanceof Error ? error.message : String(error)
}
}
})
+101
View File
@@ -0,0 +1,101 @@
import { db } from '~/drizzle/db'
import { songs, requestTimes } from '~/drizzle/schema'
import { eq, sql } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
import { createSongRejectedNotification } from '../../../services/notificationService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
const body = await readBody(event)
if (!body.songId || !['approved', 'rejected'].includes(body.status)) {
throw createError({
statusCode: 400,
message: '参数错误'
})
}
try {
let songForNotification = null
const result = await db.transaction(async (tx) => {
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createError({
statusCode: 404,
message: '歌曲不存在'
})
}
songForNotification = song
if (body.status === 'rejected') {
// 如果有 hitRequestId,减少对应时段的已接纳数量
if (song.hitRequestId) {
try {
await tx
.update(requestTimes)
.set({
accepted: sql`GREATEST(0, accepted - 1)`
})
.where(eq(requestTimes.id, song.hitRequestId))
} catch (error) {
console.error(`减少投稿时段接纳数量失败: ${error.message}`)
}
}
const note = song.submissionNote ? `[审核未通过] ${body.reason || ''}\n${song.submissionNote}` : `[审核未通过] ${body.reason || ''}`
// 释放排期序号并更新备注
const updatedSong = await tx.update(songs).set({
expectedPlayDate: null,
expectedPlaySequence: null,
hitRequestId: null,
submissionNote: note
}).where(eq(songs.id, body.songId)).returning()
return { success: true, song: updatedSong[0] }
} else {
const note = song.submissionNote ? `[审核已通过]\n${song.submissionNote}` : `[审核已通过]`
const updatedSong = await tx.update(songs).set({
submissionNote: note
}).where(eq(songs.id, body.songId)).returning()
return { success: true, song: updatedSong[0] }
}
})
if (body.status === 'rejected' && songForNotification && songForNotification.requesterId) {
createSongRejectedNotification(
songForNotification.requesterId,
{ title: songForNotification.title, artist: songForNotification.artist },
body.reason?.trim() || '未过审释放排期',
getClientIP(event)
).catch(e => console.error(e))
}
await cacheService.clearSongsCache()
return {
success: true,
message: body.status === 'rejected' ? '已驳回并释放排期' : '审核已通过',
data: result.song
}
} catch (error) {
console.error('审核歌曲失败:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: '审核失败: ' + error.message
})
}
})
+112
View File
@@ -0,0 +1,112 @@
import { db } from '~/drizzle/db'
import { schedules, songs, votes, requestTimes } from '~/drizzle/schema'
import { eq, sql } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
const body = await readBody(event)
if (!body.songId) {
throw createError({
statusCode: 400,
message: '歌曲ID不能为空'
})
}
try {
// 使用事务确保删除操作的原子性
const result = await db.transaction(async (tx) => {
// 在事务中重新检查歌曲是否存在
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createError({
statusCode: 404,
message: '歌曲不存在或已被删除'
})
}
console.log(`开始删除歌曲: ${song.title} (ID: ${body.songId})`)
// 删除歌曲的所有投票
const deletedVotes = await tx.delete(votes).where(eq(votes.songId, body.songId))
console.log(`删除了投票记录`)
// 删除歌曲的所有排期
const deletedSchedules = await tx.delete(schedules).where(eq(schedules.songId, body.songId))
console.log(`删除了排期记录`)
// 如果有 hitRequestId,减少对应时段的已接纳数量
if (song.hitRequestId) {
try {
await tx
.update(requestTimes)
.set({
accepted: sql`GREATEST(0, accepted - 1)`
})
.where(eq(requestTimes.id, song.hitRequestId))
console.log(`已减少投稿时段 ${song.hitRequestId} 的接纳数量`)
} catch (error) {
console.error(`减少投稿时段接纳数量失败: ${error.message}`)
}
}
// 删除歌曲
const deletedSong = await tx.delete(songs).where(eq(songs.id, body.songId)).returning()
const deletedSongData = deletedSong[0]
console.log(`成功删除歌曲: ${deletedSongData?.title}`)
return {
message: '歌曲已成功删除',
songId: body.songId,
deletedVotes: true,
deletedSchedules: true
}
})
// 清除相关缓存
try {
await cacheService.clearSongsCache()
if (result.deletedSchedules) {
await cacheService.clearSchedulesCache()
}
console.log('[Cache] 歌曲和排期缓存已清除(删除歌曲)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return result
} catch (error: any) {
console.error('删除歌曲时发生错误:', error)
// 如果是已经格式化的错误,直接抛出
if (error.statusCode) {
throw error
}
// 处理Prisma特定错误
if (error.code === 'P2025') {
throw createError({
statusCode: 404,
message: '歌曲不存在或已被删除'
})
}
// 其他未知错误
throw createError({
statusCode: 500,
message: '删除歌曲时发生未知错误'
})
}
})
+126
View File
@@ -0,0 +1,126 @@
import { db } from '~/drizzle/db'
import { createSongPlayedNotification } from '~~/server/services/notificationService'
import { CacheService } from '~~/server/services/cacheService'
import { songs, songReplayRequests } from '~/drizzle/schema'
import { eq, and, inArray } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户认证
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '需要登录才能标记歌曲'
})
}
// 检查是否是管理员
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员可以标记歌曲为已播放'
})
}
const body = await readBody(event)
let songIds: number[] = []
if (body.songId) {
songIds = [body.songId]
} else if (body.songIds && Array.isArray(body.songIds)) {
songIds = body.songIds
}
if (songIds.length === 0) {
throw createError({
statusCode: 400,
message: '歌曲ID列表不能为空'
})
}
// 检查是否是撤回操作
const isUnmark = body.unmark === true
// 构建更新条件:只更新状态不符合预期的歌曲
// 如果是标记为已播放(!isUnmark),则只更新 played = false 的歌曲
// 如果是撤回(isUnmark),则只更新 played = true 的歌曲
const condition = !isUnmark
? and(inArray(songs.id, songIds), eq(songs.played, false))
: and(inArray(songs.id, songIds), eq(songs.played, true))
// 使用事务确保数据一致性
const { updatedSongsResult, updatedSongIds } = await db.transaction(async (tx) => {
// 更新歌曲状态
const updatedSongsResult = await tx
.update(songs)
.set({
played: !isUnmark,
playedAt: isUnmark ? null : getBeijingTime()
})
.where(condition)
.returning()
// 获取实际更新的歌曲ID列表
const updatedSongIds = updatedSongsResult.map(s => s.id)
if (updatedSongIds.length > 0) {
// 根据操作类型更新重播申请状态
const targetStatus = !isUnmark ? 'PENDING' : 'FULFILLED'
const newStatus = !isUnmark ? 'FULFILLED' : 'PENDING'
const updatedRequests = await tx
.update(songReplayRequests)
.set({
status: newStatus,
updatedAt: new Date()
})
.where(
and(inArray(songReplayRequests.songId, updatedSongIds), eq(songReplayRequests.status, targetStatus))
)
.returning()
if (updatedRequests.length > 0) {
const logMessage = !isUnmark
? `将 ${updatedRequests.length} 个重播申请状态更新为 FULFILLED(歌曲已播放)`
: `将 ${updatedRequests.length} 个重播申请状态恢复为 PENDING(撤回已播放)`
console.log(logMessage)
}
}
return { updatedSongsResult, updatedSongIds }
})
// 清除歌曲和排期相关缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
await cacheService.clearSchedulesCache()
} catch (error) {
console.error('清除缓存失败:', error)
}
// 如果是标记为已播放,则异步发送通知(不阻塞响应)
if (!isUnmark && updatedSongIds.length > 0) {
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 异步发送通知,不等待完成
setImmediate(() => {
Promise.allSettled(updatedSongIds.map(songId =>
createSongPlayedNotification(songId, clientIP).catch((err) => {
console.error(`发送歌曲(${songId})已播放通知失败:`, err)
})
))
})
}
return {
message: isUnmark ? '歌曲已成功撤回已播放状态' : '歌曲已成功标记为已播放',
count: updatedSongsResult.length,
updatedSongIds: updatedSongIds
}
})
+153
View File
@@ -0,0 +1,153 @@
import { db } from '~/drizzle/db'
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
import { eq, sql } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
import { createSongRejectedNotification } from '../../../services/notificationService'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
const body = await readBody(event)
if (!body.songId) {
throw createError({
statusCode: 400,
message: '歌曲ID不能为空'
})
}
if (!body.reason || !body.reason.trim()) {
throw createError({
statusCode: 400,
message: '驳回原因不能为空'
})
}
try {
let songForNotification = null
// 使用事务确保驳回操作的原子性
const result = await db.transaction(async (tx) => {
// 在事务中重新检查歌曲是否存在
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createError({
statusCode: 404,
message: '歌曲不存在或已被删除'
})
}
// 保存歌曲信息用于后续发送通知
songForNotification = song
console.log(`开始驳回歌曲: ${song.title} (ID: ${body.songId})`)
// 如果选择加入黑名单,先添加到黑名单
if (body.addToBlacklist) {
try {
await tx.insert(songBlacklists).values({
type: 'SONG',
value: `${song.title} - ${song.artist}`,
reason: `歌曲驳回: ${body.reason.trim()}`,
createdBy: user.id
})
console.log(`歌曲已添加到黑名单`)
} catch (error) {
// 如果黑名单中已存在相同歌曲,忽略错误继续执行
console.log(`黑名单添加失败,可能已存在: ${error.message}`)
}
}
// 删除歌曲的所有投票
await tx.delete(votes).where(eq(votes.songId, body.songId))
console.log(`删除了投票记录`)
// 删除歌曲的所有排期
await tx.delete(schedules).where(eq(schedules.songId, body.songId))
console.log(`删除了排期记录`)
// 如果有 hitRequestId,减少对应时段的已接纳数量
if (song.hitRequestId) {
try {
await tx
.update(requestTimes)
.set({
accepted: sql`GREATEST(0, accepted - 1)`
})
.where(eq(requestTimes.id, song.hitRequestId))
console.log(`已减少投稿时段 ${song.hitRequestId} 的接纳数量`)
} catch (error) {
console.error(`减少投稿时段接纳数量失败: ${error.message}`)
}
}
// 删除歌曲
const deletedSong = await tx.delete(songs).where(eq(songs.id, body.songId)).returning()
console.log(`歌曲删除完成`)
return {
success: true,
deletedSong: deletedSong[0],
addedToBlacklist: body.addToBlacklist
}
})
// 事务提交后,发送通知
// 这样避免邮件发送等耗时操作阻塞数据库事务连接
let notificationSent = false
if (songForNotification && songForNotification.requesterId) {
// 异步发送通知,不阻塞响应
createSongRejectedNotification(
songForNotification.requesterId,
{ title: songForNotification.title, artist: songForNotification.artist },
body.reason.trim(),
getClientIP(event)
)
.then(() => {
console.log(`已发送驳回通知给用户: ${songForNotification.requesterId}`)
})
.catch((error) => {
console.error(`发送通知失败: ${error.message}`)
})
notificationSent = true
}
// 清除相关缓存
await cacheService.clearSongsCache()
await cacheService.clearSchedulesCache()
console.log(`歌曲驳回操作完成: ${body.songId}`)
return {
success: true,
message: '歌曲驳回成功',
data: {
...result,
notificationSent
}
}
} catch (error) {
console.error('驳回歌曲失败:', error)
// 如果是我们抛出的错误,直接重新抛出
if (error.statusCode) {
throw error
}
// 其他错误
throw createError({
statusCode: 500,
message: '驳回歌曲失败: ' + error.message
})
}
})
+373
View File
@@ -0,0 +1,373 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { schedules, semesters, songBlacklists, songs, users } from '~/drizzle/schema'
import { and, count, eq, gte, lt } from 'drizzle-orm'
import { cacheService } from '../../services/cacheService'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import timezone from 'dayjs/plugin/timezone.js'
dayjs.extend(utc)
dayjs.extend(timezone)
const BEIJING_TIMEZONE = 'Asia/Shanghai'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const query = getQuery(event)
const semester = query.semester as string
try {
// 尝试从缓存获取数据
const cachedStats = await cacheService.getAdminStats(semester)
if (cachedStats) {
console.log('[Cache] 管理员统计数据缓存命中')
return cachedStats
}
// 获取当前时间相关的日期
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
// 构建查询条件
const where = semester && semester !== 'all' ? { semester: semester } : {}
// 构建排期查询条件
const scheduleBaseWhere = semester && semester !== 'all' ? { song: { semester: semester } } : {}
// 添加日期条件到今日排期查询
const todayScheduleWhere = {
...scheduleBaseWhere,
playDate: {
gte: today,
lt: new Date(today.getTime() + 24 * 60 * 60 * 1000)
}
}
// 并行获取所有统计数据
const [
totalSongs,
totalUsers,
todaySchedules,
totalSchedules,
weeklyRequests,
previousWeekRequests,
songsThisWeek,
songsLastWeek,
usersThisWeek,
usersLastWeek,
currentSemester,
blacklistCount
] = await Promise.all([
// 总歌曲数
(async () => {
const whereCondition =
semester && semester !== 'all' ? eq(songs.semester, semester) : undefined
const result = await db.select({ count: count() }).from(songs).where(whereCondition)
return result[0].count
})(),
// 总用户数
(async () => {
const result = await db.select({ count: count() }).from(users)
return result[0].count
})(),
// 今日排期数 (按天计算)
(async () => {
const query = db.select({ playDate: schedules.playDate }).from(schedules)
const whereConditions = [
gte(schedules.playDate, today),
lt(schedules.playDate, new Date(today.getTime() + 24 * 60 * 60 * 1000))
]
if (semester && semester !== 'all') {
query.innerJoin(songs, eq(schedules.songId, songs.id))
whereConditions.push(eq(songs.semester, semester))
}
const todaySchedulesList = await query.where(and(...whereConditions))
// 按北京时间日期去重计算天数
const uniqueDates = new Set(
todaySchedulesList.map((s) => dayjs(s.playDate).tz(BEIJING_TIMEZONE).format('YYYY-MM-DD'))
)
return uniqueDates.size
})(),
// 总排期数 (按天计算)
(async () => {
const query = db.select({ playDate: schedules.playDate }).from(schedules)
if (semester && semester !== 'all') {
query.innerJoin(songs, eq(schedules.songId, songs.id)).where(eq(songs.semester, semester))
}
const allSchedules = await query
// 按北京时间日期去重计算天数
const uniqueDates = new Set(
allSchedules.map((s) => dayjs(s.playDate).tz(BEIJING_TIMEZONE).format('YYYY-MM-DD'))
)
return uniqueDates.size
})(),
// 本周点歌数
(async () => {
const whereConditions = [gte(songs.createdAt, weekAgo)]
if (semester && semester !== 'all') {
whereConditions.push(eq(songs.semester, semester))
}
const result = await db
.select({ count: count() })
.from(songs)
.where(and(...whereConditions))
return result[0].count
})(),
// 上周点歌数
(async () => {
const whereConditions = [gte(songs.createdAt, twoWeeksAgo), lt(songs.createdAt, weekAgo)]
if (semester && semester !== 'all') {
whereConditions.push(eq(songs.semester, semester))
}
const result = await db
.select({ count: count() })
.from(songs)
.where(and(...whereConditions))
return result[0].count
})(),
// 本周新增歌曲
(async () => {
const whereConditions = [gte(songs.createdAt, weekAgo)]
if (semester && semester !== 'all') {
whereConditions.push(eq(songs.semester, semester))
}
const result = await db
.select({ count: count() })
.from(songs)
.where(and(...whereConditions))
return result[0].count
})(),
// 上周新增歌曲
(async () => {
const whereConditions = [gte(songs.createdAt, twoWeeksAgo), lt(songs.createdAt, weekAgo)]
if (semester && semester !== 'all') {
whereConditions.push(eq(songs.semester, semester))
}
const result = await db
.select({ count: count() })
.from(songs)
.where(and(...whereConditions))
return result[0].count
})(),
// 本周新增用户
(async () => {
const result = await db
.select({ count: count() })
.from(users)
.where(gte(users.createdAt, weekAgo))
return result[0].count
})(),
// 上周新增用户
(async () => {
const result = await db
.select({ count: count() })
.from(users)
.where(and(gte(users.createdAt, twoWeeksAgo), lt(users.createdAt, weekAgo)))
return result[0].count
})(),
// 当前学期
(async () => {
const result = await db
.select({ name: semesters.name })
.from(semesters)
.where(eq(semesters.isActive, true))
.limit(1)
return result[0]
})(),
// 黑名单项目数
(async () => {
const blacklistCount = await db
.select({ count: count() })
.from(songBlacklists)
.where(eq(songBlacklists.isActive, true))
return blacklistCount[0].count
})()
])
// 计算变化百分比
const requestsChange =
previousWeekRequests > 0
? Math.round(((weeklyRequests - previousWeekRequests) / previousWeekRequests) * 100)
: weeklyRequests > 0
? 100
: 0
const songsChange = songsThisWeek - songsLastWeek
const usersChange = usersThisWeek - usersLastWeek
// 获取趋势数据 (最近7天)
const trendData = await Promise.all([
// 歌曲趋势
(async () => {
const trends = []
for (let i = 6; i >= 0; i--) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
const whereConditions = [gte(songs.createdAt, startOfDay), lt(songs.createdAt, endOfDay)]
if (semester && semester !== 'all') {
whereConditions.push(eq(songs.semester, semester))
}
const result = await db
.select({ count: count() })
.from(songs)
.where(and(...whereConditions))
const countValue = result[0].count
trends.push({
date: dayjs(startOfDay).tz(BEIJING_TIMEZONE).format('YYYY-MM-DD'),
count: countValue
})
}
return trends
})(),
// 用户趋势
(async () => {
const trends = []
for (let i = 6; i >= 0; i--) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
const result = await db
.select({ count: count() })
.from(users)
.where(and(gte(users.createdAt, startOfDay), lt(users.createdAt, endOfDay)))
const countValue = result[0].count
trends.push({
date: dayjs(startOfDay).tz(BEIJING_TIMEZONE).format('YYYY-MM-DD'),
count: countValue
})
}
return trends
})(),
// 排期趋势
(async () => {
const trends = []
for (let i = 6; i >= 0; i--) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
const query = db.select({ playDate: schedules.playDate }).from(schedules)
const whereConditions = [
gte(schedules.playDate, startOfDay),
lt(schedules.playDate, endOfDay)
]
if (semester && semester !== 'all') {
query.innerJoin(songs, eq(schedules.songId, songs.id))
whereConditions.push(eq(songs.semester, semester))
}
const schedulesList = await query.where(and(...whereConditions))
// 按北京时间日期去重计算天数
const uniqueDates = new Set(
schedulesList.map((s) => dayjs(s.playDate).tz(BEIJING_TIMEZONE).format('YYYY-MM-DD'))
)
trends.push({
date: dayjs(startOfDay).tz(BEIJING_TIMEZONE).format('YYYY-MM-DD'),
count: uniqueDates.size
})
}
return trends
})(),
// 点歌请求趋势
(async () => {
const trends = []
for (let i = 6; i >= 0; i--) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
const whereConditions = [gte(songs.createdAt, startOfDay), lt(songs.createdAt, endOfDay)]
if (semester && semester !== 'all') {
whereConditions.push(eq(songs.semester, semester))
}
const result = await db
.select({ count: count() })
.from(songs)
.where(and(...whereConditions))
const countValue = result[0].count
trends.push({
date: dayjs(startOfDay).tz(BEIJING_TIMEZONE).format('YYYY-MM-DD'),
count: countValue
})
}
return trends
})()
])
const result = {
totalSongs,
totalUsers,
todaySchedules,
totalSchedules,
weeklyRequests,
songsChange,
usersChange,
requestsChange,
currentSemester: currentSemester?.name || null,
blacklistCount,
// 趋势数据
songsTrend: trendData[0],
usersTrend: trendData[1],
schedulesTrend: trendData[2],
requestsTrend: trendData[3]
}
// 缓存结果
await cacheService.setAdminStats(semester, result)
// 只在Redis可用时显示缓存提示
const { isRedisReady } = await import('../../utils/redis')
if (isRedisReady()) {
console.log('[Cache] 管理员统计数据已缓存')
}
return result
} catch (error) {
console.error('获取统计数据失败:', error)
throw createError({
statusCode: 500,
message: '获取统计数据失败'
})
}
})
+108
View File
@@ -0,0 +1,108 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { songs, users, votes } from '~/drizzle/schema'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const query = getQuery(event)
const semester = query.semester as string
const limit = parseInt(query.limit as string) || 20
try {
// 尝试从缓存获取数据
const cacheService = CacheService.getInstance()
const cachedStats = await cacheService.getActiveUsersStats(semester, limit)
if (cachedStats) {
console.log('[Cache] 活跃用户统计数据缓存命中')
return cachedStats
}
// 构建查询条件 - 如果没有指定学期或学期为'all',查询所有用户
let songWhere = {}
if (semester && semester !== 'all') {
songWhere = { semester: semester }
}
// 获取活跃用户数据 - 有投稿歌曲的用户
const allUsers = await db.select().from(users)
const allSongs = await db.select().from(songs)
const allVotes = await db.select().from(votes)
// 过滤有投稿歌曲的用户
const activeUsers = allUsers
.filter((user) => {
const userSongs = allSongs.filter((song) => {
if (song.requesterId !== user.id) return false
if (semester && semester !== 'all' && song.semester !== semester) return false
return true
})
return userSongs.length > 0
})
.map((user) => {
const userSongs = allSongs
.filter((song) => {
if (song.requesterId !== user.id) return false
if (semester && semester !== 'all' && song.semester !== semester) return false
return true
})
.map((song) => ({
...song,
votes: allVotes.filter((vote) => vote.songId === song.id)
}))
return {
...user,
songs: userSongs
}
})
// 如果没有活跃用户,返回空数组
if (activeUsers.length === 0) {
const emptyResult = []
// 缓存空结果
await cacheService.setActiveUsersStats(semester, limit, emptyResult)
console.log('[Cache] 活跃用户统计数据已缓存(空结果)')
return emptyResult
}
// 计算每个用户的活跃度数据
const userStats = activeUsers.map((user) => {
const contributions = user.songs.length
const likes = user.songs.reduce((total, song) => total + song.votes.length, 0)
// 活跃度计算:投稿数 * 2 + 点赞数 * 0.5
const activityScore = Math.round(contributions * 2 + likes * 0.5)
const userStat = {
id: user.id,
name: user.name || user.username, // 优先显示真实姓名,如果没有则显示登录名
contributions,
likes,
activityScore
}
return userStat
})
// 按活跃度分数降序排列并限制数量
const sortedUsers = userStats.sort((a, b) => b.activityScore - a.activityScore).slice(0, limit)
// 缓存结果
await cacheService.setActiveUsersStats(semester, limit, sortedUsers)
console.log('[Cache] 活跃用户统计数据已缓存')
return sortedUsers
} catch (error) {
// 返回空数组而不是抛出错误,避免前端显示错误
return []
}
})
+191
View File
@@ -0,0 +1,191 @@
import { createError, defineEventHandler } from 'h3'
import { db } from '~/drizzle/db'
import { CacheService } from '../../../services/cacheService'
import { songs, users, votes } from '~/drizzle/schema'
import { count, eq, gte } from 'drizzle-orm'
import { getBeijingHour, getBeijingStartOfDay } from '~/utils/timeUtils'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
try {
// 尝试从缓存获取数据
const cacheService = CacheService.getInstance()
const cachedStats = await cacheService.getRealtimeStats()
if (cachedStats) {
console.log('[Cache] 实时统计数据缓存命中')
return cachedStats
}
// 获取当前时间相关的日期
const now = new Date()
const today = getBeijingStartOfDay() // 使用北京时间的一天开始
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
// 并行获取实时统计数据
const [activeUsersData, todayRequests, popularGenres, peakHours] = await Promise.all([
// 活跃用户数和用户列表 (最近1小时点歌、登录或点赞的用户)
(async () => {
try {
// 获取最近1小时内点歌的用户
const recentSongUsers = await db
.select({
requesterId: songs.requesterId,
requesterName: users.name,
requesterUsername: users.username,
requesterId2: users.id
})
.from(songs)
.innerJoin(users, eq(songs.requesterId, users.id))
.where(gte(songs.createdAt, oneHourAgo))
.groupBy(songs.requesterId, users.id, users.name, users.username)
// 获取最近1小时内登录的用户
const recentLoginUsers = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
.where(gte(users.lastLogin, oneHourAgo))
// 获取最近1小时内点赞过歌曲的用户
const recentVoteUsers = await db
.select({
userId: votes.userId,
userName: users.name,
userUsername: users.username,
userId2: users.id
})
.from(votes)
.innerJoin(users, eq(votes.userId, users.id))
.where(gte(votes.createdAt, oneHourAgo))
.groupBy(votes.userId, users.id, users.name, users.username)
// 合并并去重用户列表
const userMap = new Map()
// 添加点歌用户
recentSongUsers.forEach((song) => {
userMap.set(song.requesterId2, {
id: song.requesterId2,
username: song.requesterUsername,
name: song.requesterName
})
})
// 添加登录用户
recentLoginUsers.forEach((user) => {
userMap.set(user.id, {
id: user.id,
username: user.username,
name: user.name
})
})
// 添加点赞用户
recentVoteUsers.forEach((vote) => {
userMap.set(vote.userId2, {
id: vote.userId2,
username: vote.userUsername,
name: vote.userName
})
})
const activeUsersList = Array.from(userMap.values())
return {
count: activeUsersList.length,
users: activeUsersList
}
} catch (error) {
return {
count: 0,
users: []
}
}
})(),
// 今日点歌数
(async () => {
try {
const todayCountResult = await db
.select({ count: count() })
.from(songs)
.where(gte(songs.createdAt, today))
return todayCountResult[0].count
} catch (error) {
return 0
}
})(),
// 热门流派 (Song模型中没有genre字段,返回空数组)
(async () => {
return []
})(),
// 高峰时段 (最近7天的小时统计)
(async () => {
try {
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const songsData = await db
.select({
createdAt: songs.createdAt
})
.from(songs)
.where(gte(songs.createdAt, sevenDaysAgo))
// 按小时统计
const hourCount = songsData.reduce(
(acc, song) => {
const hour = getBeijingHour(song.createdAt)
acc[hour] = (acc[hour] || 0) + 1
return acc
},
{} as Record<number, number>
)
// 返回前3个高峰时段
return Object.entries(hourCount)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
.map(([hour, count]) => ({
hour: parseInt(hour),
count,
label: `${hour}:00-${parseInt(hour) + 1}:00`
}))
} catch (error) {
return []
}
})()
])
const result = {
activeUsers: activeUsersData.count,
activeUsersList: activeUsersData.users,
todayRequests,
popularGenres,
peakHours,
timestamp: now.toISOString()
}
// 缓存结果
await cacheService.setRealtimeStats(result)
console.log('[Cache] 实时统计数据已缓存')
return result
} catch (error) {
throw createError({
statusCode: 500,
message: '获取实时统计数据失败'
})
}
})
@@ -0,0 +1,71 @@
import { createError, defineEventHandler } from 'h3'
import { and, count, db, eq, exists, schedules, semesters, songs, votes } from '~/drizzle/db'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
try {
// 获取所有学期
const semesterList = await db.select().from(semesters).orderBy(semesters.createdAt)
// 获取每个学期的统计数据
const semesterStats = await Promise.all(
semesterList.map(async (semester) => {
// 统计该学期的歌曲数量
const [totalSongsResult, totalSchedulesResult, totalRequestsResult] = await Promise.all([
db
.select({ count: count() })
.from(songs)
.where(eq(songs.semester, semester.name)),
// 统计该学期的排期数量(通过关联歌曲)
db
.select({ count: count() })
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.where(eq(songs.semester, semester.name)),
// 统计该学期有投票的歌曲数量
db
.select({ count: count() })
.from(songs)
.where(
and(
eq(songs.semester, semester.name),
exists(db.select().from(votes).where(eq(votes.songId, songs.id)))
)
)
])
const [totalSongs, totalSchedules, totalRequests] = [
totalSongsResult[0]?.count || 0,
totalSchedulesResult[0]?.count || 0,
totalRequestsResult[0]?.count || 0
]
return {
semester: semester.name,
totalSongs,
totalSchedules,
totalRequests,
isActive: semester.isActive
}
})
)
return semesterStats
} catch (error) {
console.error('获取学期对比数据失败:', error)
throw createError({
statusCode: 500,
message: '获取学期对比数据失败'
})
}
})
+79
View File
@@ -0,0 +1,79 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { songs, users, votes, songReplayRequests } from '~/drizzle/schema'
import { count, desc, eq, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const query = getQuery(event)
const semester = query.semester as string
const limit = parseInt(query.limit as string) || 10
const sortBy = (query.sortBy as string) || 'vote'
try {
let topSongs
if (sortBy === 'replay') {
// 按重播次数排行
topSongs = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist,
requesterName: users.name,
requesterUsername: users.username,
count: count(songReplayRequests.id)
})
.from(songs)
.leftJoin(users, eq(songs.requesterId, users.id))
.leftJoin(songReplayRequests, eq(songs.id, songReplayRequests.songId))
.where(semester && semester !== 'all' ? eq(songs.semester, semester) : sql`1=1`)
.groupBy(songs.id, songs.title, songs.artist, users.name, users.username)
.orderBy(desc(count(songReplayRequests.id)))
.limit(limit)
} else {
// 按投票数排行(默认)
topSongs = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist,
requesterName: users.name,
requesterUsername: users.username,
count: count(votes.id)
})
.from(songs)
.leftJoin(users, eq(songs.requesterId, users.id))
.leftJoin(votes, eq(songs.id, votes.songId))
.where(semester && semester !== 'all' ? eq(songs.semester, semester) : sql`1=1`)
.groupBy(songs.id, songs.title, songs.artist, users.name, users.username)
.orderBy(desc(count(votes.id)))
.limit(limit)
}
// 格式化数据
const formattedData = topSongs.map((song) => ({
id: song.id,
title: song.title,
artist: song.artist,
count: Number(song.count),
requester: song.requesterName || song.requesterUsername || '未知用户'
}))
return formattedData
} catch (error) {
console.error('获取热门歌曲排行失败:', error)
throw createError({
statusCode: 500,
message: '获取热门歌曲排行失败'
})
}
})
+57
View File
@@ -0,0 +1,57 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { and, count, db, eq, gte, songs } from '~/drizzle/db'
import { sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const query = getQuery(event)
const semester = query.semester as string
try {
// 构建查询条件
const where = semester && semester !== 'all' ? { semester: semester } : {}
// 获取最近30天的歌曲点播趋势
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
// 构建where条件
let whereCondition = gte(songs.createdAt, thirtyDaysAgo)
if (semester && semester !== 'all') {
whereCondition = and(whereCondition, eq(songs.semester, semester))
}
// 使用Drizzle ORM的正确语法进行分组查询
const trendData = await db
.select({
date: sql<string>`DATE(${songs.createdAt})`,
count: count(songs.id)
})
.from(songs)
.where(whereCondition)
.groupBy(sql`DATE(${songs.createdAt})`)
.orderBy(sql`DATE(${songs.createdAt}) ASC`)
// 格式化数据
const formattedData = trendData.map((item) => ({
date: item.date,
count: item.count
}))
return formattedData
} catch (error) {
console.error('获取趋势数据失败:', error)
throw createError({
statusCode: 500,
message: '获取趋势数据失败'
})
}
})
@@ -0,0 +1,77 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { songs, users } from '~/drizzle/schema'
import { count } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const query = getQuery(event)
const semester = query.semester as string
try {
// 构建查询条件
const where = semester && semester !== 'all' ? { semester: semester } : {}
// 获取用户参与度数据
// 1. 获取总用户数
const totalUsersResult = await db.select({ count: count() }).from(users)
const totalUsers = totalUsersResult[0].count
// 2. 获取有请求歌曲的用户数
const allUsers = await db.select().from(users)
const allSongs = await db.select().from(songs)
const activeUsers = allUsers.filter((user) => {
return allSongs.some((song) => {
if (song.requesterId !== user.id) return false
if (semester && semester !== 'all' && song.semester !== semester) return false
return true
})
}).length
// 3. 获取用户请求歌曲的平均数量
const userSongCounts = allUsers.map((user) => {
const songCount = allSongs.filter((song) => song.requesterId === user.id).length
return { ...user, _count: { songs: songCount } }
})
const totalSongRequests = userSongCounts.reduce((sum, user) => sum + user._count.songs, 0)
const averageSongsPerUser = totalUsers > 0 ? totalSongRequests / totalUsers : 0
// 4. 获取最近活跃用户
const oneWeekAgo = new Date()
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)
const recentActiveUsers = allUsers.filter((user) => {
return allSongs.some((song) => {
if (song.requesterId !== user.id) return false
if (semester && semester !== 'all' && song.semester !== semester) return false
if (song.createdAt < oneWeekAgo) return false
return true
})
}).length
return {
totalUsers,
activeUsers,
activeUserPercentage: totalUsers > 0 ? (activeUsers / totalUsers) * 100 : 0,
averageSongsPerUser: parseFloat(averageSongsPerUser.toFixed(2)),
recentActiveUsers,
recentActiveUserPercentage: totalUsers > 0 ? (recentActiveUsers / totalUsers) * 100 : 0
}
} catch (error) {
console.error('获取用户参与度数据失败:', error)
throw createError({
statusCode: 500,
message: '获取用户参与度数据失败'
})
}
})
@@ -0,0 +1,360 @@
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能更新系统设置'
})
}
try {
const body = await readBody(event)
// 验证请求体
const updateData: any = {}
if (body.hideStudentInfo !== undefined) {
if (typeof body.hideStudentInfo !== 'boolean') {
throw createError({
statusCode: 400,
message: 'hideStudentInfo 必须是布尔值'
})
}
updateData.hideStudentInfo = body.hideStudentInfo
}
if (body.enablePlayTimeSelection !== undefined) {
if (typeof body.enablePlayTimeSelection !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enablePlayTimeSelection 必须是布尔值'
})
}
updateData.enablePlayTimeSelection = body.enablePlayTimeSelection
}
if (body.siteTitle !== undefined) {
updateData.siteTitle = body.siteTitle
}
if (body.siteLogoUrl !== undefined) {
updateData.siteLogoUrl = body.siteLogoUrl
}
if (body.schoolLogoHomeUrl !== undefined) {
updateData.schoolLogoHomeUrl = body.schoolLogoHomeUrl
}
if (body.schoolLogoPrintUrl !== undefined) {
updateData.schoolLogoPrintUrl = body.schoolLogoPrintUrl
}
if (body.siteDescription !== undefined) {
updateData.siteDescription = body.siteDescription
}
if (body.siteVersion !== undefined) {
if (body.siteVersion !== null && typeof body.siteVersion !== 'string') {
throw createError({
statusCode: 400,
message: 'siteVersion 必须是字符串或null'
})
}
updateData.siteVersion =
body.siteVersion === null ? null : (body.siteVersion.trim() || null)
}
if (body.submissionGuidelines !== undefined) {
updateData.submissionGuidelines = body.submissionGuidelines
}
if (body.icpNumber !== undefined) {
updateData.icpNumber = body.icpNumber
}
if (body.gonganNumber !== undefined) {
updateData.gonganNumber = body.gonganNumber
}
if (body.enableSubmissionLimit !== undefined) {
if (typeof body.enableSubmissionLimit !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableSubmissionLimit 必须是布尔值'
})
}
updateData.enableSubmissionLimit = body.enableSubmissionLimit
}
if (body.dailySubmissionLimit !== undefined) {
if (
body.dailySubmissionLimit !== null &&
(!Number.isInteger(body.dailySubmissionLimit) || body.dailySubmissionLimit < 0)
) {
throw createError({
statusCode: 400,
message: 'dailySubmissionLimit 必须是非负整数或null'
})
}
updateData.dailySubmissionLimit = body.dailySubmissionLimit
}
if (body.weeklySubmissionLimit !== undefined) {
if (
body.weeklySubmissionLimit !== null &&
(!Number.isInteger(body.weeklySubmissionLimit) || body.weeklySubmissionLimit < 0)
) {
throw createError({
statusCode: 400,
message: 'weeklySubmissionLimit 必须是非负整数或null'
})
}
updateData.weeklySubmissionLimit = body.weeklySubmissionLimit
}
if (body.monthlySubmissionLimit !== undefined) {
if (
body.monthlySubmissionLimit !== null &&
(!Number.isInteger(body.monthlySubmissionLimit) || body.monthlySubmissionLimit < 0)
) {
throw createError({
statusCode: 400,
message: 'monthlySubmissionLimit 必须是非负整数或null'
})
}
updateData.monthlySubmissionLimit = body.monthlySubmissionLimit
}
if (body.showBlacklistKeywords !== undefined) {
if (typeof body.showBlacklistKeywords !== 'boolean') {
throw createError({
statusCode: 400,
message: 'showBlacklistKeywords 必须是布尔值'
})
}
updateData.showBlacklistKeywords = body.showBlacklistKeywords
}
if (body.enableReplayRequests !== undefined) {
if (typeof body.enableReplayRequests !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableReplayRequests 必须是布尔值'
})
}
updateData.enableReplayRequests = body.enableReplayRequests
}
if (body.enableCollaborativeSubmission !== undefined) {
if (typeof body.enableCollaborativeSubmission !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableCollaborativeSubmission 必须是布尔值'
})
}
updateData.enableCollaborativeSubmission = body.enableCollaborativeSubmission
}
if (body.enableSubmissionRemarks !== undefined) {
if (typeof body.enableSubmissionRemarks !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableSubmissionRemarks 必须是布尔值'
})
}
updateData.enableSubmissionRemarks = body.enableSubmissionRemarks
}
if (body.enableRequestTimeLimitation !== undefined) {
if (typeof body.enableRequestTimeLimitation !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableRequestTimeLimitation 必须是布尔值'
})
}
updateData.enableRequestTimeLimitation = body.enableRequestTimeLimitation
}
if (body.forceBlockAllRequests !== undefined) {
if (typeof body.forceBlockAllRequests !== 'boolean') {
throw createError({
statusCode: 400,
message: 'forceBlockAllRequests 必须是布尔值'
})
}
updateData.forceBlockAllRequests = body.forceBlockAllRequests
}
// SMTP配置字段
if (body.smtpEnabled !== undefined) {
if (typeof body.smtpEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'smtpEnabled 必须是布尔值'
})
}
updateData.smtpEnabled = body.smtpEnabled
}
if (body.smtpHost !== undefined) {
updateData.smtpHost = body.smtpHost
}
if (body.smtpPort !== undefined) {
if (
body.smtpPort !== null &&
(!Number.isInteger(body.smtpPort) || body.smtpPort < 1 || body.smtpPort > 65535)
) {
throw createError({
statusCode: 400,
message: 'smtpPort 必须是1-65535之间的整数'
})
}
updateData.smtpPort = body.smtpPort
}
if (body.smtpSecure !== undefined) {
if (typeof body.smtpSecure !== 'boolean') {
throw createError({
statusCode: 400,
message: 'smtpSecure 必须是布尔值'
})
}
updateData.smtpSecure = body.smtpSecure
}
if (body.smtpUsername !== undefined) {
updateData.smtpUsername = body.smtpUsername
}
if (body.smtpPassword !== undefined && body.smtpPassword !== '****************') {
updateData.smtpPassword = body.smtpPassword
}
if (body.smtpFromEmail !== undefined) {
updateData.smtpFromEmail = body.smtpFromEmail
}
if (body.smtpFromName !== undefined) {
updateData.smtpFromName = body.smtpFromName
}
// 验证每日、每周和每月限额三选一逻辑
const limitSettings = [
body.dailySubmissionLimit,
body.weeklySubmissionLimit,
body.monthlySubmissionLimit
].filter((limit) => limit !== undefined && limit !== null)
if (body.enableSubmissionLimit && limitSettings.length > 1) {
throw createError({
statusCode: 400,
message: '每日限额、每周限额和每月限额只能选择其中一种,其他必须设置为空'
})
}
// 获取当前设置,如果不存在则创建
const settingsResult = await db.select().from(systemSettings).limit(1)
let settings = settingsResult[0] || null
if (!settings) {
// 如果不存在,创建新设置
const newSettingsResult = await db
.insert(systemSettings)
.values({
hideStudentInfo: updateData.hideStudentInfo ?? false,
enablePlayTimeSelection: updateData.enablePlayTimeSelection ?? false,
siteTitle: updateData.siteTitle ?? 'VoiceHub',
siteLogoUrl: updateData.siteLogoUrl ?? '/favicon.ico',
schoolLogoHomeUrl: updateData.schoolLogoHomeUrl ?? null,
schoolLogoPrintUrl: updateData.schoolLogoPrintUrl ?? null,
siteDescription: updateData.siteDescription ?? '校园广播站点歌系统 - 让你的声音被听见',
siteVersion: updateData.siteVersion ?? null,
submissionGuidelines:
updateData.submissionGuidelines ??
`1. 投稿时无需加入书名号
2. 除DJ外,其他类型歌曲均接收(包括小语种)
3. 禁止投递含有违规内容的歌曲
4. 点播的歌曲将由管理员进行审核
5. 审核通过后将安排在播放时段播出
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
8. 最终解释权归广播站所有`,
icpNumber: updateData.icpNumber ?? null,
gonganNumber: updateData.gonganNumber ?? null,
enableSubmissionLimit: updateData.enableSubmissionLimit ?? false,
dailySubmissionLimit: updateData.dailySubmissionLimit ?? null,
weeklySubmissionLimit: updateData.weeklySubmissionLimit ?? null,
monthlySubmissionLimit: updateData.monthlySubmissionLimit ?? null,
showBlacklistKeywords: updateData.showBlacklistKeywords ?? false,
enableReplayRequests: updateData.enableReplayRequests ?? false,
enableCollaborativeSubmission: updateData.enableCollaborativeSubmission ?? true,
enableSubmissionRemarks: updateData.enableSubmissionRemarks ?? false,
enableRequestTimeLimitation: updateData.enableRequestTimeLimitation ?? false,
requestTimeLimitation: updateData.requestTimeLimitation ?? null,
forceBlockAllRequests: updateData.forceBlockAllRequests ?? false,
smtpEnabled: updateData.smtpEnabled ?? false,
smtpHost: updateData.smtpHost ?? null,
smtpPort: updateData.smtpPort ?? 587,
smtpSecure: updateData.smtpSecure ?? false,
smtpUsername: updateData.smtpUsername ?? null,
smtpPassword: updateData.smtpPassword ?? null,
smtpFromEmail: updateData.smtpFromEmail ?? null,
smtpFromName: updateData.smtpFromName ?? '校园广播站'
})
.returning()
settings = newSettingsResult[0]
} else {
// 如果存在,更新设置
const updatedSettingsResult = await db
.update(systemSettings)
.set(updateData)
.where(eq(systemSettings.id, settings.id))
.returning()
settings = updatedSettingsResult[0]
}
// 清除系统设置缓存
try {
const { CacheService } = await import('~~/server/services/cacheService')
await CacheService.getInstance().clearSystemSettingsCache()
console.log('[Cache] 系统设置缓存已清除(更新系统设置)')
} catch (cacheError) {
console.warn('清除系统设置缓存失败:', cacheError)
}
try {
const { SmtpService } = await import('~~/server/services/smtpService')
await SmtpService.getInstance().initializeSmtpConfig()
console.log('[SMTP] SMTP配置已重新加载(更新系统设置)')
} catch (smtpError) {
console.warn('[SMTP] SMTP配置重载失败:', smtpError)
}
return settings
} catch (error) {
console.error('更新系统设置失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '更新系统设置失败'
})
}
})
+87
View File
@@ -0,0 +1,87 @@
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能查看系统设置'
})
}
try {
// 尝试从缓存获取系统设置
const cacheService = CacheService.getInstance()
let settings = await cacheService.getSystemSettings()
if (settings) {
return settings
}
// 缓存中没有,从数据库获取系统设置,如果不存在则创建默认设置
const settingsResult = await db.select().from(systemSettings).limit(1)
settings = settingsResult[0] || null
if (!settings) {
const newSettingsResult = await db
.insert(systemSettings)
.values({
enablePlayTimeSelection: false,
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || 'VoiceHub',
siteLogoUrl: process.env.NUXT_PUBLIC_SITE_LOGO || '/images/site-logo.png',
schoolLogoHomeUrl: '/images/school-logo-white.png',
schoolLogoPrintUrl: '/images/school-logo-transparent.png',
siteDescription:
process.env.NUXT_PUBLIC_SITE_DESCRIPTION || '校园广播站点歌系统 - 让你的声音被听见',
siteVersion: process.env.NUXT_PUBLIC_SITE_VERSION || null,
submissionGuidelines: '请遵守校园规定,提交健康向上的歌曲。',
icpNumber: null,
gonganNumber: null,
enableSubmissionLimit: false,
dailySubmissionLimit: null,
weeklySubmissionLimit: null,
monthlySubmissionLimit: null,
showBlacklistKeywords: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
enableRequestTimeLimitation: false,
forceBlockAllRequests: false,
enableReplayRequests: false,
smtpEnabled: false
})
.returning()
settings = newSettingsResult[0]
}
// 将设置存入缓存
try {
await cacheService.setSystemSettings(settings)
} catch (cacheError) {
console.warn('缓存系统设置失败:', cacheError)
}
// 隐藏敏感字段
if (settings.smtpPassword) {
settings.smtpPassword = '****************'
}
return settings
} catch (error) {
console.error('获取系统设置失败:', error)
throw createError({
statusCode: 500,
message: '获取系统设置失败'
})
}
})
+92
View File
@@ -0,0 +1,92 @@
import { createError, defineEventHandler, getRouterParam } from 'h3'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
try {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '没有权限访问'
})
}
const userId = getRouterParam(event, 'id')
// 检查用户是否存在
const existingUserResult = await db
.select()
.from(users)
.where(eq(users.id, parseInt(userId)))
.limit(1)
const existingUser = existingUserResult[0]
if (!existingUser) {
throw createError({
statusCode: 404,
statusMessage: '用户不存在'
})
}
// 1. 绝对禁止删除 ID 为 1 的用户 (系统初始超级管理员)
if (existingUser.id === 1) {
throw createError({
statusCode: 403,
statusMessage: '无法删除系统初始超级管理员'
})
}
// 2. 禁止删除自己 (增强类型安全)
// 使用 String 转换确保 ID 比较的准确性
if (String(existingUser.id) === String(user.id)) {
throw createError({
statusCode: 400,
statusMessage: '不能删除自己的账户'
})
}
// 3. 越级删除保护
// 如果目标用户是 SUPER_ADMIN,操作者必须是 SUPER_ADMIN
if (existingUser.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '权限不足:普通管理员无法删除超级管理员'
})
}
// 删除用户
await db.delete(users).where(eq(users.id, parseInt(userId)))
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('songs:*')
await cache.deletePattern('schedules:*')
await cache.deletePattern('stats:*')
// 清除该用户的认证缓存
await cache.delete(`auth:user:${existingUser.id}`)
console.log('[Cache] 歌曲、排期、统计和用户认证缓存已清除(用户删除)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
message: '用户删除成功'
}
} catch (error) {
console.error('删除用户失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: '删除用户失败: ' + error.message
})
}
})
+214
View File
@@ -0,0 +1,214 @@
import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { updateUserPassword } from '~~/server/services/userService'
export default defineEventHandler(async (event) => {
try {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '没有权限访问'
})
}
const userId = getRouterParam(event, 'id')
const body = await readBody(event)
const { name, username, password, role, grade, class: userClass, status } = body
// 验证必填字段
if (!name || !username) {
throw createError({
statusCode: 400,
statusMessage: '姓名和用户名为必填项'
})
}
// 验证姓名和用户名只能包含汉字
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(name) || !chineseRegex.test(username)) {
throw createError({
statusCode: 400,
statusMessage: '姓名和用户名只能包含汉字'
})
}
// 检查用户是否存在
const existingUser = await db
.select()
.from(users)
.where(eq(users.id, parseInt(userId)))
.limit(1)
if (existingUser.length === 0) {
throw createError({
statusCode: 404,
statusMessage: '用户不存在'
})
}
const targetUser = existingUser[0]
// 1. 禁止修改系统初始超级管理员 (ID: 1)
if (targetUser.id === 1) {
throw createError({
statusCode: 403,
statusMessage: '无法修改系统初始超级管理员'
})
}
// 2. 禁止对自身进行任何用户管理操作(包括角色、资料、状态、密码等)
// 使用 String 转换确保 ID 比较的准确性
if (String(userId) === String(user.id)) {
throw createError({
statusCode: 400,
statusMessage: '禁止在用户管理中修改自己的账户'
})
}
// 3. 越级修改保护
// 如果目标用户是 SUPER_ADMIN,操作者必须是 SUPER_ADMIN
if (targetUser.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '权限不足:普通管理员无法修改超级管理员信息'
})
}
// 检查用户名是否被其他用户使用
if (username !== targetUser.username) {
const duplicateUser = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1)
if (duplicateUser.length > 0) {
throw createError({
statusCode: 400,
statusMessage: '用户名已被其他用户使用'
})
}
}
// 角色权限控制
let validRole = 'USER'
if (role && ['USER', 'TEACHER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(role)) {
// 超级管理员可以设置任何角色
if (user.role === 'SUPER_ADMIN') {
validRole = role
}
// 管理员只能设置管理员以下的角色(USER, SONG_ADMIN)
else if (user.role === 'ADMIN') {
if (['USER', 'TEACHER', 'SONG_ADMIN'].includes(role)) {
validRole = role
} else {
throw createError({
statusCode: 403,
statusMessage: '管理员只能设置用户、教师和歌曲管理员角色'
})
}
}
// 其他角色不能设置角色
else {
throw createError({
statusCode: 403,
statusMessage: '没有权限设置用户角色'
})
}
}
// 验证status字段的有效性
if (status && !['active', 'withdrawn', 'graduate'].includes(status)) {
throw createError({
statusCode: 400,
statusMessage: '用户状态只能是 active, withdrawn 或 graduate'
})
}
// 准备更新数据
const updateData = {
name,
username,
role: validRole,
grade,
class: userClass
}
// 如果提供了status,则更新状态相关字段
if (status && status !== existingUser[0].status) {
updateData.status = status
updateData.statusChangedAt = new Date()
updateData.statusChangedBy = user.id
}
// 如果提供了密码,则使用统一服务更新密码
if (password) {
await updateUserPassword(parseInt(userId), password, true)
}
// 更新用户其他信息
const updatedUser = await db
.update(users)
.set(updateData)
.where(eq(users.id, parseInt(userId)))
.returning({
id: users.id,
name: users.name,
username: users.username,
role: users.role,
grade: users.grade,
class: users.class,
status: users.status,
statusChangedAt: users.statusChangedAt,
statusChangedBy: users.statusChangedBy,
lastLogin: users.lastLogin,
lastLoginIp: users.lastLoginIp,
passwordChangedAt: users.passwordChangedAt,
createdAt: users.createdAt,
updatedAt: users.updatedAt
})
// 如果状态发生变更,记录到状态变更日志
if (status && status !== existingUser[0].status) {
await db.insert(userStatusLogs).values({
userId: parseInt(userId),
oldStatus: existingUser[0].status,
newStatus: status,
reason: `管理员${user.name || user.username}修改用户状态`,
operatorId: user.id
})
}
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('songs:*')
// 清除该用户的认证缓存(角色或权限可能已变更)
await cache.delete(`auth:user:${parseInt(userId)}`)
console.log('[Cache] 歌曲和用户认证缓存已清除(用户更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
user: updatedUser[0],
message: '用户更新成功'
}
} catch (error) {
console.error('更新用户失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: '更新用户失败: ' + error.message
})
}
})
+122
View File
@@ -0,0 +1,122 @@
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
// 安全获取ID参数
const params = event.context.params || {}
const id = parseInt(params.id || '')
if (isNaN(id)) {
throw createError({
statusCode: 400,
message: '无效的用户ID'
})
}
// 处理PUT请求 - 更新用户
if (event.method === 'PUT') {
const body = await readBody(event)
if (!body.name) {
throw createError({
statusCode: 400,
message: '姓名不能为空'
})
}
try {
const updatedUserResult = await db
.update(users)
.set({
name: body.name,
role: body.role,
grade: body.grade,
class: body.class
})
.where(eq(users.id, id))
.returning({
id: users.id,
name: users.name,
role: users.role,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
grade: users.grade,
class: users.class,
username: users.username,
passwordChangedAt: users.passwordChangedAt
})
const updatedUser = updatedUserResult[0]
return updatedUser
} catch (error) {
console.error('更新用户失败:', error)
throw createError({
statusCode: 500,
message: '更新用户失败'
})
}
}
// 处理DELETE请求 - 删除用户
if (event.method === 'DELETE') {
try {
const existingUserResult = await db.select().from(users).where(eq(users.id, id)).limit(1)
if (existingUserResult.length === 0) {
throw createError({
statusCode: 404,
message: '用户不存在'
})
}
const existingUser = existingUserResult[0]
// 1. 禁止删除 ID 为 1 的用户 (系统初始超级管理员)
if (existingUser.id === 1) {
throw createError({
statusCode: 403,
message: '无法删除系统初始超级管理员'
})
}
// 2. 确保不能删除自己
if (String(existingUser.id) === String(user.id)) {
throw createError({
statusCode: 400,
message: '不能删除当前登录的用户'
})
}
// 3. 越级删除保护
if (existingUser.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '权限不足:普通管理员无法删除超级管理员'
})
}
await db.delete(users).where(eq(users.id, id))
return { success: true }
} catch (error) {
console.error('删除用户失败:', error)
throw createError({
statusCode: 500,
message: '删除用户失败'
})
}
}
throw createError({
statusCode: 405,
message: '不支持的请求方法'
})
})
@@ -0,0 +1,56 @@
import { updateUserPassword } from '~~/server/services/userService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
// 安全获取ID参数
const params = event.context.params || {}
const id = parseInt(params.id || '')
if (isNaN(id)) {
throw createError({
statusCode: 400,
message: '无效的用户ID'
})
}
const body = await readBody(event)
if (!body.newPassword) {
throw createError({
statusCode: 400,
message: '新密码不能为空'
})
}
try {
// 禁止对自身进行密码重置(通过用户管理界面)
if (id === user.id) {
throw createError({
statusCode: 400,
message: '禁止在用户管理中重置自己的密码'
})
}
// 使用统一服务重置密码 (forceReset = true)
await updateUserPassword(id, body.newPassword, true)
return {
success: true,
message: '密码重置成功'
}
} catch (error) {
console.error('重置密码失败:', error)
throw createError({
statusCode: 500,
message: '重置密码失败'
})
}
})
+272
View File
@@ -0,0 +1,272 @@
import { db } from '~/drizzle/db'
import { schedules, songReplayRequests, songs, users, votes } from '~/drizzle/schema'
import { and, count, desc, eq, inArray } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
try {
// 使用认证中间件提供的用户信息
const currentUser = event.context.user
if (!currentUser) {
throw createError({
statusCode: 401,
statusMessage: '未授权访问'
})
}
// 检查是否为管理员、歌曲管理员或超级管理员
const allowedRoles = ['ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN']
if (!allowedRoles.includes(currentUser.role)) {
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
// 获取用户ID
const userId = parseInt(getRouterParam(event, 'id') as string)
if (!userId || isNaN(userId)) {
throw createError({
statusCode: 400,
statusMessage: '无效的用户ID'
})
}
// 验证用户是否存在
const userResult = await db
.select({
id: users.id,
name: users.name,
username: users.username,
grade: users.grade,
class: users.class
})
.from(users)
.where(eq(users.id, userId))
.limit(1)
const user = userResult[0]
if (!user) {
throw createError({
statusCode: 404,
statusMessage: '用户不存在'
})
}
// 获取用户投稿的歌曲
const submittedSongs = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist,
semester: songs.semester,
createdAt: songs.createdAt,
played: songs.played
})
.from(songs)
.where(eq(songs.requesterId, userId))
.orderBy(desc(songs.createdAt))
// 获取投稿歌曲的投票数
const submittedSongIds = submittedSongs.map((song) => song.id)
const submittedVoteCountsMap = new Map()
if (submittedSongIds.length > 0) {
const submittedVoteCountsResult = await db
.select({
songId: votes.songId,
voteCount: count(votes.id)
})
.from(votes)
.where(inArray(votes.songId, submittedSongIds))
.groupBy(votes.songId)
submittedVoteCountsResult.forEach((item) => {
submittedVoteCountsMap.set(item.songId, item.voteCount)
})
}
// 获取用户投票的歌曲
const votedSongs = await db
.select({
id: votes.id,
createdAt: votes.createdAt,
songId: votes.songId,
songTitle: songs.title,
songArtist: songs.artist,
songSemester: songs.semester,
songPlayed: songs.played,
requesterName: users.name,
requesterGrade: users.grade,
requesterClass: users.class
})
.from(votes)
.leftJoin(songs, eq(votes.songId, songs.id))
.leftJoin(users, eq(songs.requesterId, users.id))
.where(eq(votes.userId, userId))
.orderBy(desc(votes.createdAt))
// 获取投票歌曲的投票数
const votedSongIds = votedSongs.map((vote) => vote.songId).filter((id) => id !== null)
const votedVoteCountsMap = new Map()
if (votedSongIds.length > 0) {
const votedVoteCountsResult = await db
.select({
songId: votes.songId,
voteCount: count(votes.id)
})
.from(votes)
.where(inArray(votes.songId, votedSongIds))
.groupBy(votes.songId)
votedVoteCountsResult.forEach((item) => {
votedVoteCountsMap.set(item.songId, item.voteCount)
})
}
// 获取投稿歌曲的排期状态
// 只查询已发布的排期,草稿不算作已排期
const submittedScheduleMap = new Map()
if (submittedSongIds.length > 0) {
const submittedScheduleResult = await db
.select({
songId: schedules.songId
})
.from(schedules)
.where(and(inArray(schedules.songId, submittedSongIds), eq(schedules.isDraft, false)))
submittedScheduleResult.forEach((item) => {
submittedScheduleMap.set(item.songId, true)
})
}
// 获取投票歌曲的排期状态
// 只查询已发布的排期,草稿不算作已排期
const votedScheduleMap = new Map()
if (votedSongIds.length > 0) {
const votedScheduleResult = await db
.select({
songId: schedules.songId
})
.from(schedules)
.where(and(inArray(schedules.songId, votedSongIds), eq(schedules.isDraft, false)))
votedScheduleResult.forEach((item) => {
votedScheduleMap.set(item.songId, true)
})
}
// 获取用户申请重播的歌曲
const replayRequestedSongs = await db
.select({
id: songReplayRequests.id,
createdAt: songReplayRequests.createdAt,
songId: songReplayRequests.songId,
songTitle: songs.title,
songArtist: songs.artist,
songSemester: songs.semester,
songPlayed: songs.played,
requesterName: users.name,
requesterGrade: users.grade,
requesterClass: users.class
})
.from(songReplayRequests)
.leftJoin(songs, eq(songReplayRequests.songId, songs.id))
.leftJoin(users, eq(songs.requesterId, users.id))
.where(eq(songReplayRequests.userId, userId))
.orderBy(desc(songReplayRequests.createdAt))
// 获取重播申请歌曲的总申请数
const replaySongIds = replayRequestedSongs.map((req) => req.songId).filter((id) => id !== null)
const replayRequestCountsMap = new Map()
if (replaySongIds.length > 0) {
const replayRequestCountsResult = await db
.select({
songId: songReplayRequests.songId,
requestCount: count(songReplayRequests.id)
})
.from(songReplayRequests)
.where(inArray(songReplayRequests.songId, replaySongIds))
.groupBy(songReplayRequests.songId)
replayRequestCountsResult.forEach((item) => {
replayRequestCountsMap.set(item.songId, item.requestCount)
})
}
// 获取重播申请歌曲的排期状态
const replayScheduleMap = new Map()
if (replaySongIds.length > 0) {
const replayScheduleResult = await db
.select({
songId: schedules.songId
})
.from(schedules)
.where(and(inArray(schedules.songId, replaySongIds), eq(schedules.isDraft, false)))
replayScheduleResult.forEach((item) => {
replayScheduleMap.set(item.songId, true)
})
}
return {
user: {
id: user.id,
name: user.name,
username: user.username,
grade: user.grade,
class: user.class
},
submittedSongs: submittedSongs.map((song) => ({
id: song.id,
title: song.title,
artist: song.artist,
semester: song.semester,
createdAt: song.createdAt,
played: song.played,
scheduled: submittedScheduleMap.has(song.id),
voteCount: submittedVoteCountsMap.get(song.id) || 0
})),
votedSongs: votedSongs.map((vote) => ({
id: vote.songId,
title: vote.songTitle,
artist: vote.songArtist,
semester: vote.songSemester,
played: vote.songPlayed,
scheduled: votedScheduleMap.has(vote.songId),
voteCount: votedVoteCountsMap.get(vote.songId) || 0,
votedAt: vote.createdAt,
requester: {
name: vote.requesterName,
grade: vote.requesterGrade,
class: vote.requesterClass
}
})),
replayRequestedSongs: replayRequestedSongs.map((req) => ({
id: req.songId,
title: req.songTitle,
artist: req.songArtist,
semester: req.songSemester,
played: req.songPlayed,
scheduled: replayScheduleMap.has(req.songId),
requestCount: replayRequestCountsMap.get(req.songId) || 0,
requestedAt: req.createdAt,
requester: {
name: req.requesterName,
grade: req.requesterGrade,
class: req.requesterClass
}
}))
}
} catch (error) {
console.error('获取用户歌曲信息失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: '服务器内部错误'
})
}
})
@@ -0,0 +1,123 @@
import { createError, defineEventHandler, getQuery, getRouterParam } from 'h3'
import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { count, desc, eq } from 'drizzle-orm'
import { getStatusText } from '~~/server/utils/user'
export default defineEventHandler(async (event) => {
try {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '没有权限访问'
})
}
const userId = getRouterParam(event, 'id')
const query = getQuery(event)
const { page = '1', limit = '20' } = query
// 验证用户ID
const numUserId = parseInt(userId)
if (isNaN(numUserId) || numUserId <= 0) {
throw createError({
statusCode: 400,
statusMessage: '无效的用户ID'
})
}
// 检查用户是否存在
const existingUser = await db
.select({
id: users.id,
name: users.name,
username: users.username
})
.from(users)
.where(eq(users.id, numUserId))
.limit(1)
if (existingUser.length === 0) {
throw createError({
statusCode: 404,
statusMessage: '用户不存在'
})
}
// 分页参数
const pageNum = Math.max(1, parseInt(page as string) || 1)
const limitNum = Math.max(1, parseInt(limit as string) || 20)
const skip = (pageNum - 1) * limitNum
// 获取总数
const totalResult = await db
.select({ count: count() })
.from(userStatusLogs)
.where(eq(userStatusLogs.userId, numUserId))
const total = totalResult[0].count
// 获取状态变更日志列表
const logs = await db
.select({
id: userStatusLogs.id,
oldStatus: userStatusLogs.oldStatus,
newStatus: userStatusLogs.newStatus,
reason: userStatusLogs.reason,
createdAt: userStatusLogs.createdAt,
operatorId: userStatusLogs.operatorId,
operatorName: users.name,
operatorUsername: users.username
})
.from(userStatusLogs)
.leftJoin(users, eq(userStatusLogs.operatorId, users.id))
.where(eq(userStatusLogs.userId, numUserId))
.orderBy(desc(userStatusLogs.createdAt))
.limit(limitNum)
.offset(skip)
// 计算分页信息
const totalPages = Math.ceil(total / limitNum)
const hasNextPage = pageNum < totalPages
const hasPrevPage = pageNum > 1
return {
success: true,
user: existingUser[0],
logs: logs.map((log) => ({
id: log.id,
oldStatus: log.oldStatus,
newStatus: log.newStatus,
oldStatusDisplay: getStatusText(log.oldStatus || ''),
newStatusDisplay: getStatusText(log.newStatus || ''),
reason: log.reason,
createdAt: log.createdAt,
operator: {
id: log.operatorId,
name: log.operatorName || '未知操作员',
username: log.operatorUsername || 'unknown'
}
})),
pagination: {
total,
page: pageNum,
limit: limitNum,
totalPages,
hasNextPage,
hasPrevPage
}
}
} catch (error) {
console.error('获取用户状态变更日志失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: '获取用户状态变更日志失败: ' + error.message
})
}
})
+134
View File
@@ -0,0 +1,134 @@
import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { getStatusText } from '~~/server/utils/user'
export default defineEventHandler(async (event) => {
try {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '没有权限访问'
})
}
const userId = getRouterParam(event, 'id')
const body = await readBody(event)
const { status, reason } = body
// 验证必填字段
if (!status || !['active', 'withdrawn', 'graduate'].includes(status)) {
throw createError({
statusCode: 400,
statusMessage: '状态必须为 active, withdrawn 或 graduate'
})
}
if (!reason || reason.trim().length === 0) {
throw createError({
statusCode: 400,
statusMessage: '变更原因为必填项'
})
}
// 检查用户是否存在
const existingUser = await db
.select({
id: users.id,
name: users.name,
username: users.username,
status: users.status,
role: users.role
})
.from(users)
.where(eq(users.id, parseInt(userId)))
.limit(1)
if (existingUser.length === 0) {
throw createError({
statusCode: 404,
statusMessage: '用户不存在'
})
}
const targetUser = existingUser[0]
// 检查是否为学生用户
if (targetUser.role !== 'USER') {
throw createError({
statusCode: 400,
statusMessage: '只能修改学生用户的状态'
})
}
// 检查状态是否有变化
if (targetUser.status === status) {
throw createError({
statusCode: 400,
statusMessage: '用户状态未发生变化'
})
}
const currentTime = getBeijingTime()
const oldStatus = targetUser.status
// 开始事务
await db.transaction(async (tx) => {
// 更新用户状态
await tx
.update(users)
.set({
status: status,
statusChangedAt: currentTime,
statusChangedBy: user.id
})
.where(eq(users.id, parseInt(userId)))
// 记录状态变更日志
await tx.insert(userStatusLogs).values({
userId: parseInt(userId),
oldStatus: oldStatus,
newStatus: status,
reason: reason.trim(),
operatorId: user.id,
createdAt: currentTime
})
})
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.delete(`auth:user:${parseInt(userId)}`)
console.log('[Cache] 用户认证缓存已清除(状态更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
message: `用户状态已更新为${getStatusText(status)}`,
data: {
userId: parseInt(userId),
oldStatus,
newStatus: status,
changedAt: currentTime,
changedBy: user.name
}
}
} catch (error) {
console.error('更新用户状态失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: '更新用户状态失败: ' + error.message
})
}
})
@@ -0,0 +1,155 @@
import { db } from '~/drizzle/db'
import { CacheService } from '~~/server/services/cacheService'
import { users } from '~/drizzle/schema'
import { inArray } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
try {
// 验证请求方法
if (event.node.req.method !== 'POST') {
throw createError({
statusCode: 405,
statusMessage: 'Method Not Allowed'
})
}
// 获取请求体
const body = await readBody(event)
const { userIds, targetGrade, keepClass } = body
// 验证必填字段
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'userIds is required and must be a non-empty array'
})
}
if (!targetGrade || typeof targetGrade !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'targetGrade is required and must be a string'
})
}
if (typeof keepClass !== 'boolean') {
throw createError({
statusCode: 400,
statusMessage: 'keepClass is required and must be a boolean'
})
}
// 使用认证中间件提供的用户信息
const currentUser = event.context.user
if (!currentUser) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
// 检查权限 - 只有管理员和超级管理员可以执行批量更新
if (!['ADMIN', 'SUPER_ADMIN'].includes(currentUser.role)) {
throw createError({
statusCode: 403,
statusMessage: 'Insufficient permissions'
})
}
// 验证用户ID是否存在
const existingUsers = await db
.select({
id: users.id,
name: users.name,
username: users.username,
grade: users.grade,
class: users.class
})
.from(users)
.where(inArray(users.id, userIds))
const existingUserIds = existingUsers.map((user) => user.id)
const nonExistentUserIds = userIds.filter((id) => !existingUserIds.includes(id))
let updated = 0
let failed = 0
const errors: string[] = []
// 记录不存在的用户ID
if (nonExistentUserIds.length > 0) {
failed += nonExistentUserIds.length
errors.push(`用户ID ${nonExistentUserIds.join(', ')} 不存在`)
}
// 批量更新存在的用户
if (existingUserIds.length > 0) {
try {
const updateData: any = {
grade: targetGrade,
updatedAt: new Date()
}
// 如果不保持班级不变,可以在这里添加班级更新逻辑
// 当前实现中keepClass固定为true,所以不更新班级
const updateResult = await db
.update(users)
.set(updateData)
.where(inArray(users.id, existingUserIds))
.returning({ id: users.id })
updated = updateResult.length
} catch (error) {
console.error('批量更新失败:', error)
failed += existingUserIds.length
errors.push('批量更新操作失败')
}
}
// 如果有用户更新成功,清除相关缓存
if (updated > 0) {
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('批量年级更新后缓存清除成功')
} catch (cacheError) {
console.error('批量年级更新后缓存清除失败:', cacheError)
}
}
// 记录操作日志(可选,如果需要的话)
try {
// 这里可以添加操作日志记录
// 由于不修改数据库结构,暂时跳过日志记录
} catch (logError) {
console.error('记录操作日志失败:', logError)
// 日志记录失败不影响主要操作
}
return {
success: errors.length === 0 || updated > 0,
updated,
failed,
errors,
details: {
totalRequested: userIds.length,
existingUsers: existingUsers.length,
nonExistentUsers: nonExistentUserIds.length,
targetGrade,
keepClass
}
}
} catch (error) {
console.error('批量年级更新API错误:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error'
})
}
})
+158
View File
@@ -0,0 +1,158 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { and, eq, inArray } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { getStatusText } from '~~/server/utils/user'
export default defineEventHandler(async (event) => {
try {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '没有权限访问'
})
}
const body = await readBody(event)
const { userIds, status, reason } = body
// 验证必填字段
if (!Array.isArray(userIds) || userIds.length === 0) {
throw createError({
statusCode: 400,
statusMessage: '用户ID列表不能为空'
})
}
if (!status || !['active', 'withdrawn', 'graduate'].includes(status)) {
throw createError({
statusCode: 400,
statusMessage: '状态必须为 active, withdrawn 或 graduate'
})
}
if (!reason || reason.trim().length === 0) {
throw createError({
statusCode: 400,
statusMessage: '变更原因为必填项'
})
}
// 验证用户ID格式
const validUserIds = userIds
.filter((id) => {
const numId = parseInt(id)
return !isNaN(numId) && numId > 0
})
.map((id) => parseInt(id))
if (validUserIds.length === 0) {
throw createError({
statusCode: 400,
statusMessage: '没有有效的用户ID'
})
}
// 检查用户是否存在且为学生角色
const existingUsers = await db
.select({
id: users.id,
name: users.name,
username: users.username,
status: users.status,
role: users.role
})
.from(users)
.where(and(inArray(users.id, validUserIds), eq(users.role, 'USER')))
if (existingUsers.length === 0) {
throw createError({
statusCode: 404,
statusMessage: '没有找到可更新的学生用户'
})
}
// 筛选出状态需要变更的用户
const usersToUpdate = existingUsers.filter((u) => u.status !== status)
if (usersToUpdate.length === 0) {
throw createError({
statusCode: 400,
statusMessage: '所选用户的状态均无需变更'
})
}
const currentTime = getBeijingTime()
const results = []
// 开始事务
await db.transaction(async (tx) => {
for (const targetUser of usersToUpdate) {
// 更新用户状态
await tx
.update(users)
.set({
status: status,
statusChangedAt: currentTime,
statusChangedBy: user.id
})
.where(eq(users.id, targetUser.id))
// 记录状态变更日志
await tx.insert(userStatusLogs).values({
userId: targetUser.id,
oldStatus: targetUser.status,
newStatus: status,
reason: reason.trim(),
operatorId: user.id,
createdAt: currentTime
})
results.push({
userId: targetUser.id,
name: targetUser.name,
username: targetUser.username,
oldStatus: targetUser.status,
newStatus: status
})
}
})
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
for (const userId of usersToUpdate.map((u) => u.id)) {
await cache.delete(`auth:user:${userId}`)
}
console.log('[Cache] 批量用户认证缓存已清除(状态更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
message: `成功更新 ${results.length} 个用户的状态为${getStatusText(status)}`,
data: {
totalRequested: validUserIds.length,
totalUpdated: results.length,
updatedUsers: results,
changedAt: currentTime,
changedBy: user.name
}
}
} catch (error) {
console.error('批量更新用户状态失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: '批量更新用户状态失败: ' + error.message
})
}
})
+234
View File
@@ -0,0 +1,234 @@
import { z } from 'zod'
import { db } from '~/drizzle/db'
import { CacheService } from '~~/server/services/cacheService'
import { users, userStatusLogs } from '~/drizzle/schema'
import { and, eq, inArray, ne } from 'drizzle-orm'
// 请求体验证模式
const batchUpdateSchema = z.object({
updates: z
.array(
z.object({
userId: z.number().int().positive(),
grade: z.string().optional(),
class: z.string().optional(),
username: z.string().optional(),
status: z.enum(['active', 'withdrawn', 'graduate']).optional()
})
)
.min(1)
.max(100) // 限制批量更新数量
})
export default defineEventHandler(async (event) => {
try {
// 使用认证中间件提供的用户信息
const currentUser = event.context.user
if (!currentUser) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
// 检查权限 - 只有管理员和超级管理员可以执行批量更新
if (!['ADMIN', 'SUPER_ADMIN'].includes(currentUser.role)) {
throw createError({
statusCode: 403,
statusMessage: 'Insufficient permissions'
})
}
// 验证请求体
const body = await readBody(event)
const validationResult = batchUpdateSchema.safeParse(body)
if (!validationResult.success) {
throw createError({
statusCode: 400,
statusMessage:
'请求参数无效: ' + validationResult.error.errors.map((e) => e.message).join(', ')
})
}
const { updates } = validationResult.data
// 获取所有要更新的用户ID
const userIds = updates.map((update) => update.userId)
// 验证用户是否存在且为学生角色
const existingUsers = await db
.select({
id: users.id,
name: users.name,
username: users.username,
role: users.role,
status: users.status
})
.from(users)
.where(and(inArray(users.id, userIds), eq(users.role, 'USER')))
// 检查是否所有用户都存在且为学生
const existingUserIds = new Set(existingUsers.map((user) => user.id))
const missingUserIds = userIds.filter((id) => !existingUserIds.has(id))
if (missingUserIds.length > 0) {
throw createError({
statusCode: 400,
statusMessage: `以下用户ID不存在或不是学生: ${missingUserIds.join(', ')}`
})
}
// 执行批量更新
const updateResults = []
const errors = []
for (const update of updates) {
try {
// 禁止批量更新包含当前操作者自身
if (update.userId === currentUser.id) {
errors.push({
userId: update.userId,
error: '禁止在用户管理中批量更新自己的账户'
})
continue
}
// 构建更新数据
const updateData: any = {}
if (update.grade !== undefined && update.grade.trim()) {
updateData.grade = update.grade.trim()
}
if (update.class !== undefined && update.class.trim()) {
updateData.class = update.class.trim()
}
// 处理用户名更新
if (update.username !== undefined && update.username.trim()) {
const newUsername = update.username.trim()
// 检查用户名是否已存在(排除当前用户)
const existingUser = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.username, newUsername), ne(users.id, update.userId)))
.limit(1)
if (existingUser.length > 0) {
errors.push({
userId: update.userId,
error: `用户名 "${newUsername}" 已存在`
})
continue
}
updateData.username = newUsername
}
if (update.status !== undefined) {
const currentUserStatus = existingUsers.find((u) => u.id === update.userId)?.status
if (update.status !== currentUserStatus) {
updateData.status = update.status
updateData.statusChangedAt = new Date()
updateData.statusChangedBy = currentUser.id
}
}
// 如果没有要更新的字段,跳过
if (Object.keys(updateData).length === 0) {
continue
}
let updatedUser
// 使用事务确保更新和日志记录的原子性
await db.transaction(async (tx) => {
// 更新用户信息
const updatedUserResult = await tx
.update(users)
.set(updateData)
.where(eq(users.id, update.userId))
.returning({
id: users.id,
name: users.name,
username: users.username,
grade: users.grade,
class: users.class,
status: users.status
})
updatedUser = updatedUserResult[0]
// 记录状态变更日志
if (updateData.status) {
const currentUserStatus = existingUsers.find((u) => u.id === update.userId)?.status
await tx.insert(userStatusLogs).values({
userId: update.userId,
oldStatus: currentUserStatus,
newStatus: updateData.status,
reason: `管理员${currentUser.name || currentUser.username}批量更新用户状态`,
operatorId: currentUser.id,
createdAt: new Date()
})
}
})
updateResults.push(updatedUser)
} catch (error) {
console.error(`处理用户 ${update.userId} 更新时出错:`, error)
errors.push({
userId: update.userId,
error: error.message || '未知错误'
})
}
}
// 返回结果
const response = {
success: true,
message: `批量更新完成,成功更新 ${updateResults.length} 个用户`,
data: {
updated: updateResults,
errors: errors,
summary: {
total: updates.length,
success: updateResults.length,
failed: errors.length
}
}
}
// 如果有部分失败,调整消息
if (errors.length > 0) {
response.message = `批量更新部分完成,成功更新 ${updateResults.length} 个用户,${errors.length} 个用户更新失败`
}
// 如果有用户更新成功,清除相关缓存
if (updateResults.length > 0) {
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('批量用户更新后缓存清除成功')
} catch (cacheError) {
console.error('批量用户更新后缓存清除失败:', cacheError)
}
}
return response
} catch (error) {
console.error('批量更新用户失败:', error)
// 如果是已知错误,直接抛出
if (error.statusCode) {
throw error
}
// 未知错误
throw createError({
statusCode: 500,
statusMessage: '批量更新用户失败: ' + error.message
})
}
})
+120
View File
@@ -0,0 +1,120 @@
import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
const body = await readBody(event)
if (!body.users || !Array.isArray(body.users) || body.users.length === 0) {
throw createError({
statusCode: 400,
message: '无效的用户数据'
})
}
const results = {
created: 0,
failed: 0,
total: body.users.length
}
// 批量添加用户
for (let i = 0; i < body.users.length; i++) {
const userData = body.users[i]
try {
// 验证必填字段
if (!userData.name || !userData.username || !userData.password) {
results.failed++
continue
}
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(userData.name) || !chineseRegex.test(userData.username)) {
results.failed++
continue
}
// 检查用户名是否已存在
const existingUser = await db
.select()
.from(users)
.where(eq(users.username, userData.username))
.limit(1)
if (existingUser.length > 0) {
results.failed++
continue
}
// 加密密码
const hashedPassword = await bcrypt.hash(userData.password, 10)
// 角色权限控制
let validRole = 'USER' // 默认角色
if (userData.role) {
if (user.role === 'SUPER_ADMIN') {
// 超级管理员可以设置任何角色
const allowedRoles = ['USER', 'TEACHER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN']
if (allowedRoles.includes(userData.role)) {
validRole = userData.role
}
} else if (user.role === 'ADMIN') {
// 普通管理员只能设置 USER 和 SONG_ADMIN 角色
const allowedRoles = ['USER', 'TEACHER', 'SONG_ADMIN']
if (allowedRoles.includes(userData.role)) {
validRole = userData.role
}
}
}
// 状态验证
let validStatus = 'active'
if (userData.status && ['active', 'withdrawn', 'graduate'].includes(userData.status)) {
validStatus = userData.status
}
// 创建用户
const newUser = await db
.insert(users)
.values({
name: userData.name,
username: userData.username,
password: hashedPassword,
role: validRole,
status: validStatus as 'active' | 'withdrawn' | 'graduate',
grade: userData.grade,
class: userData.class
})
.returning()
results.created++
} catch (error) {
console.error(`用户创建失败:`, error)
results.failed++
}
}
// 如果有用户创建成功,清除相关缓存
if (results.created > 0) {
try {
await cacheService.clearSongsCache()
console.log('批量用户创建后缓存清除成功')
} catch (cacheError) {
console.error('批量用户创建后缓存清除失败:', cacheError)
}
}
return results
})
+151
View File
@@ -0,0 +1,151 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { and, asc, desc, count, eq, ilike, or, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
try {
// 获取查询参数
const query = getQuery(event)
const { grade, class: className, search, page = '1', limit = '50', role, status, sortBy = 'id', sortOrder = 'asc' } = query
// 构建筛选条件
const whereConditions = []
// 年级筛选
if (grade && typeof grade === 'string' && grade.trim()) {
whereConditions.push(eq(users.grade, grade.trim()))
}
// 班级筛选
if (className && typeof className === 'string' && className.trim()) {
whereConditions.push(eq(users.class, className.trim()))
}
// 角色筛选
if (role && typeof role === 'string' && role.trim()) {
whereConditions.push(eq(users.role, role.trim()))
}
// 状态筛选
if (status && typeof status === 'string' && status.trim()) {
whereConditions.push(eq(users.status, status.trim()))
}
// 搜索功能(姓名、用户名或IP地址)
if (search && typeof search === 'string' && search.trim()) {
const searchTerm = search.trim()
whereConditions.push(
or(
ilike(users.name, `%${searchTerm}%`),
ilike(users.username, `%${searchTerm}%`),
ilike(users.lastLoginIp, `%${searchTerm}%`)
)
)
}
const whereClause = whereConditions.length > 0 ? and(...whereConditions) : undefined
// 分页参数
const pageNum = Math.max(1, parseInt(page as string) || 1)
const limitNum = Math.max(1, parseInt(limit as string) || 50)
const skip = (pageNum - 1) * limitNum
// 获取总数
const totalResult = await db.select({ count: count() }).from(users).where(whereClause)
const total = totalResult[0].count
// 排序逻辑
let orderByClause
if (sortBy === 'name') {
// 对中文名进行拼音排序 (使用 GBK 编码转换 hack,适用于大多数常见汉字)
orderByClause = sortOrder === 'desc'
? sql`convert_to(${users.name}, 'GBK') DESC`
: sql`convert_to(${users.name}, 'GBK') ASC`
} else if (sortBy === 'lastLogin') {
// 确保未登录用户(NULL)排在最后
orderByClause = sortOrder === 'desc'
? sql`${users.lastLogin} DESC NULLS LAST`
: asc(users.lastLogin)
} else if (sortBy === 'createdAt') {
orderByClause = sortOrder === 'desc' ? desc(users.createdAt) : asc(users.createdAt)
} else {
// 默认按 id 排序
orderByClause = sortOrder === 'desc' ? desc(users.id) : asc(users.id)
}
// 获取用户列表
const usersList = await db.query.users.findMany({
where: whereClause,
orderBy: orderByClause,
limit: limitNum,
offset: skip,
columns: {
id: true,
name: true,
username: true,
role: true,
grade: true,
class: true,
status: true,
statusChangedAt: true,
lastLogin: true,
lastLoginIp: true,
passwordChangedAt: true,
forcePasswordChange: true,
meowNickname: true,
meowBoundAt: true,
createdAt: true,
updatedAt: true
},
with: {
identities: {
columns: {
provider: true,
providerUsername: true
},
where: (identities, { eq }) => eq(identities.provider, 'github')
}
}
})
// 处理用户列表,添加头像字段
const formattedUsers = usersList.map((user) => ({
...user,
avatar: user.identities?.[0]?.providerUsername
? `https://github.com/${user.identities[0].providerUsername}.png`
: null
}))
// 计算分页信息
const totalPages = Math.ceil(total / limitNum)
const hasNextPage = pageNum < totalPages
const hasPrevPage = pageNum > 1
return {
success: true,
users: formattedUsers,
pagination: {
total,
page: pageNum,
limit: limitNum,
totalPages,
hasNextPage,
hasPrevPage
},
filters: {
grade: grade || null,
class: className || null,
role: role || null,
status: status || null,
search: search || null
}
}
} catch (error) {
console.error('获取用户列表失败:', error)
throw createError({
statusCode: 500,
statusMessage: '获取用户列表失败: ' + error.message
})
}
})
+134
View File
@@ -0,0 +1,134 @@
import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
const body = await readBody(event)
// 验证必填字段
if (!body.name || !body.username || !body.password) {
throw createError({
statusCode: 400,
message: '姓名、账号名和密码不能为空'
})
}
// 验证姓名和用户名只能包含汉字
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(body.name) || !chineseRegex.test(body.username)) {
throw createError({
statusCode: 400,
message: '姓名和账号名只能包含汉字'
})
}
try {
// 检查用户名是否已存在
const existingUserResult = await db
.select()
.from(users)
.where(eq(users.username, body.username))
.limit(1)
const existingUser = existingUserResult[0]
if (existingUser) {
throw createError({
statusCode: 400,
message: '账号名已存在'
})
}
// 加密密码
const hashedPassword = await bcrypt.hash(body.password, 10)
// 角色权限控制
let validRole = 'USER'
if (body.role && ['USER', 'TEACHER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(body.role)) {
// 超级管理员可以创建任何角色的用户
if (user.role === 'SUPER_ADMIN') {
validRole = body.role
}
// 管理员只能创建管理员以下的角色(USER, SONG_ADMIN)
else if (user.role === 'ADMIN') {
if (['USER', 'TEACHER', 'SONG_ADMIN'].includes(body.role)) {
validRole = body.role
} else {
throw createError({
statusCode: 403,
message: '管理员只能创建用户、教师和歌曲管理员角色'
})
}
}
// 其他角色不能创建用户
else {
throw createError({
statusCode: 403,
message: '没有权限创建用户'
})
}
}
// 状态验证
let validStatus = 'active'
if (body.status && ['active', 'withdrawn', 'graduate'].includes(body.status)) {
validStatus = body.status
}
// 创建用户
const newUserResult = await db
.insert(users)
.values({
name: body.name,
username: body.username,
password: hashedPassword,
role: validRole,
status: validStatus as 'active' | 'withdrawn' | 'graduate',
grade: body.grade,
class: body.class
})
.returning({
id: users.id,
name: users.name,
username: users.username,
role: users.role,
status: users.status,
grade: users.grade,
class: users.class,
createdAt: users.createdAt,
updatedAt: users.updatedAt
})
const newUser = newUserResult[0]
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('songs:*')
await cache.deletePattern('stats:*')
console.log('[Cache] 歌曲和统计缓存已清除(用户创建)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
user: newUser,
message: '用户创建成功'
}
} catch (error) {
console.error('创建用户失败:', error)
throw createError({
statusCode: 500,
message: '创建用户失败'
})
}
})
+40
View File
@@ -0,0 +1,40 @@
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { asc } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
try {
// 获取所有用户
const usersList = await db
.select({
id: users.id,
name: users.name,
username: users.username,
role: users.role,
grade: users.grade,
class: users.class,
lastLoginAt: users.lastLoginAt,
lastLoginIp: users.lastLoginIp,
passwordChangedAt: users.passwordChangedAt
})
.from(users)
.orderBy(asc(users.id))
return usersList
} catch (error) {
console.error('获取用户列表失败:', error)
throw createError({
statusCode: 500,
message: '获取用户列表失败'
})
}
})
+197
View File
@@ -0,0 +1,197 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { and, count, desc, eq, ilike, or } from 'drizzle-orm'
import { getStatusText } from '~~/server/utils/user'
export default defineEventHandler(async (event) => {
try {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
statusMessage: '没有权限访问'
})
}
const query = getQuery(event)
const { page = '1', limit = '50', search, status, operatorId } = query
// 构建筛选条件
const whereConditions = []
// 状态筛选
if (status && typeof status === 'string' && status.trim()) {
whereConditions.push(eq(userStatusLogs.newStatus, status.trim()))
}
// 操作员筛选
if (operatorId && typeof operatorId === 'string' && operatorId.trim()) {
const numOperatorId = parseInt(operatorId.trim())
if (!isNaN(numOperatorId)) {
whereConditions.push(eq(userStatusLogs.operatorId, numOperatorId))
}
}
const whereClause = whereConditions.length > 0 ? and(...whereConditions) : undefined
// 分页参数
const pageNum = Math.max(1, parseInt(page as string) || 1)
const limitNum = Math.max(1, parseInt(limit as string) || 50)
const skip = (pageNum - 1) * limitNum
// 构建基础查询
let baseQuery = db
.select({
id: userStatusLogs.id,
userId: userStatusLogs.userId,
userName: users.name,
userUsername: users.username,
oldStatus: userStatusLogs.oldStatus,
newStatus: userStatusLogs.newStatus,
reason: userStatusLogs.reason,
createdAt: userStatusLogs.createdAt,
operatorId: userStatusLogs.operatorId,
operatorName: users.name,
operatorUsername: users.username
})
.from(userStatusLogs)
.leftJoin(users, eq(userStatusLogs.userId, users.id))
// 如果有搜索条件,需要额外的join来搜索操作员信息
if (search && typeof search === 'string' && search.trim()) {
const searchTerm = search.trim()
// 这里需要重新构建查询以支持搜索用户名和操作员名
baseQuery = db
.select({
id: userStatusLogs.id,
userId: userStatusLogs.userId,
userName: users.name,
userUsername: users.username,
oldStatus: userStatusLogs.oldStatus,
newStatus: userStatusLogs.newStatus,
reason: userStatusLogs.reason,
createdAt: userStatusLogs.createdAt,
operatorId: userStatusLogs.operatorId,
operatorName: users.name,
operatorUsername: users.username
})
.from(userStatusLogs)
.leftJoin(users, eq(userStatusLogs.userId, users.id))
.where(
and(
whereClause,
or(
ilike(users.name, `%${searchTerm}%`),
ilike(users.username, `%${searchTerm}%`),
ilike(userStatusLogs.reason, `%${searchTerm}%`)
)
)
)
} else if (whereClause) {
baseQuery = baseQuery.where(whereClause)
}
// 获取总数
const totalQuery = db
.select({ count: count() })
.from(userStatusLogs)
.leftJoin(users, eq(userStatusLogs.userId, users.id))
let totalResult
if (search && typeof search === 'string' && search.trim()) {
const searchTerm = search.trim()
totalResult = await totalQuery.where(
and(
whereClause,
or(
ilike(users.name, `%${searchTerm}%`),
ilike(users.username, `%${searchTerm}%`),
ilike(userStatusLogs.reason, `%${searchTerm}%`)
)
)
)
} else if (whereClause) {
totalResult = await totalQuery.where(whereClause)
} else {
totalResult = await totalQuery
}
const total = totalResult[0].count
// 获取状态变更日志列表
const logs = await baseQuery
.orderBy(desc(userStatusLogs.createdAt))
.limit(limitNum)
.offset(skip)
// 获取操作员信息(用于显示操作员名称)
const operatorIds = [...new Set(logs.map((log) => log.operatorId).filter((id) => id))]
const operators = await db
.select({
id: users.id,
name: users.name,
username: users.username
})
.from(users)
.where(eq(users.id, operatorIds[0])) // 这里需要用inArray,但先简化处理
const operatorMap = new Map()
for (const op of operators) {
operatorMap.set(op.id, op)
}
// 计算分页信息
const totalPages = Math.ceil(total / limitNum)
const hasNextPage = pageNum < totalPages
const hasPrevPage = pageNum > 1
return {
success: true,
logs: logs.map((log) => ({
id: log.id,
user: {
id: log.userId,
name: log.userName || '未知用户',
username: log.userUsername || 'unknown'
},
oldStatus: log.oldStatus,
newStatus: log.newStatus,
oldStatusDisplay: getStatusText(log.oldStatus || ''),
newStatusDisplay: getStatusText(log.newStatus || ''),
reason: log.reason,
createdAt: log.createdAt,
operator: {
id: log.operatorId,
name: operatorMap.get(log.operatorId)?.name || log.operatorName || '未知操作员',
username: operatorMap.get(log.operatorId)?.username || log.operatorUsername || 'unknown'
}
})),
pagination: {
total,
page: pageNum,
limit: limitNum,
totalPages,
hasNextPage,
hasPrevPage
},
filters: {
search: search || null,
status: status || null,
operatorId: operatorId || null
}
}
} catch (error) {
console.error('获取状态变更日志失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: '获取状态变更日志失败: ' + error.message
})
}
})
@@ -0,0 +1,67 @@
const neteaseEnhancedApiPromise = import('@neteasecloudmusicapienhanced/api').then((mod) => {
return (mod.default || {}) as Record<string, (params: Record<string, any>) => Promise<any>>
})
const normalizeParams = (input: Record<string, any>) => {
const output: Record<string, any> = {}
for (const [key, value] of Object.entries(input || {})) {
if (value === undefined || value === null || value === '') {
continue
}
output[key] = Array.isArray(value) ? value[value.length - 1] : value
}
return output
}
export default defineEventHandler(async (event) => {
const rawPath = getRouterParam(event, 'path')
const endpointPath = Array.isArray(rawPath) ? rawPath.join('/') : rawPath
if (!endpointPath) {
throw createError({
statusCode: 400,
message: '缺少网易云接口路径'
})
}
const action = endpointPath.replace(/\//g, '_').replace(/-/g, '_')
const api = await neteaseEnhancedApiPromise
const handler = api[action]
if (typeof handler !== 'function') {
throw createError({
statusCode: 404,
message: `未找到接口: ${endpointPath}`
})
}
const method = getMethod(event)
const queryParams = normalizeParams(getQuery(event))
let bodyParams: Record<string, any> = {}
if (method !== 'GET') {
const body = await readBody(event).catch(() => ({}))
if (body && typeof body === 'object' && !Array.isArray(body)) {
bodyParams = normalizeParams(body as Record<string, any>)
}
}
const params = { ...queryParams, ...bodyParams }
try {
const result = await handler(params)
if (Array.isArray(result?.cookie) && result.cookie.length > 0) {
setHeader(event, 'set-cookie', result.cookie)
}
if (typeof result?.status === 'number') {
setResponseStatus(event, result.status)
}
return result?.body ?? result
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.status || 500,
message: error?.body?.message || error?.message || '网易云接口调用失败',
data: error?.body || null
})
}
})
+102
View File
@@ -0,0 +1,102 @@
import { db, users, eq } from '~/drizzle/db'
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
import { SmtpService } from '~~/server/services/smtpService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { randomInt } from 'crypto'
export default defineEventHandler(async (event) => {
const { userId: reqUserId, token, email } = await readBody(event)
// 必须提供预认证令牌
if (!token) {
throw createError({ statusCode: 400, message: '缺少预认证令牌' })
}
let userId: number
try {
const decoded = JWTEnhanced.verify(token) as any
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
throw new Error('无效的预认证令牌')
}
userId = decoded.userId
} catch (e) {
throw createError({ statusCode: 401, message: '会话已失效,请重新登录' })
}
// 校验 userId 是否匹配
if (!reqUserId || Number(reqUserId) !== userId) {
throw createError({ statusCode: 403, message: '非法操作:用户ID不匹配' })
}
// 获取用户邮箱
const userResult = await db.select({
id: users.id,
email: users.email,
emailVerified: users.emailVerified,
name: users.name
})
.from(users)
.where(eq(users.id, userId))
.limit(1)
const user = userResult[0]
// 增加 emailVerified 校验
if (!user || !user.email || !user.emailVerified) {
throw createError({ statusCode: 400, message: '用户不存在或未绑定邮箱' })
}
// 校验用户输入的邮箱是否匹配
if (!email || email.trim().toLowerCase() !== user.email.toLowerCase()) {
throw createError({ statusCode: 400, message: '邮箱地址不匹配' })
}
// 检查是否在冷却时间内
const existingCode = twoFactorCodes.get(userId)
if (existingCode && existingCode.expiresAt > Date.now()) {
// 5 * 60 * 1000 = 300000ms
const totalDuration = 5 * 60 * 1000
const timePassed = totalDuration - (existingCode.expiresAt - Date.now())
if (timePassed < 60 * 1000) { // 60秒冷却
const remainingSeconds = Math.ceil((60000 - timePassed) / 1000)
throw createError({
statusCode: 429,
message: `操作过于频繁,请等待 ${remainingSeconds} 秒后再试`
})
}
}
const code = randomInt(100000, 999999).toString()
twoFactorCodes.set(userId, {
code,
expiresAt: Date.now() + 5 * 60 * 1000,
attempts: 0
})
const clientIP = getClientIP(event)
// 发送邮件
const smtp = SmtpService.getInstance()
await smtp.initializeSmtpConfig()
const sent = await smtp.renderAndSend(
user.email,
'verification.code',
{
name: user.name || '用户',
email: user.email,
code,
expiresInMinutes: 5,
action: '登录验证'
},
clientIP
)
if (!sent) {
throw createError({ statusCode: 500, message: '验证码发送失败' })
}
return { success: true, message: '验证码已发送至您的邮箱' }
})
+130
View File
@@ -0,0 +1,130 @@
import otplib from 'otplib'
const { authenticator } = otplib
import { db, userIdentities, eq, and, users } from '~/drizzle/db'
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { getClientIP } from '~~/server/utils/ip-utils'
import { getBeijingTime } from '~/utils/timeUtils'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { code, type, token } = body
if (!code || !type) {
throw createError({ statusCode: 400, message: '缺少必要参数' })
}
// 验证预认证临时令牌
let targetUserId: number
if (token) {
try {
const decoded = JWTEnhanced.verify(token) as any
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
throw new Error('无效的预认证令牌')
}
targetUserId = decoded.userId
} catch (e) {
throw createError({ statusCode: 401, message: '会话已失效,请重新登录' })
}
} else {
// 强制要求 Token
throw createError({ statusCode: 400, message: '缺少预认证令牌,请重新登录' })
}
// 获取用户信息
const userResult = await db.select().from(users).where(eq(users.id, targetUserId)).limit(1)
const user = userResult[0]
if (!user) {
throw createError({ statusCode: 404, message: '用户不存在' })
}
if (user.status !== 'active') {
throw createError({ statusCode: 403, message: '账号已被禁用或限制访问' })
}
let verified = false
if (type === 'totp') {
const identity = await db.query.userIdentities.findFirst({
where: and(eq(userIdentities.userId, targetUserId), eq(userIdentities.provider, 'totp'))
})
if (!identity) {
throw createError({ statusCode: 400, message: '未开启TOTP验证' })
}
verified = authenticator.check(code, identity.providerUserId)
if (!verified) {
throw createError({ statusCode: 400, message: '动态验证码错误' })
}
} else if (type === 'email') {
const stored = twoFactorCodes.get(targetUserId)
if (!stored) {
throw createError({ statusCode: 400, message: '验证码已过期或不存在' })
}
if (stored.expiresAt <= Date.now()) {
twoFactorCodes.delete(targetUserId)
throw createError({ statusCode: 400, message: '验证码已过期' })
}
// 检查尝试次数
if (stored.attempts >= 5) {
twoFactorCodes.delete(targetUserId)
throw createError({ statusCode: 400, message: '验证尝试次数过多,请重新获取' })
}
if (stored.code === code) {
verified = true
twoFactorCodes.delete(targetUserId) // 验证成功后删除
} else {
// 增加尝试次数
stored.attempts++
twoFactorCodes.set(targetUserId, stored)
throw createError({ statusCode: 400, message: `验证码错误,剩余尝试次数:${5 - stored.attempts}` })
}
} else {
throw createError({ statusCode: 400, message: '不支持的验证类型' })
}
// 验证通过,更新登录信息
const clientIp = getClientIP(event)
await db.update(users)
.set({
lastLogin: getBeijingTime(),
lastLoginIp: clientIp
})
.where(eq(users.id, user.id))
.catch((err) => console.error('Error updating user login info:', err))
// 生成Token
const authToken = JWTEnhanced.generateToken(user.id, user.role)
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
setCookie(event, 'auth-token', authToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return {
success: true,
user: {
id: user.id,
username: user.username,
name: user.name,
grade: user.grade,
class: user.class,
role: user.role,
needsPasswordChange: !user.passwordChangedAt,
has2FA: true
}
}
})
+176
View File
@@ -0,0 +1,176 @@
import { parseState, getRedirectUri } from '~~/server/utils/oauth'
import { generateBindingToken } from '~~/server/utils/oauth-token'
import { db, eq, userIdentities } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, 'provider')
const query = getQuery(event)
const code = query.code as string
const stateStr = query.state as string
if (!provider) {
throw createError({ statusCode: 400, message: 'Missing provider' })
}
if (!code || !stateStr) {
throw createError({ statusCode: 400, message: 'Missing code or state' })
}
// 1. 验证 State
const csrfCookie = getCookie(event, 'oauth_csrf')
// 获取 Origin
const headers = getRequestHeaders(event)
const protocol = headers['x-forwarded-proto'] || 'http'
const host = headers['host']
const origin = `${protocol}://${host}`
const state = parseState(stateStr, origin, csrfCookie)
if (!state) {
throw createError({ statusCode: 400, message: 'Invalid or expired state' })
}
// 清除 CSRF cookie
deleteCookie(event, 'oauth_csrf')
const strategy = getOAuthStrategy(provider)
const redirectUri = getRedirectUri(provider)
// 2. 使用 Code 换取 Token
let accessToken = ''
try {
accessToken = await strategy.exchangeToken(code, redirectUri)
} catch (e: any) {
console.error(`[OAuth] ${provider} token exchange failed:`, e.message)
return sendRedirect(
event,
`/auth/error?code=TOKEN_EXCHANGE_FAILED&message=${encodeURIComponent('授权失败,无法获取访问令牌')}`
)
}
// 3. 获取用户信息
let userInfo
try {
userInfo = await strategy.getUserInfo(accessToken)
} catch (e: any) {
console.error(`[OAuth] ${provider} get user info failed:`, e.message)
return sendRedirect(
event,
`/auth/error?code=USER_INFO_FAILED&message=${encodeURIComponent('获取用户信息失败')}`
)
}
const providerUserId = userInfo.id
const providerUsername = userInfo.username
return handleUserLoginOrBind(event, provider, providerUserId, providerUsername)
})
async function handleUserLoginOrBind(
event: any,
provider: string,
providerUserId: string,
providerUsername: string
) {
// 4. 检查是否已登录(绑定模式)
const authToken = getCookie(event, 'auth-token')
let currentUser: any = null
if (authToken) {
try {
const payload = JWTEnhanced.verifyToken(authToken)
currentUser = payload
} catch (e) {
// Token 无效或已过期,忽略
}
}
// 5. 检查身份关联
const existingIdentity = await db.query.userIdentities.findFirst({
where: (t, { eq, and }) => and(eq(t.provider, provider), eq(t.providerUserId, providerUserId)),
with: { user: true }
})
// 如果用户已登录,则是绑定操作
if (currentUser) {
if (existingIdentity) {
// 已经被绑定
if (existingIdentity.userId === currentUser.userId) {
// 已经被当前用户绑定
return sendRedirect(event, '/account?message=' + encodeURIComponent('账号已绑定'))
} else {
// 已经被其他用户绑定
return sendRedirect(event, '/account?error=' + encodeURIComponent('该账号已被其他用户绑定'))
}
} else {
// 未被绑定,直接绑定到当前用户
await db.insert(userIdentities).values({
userId: currentUser.userId,
provider: provider,
providerUserId: providerUserId,
providerUsername: providerUsername,
createdAt: new Date()
})
return sendRedirect(event, '/account?message=' + encodeURIComponent('绑定成功'))
}
}
// 未登录,则是登录或新绑定流程
if (existingIdentity && existingIdentity.user) {
// 检查用户状态
const user = existingIdentity.user
if (user.status === 'withdrawn') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_WITHDRAWN&message=${encodeURIComponent('账号已注销')}`
)
}
if (user.status === 'graduate') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_GRADUATED&message=${encodeURIComponent('该账号已毕业,限制访问')}`
)
}
if (isUserBlocked(user.id)) {
const remaining = getUserBlockRemainingTime(user.id)
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_BLOCKED&message=${encodeURIComponent(`账户处于风险控制期,请在 ${remaining} 分钟后重试`)}`
)
}
// 登录
const token = JWTEnhanced.generateToken(existingIdentity.user.id, existingIdentity.user.role)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return sendRedirect(event, '/')
} else {
// 绑定
const bindingToken = generateBindingToken({
provider: provider,
providerUserId,
providerUsername
})
// 将绑定令牌存入 cookie
setCookie(event, 'binding-token', bindingToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10, // 10分钟
path: '/'
})
return sendRedirect(
event,
`/login?action=bind&provider=${provider}&username=${encodeURIComponent(providerUsername)}`
)
}
}
+33
View File
@@ -0,0 +1,33 @@
import { generateState, getRedirectUri } from '~~/server/utils/oauth'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, 'provider')
if (!provider) {
throw createError({ statusCode: 400, message: 'Missing provider' })
}
const strategy = getOAuthStrategy(provider)
// 获取 Origin
const headers = getRequestHeaders(event)
const protocol = headers['x-forwarded-proto'] || 'http'
const host = headers['host']
const origin = `${protocol}://${host}`
const redirectUri = getRedirectUri(provider)
const { state, csrf } = generateState(origin, provider)
setCookie(event, 'oauth_csrf', csrf, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10, // 10分钟
path: '/'
})
const url = strategy.getAuthorizeUrl(redirectUri, state)
return sendRedirect(event, url)
})
+102
View File
@@ -0,0 +1,102 @@
import bcrypt from 'bcryptjs'
import { db, eq, users, userIdentities } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { verifyBindingToken } from '~~/server/utils/oauth-token'
import {
isAccountLocked,
recordLoginFailure,
recordLoginSuccess
} from '~~/server/services/securityService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { username, password } = body
const bindingToken = getCookie(event, 'binding-token')
if (!bindingToken) {
throw createError({ statusCode: 400, message: '绑定会话已过期,请重新通过第三方登录发起' })
}
let payload
try {
payload = verifyBindingToken(bindingToken)
} catch (e) {
deleteCookie(event, 'binding-token')
throw createError({ statusCode: 400, message: '无效的绑定令牌' })
}
const clientIp = getClientIP(event)
// 验证用户凭据
if (isAccountLocked(username)) {
throw createError({ statusCode: 423, message: '账户已被锁定,请稍后重试' })
}
const user = await db.query.users.findFirst({
where: eq(users.username, username)
})
if (!user) {
recordLoginFailure(username, clientIp)
throw createError({ statusCode: 401, message: '用户名或密码错误' })
}
const valid = await bcrypt.compare(password, user.password)
if (!valid) {
recordLoginFailure(username, clientIp)
throw createError({ statusCode: 401, message: '用户名或密码错误' })
}
// 绑定
try {
await db.transaction(async (tx) => {
const existing = await tx.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(eq(t.provider, payload.provider), eq(t.providerUserId, payload.providerUserId))
})
if (existing) {
// 已经被绑定,不需要再次插入
return
}
await tx.insert(userIdentities).values({
userId: user.id,
provider: payload.provider,
providerUserId: payload.providerUserId,
providerUsername: payload.providerUsername
})
})
} catch (e: any) {
// 如果用户尝试再次绑定相同的账户,处理唯一约束违规
if (e.code === '23505') {
// Postgres 唯一性冲突
// 这意味着身份已经绑定,这是正常情况。
// 我们可以继续执行登录流程。
} else {
// 绑定失败,清除 cookie 防止重放,或者保留允许重试?
// 如果是系统错误,保留 cookie 可能更好。
// 但如果是逻辑错误,应该清除。
// 这里我们选择抛出错误,前端处理。
throw e
}
}
recordLoginSuccess(username, clientIp)
// 登录
const token = JWTEnhanced.generateToken(user.id, user.role)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
// 清除绑定令牌
deleteCookie(event, 'binding-token')
return { success: true }
})
+97
View File
@@ -0,0 +1,97 @@
import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { recordLoginFailure, recordLoginSuccess } from '../../services/securityService'
import { updateUserPassword } from '../../services/userService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 验证用户身份
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
statusMessage: '未授权'
})
}
const body = await readBody(event)
if (!body.currentPassword || !body.newPassword) {
throw createError({
statusCode: 400,
statusMessage: '当前密码和新密码都是必需的'
})
}
try {
// 查询用户详细信息
const userDetailsResult = await db
.select({
id: users.id,
username: users.username,
password: users.password
})
.from(users)
.where(eq(users.id, user.id))
.limit(1)
const userDetails = userDetailsResult[0]
if (!userDetails) {
throw createError({
statusCode: 404,
statusMessage: '用户不存在'
})
}
// 验证当前密码
const isPasswordValid = await bcrypt.compare(body.currentPassword, userDetails.password)
if (!isPasswordValid) {
// 记录安全事件
const clientIp = getClientIP(event)
recordLoginFailure(userDetails.username, clientIp)
throw createError({
statusCode: 400,
statusMessage: '当前密码不正确'
})
}
// 检查新密码是否与当前密码相同
const isSamePassword = await bcrypt.compare(body.newPassword, userDetails.password)
if (isSamePassword) {
throw createError({
statusCode: 400,
statusMessage: '新密码不能与当前密码相同'
})
}
// 更新密码
await updateUserPassword(user.id, body.newPassword)
// 记录成功的密码修改
const clientIp = getClientIP(event)
recordLoginSuccess(userDetails.username, clientIp)
return {
success: true,
message: '密码修改成功'
}
} catch (error: any) {
// 已格式化的错误直接抛出
if (error.statusCode) {
throw error
}
// 记录错误信息
console.error('修改密码过程中发生未处理的错误:', error)
// 创建错误响应
throw createError({
statusCode: 500,
statusMessage: '修改密码失败: ' + (error.message || '未知错误')
})
}
})
+38
View File
@@ -0,0 +1,38 @@
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { getClientIP } from '~~/server/utils/ip-utils'
import { sendEmailVerificationCode } from '~~/server/api/user/email/send-code.post'
export default defineEventHandler(async (event) => {
if (getMethod(event) !== 'POST') {
throw createError({ statusCode: 405, message: '方法不被允许' })
}
const body = await readBody(event)
const emailRaw = (body?.email || '').toString().trim().toLowerCase()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRaw || !emailRegex.test(emailRaw)) {
throw createError({ statusCode: 400, message: '请输入有效的邮箱地址' })
}
// 确认邮箱未被占用
const existing = await db.select().from(users).where(eq(users.email, emailRaw)).limit(1)
// 检查是否为已登录用户的修改邮箱请求
const user = event.context.user
if (existing.length > 0) {
if (!user || existing[0].id !== user.id) {
throw createError({ statusCode: 400, message: '该邮箱已被其他用户绑定' })
}
}
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 发送邮件验证码
await sendEmailVerificationCode(user?.id || null, emailRaw, user?.name || '新用户', clientIP)
return { success: true, message: '验证码已发送,请查收邮箱' }
})

Some files were not shown because too many files have changed in this diff Show More