初次提交
This commit is contained in:
@@ -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: '获取活动记录失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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访问日志失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '删除备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '下载备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '创建数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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 || '上传备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '删除黑名单项失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '更新黑名单项失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '获取黑名单失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '添加黑名单项失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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]
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '服务器内部错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '保存失败' })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 : '未知错误'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 || '发送通知失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '不支持的请求方法'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '创建播出时段失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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: '创建投稿开放时段失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '获取投稿开放时段失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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: '角色配置已保存' }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 || '服务器内部错误,请稍后重试'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '获取完整排期数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '迁移排期日期失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '发布排期失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '更新排期顺序失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '学期删除成功'
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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: '活跃学期设置成功' }
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '删除歌曲时发生未知错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '获取统计数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 []
|
||||
}
|
||||
})
|
||||
@@ -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: '获取学期对比数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '获取热门歌曲排行失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '更新系统设置失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '获取系统设置失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '重置密码失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '创建用户失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '获取用户列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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: '验证码已发送至您的邮箱' }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 || '未知错误')
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user