feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { schedules, songs, users } from '~/drizzle/schema'
import { desc, eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['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: '获取活动记录失败'
})
}
})
+79
View File
@@ -0,0 +1,79 @@
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
import { eq } from 'drizzle-orm'
/**
* 删除API Key
* DELETE /api/admin/api-keys/[id]
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理 API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
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}`
})
}
})
+92
View File
@@ -0,0 +1,92 @@
import { apiKeyPermissions, apiKeys, db, users } from '~/drizzle/db'
import { eq } from 'drizzle-orm'
/**
* 获取API Key详情
* GET /api/admin/api-keys/[id]
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理 API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
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}`
})
}
})
+164
View File
@@ -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}`
})
}
})
+199
View File
@@ -0,0 +1,199 @@
import { apiKeyPermissions, apiKeys, db, users } from '~/drizzle/db'
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
/**
* 获取API Key列表
* GET /api/admin/api-keys
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理 API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
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}`
})
}
})
+161
View File
@@ -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}`
})
}
})
+71
View File
@@ -0,0 +1,71 @@
import { ApiLogService } from '~~/server/services/apiLogService'
/**
* 获取API访问日志
* GET /api/admin/api-keys/logs
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以查看 API 日志
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
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 访问日志失败'
})
}
})
+12
View File
@@ -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
}
+114
View File
@@ -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: '自动备份配置已保存' }
})
+178
View File
@@ -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 || '删除备份文件失败'
})
}
})
+84
View File
@@ -0,0 +1,84 @@
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { promises as fs } from 'fs'
import path from 'path'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || 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 || '下载备份文件失败'
})
}
})
+618
View File
@@ -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 } }
})
+14
View File
@@ -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 }
})
+158
View File
@@ -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 || '邮件发送失败' }
}
})
+58
View File
@@ -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 连接测试失败' }
}
})
+133
View File
@@ -0,0 +1,133 @@
import { createError, defineEventHandler } from 'h3'
import { promises as fs } from 'fs'
import path from 'path'
import formidable from 'formidable'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || 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 || '上传备份文件失败'
})
}
})
+57
View File
@@ -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: '删除黑名单项失败'
})
}
})
+72
View File
@@ -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: '更新黑名单项失败'
})
}
})
+63
View File
@@ -0,0 +1,63 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { songBlacklists } from '~/drizzle/schema'
import { and, count, desc, eq, ilike } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
})
}
const query = getQuery(event)
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 20
const type = query.type as string
const search = query.search as string
try {
// 构建查询条件
const whereConditions = []
if (type) {
whereConditions.push(eq(songBlacklists.type, type))
}
if (search) {
whereConditions.push(ilike(songBlacklists.value, `%${search}%`))
}
const whereClause = whereConditions.length > 0 ? and(...whereConditions) : undefined
// 获取总数
const totalResult = await db.select({ count: count() }).from(songBlacklists).where(whereClause)
const total = totalResult[0].count
// 获取黑名单列表
const blacklist = await db
.select()
.from(songBlacklists)
.where(whereClause)
.orderBy(desc(songBlacklists.createdAt))
.limit(limit)
.offset((page - 1) * limit)
return {
blacklist,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
}
} catch (error) {
console.error('获取黑名单失败:', error)
throw createError({
statusCode: 500,
message: '获取黑名单失败'
})
}
})
+95
View File
@@ -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: '添加黑名单项失败'
})
}
})
+96
View File
@@ -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 || '更新失败' })
}
})
+117
View File
@@ -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: '删除点歌券失败' })
}
})
+85
View File
@@ -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 || '导出点歌券失败' })
}
})
+74
View File
@@ -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: '获取点歌券兑换日志失败' })
}
})
+109
View File
@@ -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 || '更新点歌券失败' })
}
})
+35
View File
@@ -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'
})
}
})
+282
View File
@@ -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 || '服务器内部错误'
})
}
})
+57
View File
@@ -0,0 +1,57 @@
import { verifyAdminAuth } from '~~/server/utils/auth'
import { databaseManager } from '~~/server/utils/database-manager'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const authResult = await verifyAdminAuth(event)
if (!authResult.success) {
throw createError({
statusCode: 401,
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'
})
}
})
+69
View File
@@ -0,0 +1,69 @@
import { createError, defineEventHandler } from 'h3'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { count, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
try {
const status = {
connected: false,
tables: {
user: false,
song: false,
vote: false,
schedule: false,
notification: false
},
userCount: 0
}
// 检查数据库连接
try {
await db.execute(sql`SELECT 1`)
status.connected = true
} catch (error) {
console.error('数据库连接失败:', error)
return { success: false, status, error: '数据库连接失败' }
}
// 检查各个表是否存在
const tables = ['user', 'song', 'vote', 'schedule', 'notification']
for (const tableName of tables) {
try {
const result = await db.execute(sql`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = ${tableName}
)
`)
status.tables[tableName] = result[0]?.exists || false
} catch (error) {
console.error(`检查表 ${tableName} 失败:`, error)
status.tables[tableName] = false
}
}
// 如果表存在,获取记录数
if (status.tables.user) {
try {
const userCountResult = await db.select({ count: count() }).from(users)
status.userCount = userCountResult[0]?.count || 0
} catch (error) {
console.error('获取用户数量失败:', error)
}
}
return {
success: true,
status
}
} catch (error) {
console.error('检查数据库状态失败:', error)
throw createError({
statusCode: 500,
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 }
})
+213
View File
@@ -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
}
})
+50
View File
@@ -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 }
}
})
+220
View File
@@ -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
}
})
+240
View File
@@ -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: '不支持的请求方法'
})
}
})
+101
View File
@@ -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: '创建播出时段失败'
})
}
})
+35
View File
@@ -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 }
})
+188
View File
@@ -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: '创建投稿开放时段失败'
})
}
})
+40
View File
@@ -0,0 +1,40 @@
import { asc, db, desc, requestTimes } from '~/drizzle/db'
import { lt } from 'drizzle-orm'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['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: '获取投稿开放时段失败'
})
}
})
+224
View File
@@ -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 || '发布排期失败'
})
}
})
+158
View File
@@ -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
}
})
+220
View File
@@ -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 || '服务器内部错误,请稍后重试'
})
}
})
+459
View File
@@ -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 || '获取完整排期数据失败'
})
}
})
+128
View File
@@ -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 || '迁移排期日期失败'
})
}
})
+194
View File
@@ -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 }
})
+139
View File
@@ -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 } // 有效记录数(已过滤掉歌曲被删除的孤立记录)
})
+102
View File
@@ -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() }
})
+63
View File
@@ -0,0 +1,63 @@
import { db, eq, semesters, songs } from '~/drizzle/db'
export default defineEventHandler(async (event) => {
// 验证管理员权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
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: '学期删除成功'
}
})
+93
View File
@@ -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
})
+27
View File
@@ -0,0 +1,27 @@
import { db } from '~/drizzle/db'
import { semesters } from '~/drizzle/schema'
import { desc } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '需要登录才能访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能管理学期'
})
}
// 获取所有学期,按创建时间倒序排列
const semestersList = await db.select().from(semesters).orderBy(desc(semesters.createdAt))
return semestersList
})
+72
View File
@@ -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: '活跃学期设置成功' }
})
+37
View File
@@ -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)
}
}
})
+121
View File
@@ -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)
}
}
})
+100
View File
@@ -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: '删除歌曲时发生未知错误'
})
}
})
+291
View File
@@ -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} 接口异常,请稍后重试`
}
}
})
+136
View File
@@ -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
}
})
+147
View File
@@ -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
})
}
})
+366
View File
@@ -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 []
}
})
+230
View File
@@ -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: '获取学期对比数据失败'
})
}
})
+80
View File
@@ -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: '获取热门歌曲排行失败'
})
}
})
+57
View File
@@ -0,0 +1,57 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { and, count, db, eq, gte, songs } from '~/drizzle/db'
import { sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['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