feat: align with upstream 1.5.9.9
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 || !['SONG_ADMIN', '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,
|
||||
message: '只有超级管理员可以管理 API Key'
|
||||
})
|
||||
}
|
||||
|
||||
const apiKeyId = getRouterParam(event, 'id')
|
||||
|
||||
if (!apiKeyId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '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,
|
||||
message: '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,
|
||||
message: `删除 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,
|
||||
message: '只有超级管理员可以管理 API Key'
|
||||
})
|
||||
}
|
||||
|
||||
const apiKeyId = getRouterParam(event, 'id')
|
||||
|
||||
if (!apiKeyId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '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,
|
||||
message: '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,
|
||||
message: `获取 API Key 详情失败:${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { apiPermissionSchema } from './permissions'
|
||||
|
||||
/**
|
||||
* 更新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(apiPermissionSchema)
|
||||
.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,
|
||||
message: '只有超级管理员可以管理 API Key'
|
||||
})
|
||||
}
|
||||
|
||||
const apiKeyId = getRouterParam(event, 'id')
|
||||
|
||||
if (!apiKeyId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '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,
|
||||
message: '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,
|
||||
message: '无效的过期时间格式'
|
||||
})
|
||||
}
|
||||
// 验证过期时间不能是过去的时间
|
||||
if (expiresAt <= getBeijingTime()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '过期时间不能是过去的时间'
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的过期时间格式'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
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,
|
||||
message: `请求参数验证失败:${error.errors.map((e: any) => e.message).join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `更新 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,
|
||||
message: '只有超级管理员可以管理 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)
|
||||
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,
|
||||
message: `获取 API Key 列表失败:${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
|
||||
import { z } from 'zod'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { apiPermissionSchema } from './permissions'
|
||||
import { generateApiKey, hashApiKey } from '~~/server/utils/apiKeyUtils'
|
||||
|
||||
/**
|
||||
* 创建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(apiPermissionSchema).min(1, '至少需要选择一个权限')
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户权限 - 只有超级管理员可以管理 API Key
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以管理 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 = await hashApiKey(apiKey)
|
||||
|
||||
// 处理过期时间
|
||||
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,
|
||||
message: '无效的过期时间格式'
|
||||
})
|
||||
}
|
||||
// 验证过期时间不能是过去的时间
|
||||
if (expiresAt <= getBeijingTime()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '过期时间不能是过去的时间'
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的过期时间格式'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
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 createdApiKey = apiKeyResult[0]
|
||||
if (!createdApiKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '创建 API Key 失败'
|
||||
})
|
||||
}
|
||||
|
||||
const apiKeyId = createdApiKey.id
|
||||
|
||||
// 插入权限记录
|
||||
const permissionValues = validatedData.permissions.map((permission) => ({
|
||||
apiKeyId,
|
||||
permission
|
||||
}))
|
||||
|
||||
await tx.insert(apiKeyPermissions).values(permissionValues)
|
||||
|
||||
return {
|
||||
id: apiKeyId,
|
||||
apiKey,
|
||||
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,
|
||||
message: `请求参数验证失败:${error.errors.map((e: any) => e.message).join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `创建 API Key 失败:${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
message: '只有超级管理员可以查看 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,
|
||||
message: '获取 API 访问日志失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const apiPermissionSchema = z.enum([
|
||||
'schedules:read',
|
||||
'songs:read',
|
||||
'songs:request',
|
||||
'songs:write',
|
||||
'card-codes:read',
|
||||
'card-codes:write',
|
||||
'card-codes:delete',
|
||||
'backup:execute'
|
||||
])
|
||||
@@ -0,0 +1,44 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getAutoBackupConfig, isAutoBackupEnabled } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以查看自动备份配置')
|
||||
}
|
||||
|
||||
const enabled = await isAutoBackupEnabled()
|
||||
const config = await getAutoBackupConfig()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled,
|
||||
config: config ? sanitizeConfig(config) : null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 移除敏感信息后返回,参考 OAuth 配置的密钥保护模式 */
|
||||
function sanitizeConfig(config: any) {
|
||||
const sanitized = JSON.parse(JSON.stringify(config))
|
||||
const configuredSecrets: Record<string, boolean> = {}
|
||||
|
||||
if (sanitized.methods?.s3) {
|
||||
configuredSecrets.s3SecretKey = !!sanitized.methods.s3.secretKey
|
||||
sanitized.methods.s3.secretKey = ''
|
||||
}
|
||||
if (sanitized.methods?.webdav) {
|
||||
configuredSecrets.webdavPassword = !!sanitized.methods.webdav.password
|
||||
sanitized.methods.webdav.password = ''
|
||||
}
|
||||
if (sanitized.methods?.telegram) {
|
||||
configuredSecrets.telegramBotToken = !!sanitized.methods.telegram.botToken
|
||||
sanitized.methods.telegram.botToken = ''
|
||||
}
|
||||
|
||||
sanitized.configuredSecrets = configuredSecrets
|
||||
return sanitized
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { SYSTEM_SETTINGS_DEFAULTS } from '~~/server/utils/system-settings-defaults'
|
||||
|
||||
/** 备份配置结构校验 */
|
||||
const backupConfigSchema = z.object({
|
||||
methods: z.object({
|
||||
s3: z.object({
|
||||
enabled: z.boolean(),
|
||||
endpoint: z.string(),
|
||||
bucket: z.string(),
|
||||
region: z.string(),
|
||||
pathPrefix: z.string(),
|
||||
accessKey: z.string(),
|
||||
secretKey: z.string().optional()
|
||||
}),
|
||||
webdav: z.object({
|
||||
enabled: z.boolean(),
|
||||
url: z.string(),
|
||||
username: z.string(),
|
||||
password: z.string().optional(),
|
||||
path: z.string()
|
||||
}),
|
||||
telegram: z.object({
|
||||
enabled: z.boolean(),
|
||||
botToken: z.string().optional(),
|
||||
chatId: z.string()
|
||||
}),
|
||||
email: z.object({
|
||||
enabled: z.boolean(),
|
||||
recipient: z.string()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** 密钥字段映射:前端留空时保留数据库中的现有值 */
|
||||
const SECRET_FIELDS: Record<string, string[]> = {
|
||||
s3: ['secretKey'],
|
||||
webdav: ['password'],
|
||||
telegram: ['botToken']
|
||||
}
|
||||
|
||||
function mergeSecrets(incoming: any, existing: any): any {
|
||||
if (!incoming || !existing) return incoming
|
||||
const merged = JSON.parse(JSON.stringify(incoming))
|
||||
for (const [method, fields] of Object.entries(SECRET_FIELDS)) {
|
||||
if (!merged.methods?.[method] || !existing.methods?.[method]) continue
|
||||
for (const field of fields) {
|
||||
if (!merged.methods[method][field] && existing.methods[method][field]) {
|
||||
merged.methods[method][field] = existing.methods[method][field]
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以修改自动备份配置')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { enabled, config } = body
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'enabled 字段必填')
|
||||
}
|
||||
|
||||
// 校验 config 结构,防止畸形配置导致备份执行时崩溃
|
||||
if (config) {
|
||||
const parsed = backupConfigSchema.safeParse(config)
|
||||
if (!parsed.success) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `备份配置结构无效: ${parsed.error.issues[0]?.message || '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const [existing] = await db.select({
|
||||
id: systemSettings.id,
|
||||
autoBackupConfig: systemSettings.autoBackupConfig
|
||||
}).from(systemSettings).limit(1)
|
||||
|
||||
// 合并密钥:前端留空时保留数据库中已有的值
|
||||
let finalConfig = config
|
||||
if (config && existing?.autoBackupConfig) {
|
||||
try {
|
||||
const existingConfig = JSON.parse(existing.autoBackupConfig)
|
||||
finalConfig = mergeSecrets(config, existingConfig)
|
||||
} catch { /* 解析失败则使用传入的 config */ }
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(systemSettings).values({
|
||||
...SYSTEM_SETTINGS_DEFAULTS,
|
||||
autoBackupEnabled: enabled,
|
||||
autoBackupConfig: finalConfig ? JSON.stringify(finalConfig) : null
|
||||
})
|
||||
} else {
|
||||
await db.update(systemSettings)
|
||||
.set({
|
||||
autoBackupEnabled: enabled,
|
||||
autoBackupConfig: finalConfig ? JSON.stringify(finalConfig) : null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(systemSettings.id, existing.id))
|
||||
}
|
||||
|
||||
return { success: true, message: '自动备份配置已保存' }
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
apiKeyPermissions,
|
||||
apiKeys,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
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 || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以清空数据库'
|
||||
})
|
||||
}
|
||||
|
||||
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(cardCodeRedeemLogs).where(eq(cardCodeRedeemLogs.redeemedBy, 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(cardCodeRedeemLogs)
|
||||
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(cardCodes)
|
||||
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(cardCodeRedeemLogs)
|
||||
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(cardCodes)
|
||||
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,
|
||||
message: '清空数据失败:' + 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 || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以管理备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const filename = getRouterParam(event, 'filename')
|
||||
|
||||
if (!filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请指定文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件名安全性
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件扩展名
|
||||
if (!filename.endsWith('.json')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '只能删除 JSON 格式的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
const filepath = path.join(backupDir, filename)
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(filepath)
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
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,
|
||||
message: error.message || '删除备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以下载备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const { filename } = query
|
||||
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少文件名参数'
|
||||
})
|
||||
}
|
||||
|
||||
// 安全检查:确保文件名不包含路径遍历字符
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查文件是否为备份文件
|
||||
if (
|
||||
!filename.endsWith('.json') ||
|
||||
(!filename.startsWith('database-backup-') &&
|
||||
!filename.startsWith('users-backup-') &&
|
||||
!filename.startsWith('uploaded-'))
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
message: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '读取备份文件失败'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '下载备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以下载备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const filename = getRouterParam(event, 'filename')
|
||||
|
||||
if (!filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请指定文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件名安全性
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件扩展名
|
||||
if (!filename.endsWith('.json')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '只能下载 JSON 格式的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
const filepath = path.join(backupDir, filename)
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(filepath)
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
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,
|
||||
message: error.message || '下载备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,618 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
apiKeys,
|
||||
apiKeyPermissions,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
collaborationLogs,
|
||||
emailTemplates,
|
||||
notifications,
|
||||
notificationSettings,
|
||||
playTimes,
|
||||
requestTimes,
|
||||
schedules,
|
||||
scheduleSongPool,
|
||||
semesters,
|
||||
songBlacklists,
|
||||
songCollaborators,
|
||||
songReplayRequests,
|
||||
songs,
|
||||
systemSettings,
|
||||
users,
|
||||
userIdentities,
|
||||
userStatusLogs,
|
||||
gradeClass,
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以导出备份'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { tables = 'all' } = body
|
||||
const includeSystemData =
|
||||
typeof body.includeSystemData === 'boolean' ? body.includeSystemData : tables !== 'users'
|
||||
|
||||
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: '歌曲数据'
|
||||
},
|
||||
cardCodes: {
|
||||
query: async () => {
|
||||
const codesData = await db.select().from(cardCodes)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return codesData.map((code) => ({
|
||||
...code,
|
||||
lockedByUser: code.lockedBy ? usersData.find((user) => user.id === code.lockedBy) : null,
|
||||
redeemedByUser: code.redeemedBy ? usersData.find((user) => user.id === code.redeemedBy) : null
|
||||
}))
|
||||
},
|
||||
description: '点歌券数据'
|
||||
},
|
||||
cardCodeRedeemLogs: {
|
||||
query: async () => {
|
||||
const logsData = await db.select().from(cardCodeRedeemLogs)
|
||||
const codesData = await db.select({ id: cardCodes.id, code: cardCodes.code }).from(cardCodes)
|
||||
const songsData = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return logsData.map((log) => ({
|
||||
...log,
|
||||
cardCode: codesData.find((code) => code.id === log.cardCodeId),
|
||||
redeemer: usersData.find((user) => user.id === log.redeemedBy),
|
||||
song: log.songId ? songsData.find((song) => song.id === log.songId) : null
|
||||
}))
|
||||
},
|
||||
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: '投票数据'
|
||||
},
|
||||
scheduleSongPool: {
|
||||
query: async () => {
|
||||
const poolData = await db.select().from(scheduleSongPool)
|
||||
if (poolData.length === 0) return []
|
||||
const poolSongIds = poolData.map((row) => row.songId)
|
||||
const songsData = await db.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
}).from(songs).where(inArray(songs.id, poolSongIds))
|
||||
const songsMap = new Map(songsData.map((s) => [s.id, s]))
|
||||
return poolData.map((row) => ({
|
||||
...row,
|
||||
song: songsMap.get(row.songId) || null
|
||||
}))
|
||||
},
|
||||
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: '用户状态变更日志'
|
||||
},
|
||||
gradeClass: {
|
||||
query: () => db.select().from(gradeClass),
|
||||
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: async () => {
|
||||
const settings = await db.select().from(systemSettings)
|
||||
return settings
|
||||
},
|
||||
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,
|
||||
message: `备份表 ${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,
|
||||
message: error.message || '创建数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { cleanupOldHistory } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以清理备份历史')
|
||||
}
|
||||
|
||||
const count = await cleanupOldHistory(0) // 0 = 清理全部
|
||||
return { success: true, message: `已清理 ${count} 条备份历史记录`, data: { count } }
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getBackupHistory } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以查看备份历史')
|
||||
}
|
||||
|
||||
const history = await getBackupHistory()
|
||||
return { success: true, data: history }
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
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 || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以查看备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
// 检测运行环境
|
||||
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,
|
||||
message: '获取备份文件列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以测试邮件发送')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { recipient } = body
|
||||
|
||||
if (!recipient) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少必要参数:recipient')
|
||||
}
|
||||
|
||||
const settings = await getSystemSettingsCached()
|
||||
if (!settings?.smtpHost || !settings?.smtpUsername || !settings?.smtpPassword) {
|
||||
return { success: false, message: '邮件服务未配置,请先在站点配置中设置 SMTP' }
|
||||
}
|
||||
|
||||
try {
|
||||
const nodemailer = await import('nodemailer').then((m) => m.default || m)
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: settings.smtpHost,
|
||||
port: settings.smtpPort || 587,
|
||||
secure: settings.smtpSecure || false,
|
||||
auth: {
|
||||
user: settings.smtpUsername,
|
||||
pass: settings.smtpPassword
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: `"${settings.smtpFromName || 'VoiceHub'}" <${settings.smtpFromEmail || settings.smtpUsername}>`,
|
||||
to: recipient,
|
||||
subject: 'VoiceHub 邮件发送测试',
|
||||
text: '如果您收到此邮件,说明 VoiceHub 的邮件服务配置正确。'
|
||||
})
|
||||
|
||||
return { success: true, message: '测试邮件发送成功' }
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || '邮件发送失败' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { uploadToS3, deleteFromS3 } from '~~/server/utils/s3Client'
|
||||
import { getAutoBackupConfig } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以测试 S3 连接')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
let { endpoint, bucket, region, accessKey, secretKey } = body
|
||||
|
||||
// 密钥为空时从已保存配置中获取
|
||||
if (!secretKey) {
|
||||
const config = await getAutoBackupConfig()
|
||||
secretKey = config?.methods?.s3?.secretKey || ''
|
||||
accessKey = accessKey || config?.methods?.s3?.accessKey || ''
|
||||
endpoint = endpoint || config?.methods?.s3?.endpoint || ''
|
||||
bucket = bucket || config?.methods?.s3?.bucket || ''
|
||||
region = region || config?.methods?.s3?.region || 'auto'
|
||||
}
|
||||
|
||||
if (!endpoint || !bucket || !accessKey || !secretKey) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少必要参数:endpoint, bucket, accessKey, secretKey')
|
||||
}
|
||||
|
||||
const testKey = '.voicehub-test-connection'
|
||||
|
||||
try {
|
||||
await uploadToS3(
|
||||
endpoint,
|
||||
bucket,
|
||||
region || 'auto',
|
||||
accessKey,
|
||||
secretKey,
|
||||
testKey,
|
||||
'VoiceHub S3 connection test',
|
||||
'text/plain'
|
||||
)
|
||||
|
||||
// 清理测试文件
|
||||
await deleteFromS3(
|
||||
endpoint,
|
||||
bucket,
|
||||
region || 'auto',
|
||||
accessKey,
|
||||
secretKey,
|
||||
testKey
|
||||
).catch(() => {})
|
||||
|
||||
return { success: true, message: 'S3 连接测试成功' }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || 'S3 连接测试失败' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getAutoBackupConfig } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以测试 Telegram 发送')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
let { botToken, chatId } = body
|
||||
|
||||
// botToken 为空时从已保存配置中获取
|
||||
if (!botToken) {
|
||||
const config = await getAutoBackupConfig()
|
||||
botToken = config?.methods?.telegram?.botToken || ''
|
||||
chatId = chatId || config?.methods?.telegram?.chatId || ''
|
||||
}
|
||||
|
||||
if (!botToken || !chatId) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少必要参数:botToken, chatId')
|
||||
}
|
||||
|
||||
try {
|
||||
// 先发送一条文本消息确认连接
|
||||
const textRes = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: 'VoiceHub Telegram Bot 连接测试成功!'
|
||||
})
|
||||
})
|
||||
|
||||
const result = await textRes.json() as any
|
||||
if (!result.ok) {
|
||||
throw new Error(result.description || 'Telegram API 返回错误')
|
||||
}
|
||||
|
||||
return { success: true, message: 'Telegram Bot 连接测试成功' }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || 'Telegram Bot 连接测试失败' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getAutoBackupConfig } from '~~/server/services/autoBackupService'
|
||||
|
||||
/** 规范化路径,去除首尾空格和多余斜杠 */
|
||||
function normalizePath(p: string): string {
|
||||
return (p || '').trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以测试 WebDAV 连接')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
let { url, username, password, path: dirPath } = body
|
||||
|
||||
// 密码为空时从已保存配置中获取
|
||||
if (!password) {
|
||||
const config = await getAutoBackupConfig()
|
||||
password = config?.methods?.webdav?.password || ''
|
||||
username = username || config?.methods?.webdav?.username || ''
|
||||
url = url || config?.methods?.webdav?.url || ''
|
||||
dirPath = dirPath || config?.methods?.webdav?.path || ''
|
||||
}
|
||||
|
||||
if (!url || !username || !password) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少必要参数:url, username, password')
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = url.replace(/\/$/, '')
|
||||
const dir = normalizePath(dirPath)
|
||||
const auth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
|
||||
// 递归创建中间目录
|
||||
if (dir) {
|
||||
const segments = dir.split('/')
|
||||
let currentPath = baseUrl
|
||||
for (const segment of segments) {
|
||||
currentPath += `/${segment}`
|
||||
const res = await fetch(currentPath, {
|
||||
method: 'MKCOL',
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
if (!res.ok && res.status !== 405 && res.status !== 409) {
|
||||
throw new Error(`创建目录失败: ${currentPath} (${res.status})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testUrl = dir ? `${baseUrl}/${dir}/.voicehub-test-connection` : `${baseUrl}/.voicehub-test-connection`
|
||||
|
||||
// 写入测试文件
|
||||
const putRes = await fetch(testUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'text/plain'
|
||||
},
|
||||
body: 'VoiceHub WebDAV connection test'
|
||||
})
|
||||
|
||||
if (!putRes.ok) {
|
||||
throw new Error(`WebDAV 写入失败: ${putRes.status} ${putRes.statusText}`)
|
||||
}
|
||||
|
||||
// 清理测试文件
|
||||
await fetch(testUrl, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
}).catch(() => {})
|
||||
|
||||
return { success: true, message: 'WebDAV 连接测试成功' }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || 'WebDAV 连接测试失败' }
|
||||
}
|
||||
})
|
||||
@@ -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 || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以上传备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
// 创建备份目录
|
||||
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,
|
||||
message: '请选择要上传的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
message: '备份文件格式无效'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证备份文件结构
|
||||
if (!backupData.metadata && !backupData.users) {
|
||||
await fs.unlink(uploadedFile.filepath)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '不是有效的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
// 生成新的文件名
|
||||
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,
|
||||
message: error.message || '上传备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createError, defineEventHandler, getRouterParam } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songBlacklists } 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 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))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '黑名单项删除成功'
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
console.error('删除黑名单项失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '删除黑名单项失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songBlacklists } 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 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()
|
||||
|
||||
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,95 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songBlacklists } from '~/drizzle/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
BLACKLIST_TYPES,
|
||||
BLACKLIST_LANGUAGE_VALUES,
|
||||
BLACKLIST_GENRE_VALUES
|
||||
} from '~~/server/config/constants'
|
||||
|
||||
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 (!Object.values(BLACKLIST_TYPES).includes(type)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的黑名单类型'
|
||||
})
|
||||
}
|
||||
|
||||
// 语种/曲风为固定候选值,防止录入无法匹配的自由文本
|
||||
if (type === BLACKLIST_TYPES.LANGUAGE && !BLACKLIST_LANGUAGE_VALUES.includes(value.trim())) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的语种候选值'
|
||||
})
|
||||
}
|
||||
if (type === BLACKLIST_TYPES.GENRE && !BLACKLIST_GENRE_VALUES.includes(value.trim())) {
|
||||
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()
|
||||
|
||||
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,96 @@
|
||||
import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { cardCodes, cardCodeRedeemLogs } from '~/drizzle/schema'
|
||||
import { CARD_CODE_STATUSES } from '../../../card-codes/statuses'
|
||||
|
||||
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 id = Number(getRouterParam(event, 'id'))
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw createError({ statusCode: 400, message: '点歌券ID无效' })
|
||||
}
|
||||
|
||||
const body = await readBody(event) ?? {}
|
||||
const status = typeof body.status === 'string' ? body.status.trim().toUpperCase() : ''
|
||||
const note = typeof body.note === 'string' ? body.note.trim() || null : undefined
|
||||
|
||||
if (!status && note === undefined) {
|
||||
throw createError({ statusCode: 400, message: '没有需要更新的字段' })
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
// 获取当前状态
|
||||
const rows = await tx.select().from(cardCodes).where(eq(cardCodes.id, id)).limit(1)
|
||||
const current = rows[0]
|
||||
if (!current) throw createError({ statusCode: 404, message: '点歌券未找到' })
|
||||
|
||||
const newValues: any = { updatedAt: new Date() }
|
||||
const now = new Date()
|
||||
|
||||
if (status) {
|
||||
if (!CARD_CODE_STATUSES.includes(status as any)) {
|
||||
throw createError({ statusCode: 400, message: '不支持的状态值' })
|
||||
}
|
||||
if (status === 'REDEEMED') {
|
||||
newValues.status = 'REDEEMED'
|
||||
newValues.redeemedBy = user.id
|
||||
newValues.redeemedAt = now
|
||||
// 清理锁定信息
|
||||
newValues.lockedBy = null
|
||||
newValues.lockedAt = null
|
||||
} else if (status === 'AVAILABLE') {
|
||||
newValues.status = 'AVAILABLE'
|
||||
newValues.lockedBy = null
|
||||
newValues.lockedAt = null
|
||||
newValues.redeemedBy = null
|
||||
newValues.redeemedAt = null
|
||||
} else if (status === 'LOCKED') {
|
||||
newValues.status = 'LOCKED'
|
||||
newValues.lockedBy = user.id
|
||||
newValues.lockedAt = now
|
||||
newValues.redeemedBy = null
|
||||
newValues.redeemedAt = null
|
||||
} else if (status === 'INVALID') {
|
||||
newValues.status = 'INVALID'
|
||||
// 作废时清理锁定与核销信息,与批量更新行为一致
|
||||
newValues.lockedBy = null
|
||||
newValues.lockedAt = null
|
||||
newValues.redeemedBy = null
|
||||
newValues.redeemedAt = null
|
||||
}
|
||||
}
|
||||
|
||||
if (note !== undefined) newValues.note = note
|
||||
|
||||
const res = await tx.update(cardCodes).set(newValues).where(eq(cardCodes.id, id)).returning()
|
||||
const newRow = res[0]
|
||||
|
||||
if (status === 'REDEEMED' && current.status !== status) {
|
||||
await tx.insert(cardCodeRedeemLogs).values({
|
||||
cardCodeId: id,
|
||||
codeSnapshot: newRow.code,
|
||||
redeemedBy: user.id,
|
||||
redeemedAt: newRow.redeemedAt || now,
|
||||
source: 'ADMIN_MANUAL',
|
||||
songId: null
|
||||
})
|
||||
}
|
||||
|
||||
return newRow
|
||||
})
|
||||
|
||||
return { success: true, data: updated }
|
||||
} catch (err: any) {
|
||||
console.error('更新点歌券失败:', err)
|
||||
throw createError({ statusCode: err.statusCode || 500, message: err.message || '更新失败' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { cardCodes } from '~/drizzle/schema'
|
||||
import { inArray } 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 body = await readBody(event) ?? {}
|
||||
|
||||
const rawCodes = Array.isArray(body.codes) ? body.codes : body.codes ? [body.codes] : []
|
||||
const codes = rawCodes.map((c: string) => String(c).trim().toUpperCase()).filter(Boolean)
|
||||
|
||||
const MAX_BATCH_COUNT = 10000
|
||||
const parsedCount = Number(body.count)
|
||||
const requestedBatchCount = Number.isInteger(parsedCount) ? parsedCount : 0
|
||||
if (requestedBatchCount > MAX_BATCH_COUNT) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `单次最多生成 ${MAX_BATCH_COUNT} 个点歌券`
|
||||
})
|
||||
}
|
||||
const batchCount = requestedBatchCount
|
||||
const prefix = typeof body.prefix === 'string' ? body.prefix.trim().toUpperCase() : ''
|
||||
const parsedLength = Number(body.length)
|
||||
const length = Number.isInteger(parsedLength) ? parsedLength : 12
|
||||
if (length > 64) {
|
||||
throw createError({ statusCode: 400, message: '点歌券随机长度不能超过 64 位' })
|
||||
}
|
||||
const charsetInput = typeof body.charset === 'string' && body.charset.trim() ? body.charset.trim().toUpperCase() : 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const charset = [...new Set(charsetInput.split(''))].join('')
|
||||
if (!charset) {
|
||||
throw createError({ statusCode: 400, message: '字符集不能为空' })
|
||||
}
|
||||
|
||||
const manualCodeSet = new Set(codes)
|
||||
const generatedCodeSet = new Set<string>()
|
||||
if (batchCount > 0) {
|
||||
const actualLength = Math.max(4, length)
|
||||
const maxPossibleCodes = charset.length ** actualLength
|
||||
if (maxPossibleCodes < batchCount) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '可用字符集过小或长度不足,无法生成足够数量的唯一点歌券,请增大字符集或长度'
|
||||
})
|
||||
}
|
||||
|
||||
const makeRandom = () => {
|
||||
let code = prefix
|
||||
for (let i = 0; i < actualLength; i++) {
|
||||
code += charset.charAt(Math.floor(Math.random() * charset.length))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = batchCount * 100
|
||||
while (generatedCodeSet.size < batchCount && attempts < maxAttempts) {
|
||||
attempts++
|
||||
const next = makeRandom()
|
||||
if (!generatedCodeSet.has(next) && !manualCodeSet.has(next)) {
|
||||
generatedCodeSet.add(next)
|
||||
}
|
||||
}
|
||||
if (generatedCodeSet.size < batchCount) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '可用字符集过小或长度不足,无法生成足够数量的唯一点歌券,请增大字符集或长度'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const finalCodes = [...codes, ...generatedCodeSet]
|
||||
if (!finalCodes.length) {
|
||||
throw createError({ statusCode: 400, message: '请提供要创建的点歌券或生成数量' })
|
||||
}
|
||||
if (finalCodes.length > MAX_BATCH_COUNT) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `单次最多创建 ${MAX_BATCH_COUNT} 个点歌券`
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const uniqueCodes = [...new Set(finalCodes)]
|
||||
const existingRows = await db
|
||||
.select({ code: cardCodes.code })
|
||||
.from(cardCodes)
|
||||
.where(inArray(cardCodes.code, uniqueCodes))
|
||||
const existingCodes = new Set(existingRows.map((row) => row.code))
|
||||
const insertCodes = uniqueCodes.filter((code) => !existingCodes.has(code))
|
||||
|
||||
if (!insertCodes.length) {
|
||||
throw createError({ statusCode: 400, message: '这些点歌券已经存在,无需重复创建' })
|
||||
}
|
||||
|
||||
const inserts = insertCodes.map((c: string) => ({
|
||||
code: c,
|
||||
status: 'AVAILABLE',
|
||||
note: typeof body.note === 'string' ? body.note.trim() || null : null
|
||||
}))
|
||||
const res = await db.insert(cardCodes).values(inserts).returning()
|
||||
return {
|
||||
success: true,
|
||||
data: res,
|
||||
skipped: uniqueCodes.length - insertCodes.length
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('创建点歌券失败', err)
|
||||
throw createError({ statusCode: err.statusCode || 500, message: err.message || '创建点歌券失败' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { deleteCardCodesByIds } from '~~/server/services/cardCodeDeleteService'
|
||||
|
||||
const parseIds = (body: any): number[] => {
|
||||
const rawIds = Array.isArray(body?.ids) ? body.ids : body?.id !== undefined ? [body.id] : []
|
||||
return Array.from(new Set(
|
||||
rawIds
|
||||
.filter((id: any) => (typeof id === 'number' || typeof id === 'string') && id !== '')
|
||||
.map((id: any) => Number(id))
|
||||
.filter((id: number) => Number.isInteger(id) && id > 0)
|
||||
))
|
||||
}
|
||||
|
||||
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).catch(() => ({})) ?? {}
|
||||
const ids = parseIds(body)
|
||||
if (!ids.length) {
|
||||
throw createError({ statusCode: 400, message: '缺少有效点歌券ID' })
|
||||
}
|
||||
if (ids.length > 500) {
|
||||
throw createError({ statusCode: 400, message: '单次最多删除 500 个点歌券' })
|
||||
}
|
||||
|
||||
try {
|
||||
const deletedRows = await deleteCardCodesByIds(ids)
|
||||
return {
|
||||
success: true,
|
||||
message: '点歌券删除成功',
|
||||
data: {
|
||||
cardCodes: deletedRows,
|
||||
deleted: deletedRows.length,
|
||||
requested: ids.length
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.statusCode) throw err
|
||||
console.error('删除点歌券失败', err)
|
||||
throw createError({ statusCode: 500, message: '删除点歌券失败' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { cardCodes } from '~/drizzle/schema'
|
||||
import { and, desc, eq, ilike, or, inArray } from 'drizzle-orm'
|
||||
import { setHeader } from 'h3'
|
||||
|
||||
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: '权限不足' })
|
||||
}
|
||||
|
||||
try {
|
||||
const query = getQuery(event)
|
||||
const idsParam = typeof query.ids === 'string' ? query.ids.trim() : ''
|
||||
const status = typeof query.status === 'string' ? query.status.trim().toUpperCase() : ''
|
||||
const keyword = typeof query.q === 'string' ? query.q.trim() : ''
|
||||
|
||||
const conditions: any[] = []
|
||||
if (status) conditions.push(eq(cardCodes.status, status as any))
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(cardCodes.code, `%${keyword}%`),
|
||||
ilike(cardCodes.note, `%${keyword}%`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let rows
|
||||
if (idsParam) {
|
||||
const ids = idsParam.split(',').map((s) => Number(s)).filter((id) => Number.isInteger(id) && id > 0)
|
||||
if (ids.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '无效的点歌券ID' })
|
||||
}
|
||||
rows = await db.select().from(cardCodes).where(inArray(cardCodes.id, ids)).orderBy(desc(cardCodes.createdAt))
|
||||
} else {
|
||||
let qb = db.select().from(cardCodes)
|
||||
if (conditions.length) qb = qb.where(and(...conditions))
|
||||
rows = await qb.orderBy(desc(cardCodes.createdAt))
|
||||
}
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
AVAILABLE: '可用',
|
||||
LOCKED: '已锁定',
|
||||
REDEEMED: '已核销',
|
||||
INVALID: '已作废'
|
||||
}
|
||||
const header = ['ID', '点歌券', '状态', '备注', '创建时间', '更新时间', '锁定用户ID', '锁定时间', '核销用户ID', '核销时间']
|
||||
const csvRows = [header.join(',')]
|
||||
for (const r of rows) {
|
||||
const esc = (v: any) => {
|
||||
if (v === null || typeof v === 'undefined') return ''
|
||||
let s = v instanceof Date ? v.toISOString() : String(v)
|
||||
if (/^[=+\-@]/.test(s)) {
|
||||
s = `\t${s}`
|
||||
}
|
||||
return s.includes(',') || s.includes('\n') || s.includes('"') ? '"' + s.replace(/"/g, '""') + '"' : s
|
||||
}
|
||||
csvRows.push([
|
||||
esc(r.id),
|
||||
esc(r.code),
|
||||
esc(statusLabelMap[r.status] || r.status),
|
||||
esc(r.note),
|
||||
esc(r.createdAt),
|
||||
esc(r.updatedAt),
|
||||
esc(r.lockedBy),
|
||||
esc(r.lockedAt),
|
||||
esc(r.redeemedBy),
|
||||
esc(r.redeemedAt)
|
||||
].join(','))
|
||||
}
|
||||
|
||||
const csv = '\ufeff' + csvRows.join('\n')
|
||||
|
||||
setHeader(event, 'Content-Type', 'text/csv; charset=utf-8')
|
||||
setHeader(event, 'Content-Disposition', 'attachment; filename="card-codes.csv"')
|
||||
return csv
|
||||
} catch (err: any) {
|
||||
console.error('导出点歌券失败', err)
|
||||
throw createError({ statusCode: err.statusCode || 500, message: err.message || '导出点歌券失败' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { cardCodes } from '~/drizzle/schema'
|
||||
import { and, count, desc, eq, ilike, or } 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: '权限不足' })
|
||||
}
|
||||
|
||||
try {
|
||||
const query = getQuery(event)
|
||||
const status = typeof query.status === 'string' ? query.status.trim().toUpperCase() : ''
|
||||
const keyword = typeof query.q === 'string' ? query.q.trim() : ''
|
||||
const page = Math.max(1, Number.parseInt(String(query.page || '1'), 10) || 1)
|
||||
const limitInput = Number.parseInt(String(query.limit || '10'), 10) || 10
|
||||
const limit = Math.min(100, Math.max(1, limitInput))
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const conditions = [] as any[]
|
||||
if (status) {
|
||||
conditions.push(eq(cardCodes.status, status as any))
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(cardCodes.code, `%${keyword}%`),
|
||||
ilike(cardCodes.note, `%${keyword}%`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let queryBuilder = db.select().from(cardCodes)
|
||||
let countQueryBuilder = db.select({ count: count() }).from(cardCodes)
|
||||
if (conditions.length > 0) {
|
||||
queryBuilder = queryBuilder.where(and(...conditions))
|
||||
countQueryBuilder = countQueryBuilder.where(and(...conditions))
|
||||
}
|
||||
|
||||
const [result, totalResult, statsRows] = await Promise.all([
|
||||
queryBuilder.orderBy(desc(cardCodes.createdAt)).limit(limit).offset(offset),
|
||||
countQueryBuilder,
|
||||
db.select({ status: cardCodes.status, count: count() }).from(cardCodes).groupBy(cardCodes.status)
|
||||
])
|
||||
|
||||
const stats = { total: 0, available: 0, locked: 0, redeemed: 0 }
|
||||
for (const row of statsRows) {
|
||||
const value = Number(row.count || 0)
|
||||
stats.total += value
|
||||
if (row.status === 'AVAILABLE') stats.available = value
|
||||
if (row.status === 'LOCKED') stats.locked = value
|
||||
if (row.status === 'REDEEMED') stats.redeemed = value
|
||||
}
|
||||
|
||||
const total = Number(totalResult[0]?.count || 0)
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit))
|
||||
},
|
||||
stats
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取点歌券列表失败', err)
|
||||
throw createError({ statusCode: 500, message: '获取点歌券列表失败' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { cardCodeRedeemLogs, cardCodes, songs, users } from '~/drizzle/schema'
|
||||
import { and, desc, eq, gte, ilike, lte, or } 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: '权限不足' })
|
||||
}
|
||||
|
||||
try {
|
||||
const query = getQuery(event)
|
||||
const limitParam = Number(query.limit)
|
||||
const limit = Number.isInteger(limitParam) ? Math.min(Math.max(limitParam, 1), 200) : 50
|
||||
|
||||
const keyword = typeof query.q === 'string' ? query.q.trim() : ''
|
||||
const source = typeof query.source === 'string' ? query.source.trim().toUpperCase() : ''
|
||||
const redeemer = typeof query.redeemer === 'string' ? query.redeemer.trim() : ''
|
||||
const cardCode = typeof query.cardCode === 'string' ? query.cardCode.trim() : ''
|
||||
const songKeyword = typeof query.song === 'string' ? query.song.trim() : ''
|
||||
const startDate = typeof query.startDate === 'string' ? query.startDate.trim() : ''
|
||||
const endDate = typeof query.endDate === 'string' ? query.endDate.trim() : ''
|
||||
|
||||
const conditions: any[] = []
|
||||
|
||||
if (source) {
|
||||
conditions.push(eq(cardCodeRedeemLogs.source, source as any))
|
||||
}
|
||||
|
||||
if (cardCode) {
|
||||
conditions.push(or(ilike(cardCodes.code, `%${cardCode}%`), ilike(cardCodeRedeemLogs.codeSnapshot, `%${cardCode}%`)))
|
||||
}
|
||||
|
||||
if (redeemer) {
|
||||
conditions.push(or(ilike(users.name, `%${redeemer}%`), ilike(users.username, `%${redeemer}%`)))
|
||||
}
|
||||
|
||||
if (songKeyword) {
|
||||
conditions.push(or(ilike(songs.title, `%${songKeyword}%`), ilike(songs.artist, `%${songKeyword}%`)))
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(cardCodes.code, `%${keyword}%`),
|
||||
ilike(cardCodeRedeemLogs.codeSnapshot, `%${keyword}%`),
|
||||
ilike(users.name, `%${keyword}%`),
|
||||
ilike(users.username, `%${keyword}%`),
|
||||
ilike(songs.title, `%${keyword}%`),
|
||||
ilike(songs.artist, `%${keyword}%`),
|
||||
ilike(cardCodeRedeemLogs.source, `%${keyword}%`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const startDateValue = startDate ? new Date(startDate) : null
|
||||
if (startDateValue && !Number.isNaN(startDateValue.getTime())) {
|
||||
conditions.push(gte(cardCodeRedeemLogs.redeemedAt, startDateValue))
|
||||
}
|
||||
|
||||
const endDateValue = endDate ? new Date(endDate) : null
|
||||
if (endDateValue && !Number.isNaN(endDateValue.getTime())) {
|
||||
const adjustedEndDate = new Date(endDateValue)
|
||||
adjustedEndDate.setHours(23, 59, 59, 999)
|
||||
conditions.push(lte(cardCodeRedeemLogs.redeemedAt, adjustedEndDate))
|
||||
}
|
||||
|
||||
let queryBuilder = db
|
||||
.select({
|
||||
id: cardCodeRedeemLogs.id,
|
||||
cardCodeId: cardCodeRedeemLogs.cardCodeId,
|
||||
codeSnapshot: cardCodeRedeemLogs.codeSnapshot,
|
||||
redeemedAt: cardCodeRedeemLogs.redeemedAt,
|
||||
source: cardCodeRedeemLogs.source,
|
||||
songId: cardCodeRedeemLogs.songId,
|
||||
currentCode: cardCodes.code,
|
||||
songTitle: songs.title,
|
||||
songArtist: songs.artist,
|
||||
redeemerId: users.id,
|
||||
redeemerName: users.name,
|
||||
redeemerUsername: users.username
|
||||
})
|
||||
.from(cardCodeRedeemLogs)
|
||||
.leftJoin(users, eq(cardCodeRedeemLogs.redeemedBy, users.id))
|
||||
.leftJoin(cardCodes, eq(cardCodeRedeemLogs.cardCodeId, cardCodes.id))
|
||||
.leftJoin(songs, eq(cardCodeRedeemLogs.songId, songs.id))
|
||||
|
||||
if (conditions.length > 0) {
|
||||
queryBuilder = queryBuilder.where(and(...conditions))
|
||||
}
|
||||
|
||||
const result = await queryBuilder
|
||||
.orderBy(desc(cardCodeRedeemLogs.redeemedAt), desc(cardCodeRedeemLogs.id))
|
||||
.limit(limit)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.map((item) => ({
|
||||
id: item.id,
|
||||
cardCodeId: item.cardCodeId,
|
||||
code: item.currentCode || item.codeSnapshot,
|
||||
codeSnapshot: item.codeSnapshot,
|
||||
redeemedAt: item.redeemedAt,
|
||||
source: item.source,
|
||||
song: item.songId
|
||||
? {
|
||||
id: item.songId,
|
||||
title: item.songTitle || '未知歌曲',
|
||||
artist: item.songArtist || ''
|
||||
}
|
||||
: null,
|
||||
redeemer: {
|
||||
id: item.redeemerId,
|
||||
name: item.redeemerName || '未知用户',
|
||||
username: item.redeemerUsername || 'unknown'
|
||||
}
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取点歌券兑换日志失败', err)
|
||||
throw createError({ statusCode: 500, message: '获取点歌券兑换日志失败' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { cardCodes, cardCodeRedeemLogs } from '~/drizzle/schema'
|
||||
import { inArray, eq } from 'drizzle-orm'
|
||||
import { CARD_CODE_STATUSES } from '../../../card-codes/statuses'
|
||||
|
||||
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) ?? {}
|
||||
const ids = Array.isArray(body.ids) ? body.ids : body.id ? [body.id] : []
|
||||
const status = typeof body.status === 'string' ? body.status.trim().toUpperCase() : ''
|
||||
if (!ids.length) {
|
||||
throw createError({ statusCode: 400, message: '缺少点歌券ID' })
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedIds = ids.map((id: any) => Number(id)).filter((id: number) => Number.isInteger(id) && id > 0)
|
||||
if (!normalizedIds.length) {
|
||||
throw createError({ statusCode: 400, message: '点歌券ID无效' })
|
||||
}
|
||||
|
||||
const beforeRows = await db
|
||||
.select({
|
||||
id: cardCodes.id,
|
||||
code: cardCodes.code,
|
||||
status: cardCodes.status
|
||||
})
|
||||
.from(cardCodes)
|
||||
.where(inArray(cardCodes.id, normalizedIds))
|
||||
|
||||
const beforeMap = new Map(beforeRows.map((row) => [row.id, row]))
|
||||
|
||||
const updateObj: any = {}
|
||||
if (status) {
|
||||
if (!CARD_CODE_STATUSES.includes(status as any)) {
|
||||
throw createError({ statusCode: 400, message: '不支持的状态值' })
|
||||
}
|
||||
updateObj.status = status
|
||||
}
|
||||
if (status === 'REDEEMED') {
|
||||
updateObj.redeemedBy = user.id
|
||||
updateObj.redeemedAt = new Date()
|
||||
updateObj.lockedBy = null
|
||||
updateObj.lockedAt = null
|
||||
}
|
||||
if (status === 'LOCKED') {
|
||||
updateObj.lockedBy = user.id
|
||||
updateObj.lockedAt = new Date()
|
||||
updateObj.redeemedBy = null
|
||||
updateObj.redeemedAt = null
|
||||
}
|
||||
if (status === 'AVAILABLE') {
|
||||
updateObj.lockedBy = null
|
||||
updateObj.lockedAt = null
|
||||
updateObj.redeemedBy = null
|
||||
updateObj.redeemedAt = null
|
||||
}
|
||||
if (status === 'INVALID') {
|
||||
updateObj.lockedBy = null
|
||||
updateObj.lockedAt = null
|
||||
updateObj.redeemedBy = null
|
||||
updateObj.redeemedAt = null
|
||||
}
|
||||
if (typeof body.note !== 'undefined') {
|
||||
updateObj.note = typeof body.note === 'string' ? body.note.trim() || null : body.note
|
||||
}
|
||||
if (Object.keys(updateObj).length === 0) {
|
||||
throw createError({ statusCode: 400, message: '没有需要更新的字段' })
|
||||
}
|
||||
updateObj.updatedAt = new Date()
|
||||
|
||||
const res = await db.transaction(async (tx) => {
|
||||
const updatedRows = await tx.update(cardCodes).set(updateObj).where(inArray(cardCodes.id, normalizedIds)).returning()
|
||||
|
||||
if (status === 'REDEEMED' && updatedRows.length > 0) {
|
||||
const logsToInsert = updatedRows
|
||||
.filter((row) => {
|
||||
const before = beforeMap.get(row.id)
|
||||
return before && before.status !== status
|
||||
})
|
||||
.map((row) => ({
|
||||
cardCodeId: row.id,
|
||||
codeSnapshot: row.code,
|
||||
redeemedBy: user.id,
|
||||
redeemedAt: row.redeemedAt || new Date(),
|
||||
source: 'ADMIN_MANUAL',
|
||||
songId: null
|
||||
}))
|
||||
|
||||
if (logsToInsert.length > 0) {
|
||||
await tx.insert(cardCodeRedeemLogs).values(logsToInsert)
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRows
|
||||
})
|
||||
|
||||
return { success: true, data: res }
|
||||
} catch (err: any) {
|
||||
console.error('更新点歌券失败', err)
|
||||
throw createError({ statusCode: err.statusCode || 500, message: err.message || '更新点歌券失败' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
message: authResult.message
|
||||
})
|
||||
}
|
||||
|
||||
const cleanedCount = await databaseManager.cleanupExpiredSessions()
|
||||
|
||||
databaseManager.clearHealthCheckCache()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully cleaned up ${cleanedCount} expired sessions`,
|
||||
cleanedCount,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 401) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 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,
|
||||
message: 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,
|
||||
message: '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,
|
||||
message: 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,
|
||||
message: 'Internal Server Error'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import { createError, defineEventHandler } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
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'
|
||||
|
||||
function getFirstRow<T>(result: any): T | undefined {
|
||||
return result?.rows?.[0] ?? result?.[0]
|
||||
}
|
||||
|
||||
function quoteIdentifierPart(value: string) {
|
||||
const normalized =
|
||||
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/""/g, '"') : value
|
||||
|
||||
return `"${normalized.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function toQualifiedIdentifier(value: string) {
|
||||
return sql.raw(value.split('.').map(quoteIdentifierPart).join('.'))
|
||||
}
|
||||
|
||||
function toPgRelationName(value: string) {
|
||||
return value.split('.').map(quoteIdentifierPart).join('.')
|
||||
}
|
||||
|
||||
function toRegclass(value: string) {
|
||||
return sql.raw(`'${value.replace(/'/g, "''")}'::regclass`)
|
||||
}
|
||||
|
||||
// 重置所有表的自增序列
|
||||
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 {
|
||||
// 获取表的最大ID
|
||||
const maxIdResult = await db.execute(
|
||||
sql`SELECT MAX(id) as max_id FROM ${toQualifiedIdentifier(table.dbName)}`
|
||||
)
|
||||
const maxId = Number(
|
||||
getFirstRow<{ max_id: number | string | null }>(maxIdResult)?.max_id || 0
|
||||
)
|
||||
|
||||
// 获取序列名称
|
||||
const sequenceNameResult = await db.execute(
|
||||
sql`SELECT pg_get_serial_sequence(${toPgRelationName(table.dbName)}, 'id') as sequence_name`
|
||||
)
|
||||
const sequenceName = getFirstRow<{ sequence_name: string | null }>(
|
||||
sequenceNameResult
|
||||
)?.sequence_name
|
||||
|
||||
if (sequenceName) {
|
||||
if (maxId === 0) {
|
||||
await db.execute(
|
||||
sql`ALTER SEQUENCE ${toQualifiedIdentifier(sequenceName)} RESTART WITH 1`
|
||||
)
|
||||
} else {
|
||||
const newSequenceValue = maxId
|
||||
await db.execute(sql`SELECT setval(${toRegclass(sequenceName)}, ${newSequenceValue})`)
|
||||
}
|
||||
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,
|
||||
message: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
} catch (error) {
|
||||
console.error('重置数据库失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `重置数据库失败:${error.message}`
|
||||
})
|
||||
}
|
||||
|
||||
return resetResults
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '服务器内部错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
message: 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,
|
||||
message: '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,
|
||||
message: '检查数据库状态失败:' + 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,213 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
function getFirstRow<T>(result: any): T | undefined {
|
||||
return result?.rows?.[0] ?? result?.[0]
|
||||
}
|
||||
|
||||
function quoteIdentifierPart(value: string) {
|
||||
const normalized =
|
||||
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/""/g, '"') : value
|
||||
|
||||
return `"${normalized.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function toQualifiedIdentifier(value: string) {
|
||||
return sql.raw(value.split('.').map(quoteIdentifierPart).join('.'))
|
||||
}
|
||||
|
||||
function toPgRelationName(value: string) {
|
||||
return value.split('.').map(quoteIdentifierPart).join('.')
|
||||
}
|
||||
|
||||
function toRegclass(value: string) {
|
||||
return sql.raw(`'${value.replace(/'/g, "''")}'::regclass`)
|
||||
}
|
||||
|
||||
// 修复单个表的序列
|
||||
async function fixTableSequence(table: string, dbTableName: string) {
|
||||
try {
|
||||
// 获取表的最大ID
|
||||
const maxIdResult = await db.execute(
|
||||
sql`SELECT MAX(id) as max_id FROM ${toQualifiedIdentifier(dbTableName)}`
|
||||
)
|
||||
const maxId = Number(getFirstRow<{ max_id: number | string | null }>(maxIdResult)?.max_id || 0)
|
||||
|
||||
// 获取序列名称
|
||||
const sequenceNameResult = await db.execute(
|
||||
sql`SELECT pg_get_serial_sequence(${toPgRelationName(dbTableName)}, 'id') as sequence_name`
|
||||
)
|
||||
const sequenceName = getFirstRow<{ sequence_name: string | null }>(sequenceNameResult)?.sequence_name
|
||||
|
||||
if (!sequenceName) {
|
||||
return {
|
||||
success: false,
|
||||
table: table,
|
||||
error: `无法找到表 ${table}.id 的序列`
|
||||
}
|
||||
}
|
||||
|
||||
if (maxId === 0) {
|
||||
await db.execute(sql`ALTER SEQUENCE ${toQualifiedIdentifier(sequenceName)} RESTART WITH 1`)
|
||||
return {
|
||||
success: true,
|
||||
table: table,
|
||||
message: `表 ${table} 为空,序列已重置为 1`,
|
||||
data: {
|
||||
table: table,
|
||||
maxId: 0,
|
||||
newSequenceValue: 1,
|
||||
sequenceName: sequenceName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentSeqResult = await db.execute(
|
||||
sql`SELECT last_value FROM ${toQualifiedIdentifier(sequenceName)}`
|
||||
)
|
||||
const currentSeqValue = Number(
|
||||
getFirstRow<{ last_value: number | string }>(currentSeqResult)?.last_value || 0
|
||||
)
|
||||
const newSequenceValue = maxId
|
||||
await db.execute(sql`SELECT setval(${toRegclass(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,
|
||||
message: '需要管理员权限'
|
||||
})
|
||||
}
|
||||
|
||||
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,36 @@
|
||||
import { defineEventHandler, getRouterParam } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { gradeClass } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
const user = event.context.user
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '没有权限访问')
|
||||
}
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '无效的配置 ID')
|
||||
}
|
||||
|
||||
const existing = await db.query.gradeClass.findFirst({
|
||||
where: eq(gradeClass.id, id),
|
||||
columns: { id: true }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.COMMON_TARGET_NOT_FOUND, '配置项不存在')
|
||||
}
|
||||
|
||||
// 仅删除配置行,不触碰任何用户数据
|
||||
await db.delete(gradeClass).where(eq(gradeClass.id, id))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '配置项已删除'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { gradeClass } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
const user = event.context.user
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '没有权限访问')
|
||||
}
|
||||
|
||||
const grade = typeof getQuery(event).grade === 'string' ? getQuery(event).grade.trim() : ''
|
||||
if (!grade) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '年级不能为空')
|
||||
}
|
||||
|
||||
await db.delete(gradeClass).where(eq(gradeClass.grade, grade))
|
||||
|
||||
return {
|
||||
success: true
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { and, eq, isNotNull, sql } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { gradeClass, users } from '~/drizzle/schema'
|
||||
import { smartSort } from '~~/server/utils/grade-class-core'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
const user = event.context.user
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '没有权限访问')
|
||||
}
|
||||
|
||||
const [rows, counts] = await Promise.all([
|
||||
db.select().from(gradeClass),
|
||||
// 当前各年级班级的活跃用户数(用于管理界面人数展示)
|
||||
db
|
||||
.select({
|
||||
grade: users.grade,
|
||||
class: users.class,
|
||||
count: sql<number>`count(*)::int`
|
||||
})
|
||||
.from(users)
|
||||
.where(and(eq(users.status, 'active'), isNotNull(users.grade), isNotNull(users.class)))
|
||||
.groupBy(users.grade, users.class)
|
||||
])
|
||||
|
||||
const countMap = new Map(
|
||||
counts.map((row) => [`${row.grade}\u0000${row.class}`, row.count])
|
||||
)
|
||||
|
||||
const items = rows
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
grade: item.grade,
|
||||
class: item.class,
|
||||
userCount: countMap.get(`${item.grade}\u0000${item.class}`) || 0
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const gradeResult = smartSort(a.grade, b.grade)
|
||||
return gradeResult || smartSort(a.class, b.class)
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
items
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { gradeClass } from '~/drizzle/schema'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
const MAX_BATCH_SIZE = 500
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
const user = event.context.user
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '没有权限访问')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
// 批量模式:items 数组,已存在的组合跳过,返回新增/跳过数量
|
||||
if (Array.isArray(body?.items)) {
|
||||
if (body.items.length > MAX_BATCH_SIZE) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `单次批量最多 ${MAX_BATCH_SIZE} 项`)
|
||||
}
|
||||
|
||||
const pairs = body.items
|
||||
.map((item) => ({
|
||||
grade: typeof item?.grade === 'string' ? item.grade.trim() : '',
|
||||
class: typeof item?.class === 'string' ? item.class.trim() : ''
|
||||
}))
|
||||
.filter((item) => item.grade && item.class)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const uniquePairs = pairs.filter((item) => {
|
||||
const key = `${item.grade}\u0000${item.class}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
if (uniquePairs.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '年级和班级不能为空')
|
||||
}
|
||||
|
||||
const existingRows = await db
|
||||
.select({ grade: gradeClass.grade, class: gradeClass.class })
|
||||
.from(gradeClass)
|
||||
const existingKeys = new Set(existingRows.map((row) => `${row.grade}\u0000${row.class}`))
|
||||
|
||||
const toInsert = uniquePairs.filter((item) => !existingKeys.has(`${item.grade}\u0000${item.class}`))
|
||||
const skipped = uniquePairs.length - toInsert.length
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
// onConflictDoNothing 兜底并发下的唯一约束冲突(预查已跳过存量,此处防竞态)
|
||||
await db.insert(gradeClass).values(toInsert).onConflictDoNothing()
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
added: toInsert.length,
|
||||
skipped
|
||||
}
|
||||
}
|
||||
|
||||
// 单条模式(插入用 onConflictDoNothing 兜底并发唯一冲突,冲突返回 409)
|
||||
const grade = typeof body.grade === 'string' ? body.grade.trim() : ''
|
||||
const studentClass = typeof body.class === 'string' ? body.class.trim() : ''
|
||||
|
||||
if (!grade || !studentClass) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '年级和班级不能为空')
|
||||
}
|
||||
|
||||
const inserted = (await db
|
||||
.insert(gradeClass)
|
||||
.values({ grade, class: studentClass })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: gradeClass.id, grade: gradeClass.grade, class: gradeClass.class }))[0]
|
||||
|
||||
if (!inserted) {
|
||||
throw createApiError(409, SERVER_ERROR_CODES.GRADE_CLASS_DUPLICATE, '该年级班级组合已存在')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
item: inserted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { and, eq, isNotNull } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { gradeClass, users } from '~/drizzle/schema'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
const MAX_BATCH_SIZE = 500
|
||||
|
||||
// 从现有用户提取年级班级组合并写入配置(仅 active 用户)
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
const user = event.context.user
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '没有权限访问')
|
||||
}
|
||||
|
||||
const userCombos = await db
|
||||
.selectDistinct({
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
})
|
||||
.from(users)
|
||||
.where(and(eq(users.status, 'active'), isNotNull(users.grade), isNotNull(users.class)))
|
||||
|
||||
const pairs = userCombos
|
||||
.map((item) => ({
|
||||
grade: typeof item.grade === 'string' ? item.grade.trim() : '',
|
||||
class: typeof item.class === 'string' ? item.class.trim() : ''
|
||||
}))
|
||||
.filter((item) => item.grade && item.class)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const uniquePairs = pairs.filter((item) => {
|
||||
const key = `${item.grade}\u0000${item.class}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
const existingRows = await db
|
||||
.select({ grade: gradeClass.grade, class: gradeClass.class })
|
||||
.from(gradeClass)
|
||||
const existingKeys = new Set(existingRows.map((row) => `${row.grade}\u0000${row.class}`))
|
||||
const toInsert = uniquePairs.filter((item) => !existingKeys.has(`${item.grade}\u0000${item.class}`))
|
||||
|
||||
let added = 0
|
||||
for (let i = 0; i < toInsert.length; i += MAX_BATCH_SIZE) {
|
||||
const batch = toInsert.slice(i, i + MAX_BATCH_SIZE)
|
||||
const inserted = await db
|
||||
.insert(gradeClass)
|
||||
.values(batch)
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: gradeClass.id })
|
||||
added += inserted.length
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
total: uniquePairs.length,
|
||||
added,
|
||||
skipped: uniquePairs.length - added
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { and, asc, count, desc, eq, ilike, isNotNull, or, sql } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notifications } from '~/drizzle/schema'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
NOTIFICATION_SOURCES,
|
||||
serializeNotificationSender
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import {
|
||||
resolveNotificationHistoryFilters,
|
||||
resolveNotificationHistoryPagination
|
||||
} from '~~/server/utils/notification-history-policy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(
|
||||
401,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED,
|
||||
'请先登录后查看通知历史'
|
||||
)
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以查看通知历史'
|
||||
)
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const filters = resolveNotificationHistoryFilters({
|
||||
keyword: query.keyword,
|
||||
type: query.type,
|
||||
sender: query.sender,
|
||||
sortOrder: query.sortOrder
|
||||
})
|
||||
if (!filters) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_FILTER_INVALID,
|
||||
'通知历史筛选条件无效'
|
||||
)
|
||||
}
|
||||
|
||||
const { page, limit, offset } = resolveNotificationHistoryPagination(query.page, query.limit)
|
||||
const baseCondition = and(
|
||||
eq(notifications.type, 'SYSTEM_NOTICE'),
|
||||
eq(notifications.source, NOTIFICATION_SOURCES.ADMIN_MANUAL)
|
||||
)
|
||||
// Drizzle ORM 的 ilike 已对参数做参数化绑定,无需手动转义 % 和 _
|
||||
const keywordPattern = filters.keyword ? `%${filters.keyword}%` : null
|
||||
const keywordCondition = keywordPattern
|
||||
? or(ilike(notifications.title, keywordPattern), ilike(notifications.message, keywordPattern))
|
||||
: undefined
|
||||
const typeCondition =
|
||||
filters.type === 'IMPORTANT'
|
||||
? eq(notifications.important, true)
|
||||
: filters.type === 'NORMAL'
|
||||
? eq(notifications.important, false)
|
||||
: undefined
|
||||
const senderCondition = filters.senderId
|
||||
? eq(notifications.senderId, filters.senderId)
|
||||
: undefined
|
||||
const batchKey = sql<string>`coalesce(
|
||||
${notifications.batchId},
|
||||
'legacy-' || cast(${notifications.id} as text)
|
||||
)`
|
||||
|
||||
const notificationBatches = db
|
||||
.select({
|
||||
batchId: batchKey.as('batch_id'),
|
||||
notificationId: sql<number>`max(${notifications.id})`.as('notification_id'),
|
||||
title: notifications.title,
|
||||
message: notifications.message,
|
||||
important: notifications.important,
|
||||
senderId: notifications.senderId,
|
||||
senderName: notifications.senderName,
|
||||
senderUsername: notifications.senderUsername,
|
||||
createdAt: notifications.createdAt,
|
||||
recipientCount: count().as('recipient_count')
|
||||
})
|
||||
.from(notifications)
|
||||
.where(and(baseCondition, keywordCondition, typeCondition, senderCondition))
|
||||
.groupBy(
|
||||
batchKey,
|
||||
notifications.title,
|
||||
notifications.message,
|
||||
notifications.important,
|
||||
notifications.senderId,
|
||||
notifications.senderName,
|
||||
notifications.senderUsername,
|
||||
notifications.createdAt
|
||||
)
|
||||
.as('notification_batches')
|
||||
|
||||
const senderName = sql<string | null>`(
|
||||
array_agg(${notifications.senderName} order by ${notifications.createdAt} desc)
|
||||
)[1]`
|
||||
const senderUsername = sql<string | null>`(
|
||||
array_agg(${notifications.senderUsername} order by ${notifications.createdAt} desc)
|
||||
)[1]`
|
||||
const senderOptionsQuery = db
|
||||
.select({
|
||||
id: notifications.senderId,
|
||||
name: senderName.as('sender_name'),
|
||||
username: senderUsername.as('sender_username')
|
||||
})
|
||||
.from(notifications)
|
||||
.where(and(baseCondition, isNotNull(notifications.senderId)))
|
||||
.groupBy(notifications.senderId)
|
||||
.orderBy(asc(notifications.senderId))
|
||||
|
||||
const primarySort =
|
||||
filters.sortOrder === 'ASC'
|
||||
? asc(notificationBatches.createdAt)
|
||||
: desc(notificationBatches.createdAt)
|
||||
const secondarySort =
|
||||
filters.sortOrder === 'ASC'
|
||||
? asc(notificationBatches.notificationId)
|
||||
: desc(notificationBatches.notificationId)
|
||||
|
||||
try {
|
||||
const [historyRows, totalRows, senderRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(notificationBatches)
|
||||
.orderBy(primarySort, secondarySort)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(notificationBatches),
|
||||
senderOptionsQuery
|
||||
])
|
||||
|
||||
const total = Number(totalRows[0]?.count || 0)
|
||||
return {
|
||||
notifications: historyRows.map((row) => ({
|
||||
batchId: row.batchId,
|
||||
title: row.title,
|
||||
message: row.message,
|
||||
important: row.important,
|
||||
sender: serializeNotificationSender({
|
||||
senderId: row.senderId,
|
||||
senderName: row.senderName,
|
||||
senderUsername: row.senderUsername
|
||||
}),
|
||||
createdAt: row.createdAt,
|
||||
recipientCount: Number(row.recipientCount || 0)
|
||||
})),
|
||||
senders: senderRows.map((sender) => ({
|
||||
id: Number(sender.id),
|
||||
name: sender.name,
|
||||
username: sender.username
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取通知历史失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_FETCH_FAILED,
|
||||
'获取通知历史失败'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notifications } from '~/drizzle/schema'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
NOTIFICATION_SOURCES
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import { resolveNotificationBatchReference } from '~~/server/utils/notification-history-policy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED, '请先登录后删除通知')
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以删除通知'
|
||||
)
|
||||
}
|
||||
|
||||
const batchReference = resolveNotificationBatchReference(getRouterParam(event, 'batchId'))
|
||||
if (!batchReference) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
const batchCondition = batchReference.batchId
|
||||
? eq(notifications.batchId, batchReference.batchId)
|
||||
: eq(notifications.id, batchReference.notificationId!)
|
||||
|
||||
let deletedRows
|
||||
try {
|
||||
deletedRows = await db
|
||||
.delete(notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.type, 'SYSTEM_NOTICE'),
|
||||
eq(notifications.source, NOTIFICATION_SOURCES.ADMIN_MANUAL),
|
||||
batchCondition
|
||||
)
|
||||
)
|
||||
.returning({ id: notifications.id })
|
||||
} catch (error) {
|
||||
console.error('删除通知失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_DELETE_FAILED,
|
||||
'删除通知失败'
|
||||
)
|
||||
}
|
||||
|
||||
if (deletedRows.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: deletedRows.length
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { and, count, desc, eq } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notifications, users } from '~/drizzle/schema'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
NOTIFICATION_SOURCES,
|
||||
serializeNotificationSender
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import {
|
||||
resolveNotificationBatchReference,
|
||||
resolveNotificationHistoryPagination,
|
||||
resolveNotificationHistoryStatus
|
||||
} from '~~/server/utils/notification-history-policy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(
|
||||
401,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED,
|
||||
'请先登录后查看通知明细'
|
||||
)
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以查看通知明细'
|
||||
)
|
||||
}
|
||||
|
||||
const batchReference = resolveNotificationBatchReference(getRouterParam(event, 'batchId'))
|
||||
if (!batchReference) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const status = resolveNotificationHistoryStatus(query.status)
|
||||
if (!status) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_STATUS_INVALID,
|
||||
'通知历史状态筛选值无效'
|
||||
)
|
||||
}
|
||||
|
||||
const { page, limit, offset } = resolveNotificationHistoryPagination(query.page, query.limit)
|
||||
const batchCondition = batchReference.batchId
|
||||
? eq(notifications.batchId, batchReference.batchId)
|
||||
: eq(notifications.id, batchReference.notificationId!)
|
||||
const baseCondition = and(
|
||||
eq(notifications.type, 'SYSTEM_NOTICE'),
|
||||
eq(notifications.source, NOTIFICATION_SOURCES.ADMIN_MANUAL),
|
||||
batchCondition
|
||||
)!
|
||||
const filteredConditions = [baseCondition]
|
||||
if (status === 'READ') filteredConditions.push(eq(notifications.read, true))
|
||||
if (status === 'UNREAD') filteredConditions.push(eq(notifications.read, false))
|
||||
const filteredCondition = and(...filteredConditions)!
|
||||
|
||||
let queryResult
|
||||
|
||||
try {
|
||||
queryResult = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: notifications.id,
|
||||
read: notifications.read,
|
||||
updatedAt: notifications.updatedAt,
|
||||
userId: notifications.userId,
|
||||
username: users.username,
|
||||
userName: users.name,
|
||||
grade: users.grade,
|
||||
className: users.class
|
||||
})
|
||||
.from(notifications)
|
||||
.leftJoin(users, eq(notifications.userId, users.id))
|
||||
.where(filteredCondition)
|
||||
.orderBy(desc(notifications.id))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(notifications).where(filteredCondition),
|
||||
db
|
||||
.select({ read: notifications.read, count: count() })
|
||||
.from(notifications)
|
||||
.where(baseCondition)
|
||||
.groupBy(notifications.read),
|
||||
db
|
||||
.select({
|
||||
title: notifications.title,
|
||||
message: notifications.message,
|
||||
important: notifications.important,
|
||||
senderId: notifications.senderId,
|
||||
senderName: notifications.senderName,
|
||||
senderUsername: notifications.senderUsername,
|
||||
createdAt: notifications.createdAt
|
||||
})
|
||||
.from(notifications)
|
||||
.where(baseCondition)
|
||||
.limit(1)
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('获取通知明细失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_FETCH_FAILED,
|
||||
'获取通知明细失败'
|
||||
)
|
||||
}
|
||||
|
||||
const [detailRows, totalRows, statsRows, metadataRows] = queryResult
|
||||
|
||||
const metadata = metadataRows[0]
|
||||
if (!metadata) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
const stats = { total: 0, read: 0, unread: 0 }
|
||||
for (const row of statsRows) {
|
||||
const value = Number(row.count || 0)
|
||||
stats.total += value
|
||||
if (row.read) stats.read = value
|
||||
else stats.unread = value
|
||||
}
|
||||
|
||||
const total = Number(totalRows[0]?.count || 0)
|
||||
const { senderId, senderName, senderUsername, ...notificationMetadata } = metadata
|
||||
return {
|
||||
notification: {
|
||||
batchId: getRouterParam(event, 'batchId'),
|
||||
...notificationMetadata,
|
||||
sender: serializeNotificationSender({ senderId, senderName, senderUsername })
|
||||
},
|
||||
recipients: detailRows.map((row) => ({
|
||||
id: row.id,
|
||||
read: row.read,
|
||||
readAt: row.read ? row.updatedAt : null,
|
||||
recipient: {
|
||||
id: row.userId,
|
||||
username: row.username,
|
||||
name: row.userName,
|
||||
grade: row.grade,
|
||||
class: row.className
|
||||
}
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit))
|
||||
},
|
||||
stats
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notifications } from '~/drizzle/schema'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
createNotificationSenderSnapshot,
|
||||
NOTIFICATION_SOURCES,
|
||||
NOTIFICATION_CONTENT_MAX_LENGTH,
|
||||
NOTIFICATION_TITLE_MAX_LENGTH,
|
||||
resolveImportantFlag,
|
||||
serializeNotificationSender
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import { resolveNotificationBatchReference } from '~~/server/utils/notification-history-policy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED, '请先登录后修改通知')
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以修改通知'
|
||||
)
|
||||
}
|
||||
|
||||
const batchReference = resolveNotificationBatchReference(getRouterParam(event, 'batchId'))
|
||||
if (!batchReference) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const title = typeof body?.title === 'string' ? body.title.trim() : ''
|
||||
const rawContent = typeof body?.content === 'string' ? body.content : body?.message
|
||||
const content = typeof rawContent === 'string' ? rawContent.trim() : ''
|
||||
const important = resolveImportantFlag(body?.important)
|
||||
|
||||
if (important === null) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_IMPORTANT_INVALID,
|
||||
'重要通知标记必须是布尔值'
|
||||
)
|
||||
}
|
||||
|
||||
if (!title || !content) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_TITLE_CONTENT_REQUIRED,
|
||||
'通知标题和内容不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
if (title.length > NOTIFICATION_TITLE_MAX_LENGTH) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_TITLE_TOO_LONG,
|
||||
`通知标题不能超过 ${NOTIFICATION_TITLE_MAX_LENGTH} 个字符`,
|
||||
{ params: [NOTIFICATION_TITLE_MAX_LENGTH] }
|
||||
)
|
||||
}
|
||||
|
||||
if (content.length > NOTIFICATION_CONTENT_MAX_LENGTH) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_CONTENT_TOO_LONG,
|
||||
`通知正文不能超过 ${NOTIFICATION_CONTENT_MAX_LENGTH} 个字符`,
|
||||
{ params: [NOTIFICATION_CONTENT_MAX_LENGTH] }
|
||||
)
|
||||
}
|
||||
|
||||
const batchCondition = batchReference.batchId
|
||||
? eq(notifications.batchId, batchReference.batchId)
|
||||
: eq(notifications.id, batchReference.notificationId!)
|
||||
const baseCondition = and(
|
||||
eq(notifications.type, 'SYSTEM_NOTICE'),
|
||||
eq(notifications.source, NOTIFICATION_SOURCES.ADMIN_MANUAL),
|
||||
batchCondition
|
||||
)!
|
||||
|
||||
if (important) {
|
||||
try {
|
||||
const batchId = randomUUID()
|
||||
const createdAt = new Date()
|
||||
const senderSnapshot = createNotificationSenderSnapshot({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username
|
||||
})
|
||||
|
||||
// 同一事务内删除旧批次并重新下发,避免旧内容继续弹窗、历史列表残留重复记录
|
||||
const recipientUserIds = await db.transaction(async (tx) => {
|
||||
const recipients = await tx
|
||||
.delete(notifications)
|
||||
.where(baseCondition)
|
||||
.returning({ userId: notifications.userId })
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const userIds = [...new Set(recipients.map(({ userId }) => userId))]
|
||||
await tx.insert(notifications).values(
|
||||
userIds.map((userId) => ({
|
||||
userId,
|
||||
type: 'SYSTEM_NOTICE',
|
||||
batchId,
|
||||
source: NOTIFICATION_SOURCES.ADMIN_MANUAL,
|
||||
...senderSnapshot,
|
||||
title,
|
||||
message: content,
|
||||
important: true,
|
||||
read: false,
|
||||
userDeleted: false,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
}))
|
||||
)
|
||||
return userIds
|
||||
})
|
||||
|
||||
if (recipientUserIds.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
createdNewBatch: true,
|
||||
updatedCount: recipientUserIds.length,
|
||||
notification: {
|
||||
batchId,
|
||||
title,
|
||||
message: content,
|
||||
important: true,
|
||||
sender: serializeNotificationSender(senderSnapshot),
|
||||
createdAt,
|
||||
recipientCount: recipientUserIds.length
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error && 'statusCode' in error) throw error
|
||||
console.error('重新发送重要通知失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_UPDATE_FAILED,
|
||||
'修改通知失败'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let updatedRows
|
||||
try {
|
||||
updatedRows = await db
|
||||
.update(notifications)
|
||||
.set({ title, message: content, important: false })
|
||||
.where(baseCondition)
|
||||
.returning({ id: notifications.id })
|
||||
} catch (error) {
|
||||
console.error('修改通知失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_UPDATE_FAILED,
|
||||
'修改通知失败'
|
||||
)
|
||||
}
|
||||
|
||||
if (updatedRows.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedCount: updatedRows.length,
|
||||
notification: { title, message: content, important }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import {
|
||||
createBatchSystemNotifications,
|
||||
createSystemNotification
|
||||
} from '~~/server/services/notificationService'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
NOTIFICATION_CONTENT_MAX_LENGTH,
|
||||
NOTIFICATION_TITLE_MAX_LENGTH,
|
||||
resolveImportantFlag
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
|
||||
type NotificationFilter = {
|
||||
grade?: unknown
|
||||
class?: unknown
|
||||
classes?: unknown
|
||||
userIds?: unknown
|
||||
}
|
||||
|
||||
const supportedScopes = ['ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'] as const
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createApiError(401, SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED, '需要登录才能发送通知')
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以发送系统通知'
|
||||
)
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const sender = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username
|
||||
}
|
||||
const title = typeof body?.title === 'string' ? body.title.trim() : ''
|
||||
const rawContent = typeof body?.content === 'string' ? body.content : body?.message
|
||||
const content = typeof rawContent === 'string' ? rawContent.trim() : ''
|
||||
const important = resolveImportantFlag(body?.important)
|
||||
|
||||
if (important === null) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_IMPORTANT_INVALID,
|
||||
'重要通知标记必须是布尔值'
|
||||
)
|
||||
}
|
||||
|
||||
if (!title || !content) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_TITLE_CONTENT_REQUIRED,
|
||||
'通知标题和内容不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
if (title.length > NOTIFICATION_TITLE_MAX_LENGTH) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_TITLE_TOO_LONG,
|
||||
`通知标题不能超过 ${NOTIFICATION_TITLE_MAX_LENGTH} 个字符`,
|
||||
{ params: [NOTIFICATION_TITLE_MAX_LENGTH] }
|
||||
)
|
||||
}
|
||||
|
||||
if (content.length > NOTIFICATION_CONTENT_MAX_LENGTH) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_CONTENT_TOO_LONG,
|
||||
`通知正文不能超过 ${NOTIFICATION_CONTENT_MAX_LENGTH} 个字符`,
|
||||
{ params: [NOTIFICATION_CONTENT_MAX_LENGTH] }
|
||||
)
|
||||
}
|
||||
|
||||
const targetUserId = body?.userId
|
||||
if (targetUserId !== undefined) {
|
||||
// 兼容旧调用方传入数字字符串的情况
|
||||
const normalizedUserId =
|
||||
typeof targetUserId === 'string' && targetUserId.trim()
|
||||
? Number(targetUserId.trim())
|
||||
: targetUserId
|
||||
if (!Number.isInteger(normalizedUserId) || Number(normalizedUserId) <= 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_USER_ID_INVALID, '无效的用户 ID')
|
||||
}
|
||||
|
||||
const result = await createSystemNotification(
|
||||
Number(normalizedUserId),
|
||||
title,
|
||||
content,
|
||||
important,
|
||||
sender
|
||||
)
|
||||
if (!result) {
|
||||
throw createApiError(500, SERVER_ERROR_CODES.NOTIFICATION_SEND_FAILED, '发送通知失败')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '通知发送成功',
|
||||
sentCount: 1,
|
||||
totalUsers: 1
|
||||
}
|
||||
}
|
||||
|
||||
const scope = typeof body?.scope === 'string' ? body.scope : ''
|
||||
if (!supportedScopes.includes(scope as (typeof supportedScopes)[number])) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_SCOPE_INVALID, '无效的通知范围')
|
||||
}
|
||||
|
||||
const filter = (
|
||||
body?.filter && typeof body.filter === 'object' ? body.filter : {}
|
||||
) as NotificationFilter
|
||||
let userIds: number[]
|
||||
|
||||
// 仅向活跃状态的用户发送通知,排除已退学/毕业用户
|
||||
const activeUserCondition = eq(users.status, 'active')
|
||||
|
||||
if (scope === 'ALL') {
|
||||
const allUsers = await db.select({ id: users.id }).from(users).where(activeUserCondition)
|
||||
userIds = allUsers.map((target) => target.id)
|
||||
} else if (scope === 'GRADE') {
|
||||
const grade = typeof filter.grade === 'string' ? filter.grade.trim() : ''
|
||||
if (!grade) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_GRADE_REQUIRED, '年级不能为空')
|
||||
}
|
||||
|
||||
const gradeUsers = await db.select({ id: users.id }).from(users).where(and(eq(users.grade, grade), activeUserCondition))
|
||||
userIds = gradeUsers.map((target) => target.id)
|
||||
} else if (scope === 'CLASS') {
|
||||
const grade = typeof filter.grade === 'string' ? filter.grade.trim() : ''
|
||||
const className = typeof filter.class === 'string' ? filter.class.trim() : ''
|
||||
if (!grade || !className) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_GRADE_CLASS_REQUIRED,
|
||||
'年级和班级不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
const classUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.grade, grade), eq(users.class, className), activeUserCondition))
|
||||
userIds = classUsers.map((target) => target.id)
|
||||
} else if (scope === 'MULTI_CLASS') {
|
||||
if (!Array.isArray(filter.classes) || filter.classes.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_CLASSES_REQUIRED, '未选择任何班级')
|
||||
}
|
||||
|
||||
const classes = filter.classes.map((entry) => {
|
||||
const candidate = entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : {}
|
||||
return {
|
||||
grade: typeof candidate.grade === 'string' ? candidate.grade.trim() : '',
|
||||
className: typeof candidate.class === 'string' ? candidate.class.trim() : ''
|
||||
}
|
||||
})
|
||||
|
||||
if (classes.some((entry) => !entry.grade || !entry.className)) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_CLASSES_REQUIRED, '班级信息不完整')
|
||||
}
|
||||
|
||||
const whereConditions = classes.map((entry) =>
|
||||
and(eq(users.grade, entry.grade), eq(users.class, entry.className), activeUserCondition)
|
||||
)
|
||||
const multiClassUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(or(...whereConditions))
|
||||
userIds = multiClassUsers.map((target) => target.id)
|
||||
} else {
|
||||
if (!Array.isArray(filter.userIds) || filter.userIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_USERS_REQUIRED, '未选择任何用户')
|
||||
}
|
||||
|
||||
if (filter.userIds.some((id) => !Number.isInteger(id) || Number(id) <= 0)) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_USER_ID_INVALID,
|
||||
'用户 ID 列表包含无效值'
|
||||
)
|
||||
}
|
||||
|
||||
userIds = [...new Set(filter.userIds.map(Number))]
|
||||
}
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: '没有找到符合条件的用户',
|
||||
sentCount: 0,
|
||||
totalUsers: 0
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createBatchSystemNotifications(userIds, title, content, important, sender)
|
||||
if (!result) {
|
||||
throw createApiError(500, SERVER_ERROR_CODES.NOTIFICATION_SEND_FAILED, '发送通知失败')
|
||||
}
|
||||
|
||||
const sentCount = Array.isArray(result) ? result.length : result.count
|
||||
const totalUsers = Array.isArray(result) ? userIds.length : result.total || userIds.length
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '通知发送成功',
|
||||
sentCount,
|
||||
totalUsers
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
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 (!['SONG_ADMIN', '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]
|
||||
|
||||
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]
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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,101 @@
|
||||
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 (!['SONG_ADMIN', '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]
|
||||
|
||||
return newPlayTime
|
||||
} catch (error: any) {
|
||||
console.error('创建播出时段失败:', error)
|
||||
|
||||
// 如果是我们自定义的错误,直接抛出
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '创建播出时段失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { asc, db, desc, playTimes } from '~/drizzle/db'
|
||||
|
||||
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: '只有管理员才能管理播出时段'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const playTimesResult = await db
|
||||
.select()
|
||||
.from(playTimes)
|
||||
.orderBy(desc(playTimes.enabled), asc(playTimes.startTime))
|
||||
|
||||
return playTimesResult
|
||||
} catch (error) {
|
||||
console.error('获取播出时段失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取播出时段失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { db, songs, songReplayRequests, users } from '~/drizzle/db'
|
||||
import { desc, eq, sql, and } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. 检查权限
|
||||
const user = event.context.user
|
||||
if (!user || !['SONG_ADMIN', '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) {
|
||||
conditions.push(eq(songs.semester, semesterName))
|
||||
}
|
||||
|
||||
// 2. 查询
|
||||
// 获取歌曲信息及重播申请数量,同时关联最新申请人信息
|
||||
const requestsQuery = db
|
||||
.select({
|
||||
song: {
|
||||
id: songs.id,
|
||||
createdAt: songs.createdAt,
|
||||
updatedAt: songs.updatedAt,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
requesterId: songs.requesterId,
|
||||
played: songs.played,
|
||||
playedAt: songs.playedAt,
|
||||
semester: songs.semester,
|
||||
preferredPlayTimeId: songs.preferredPlayTimeId,
|
||||
cover: songs.cover,
|
||||
playUrl: songs.playUrl,
|
||||
musicPlatform: songs.musicPlatform,
|
||||
musicId: songs.musicId,
|
||||
durationSeconds: songs.durationSeconds,
|
||||
submissionNote: songs.submissionNote,
|
||||
submissionNotePublic: songs.submissionNotePublic,
|
||||
hitRequestId: songs.hitRequestId
|
||||
},
|
||||
// insert-only 模型下同一用户可能有多条申请,按申请人去重计数
|
||||
requestCount: sql<number>`count(distinct ${songReplayRequests.userId})`
|
||||
.mapWith(Number)
|
||||
.as('request_count'),
|
||||
lastRequestedAt: sql<string>`max(${songReplayRequests.createdAt})`.as('last_requested_at')
|
||||
})
|
||||
.from(songs)
|
||||
.innerJoin(songReplayRequests, eq(songs.id, songReplayRequests.songId))
|
||||
.groupBy(songs.id)
|
||||
.orderBy(desc(sql`request_count`))
|
||||
|
||||
if (conditions.length > 0) {
|
||||
requestsQuery.where(and(...conditions))
|
||||
}
|
||||
|
||||
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 formatDisplayName = (userObj: any) => {
|
||||
if (!userObj || !userObj.name) return '未知用户'
|
||||
let displayName = userObj.name
|
||||
|
||||
const sameNameUsers = nameToUsers.get(displayName)
|
||||
if (sameNameUsers && sameNameUsers.length > 1) {
|
||||
if (userObj.grade) {
|
||||
displayName = `${displayName}(${userObj.grade})`
|
||||
}
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
// 获取详细的重播申请人信息
|
||||
const requestDetailsQuery = db
|
||||
.select({
|
||||
id: songReplayRequests.id,
|
||||
songId: songReplayRequests.songId,
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
},
|
||||
createdAt: songReplayRequests.createdAt,
|
||||
status: songReplayRequests.status,
|
||||
preferredPlayTimeId: songReplayRequests.preferredPlayTimeId,
|
||||
submissionNote: songReplayRequests.submissionNote,
|
||||
submissionNotePublic: songReplayRequests.submissionNotePublic,
|
||||
submissionNotePublicStatus: songReplayRequests.submissionNotePublicStatus
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.innerJoin(users, eq(songReplayRequests.userId, users.id))
|
||||
.innerJoin(songs, eq(songReplayRequests.songId, songs.id))
|
||||
.orderBy(desc(songReplayRequests.createdAt))
|
||||
|
||||
const subConditions = []
|
||||
if (status !== 'ALL') {
|
||||
subConditions.push(eq(songReplayRequests.status, status as any))
|
||||
}
|
||||
if (semesterName) {
|
||||
subConditions.push(eq(songs.semester, semesterName))
|
||||
}
|
||||
|
||||
if (subConditions.length > 0) {
|
||||
requestDetailsQuery.where(and(...subConditions))
|
||||
}
|
||||
|
||||
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({
|
||||
id: d.id,
|
||||
userId: d.user.id,
|
||||
name: formatDisplayName(d.user),
|
||||
grade: d.user.grade,
|
||||
class: d.user.class,
|
||||
createdAt: d.createdAt,
|
||||
status: d.status,
|
||||
preferredPlayTimeId: d.preferredPlayTimeId,
|
||||
submissionNote: d.submissionNote,
|
||||
submissionNotePublic: d.submissionNotePublic
|
||||
})
|
||||
})
|
||||
|
||||
return requests.map((item) => {
|
||||
const requestDetails = detailsMap.get(item.song.id) || []
|
||||
const latestRequest = requestDetails[0]
|
||||
|
||||
return {
|
||||
...item.song,
|
||||
requesterId: latestRequest?.userId || null,
|
||||
preferredPlayTimeId: latestRequest?.preferredPlayTimeId || null,
|
||||
submissionNote: latestRequest?.submissionNote || null,
|
||||
submissionNotePublic: latestRequest?.submissionNotePublic === true,
|
||||
submissionNotePublicStatus: latestRequest?.submissionNotePublicStatus || null,
|
||||
hasSubmissionNote: !!latestRequest?.submissionNote,
|
||||
requester: latestRequest?.name || '未知用户',
|
||||
requesterGrade: latestRequest?.grade || null,
|
||||
requesterClass: latestRequest?.class || null,
|
||||
replayRequestId: latestRequest?.id || null,
|
||||
replayRequestCount: item.requestCount,
|
||||
replayRequesters: requestDetails.map((request: any) => ({
|
||||
id: request.userId,
|
||||
name: request.name,
|
||||
displayName: request.name,
|
||||
grade: request.grade,
|
||||
class: request.class,
|
||||
status: request.status,
|
||||
createdAt: request.createdAt
|
||||
})),
|
||||
isReplay: true,
|
||||
voteCount: item.requestCount,
|
||||
requestCount: item.requestCount,
|
||||
lastRequestedAt: item.lastRequestedAt,
|
||||
requestDetails
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
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 || !['SONG_ADMIN', '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')))
|
||||
|
||||
// 发送通知
|
||||
for (const req of pendingRequests) {
|
||||
await createReplayRequestRejectedNotification(
|
||||
req.userId,
|
||||
{
|
||||
title: song.title,
|
||||
artist: song.artist
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
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 (!['SONG_ADMIN', '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]
|
||||
|
||||
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]
|
||||
|
||||
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]
|
||||
|
||||
return {
|
||||
message: '投稿开放时段已成功删除'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除投稿开放时段失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '删除投稿开放时段失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 405,
|
||||
message: '不支持的请求方法'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
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 (!['SONG_ADMIN', '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]
|
||||
|
||||
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 (!['SONG_ADMIN', '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,224 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { and, desc, eq, gte, lte, ne } from 'drizzle-orm'
|
||||
import { createSongSelectedNotification, createReplaySongSelectedNotification } from '../../services/notificationService'
|
||||
import { redeemCardCodeForSchedule } from '~~/server/services/cardCodeLifecycleService'
|
||||
import { fulfillReplayRequestsForSchedule } from '~~/server/utils/scheduleReplayBinding'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
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
|
||||
|
||||
// 前端显式选择的重播申请,发布时优先绑定
|
||||
const preferredReplayRequestId =
|
||||
Number.isInteger(Number(body.replayRequestId)) && Number(body.replayRequestId) > 0
|
||||
? Number(body.replayRequestId)
|
||||
: null
|
||||
|
||||
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: '歌曲不存在'
|
||||
})
|
||||
}
|
||||
|
||||
const transactionResult = await db.transaction(async (tx) => {
|
||||
// 获取序号,如果未提供则查找当天最大序号+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)
|
||||
|
||||
const latestSchedule = sameDaySchedules[0]
|
||||
if (latestSchedule) sequence = (latestSchedule.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 : getServerDate()
|
||||
})
|
||||
.returning()
|
||||
|
||||
const createdSchedule = scheduleResult[0]
|
||||
if (!createdSchedule) {
|
||||
throw createError({ statusCode: 500, message: '创建排期失败' })
|
||||
}
|
||||
|
||||
const schedule = {
|
||||
...createdSchedule,
|
||||
song: {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
requesterId: song.requesterId,
|
||||
cardCodeId: song.cardCodeId
|
||||
}
|
||||
}
|
||||
|
||||
let shouldNotify = false
|
||||
let replayRequesterIds: number[] = []
|
||||
|
||||
// 只有在非草稿模式下才更新重播状态
|
||||
if (!isDraft) {
|
||||
// 检查该歌曲是否已经有其他已发布的排期
|
||||
const existingPublished = await tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(
|
||||
and(
|
||||
eq(schedules.songId, schedule.song.id),
|
||||
eq(schedules.isDraft, false),
|
||||
ne(schedules.id, createdSchedule.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
// 如果之前没有正式发布过,才发送通知(首次排期通知)
|
||||
shouldNotify = existingPublished.length === 0
|
||||
|
||||
const replayBinding = await fulfillReplayRequestsForSchedule({
|
||||
tx,
|
||||
songId: schedule.song.id,
|
||||
scheduleId: createdSchedule.id,
|
||||
at: createdSchedule.publishedAt || getServerDate(),
|
||||
preferredRequestId: preferredReplayRequestId || undefined
|
||||
})
|
||||
|
||||
if (replayBinding) {
|
||||
replayRequesterIds = replayBinding.replayRequesterIds
|
||||
}
|
||||
|
||||
if (existingPublished.length > 0) {
|
||||
console.log(`歌曲 ${schedule.song.id} 已有其他正式排期,不再重复发送通知`)
|
||||
}
|
||||
|
||||
await redeemCardCodeForSchedule(tx, {
|
||||
songId: schedule.song.id,
|
||||
cardCodeId: schedule.song.cardCodeId,
|
||||
operatorId: user.id,
|
||||
at: createdSchedule.publishedAt || getServerDate()
|
||||
})
|
||||
}
|
||||
|
||||
return { schedule, shouldNotify, replayRequesterIds }
|
||||
})
|
||||
|
||||
let notificationSent = true
|
||||
if (transactionResult.shouldNotify) {
|
||||
try {
|
||||
await createSongSelectedNotification(song.requesterId, song.id, {
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
playDate: transactionResult.schedule.playDate
|
||||
})
|
||||
} catch (notificationError) {
|
||||
notificationSent = false
|
||||
console.error('排期已创建,但发送歌曲入选通知失败:', notificationError)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送重播申请已安排通知
|
||||
if (!isDraft && transactionResult.replayRequesterIds.length > 0) {
|
||||
for (const replayUserId of transactionResult.replayRequesterIds) {
|
||||
try {
|
||||
await createReplaySongSelectedNotification(replayUserId, song.id, {
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
playDate: transactionResult.schedule.playDate
|
||||
}, transactionResult.schedule.id)
|
||||
} catch (error) {
|
||||
console.error(`发送重播安排通知给用户 ${replayUserId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = transactionResult.schedule
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
isDraft: isDraft,
|
||||
publishedAt: isDraft ? null : schedule.publishedAt,
|
||||
message: isDraft
|
||||
? '排期草稿保存成功'
|
||||
: transactionResult.shouldNotify
|
||||
? notificationSent
|
||||
? '排期创建成功,通知已发送'
|
||||
: '排期创建成功,但通知发送失败'
|
||||
: '排期创建成功'
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('创建排期失败:', error)
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || '创建排期失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { inArray, and, eq, gte, lte } from 'drizzle-orm'
|
||||
import { createSongSelectedNotification, createReplaySongSelectedNotification } from '~~/server/services/notificationService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import {
|
||||
redeemCardCodeForSchedule,
|
||||
restoreCardCodeAfterScheduleRemoval
|
||||
} from '~~/server/services/cardCodeLifecycleService'
|
||||
import {
|
||||
fulfillReplayRequestsForSchedule,
|
||||
restoreReplayRequestsToPending
|
||||
} from '~~/server/utils/scheduleReplayBinding'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
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 scheduleItems: Array<{ songId: number; sequence: number; replayRequestId: number | null }> =
|
||||
(body.songs as any[]).map((item: any, index: number) => {
|
||||
const songId = Number(item?.songId)
|
||||
const sequence = Number(item?.sequence)
|
||||
if (
|
||||
!Number.isInteger(songId) ||
|
||||
songId <= 0 ||
|
||||
!Number.isInteger(sequence) ||
|
||||
sequence <= 0
|
||||
) {
|
||||
throw createError({ statusCode: 400, message: `第 ${index + 1} 条排期数据无效` })
|
||||
}
|
||||
// 管理员拖拽时显式选择的重播申请,发布时优先绑定
|
||||
const replayRequestId = Number(item?.replayRequestId)
|
||||
return {
|
||||
songId,
|
||||
sequence,
|
||||
replayRequestId: Number.isInteger(replayRequestId) && replayRequestId > 0 ? replayRequestId : null
|
||||
}
|
||||
})
|
||||
|
||||
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 = scheduleItems.map((item) => item.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 missingSongIds = Array.from(new Set(songIds)).filter((songId) => !songMap.has(songId))
|
||||
if (missingSongIds.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: `歌曲不存在:${missingSongIds.join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
// 需要发送通知的列表
|
||||
const notificationsToSend: Array<{
|
||||
requesterId: number
|
||||
songId: number
|
||||
songInfo: { title: string; artist: string; playDate: Date }
|
||||
}> = []
|
||||
const replayNotificationsToSend: Array<{
|
||||
userId: number
|
||||
songId: number
|
||||
songInfo: { title: string; artist: string; playDate: Date }
|
||||
scheduleId: number
|
||||
}> = []
|
||||
|
||||
// 开始事务
|
||||
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 newSongIds = new Set<number>(scheduleItems.map((item) => item.songId))
|
||||
const newSongIdsArray = Array.from(newSongIds)
|
||||
|
||||
const globalExistingPublished =
|
||||
newSongIdsArray.length > 0
|
||||
? await tx
|
||||
.select({ songId: schedules.songId })
|
||||
.from(schedules)
|
||||
.where(and(eq(schedules.isDraft, false), inArray(schedules.songId, newSongIdsArray)))
|
||||
: []
|
||||
|
||||
const existingPublishedSongIds = new Set(globalExistingPublished.map((s) => s.songId))
|
||||
|
||||
const schedulesToDelete = await tx
|
||||
.select({
|
||||
songId: schedules.songId,
|
||||
isDraft: schedules.isDraft,
|
||||
replayRequestId: schedules.replayRequestId,
|
||||
cardCodeId: songs.cardCodeId
|
||||
})
|
||||
.from(schedules)
|
||||
.leftJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(and(...whereConditions))
|
||||
|
||||
// 记录被删除的已发布排期的重播绑定,同歌重发时回写,避免重播标识丢失
|
||||
const previousReplayBindings = new Map<number, number>()
|
||||
for (const scheduleToDelete of schedulesToDelete) {
|
||||
if (!scheduleToDelete.isDraft && scheduleToDelete.replayRequestId) {
|
||||
previousReplayBindings.set(scheduleToDelete.songId, scheduleToDelete.replayRequestId)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 删除该时间段内的所有排期(包括草稿和已发布)
|
||||
await tx.delete(schedules).where(and(...whereConditions))
|
||||
|
||||
// 记录本次删除了哪些歌的排期,如果新排期里没有它们,并且全局也没别的正式排期了,需要恢复 PENDING
|
||||
const deletedSongIds = new Set(
|
||||
schedulesToDelete.filter((s) => !s.isDraft).map((s) => s.songId)
|
||||
)
|
||||
const songsToRestore = Array.from(deletedSongIds).filter((id) => !newSongIds.has(id))
|
||||
|
||||
if (songsToRestore.length > 0) {
|
||||
const otherPublished = await tx
|
||||
.select({ songId: schedules.songId })
|
||||
.from(schedules)
|
||||
.where(and(eq(schedules.isDraft, false), inArray(schedules.songId, songsToRestore)))
|
||||
|
||||
const songsWithOtherSchedules = new Set(otherPublished.map((s) => s.songId))
|
||||
const finalRestoreIds = songsToRestore.filter((id) => !songsWithOtherSchedules.has(id))
|
||||
|
||||
if (finalRestoreIds.length > 0) {
|
||||
const finalRestoreIdSet = new Set(finalRestoreIds)
|
||||
const restoreCardCodeMap = new Map<number, number>()
|
||||
for (const deletedSchedule of schedulesToDelete) {
|
||||
if (
|
||||
!deletedSchedule.isDraft &&
|
||||
deletedSchedule.cardCodeId &&
|
||||
finalRestoreIdSet.has(deletedSchedule.songId)
|
||||
) {
|
||||
restoreCardCodeMap.set(deletedSchedule.songId, deletedSchedule.cardCodeId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [songId, cardCodeId] of restoreCardCodeMap) {
|
||||
const restoreResult = await restoreCardCodeAfterScheduleRemoval(tx, {
|
||||
songId,
|
||||
cardCodeId,
|
||||
operatorId: user.id
|
||||
})
|
||||
if (
|
||||
!restoreResult.changed &&
|
||||
['CONCURRENT_CHANGE', 'MISSING_CARD_CODE'].includes(
|
||||
String(restoreResult.reason || '')
|
||||
)
|
||||
) {
|
||||
throw createError({ statusCode: 409, message: '点歌券返还失败,发布排期已终止' })
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复重播申请(每人仅恢复最新一条,避免违反部分唯一索引)
|
||||
await restoreReplayRequestsToPending({
|
||||
tx,
|
||||
songIds: finalRestoreIds,
|
||||
at: getServerDate()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 插入新的排期并处理通知
|
||||
const publishedAt = getServerDate()
|
||||
|
||||
for (const item of scheduleItems) {
|
||||
const song = songMap.get(item.songId)!
|
||||
|
||||
// 插入排期
|
||||
const insertedSchedules = await tx
|
||||
.insert(schedules)
|
||||
.values({
|
||||
songId: item.songId,
|
||||
playDate: playDate,
|
||||
sequence: item.sequence,
|
||||
playTimeId: playTimeId,
|
||||
isDraft: false, // 直接发布
|
||||
publishedAt: publishedAt,
|
||||
updatedAt: publishedAt
|
||||
})
|
||||
.returning({ id: schedules.id })
|
||||
|
||||
const insertedSchedule = insertedSchedules[0]
|
||||
if (!insertedSchedule) {
|
||||
throw createError({ statusCode: 500, message: '创建排期失败' })
|
||||
}
|
||||
|
||||
const replayBinding = await fulfillReplayRequestsForSchedule({
|
||||
tx,
|
||||
songId: song.id,
|
||||
scheduleId: insertedSchedule.id,
|
||||
at: publishedAt,
|
||||
preferredRequestId: item.replayRequestId || undefined,
|
||||
fallbackRequestId: previousReplayBindings.get(song.id) || undefined
|
||||
})
|
||||
|
||||
if (replayBinding) {
|
||||
for (const replayUserId of replayBinding.replayRequesterIds) {
|
||||
replayNotificationsToSend.push({
|
||||
userId: replayUserId,
|
||||
songId: song.id,
|
||||
songInfo: { title: song.title, artist: song.artist, playDate: playDate },
|
||||
scheduleId: insertedSchedule.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果该歌曲之前未在此时间段发布过,则发送通知
|
||||
if (!existingPublishedSongIds.has(item.songId)) {
|
||||
notificationsToSend.push({
|
||||
requesterId: song.requesterId,
|
||||
songId: song.id,
|
||||
songInfo: {
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
playDate: playDate
|
||||
}
|
||||
})
|
||||
existingPublishedSongIds.add(item.songId)
|
||||
}
|
||||
|
||||
await redeemCardCodeForSchedule(tx, {
|
||||
songId: song.id,
|
||||
cardCodeId: song.cardCodeId,
|
||||
operatorId: user.id,
|
||||
at: publishedAt
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 发送重播安排通知
|
||||
if (replayNotificationsToSend.length > 0) {
|
||||
event.waitUntil(
|
||||
Promise.allSettled(
|
||||
replayNotificationsToSend.map((n) =>
|
||||
createReplaySongSelectedNotification(n.userId, n.songId, n.songInfo, n.scheduleId)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 由运行时托管后台通知,避免 Serverless 在响应结束后中止任务。
|
||||
if (notificationsToSend.length > 0) {
|
||||
event.waitUntil(
|
||||
Promise.allSettled(
|
||||
notificationsToSend.map((n) =>
|
||||
createSongSelectedNotification(n.requesterId, n.songId, n.songInfo)
|
||||
)
|
||||
).then((results) => {
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled').length
|
||||
console.log(
|
||||
`[Notification] 批量发布通知发送完成: ${successCount}/${notificationsToSend.length} 成功`
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
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: error.statusCode || 500,
|
||||
message: error.message || '发布排期失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { and, eq, gte, lte } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
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 startTime = Date.now()
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
const fromDate = typeof body?.fromDate === 'string' ? body.fromDate.trim() : ''
|
||||
const toDate = typeof body?.toDate === 'string' ? body.toDate.trim() : ''
|
||||
const overwriteDrafts = body?.overwriteDrafts === true
|
||||
|
||||
if (!fromDate || !toDate) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少来源日期或目标日期'
|
||||
})
|
||||
}
|
||||
|
||||
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 格式并确保日期有效'
|
||||
})
|
||||
}
|
||||
|
||||
const copyResult = await db.transaction(async (tx) => {
|
||||
// 检查目标日期是否已有排期(含草稿)
|
||||
const [existingCount, draftCount] = await Promise.all([
|
||||
tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd), eq(schedules.isDraft, false)))
|
||||
.limit(1),
|
||||
tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd), eq(schedules.isDraft, true)))
|
||||
.limit(999)
|
||||
])
|
||||
|
||||
const hasExistingSchedule = existingCount.length > 0
|
||||
const overwritingDrafts = overwriteDrafts && draftCount.length > 0
|
||||
|
||||
// 有已发布排期不允许复制
|
||||
if (hasExistingSchedule) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '目标日期已存在已发布排期,无法复制。请先清空目标日期的排期。'
|
||||
})
|
||||
}
|
||||
|
||||
// 有草稿但未请求覆盖,返回信息供前端询问用户
|
||||
if (draftCount.length > 0 && !overwritingDrafts) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '目标日期已存在草稿,请确认是否覆盖。',
|
||||
data: { draftCount: draftCount.length, requiresConfirmation: true }
|
||||
})
|
||||
}
|
||||
|
||||
let replacedDrafts = []
|
||||
|
||||
// 清除目标日期草稿(如用户确认覆盖)
|
||||
if (overwritingDrafts) {
|
||||
replacedDrafts = await tx
|
||||
.select()
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd), eq(schedules.isDraft, true)))
|
||||
await tx.delete(schedules).where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd)))
|
||||
}
|
||||
|
||||
// 查询来源日期的所有排期(含草稿和已发布),按 sequence 排序
|
||||
const sourceSchedules = await tx
|
||||
.select({
|
||||
songId: schedules.songId,
|
||||
sequence: schedules.sequence,
|
||||
playTimeId: schedules.playTimeId
|
||||
})
|
||||
.from(schedules)
|
||||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(and(gte(schedules.playDate, fromStart), lte(schedules.playDate, fromEnd)))
|
||||
.orderBy(schedules.sequence)
|
||||
|
||||
if (sourceSchedules.length === 0) {
|
||||
return { copiedCount: 0, createdScheduleIds: [], replacedDrafts }
|
||||
}
|
||||
|
||||
let copiedCount = 0
|
||||
const createdScheduleIds = []
|
||||
const now = getServerDate()
|
||||
|
||||
for (const source of sourceSchedules) {
|
||||
const inserted = await tx
|
||||
.insert(schedules)
|
||||
.values({
|
||||
songId: source.songId,
|
||||
playDate: toPlayDate,
|
||||
sequence: source.sequence,
|
||||
playTimeId: source.playTimeId,
|
||||
isDraft: true, // 复制的排期默认保存为草稿,管理员审核后发布
|
||||
publishedAt: null,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning({ id: schedules.id })
|
||||
|
||||
copiedCount++
|
||||
if (inserted[0]) createdScheduleIds.push(inserted[0].id)
|
||||
}
|
||||
|
||||
return { copiedCount, createdScheduleIds, replacedDrafts }
|
||||
})
|
||||
|
||||
console.log(`[Performance] 复制排期耗时: ${Date.now() - startTime}ms`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fromDate,
|
||||
toDate,
|
||||
copiedCount: copyResult.copiedCount ?? 0,
|
||||
createdScheduleIds: copyResult.createdScheduleIds ?? [],
|
||||
replacedDrafts: copyResult.replacedDrafts ?? [],
|
||||
draftCount: copyResult.draftCount,
|
||||
overwriteDrafts: copyResult.overwriteDrafts
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { playTimes, schedules, songs, songReplayRequests } from '~/drizzle/schema'
|
||||
import { and, desc, eq, gte, lte } from 'drizzle-orm'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
// 输入验证函数
|
||||
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: '播放时间不存在'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 校验重播申请绑定(草稿仅记录绑定关系,不修改申请状态)
|
||||
let replayRequestId: number | null = null
|
||||
const requestedReplayId = Number(body.replayRequestId)
|
||||
if (body.replayRequestId != null && Number.isInteger(requestedReplayId) && requestedReplayId > 0) {
|
||||
const replayRequestResult = await tx
|
||||
.select({ id: songReplayRequests.id, songId: songReplayRequests.songId, status: songReplayRequests.status })
|
||||
.from(songReplayRequests)
|
||||
.where(eq(songReplayRequests.id, requestedReplayId))
|
||||
.limit(1)
|
||||
|
||||
const replayRequest = replayRequestResult[0]
|
||||
if (!replayRequest || replayRequest.songId !== song.id || replayRequest.status !== 'PENDING') {
|
||||
throw createApiError(400, 'SONG_REPLAY_INVALID_REQUEST', '重播申请无效或已被处理')
|
||||
}
|
||||
|
||||
replayRequestId = replayRequest.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')
|
||||
|
||||
// 查找当天的所有排期和草稿(包括草稿状态的)
|
||||
const sameDaySchedules = await tx
|
||||
.select()
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, startOfDay), lte(schedules.playDate, endOfDay)))
|
||||
.orderBy(desc(schedules.sequence))
|
||||
.limit(1)
|
||||
|
||||
const latestSchedule = sameDaySchedules[0]
|
||||
if (latestSchedule) sequence = (latestSchedule.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,
|
||||
replayRequestId,
|
||||
isDraft: true, // 标记为草稿
|
||||
publishedAt: null // 草稿状态下没有发布时间
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
schedule: scheduleResult[0],
|
||||
song: {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
requesterId: song.requesterId,
|
||||
cardCodeId: song.cardCodeId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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,459 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
playTimes,
|
||||
schedules,
|
||||
songs,
|
||||
users,
|
||||
votes,
|
||||
songCollaborators,
|
||||
songReplayRequests
|
||||
} from '~/drizzle/schema'
|
||||
import { and, asc, count, countDistinct, 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,
|
||||
replayRequestId: schedules.replayRequestId,
|
||||
songId: schedules.songId,
|
||||
songTitle: songs.title,
|
||||
songArtist: songs.artist,
|
||||
songRequesterId: songs.requesterId,
|
||||
songPlayed: songs.played,
|
||||
songCover: songs.cover,
|
||||
songMusicPlatform: songs.musicPlatform,
|
||||
songMusicId: songs.musicId,
|
||||
songDurationSeconds: songs.durationSeconds,
|
||||
songCardCodeId: songs.cardCodeId,
|
||||
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: countDistinct(songReplayRequests.userId)
|
||||
})
|
||||
.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,
|
||||
preferredPlayTimeId: songReplayRequests.preferredPlayTimeId,
|
||||
submissionNote: songReplayRequests.submissionNote,
|
||||
submissionNotePublic: songReplayRequests.submissionNotePublic
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.innerJoin(users, eq(songReplayRequests.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, songIds),
|
||||
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
|
||||
)
|
||||
)
|
||||
.orderBy(desc(songReplayRequests.createdAt))
|
||||
|
||||
// 每首歌按用户去重(保留最新一条),只保留前5个申请人
|
||||
const seenRequesterIds = new Map()
|
||||
replayRequestersData.forEach((r) => {
|
||||
if (!replayRequestersMap.has(r.songId)) {
|
||||
replayRequestersMap.set(r.songId, [])
|
||||
seenRequesterIds.set(r.songId, new Set())
|
||||
}
|
||||
const seen = seenRequesterIds.get(r.songId)
|
||||
if (seen.has(r.user.id)) {
|
||||
return
|
||||
}
|
||||
if (replayRequestersMap.get(r.songId).length < 5) {
|
||||
seen.add(r.user.id)
|
||||
replayRequestersMap.get(r.songId).push({
|
||||
id: r.user.id,
|
||||
name: r.user.name || '未知用户',
|
||||
grade: r.user.grade,
|
||||
class: r.user.class,
|
||||
status: r.status
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取重播申请元数据(按 ID 索引,展示时排期按 replayRequestId 取对应记录)
|
||||
const replayMetadataByIdMap = new Map<number, { submissionNote: string | null; submissionNotePublic: boolean; submissionNotePublicStatus: string | null; preferredPlayTimeId: number | null }>()
|
||||
if (songIds.length > 0) {
|
||||
const replayMetaData = await db
|
||||
.select()
|
||||
.from(songReplayRequests)
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, songIds),
|
||||
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
|
||||
)
|
||||
)
|
||||
|
||||
for (const row of replayMetaData) {
|
||||
replayMetadataByIdMap.set(row.id, {
|
||||
submissionNote: row.submissionNote,
|
||||
submissionNotePublic: row.submissionNotePublic,
|
||||
submissionNotePublicStatus: row.submissionNotePublicStatus,
|
||||
preferredPlayTimeId: row.preferredPlayTimeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化响应数据
|
||||
const formattedSchedules = schedulesData.map((schedule) => {
|
||||
const dateOnly = schedule.playDate
|
||||
|
||||
// 处理投稿人姓名消歧
|
||||
let requesterName = schedule.requesterName || 'Unknown User'
|
||||
|
||||
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})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:格式化显示名称 (用于联合投稿人)
|
||||
const formatDisplayName = (userObj: any) => {
|
||||
if (!userObj || !userObj.name) return '未知用户'
|
||||
let displayName = userObj.name
|
||||
|
||||
const sameNameUsers = nameToUsers.get(displayName)
|
||||
if (sameNameUsers && sameNameUsers.length > 1) {
|
||||
if (userObj.grade) {
|
||||
const sameGradeUsers = sameNameUsers.filter((u: any) => u.grade === userObj.grade)
|
||||
if (sameGradeUsers.length > 1 && userObj.class) {
|
||||
displayName = `${displayName}(${userObj.grade} ${userObj.class})`
|
||||
} else {
|
||||
displayName = `${displayName}(${userObj.grade})`
|
||||
}
|
||||
}
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
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 linkedReplayRequestId = schedule.replayRequestId || null
|
||||
const replayRequestCount = linkedReplayRequestId
|
||||
? replayRequestCountsMap.get(schedule.songId) || 0
|
||||
: 0
|
||||
const replayRequesters = linkedReplayRequestId ? 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
|
||||
}))
|
||||
|
||||
// 只有正式绑定了重播申请的排期才按重播展示;草稿排期不提前暴露申请信息
|
||||
const isReplaySong = linkedReplayRequestId !== null
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
createdAt: schedule.createdAt,
|
||||
updatedAt: schedule.updatedAt,
|
||||
playDate: dateOnly.toISOString().split('T')[0],
|
||||
sequence: schedule.sequence || 1,
|
||||
replayRequestId: linkedReplayRequestId,
|
||||
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: (() => {
|
||||
const replayMeta = linkedReplayRequestId ? replayMetadataByIdMap.get(linkedReplayRequestId) : null
|
||||
const hasReplayMeta = !!replayMeta
|
||||
|
||||
return {
|
||||
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,
|
||||
cardCodeId: schedule.songCardCodeId || null,
|
||||
musicPlatform: schedule.songMusicPlatform || null,
|
||||
musicId: schedule.songMusicId || null,
|
||||
durationSeconds: schedule.songDurationSeconds || null,
|
||||
semester: schedule.songSemester || null,
|
||||
createdAt: schedule.songCreatedAt,
|
||||
submissionNote: hasReplayMeta ? replayMeta.submissionNote : null,
|
||||
submissionNotePublic: hasReplayMeta ? replayMeta.submissionNotePublic : false,
|
||||
submissionNotePublicStatus: hasReplayMeta ? (replayMeta.submissionNotePublicStatus || null) : null,
|
||||
hasSubmissionNote: hasReplayMeta && !!replayMeta.submissionNote,
|
||||
preferredPlayTimeId: hasReplayMeta ? replayMeta.preferredPlayTimeId : null,
|
||||
// 重播申请信息
|
||||
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,128 @@
|
||||
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { createSystemNotification } from '~~/server/services/notificationService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
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 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 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 = getServerDate()
|
||||
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)
|
||||
})
|
||||
|
||||
if (notificationsToSend.length > 0) {
|
||||
Promise.allSettled(notificationsToSend).catch((error) => {
|
||||
console.error('[Notification] 发送排期迁移通知失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
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,194 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { createSongSelectedNotification, createReplaySongSelectedNotification } from '~~/server/services/notificationService'
|
||||
import { redeemCardCodeForSchedule } from '~~/server/services/cardCodeLifecycleService'
|
||||
import { fulfillReplayRequestsForSchedule } from '~~/server/utils/scheduleReplayBinding'
|
||||
import { getBeijingTimestamp } from '~/utils/timeUtils'
|
||||
|
||||
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,
|
||||
replayRequestId: schedules.replayRequestId,
|
||||
song: {
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
requesterId: songs.requesterId,
|
||||
cardCodeId: songs.cardCodeId
|
||||
}
|
||||
})
|
||||
.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 publishResult = await db.transaction(async (tx) => {
|
||||
// 更新草稿为已发布状态
|
||||
const publishedAt = new Date(getBeijingTimestamp())
|
||||
|
||||
const publishResult = await tx
|
||||
.update(schedules)
|
||||
.set({
|
||||
isDraft: false,
|
||||
publishedAt: publishedAt,
|
||||
updatedAt: publishedAt
|
||||
})
|
||||
.where(and(eq(schedules.id, body.scheduleId), eq(schedules.isDraft, true)))
|
||||
.returning()
|
||||
|
||||
const schedule = publishResult[0]
|
||||
|
||||
if (!schedule) {
|
||||
throw createError({ statusCode: 409, message: '排期已发布或状态已变化,请刷新后重试' })
|
||||
}
|
||||
|
||||
// 检查该歌曲是否已经有其他已发布的排期
|
||||
const existingPublished = await tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(
|
||||
and(
|
||||
eq(schedules.songId, draft.song.id),
|
||||
eq(schedules.isDraft, false),
|
||||
ne(schedules.id, body.scheduleId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const shouldNotify = existingPublished.length === 0
|
||||
|
||||
// 履行该歌曲全部待处理重播申请,优先绑定草稿指定的申请
|
||||
let replayRequesterIds: number[] = []
|
||||
const replayBinding = await fulfillReplayRequestsForSchedule({
|
||||
tx,
|
||||
songId: draft.song.id,
|
||||
scheduleId: body.scheduleId,
|
||||
at: publishedAt,
|
||||
preferredRequestId: draft.replayRequestId || undefined,
|
||||
fallbackRequestId: draft.replayRequestId || undefined
|
||||
})
|
||||
|
||||
if (replayBinding) {
|
||||
replayRequesterIds = replayBinding.replayRequesterIds
|
||||
console.log(`发布排期:重播申请 #${replayBinding.replayRequestId} 绑定到排期 #${body.scheduleId}`)
|
||||
}
|
||||
|
||||
if (existingPublished.length > 0) {
|
||||
console.log(`歌曲 ${draft.song.id} 已有其他正式排期,不再重复发送通知`)
|
||||
}
|
||||
|
||||
await redeemCardCodeForSchedule(tx, {
|
||||
songId: draft.song.id,
|
||||
cardCodeId: draft.song.cardCodeId,
|
||||
operatorId: user.id,
|
||||
at: publishedAt
|
||||
})
|
||||
|
||||
return { schedule, shouldNotify, replayRequesterIds, replayRequestId: replayBinding?.replayRequestId ?? null }
|
||||
})
|
||||
|
||||
// 发送重播申请已安排通知
|
||||
if (publishResult.replayRequesterIds?.length > 0) {
|
||||
for (const replayUserId of publishResult.replayRequesterIds) {
|
||||
try {
|
||||
await createReplaySongSelectedNotification(replayUserId, draft.song.id, {
|
||||
title: draft.song.title,
|
||||
artist: draft.song.artist,
|
||||
playDate: publishResult.schedule.playDate
|
||||
}, publishResult.schedule.id)
|
||||
} catch (error) {
|
||||
console.error(`发送重播安排通知给用户 ${replayUserId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let notificationSent = true
|
||||
if (publishResult.shouldNotify) {
|
||||
try {
|
||||
await createSongSelectedNotification(draft.song.requesterId, draft.song.id, {
|
||||
title: draft.song.title,
|
||||
artist: draft.song.artist,
|
||||
playDate: publishResult.schedule.playDate
|
||||
})
|
||||
} catch (notificationError) {
|
||||
notificationSent = false
|
||||
console.error('排期已发布,但发送歌曲入选通知失败:', notificationError)
|
||||
}
|
||||
}
|
||||
|
||||
const publishedSchedule = publishResult.schedule
|
||||
|
||||
return {
|
||||
...draft,
|
||||
...publishedSchedule,
|
||||
song: draft.song,
|
||||
isDraft: false,
|
||||
publishedAt: publishedSchedule.publishedAt,
|
||||
// 绑定在 fulfill 中回写,.returning() 快照早于绑定,需显式覆盖
|
||||
replayRequestId: publishResult.replayRequestId,
|
||||
message: publishResult.shouldNotify
|
||||
? notificationSent
|
||||
? '排期发布成功,通知已发送'
|
||||
: '排期发布成功,但通知发送失败'
|
||||
: '排期发布成功'
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('发布排期失败:', error)
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || '发布排期失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules } from '~/drizzle/schema'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
// 回滚辅助接口:仅删除指定批次创建的排期,并可恢复被覆盖的草稿
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
const scheduleIds = Array.isArray(body?.scheduleIds)
|
||||
? body.scheduleIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)
|
||||
: []
|
||||
const restoreSchedules = Array.isArray(body?.restoreSchedules) ? body.restoreSchedules : []
|
||||
if ((scheduleIds.length === 0 && restoreSchedules.length === 0) || scheduleIds.length > 1000 || restoreSchedules.length > 1000) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'scheduleIds 或 restoreSchedules 必须包含 1-1000 条记录')
|
||||
}
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const deleted = await tx
|
||||
.delete(schedules)
|
||||
.where(and(inArray(schedules.id, scheduleIds), eq(schedules.isDraft, true), isNull(schedules.publishedAt)))
|
||||
.returning({ id: schedules.id })
|
||||
if (restoreSchedules.length > 0) {
|
||||
await tx.insert(schedules).values(restoreSchedules.map((schedule) => ({
|
||||
id: Number(schedule.id),
|
||||
createdAt: new Date(schedule.createdAt),
|
||||
updatedAt: new Date(schedule.updatedAt),
|
||||
songId: Number(schedule.songId),
|
||||
playDate: new Date(schedule.playDate),
|
||||
played: Boolean(schedule.played),
|
||||
sequence: Number(schedule.sequence),
|
||||
playTimeId: schedule.playTimeId == null ? null : Number(schedule.playTimeId),
|
||||
isDraft: true,
|
||||
publishedAt: null,
|
||||
replayRequestId: schedule.replayRequestId == null ? null : Number(schedule.replayRequestId)
|
||||
})))
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
|
||||
return { success: true, removed: result.length, restored: restoreSchedules.length }
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { db, eq, ne, schedules, songs, and } from '~/drizzle/db'
|
||||
import { restoreCardCodeAfterScheduleRemoval } from '~~/server/services/cardCodeLifecycleService'
|
||||
import { restoreReplayRequestsToPending } from '~~/server/utils/scheduleReplayBinding'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
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: '需要歌曲管理员及以上权限'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const { scheduleId } = body
|
||||
|
||||
if (!scheduleId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少排期 ID'
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleIdNumber = Number(scheduleId)
|
||||
|
||||
console.log(`准备删除排期 ID=${scheduleIdNumber}`)
|
||||
|
||||
// 先检查排期是否存在,并获取歌曲ID
|
||||
const existingSchedule = await db
|
||||
.select({
|
||||
id: schedules.id,
|
||||
songId: schedules.songId,
|
||||
isDraft: schedules.isDraft,
|
||||
cardCodeId: songs.cardCodeId,
|
||||
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 result = await db.transaction(async (tx) => {
|
||||
// 删除排期
|
||||
const deletedSchedule = await tx
|
||||
.delete(schedules)
|
||||
.where(eq(schedules.id, scheduleIdNumber))
|
||||
.returning()
|
||||
|
||||
console.log(`成功删除排期 ID=${scheduleIdNumber}`)
|
||||
|
||||
// 恢复该歌曲的重播申请状态为 PENDING
|
||||
// 只有当该歌曲没有其他正式排期(非草稿)时,才恢复重播申请状态
|
||||
if (existingSchedule.songId) {
|
||||
const otherSchedules = await tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(
|
||||
and(
|
||||
eq(schedules.songId, existingSchedule.songId),
|
||||
eq(schedules.isDraft, false),
|
||||
ne(schedules.id, scheduleIdNumber)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (otherSchedules.length === 0) {
|
||||
if (!existingSchedule.isDraft && existingSchedule.cardCodeId) {
|
||||
const restoreResult = await restoreCardCodeAfterScheduleRemoval(tx, {
|
||||
songId: existingSchedule.songId,
|
||||
cardCodeId: existingSchedule.cardCodeId,
|
||||
operatorId: user.id
|
||||
})
|
||||
if (
|
||||
!restoreResult.changed &&
|
||||
['CONCURRENT_CHANGE', 'MISSING_CARD_CODE'].includes(
|
||||
String(restoreResult.reason || '')
|
||||
)
|
||||
) {
|
||||
throw createError({ statusCode: 409, message: '点歌券返还失败,移除排期已终止' })
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复重播申请(每人仅恢复最新一条,避免违反部分唯一索引)
|
||||
const restoredCount = await restoreReplayRequestsToPending({
|
||||
tx,
|
||||
songIds: [existingSchedule.songId],
|
||||
at: getServerDate()
|
||||
})
|
||||
|
||||
if (restoredCount > 0) {
|
||||
console.log(`恢复了 ${restoredCount} 个重播申请状态为 PENDING`)
|
||||
}
|
||||
} else {
|
||||
console.log(`该歌曲仍有其他正式排期,不恢复重播申请状态`)
|
||||
}
|
||||
}
|
||||
|
||||
return deletedSchedule
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
schedule: result
|
||||
}
|
||||
} 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,67 @@
|
||||
import { db, eq } from '~/drizzle/db'
|
||||
import { schedules } from '~/drizzle/schema'
|
||||
|
||||
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: '需要歌曲管理员及以上权限'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const scheduleUpdates = body.schedules
|
||||
|
||||
if (!Array.isArray(scheduleUpdates) || scheduleUpdates.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少排期数据'
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedUpdates = scheduleUpdates.map((item: any, index: number) => {
|
||||
const id = Number(item?.id)
|
||||
const sequence = Number(item?.sequence)
|
||||
if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(sequence) || sequence <= 0) {
|
||||
throw createError({ statusCode: 400, message: `第 ${index + 1} 条排期数据无效` })
|
||||
}
|
||||
return { id, sequence }
|
||||
})
|
||||
|
||||
// 批量更新排期顺序
|
||||
const results = await db.transaction(async (tx) => {
|
||||
const updatedRows = await Promise.all(
|
||||
normalizedUpdates.map((item) =>
|
||||
tx
|
||||
.update(schedules)
|
||||
.set({ sequence: item.sequence })
|
||||
.where(eq(schedules.id, item.id))
|
||||
.returning({ id: schedules.id })
|
||||
)
|
||||
)
|
||||
|
||||
if (updatedRows.some((rows) => rows.length === 0)) {
|
||||
throw createError({ statusCode: 404, message: '部分排期不存在,请刷新后重试' })
|
||||
}
|
||||
|
||||
return updatedRows
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.length
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('更新排期顺序失败:', error)
|
||||
|
||||
if (error.statusCode) throw error
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || '更新排期顺序失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { scheduleSongPool } from '~/drizzle/schema'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { fetchPoolCount } from '~~/server/utils/scheduleSongPool'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const songIdsRaw = query.songIds
|
||||
|
||||
let songIds: number[] = []
|
||||
if (typeof songIdsRaw === 'string') {
|
||||
songIds = songIdsRaw.split(',').map((s) => Number.parseInt(s.trim(), 10)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
} else if (Array.isArray(songIdsRaw)) {
|
||||
songIds = songIdsRaw.map((s) => Number.parseInt(String(s).trim(), 10)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
}
|
||||
|
||||
if (songIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 必须为非空数组或逗号分隔字符串')
|
||||
}
|
||||
if (songIds.length > 1000) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 数量不能超过 1000')
|
||||
}
|
||||
|
||||
const result = await db.delete(scheduleSongPool).where(inArray(scheduleSongPool.songId, songIds)).returning()
|
||||
return { ok: true, removed: result.length, total: await fetchPoolCount() }
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, scheduleSongPool, users, votes } from '~/drizzle/schema'
|
||||
import { inArray, count } from 'drizzle-orm'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
|
||||
const isSuperAdmin = event.context.user?.role === 'SUPER_ADMIN'
|
||||
|
||||
const poolRows = await db.select().from(scheduleSongPool).orderBy(scheduleSongPool.createdAt)
|
||||
|
||||
if (poolRows.length === 0) {
|
||||
return { pool: [], count: 0 }
|
||||
}
|
||||
|
||||
const poolSongIds = poolRows.map((row) => row.songId)
|
||||
const songsRows = await db.select().from(songs).where(inArray(songs.id, poolSongIds))
|
||||
const songsMap = new Map(songsRows.map((s) => [s.id, s]))
|
||||
|
||||
// 批量加载投稿人信息
|
||||
const requesterIds = [...new Set(songsRows.map((s) => s.requesterId).filter(Boolean))]
|
||||
const requesterMap = new Map()
|
||||
if (requesterIds.length > 0) {
|
||||
const userRows = await db.select().from(users).where(inArray(users.id, requesterIds))
|
||||
userRows.forEach((u) => requesterMap.set(u.id, u))
|
||||
}
|
||||
|
||||
// 批量加载添加者信息
|
||||
const addedByIds = [...new Set(poolRows.map((row) => row.addedBy).filter(Boolean))]
|
||||
const addedByUsersMap = new Map()
|
||||
if (addedByIds.length > 0) {
|
||||
const userRows = await db.select().from(users).where(inArray(users.id, addedByIds))
|
||||
userRows.forEach((u) => addedByUsersMap.set(u.id, u))
|
||||
}
|
||||
|
||||
// 批量加载投票数
|
||||
const voteCountMap = new Map()
|
||||
if (poolSongIds.length > 0) {
|
||||
const voteCountsResult = await db
|
||||
.select({ songId: votes.songId, count: count() })
|
||||
.from(votes)
|
||||
.where(inArray(votes.songId, poolSongIds))
|
||||
.groupBy(votes.songId)
|
||||
voteCountsResult.forEach((v) => voteCountMap.set(v.songId, v.count))
|
||||
}
|
||||
|
||||
const pool = poolRows.map((row) => {
|
||||
const song = songsMap.get(row.songId)
|
||||
if (!song) return null
|
||||
const requesterUser = song.requesterId ? requesterMap.get(song.requesterId) : null
|
||||
const addedByUser = row.addedBy ? addedByUsersMap.get(row.addedBy) : null
|
||||
return {
|
||||
poolId: row.id,
|
||||
songId: row.songId,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
durationSeconds: song.durationSeconds,
|
||||
played: song.played === true,
|
||||
requester: requesterUser ? (requesterUser.name || requesterUser.username || 'Unknown User') : 'Unknown User',
|
||||
requesterId: song.requesterId,
|
||||
requesterGrade: requesterUser?.grade || null,
|
||||
requesterClass: requesterUser?.class || null,
|
||||
preferredPlayTimeId: song.preferredPlayTimeId,
|
||||
cover: song.cover,
|
||||
semester: song.semester,
|
||||
musicId: song.musicId,
|
||||
musicPlatform: song.musicPlatform,
|
||||
voteCount: voteCountMap.get(row.songId) || 0,
|
||||
cardCodeId: song.cardCodeId,
|
||||
usedCardCode: song.cardCodeId != null,
|
||||
hasSubmissionNote: song.submissionNote ? true : false,
|
||||
submissionNote: isSuperAdmin ? song.submissionNote : undefined,
|
||||
addedBy: row.addedBy,
|
||||
addedByName: addedByUser?.name || addedByUser?.username || null,
|
||||
createdAt: row.createdAt
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return { pool, count: pool.length } // 有效记录数(已过滤掉歌曲被删除的孤立记录)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, scheduleSongPool } from '~/drizzle/schema'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { fetchPoolCount } from '~~/server/utils/scheduleSongPool'
|
||||
import { fetchSongDuration } from '~~/server/utils/songDurationFetcher'
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
const user = event.context.user as any
|
||||
const body = await readBody(event)
|
||||
if (!Array.isArray(body.songIds) || body.songIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 必须为非空数组')
|
||||
}
|
||||
const songIds = body.songIds.map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
||||
if (songIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 全部无效')
|
||||
}
|
||||
if (songIds.length > 1000) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 数量不能超过 1000')
|
||||
}
|
||||
const now = getServerDate()
|
||||
// 批量查询歌曲,避免 N+1
|
||||
const songsRows = await db.select().from(songs).where(inArray(songs.id, songIds))
|
||||
// 时长补全放到响应后的后台任务,避免批量加入时串行等待外部平台
|
||||
const missingDurationSongs = songsRows.filter(
|
||||
(s) => s.durationSeconds == null && s.musicPlatform && s.musicId
|
||||
)
|
||||
const frontendDurationMap = new Map(
|
||||
(Array.isArray(body.songDurations) ? body.songDurations : [])
|
||||
.map((item) => [Number(item?.songId), Number(item?.durationSeconds)])
|
||||
.filter(([songId, duration]) => Number.isInteger(songId) && Number.isFinite(duration))
|
||||
)
|
||||
const durationTolerance = 3
|
||||
const clientDurationUpdates = missingDurationSongs
|
||||
.map((song) => {
|
||||
const duration = frontendDurationMap.get(song.id)
|
||||
return Number.isInteger(duration) && duration >= 30 && duration <= 3600
|
||||
? { song, duration }
|
||||
: null
|
||||
})
|
||||
.filter(Boolean)
|
||||
await Promise.all(
|
||||
clientDurationUpdates.map(({ song, duration }) =>
|
||||
db.update(songs).set({ durationSeconds: duration }).where(eq(songs.id, song.id))
|
||||
)
|
||||
)
|
||||
if (missingDurationSongs.length > 0) {
|
||||
const durationTask = (async () => {
|
||||
for (const song of missingDurationSongs.filter(
|
||||
(item) => !clientDurationUpdates.some((update) => update.song.id === item.id)
|
||||
)) {
|
||||
const duration = await fetchSongDuration(song.musicPlatform, song.musicId)
|
||||
if (duration != null) {
|
||||
const frontendDuration = frontendDurationMap.get(song.id)
|
||||
if (frontendDuration != null && Math.abs(duration - frontendDuration) > durationTolerance) {
|
||||
console.warn(`[备选池] 歌曲 #${song.id} 前端时长 ${frontendDuration}s 与后端获取 ${duration}s 不一致,以后端为准`)
|
||||
}
|
||||
await db.update(songs).set({ durationSeconds: duration }).where(eq(songs.id, song.id))
|
||||
}
|
||||
}
|
||||
})()
|
||||
if (typeof event.waitUntil === 'function') {
|
||||
event.waitUntil(durationTask)
|
||||
} else {
|
||||
durationTask.catch((error) => console.error('[备选池] 后台补全时长失败:', error))
|
||||
}
|
||||
}
|
||||
const refreshedMap = new Map(songsRows.map((s) => [s.id, s]))
|
||||
// 构建待插入值和跳过列表
|
||||
const insertValues = []
|
||||
const skipped = []
|
||||
for (const songId of songIds) {
|
||||
const song = refreshedMap.get(songId)
|
||||
if (!song) {
|
||||
skipped.push({ songId, reason: '歌曲不存在(无法加入备选池)' })
|
||||
continue
|
||||
}
|
||||
insertValues.push({ songId, title: song.title, artist: song.artist, createdAt: now, addedBy: user.id })
|
||||
}
|
||||
// 批量插入
|
||||
const added = []
|
||||
if (insertValues.length > 0) {
|
||||
const inserted = await db
|
||||
.insert(scheduleSongPool)
|
||||
.values(insertValues.map((v) => ({ songId: v.songId, createdAt: v.createdAt, addedBy: v.addedBy })))
|
||||
.onConflictDoNothing({ target: [scheduleSongPool.songId] })
|
||||
.returning()
|
||||
const insertedIds = new Set(inserted.map((r) => r.songId))
|
||||
for (const v of insertValues) {
|
||||
if (insertedIds.has(v.songId)) {
|
||||
added.push({ songId: v.songId, title: v.title, artist: v.artist })
|
||||
} else {
|
||||
skipped.push({ songId: v.songId, reason: '已存在于备选池' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { added, skipped, total: await fetchPoolCount() }
|
||||
})
|
||||
@@ -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,
|
||||
message: '需要管理员权限'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取学期 ID
|
||||
const semesterId = parseInt(getRouterParam(event, 'id') || '0')
|
||||
if (!semesterId || isNaN(semesterId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的学期 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,
|
||||
message: '学期不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否为当前活跃学期
|
||||
if (semester.isActive) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '不能删除当前活跃的学期'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否有关联的歌曲
|
||||
const songCountResult = await db.select().from(songs).where(eq(songs.semester, semester.name))
|
||||
const songCount = songCountResult.length
|
||||
|
||||
if (songCount > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `该学期下还有 ${songCount} 首歌曲,无法删除`
|
||||
})
|
||||
}
|
||||
|
||||
// 删除学期
|
||||
await db.delete(semesters).where(eq(semesters.id, semesterId))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '学期删除成功'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { semesters, songs } from '~/drizzle/schema'
|
||||
import { eq, and, 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', 'SONG_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有管理员才能管理学期'
|
||||
})
|
||||
}
|
||||
|
||||
const semesterId = parseInt(event.context.params?.id || '0')
|
||||
|
||||
if (!semesterId || isNaN(semesterId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的学期ID'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const nextName = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
|
||||
if (!nextName) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '学期名称不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查学期是否存在
|
||||
const currentSemesterResult = await db
|
||||
.select()
|
||||
.from(semesters)
|
||||
.where(eq(semesters.id, semesterId))
|
||||
.limit(1)
|
||||
|
||||
if (currentSemesterResult.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '学期不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查新名称是否与其他学期冲突
|
||||
const existingSemesterResult = await db
|
||||
.select()
|
||||
.from(semesters)
|
||||
.where(and(eq(semesters.name, nextName), ne(semesters.id, semesterId)))
|
||||
.limit(1)
|
||||
|
||||
if (existingSemesterResult.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '该学期名称已存在'
|
||||
})
|
||||
}
|
||||
|
||||
const oldName = currentSemesterResult[0].name
|
||||
|
||||
if (oldName === nextName) {
|
||||
return currentSemesterResult[0]
|
||||
}
|
||||
|
||||
// 更新学期名称及关联歌曲数据
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const updateResult = await tx
|
||||
.update(semesters)
|
||||
.set({ name: nextName })
|
||||
.where(eq(semesters.id, semesterId))
|
||||
.returning()
|
||||
|
||||
if (updateResult.length > 0) {
|
||||
// 同步更新歌曲表中的学期名称引用
|
||||
await tx.update(songs).set({ semester: nextName }).where(eq(songs.semester, oldName))
|
||||
}
|
||||
|
||||
return updateResult[0]
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
@@ -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,72 @@
|
||||
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)
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
|
||||
if (!name) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '学期名称不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查学期名称是否已存在
|
||||
const existingSemesterResult = await db
|
||||
.select()
|
||||
.from(semesters)
|
||||
.where(eq(semesters.name, 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,
|
||||
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,37 @@
|
||||
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()
|
||||
// 手动重载必须强制刷新,否则 transporter 已存在时直接早退
|
||||
const initialized = await smtpService.initializeSmtpConfig(true)
|
||||
|
||||
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,110 @@
|
||||
import { SmtpService } from '~~/server/services/smtpService'
|
||||
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
|
||||
import { SMTP_PASSWORD_MASK } from '~~/server/api/admin/system-settings/secretMask'
|
||||
|
||||
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 {
|
||||
// 掩码密码回退:以数据库持久化值为准,内存单例不可靠(冷启动竞态/陈旧配置)
|
||||
let testPassword = body.smtpPassword
|
||||
if (testPassword === SMTP_PASSWORD_MASK) {
|
||||
const dbSettings = await getSystemSettingsCached()
|
||||
testPassword = dbSettings?.smtpPassword || ''
|
||||
}
|
||||
|
||||
if (!testPassword) {
|
||||
return {
|
||||
success: false,
|
||||
message: '未找到有效的SMTP密码,请重新填写授权码并保存配置'
|
||||
}
|
||||
}
|
||||
|
||||
// 设置测试配置
|
||||
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,121 @@
|
||||
import { SmtpService } from '~~/server/services/smtpService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
|
||||
import { SMTP_PASSWORD_MASK } from '~~/server/api/admin/system-settings/secretMask'
|
||||
|
||||
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 {
|
||||
// 掩码密码回退:以数据库持久化值为准,内存单例不可靠(冷启动竞态/陈旧配置)
|
||||
let testPassword = body.smtpPassword
|
||||
if (testPassword === SMTP_PASSWORD_MASK) {
|
||||
const dbSettings = await getSystemSettingsCached()
|
||||
testPassword = dbSettings?.smtpPassword || ''
|
||||
}
|
||||
|
||||
if (!testPassword) {
|
||||
return {
|
||||
success: false,
|
||||
message: '未找到有效的SMTP密码,请重新填写授权码并保存配置'
|
||||
}
|
||||
}
|
||||
|
||||
// 设置测试配置
|
||||
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,100 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs, votes, requestTimes } from '~/drizzle/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
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,291 @@
|
||||
import { readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import {
|
||||
createTxSongDetailBody,
|
||||
normalizeTxMusicId,
|
||||
txHeaders
|
||||
} from '~~/server/utils/native_tx'
|
||||
import { fetchSongDuration } from '~~/server/utils/songDurationFetcher'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
// 新增歌曲场景:未传 songId 时按平台+音乐ID获取时长,仅返回不写入
|
||||
if (body?.songId == null) {
|
||||
const platform = body?.platform
|
||||
const musicId = body?.musicId
|
||||
if (!platform || !musicId) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.SONG_DURATION_PLATFORM_REQUIRED,
|
||||
'缺少平台或音乐 ID 信息,无法获取时长'
|
||||
)
|
||||
}
|
||||
const durationSeconds = await fetchSongDuration(String(platform), String(musicId))
|
||||
if (durationSeconds == null) {
|
||||
return { success: false, songId: null, durationSeconds: null, message: '未获取到有效时长' }
|
||||
}
|
||||
return { success: true, songId: null, durationSeconds, message: '时长获取成功' }
|
||||
}
|
||||
|
||||
const songId = body.songId == null ? null : Number(body.songId)
|
||||
|
||||
if (!Number.isFinite(songId) || songId < 1) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.SONG_INVALID_ID, '歌曲 ID 无效')
|
||||
}
|
||||
|
||||
const song = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
|
||||
if (song.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.SONG_NOT_FOUND, '歌曲不存在')
|
||||
}
|
||||
|
||||
const s = song[0]
|
||||
|
||||
// 浏览器读取或管理员手动输入时,只由服务端负责校验并保存结果。
|
||||
if (Object.prototype.hasOwnProperty.call(body || {}, 'durationSeconds')) {
|
||||
if (body.durationSeconds === null) {
|
||||
await db.update(songs).set({ durationSeconds: null }).where(eq(songs.id, songId))
|
||||
return { success: true, songId, durationSeconds: null, message: '时长已清空' }
|
||||
}
|
||||
|
||||
const durationSeconds = Number(body.durationSeconds)
|
||||
if (!Number.isInteger(durationSeconds) || durationSeconds < 0 || durationSeconds > 7200) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
|
||||
'时长需在 0 秒至 2 小时之间'
|
||||
)
|
||||
}
|
||||
|
||||
if (s.durationSeconds !== durationSeconds) {
|
||||
await db.update(songs).set({ durationSeconds }).where(eq(songs.id, songId))
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
songId,
|
||||
durationSeconds,
|
||||
message: s.durationSeconds === durationSeconds ? '时长未变化' : '时长更新成功'
|
||||
}
|
||||
}
|
||||
|
||||
const platform = s.musicPlatform
|
||||
const musicId = s.musicId
|
||||
|
||||
if (!platform || !musicId) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.SONG_DURATION_PLATFORM_REQUIRED,
|
||||
'歌曲缺少平台或音乐 ID 信息,无法获取时长'
|
||||
)
|
||||
}
|
||||
|
||||
let durationSeconds: number | null = null
|
||||
|
||||
try {
|
||||
if (platform === 'netease' || platform === 'netease-podcast') {
|
||||
const response: any = await $fetch('/api/api-enhanced/netease/song/url/v1', {
|
||||
params: { id: musicId, level: 'standard' },
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
if (response?.code === 200 && Array.isArray(response.data) && response.data.length > 0) {
|
||||
const timeMs = response.data[0]?.time
|
||||
if (typeof timeMs === 'number' && timeMs > 0) {
|
||||
durationSeconds = Math.floor(timeMs / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
if (!durationSeconds) {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: '网易云 API 未返回有效时长'
|
||||
}
|
||||
}
|
||||
} else if (platform === 'tencent') {
|
||||
const normalized = normalizeTxMusicId(musicId)
|
||||
const result: any = await $fetch(
|
||||
'https://u.y.qq.com/cgi-bin/musicu.fcg',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: txHeaders,
|
||||
body: createTxSongDetailBody(normalized),
|
||||
responseType: 'json',
|
||||
timeout: 10000
|
||||
}
|
||||
)
|
||||
|
||||
if (result?.code === 0 && result?.req?.code === 0) {
|
||||
const interval = result.req?.data?.track_info?.interval
|
||||
if (typeof interval === 'number' && interval > 0) {
|
||||
durationSeconds = Math.floor(interval)
|
||||
}
|
||||
}
|
||||
|
||||
if (!durationSeconds) {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: 'QQ 音乐详情接口未返回有效时长'
|
||||
}
|
||||
}
|
||||
} else if (platform === 'migu') {
|
||||
const songInfo: any = await $fetch(
|
||||
`https://app.c.nf.migu.cn/resource/song/by-contentids/v2.0?contentId=${encodeURIComponent(musicId)}`,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
if (songInfo?.code?.toString() === '000000' && songInfo?.data?.length) {
|
||||
const duration = songInfo.data[0]?.duration
|
||||
if (typeof duration === 'number' && duration > 0) {
|
||||
durationSeconds = Math.floor(duration)
|
||||
}
|
||||
}
|
||||
|
||||
if (!durationSeconds) {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: '咪咕歌曲信息接口未返回有效时长'
|
||||
}
|
||||
}
|
||||
} else if (platform === 'bilibili') {
|
||||
// musicId 格式为 bvid:cid 或 bvid:cid:page
|
||||
const parts = musicId.split(':')
|
||||
const bvid = parts[0]
|
||||
const page = parts.length > 2 ? Number(parts[2]) : 1
|
||||
if (parts.length > 2 && (!Number.isInteger(page) || page < 1)) {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: `哔哩哔哩视频 ID 格式无效:${musicId}`
|
||||
}
|
||||
}
|
||||
const viewResp: any = await $fetch(
|
||||
'https://api.bilibili.com/x/web-interface/view',
|
||||
{
|
||||
params: { bvid },
|
||||
headers: {
|
||||
Cookie: 'buvid3=0',
|
||||
Referer: 'https://www.bilibili.com/'
|
||||
},
|
||||
timeout: 10000
|
||||
}
|
||||
)
|
||||
|
||||
if (viewResp?.code === 0 && viewResp?.data?.pages?.length) {
|
||||
const pageIdx = page > 1 ? Math.max(0, page - 1) : 0
|
||||
const duration = viewResp.data.pages[pageIdx]?.duration
|
||||
if (typeof duration === 'number' && duration > 0) {
|
||||
durationSeconds = Math.floor(duration)
|
||||
}
|
||||
}
|
||||
|
||||
if (!durationSeconds) {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: '哔哩哔哩视频详情接口未返回有效时长'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: `暂不支持的平台:${platform}`
|
||||
}
|
||||
}
|
||||
|
||||
// 网易云固定返回 30 秒时通常是试听片段,不作为歌曲完整时长。
|
||||
if ((platform === 'netease' || platform === 'netease-podcast') && durationSeconds === 30) {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: '网易云返回试听片段时长,未写入'
|
||||
}
|
||||
}
|
||||
|
||||
// 校验时长范围(合理的歌曲时长:30秒~1小时)
|
||||
if (durationSeconds < 30 || durationSeconds > 3600) {
|
||||
console.warn(`[时长刷新] 歌曲 #${songId} 时长异常:${durationSeconds}s,跳过写入`)
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: `获取到的时长 ${durationSeconds}s 超出合理范围(30秒~1小时),未写入`
|
||||
}
|
||||
}
|
||||
|
||||
// 避免无变化写入
|
||||
if (s.durationSeconds === durationSeconds) {
|
||||
return {
|
||||
success: true,
|
||||
songId,
|
||||
durationSeconds,
|
||||
message: '时长未变化'
|
||||
}
|
||||
}
|
||||
|
||||
// 写入数据库
|
||||
await db.update(songs).set({ durationSeconds }).where(eq(songs.id, songId))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
songId,
|
||||
durationSeconds,
|
||||
message: '时长获取成功'
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err?.message || '未知错误'
|
||||
const platformName = platform === 'netease' || platform === 'netease-podcast' ? '网易云' : platform === 'tencent' ? 'QQ音乐' : platform === 'migu' ? '咪咕' : platform === 'bilibili' ? 'Bilibili' : platform
|
||||
const statusCode = err.statusCode || err.status || (err.response?.status as number | undefined)
|
||||
// AbortError:请求超时或批量刷新中止
|
||||
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: `${platformName} 响应超时`
|
||||
}
|
||||
}
|
||||
// 404:平台限流或歌曲不存在
|
||||
if (statusCode === 404) {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: `${platformName} 未找到该歌曲`
|
||||
}
|
||||
}
|
||||
if (statusCode === 403 || statusCode === 429) {
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: `${platformName} 接口被限制,请稍后重试`
|
||||
}
|
||||
}
|
||||
console.error(`[时长刷新] 歌曲 #${songId} 获取时长失败 (${platformName}):`, errorMsg)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
songId,
|
||||
durationSeconds: null,
|
||||
message: `${platformName} 接口异常,请稍后重试`
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { createSongPlayedNotification } from '~~/server/services/notificationService'
|
||||
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'
|
||||
import { restoreReplayRequestsToPending } from '~~/server/utils/scheduleReplayBinding'
|
||||
import { z } from 'zod'
|
||||
|
||||
const markPlayedSchema = z.object({
|
||||
songId: z.number().optional(),
|
||||
songIds: z.array(z.number()).max(100, '批量操作单次最多支持 100 首歌曲').optional(),
|
||||
unmark: z.boolean().optional()
|
||||
})
|
||||
|
||||
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)
|
||||
const validatedDataResult = markPlayedSchema.safeParse(body)
|
||||
|
||||
if (!validatedDataResult.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: validatedDataResult.error.issues[0]?.message || '请求参数无效'
|
||||
})
|
||||
}
|
||||
|
||||
const validatedData = validatedDataResult.data
|
||||
|
||||
const songIds = Array.from(
|
||||
new Set([
|
||||
...(validatedData.songId !== undefined ? [validatedData.songId] : []),
|
||||
...(validatedData.songIds || [])
|
||||
])
|
||||
)
|
||||
|
||||
if (songIds.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '歌曲ID列表不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
// 撤回已播放
|
||||
const isUnmark = validatedData.unmark === 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: songs.id })
|
||||
|
||||
const updatedSongIds = updatedSongsResult.map((s) => s.id)
|
||||
|
||||
if (updatedSongIds.length > 0) {
|
||||
if (!isUnmark) {
|
||||
// 标记已播放:履行全部待处理的重播申请
|
||||
const updatedRequests = await tx
|
||||
.update(songReplayRequests)
|
||||
.set({
|
||||
status: 'FULFILLED',
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, updatedSongIds),
|
||||
eq(songReplayRequests.status, 'PENDING')
|
||||
)
|
||||
)
|
||||
.returning({ id: songReplayRequests.id })
|
||||
|
||||
if (updatedRequests.length > 0) {
|
||||
console.log(`将 ${updatedRequests.length} 个重播申请状态更新为 FULFILLED(歌曲已播放)`)
|
||||
}
|
||||
} else {
|
||||
// 撤回已播放:每人仅恢复最新一条,避免违反部分唯一索引
|
||||
const restoredCount = await restoreReplayRequestsToPending({
|
||||
tx,
|
||||
songIds: updatedSongIds,
|
||||
at: new Date()
|
||||
})
|
||||
|
||||
if (restoredCount > 0) {
|
||||
console.log(`将 ${restoredCount} 个重播申请状态恢复为 PENDING(撤回已播放)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { updatedSongsResult, updatedSongIds }
|
||||
})
|
||||
|
||||
// 异步发送通知
|
||||
if (!isUnmark && updatedSongIds.length > 0) {
|
||||
event.waitUntil(
|
||||
Promise.allSettled(
|
||||
updatedSongIds.map((songId) =>
|
||||
createSongPlayedNotification(songId).catch((err) => {
|
||||
console.error(`发送歌曲(${songId})已播放通知失败:`, err)
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
message: isUnmark ? '歌曲已成功撤回已播放状态' : '歌曲已成功标记为已播放',
|
||||
count: updatedSongsResult.length,
|
||||
updatedSongIds: updatedSongIds
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
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()
|
||||
)
|
||||
.then(() => {
|
||||
console.log(`已发送驳回通知给用户: ${songForNotification.requesterId}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`发送通知失败: ${error.message}`)
|
||||
})
|
||||
|
||||
notificationSent = true
|
||||
}
|
||||
|
||||
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,366 @@
|
||||
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, ne } from 'drizzle-orm'
|
||||
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 || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '需要管理员权限'
|
||||
})
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const semester = query.semester as string
|
||||
|
||||
try {
|
||||
// 获取当前时间相关的日期
|
||||
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)
|
||||
.where(ne(users.status, 'pending'))
|
||||
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(and(gte(users.createdAt, weekAgo), ne(users.status, 'pending')))
|
||||
return result[0].count
|
||||
})(),
|
||||
|
||||
// 上周新增用户(排除待审核)
|
||||
(async () => {
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
gte(users.createdAt, twoWeeksAgo),
|
||||
lt(users.createdAt, weekAgo),
|
||||
ne(users.status, 'pending')
|
||||
)
|
||||
)
|
||||
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), ne(users.status, 'pending')))
|
||||
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]
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取统计数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, users, votes } from '~/drizzle/schema'
|
||||
|
||||
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 query = getQuery(event)
|
||||
const semester = query.semester as string
|
||||
const limit = parseInt(query.limit as string) || 20
|
||||
|
||||
try {
|
||||
// 构建查询条件 - 如果没有指定学期或学期为'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 = []
|
||||
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)
|
||||
|
||||
return sortedUsers
|
||||
} catch (error) {
|
||||
// 返回空数组而不是抛出错误,避免前端显示错误
|
||||
return []
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import { createError, defineEventHandler } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, users, userIdentities, votes } from '~/drizzle/schema'
|
||||
import { count, eq, gte, inArray } from 'drizzle-orm'
|
||||
import { getBeijingHour, getBeijingStartOfDay } from '~/utils/timeUtils'
|
||||
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
|
||||
|
||||
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: '需要管理员权限'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前时间相关的日期
|
||||
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,
|
||||
avatar: null
|
||||
})
|
||||
})
|
||||
|
||||
// 添加登录用户
|
||||
recentLoginUsers.forEach((user) => {
|
||||
userMap.set(user.id, {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
avatar: null
|
||||
})
|
||||
})
|
||||
|
||||
// 添加点赞用户
|
||||
recentVoteUsers.forEach((vote) => {
|
||||
userMap.set(vote.userId2, {
|
||||
id: vote.userId2,
|
||||
username: vote.userUsername,
|
||||
name: vote.userName,
|
||||
avatar: null
|
||||
})
|
||||
})
|
||||
|
||||
// 解析头像(与用户管理面板同源:优先用户选中的身份,否则按创建顺序取第一个可用头像)
|
||||
const activeUserIds = Array.from(userMap.keys())
|
||||
if (activeUserIds.length > 0) {
|
||||
const [userPrefs, identities] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: users.id,
|
||||
avatarProvider: users.avatarProvider,
|
||||
avatarProviderUserId: users.avatarProviderUserId
|
||||
})
|
||||
.from(users)
|
||||
.where(inArray(users.id, activeUserIds)),
|
||||
db
|
||||
.select({
|
||||
userId: userIdentities.userId,
|
||||
provider: userIdentities.provider,
|
||||
providerUsername: userIdentities.providerUsername,
|
||||
providerUserId: userIdentities.providerUserId,
|
||||
avatar: userIdentities.avatar,
|
||||
createdAt: userIdentities.createdAt
|
||||
})
|
||||
.from(userIdentities)
|
||||
.where(inArray(userIdentities.userId, activeUserIds))
|
||||
])
|
||||
|
||||
const prefsMap = new Map(userPrefs.map((pref) => [pref.id, pref]))
|
||||
const identitiesMap = new Map()
|
||||
identities.forEach((identity) => {
|
||||
if (!identitiesMap.has(identity.userId)) {
|
||||
identitiesMap.set(identity.userId, [])
|
||||
}
|
||||
identitiesMap.get(identity.userId).push(identity)
|
||||
})
|
||||
|
||||
userMap.forEach((info, id) => {
|
||||
const pref = prefsMap.get(id)
|
||||
const avatarSource = resolveAvatarSource(
|
||||
{
|
||||
avatarProvider: pref?.avatarProvider,
|
||||
avatarProviderUserId: pref?.avatarProviderUserId
|
||||
},
|
||||
identitiesMap.get(id) || []
|
||||
)
|
||||
info.avatar = avatarSource?.url ?? null
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取实时统计数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
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 || !['SONG_ADMIN', '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 = await db
|
||||
.select({ count: count() })
|
||||
.from(songs)
|
||||
.where(eq(songs.semester, semester.name))
|
||||
|
||||
// 统计该学期的排期数量(通过关联歌曲)
|
||||
const totalSchedulesResult = await db
|
||||
.select({ count: count() })
|
||||
.from(schedules)
|
||||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(eq(songs.semester, semester.name))
|
||||
|
||||
// 统计该学期有投票的歌曲数量
|
||||
const totalRequestsResult = await 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,80 @@
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, users, votes, songReplayRequests } from '~/drizzle/schema'
|
||||
import { count, countDistinct, desc, eq, sql } from 'drizzle-orm'
|
||||
|
||||
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 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,
|
||||
// insert-only 模型下同一用户可能有多条申请,按申请人去重计数
|
||||
count: countDistinct(songReplayRequests.userId)
|
||||
})
|
||||
.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(countDistinct(songReplayRequests.userId)))
|
||||
.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 || !['SONG_ADMIN', '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,70 @@
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, users } from '~/drizzle/schema'
|
||||
import { and, count, eq, gte, sql } from 'drizzle-orm'
|
||||
|
||||
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 query = getQuery(event)
|
||||
const semester = query.semester as string
|
||||
|
||||
try {
|
||||
const songWhereCondition = semester && semester !== 'all' ? eq(songs.semester, semester) : undefined
|
||||
|
||||
// 获取用户参与度数据
|
||||
// 1. 获取总用户数
|
||||
const totalUsersResult = await db.select({ count: count() }).from(users)
|
||||
const totalUsers = Number(totalUsersResult[0]?.count || 0)
|
||||
|
||||
// 2. 获取有请求歌曲的用户数
|
||||
const activeUsersResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${songs.requesterId})` })
|
||||
.from(songs)
|
||||
.where(songWhereCondition)
|
||||
const activeUsers = Number(activeUsersResult[0]?.count || 0)
|
||||
|
||||
// 3. 获取用户请求歌曲的平均数量
|
||||
const totalSongRequestsResult = await db
|
||||
.select({ count: count() })
|
||||
.from(songs)
|
||||
.where(songWhereCondition)
|
||||
const totalSongRequests = Number(totalSongRequestsResult[0]?.count || 0)
|
||||
const averageSongsPerUser = totalUsers > 0 ? totalSongRequests / totalUsers : 0
|
||||
|
||||
// 4. 获取最近活跃用户
|
||||
const oneWeekAgo = new Date()
|
||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)
|
||||
|
||||
const recentWhereCondition = songWhereCondition
|
||||
? and(songWhereCondition, gte(songs.createdAt, oneWeekAgo))
|
||||
: gte(songs.createdAt, oneWeekAgo)
|
||||
const recentActiveUsersResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${songs.requesterId})` })
|
||||
.from(songs)
|
||||
.where(recentWhereCondition)
|
||||
const recentActiveUsers = Number(recentActiveUsersResult[0]?.count || 0)
|
||||
|
||||
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,121 @@
|
||||
import { db, users, userIdentities } from '~/drizzle/db'
|
||||
import { eq, or, like, inArray } from 'drizzle-orm'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有管理员才能清除聚合登录绑定'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// 1. 查询所有聚合登录相关的身份
|
||||
const targetIdentities = await tx.query.userIdentities.findMany({
|
||||
where: or(
|
||||
eq(userIdentities.provider, 'aggregate'),
|
||||
like(userIdentities.provider, 'aggregate:%')
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
userId: true,
|
||||
provider: true,
|
||||
providerUserId: true
|
||||
}
|
||||
})
|
||||
|
||||
if (targetIdentities.length === 0) {
|
||||
return { count: 0, usersAffected: 0 }
|
||||
}
|
||||
|
||||
const affectedUserIds = Array.from(new Set(targetIdentities.map((item) => item.userId)))
|
||||
|
||||
// 2. 检查受影响用户的头像来源
|
||||
const targetKeys = new Set(
|
||||
targetIdentities.map((identity) => `${identity.provider}:${identity.providerUserId}`)
|
||||
)
|
||||
|
||||
const affectedUsers = await tx.query.users.findMany({
|
||||
where: inArray(users.id, affectedUserIds),
|
||||
columns: {
|
||||
id: true,
|
||||
avatarProvider: true,
|
||||
avatarProviderUserId: true
|
||||
}
|
||||
})
|
||||
|
||||
const allIdentitiesForAffected = await tx.query.userIdentities.findMany({
|
||||
where: inArray(userIdentities.userId, affectedUserIds),
|
||||
columns: {
|
||||
id: true,
|
||||
userId: true,
|
||||
provider: true,
|
||||
providerUserId: true,
|
||||
providerUsername: true,
|
||||
avatar: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
// 按用户分组剩余身份
|
||||
const identitiesByUser = new Map<number, typeof allIdentitiesForAffected>()
|
||||
for (const identity of allIdentitiesForAffected) {
|
||||
if (!targetKeys.has(`${identity.provider}:${identity.providerUserId}`)) {
|
||||
if (!identitiesByUser.has(identity.userId)) {
|
||||
identitiesByUser.set(identity.userId, [])
|
||||
}
|
||||
identitiesByUser.get(identity.userId)!.push(identity)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新需要重置头像的用户
|
||||
for (const userRecord of affectedUsers) {
|
||||
const isAvatarSourceDeleted =
|
||||
userRecord.avatarProvider &&
|
||||
(userRecord.avatarProvider === 'aggregate' ||
|
||||
userRecord.avatarProvider.startsWith('aggregate:') ||
|
||||
targetKeys.has(`${userRecord.avatarProvider}:${userRecord.avatarProviderUserId}`))
|
||||
|
||||
if (isAvatarSourceDeleted) {
|
||||
const remaining = identitiesByUser.get(userRecord.id) || []
|
||||
const fallback = resolveAvatarSource(userRecord, remaining)
|
||||
await tx
|
||||
.update(users)
|
||||
.set({
|
||||
avatarProvider: fallback?.provider ?? null,
|
||||
avatarProviderUserId: fallback?.providerUserId ?? null
|
||||
})
|
||||
.where(eq(users.id, userRecord.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 批量删除所有聚合登录身份
|
||||
await tx
|
||||
.delete(userIdentities)
|
||||
.where(
|
||||
or(
|
||||
eq(userIdentities.provider, 'aggregate'),
|
||||
like(userIdentities.provider, 'aggregate:%')
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
count: targetIdentities.length,
|
||||
usersAffected: affectedUserIds.length
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: result.count,
|
||||
usersAffected: result.usersAffected
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { SYSTEM_SETTINGS_DEFAULTS } from '../../../utils/system-settings-defaults'
|
||||
import { maskSystemSettingsSecrets } from './secretMask'
|
||||
import {
|
||||
getAggregateOAuthLoginTypesOrDefault,
|
||||
isSafeAggregateOAuthUrl
|
||||
} from '~~/server/utils/oauth-providers'
|
||||
|
||||
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 provider = typeof body.provider === 'string' ? body.provider : ''
|
||||
|
||||
const updateData: Record<string, any> = {}
|
||||
|
||||
if (provider === 'base') {
|
||||
if (!process.env.OAUTH_REDIRECT_URI && !process.env.OAUTH_STATE_SECRET) {
|
||||
throw createError({ statusCode: 400, message: '未检测到可导入的基础 OAuth 环境配置' })
|
||||
}
|
||||
if (process.env.OAUTH_REDIRECT_URI) {
|
||||
updateData.oauthRedirectUri = process.env.OAUTH_REDIRECT_URI
|
||||
}
|
||||
if (process.env.OAUTH_STATE_SECRET) {
|
||||
updateData.oauthStateSecret = process.env.OAUTH_STATE_SECRET
|
||||
}
|
||||
} else if (provider === 'github') {
|
||||
if (!process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET) {
|
||||
throw createError({ statusCode: 400, message: '未检测到完整的 GitHub 环境配置' })
|
||||
}
|
||||
updateData.githubOAuthEnabled = true
|
||||
updateData.githubClientId = process.env.GITHUB_CLIENT_ID
|
||||
updateData.githubClientSecret = process.env.GITHUB_CLIENT_SECRET
|
||||
} else if (provider === 'casdoor') {
|
||||
if (
|
||||
!process.env.CASDOOR_ENDPOINT ||
|
||||
!process.env.CASDOOR_CLIENT_ID ||
|
||||
!process.env.CASDOOR_CLIENT_SECRET
|
||||
) {
|
||||
throw createError({ statusCode: 400, message: '未检测到完整的 Casdoor 环境配置' })
|
||||
}
|
||||
updateData.casdoorOAuthEnabled = true
|
||||
updateData.casdoorServerUrl = process.env.CASDOOR_ENDPOINT
|
||||
updateData.casdoorClientId = process.env.CASDOOR_CLIENT_ID
|
||||
updateData.casdoorClientSecret = process.env.CASDOOR_CLIENT_SECRET
|
||||
updateData.casdoorOrganizationName = process.env.CASDOOR_ORGANIZATION_NAME || 'built-in'
|
||||
} else if (provider === 'google') {
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
throw createError({ statusCode: 400, message: '未检测到完整的 Google 环境配置' })
|
||||
}
|
||||
updateData.googleOAuthEnabled = true
|
||||
updateData.googleClientId = process.env.GOOGLE_CLIENT_ID
|
||||
updateData.googleClientSecret = process.env.GOOGLE_CLIENT_SECRET
|
||||
} else if (provider === 'aggregate') {
|
||||
const appId = process.env.AGGREGATE_OAUTH_APP_ID?.trim()
|
||||
const appKey = process.env.AGGREGATE_OAUTH_APP_KEY?.trim()
|
||||
if (!appId || !appKey) {
|
||||
throw createError({ statusCode: 400, message: '未检测到完整的聚合登陆环境配置' })
|
||||
}
|
||||
const loginTypes = getAggregateOAuthLoginTypesOrDefault(process.env.AGGREGATE_OAUTH_LOGIN_TYPE)
|
||||
if (loginTypes.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '未检测到受支持的聚合登录方式' })
|
||||
}
|
||||
const endpoint = process.env.AGGREGATE_OAUTH_ENDPOINT?.trim()
|
||||
if (!endpoint) {
|
||||
throw createError({ statusCode: 400, message: '未检测到 AGGREGATE_OAUTH_ENDPOINT 环境配置' })
|
||||
}
|
||||
if (!isSafeAggregateOAuthUrl(endpoint)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'AGGREGATE_OAUTH_ENDPOINT 公网地址必须使用 HTTPS,内网地址可使用 HTTP'
|
||||
})
|
||||
}
|
||||
updateData.aggregateOAuthEnabled = true
|
||||
updateData.aggregateOAuthAppId = appId
|
||||
updateData.aggregateOAuthAppKey = appKey
|
||||
updateData.aggregateOAuthLoginType = JSON.stringify(loginTypes)
|
||||
updateData.aggregateOAuthEndpoint = endpoint
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: '不支持的导入类型' })
|
||||
}
|
||||
|
||||
const settingsResult = await db.select().from(systemSettings).limit(1)
|
||||
let settings = settingsResult[0]
|
||||
|
||||
if (!settings) {
|
||||
const inserted = await db
|
||||
.insert(systemSettings)
|
||||
.values({ ...SYSTEM_SETTINGS_DEFAULTS, ...updateData })
|
||||
.returning()
|
||||
settings = inserted[0]
|
||||
} else {
|
||||
const updated = await db
|
||||
.update(systemSettings)
|
||||
.set(updateData)
|
||||
.where(eq(systemSettings.id, settings.id))
|
||||
.returning()
|
||||
settings = updated[0]
|
||||
}
|
||||
|
||||
return maskSystemSettingsSecrets(settings)
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
const hasEnvValue = (value?: string) => typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
export default defineEventHandler((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 {
|
||||
hasBaseConfig: !!(process.env.OAUTH_REDIRECT_URI || process.env.OAUTH_STATE_SECRET),
|
||||
hasGithubConfig: !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET),
|
||||
hasCasdoorConfig: !!(
|
||||
process.env.CASDOOR_ENDPOINT &&
|
||||
process.env.CASDOOR_CLIENT_ID &&
|
||||
process.env.CASDOOR_CLIENT_SECRET
|
||||
),
|
||||
hasGoogleConfig: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
|
||||
hasAggregateConfig:
|
||||
hasEnvValue(process.env.AGGREGATE_OAUTH_APP_ID) &&
|
||||
hasEnvValue(process.env.AGGREGATE_OAUTH_APP_KEY)
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user