feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ 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)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
+5 -5
View File
@@ -6,12 +6,12 @@ import { eq } from 'drizzle-orm'
* DELETE /api/admin/api-keys/[id]
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理 API Key
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以管理 API Key'
statusMessage: '只有超级管理员可以管理API Key'
})
}
@@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
if (!apiKeyId) {
throw createError({
statusCode: 400,
message: 'API Key ID 不能为空'
statusMessage: 'API Key ID不能为空'
})
}
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
if (existingApiKey.length === 0) {
throw createError({
statusCode: 404,
message: 'API Key 不存在'
statusMessage: 'API Key不存在'
})
}
@@ -73,7 +73,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: `删除 API Key 失败:${error.message}`
statusMessage: `删除API Key失败: ${error.message}`
})
}
})
+5 -5
View File
@@ -6,12 +6,12 @@ import { eq } from 'drizzle-orm'
* GET /api/admin/api-keys/[id]
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理 API Key
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以管理 API Key'
statusMessage: '只有超级管理员可以管理API Key'
})
}
@@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
if (!apiKeyId) {
throw createError({
statusCode: 400,
message: 'API Key ID 不能为空'
statusMessage: 'API Key ID不能为空'
})
}
@@ -52,7 +52,7 @@ export default defineEventHandler(async (event) => {
if (!apiKey) {
throw createError({
statusCode: 404,
message: 'API Key 不存在'
statusMessage: 'API Key不存在'
})
}
@@ -86,7 +86,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: `获取 API Key 详情失败:${error.message}`
statusMessage: `获取API Key详情失败: ${error.message}`
})
}
})
+11 -12
View File
@@ -2,7 +2,6 @@ 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
@@ -21,18 +20,18 @@ const updateApiKeySchema = z.object({
expiresAt: z.string().optional().nullable(),
permissions: z
.array(apiPermissionSchema)
.array(z.enum(['schedules:read', 'songs:read']))
.min(1, '至少需要选择一个权限')
.optional()
})
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理 API Key
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以管理 API Key'
statusMessage: '只有超级管理员可以管理API Key'
})
}
@@ -41,7 +40,7 @@ export default defineEventHandler(async (event) => {
if (!apiKeyId) {
throw createError({
statusCode: 400,
message: 'API Key ID 不能为空'
statusMessage: 'API Key ID不能为空'
})
}
@@ -60,7 +59,7 @@ export default defineEventHandler(async (event) => {
if (existingApiKey.length === 0) {
throw createError({
statusCode: 404,
message: 'API Key 不存在'
statusMessage: 'API Key不存在'
})
}
@@ -83,14 +82,14 @@ export default defineEventHandler(async (event) => {
if (isNaN(expiresAt.getTime())) {
throw createError({
statusCode: 400,
message: '无效的过期时间格式'
statusMessage: '无效的过期时间格式'
})
}
// 验证过期时间不能是过去的时间
if (expiresAt <= getBeijingTime()) {
throw createError({
statusCode: 400,
message: '过期时间不能是过去的时间'
statusMessage: '过期时间不能是过去的时间'
})
}
}
@@ -100,7 +99,7 @@ export default defineEventHandler(async (event) => {
}
throw createError({
statusCode: 400,
message: '无效的过期时间格式'
statusMessage: '无效的过期时间格式'
})
}
}
@@ -148,17 +147,17 @@ export default defineEventHandler(async (event) => {
throw error
}
// 处理 Zod 验证错误
// 处理Zod验证错误
if (error.name === 'ZodError') {
throw createError({
statusCode: 400,
message: `请求参数验证失败:${error.errors.map((e: any) => e.message).join(', ')}`
statusMessage: `请求参数验证失败: ${error.errors.map((e: any) => e.message).join(', ')}`
})
}
throw createError({
statusCode: 500,
message: `更新 API Key 失败:${error.message}`
statusMessage: `更新API Key失败: ${error.message}`
})
}
})
+4 -4
View File
@@ -6,12 +6,12 @@ import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
* GET /api/admin/api-keys
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理 API Key
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以管理 API Key'
statusMessage: '只有超级管理员可以管理API Key'
})
}
@@ -167,7 +167,7 @@ export default defineEventHandler(async (event) => {
: 'active'
}
} catch (keyError) {
console.error('处理API Key数据失败:', keyError)
console.error('处理API Key数据失败:', keyError, key)
return null
}
})
@@ -193,7 +193,7 @@ export default defineEventHandler(async (event) => {
} catch (error: any) {
throw createError({
statusCode: 500,
message: `获取 API Key 列表失败:${error.message}`
statusMessage: `获取API Key列表失败: ${error.message}`
})
}
})
+22 -22
View File
@@ -1,8 +1,7 @@
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
import crypto from 'crypto'
import { z } from 'zod'
import { getBeijingTime } from '~/utils/timeUtils'
import { apiPermissionSchema } from './permissions'
import { generateApiKey, hashApiKey } from '~~/server/utils/apiKeyUtils'
/**
* 创建API Key
@@ -17,16 +16,16 @@ const createApiKeySchema = z.object({
.optional(),
expiresAt: z.union([z.string(), z.null(), z.undefined()]).optional(),
permissions: z.array(apiPermissionSchema).min(1, '至少需要选择一个权限')
permissions: z.array(z.enum(['schedules:read', 'songs:read'])).min(1, '至少需要选择一个权限')
})
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以管理 API Key
// 检查用户权限 - 只有超级管理员可以管理API Key
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以管理 API Key'
statusMessage: '只有超级管理员可以管理API Key'
})
}
@@ -38,7 +37,7 @@ export default defineEventHandler(async (event) => {
// 生成API Key
const apiKey = generateApiKey()
const keyPrefix = apiKey.substring(0, 10) // vhub_xxxxx
const keyHash = await hashApiKey(apiKey)
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex')
// 处理过期时间
let expiresAt: Date | null = null
@@ -60,14 +59,14 @@ export default defineEventHandler(async (event) => {
if (isNaN(expiresAt.getTime())) {
throw createError({
statusCode: 400,
message: '无效的过期时间格式'
statusMessage: '无效的过期时间格式'
})
}
// 验证过期时间不能是过去的时间
if (expiresAt <= getBeijingTime()) {
throw createError({
statusCode: 400,
message: '过期时间不能是过去的时间'
statusMessage: '过期时间不能是过去的时间'
})
}
}
@@ -77,7 +76,7 @@ export default defineEventHandler(async (event) => {
}
throw createError({
statusCode: 400,
message: '无效的过期时间格式'
statusMessage: '无效的过期时间格式'
})
}
}
@@ -100,15 +99,7 @@ export default defineEventHandler(async (event) => {
})
.returning({ id: apiKeys.id })
const createdApiKey = apiKeyResult[0]
if (!createdApiKey) {
throw createError({
statusCode: 500,
message: '创建 API Key 失败'
})
}
const apiKeyId = createdApiKey.id
const apiKeyId = apiKeyResult[0].id
// 插入权限记录
const permissionValues = validatedData.permissions.map((permission) => ({
@@ -120,7 +111,7 @@ export default defineEventHandler(async (event) => {
return {
id: apiKeyId,
apiKey,
apiKey, // 只在创建时返回完整的API Key
name: validatedData.name,
description: validatedData.description,
keyPrefix,
@@ -144,18 +135,27 @@ export default defineEventHandler(async (event) => {
throw error
}
// 处理 Zod 验证错误
// 处理Zod验证错误
if (error.name === 'ZodError') {
throw createError({
statusCode: 400,
message: `请求参数验证失败:${error.errors.map((e: any) => e.message).join(', ')}`
statusMessage: `请求参数验证失败: ${error.errors.map((e: any) => e.message).join(', ')}`
})
}
throw createError({
statusCode: 500,
message: `创建 API Key 失败:${error.message}`
statusMessage: `创建API Key失败: ${error.message}`
})
}
})
/**
* 生成API Key
* 格式: vhub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (37字符)
*/
function generateApiKey(): string {
const prefix = 'vhub_'
const randomBytes = crypto.randomBytes(16).toString('hex') // 32字符
return prefix + randomBytes
}
+4 -4
View File
@@ -5,12 +5,12 @@ import { ApiLogService } from '~~/server/services/apiLogService'
* GET /api/admin/api-keys/logs
*/
export default defineEventHandler(async (event) => {
// 检查用户权限 - 只有超级管理员可以查看 API 日志
// 检查用户权限 - 只有超级管理员可以查看API日志
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以查看 API 日志'
statusMessage: '只有超级管理员可以查看API日志'
})
}
@@ -62,10 +62,10 @@ export default defineEventHandler(async (event) => {
stats: stats.basic
}
} catch (error) {
console.error('获取 API 访问日志失败:', error)
console.error('获取API访问日志失败:', error)
throw createError({
statusCode: 500,
message: '获取 API 访问日志失败'
statusMessage: '获取API访问日志失败'
})
}
})
+13 -16
View File
@@ -4,8 +4,6 @@ import {
apiKeyPermissions,
apiKeys,
apiLogs,
cardCodeRedeemLogs,
cardCodes,
collaborationLogs,
emailTemplates,
notifications,
@@ -29,10 +27,18 @@ 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') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以清空数据库'
statusMessage: '权限不足'
})
}
// 只有超级管理员可以清空数据库
if (user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以清空数据库'
})
}
@@ -61,7 +67,6 @@ export default defineEventHandler(async (event) => {
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))
@@ -93,7 +98,6 @@ export default defineEventHandler(async (event) => {
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)
@@ -104,7 +108,6 @@ export default defineEventHandler(async (event) => {
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)
@@ -137,22 +140,16 @@ export default defineEventHandler(async (event) => {
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(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(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)
@@ -172,7 +169,7 @@ export default defineEventHandler(async (event) => {
console.error('清空数据失败:', error)
throw createError({
statusCode: 500,
message: '清空数据失败:' + error.message
statusMessage: '清空数据失败: ' + error.message
})
}
})
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以管理备份文件'
statusMessage: '权限不足'
})
}
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
if (!filename) {
throw createError({
statusCode: 400,
message: '请指定文件名'
statusMessage: '请指定文件名'
})
}
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
message: '无效的文件名'
statusMessage: '无效的文件名'
})
}
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
if (!filename.endsWith('.json')) {
throw createError({
statusCode: 400,
message: '只能删除 JSON 格式的备份文件'
statusMessage: '只能删除JSON格式的备份文件'
})
}
@@ -47,14 +47,14 @@ export default defineEventHandler(async (event) => {
} catch {
throw createError({
statusCode: 404,
message: '备份文件不存在'
statusMessage: '备份文件不存在'
})
}
// 删除文件
await fs.unlink(filepath)
console.log(`管理员 ${user.username} 删除了备份文件:${filename}`)
console.log(`管理员 ${user.username} 删除了备份文件: ${filename}`)
return {
success: true,
@@ -65,7 +65,7 @@ export default defineEventHandler(async (event) => {
console.error('删除备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '删除备份文件失败'
statusMessage: error.statusMessage || '删除备份文件失败'
})
}
})
+8 -8
View File
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以下载备份文件'
statusMessage: '权限不足'
})
}
@@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => {
if (!filename || typeof filename !== 'string') {
throw createError({
statusCode: 400,
message: '缺少文件名参数'
statusMessage: '缺少文件名参数'
})
}
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
message: '无效的文件名'
statusMessage: '无效的文件名'
})
}
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
) {
throw createError({
statusCode: 400,
message: '无效的备份文件'
statusMessage: '无效的备份文件'
})
}
@@ -66,19 +66,19 @@ export default defineEventHandler(async (event) => {
if (error.code === 'ENOENT') {
throw createError({
statusCode: 404,
message: '备份文件不存在'
statusMessage: '备份文件不存在'
})
}
throw createError({
statusCode: 500,
message: '读取备份文件失败'
statusMessage: '读取备份文件失败'
})
}
} catch (error) {
console.error('下载备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '下载备份文件失败'
statusMessage: error.statusMessage || '下载备份文件失败'
})
}
})
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以下载备份文件'
statusMessage: '权限不足'
})
}
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
if (!filename) {
throw createError({
statusCode: 400,
message: '请指定文件名'
statusMessage: '请指定文件名'
})
}
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
message: '无效的文件名'
statusMessage: '无效的文件名'
})
}
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
if (!filename.endsWith('.json')) {
throw createError({
statusCode: 400,
message: '只能下载 JSON 格式的备份文件'
statusMessage: '只能下载JSON格式的备份文件'
})
}
@@ -47,7 +47,7 @@ export default defineEventHandler(async (event) => {
} catch {
throw createError({
statusCode: 404,
message: '备份文件不存在'
statusMessage: '备份文件不存在'
})
}
@@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => {
console.error('下载备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '下载备份文件失败'
statusMessage: error.statusMessage || '下载备份文件失败'
})
}
})
+6 -85
View File
@@ -4,8 +4,6 @@ import {
apiKeys,
apiKeyPermissions,
apiLogs,
cardCodeRedeemLogs,
cardCodes,
collaborationLogs,
emailTemplates,
notifications,
@@ -13,7 +11,6 @@ import {
playTimes,
requestTimes,
schedules,
scheduleSongPool,
semesters,
songBlacklists,
songCollaborators,
@@ -23,10 +20,8 @@ import {
users,
userIdentities,
userStatusLogs,
gradeClass,
votes
} from '~/drizzle/schema'
import { inArray } from 'drizzle-orm'
import { promises as fs } from 'fs'
import path from 'path'
@@ -34,17 +29,15 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以导出备份'
statusMessage: '权限不足'
})
}
const body = await readBody(event)
const { tables = 'all' } = body
const includeSystemData =
typeof body.includeSystemData === 'boolean' ? body.includeSystemData : tables !== 'users'
const { tables = 'all', includeSystemData = true } = body
console.log('开始创建数据库备份...')
@@ -107,53 +100,6 @@ export default defineEventHandler(async (event) => {
},
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)
@@ -180,24 +126,6 @@ export default defineEventHandler(async (event) => {
},
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)
@@ -294,10 +222,6 @@ export default defineEventHandler(async (event) => {
},
description: '用户状态变更日志'
},
gradeClass: {
query: () => db.select().from(gradeClass),
description: '年级班级配置'
},
userIdentities: {
query: async () => {
const identitiesData = await db.select().from(userIdentities)
@@ -426,10 +350,7 @@ export default defineEventHandler(async (event) => {
// 如果包含系统数据,添加系统设置表
if (includeSystemData) {
tablesToBackup.systemSettings = {
query: async () => {
const settings = await db.select().from(systemSettings)
return settings
},
query: () => db.select().from(systemSettings),
description: '系统设置'
}
}
@@ -519,7 +440,7 @@ export default defineEventHandler(async (event) => {
console.error(`备份表 ${tableName} 失败:`, error)
throw createError({
statusCode: 500,
message: `备份表 ${tableName} 失败:${error.message}`
statusMessage: `备份表 ${tableName} 失败: ${error.message}`
})
}
}
@@ -612,7 +533,7 @@ export default defineEventHandler(async (event) => {
console.error('创建数据库备份失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '创建数据库备份失败'
statusMessage: error.statusMessage || '创建数据库备份失败'
})
}
})
+4 -7
View File
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以查看备份文件'
statusMessage: '权限不足'
})
}
@@ -109,10 +109,7 @@ export default defineEventHandler(async (event) => {
filename.startsWith('users-system-backup-')
) {
type = 'users'
} else if (
filename.startsWith('songs-backup-') ||
filename.startsWith('songs-system-backup-')
) {
} else if (filename.startsWith('songs-backup-') || filename.startsWith('songs-system-backup-')) {
type = 'songs'
} else if (filename.startsWith('system-settings-backup-')) {
type = 'system'
@@ -152,7 +149,7 @@ export default defineEventHandler(async (event) => {
console.error('获取备份文件列表失败:', error)
throw createError({
statusCode: 500,
message: '获取备份文件列表失败'
statusMessage: '获取备份文件列表失败'
})
}
})
+15 -437
View File
@@ -1,10 +1,6 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { db } from '~/drizzle/db'
import {
cardCodeRedeemLogs,
cardCodes,
emailTemplates,
gradeClass,
notificationSettings,
notifications,
playTimes,
@@ -19,21 +15,14 @@ import {
votes
} from '~/drizzle/schema'
import { and, eq } from 'drizzle-orm'
import { restoreScheduleSongPoolRecord } from '~~/server/utils/restoreScheduleSongPool'
import { omitMaskedSystemSettingsSecrets } from '~~/server/api/admin/system-settings/secretMask'
import { createApiError } from '~~/server/utils/apiError'
import { validateThemeConfig } from '~~/server/utils/theme-config'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { normalizeScheduleVisibilitySettings } from '~~/server/utils/system-settings-defaults'
import { syncAllSequences } from '~~/server/utils/sequence-sync'
export default defineEventHandler(async (event) => {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以恢复备份'
statusMessage: '权限不足'
})
}
@@ -50,14 +39,14 @@ export default defineEventHandler(async (event) => {
if (overwriteSuperAdmin && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '仅超级管理员可以覆盖超级管理员账号数据'
statusMessage: '仅超级管理员可以覆盖超级管理员账号数据'
})
}
if (!tableName || !records || !Array.isArray(records)) {
throw createError({
statusCode: 400,
message: '无效的请求参数'
statusMessage: '无效的请求参数'
})
}
@@ -68,9 +57,6 @@ export default defineEventHandler(async (event) => {
const songIdMapping = new Map(
Object.entries(mappings?.songs || {}).map(([k, v]) => [Number(k), Number(v)])
)
const cardCodeIdMapping = new Map(
Object.entries(mappings?.cardCodes || {}).map(([k, v]) => [Number(k), Number(v)])
)
const preservedSuperAdminIds = new Set(
(mappings?.meta?.preservedSuperAdminIds || []).map((id) => Number(id))
)
@@ -81,8 +67,7 @@ export default defineEventHandler(async (event) => {
const newMappings = {
users: {},
songs: {},
cardCodes: {}
songs: {}
}
const stats = {
@@ -114,10 +99,7 @@ export default defineEventHandler(async (event) => {
'meowNickname',
'forcePasswordChange',
'status',
'statusChangedBy',
'remark',
'avatarProvider',
'avatarProviderUserId'
'statusChangedBy'
]
const dateFields = [
'createdAt',
@@ -131,7 +113,7 @@ export default defineEventHandler(async (event) => {
userFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
if (field === 'role') {
if (!['USER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(record[field])) {
if (!['USER', 'TEACHER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(record[field])) {
userData[field] = 'USER'
} else {
userData[field] = record[field]
@@ -285,14 +267,10 @@ export default defineEventHandler(async (event) => {
provider: record.provider,
providerUserId: record.providerUserId,
providerUsername: record.providerUsername,
avatar: record.avatar || null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
if (
!shouldOverwriteSuperAdmin &&
preservedSuperAdminIds.has(Number(validIdentityUserId))
) {
if (!shouldOverwriteSuperAdmin && preservedSuperAdminIds.has(Number(validIdentityUserId))) {
stats.updated++
break
}
@@ -352,12 +330,10 @@ export default defineEventHandler(async (event) => {
const userStatusLogData = {
userId: validUserId,
username: record.username ?? null,
name: record.name ?? null,
oldStatus: record.oldStatus || record.previousStatus || null,
previousStatus: record.previousStatus || null,
newStatus: record.newStatus,
reason: record.reason || null,
operatorId: record.operatorId || record.changedBy || null,
changedBy: record.changedBy || null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
@@ -391,83 +367,6 @@ export default defineEventHandler(async (event) => {
break
}
case 'cardCodes': {
const cardCodeData: any = {}
const cardCodeFields = ['code', 'status', 'note']
cardCodeFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
cardCodeData[field] = record[field]
}
})
if (!cardCodeData.code) return
const mapUserId = async (field: 'lockedBy' | 'redeemedBy') => {
if (!record[field]) return null
const mappedUserId = userIdMapping.get(record[field])
if (mappedUserId) return mappedUserId
if (mode === 'merge') return null
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record[field])
})
return userExists ? record[field] : null
}
cardCodeData.lockedBy = await mapUserId('lockedBy')
cardCodeData.redeemedBy = await mapUserId('redeemedBy')
cardCodeData.lockedAt = record.lockedAt ? new Date(record.lockedAt) : null
cardCodeData.redeemedAt = record.redeemedAt ? new Date(record.redeemedAt) : null
cardCodeData.createdAt = record.createdAt ? new Date(record.createdAt) : new Date()
cardCodeData.updatedAt = record.updatedAt ? new Date(record.updatedAt) : new Date()
let restoredCardCode
if (mode === 'merge') {
const existing = await tx.query.cardCodes.findFirst({
where: eq(cardCodes.code, cardCodeData.code)
})
if (existing) {
restoredCardCode = (
await tx
.update(cardCodes)
.set(cardCodeData)
.where(eq(cardCodes.id, existing.id))
.returning()
)[0]
stats.updated++
} else {
restoredCardCode = (await tx.insert(cardCodes).values(cardCodeData).returning())[0]
stats.created++
}
} else {
const existing = await tx.query.cardCodes.findFirst({
where: eq(cardCodes.id, record.id)
})
if (existing) {
restoredCardCode = (
await tx
.update(cardCodes)
.set(cardCodeData)
.where(eq(cardCodes.id, record.id))
.returning()
)[0]
stats.updated++
} else {
restoredCardCode = (
await tx
.insert(cardCodes)
.values({ ...cardCodeData, id: record.id })
.returning()
)[0]
stats.created++
}
}
if (record.id && restoredCardCode?.id) {
newMappings.cardCodes[record.id] = restoredCardCode.id
}
break
}
case 'songs': {
let validRequesterId = record.requesterId
let validPreferredPlayTimeId = record.preferredPlayTimeId
@@ -498,25 +397,9 @@ export default defineEventHandler(async (event) => {
}
}
let validCardCodeId = record.cardCodeId || null
if (record.cardCodeId) {
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
if (mappedCardCodeId) {
validCardCodeId = mappedCardCodeId
} else {
const cardCodeExists = await tx.query.cardCodes.findFirst({
where: eq(cardCodes.id, record.cardCodeId)
})
if (!cardCodeExists) {
validCardCodeId = null
}
}
}
const songData: any = {
requesterId: validRequesterId,
preferredPlayTimeId: validPreferredPlayTimeId,
cardCodeId: validCardCodeId,
played: record.hasOwnProperty('played') ? record.played : false
}
@@ -527,10 +410,8 @@ export default defineEventHandler(async (event) => {
'cover',
'musicPlatform',
'musicId',
'durationSeconds',
'submissionNote',
'submissionNotePublic',
'submissionNotePublicStatus'
'submissionNotePublic'
]
songFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
@@ -700,52 +581,10 @@ export default defineEventHandler(async (event) => {
break
}
case 'gradeClass': {
const gradeClassData: any = {}
const gradeClassFields = ['grade', 'class']
gradeClassFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
gradeClassData[field] =
typeof record[field] === 'string' ? record[field].trim() : record[field]
}
})
// 空行直接跳过,避免空值配置行锁死配置优先校验
if (!gradeClassData.grade || !gradeClassData.class) {
stats.warnings.push(`gradeClass 记录 ${record.id ?? ''} 年级或班级为空,已跳过`)
break
}
if (mode === 'merge') {
const existingGradeClass = await tx.query.gradeClass.findFirst({
where: and(
eq(gradeClass.grade, gradeClassData.grade),
eq(gradeClass.class, gradeClassData.class)
)
})
if (!existingGradeClass) {
await tx.insert(gradeClass).values(gradeClassData)
stats.created++
}
} else {
const existing = await tx.query.gradeClass.findFirst({
where: eq(gradeClass.id, record.id)
})
if (!existing) {
await tx.insert(gradeClass).values({ ...gradeClassData, id: record.id })
stats.created++
}
}
break
}
case 'systemSettings': {
let systemSettingsData: any = {}
const systemSettingsData: any = {}
const fields = [
'enablePlayTimeSelection',
'instanceId',
'defaultTheme',
'enabledThemes',
'telemetryEnabled',
'siteTitle',
'siteLogoUrl',
'schoolLogoHomeUrl',
@@ -758,25 +597,14 @@ export default defineEventHandler(async (event) => {
'dailySubmissionLimit',
'weeklySubmissionLimit',
'monthlySubmissionLimit',
'scheduleDaysBeforeEnabled',
'scheduleDaysBefore',
'scheduleDaysAfterEnabled',
'scheduleDaysAfter',
'showBlacklistKeywords',
'hideStudentInfo',
'enableReplayRequests',
'enableCollaborativeSubmission',
'enableSubmissionRemarks',
'enableCardCodeRequests',
'requireCardCodeForRequests',
'enableCardCodeLimitBypass',
'enableSubmissionRestriction',
'submissionRestrictionScope',
'sameSongRestrictionHours',
'sameArtistRestrictionHours',
'enableRequestTimeLimitation',
'requestTimeLimitation',
'forceBlockAllRequests',
'forcePasswordChangeOnFirstLogin',
'smtpEnabled',
'smtpHost',
'smtpPort',
@@ -784,79 +612,11 @@ export default defineEventHandler(async (event) => {
'smtpUsername',
'smtpPassword',
'smtpFromEmail',
'smtpFromName',
'allowOAuthRegistration',
'allowRegister',
'registerRequiresApproval',
'registerEmailRequired',
'registerRequiresGradeClass',
'oauthRegisterRequiresApproval',
'submissionNoteRequiresApproval',
'oauthRedirectUri',
'oauthStateSecret',
'oauthProviders',
'githubOAuthEnabled',
'githubClientId',
'githubClientSecret',
'casdoorOAuthEnabled',
'casdoorServerUrl',
'casdoorClientId',
'casdoorClientSecret',
'casdoorOrganizationName',
'googleOAuthEnabled',
'googleClientId',
'googleClientSecret',
'aggregateOAuthEnabled',
'aggregateOAuthAppId',
'aggregateOAuthAppKey',
'aggregateOAuthLoginType',
'aggregateOAuthEndpoint',
'customOAuthEnabled',
'customOAuthDisplayName',
'customOAuthAuthorizeUrl',
'customOAuthTokenUrl',
'customOAuthUserInfoUrl',
'customOAuthScope',
'customOAuthClientId',
'customOAuthClientSecret',
'customOAuthUserIdField',
'customOAuthUsernameField',
'customOAuthNameField',
'customOAuthEmailField',
'customOAuthAvatarField',
'captchaEnabled',
'captchaMaxFailures',
'captchaProvider',
'turnstileSiteKey',
'turnstileSecretKey',
'autoBackupEnabled',
'autoBackupConfig',
'statisticsCodeEnabled',
'statisticsCode',
'enabledPlatforms',
'platformOrder'
'smtpFromName'
]
fields.forEach((field) => {
if (record.hasOwnProperty(field)) systemSettingsData[field] = record[field]
})
if (Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
if (!Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || !Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '主题配置必须同时包含默认主题和启用主题列表')
}
systemSettingsData.enabledThemes = JSON.stringify(validateThemeConfig(systemSettingsData.defaultTheme, systemSettingsData.enabledThemes))
}
for (const field of ['scheduleDaysBeforeEnabled', 'scheduleDaysAfterEnabled']) {
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && systemSettingsData[field] !== null && typeof systemSettingsData[field] !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是布尔值`)
}
}
for (const field of ['scheduleDaysBefore', 'scheduleDaysAfter']) {
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && (systemSettingsData[field] !== null && (!Number.isInteger(systemSettingsData[field]) || systemSettingsData[field] < 1 || systemSettingsData[field] > 730))) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是 1-730 的正整数`)
}
}
systemSettingsData = normalizeScheduleVisibilitySettings(systemSettingsData)
systemSettingsData = omitMaskedSystemSettingsSecrets(systemSettingsData)
if (mode === 'merge') {
const existing = await tx.query.systemSettings.findFirst()
@@ -888,49 +648,6 @@ export default defineEventHandler(async (event) => {
break
}
case 'emailTemplates': {
// 邮件模板(按 key 去重:merge 模式同名 key 覆盖)
if (!record.key || !record.name || !record.subject) return
const templateData: any = {
key: record.key,
name: record.name,
subject: record.subject,
html: record.html || ''
}
if (record.contentType) templateData.contentType = record.contentType
if (record.headerSubtitle) templateData.headerSubtitle = record.headerSubtitle
if (record.actionText) templateData.actionText = record.actionText
if (record.actionUrl) templateData.actionUrl = record.actionUrl
if (record.updatedByUserId) templateData.updatedByUserId = record.updatedByUserId
if (record.createdAt) templateData.createdAt = new Date(record.createdAt)
if (record.updatedAt) templateData.updatedAt = new Date(record.updatedAt)
if (mode === 'merge') {
const existing = await tx.query.emailTemplates.findFirst({
where: eq(emailTemplates.key, record.key)
})
if (existing) {
await tx.update(emailTemplates).set(templateData).where(eq(emailTemplates.id, existing.id))
stats.updated++
} else {
await tx.insert(emailTemplates).values(templateData)
stats.created++
}
} else {
const existing = await tx.query.emailTemplates.findFirst({
where: eq(emailTemplates.id, record.id)
})
if (existing) {
await tx.update(emailTemplates).set(templateData).where(eq(emailTemplates.id, record.id))
stats.updated++
} else {
await tx.insert(emailTemplates).values({ ...templateData, id: record.id })
stats.created++
}
}
break
}
case 'schedules': {
if (!record.songId || !record.playDate) return
@@ -1085,43 +802,11 @@ export default defineEventHandler(async (event) => {
}
const notificationData: any = { userId: validUserId }
const fields = [
'batchId',
'source',
'senderName',
'senderUsername',
'title',
'message',
'type',
'userDeleted'
]
const fields = ['title', 'message', 'type']
fields.forEach((field) => {
if (record.hasOwnProperty(field)) notificationData[field] = record[field]
})
// senderId 需要重新映射到目标库的用户 ID;映射未命中时回查同 ID 用户并比对用户名快照,
// 不一致则置空,避免跨库恢复时将发送人归属到错误用户,展示仍靠快照字段
if (Object.prototype.hasOwnProperty.call(record, 'senderId')) {
if (record.senderId) {
const mappedSenderId = userIdMapping.get(record.senderId)
if (mappedSenderId) {
notificationData.senderId = mappedSenderId
} else {
const senderExists = await tx.query.users.findFirst({
where: eq(users.id, record.senderId)
})
notificationData.senderId =
senderExists && senderExists.username === record.senderUsername
? record.senderId
: null
}
} else {
notificationData.senderId = null
}
}
notificationData.read = record.hasOwnProperty('read') ? record.read : false
notificationData.important = Object.prototype.hasOwnProperty.call(record, 'important')
? record.important
: false
notificationData.createdAt = record.createdAt ? new Date(record.createdAt) : new Date()
notificationData.updatedAt = record.updatedAt ? new Date(record.updatedAt) : new Date()
@@ -1219,79 +904,6 @@ export default defineEventHandler(async (event) => {
break
}
case 'cardCodeRedeemLogs': {
let validCardCodeId = record.cardCodeId || null
if (record.cardCodeId) {
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
if (mappedCardCodeId) {
validCardCodeId = mappedCardCodeId
} else {
const cardCodeExists = await tx.query.cardCodes.findFirst({
where: eq(cardCodes.id, record.cardCodeId)
})
if (!cardCodeExists) validCardCodeId = null
}
}
let validRedeemedBy = record.redeemedBy
if (record.redeemedBy) {
const mappedUserId = userIdMapping.get(record.redeemedBy)
if (mappedUserId) {
validRedeemedBy = mappedUserId
} else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.redeemedBy)
})
if (!userExists) return
}
} else return
let validSongId = record.songId || null
if (record.songId) {
const mappedSongId = songIdMapping.get(record.songId)
if (mappedSongId) {
validSongId = mappedSongId
} else {
const songExists = await tx.query.songs.findFirst({
where: eq(songs.id, record.songId)
})
if (!songExists) validSongId = null
}
}
const logData: any = {
cardCodeId: validCardCodeId,
codeSnapshot: record.codeSnapshot,
redeemedBy: validRedeemedBy,
redeemedAt: record.redeemedAt ? new Date(record.redeemedAt) : new Date(),
source: record.source || 'UNKNOWN',
songId: validSongId,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
if (!logData.codeSnapshot) return
if (mode === 'merge') {
await tx.insert(cardCodeRedeemLogs).values(logData)
stats.created++
} else {
const existing = await tx.query.cardCodeRedeemLogs.findFirst({
where: eq(cardCodeRedeemLogs.id, record.id)
})
if (existing) {
await tx
.update(cardCodeRedeemLogs)
.set(logData)
.where(eq(cardCodeRedeemLogs.id, record.id))
stats.updated++
} else {
await tx.insert(cardCodeRedeemLogs).values({ ...logData, id: record.id })
stats.created++
}
}
break
}
case 'votes': {
let validUserId = record.userId
if (record.userId) {
@@ -1337,11 +949,6 @@ export default defineEventHandler(async (event) => {
}
break
}
case 'scheduleSongPool': {
await restoreScheduleSongPoolRecord(tx, record, songIdMapping, userIdMapping, stats, () => { stats.created++ })
break
}
}
})
stats.processed++
@@ -1352,35 +959,6 @@ export default defineEventHandler(async (event) => {
}
}
// 防锁死:恢复系统设置块后校验 SMTP 已配置,否则剥离 registerEmailRequired
// (避免恢复后注册必填邮箱却无法发码)
if (tableName === 'systemSettings') {
try {
const restoredSettings = await db.select().from(systemSettings).limit(1)
if (
restoredSettings[0]?.registerEmailRequired &&
(!restoredSettings[0]?.smtpEnabled || !restoredSettings[0]?.smtpHost)
) {
await db
.update(systemSettings)
.set({ registerEmailRequired: false })
.where(eq(systemSettings.id, restoredSettings[0].id))
stats.warnings.push('SMTP 未配置,已剥离 registerEmailRequired,避免注册邮箱流程不可用')
}
} catch (settingsError) {
console.error('恢复后设置一致性校验失败:', settingsError)
}
}
// 恢复可能携带显式 id,完成后同步所有自增序列,避免后续插入主键冲突
try {
await syncAllSequences()
} catch (seqError) {
const seqErrorMsg = seqError instanceof Error ? seqError.message : '未知错误'
stats.warnings.push(`自增序列同步失败: ${seqErrorMsg}`)
console.error('自增序列同步失败:', seqError)
}
return {
success: true,
newMappings,
+42 -505
View File
@@ -4,17 +4,13 @@ import {
apiKeyPermissions,
apiKeys,
apiLogs,
cardCodeRedeemLogs,
cardCodes,
collaborationLogs,
emailTemplates,
gradeClass,
notificationSettings,
notifications,
playTimes,
requestTimes,
schedules,
scheduleSongPool,
semesters,
songBlacklists,
songCollaborators,
@@ -28,23 +24,18 @@ import {
} from '~/drizzle/schema'
import { promises as fs } from 'fs'
import path from 'path'
import { CacheService } from '../../../services/cacheService'
import { SmtpService } from '../../../services/smtpService'
import { and, eq, inArray, isNull, notInArray, or } from 'drizzle-orm'
import { restoreScheduleSongPoolRecord } from '~~/server/utils/restoreScheduleSongPool'
import { omitMaskedSystemSettingsSecrets } from '~~/server/api/admin/system-settings/secretMask'
import { validateThemeConfig } from '~~/server/utils/theme-config'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { normalizeScheduleVisibilitySettings } from '~~/server/utils/system-settings-defaults'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以恢复备份'
statusMessage: '权限不足'
})
}
@@ -63,7 +54,7 @@ export default defineEventHandler(async (event) => {
if (!formData) {
throw createError({
statusCode: 400,
message: '请上传备份文件'
statusMessage: '请上传备份文件'
})
}
@@ -83,7 +74,7 @@ export default defineEventHandler(async (event) => {
if (!fileData) {
throw createError({
statusCode: 400,
message: '请上传备份文件'
statusMessage: '请上传备份文件'
})
}
@@ -92,7 +83,7 @@ export default defineEventHandler(async (event) => {
} catch (error) {
throw createError({
statusCode: 400,
message: '备份文件格式错误'
statusMessage: '备份文件格式错误'
})
}
@@ -110,7 +101,7 @@ export default defineEventHandler(async (event) => {
if (!filename) {
throw createError({
statusCode: 400,
message: '请指定备份文件名或上传备份文件'
statusMessage: '请指定备份文件名或上传备份文件'
})
}
@@ -118,7 +109,7 @@ export default defineEventHandler(async (event) => {
if (safeFilename !== filename) {
throw createError({
statusCode: 400,
message: '备份文件名不合法'
statusMessage: '备份文件名不合法'
})
}
@@ -126,7 +117,7 @@ export default defineEventHandler(async (event) => {
clearExisting = bodyClearExisting
overwriteSuperAdmin = bodyOverwriteSuperAdmin
console.log(`开始恢复数据库备份:${safeFilename}`)
console.log(`开始恢复数据库备份: ${safeFilename}`)
// 读取备份文件
const backupDir = path.join(process.cwd(), 'backups')
@@ -138,7 +129,7 @@ export default defineEventHandler(async (event) => {
} catch (error) {
throw createError({
statusCode: 404,
message: '备份文件不存在或格式错误'
statusMessage: '备份文件不存在或格式错误'
})
}
}
@@ -147,12 +138,12 @@ export default defineEventHandler(async (event) => {
if (!backupData.metadata || !backupData.data) {
throw createError({
statusCode: 400,
message: '备份文件格式无效'
statusMessage: '备份文件格式无效'
})
}
console.log(`备份文件信息:`)
console.log(`- 版本:${backupData.metadata.version}`)
console.log(`- 版本: ${backupData.metadata.version}`)
console.log(`- 创建时间: ${backupData.metadata.timestamp}`)
console.log(`- 创建者: ${backupData.metadata.creator}`)
console.log(`- 总记录数: ${backupData.metadata.totalRecords}`)
@@ -162,7 +153,7 @@ export default defineEventHandler(async (event) => {
if (overwriteSuperAdmin && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '仅超级管理员可以覆盖超级管理员账号数据'
statusMessage: '仅超级管理员可以覆盖超级管理员账号数据'
})
}
const shouldOverwriteSuperAdmin =
@@ -190,15 +181,12 @@ export default defineEventHandler(async (event) => {
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(scheduleSongPool)
await db.delete(schedules)
await db.delete(votes)
await db.delete(songs)
await db.delete(cardCodes)
await db.delete(songBlacklists)
await db.delete(userStatusLogs)
await db.delete(emailTemplates)
@@ -239,23 +227,15 @@ export default defineEventHandler(async (event) => {
}
if (preservedUserIdList.length > 0) {
await db.delete(cardCodeRedeemLogs)
await db.delete(apiKeys).where(notInArray(apiKeys.createdByUserId, preservedUserIdList))
await db
.delete(notifications)
.where(notInArray(notifications.userId, preservedUserIdList))
await db.delete(notifications).where(notInArray(notifications.userId, preservedUserIdList))
await db
.delete(notificationSettings)
.where(notInArray(notificationSettings.userId, preservedUserIdList))
await db
.delete(userStatusLogs)
.where(notInArray(userStatusLogs.userId, preservedUserIdList))
await db
.delete(userIdentities)
.where(notInArray(userIdentities.userId, preservedUserIdList))
await db.delete(userStatusLogs).where(notInArray(userStatusLogs.userId, preservedUserIdList))
await db.delete(userIdentities).where(notInArray(userIdentities.userId, preservedUserIdList))
await db.delete(users).where(notInArray(users.id, preservedUserIdList))
} else {
await db.delete(cardCodeRedeemLogs)
await db.delete(apiKeys)
await db.delete(notifications)
await db.delete(notificationSettings)
@@ -267,11 +247,9 @@ export default defineEventHandler(async (event) => {
await db.delete(collaborationLogs)
await db.delete(songCollaborators)
await db.delete(songReplayRequests)
await db.delete(scheduleSongPool)
await db.delete(schedules)
await db.delete(votes)
await db.delete(songs)
await db.delete(cardCodes)
await db.delete(songBlacklists)
await db.delete(emailTemplates)
await db.delete(playTimes)
@@ -289,7 +267,6 @@ export default defineEventHandler(async (event) => {
// 建立ID映射表
const userIdMapping = new Map() // 备份ID -> 当前数据库ID
const songIdMapping = new Map() // 备份ID -> 当前数据库ID
const cardCodeIdMapping = new Map() // 备份ID -> 当前数据库ID
// 定义恢复顺序(考虑外键依赖)
const restoreOrder = [
@@ -297,21 +274,17 @@ export default defineEventHandler(async (event) => {
'playTimes',
'semesters',
'requestTimes',
'gradeClass',
'users',
'userIdentities',
'emailTemplates',
'userStatusLogs',
'songBlacklist',
'cardCodes',
'songs',
'songCollaborators',
'collaborationLogs',
'songReplayRequests',
'votes',
'schedules',
'scheduleSongPool',
'cardCodeRedeemLogs',
'notificationSettings',
'notifications',
'apiKeys',
@@ -349,10 +322,6 @@ export default defineEventHandler(async (event) => {
try {
await db.$transaction(
async (tx) => {
const stats = {
created: 0,
warnings: restoreResults.details.warnings
}
// 根据表名选择恢复策略
switch (tableName) {
case 'users':
@@ -371,10 +340,7 @@ export default defineEventHandler(async (event) => {
'forcePasswordChange',
'meowNickname',
'status',
'statusChangedBy',
'remark',
'avatarProvider',
'avatarProviderUserId'
'statusChangedBy'
]
// 处理日期字段
@@ -393,7 +359,7 @@ export default defineEventHandler(async (event) => {
// 确保角色为有效的硬编码角色
if (field === 'role') {
if (
!['USER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(
!['USER', 'TEACHER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(
record[field]
)
) {
@@ -483,8 +449,7 @@ export default defineEventHandler(async (event) => {
}
} else {
const isBackupSuperAdminRecord =
record.role === 'SUPER_ADMIN' ||
preservedSuperAdminIds.has(Number(record.id))
record.role === 'SUPER_ADMIN' || preservedSuperAdminIds.has(Number(record.id))
if (!shouldOverwriteSuperAdmin && isBackupSuperAdminRecord) {
if (record.id) {
const existingProtectedUser = await tx
@@ -494,12 +459,12 @@ export default defineEventHandler(async (event) => {
.limit(1)
if (existingProtectedUser.length > 0) {
userIdMapping.set(record.id, existingProtectedUser[0].id)
restoreResults.details.warnings.push(
`已保留超级管理员账户 ${record.username || `#${record.id}`}`
)
break
}
}
restoreResults.details.warnings.push(
`已保留超级管理员账户 ${record.username || `#${record.id}`}`
)
break
}
// 完全恢复模式,检查ID是否已存在
@@ -600,7 +565,6 @@ export default defineEventHandler(async (event) => {
provider: record.provider,
providerUserId: record.providerUserId,
providerUsername: record.providerUsername,
avatar: record.avatar || null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
@@ -671,11 +635,9 @@ export default defineEventHandler(async (event) => {
return // 跳过此记录,因为userId是必需的
}
// 构建用户状态日志数据(含快照列,删除用户后审计可追溯)
// 构建用户状态日志数据
const userStatusLogData = {
userId: validUserStatusLogUserId,
username: record.username ?? null,
name: record.name ?? null,
oldStatus: record.oldStatus || record.previousStatus || null,
newStatus: record.newStatus,
reason: record.reason || null,
@@ -716,98 +678,6 @@ export default defineEventHandler(async (event) => {
}
break
case 'cardCodes': {
const cardCodeData: any = {}
const cardCodeFields = ['code', 'status', 'note']
cardCodeFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
cardCodeData[field] = record[field]
}
})
if (!cardCodeData.code) {
console.warn('点歌券缺少 code,跳过此记录')
return
}
const mapUserId = async (field: 'lockedBy' | 'redeemedBy') => {
if (!record[field]) return null
const mappedUserId = userIdMapping.get(record[field])
if (mappedUserId) return mappedUserId
if (mode === 'merge') return null
const userExists = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, record[field]))
.limit(1)
return userExists.length > 0 ? record[field] : null
}
cardCodeData.lockedBy = await mapUserId('lockedBy')
cardCodeData.redeemedBy = await mapUserId('redeemedBy')
cardCodeData.lockedAt = record.lockedAt ? new Date(record.lockedAt) : null
cardCodeData.redeemedAt = record.redeemedAt
? new Date(record.redeemedAt)
: null
cardCodeData.createdAt = record.createdAt
? new Date(record.createdAt)
: new Date()
cardCodeData.updatedAt = record.updatedAt
? new Date(record.updatedAt)
: new Date()
let restoredCardCode
if (mode === 'merge') {
const existingCardCode = await tx
.select()
.from(cardCodes)
.where(eq(cardCodes.code, cardCodeData.code))
.limit(1)
if (existingCardCode.length > 0) {
restoredCardCode = (
await tx
.update(cardCodes)
.set(cardCodeData)
.where(eq(cardCodes.id, existingCardCode[0].id))
.returning()
)[0]
} else {
restoredCardCode = (
await tx.insert(cardCodes).values(cardCodeData).returning()
)[0]
}
} else {
const existingCardCodeWithId = await tx
.select()
.from(cardCodes)
.where(eq(cardCodes.id, record.id))
.limit(1)
if (existingCardCodeWithId.length > 0) {
restoredCardCode = (
await tx
.update(cardCodes)
.set(cardCodeData)
.where(eq(cardCodes.id, record.id))
.returning()
)[0]
} else {
restoredCardCode = (
await tx
.insert(cardCodes)
.values({ ...cardCodeData, id: record.id })
.returning()
)[0]
}
}
if (record.id && restoredCardCode?.id) {
cardCodeIdMapping.set(record.id, restoredCardCode.id)
}
break
}
case 'songs':
// 验证外键约束
let validRequesterId = record.requesterId
@@ -852,31 +722,10 @@ export default defineEventHandler(async (event) => {
}
}
let validCardCodeId = record.cardCodeId || null
if (record.cardCodeId) {
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
if (mappedCardCodeId) {
validCardCodeId = mappedCardCodeId
} else {
const cardCodeExists = await tx
.select({ id: cardCodes.id })
.from(cardCodes)
.where(eq(cardCodes.id, record.cardCodeId))
.limit(1)
if (cardCodeExists.length === 0) {
console.warn(
`歌曲 ${record.title} 的点歌券ID ${record.cardCodeId} 不存在,将设为null`
)
validCardCodeId = null
}
}
}
// 动态构建歌曲数据,自动跳过不存在的字段
const songData = {
requesterId: validRequesterId, // 必需字段
preferredPlayTimeId: validPreferredPlayTimeId, // 已验证的字段
cardCodeId: validCardCodeId
preferredPlayTimeId: validPreferredPlayTimeId // 已验证的字段
}
// 基本字段
@@ -887,10 +736,8 @@ export default defineEventHandler(async (event) => {
'cover',
'musicPlatform',
'musicId',
'durationSeconds',
'submissionNote',
'submissionNotePublic',
'submissionNotePublicStatus'
'submissionNotePublic'
]
songFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
@@ -1134,58 +981,11 @@ export default defineEventHandler(async (event) => {
}
break
case 'gradeClass':
// 年级班级配置(唯一约束 grade+class)
const gradeClassData = {
grade: typeof record.grade === 'string' ? record.grade.trim() : '',
class: typeof record.class === 'string' ? record.class.trim() : ''
}
// 空行直接跳过,避免空值配置行锁死配置优先校验
if (!gradeClassData.grade || !gradeClassData.class) {
stats.warnings.push(`gradeClass 记录 ${record.id ?? ''} 年级或班级为空,已跳过`)
break
}
if (mode === 'merge') {
const existingGradeClass = await tx
.select()
.from(gradeClass)
.where(
and(
eq(gradeClass.grade, gradeClassData.grade),
eq(gradeClass.class, gradeClassData.class)
)
)
.limit(1)
if (existingGradeClass.length === 0) {
await tx.insert(gradeClass).values(gradeClassData)
}
} else {
// 完全恢复模式,检查ID是否已存在
const existingGradeClassWithId = await tx
.select()
.from(gradeClass)
.where(eq(gradeClass.id, record.id))
.limit(1)
if (existingGradeClassWithId.length === 0) {
await tx.insert(gradeClass).values({
...gradeClassData,
id: record.id
})
}
}
break
case 'systemSettings':
// 动态构建系统设置数据,自动跳过不存在的字段
let systemSettingsData = {}
const systemSettingsData = {}
const systemSettingsFields = [
'enablePlayTimeSelection',
'instanceId',
'defaultTheme',
'enabledThemes',
'telemetryEnabled',
'siteTitle',
'siteLogoUrl',
'schoolLogoHomeUrl',
@@ -1198,24 +998,13 @@ export default defineEventHandler(async (event) => {
'dailySubmissionLimit',
'weeklySubmissionLimit',
'monthlySubmissionLimit',
'scheduleDaysBeforeEnabled',
'scheduleDaysBefore',
'scheduleDaysAfterEnabled',
'scheduleDaysAfter',
'showBlacklistKeywords',
'enableRequestTimeLimitation',
'requestTimeLimitation',
'forceBlockAllRequests',
'forcePasswordChangeOnFirstLogin',
'enableReplayRequests',
'enableCollaborativeSubmission',
'enableSubmissionRemarks',
'enableCardCodeRequests',
'requireCardCodeForRequests',
'enableCardCodeLimitBypass',
'enableSubmissionRestriction',
'submissionRestrictionScope',
'sameSongRestrictionHours',
'sameArtistRestrictionHours',
'hideStudentInfo',
'smtpEnabled',
'smtpHost',
@@ -1224,57 +1013,7 @@ export default defineEventHandler(async (event) => {
'smtpUsername',
'smtpPassword',
'smtpFromEmail',
'smtpFromName',
'allowOAuthRegistration',
'allowRegister',
'registerRequiresApproval',
'registerEmailRequired',
'registerRequiresGradeClass',
'oauthRegisterRequiresApproval',
'submissionNoteRequiresApproval',
'oauthRedirectUri',
'oauthStateSecret',
'oauthProviders',
'githubOAuthEnabled',
'githubClientId',
'githubClientSecret',
'casdoorOAuthEnabled',
'casdoorServerUrl',
'casdoorClientId',
'casdoorClientSecret',
'casdoorOrganizationName',
'googleOAuthEnabled',
'googleClientId',
'googleClientSecret',
'aggregateOAuthEnabled',
'aggregateOAuthAppId',
'aggregateOAuthAppKey',
'aggregateOAuthLoginType',
'aggregateOAuthEndpoint',
'customOAuthEnabled',
'customOAuthDisplayName',
'customOAuthAuthorizeUrl',
'customOAuthTokenUrl',
'customOAuthUserInfoUrl',
'customOAuthScope',
'customOAuthClientId',
'customOAuthClientSecret',
'customOAuthUserIdField',
'customOAuthUsernameField',
'customOAuthNameField',
'customOAuthEmailField',
'customOAuthAvatarField',
'captchaEnabled',
'captchaMaxFailures',
'captchaProvider',
'turnstileSiteKey',
'turnstileSecretKey',
'autoBackupEnabled',
'autoBackupConfig',
'statisticsCodeEnabled',
'statisticsCode',
'enabledPlatforms',
'platformOrder'
'smtpFromName'
]
// 只添加备份数据中存在的字段
@@ -1283,24 +1022,6 @@ export default defineEventHandler(async (event) => {
systemSettingsData[field] = record[field]
}
})
if (Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
if (!Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || !Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '主题配置必须同时包含默认主题和启用主题列表')
}
systemSettingsData.enabledThemes = JSON.stringify(validateThemeConfig(systemSettingsData.defaultTheme, systemSettingsData.enabledThemes))
}
for (const field of ['scheduleDaysBeforeEnabled', 'scheduleDaysAfterEnabled']) {
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && systemSettingsData[field] !== null && typeof systemSettingsData[field] !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是布尔值`)
}
}
for (const field of ['scheduleDaysBefore', 'scheduleDaysAfter']) {
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && (systemSettingsData[field] !== null && (!Number.isInteger(systemSettingsData[field]) || systemSettingsData[field] < 1 || systemSettingsData[field] > 730))) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是 1-730 的正整数`)
}
}
systemSettingsData = normalizeScheduleVisibilitySettings(systemSettingsData)
systemSettingsData = omitMaskedSystemSettingsSecrets(systemSettingsData)
if (mode === 'merge') {
// 检查是否存在系统设置记录(通常只有一条记录)
@@ -1407,26 +1128,6 @@ export default defineEventHandler(async (event) => {
? record.sequence
: 1
// 重播申请绑定:申请先于排期恢复,校验绑定存在且属于同一首歌,避免悬空绑定
let validScheduleReplayRequestId = null
if (record.replayRequestId) {
const boundReplayRequest = await tx
.select({
id: songReplayRequests.id,
songId: songReplayRequests.songId
})
.from(songReplayRequests)
.where(eq(songReplayRequests.id, record.replayRequestId))
.limit(1)
if (
boundReplayRequest.length > 0 &&
boundReplayRequest[0].songId === validSongId
) {
validScheduleReplayRequestId = record.replayRequestId
}
}
scheduleData.replayRequestId = validScheduleReplayRequestId
if (mode === 'merge') {
// 检查是否存在相同的排期(按歌曲ID和播放日期)
const existingSchedule = await tx
@@ -1603,16 +1304,7 @@ export default defineEventHandler(async (event) => {
// 动态构建通知数据,自动跳过不存在的字段
const notificationData = { userId: validNotificationUserId }
const notificationDataFields = [
'batchId',
'source',
'senderName',
'senderUsername',
'title',
'message',
'type',
'userDeleted'
]
const notificationDataFields = ['title', 'message', 'type']
notificationDataFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
@@ -1620,38 +1312,8 @@ export default defineEventHandler(async (event) => {
}
})
// senderId 需要重新映射到目标库的用户 ID;映射未命中时回查同 ID 用户并比对用户名快照,
// 不一致则置空,避免跨库恢复时将发送人归属到错误用户,展示仍靠快照字段
if (Object.prototype.hasOwnProperty.call(record, 'senderId')) {
if (record.senderId) {
const mappedSenderId = userIdMapping.get(record.senderId)
if (mappedSenderId) {
notificationData.senderId = mappedSenderId
} else {
const senderExists = await tx
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, record.senderId))
.limit(1)
notificationData.senderId =
senderExists.length > 0 &&
senderExists[0].username === record.senderUsername
? record.senderId
: null
}
} else {
notificationData.senderId = null
}
}
// 布尔字段,提供默认值
notificationData.read = record.hasOwnProperty('read') ? record.read : false
notificationData.important = Object.prototype.hasOwnProperty.call(
record,
'important'
)
? record.important
: false
// 日期字段
notificationData.createdAt = record.createdAt
@@ -1864,28 +1526,9 @@ export default defineEventHandler(async (event) => {
if (mappedSongId) validReplaySongId = mappedSongId
}
// 校验期望播出时段是否存在(可选字段)
let validReplayPlayTimeId = record.preferredPlayTimeId || null
if (validReplayPlayTimeId) {
const replayPlayTimeExists = await tx
.select({ id: playTimes.id })
.from(playTimes)
.where(eq(playTimes.id, validReplayPlayTimeId))
.limit(1)
if (replayPlayTimeExists.length === 0) {
validReplayPlayTimeId = null
}
}
// 保留原始状态与申请元数据,避免历史已履行记录恢复为 PENDING 触发部分唯一索引冲突
const replayData = {
songId: validReplaySongId,
userId: validReplayUserId,
status: record.status || 'PENDING',
preferredPlayTimeId: validReplayPlayTimeId,
submissionNote: record.submissionNote ?? null,
submissionNotePublic: record.submissionNotePublic === true,
submissionNotePublicStatus: record.submissionNotePublicStatus ?? null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date(),
updatedAt: record.updatedAt ? new Date(record.updatedAt) : new Date()
}
@@ -1920,101 +1563,6 @@ export default defineEventHandler(async (event) => {
}
break
case 'cardCodeRedeemLogs': {
let validCardCodeId = record.cardCodeId || null
if (record.cardCodeId) {
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
if (mappedCardCodeId) {
validCardCodeId = mappedCardCodeId
} else {
const cardCodeExists = await tx
.select({ id: cardCodes.id })
.from(cardCodes)
.where(eq(cardCodes.id, record.cardCodeId))
.limit(1)
if (cardCodeExists.length === 0) {
validCardCodeId = null
}
}
}
let validRedeemedBy = record.redeemedBy
if (record.redeemedBy) {
const mappedUserId = userIdMapping.get(record.redeemedBy)
if (mappedUserId) {
validRedeemedBy = mappedUserId
} else {
const userExists = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, record.redeemedBy))
.limit(1)
if (userExists.length === 0) {
console.warn(
`点歌券日志的操作用户ID ${record.redeemedBy} 不存在,跳过此记录`
)
return
}
}
} else {
console.warn('点歌券日志缺少redeemedBy,跳过此记录')
return
}
let validLogSongId = record.songId || null
if (record.songId) {
const mappedSongId = songIdMapping.get(record.songId)
if (mappedSongId) {
validLogSongId = mappedSongId
} else {
const songExists = await tx
.select({ id: songs.id })
.from(songs)
.where(eq(songs.id, record.songId))
.limit(1)
if (songExists.length === 0) {
validLogSongId = null
}
}
}
const logData = {
cardCodeId: validCardCodeId,
codeSnapshot: record.codeSnapshot,
redeemedBy: validRedeemedBy,
redeemedAt: record.redeemedAt ? new Date(record.redeemedAt) : new Date(),
source: record.source || 'UNKNOWN',
songId: validLogSongId,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
if (!logData.codeSnapshot) {
console.warn('点歌券日志缺少codeSnapshot,跳过此记录')
return
}
if (mode === 'merge') {
await tx.insert(cardCodeRedeemLogs).values(logData)
} else {
const existingLogWithId = await tx
.select()
.from(cardCodeRedeemLogs)
.where(eq(cardCodeRedeemLogs.id, record.id))
.limit(1)
if (existingLogWithId.length > 0) {
await tx
.update(cardCodeRedeemLogs)
.set(logData)
.where(eq(cardCodeRedeemLogs.id, record.id))
} else {
await tx
.insert(cardCodeRedeemLogs)
.values({ ...logData, id: record.id })
}
}
break
}
case 'votes':
// 验证外键约束
let validVoteUserId = record.userId
@@ -2120,11 +1668,6 @@ export default defineEventHandler(async (event) => {
}
break
case 'scheduleSongPool': {
await restoreScheduleSongPoolRecord(tx, record, songIdMapping, userIdMapping, stats)
break
}
case 'requestTimes':
// requestTimes表没有外键依赖
const requestTimeData = {
@@ -2572,23 +2115,6 @@ export default defineEventHandler(async (event) => {
console.log(`📊 处理了 ${restoreResults.details.tablesProcessed} 个表`)
console.log(`📊 恢复了 ${restoreResults.details.recordsRestored} 条记录`)
// 防锁死:恢复开关前校验 SMTP 已配置,否则剥离 registerEmailRequired(避免恢复后注册必填邮箱却无法发码)
try {
const restoredSettings = await db.select().from(systemSettings).limit(1)
if (
restoredSettings[0]?.registerEmailRequired &&
(!restoredSettings[0]?.smtpEnabled || !restoredSettings[0]?.smtpHost)
) {
await db
.update(systemSettings)
.set({ registerEmailRequired: false })
.where(eq(systemSettings.id, restoredSettings[0].id))
console.warn('⚠️ SMTP 未配置,已剥离 registerEmailRequired,避免注册邮箱流程不可用')
}
} catch (settingsError) {
console.error('恢复后设置一致性校验失败:', settingsError)
}
// 重置所有自增序列
console.log(`🔄 开始重置自增序列...`)
const sequenceResetResults = []
@@ -2647,6 +2173,17 @@ export default defineEventHandler(async (event) => {
restoreResults.message = '数据恢复完成,但存在错误'
}
// 清除所有缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearAllCaches()
console.log('数据恢复后缓存已清除')
} catch (cacheError) {
console.warn('清除缓存失败:', cacheError)
restoreResults.details.warnings = restoreResults.details.warnings || []
restoreResults.details.warnings.push('清除缓存失败')
}
try {
const smtpService = SmtpService.getInstance()
const initialized = await smtpService.initializeSmtpConfig()
@@ -2666,7 +2203,7 @@ export default defineEventHandler(async (event) => {
console.error('恢复数据库备份失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '恢复数据库备份失败'
statusMessage: error.statusMessage || '恢复数据库备份失败'
})
}
})
+8 -8
View File
@@ -7,10 +7,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以上传备份文件'
statusMessage: '权限不足'
})
}
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
keepExtensions: true,
maxFileSize: 100 * 1024 * 1024, // 100MB
filter: ({ mimetype, originalFilename }) => {
// 只允许 JSON 文件
// 只允许JSON文件
return (
mimetype === 'application/json' ||
(originalFilename && originalFilename.endsWith('.json'))
@@ -41,7 +41,7 @@ export default defineEventHandler(async (event) => {
if (!files.file || !files.file[0]) {
throw createError({
statusCode: 400,
message: '请选择要上传的备份文件'
statusMessage: '请选择要上传的备份文件'
})
}
@@ -57,7 +57,7 @@ export default defineEventHandler(async (event) => {
await fs.unlink(uploadedFile.filepath)
throw createError({
statusCode: 400,
message: '备份文件格式无效'
statusMessage: '备份文件格式无效'
})
}
@@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
await fs.unlink(uploadedFile.filepath)
throw createError({
statusCode: 400,
message: '不是有效的备份文件'
statusMessage: '不是有效的备份文件'
})
}
@@ -106,7 +106,7 @@ export default defineEventHandler(async (event) => {
// 重新保存文件
await fs.writeFile(newFilepath, JSON.stringify(backupData, null, 2), 'utf8')
console.log(`管理员 ${user.username} 上传了备份文件:${newFilename}`)
console.log(`管理员 ${user.username} 上传了备份文件: ${newFilename}`)
// 获取文件信息
const stats = await fs.stat(newFilepath)
@@ -127,7 +127,7 @@ export default defineEventHandler(async (event) => {
console.error('上传备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '上传备份文件失败'
statusMessage: error.statusMessage || '上传备份文件失败'
})
}
})
+10
View File
@@ -2,6 +2,7 @@ import { createError, defineEventHandler, getRouterParam } from 'h3'
import { db } from '~/drizzle/db'
import { songBlacklists } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
@@ -40,6 +41,15 @@ export default defineEventHandler(async (event) => {
// 删除黑名单项
await db.delete(songBlacklists).where(eq(songBlacklists.id, id))
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('黑名单删除后歌曲缓存已清除')
} catch (cacheError) {
console.warn('清除歌曲缓存失败:', cacheError)
}
return {
success: true,
message: '黑名单项删除成功'
+10
View File
@@ -2,6 +2,7 @@ import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { songBlacklists } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
@@ -54,6 +55,15 @@ export default defineEventHandler(async (event) => {
.where(eq(songBlacklists.id, id))
.returning()
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('黑名单更新后歌曲缓存已清除')
} catch (cacheError) {
console.warn('清除歌曲缓存失败:', cacheError)
}
return {
success: true,
message: '黑名单项更新成功',
+11 -20
View File
@@ -2,11 +2,7 @@ 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'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
@@ -29,27 +25,13 @@ export default defineEventHandler(async (event) => {
})
}
if (!Object.values(BLACKLIST_TYPES).includes(type)) {
if (!['SONG', 'KEYWORD', 'LANGUAGE', 'GENRE'].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
@@ -77,6 +59,15 @@ export default defineEventHandler(async (event) => {
})
.returning()
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('黑名单添加后歌曲缓存已清除')
} catch (cacheError) {
console.warn('清除歌曲缓存失败:', cacheError)
}
return {
success: true,
message: '黑名单项添加成功',
+28
View File
@@ -0,0 +1,28 @@
import { eq } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { changelogEntries } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '只有管理员才能删除更新日志' })
}
const id = Number(getRouterParam(event, 'id'))
if (!Number.isFinite(id) || id <= 0) {
throw createError({ statusCode: 400, message: '无效的 id' })
}
const rows = await db.delete(changelogEntries).where(eq(changelogEntries.id, id)).returning()
if (!rows[0]) {
throw createError({ statusCode: 404, message: '更新日志不存在' })
}
return { success: true }
})
+69
View File
@@ -0,0 +1,69 @@
import { eq } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { changelogEntries } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '只有管理员才能更新更新日志' })
}
const id = Number(getRouterParam(event, 'id'))
if (!Number.isFinite(id) || id <= 0) {
throw createError({ statusCode: 400, message: '无效的 id' })
}
const body = await readBody(event)
const updateData: any = { updatedAt: new Date() }
if (body?.version !== undefined) {
if (body.version !== null && typeof body.version !== 'string') {
throw createError({ statusCode: 400, message: 'version 必须是字符串' })
}
updateData.version = (body.version || '').trim()
}
if (body?.title !== undefined) {
if (body.title !== null && typeof body.title !== 'string') {
throw createError({ statusCode: 400, message: 'title 必须是字符串' })
}
updateData.title = (body.title || '').trim()
}
if (body?.content !== undefined) {
if (body.content !== null && typeof body.content !== 'string') {
throw createError({ statusCode: 400, message: 'content 必须是字符串' })
}
updateData.content = (body.content || '').trim()
}
if (body?.published !== undefined) {
if (typeof body.published !== 'boolean') {
throw createError({ statusCode: 400, message: 'published 必须是布尔值' })
}
updateData.published = body.published
updateData.publishedAt = body.published ? new Date() : null
}
if (Object.keys(updateData).length <= 1) {
throw createError({ statusCode: 400, message: '没有可更新的字段' })
}
const rows = await db
.update(changelogEntries)
.set(updateData)
.where(eq(changelogEntries.id, id))
.returning()
if (!rows[0]) {
throw createError({ statusCode: 404, message: '更新日志不存在' })
}
return rows[0]
})
+17
View File
@@ -0,0 +1,17 @@
import { desc } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { changelogEntries } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '只有管理员才能查看更新日志' })
}
return db.select().from(changelogEntries).orderBy(desc(changelogEntries.createdAt))
})
+44
View File
@@ -0,0 +1,44 @@
import { db } from '~/drizzle/db'
import { changelogEntries } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '只有管理员才能创建更新日志' })
}
const body = await readBody(event)
const version = typeof body?.version === 'string' ? body.version.trim() : ''
const title = typeof body?.title === 'string' ? body.title.trim() : ''
const content = typeof body?.content === 'string' ? body.content.trim() : ''
const published = body?.published === true
if (!version) {
throw createError({ statusCode: 400, message: 'version 不能为空' })
}
if (!title) {
throw createError({ statusCode: 400, message: 'title 不能为空' })
}
if (!content) {
throw createError({ statusCode: 400, message: 'content 不能为空' })
}
const rows = await db
.insert(changelogEntries)
.values({
version,
title,
content,
published,
publishedAt: published ? new Date() : null,
createdBy: user.id
})
.returning()
return rows[0]
})
+11 -12
View File
@@ -3,17 +3,13 @@ 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
})
}
// 验证管理员权限
await verifyAdminAuth(event)
// 清理过期会话
const cleanedCount = await databaseManager.cleanupExpiredSessions()
// 清除健康检查缓存以确保下次检查获取最新状态
databaseManager.clearHealthCheckCache()
return {
@@ -22,14 +18,17 @@ export default defineEventHandler(async (event) => {
cleanedCount,
timestamp: new Date().toISOString()
}
} catch (error: any) {
if (error.statusCode === 401) {
throw error
} catch (error) {
if (error instanceof Error && error.message.includes('认证')) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized'
})
}
throw createError({
statusCode: 500,
message: error instanceof Error ? error.message : 'Cleanup failed'
statusMessage: error instanceof Error ? error.message : 'Cleanup failed'
})
}
})
+3 -3
View File
@@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => {
if (!authResult.success) {
throw createError({
statusCode: 401,
message: authResult.message
statusMessage: authResult.message
})
}
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
console.error('Database performance API error:', error)
throw createError({
statusCode: 500,
message: 'Internal Server Error'
statusMessage: 'Internal Server Error'
})
}
})
})
+3 -3
View File
@@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => {
if (!authResult.success) {
throw createError({
statusCode: 401,
message: authResult.message
statusMessage: authResult.message
})
}
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
console.error('Database pool status API error:', error)
throw createError({
statusCode: 500,
message: 'Internal Server Error'
statusMessage: 'Internal Server Error'
})
}
})
})
+18 -46
View File
@@ -1,5 +1,6 @@
import { createError, defineEventHandler } from 'h3'
import { db } from '~/drizzle/db'
import { CacheService } from '../../../services/cacheService'
import {
apiKeyPermissions,
apiKeys,
@@ -24,29 +25,6 @@ import {
} 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 = [
@@ -71,31 +49,15 @@ async function resetAutoIncrementSequences() {
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`
sql.raw(`SELECT pg_get_serial_sequence('"${table.dbName}"', 'id') as sequence_name`)
)
const sequenceName = getFirstRow<{ sequence_name: string | null }>(
sequenceNameResult
)?.sequence_name
const sequenceName = (sequenceNameResult as any)[0]?.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})`)
}
// 重置序列值到 1
await db.execute(sql.raw(`ALTER SEQUENCE ${sequenceName} RESTART WITH 1`))
results.push({ table: table.name, success: true })
} else {
results.push({ table: table.name, success: false, message: 'Sequence not found' })
@@ -115,7 +77,7 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '权限不足'
statusMessage: '权限不足'
})
}
@@ -264,11 +226,21 @@ export default defineEventHandler(async (event) => {
// 重置自增序列
const sequenceResults = await resetAutoIncrementSequences()
resetResults.details.sequencesReset = sequenceResults.filter((r) => r.success).length
// 清除缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearAllCache()
} catch (error) {
console.warn('清除缓存失败:', error)
resetResults.details.warnings = resetResults.details.warnings || []
resetResults.details.warnings.push('清除缓存失败')
}
} catch (error) {
console.error('重置数据库失败:', error)
throw createError({
statusCode: 500,
message: `重置数据库失败:${error.message}`
statusMessage: `重置数据库失败: ${error.message}`
})
}
@@ -276,7 +248,7 @@ export default defineEventHandler(async (event) => {
} catch (error) {
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '服务器内部错误'
statusMessage: error.statusMessage || '服务器内部错误'
})
}
})
+3 -3
View File
@@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => {
if (!authResult.success) {
throw createError({
statusCode: 401,
message: authResult.message
statusMessage: authResult.message
})
}
@@ -51,7 +51,7 @@ export default defineEventHandler(async (event) => {
console.error('Database status API error:', error)
throw createError({
statusCode: 500,
message: 'Internal Server Error'
statusMessage: 'Internal Server Error'
})
}
})
})
+1 -1
View File
@@ -63,7 +63,7 @@ export default defineEventHandler(async (event) => {
console.error('检查数据库状态失败:', error)
throw createError({
statusCode: 500,
message: '检查数据库状态失败:' + error.message
statusMessage: '检查数据库状态失败: ' + error.message
})
}
})
@@ -0,0 +1,27 @@
import { db } from '~/drizzle/db'
import { expectedPlayDates } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN')) {
throw createError({ statusCode: 403, message: '权限不足' })
}
const idParam = event.context.params?.id
if (!idParam) {
throw createError({ statusCode: 400, message: '无效的ID' })
}
const id = Number(idParam)
if (isNaN(id)) {
throw createError({ statusCode: 400, message: '无效的ID' })
}
try {
await db.delete(expectedPlayDates).where(eq(expectedPlayDates.id, id))
return { success: true }
} catch (e: any) {
throw createError({ statusCode: 500, message: e.message || '删除失败' })
}
})
@@ -0,0 +1,95 @@
import { db } from '~/drizzle/db'
import { expectedPlayDates, songs } from '~/drizzle/schema'
import { desc, eq, and } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const method = event.node.req.method
const user = event.context.user
if (!user || (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN')) {
throw createError({ statusCode: 403, message: '权限不足' })
}
if (method === 'GET') {
const dates = await db.select().from(expectedPlayDates).orderBy(desc(expectedPlayDates.date))
const result = []
for (const d of dates) {
const dateObj = new Date(d.date)
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekday = weekdays[dateObj.getDay()]
let availableSeqs: number[] = []
if (d.availableSequences) {
availableSeqs = d.availableSequences.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n))
}
const takenSongs = await db.select({
sequence: songs.expectedPlaySequence,
title: songs.title,
artist: songs.artist,
requesterId: songs.requesterId
})
.from(songs)
.where(
and(
eq(songs.expectedPlayDate, d.date),
eq(songs.played, false)
)
)
const takenSeqs = new Set(takenSongs.map(s => s.sequence).filter(seq => seq !== null))
const takenDetails: Record<number, any> = {}
takenSongs.forEach(s => {
if (s.sequence !== null) {
takenDetails[s.sequence] = { title: s.title, artist: s.artist }
}
})
const freeSeqs = availableSeqs.filter(seq => !takenSeqs.has(seq))
result.push({
...d,
weekday,
allSequences: availableSeqs.sort((a, b) => a - b),
takenSequences: Array.from(takenSeqs).sort((a, b) => a - b),
takenDetails,
freeSequences: freeSeqs.sort((a, b) => a - b),
isFull: freeSeqs.length === 0
})
}
return result
}
if (method === 'POST') {
const body = await readBody(event)
const { date, availableSequences } = body
if (!date || !availableSequences) {
throw createError({ statusCode: 400, message: '日期和序号不能为空' })
}
try {
// 检查是否存在
const existing = await db.select().from(expectedPlayDates).where(eq(expectedPlayDates.date, date))
if (existing.length > 0) {
// 更新
const result = await db.update(expectedPlayDates)
.set({ availableSequences })
.where(eq(expectedPlayDates.date, date))
.returning()
return result[0]
} else {
// 新增
const result = await db.insert(expectedPlayDates)
.values({ date, availableSequences })
.returning()
return result[0]
}
} catch (e: any) {
throw createError({ statusCode: 500, message: e.message || '保存失败' })
}
}
})
+32 -44
View File
@@ -1,43 +1,31 @@
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 maxIdResult = await db.execute(sql.raw(`SELECT MAX(id) as max_id FROM "${dbTableName}"`))
const maxId = Number((maxIdResult as any)[0]?.max_id || 0)
if (maxId === 0) {
return {
success: true,
table: table,
message: `表 ${table} 为空,无需修复序列`,
data: {
table: table,
maxId: 0,
skipped: true
}
}
}
// 获取序列名称
const sequenceNameResult = await db.execute(
sql`SELECT pg_get_serial_sequence(${toPgRelationName(dbTableName)}, 'id') as sequence_name`
sql.raw(`SELECT pg_get_serial_sequence('"${dbTableName}"', 'id') as sequence_name`)
)
const sequenceName = getFirstRow<{ sequence_name: string | null }>(sequenceNameResult)?.sequence_name
const sequenceName = (sequenceNameResult as any)[0]?.sequence_name
if (!sequenceName) {
return {
@@ -47,29 +35,29 @@ async function fixTableSequence(table: string, dbTableName: string) {
}
}
if (maxId === 0) {
await db.execute(sql`ALTER SEQUENCE ${toQualifiedIdentifier(sequenceName)} RESTART WITH 1`)
// 获取当前序列值
const currentSeqResult = await db.execute(sql.raw(`SELECT last_value FROM ${sequenceName}`))
const currentSeqValue = Number((currentSeqResult as any)[0]?.last_value || 0)
// 重置序列值到最大ID + 1
const newSequenceValue = maxId + 1
// 检查序列是否已经正确
if (currentSeqValue >= newSequenceValue) {
return {
success: true,
table: table,
message: `表 ${table} 为空,序列已重置为 1`,
message: `表 ${table} 序列值已正确,无需修复`,
data: {
table: table,
maxId: 0,
newSequenceValue: 1,
sequenceName: sequenceName
maxId: maxId,
currentSequenceValue: currentSeqValue,
alreadyCorrect: true
}
}
}
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})`)
await db.execute(sql.raw(`SELECT setval('${sequenceName}', ${newSequenceValue})`))
return {
success: true,
@@ -99,7 +87,7 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
statusMessage: '需要管理员权限'
})
}
+149 -190
View File
@@ -1,220 +1,179 @@
import { and, eq, or } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { and, eq, or } from 'drizzle-orm'
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
} from '../../../services/notificationService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户是否为管理员
const user = event.context.user
if (!user) {
throw createApiError(401, SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED, '需要登录才能发送通知')
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!canSendSystemNotification(user.role)) {
throw createApiError(
403,
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
'只有管理员可以发送系统通知'
)
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员可以发送系统通知'
})
}
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)
try {
// 获取客户端IP地址
const clientIP = getClientIP(event)
if (important === null) {
throw createApiError(
400,
SERVER_ERROR_CODES.NOTIFICATION_IMPORTANT_INVALID,
'重要通知标记必须是布尔值'
)
}
// 获取请求数据
const body = await readBody(event)
const { title, message, content, scope, filter, userId, type } = body
if (!title || !content) {
throw createApiError(
400,
SERVER_ERROR_CODES.NOTIFICATION_TITLE_CONTENT_REQUIRED,
'通知标题和内容不能为空'
)
}
// 处理单个用户通知(用于权限变更等系统通知)
if (userId && title && (message || content)) {
const notificationContent = message || content
const result = await createSystemNotification(userId, title, notificationContent, clientIP)
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')
if (result) {
return {
success: true,
message: '通知发送成功',
sentCount: 1,
totalUsers: 1
}
} else {
throw createError({
statusCode: 500,
message: '发送通知失败'
})
}
}
const result = await createSystemNotification(
Number(normalizedUserId),
title,
content,
important,
sender
)
// 验证批量通知请求数据
if (!title || !content) {
throw createError({
statusCode: 400,
message: '通知标题和内容不能为空'
})
}
if (!scope || !['ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'].includes(scope)) {
throw createError({
statusCode: 400,
message: '无效的通知范围'
})
}
// 查询符合条件的用户IDs
let userIds: number[] = []
if (scope === 'ALL') {
// 查询所有用户
const allUsers = await db.select({ id: users.id }).from(users)
userIds = allUsers.map((user) => user.id)
} else if (scope === 'GRADE') {
// 按年级查询
if (!filter?.grade) {
throw createError({
statusCode: 400,
message: '年级不能为空'
})
}
const gradeUsers = await db
.select({ id: users.id })
.from(users)
.where(eq(users.grade, filter.grade))
userIds = gradeUsers.map((user) => user.id)
} else if (scope === 'CLASS') {
// 按单个班级查询
if (!filter?.grade || !filter?.class) {
throw createError({
statusCode: 400,
message: '年级和班级不能为空'
})
}
const classUsers = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.grade, filter.grade), eq(users.class, filter.class)))
userIds = classUsers.map((user) => user.id)
} else if (scope === 'MULTI_CLASS') {
// 按多个班级查询
if (!filter?.classes || !Array.isArray(filter.classes) || filter.classes.length === 0) {
throw createError({
statusCode: 400,
message: '未选择任何班级'
})
}
// 构建查询条件,使用OR连接多个班级条件
const whereConditions = filter.classes.map((cls: { grade: string; class: string }) =>
and(eq(users.grade, cls.grade), eq(users.class, cls.class))
)
const multiClassUsers = await db
.select({ id: users.id })
.from(users)
.where(or(...whereConditions))
userIds = multiClassUsers.map((user) => user.id)
} else if (scope === 'SPECIFIC_USERS') {
// 指定用户
if (!filter?.userIds || !Array.isArray(filter.userIds) || filter.userIds.length === 0) {
throw createError({
statusCode: 400,
message: '未选择任何用户'
})
}
userIds = filter.userIds
}
if (userIds.length === 0) {
return {
success: true,
message: '没有找到符合条件的用户',
sentCount: 0,
totalUsers: 0
}
}
// 使用通知服务批量发送通知
const result = await createBatchSystemNotifications(userIds, title, content, clientIP)
if (!result) {
throw createApiError(500, SERVER_ERROR_CODES.NOTIFICATION_SEND_FAILED, '发送通知失败')
throw new Error('发送通知失败')
}
// 处理结果
let sentCount = 0
let totalUsers = userIds.length
// 判断result类型
if (Array.isArray(result)) {
sentCount = result.length
} else if (result && typeof result === 'object' && 'count' in result) {
sentCount = result.count
totalUsers = result.total || userIds.length
}
return {
success: true,
message: '通知发送成功',
sentCount: 1,
totalUsers: 1
sentCount,
totalUsers
}
}
} catch (error: any) {
console.error('发送通知失败:', error)
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() : ''
}
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '发送通知失败'
})
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
}
})
+35 -1
View File
@@ -1,4 +1,5 @@
import { db } from '~/drizzle/db'
import { CacheService } from '../../../services/cacheService'
import { playTimes, schedules, songs } from '~/drizzle/schema'
import { and, count, eq, ne } from 'drizzle-orm'
@@ -13,7 +14,7 @@ export default defineEventHandler(async (event) => {
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能访问播出时段'
@@ -120,6 +121,16 @@ export default defineEventHandler(async (event) => {
.returning()
const updatedPlayTime = updatedPlayTimeResult[0]
// 清除相关缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSchedulesCache()
await cacheService.clearPlayTimesCache()
console.log('[Cache] 排期缓存已清除(播放时间更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return updatedPlayTime
} catch (error: any) {
console.error('更新播出时段失败:', error)
@@ -172,6 +183,16 @@ export default defineEventHandler(async (event) => {
.returning()
const updatedPlayTime = updatedPlayTimeResult[0]
// 清除相关缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSchedulesCache()
await cacheService.clearPlayTimesCache()
console.log('[Cache] 排期缓存已清除(播放时间部分更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return updatedPlayTime
} catch (error: any) {
console.error('部分更新播出时段失败:', error)
@@ -220,6 +241,19 @@ export default defineEventHandler(async (event) => {
await db.update(schedules).set({ playTimeId: null }).where(eq(schedules.playTimeId, id))
}
// 清除相关缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSchedulesCache()
await cacheService.clearPlayTimesCache()
if (songsCount > 0) {
await cacheService.clearSongsCache()
}
console.log('[Cache] 缓存已清除(播放时间删除)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
message: '播出时段已成功删除',
affected: { songs: songsCount, schedules: schedulesCount }
+11 -1
View File
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能添加播出时段'
@@ -84,6 +84,16 @@ export default defineEventHandler(async (event) => {
.returning()
const newPlayTime = newPlayTimeResult[0]
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
await cache.deletePattern('playtimes:*')
console.log('[Cache] 排期和播放时间缓存已清除(创建播放时间)')
} catch (cacheError) {
console.warn('清除缓存失败:', cacheError)
}
return newPlayTime
} catch (error: any) {
console.error('创建播出时段失败:', error)
+18 -1
View File
@@ -1,4 +1,5 @@
import { asc, db, desc, playTimes } from '~/drizzle/db'
import { CacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
@@ -11,7 +12,7 @@ export default defineEventHandler(async (event) => {
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能管理播出时段'
@@ -19,11 +20,27 @@ export default defineEventHandler(async (event) => {
}
try {
// 尝试从缓存获取播放时间数据
const cacheService = CacheService.getInstance()
const cachedPlayTimes = await cacheService.getPlayTimes()
if (cachedPlayTimes) {
return cachedPlayTimes
}
// 缓存中没有,从数据库获取所有播出时段
const playTimesResult = await db
.select()
.from(playTimes)
.orderBy(desc(playTimes.enabled), asc(playTimes.startTime))
// 将数据存入缓存
try {
await cacheService.setPlayTimes(playTimesResult)
} catch (cacheError) {
console.warn('缓存播放时间数据失败:', cacheError)
}
return playTimesResult
} catch (error) {
console.error('获取播出时段失败:', error)
+57 -107
View File
@@ -1,10 +1,10 @@
import { db, songs, songReplayRequests, users } from '~/drizzle/db'
import { desc, eq, sql, and } from 'drizzle-orm'
import { db, songs, songReplayRequests, users, semesters } from '~/drizzle/db'
import { desc, eq, sql, and, or } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 1. 检查权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
@@ -20,49 +20,33 @@ export default defineEventHandler(async (event) => {
conditions.push(eq(songReplayRequests.status, status as any))
}
if (semesterName) {
if (semesterName && semesterName !== 'all') {
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
song: songs,
requester: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
},
// insert-only 模型下同一用户可能有多条申请,按申请人去重计数
requestCount: sql<number>`count(distinct ${songReplayRequests.userId})`
requestCount: sql<number>`count(${songReplayRequests.id})`
.mapWith(Number)
.as('request_count'),
lastRequestedAt: sql<string>`max(${songReplayRequests.createdAt})`.as('last_requested_at')
})
.from(songs)
.innerJoin(songReplayRequests, eq(songs.id, songReplayRequests.songId))
.groupBy(songs.id)
.leftJoin(users, eq(songs.requesterId, users.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.groupBy(songs.id, users.id, users.name, users.grade, users.class)
.orderBy(desc(sql`request_count`))
if (conditions.length > 0) {
requestsQuery.where(and(...conditions))
}
const requests = await requestsQuery
// 获取所有用户姓名,用于处理同名情况
@@ -83,53 +67,47 @@ export default defineEventHandler(async (event) => {
}
})
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 formatClassInfo = (userObj: any) => {
if (!userObj) return ''
const parts: string[] = []
if (userObj.grade) parts.push(String(userObj.grade))
if (userObj.class) parts.push(String(userObj.class))
return parts.join(' ').trim()
}
// 获取详细的重播申请人信息
const 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 formatDisplayName = (userObj: any) => {
if (!userObj) return '未知用户'
const name = userObj.name ? String(userObj.name).trim() : ''
const classInfo = formatClassInfo(userObj)
if (!name) return classInfo || '未知用户'
return classInfo ? `${classInfo} ${name}` : name
}
const subConditions = []
if (status !== 'ALL') {
subConditions.push(eq(songReplayRequests.status, status as any))
}
if (semesterName) {
if (semesterName && semesterName !== 'all') {
subConditions.push(eq(songs.semester, semesterName))
}
if (subConditions.length > 0) {
requestDetailsQuery.where(and(...subConditions))
}
// 获取详细的重播申请人信息
let requestDetailsQuery = db
.select({
songId: songReplayRequests.songId,
user: {
name: users.name,
grade: users.grade,
class: users.class
},
createdAt: songReplayRequests.createdAt,
status: songReplayRequests.status
})
.from(songReplayRequests)
.innerJoin(users, eq(songReplayRequests.userId, users.id))
.innerJoin(songs, eq(songReplayRequests.songId, songs.id))
.where(subConditions.length > 0 ? and(...subConditions) : undefined)
.orderBy(desc(songReplayRequests.createdAt))
const requestDetails = await requestDetailsQuery
@@ -137,50 +115,22 @@ export default defineEventHandler(async (event) => {
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
status: d.status
})
})
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
}
})
return requests.map((item) => ({
...item.song,
requester: formatDisplayName(item.requester),
requesterGrade: item.requester?.grade,
requesterClass: item.requester?.class,
voteCount: item.requestCount,
requestCount: item.requestCount,
lastRequestedAt: item.lastRequestedAt,
requestDetails: detailsMap.get(item.song.id) || []
}))
})
@@ -5,7 +5,7 @@ import { createReplayRequestRejectedNotification } from '~~/server/services/noti
export default defineEventHandler(async (event) => {
// 检查管理员权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
@@ -40,13 +40,15 @@ export default defineEventHandler(async (event) => {
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.status, 'PENDING')))
// 发送通知
const ipAddress = getRequestIP(event, { xForwardedFor: true })
for (const req of pendingRequests) {
await createReplayRequestRejectedNotification(
req.userId,
{
title: song.title,
artist: song.artist
}
},
ipAddress
)
}
+25 -1
View File
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能访问投稿开放时段'
@@ -100,6 +100,14 @@ export default defineEventHandler(async (event) => {
.returning()
const updatedRequestTime = updatedRequestTimeResult[0]
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
console.log('[Cache] 排期缓存已清除(播放时间更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return updatedRequestTime
} catch (error: any) {
console.error('更新投稿开放时段失败:', error)
@@ -148,6 +156,14 @@ export default defineEventHandler(async (event) => {
.returning()
const updatedRequestTime = updatedRequestTimeResult[0]
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
console.log('[Cache] 排期缓存已清除(播放时间部分更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return updatedRequestTime
} catch (error: any) {
console.error('部分更新投稿开放时段失败:', error)
@@ -169,6 +185,14 @@ export default defineEventHandler(async (event) => {
.returning()
const deletedRequestTime = deletedRequestTimeResult[0]
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
console.log('[Cache] 缓存已清除(播放时间删除)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
message: '投稿开放时段已成功删除'
}
+10 -1
View File
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能添加投稿开放时段'
@@ -83,6 +83,15 @@ export default defineEventHandler(async (event) => {
.returning()
const newRequestTime = newRequestTimeResult[0]
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('schedules:*')
await cache.deletePattern('requestTimes:*')
console.log('[Cache] 排期和播放时间缓存已清除(创建播放时间)')
} catch (cacheError) {
console.warn('清除缓存失败:', cacheError)
}
return newRequestTime
} catch (error: any) {
console.error('创建投稿开放时段失败:', error)
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
})
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能管理投稿开放时段'
+14
View File
@@ -0,0 +1,14 @@
import { ROLES } from '~~/server/utils/permissions'
export default defineEventHandler((event) => {
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以访问角色配置'
})
}
return ROLES
})
+46
View File
@@ -0,0 +1,46 @@
import { ROLES, saveRolesConfig } from '~~/server/utils/permissions'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '只有超级管理员可以修改角色配置'
})
}
const body = await readBody(event)
if (!body || typeof body !== 'object') {
throw createError({
statusCode: 400,
message: '无效的角色配置数据'
})
}
// 保护 SUPER_ADMIN 的关键权限不被移除
if (body.SUPER_ADMIN) {
const requiredPages = ['roles', 'users', 'site-config']
const hasRequired = requiredPages.every(p => body.SUPER_ADMIN.pages.includes(p))
if (!hasRequired) {
requiredPages.forEach(p => {
if (!body.SUPER_ADMIN.pages.includes(p)) {
body.SUPER_ADMIN.pages.push(p)
}
})
}
body.SUPER_ADMIN.canAccessAdmin = true
}
const success = saveRolesConfig(body)
if (!success) {
throw createError({
statusCode: 500,
message: '保存角色配置失败'
})
}
return { success: true, message: '角色配置已保存' }
})
+211 -97
View File
@@ -1,10 +1,17 @@
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'
import {
playTimes,
schedules,
songs,
users,
votes,
songReplayRequests,
scheduleSongPool
} from '~/drizzle/schema'
import { and, asc, count, desc, eq, gte, lte } from 'drizzle-orm'
import { createSongSelectedNotification } from '../../services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
@@ -36,12 +43,6 @@ export default defineEventHandler(async (event) => {
// 检查是否为草稿模式(默认直接发布)
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)
@@ -55,7 +56,20 @@ export default defineEventHandler(async (event) => {
})
}
const transactionResult = await db.transaction(async (tx) => {
await db.transaction(async (tx) => {
// 检查是否已经为该歌曲创建过排期,如果有则删除旧的排期
const existingScheduleResult = await tx
.select()
.from(schedules)
.where(eq(schedules.songId, body.songId))
.limit(1)
const existingSchedule = existingScheduleResult[0]
if (existingSchedule) {
// 删除现有排期
await tx.delete(schedules).where(eq(schedules.id, existingSchedule.id))
}
// 获取序号,如果未提供则查找当天最大序号+1
let sequence = body.sequence || 1
@@ -81,8 +95,9 @@ export default defineEventHandler(async (event) => {
.orderBy(desc(schedules.sequence))
.limit(1)
const latestSchedule = sameDaySchedules[0]
if (latestSchedule) sequence = (latestSchedule.sequence || 0) + 1
if (sameDaySchedules.length > 0) {
sequence = (sameDaySchedules[0].sequence || 0) + 1
}
}
// 解析输入的日期字符串,确保日期正确
@@ -102,120 +117,219 @@ export default defineEventHandler(async (event) => {
playTimeId: body.playTimeId || null,
// 草稿支持:根据参数决定是否为草稿
isDraft: isDraft,
publishedAt: isDraft ? null : getServerDate()
publishedAt: isDraft ? null : new Date()
})
.returning()
const createdSchedule = scheduleResult[0]
if (!createdSchedule) {
throw createError({ statusCode: 500, message: '创建排期失败' })
}
const schedule = {
...createdSchedule,
...scheduleResult[0],
song: {
id: song.id,
title: song.title,
artist: song.artist,
requesterId: song.requesterId,
cardCodeId: song.cardCodeId
requesterId: song.requesterId
}
}
let shouldNotify = false
let replayRequesterIds: number[] = []
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 只有在非草稿模式下才更新重播状态
await tx
.update(scheduleSongPool)
.set({
status: 'ASSIGNED',
targetPlayDate: playDate
})
.where(
and(
eq(scheduleSongPool.songId, body.songId),
eq(scheduleSongPool.status, 'PENDING')
)
)
// 只有在非草稿模式下才创建通知
if (!isDraft) {
// 检查该歌曲是否已经有其他已发布的排期
const existingPublished = await tx
.select({ id: schedules.id })
.from(schedules)
await createSongSelectedNotification(
schedule.song.requesterId,
schedule.song.id,
{
title: schedule.song.title,
artist: schedule.song.artist,
playDate: schedule.playDate
},
clientIP
)
// 标记该歌曲的所有待处理重播申请为已完成
await tx
.update(songReplayRequests)
.set({ status: 'FULFILLED' })
.where(
and(
eq(schedules.songId, schedule.song.id),
eq(schedules.isDraft, false),
ne(schedules.id, createdSchedule.id)
eq(songReplayRequests.songId, schedule.song.id),
eq(songReplayRequests.status, 'PENDING')
)
)
.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
// 清除相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache() // 清除歌曲列表缓存,确保scheduled状态更新
console.log(`[Cache] 排期缓存和歌曲列表缓存已清除(${isDraft ? '保存草稿' : '创建排期'})`)
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
// 重新缓存完整的排期列表
try {
// 获取所有排期数据
const schedulesData = await db
.select({
id: schedules.id,
playDate: schedules.playDate,
sequence: schedules.sequence,
played: schedules.played,
playTimeId: schedules.playTimeId,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId,
songPlayed: songs.played,
songCover: songs.cover,
songMusicPlatform: songs.musicPlatform,
songMusicId: songs.musicId,
songPlayUrl: songs.playUrl,
songSemester: songs.semester,
requesterName: users.name,
requesterGrade: users.grade,
requesterClass: users.class,
playTimeName: playTimes.name,
playTimeStartTime: playTimes.startTime,
playTimeEndTime: playTimes.endTime,
playTimeEnabled: playTimes.enabled
})
} catch (notificationError) {
notificationSent = false
console.error('排期已创建,但发送歌曲入选通知失败:', notificationError)
}
}
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.innerJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.orderBy(asc(schedules.playDate))
// 发送重播申请已安排通知
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 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 schedule = transactionResult.schedule
// 获取每首歌的投票数
const songIds = schedulesData.map((s) => s.songId)
const voteCounts = await Promise.all(
songIds.map(async (songId) => {
const voteCountResult = await db
.select({ count: count() })
.from(votes)
.where(eq(votes.songId, songId))
return { songId, count: voteCountResult[0].count }
})
)
const voteCountMap = new Map(voteCounts.map((v) => [v.songId, v.count]))
// 转换数据格式
const formattedSchedules = schedulesData.map((schedule) => {
const dateOnly = schedule.playDate
// 处理投稿人姓名,如果是同名用户则添加后缀
let requesterName = schedule.requesterName || '未知用户'
const sameNameUsers = nameToUsers.get(requesterName)
if (sameNameUsers && sameNameUsers.length > 1) {
if (schedule.requesterGrade) {
const sameGradeUsers = sameNameUsers.filter(
(u: {
id: number
name: string | null
grade: string | null
class: string | null
}) => u.grade === schedule.requesterGrade
)
if (sameGradeUsers.length > 1 && schedule.requesterClass) {
requesterName = `${requesterName}(${schedule.requesterGrade} ${schedule.requesterClass})`
} else {
requesterName = `${requesterName}(${schedule.requesterGrade})`
}
}
}
return {
id: schedule.id,
playDate: dateOnly.toISOString().split('T')[0],
sequence: schedule.sequence || 1,
played: schedule.played || false,
playTimeId: schedule.playTimeId,
playTime: schedule.playTimeName
? {
id: schedule.playTimeId,
name: schedule.playTimeName,
startTime: schedule.playTimeStartTime,
endTime: schedule.playTimeEndTime,
enabled: schedule.playTimeEnabled
}
: null,
song: {
id: schedule.songId,
title: schedule.songTitle,
artist: schedule.songArtist,
requester: requesterName,
voteCount: voteCountMap.get(schedule.songId) || 0,
played: schedule.songPlayed || false,
cover: schedule.songCover || null,
musicPlatform: schedule.songMusicPlatform || null,
musicId: schedule.songMusicId || null,
playUrl: schedule.songPlayUrl || null,
semester: schedule.songSemester || null
}
}
})
const completeSchedules = formattedSchedules
// 重新缓存完整的排期列表
if (completeSchedules && completeSchedules.length > 0) {
await cacheService.setSchedulesList(completeSchedules)
console.log(`[Cache] 完整排期列表已重新缓存,数量: ${completeSchedules.length}`)
} else {
console.log('[Cache] 没有排期数据需要缓存')
}
} catch (cacheError) {
console.warn('[Cache] 重新缓存完整排期列表失败,但不影响排期创建:', cacheError)
}
return {
...schedule,
isDraft: isDraft,
publishedAt: isDraft ? null : schedule.publishedAt,
message: isDraft
? '排期草稿保存成功'
: transactionResult.shouldNotify
? notificationSent
? '排期创建成功,通知已发送'
: '排期创建成功,但通知发送失败'
: '排期创建成功'
message: isDraft ? '排期草稿保存成功' : '排期创建成功,通知已发送'
}
} catch (error: any) {
console.error('创建排期失败:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || '创建排期失败'
+57 -200
View File
@@ -1,17 +1,9 @@
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 { playTimes, schedules, songs, songReplayRequests } from '~/drizzle/schema'
import { and, eq, gte, lte, inArray } from 'drizzle-orm'
import { createSongSelectedNotification } from '~~/server/services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
import { getClientIP } from '~~/server/utils/ip-utils'
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) => {
// 检查用户认证和权限
@@ -41,27 +33,6 @@ export default defineEventHandler(async (event) => {
})
}
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()
@@ -76,7 +47,7 @@ export default defineEventHandler(async (event) => {
const playTimeId = body.playTimeId ? parseInt(body.playTimeId) : null
// 获取所有涉及的歌曲详情(用于通知)
const songIds = scheduleItems.map((item) => item.songId)
const songIds = body.songs.map((s: any) => s.songId)
if (songIds.length === 0) {
// 如果列表为空,说明是清空排期,直接执行删除逻辑即可
}
@@ -85,13 +56,6 @@ export default defineEventHandler(async (event) => {
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<{
@@ -99,12 +63,6 @@ export default defineEventHandler(async (event) => {
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) => {
@@ -118,142 +76,43 @@ export default defineEventHandler(async (event) => {
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
// 2. 查找该时间段内已发布的排期(用于避免重复发送通知)
const existingPublished = await tx
.select({
songId: schedules.songId,
isDraft: schedules.isDraft,
replayRequestId: schedules.replayRequestId,
cardCodeId: songs.cardCodeId
songId: schedules.songId
})
.from(schedules)
.leftJoin(songs, eq(schedules.songId, songs.id))
.where(and(...whereConditions))
.where(and(...whereConditions, eq(schedules.isDraft, false)))
// 记录被删除的已发布排期的重播绑定,同歌重发时回写,避免重播标识丢失
const previousReplayBindings = new Map<number, number>()
for (const scheduleToDelete of schedulesToDelete) {
if (!scheduleToDelete.isDraft && scheduleToDelete.replayRequestId) {
previousReplayBindings.set(scheduleToDelete.songId, scheduleToDelete.replayRequestId)
}
}
const existingPublishedSongIds = new Set(existingPublished.map((s) => s.songId))
// 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()
const publishedAt = new Date()
for (const item of scheduleItems) {
const song = songMap.get(item.songId)!
for (const item of body.songs) {
const song = songMap.get(item.songId)
if (!song) {
console.warn(`找不到歌曲 ID: ${item.songId},跳过排期`)
continue
}
// 插入排期
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
await tx.insert(schedules).values({
songId: item.songId,
playDate: playDate,
sequence: item.sequence,
playTimeId: playTimeId,
isDraft: false, // 直接发布
publishedAt: publishedAt,
updatedAt: publishedAt
})
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,
@@ -263,44 +122,42 @@ export default defineEventHandler(async (event) => {
playDate: playDate
}
})
existingPublishedSongIds.add(item.songId)
}
await redeemCardCodeForSchedule(tx, {
songId: song.id,
cardCodeId: song.cardCodeId,
operatorId: user.id,
at: publishedAt
})
// 更新重播申请状态
await tx
.update(songReplayRequests)
.set({ status: 'FULFILLED' })
.where(
and(eq(songReplayRequests.songId, song.id), eq(songReplayRequests.status, 'PENDING'))
)
}
}
})
// 发送重播安排通知
if (replayNotificationsToSend.length > 0) {
event.waitUntil(
Promise.allSettled(
replayNotificationsToSend.map((n) =>
createReplaySongSelectedNotification(n.userId, n.songId, n.songInfo, n.scheduleId)
)
)
)
// 事务成功提交后,清除缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log(`[Cache] 排期缓存和歌曲列表缓存已清除(批量发布排期)`)
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
// 由运行时托管后台通知,避免 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} 成功`
)
})
// 异步发送通知(不阻塞响应)
Promise.allSettled(
notificationsToSend.map((n) =>
createSongSelectedNotification(n.requesterId, n.songId, n.songInfo, clientIP)
)
}
)
.then((results) => {
const successCount = results.filter((r) => r.status === 'fulfilled').length
console.log(
`[Notification] 批量发布通知发送完成: ${successCount}/${notificationsToSend.length} 成功`
)
})
.catch((err) => {
console.error('[Notification] 批量发送通知时发生未捕获错误:', err)
})
console.log(`[Performance] 批量发布排期耗时: ${Date.now() - startTime}ms`)
@@ -317,7 +174,7 @@ export default defineEventHandler(async (event) => {
})
throw createError({
statusCode: error.statusCode || 500,
statusCode: 500,
message: error.message || '发布排期失败'
})
}
@@ -0,0 +1,107 @@
import { db } from '~/drizzle/db'
import { schedules, songReplayRequests, scheduleSongPool } from '~/drizzle/schema'
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
function isValidDateString(dateStr: unknown) {
if (typeof dateStr !== 'string') return false
const trimmed = dateStr.trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return false
const date = new Date(trimmed + 'T00:00:00.000Z')
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === trimmed
}
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '权限不足'
})
}
const body = await readBody(event)
const playDate = body?.playDate
const playTimeId = body?.playTimeId
const songIds = body?.songIds
if (!isValidDateString(playDate)) {
throw createError({ statusCode: 400, message: '播放日期格式必须为YYYY-MM-DD' })
}
if (!Array.isArray(songIds) || !songIds.every((id) => typeof id === 'number' && id > 0)) {
throw createError({ statusCode: 400, message: '歌曲ID列表不能为空且必须为正整数数组' })
}
const playTimeFilterEnabled = playTimeId !== undefined && playTimeId !== null
if (playTimeFilterEnabled && (typeof playTimeId !== 'number' || playTimeId <= 0)) {
throw createError({ statusCode: 400, message: '播放时间ID必须是正整数' })
}
const startOfDay = new Date(playDate + 'T00:00:00.000Z')
const endOfDay = new Date(playDate + 'T23:59:59.999Z')
const result = await db.transaction(async (tx) => {
const whereBase = and(gte(schedules.playDate, startOfDay), lte(schedules.playDate, endOfDay))
const whereCondition = playTimeFilterEnabled
? and(whereBase, eq(schedules.playTimeId, playTimeId))
: whereBase
const existingSchedules = await tx
.select({ id: schedules.id, songId: schedules.songId })
.from(schedules)
.where(whereCondition)
const existingSongIds = existingSchedules.map((s) => s.songId).filter(Boolean) as number[]
await tx.delete(schedules).where(whereCondition)
if (existingSongIds.length > 0) {
await tx
.update(songReplayRequests)
.set({ status: 'PENDING', updatedAt: new Date() })
.where(
and(inArray(songReplayRequests.songId, existingSongIds), eq(songReplayRequests.status, 'FULFILLED'))
)
}
if (songIds.length === 0) {
return { deletedCount: existingSchedules.length, insertedCount: 0 }
}
const rows = songIds.map((songId, index) => ({
songId,
playDate: startOfDay,
sequence: index + 1,
playTimeId: playTimeFilterEnabled ? playTimeId : null,
isDraft: true,
publishedAt: null
}))
const inserted = await tx.insert(schedules).values(rows).returning({ id: schedules.id })
if (songIds.length > 0) {
await tx
.update(scheduleSongPool)
.set({
status: 'ASSIGNED',
targetPlayDate: startOfDay
})
.where(
and(
inArray(scheduleSongPool.songId, songIds),
eq(scheduleSongPool.status, 'PENDING')
)
)
}
return { deletedCount: existingSchedules.length, insertedCount: inserted.length }
})
await Promise.allSettled([cacheService.clearSchedulesCache(), cacheService.clearSongsCache()])
return {
success: true,
...result
}
})
+46 -20
View File
@@ -1,8 +1,8 @@
import { db } from '~/drizzle/db'
import { playTimes, schedules, songs, songReplayRequests } from '~/drizzle/schema'
import { and, desc, eq, gte, lte } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { createApiError } from '~~/server/utils/apiError'
// 输入验证函数
function validateInput(body: any) {
@@ -111,22 +111,39 @@ export default defineEventHandler(async (event) => {
}
}
// 校验重播申请绑定(草稿仅记录绑定关系,不修改申请状态)
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 existingScheduleResult = await tx
.select()
.from(schedules)
.where(eq(schedules.songId, body.songId))
.limit(1)
const replayRequest = replayRequestResult[0]
if (!replayRequest || replayRequest.songId !== song.id || replayRequest.status !== 'PENDING') {
throw createApiError(400, 'SONG_REPLAY_INVALID_REQUEST', '重播申请无效或已被处理')
const existingSchedule = existingScheduleResult[0]
if (existingSchedule) {
// 删除现有排期或草稿
await tx.delete(schedules).where(eq(schedules.id, existingSchedule.id))
// 恢复该歌曲的重播申请状态为 PENDING
// 只恢复状态为 FULFILLED 的申请
const updatedRequests = await tx
.update(songReplayRequests)
.set({
status: 'PENDING',
updatedAt: new Date()
})
.where(
and(
eq(songReplayRequests.songId, body.songId),
eq(songReplayRequests.status, 'FULFILLED')
)
)
.returning()
if (updatedRequests.length > 0) {
console.log(
`恢复了 ${updatedRequests.length} 个重播申请状态为 PENDING(草稿保存时删除旧排期)`
)
}
replayRequestId = replayRequest.id
}
// 获取序号,如果未提供则查找当天最大序号+1
@@ -149,8 +166,9 @@ export default defineEventHandler(async (event) => {
.orderBy(desc(schedules.sequence))
.limit(1)
const latestSchedule = sameDaySchedules[0]
if (latestSchedule) sequence = (latestSchedule.sequence || 0) + 1
if (sameDaySchedules.length > 0) {
sequence = (sameDaySchedules[0].sequence || 0) + 1
}
}
// 解析输入的日期字符串,确保日期正确
@@ -168,7 +186,6 @@ export default defineEventHandler(async (event) => {
playDate: playDate,
sequence: sequence,
playTimeId: body.playTimeId || null,
replayRequestId,
isDraft: true, // 标记为草稿
publishedAt: null // 草稿状态下没有发布时间
})
@@ -180,12 +197,21 @@ export default defineEventHandler(async (event) => {
id: song.id,
title: song.title,
artist: song.artist,
requesterId: song.requesterId,
cardCodeId: song.cardCodeId
requesterId: song.requesterId
}
}
})
// 清除相关缓存(在事务外执行,避免影响事务性能)
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(保存草稿)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
// 缓存清除失败不应该影响主要操作的成功
}
const responseData = {
...result.schedule,
song: result.song,
+52 -114
View File
@@ -8,7 +8,7 @@ import {
songCollaborators,
songReplayRequests
} from '~/drizzle/schema'
import { and, asc, count, countDistinct, eq, gte, lt, inArray, desc } from 'drizzle-orm'
import { and, asc, count, eq, gte, lt, inArray, desc } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户身份验证和权限
@@ -79,7 +79,6 @@ export default defineEventHandler(async (event) => {
playTimeId: schedules.playTimeId,
isDraft: schedules.isDraft,
publishedAt: schedules.publishedAt,
replayRequestId: schedules.replayRequestId,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist,
@@ -88,8 +87,6 @@ export default defineEventHandler(async (event) => {
songCover: songs.cover,
songMusicPlatform: songs.musicPlatform,
songMusicId: songs.musicId,
songDurationSeconds: songs.durationSeconds,
songCardCodeId: songs.cardCodeId,
songSemester: songs.semester,
songCreatedAt: songs.createdAt,
requesterName: users.name,
@@ -180,11 +177,11 @@ export default defineEventHandler(async (event) => {
const replayRequestersMap = new Map()
if (songIds.length > 0) {
// 获取每首歌的重播申请数量(统计 PENDING 和 FULFILLED 状态,按申请人去重)
// 获取每首歌的重播申请数量(统计 PENDING 和 FULFILLED 状态)
const replayCountsData = await db
.select({
songId: songReplayRequests.songId,
count: countDistinct(songReplayRequests.userId)
count: count(songReplayRequests.id)
})
.from(songReplayRequests)
.where(
@@ -210,10 +207,7 @@ export default defineEventHandler(async (event) => {
class: users.class
},
status: songReplayRequests.status,
createdAt: songReplayRequests.createdAt,
preferredPlayTimeId: songReplayRequests.preferredPlayTimeId,
submissionNote: songReplayRequests.submissionNote,
submissionNotePublic: songReplayRequests.submissionNotePublic
createdAt: songReplayRequests.createdAt
})
.from(songReplayRequests)
.innerJoin(users, eq(songReplayRequests.userId, users.id))
@@ -225,19 +219,12 @@ export default defineEventHandler(async (event) => {
)
.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
}
// 只保留前5个
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 || '未知用户',
@@ -249,69 +236,36 @@ export default defineEventHandler(async (event) => {
})
}
// 获取重播申请元数据(按 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 requesterClassParts: string[] = []
if (schedule.requesterGrade) requesterClassParts.push(String(schedule.requesterGrade))
if (schedule.requesterClass) requesterClassParts.push(String(schedule.requesterClass))
const requesterClassInfo = requesterClassParts.join(' ').trim()
if (requesterClassInfo && requesterName) {
requesterName = `${requesterClassInfo} ${requesterName}`
} else if (requesterClassInfo) {
requesterName = requesterClassInfo
}
// 辅助函数:格式化显示名称 (用于联合投稿人)
const formatDisplayName = (userObj: any) => {
if (!userObj || !userObj.name) return '未知用户'
let displayName = userObj.name
const formatClassInfo = (userObj: any) => {
if (!userObj) return ''
const parts: string[] = []
if (userObj.grade) parts.push(String(userObj.grade))
if (userObj.class) parts.push(String(userObj.class))
return parts.join(' ').trim()
}
const 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 formatDisplayName = (userObj: any) => {
if (!userObj) return '未知用户'
const name = userObj.name ? String(userObj.name).trim() : ''
const classInfo = formatClassInfo(userObj)
if (!name) return classInfo || '未知用户'
return classInfo ? `${classInfo} ${name}` : name
}
const collaborators = collaboratorsMap.get(schedule.songId) || []
@@ -324,11 +278,8 @@ export default defineEventHandler(async (event) => {
}))
// 获取重播申请信息
const linkedReplayRequestId = schedule.replayRequestId || null
const replayRequestCount = linkedReplayRequestId
? replayRequestCountsMap.get(schedule.songId) || 0
: 0
const replayRequesters = linkedReplayRequestId ? replayRequestersMap.get(schedule.songId) || [] : []
const replayRequestCount = replayRequestCountsMap.get(schedule.songId) || 0
const replayRequesters = replayRequestersMap.get(schedule.songId) || []
const formattedReplayRequesters = replayRequesters.map((r: any) => ({
id: r.id,
name: r.name,
@@ -338,8 +289,8 @@ export default defineEventHandler(async (event) => {
status: r.status
}))
// 只有正式绑定了重播申请的排期才按重播展示;草稿排期不提前暴露申请信息
const isReplaySong = linkedReplayRequestId !== null
// 判断是否为重播:有重播申请(PENDING 或 FULFILLED)
const isReplaySong = replayRequestCount > 0
return {
id: schedule.id,
@@ -347,7 +298,6 @@ export default defineEventHandler(async (event) => {
updatedAt: schedule.updatedAt,
playDate: dateOnly.toISOString().split('T')[0],
sequence: schedule.sequence || 1,
replayRequestId: linkedReplayRequestId,
played: schedule.played || false,
playTimeId: schedule.playTimeId,
// 草稿状态信息
@@ -363,39 +313,27 @@ export default defineEventHandler(async (event) => {
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
}
})()
song: {
id: schedule.songId,
title: schedule.songTitle,
artist: schedule.songArtist,
requester: requesterName,
requesterId: schedule.songRequesterId,
requesterGrade: schedule.requesterGrade || null,
requesterClass: schedule.requesterClass || null,
collaborators: formattedCollaborators,
voteCount: voteCountMap.get(schedule.songId) || 0,
played: schedule.songPlayed || false,
cover: schedule.songCover || null,
musicPlatform: schedule.songMusicPlatform || null,
musicId: schedule.songMusicId || null,
semester: schedule.songSemester || null,
createdAt: schedule.songCreatedAt,
// 重播申请信息
replayRequestCount: isReplaySong ? replayRequestCount : 0,
replayRequesters: isReplaySong ? formattedReplayRequesters : [],
isReplay: isReplaySong // 只有已播放过且有重播申请的才标记为重播
}
}
})
+13 -4
View File
@@ -1,16 +1,16 @@
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { schedules, songs } from '~/drizzle/schema'
import { cacheService } from '~~/server/services/cacheService'
import { createSystemNotification } from '~~/server/services/notificationService'
import { getClientIP } from '~~/server/utils/ip-utils'
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: '需要歌曲管理员及以上权限'
statusMessage: '需要歌曲管理员及以上权限'
})
}
@@ -47,6 +47,7 @@ export default defineEventHandler(async (event) => {
}
try {
const clientIP = getClientIP(event)
const moveResult = await db.transaction(async (tx) => {
const existingOnToDate = await tx
.select({ id: schedules.id })
@@ -80,7 +81,7 @@ export default defineEventHandler(async (event) => {
}
const scheduleIds = sourceSchedules.map((item) => item.id)
const updateTime = getServerDate()
const updateTime = new Date()
const movedSchedules = await tx
.update(schedules)
.set({
@@ -100,7 +101,7 @@ export default defineEventHandler(async (event) => {
const notificationsToSend = moveResult.movedSongs.map((item) => {
const message = `您投稿的歌曲《${item.songTitle}》原定于 ${fromDate} 播放,已调整至 ${toDate}。`
return createSystemNotification(item.requesterId, '排期调整通知', message)
return createSystemNotification(item.requesterId, '排期调整通知', message, clientIP)
})
if (notificationsToSend.length > 0) {
@@ -109,6 +110,14 @@ export default defineEventHandler(async (event) => {
})
}
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(迁移排期日期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
fromDate,
+206 -109
View File
@@ -1,10 +1,10 @@
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 { playTimes, schedules, songs, users, votes, songReplayRequests } from '~/drizzle/schema'
import { and, asc, count, eq } from 'drizzle-orm'
import { createSongSelectedNotification } from '~~/server/services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
import { getBeijingTimestamp } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
@@ -44,13 +44,11 @@ export default defineEventHandler(async (event) => {
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
requesterId: songs.requesterId
}
})
.from(schedules)
@@ -72,120 +70,219 @@ export default defineEventHandler(async (event) => {
})
}
// 使用事务包装更新排期和重播申请状态操作,保证原子性
const publishResult = await db.transaction(async (tx) => {
// 更新草稿为已发布状态
const publishedAt = new Date(getBeijingTimestamp())
// 更新草稿为已发布状态
const publishedAt = new Date(getBeijingTimestamp())
const publishResult = await tx
.update(schedules)
.set({
isDraft: false,
publishedAt: publishedAt,
updatedAt: publishedAt
const publishResult = await db
.update(schedules)
.set({
isDraft: false,
publishedAt: publishedAt,
updatedAt: publishedAt
})
.where(eq(schedules.id, body.scheduleId))
.returning()
const publishedSchedule = publishResult[0]
if (!publishedSchedule) {
throw createError({
statusCode: 500,
message: '发布排期失败'
})
}
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 发布后发送通知(这是与草稿保存的主要区别)
await createSongSelectedNotification(
draft.song.requesterId,
draft.song.id,
{
title: draft.song.title,
artist: draft.song.artist,
playDate: draft.playDate
},
clientIP
)
// 将该歌曲的所有待处理重播申请标记为已完成
const updatedRequests = await db
.update(songReplayRequests)
.set({
status: 'FULFILLED',
updatedAt: new Date()
})
.where(
and(eq(songReplayRequests.songId, draft.song.id), eq(songReplayRequests.status, 'PENDING'))
)
.returning()
if (updatedRequests.length > 0) {
console.log(`发布排期:将 ${updatedRequests.length} 个重播申请标记为 FULFILLED`)
}
// 清除相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(发布排期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
// 重新缓存完整的排期列表
try {
// 获取所有已发布的排期数据
const schedulesData = await db
.select({
id: schedules.id,
playDate: schedules.playDate,
sequence: schedules.sequence,
played: schedules.played,
playTimeId: schedules.playTimeId,
isDraft: schedules.isDraft,
publishedAt: schedules.publishedAt,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId,
songPlayed: songs.played,
songCover: songs.cover,
songMusicPlatform: songs.musicPlatform,
songMusicId: songs.musicId,
songSemester: songs.semester,
requesterName: users.name,
requesterGrade: users.grade,
requesterClass: users.class,
playTimeName: playTimes.name,
playTimeStartTime: playTimes.startTime,
playTimeEndTime: playTimes.endTime,
playTimeEnabled: playTimes.enabled
})
.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)
.innerJoin(songs, eq(schedules.songId, songs.id))
.innerJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(eq(schedules.isDraft, false)) // 只缓存已发布的排期
.orderBy(asc(schedules.playDate))
const 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
// 获取所有用户的姓名列表,用于检测同名用户
const allUsers = await db
.select({
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
})
} catch (notificationError) {
notificationSent = false
console.error('排期已发布,但发送歌曲入选通知失败:', notificationError)
}
}
.from(users)
const publishedSchedule = publishResult.schedule
// 创建姓名到用户数组的映射
const nameToUsers = new Map()
allUsers.forEach((user) => {
if (user.name) {
if (!nameToUsers.has(user.name)) {
nameToUsers.set(user.name, [])
}
nameToUsers.get(user.name).push(user)
}
})
// 获取每首歌的投票数
const songIds = schedulesData.map((s) => s.songId)
const voteCounts = await Promise.all(
songIds.map(async (songId) => {
const voteCountResult = await db
.select({ count: count() })
.from(votes)
.where(eq(votes.songId, songId))
return { songId, count: voteCountResult[0].count }
})
)
const voteCountMap = new Map(voteCounts.map((v) => [v.songId, v.count]))
// 转换数据格式
const formattedSchedules = schedulesData.map((schedule) => {
const dateOnly = schedule.playDate
// 处理投稿人姓名,如果是同名用户则添加后缀
let requesterName = schedule.requesterName || '未知用户'
const sameNameUsers = nameToUsers.get(requesterName)
if (sameNameUsers && sameNameUsers.length > 1) {
if (schedule.requesterGrade) {
const sameGradeUsers = sameNameUsers.filter(
(u: {
id: number
name: string | null
grade: string | null
class: string | null
}) => u.grade === schedule.requesterGrade
)
if (sameGradeUsers.length > 1 && schedule.requesterClass) {
requesterName = `${requesterName}(${schedule.requesterGrade} ${schedule.requesterClass})`
} else {
requesterName = `${requesterName}(${schedule.requesterGrade})`
}
}
}
return {
id: schedule.id,
playDate: dateOnly.toISOString().split('T')[0],
sequence: schedule.sequence || 1,
played: schedule.played || false,
playTimeId: schedule.playTimeId,
isDraft: schedule.isDraft,
publishedAt: schedule.publishedAt,
playTime: schedule.playTimeName
? {
id: schedule.playTimeId,
name: schedule.playTimeName,
startTime: schedule.playTimeStartTime,
endTime: schedule.playTimeEndTime,
enabled: schedule.playTimeEnabled
}
: null,
song: {
id: schedule.songId,
title: schedule.songTitle,
artist: schedule.songArtist,
requester: requesterName,
voteCount: voteCountMap.get(schedule.songId) || 0,
played: schedule.songPlayed || false,
cover: schedule.songCover || null,
musicPlatform: schedule.songMusicPlatform || null,
musicId: schedule.songMusicId || null,
semester: schedule.songSemester || null
}
}
})
const completeSchedules = formattedSchedules
// 重新缓存完整的排期列表
if (completeSchedules && completeSchedules.length > 0) {
await cacheService.setSchedulesList(completeSchedules)
console.log(`[Cache] 已发布排期列表已重新缓存,数量: ${completeSchedules.length}`)
} else {
console.log('[Cache] 没有已发布排期数据需要缓存')
}
} catch (cacheError) {
console.warn('[Cache] 重新缓存完整排期列表失败,但不影响排期发布:', cacheError)
}
return {
...draft,
...publishedSchedule,
song: draft.song,
isDraft: false,
publishedAt: publishedSchedule.publishedAt,
// 绑定在 fulfill 中回写,.returning() 快照早于绑定,需显式覆盖
replayRequestId: publishResult.replayRequestId,
message: publishResult.shouldNotify
? notificationSent
? '排期发布成功,通知已发送'
: '排期发布成功,但通知发送失败'
: '排期发布成功'
publishedAt: publishedAt,
message: '排期发布成功,通知已发送'
}
} catch (error: any) {
console.error('发布排期失败:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || '发布排期失败'
+40 -64
View File
@@ -1,7 +1,5 @@
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'
import { db, eq, schedules, songs, songReplayRequests, and } from '~/drizzle/db'
import { cacheService } from '~~/server/services/cacheService'
export default defineEventHandler(async (event) => {
// 验证管理员权限
@@ -9,7 +7,7 @@ export default defineEventHandler(async (event) => {
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要歌曲管理员及以上权限'
statusMessage: '需要歌曲管理员及以上权限'
})
}
@@ -20,7 +18,7 @@ export default defineEventHandler(async (event) => {
if (!scheduleId) {
throw createError({
statusCode: 400,
message: '缺少排期 ID'
statusMessage: '缺少排期ID'
})
}
@@ -33,8 +31,6 @@ export default defineEventHandler(async (event) => {
.select({
id: schedules.id,
songId: schedules.songId,
isDraft: schedules.isDraft,
cardCodeId: songs.cardCodeId,
songTitle: songs.title,
songArtist: songs.artist
})
@@ -54,69 +50,49 @@ export default defineEventHandler(async (event) => {
console.log(`找到排期 ID=${scheduleIdNumber}, 歌曲=${existingSchedule.songTitle || '未知歌曲'}`)
// 使用事务包装删除和重播状态更新操作
const result = await db.transaction(async (tx) => {
// 删除排期
const deletedSchedule = await tx
.delete(schedules)
.where(eq(schedules.id, scheduleIdNumber))
// 删除排期
const deletedSchedule = await db
.delete(schedules)
.where(eq(schedules.id, scheduleIdNumber))
.returning()
console.log(`成功删除排期 ID=${scheduleIdNumber}`)
// 恢复该歌曲的重播申请状态为 PENDING
// 只恢复状态不是 PENDING 的申请(可能是 FULFILLED 或其他状态)
if (existingSchedule.songId) {
const updatedRequests = await db
.update(songReplayRequests)
.set({
status: 'PENDING',
updatedAt: new Date()
})
.where(
and(
eq(songReplayRequests.songId, existingSchedule.songId),
// 不恢复已拒绝的申请
eq(songReplayRequests.status, 'FULFILLED')
)
)
.returning()
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(`该歌曲仍有其他正式排期,不恢复重播申请状态`)
}
if (updatedRequests.length > 0) {
console.log(`恢复了 ${updatedRequests.length} 个重播申请状态为 PENDING`)
}
}
return deletedSchedule
})
// 清除相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache() // 清除歌曲列表缓存,确保scheduled状态更新
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(移除排期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
schedule: result
schedule: deletedSchedule
}
} catch (error: any) {
console.error('移除排期失败:', error)
+22 -32
View File
@@ -1,5 +1,6 @@
import { db, eq } from '~/drizzle/db'
import { schedules } from '~/drizzle/schema'
import { cacheService } from '~~/server/services/cacheService'
export default defineEventHandler(async (event) => {
// 验证用户认证和权限
@@ -7,48 +8,39 @@ export default defineEventHandler(async (event) => {
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要歌曲管理员及以上权限'
statusMessage: '需要歌曲管理员及以上权限'
})
}
try {
const body = await readBody(event)
const scheduleUpdates = body.schedules
const { schedules } = body
if (!Array.isArray(scheduleUpdates) || scheduleUpdates.length === 0) {
if (!schedules || !Array.isArray(schedules) || schedules.length === 0) {
throw createError({
statusCode: 400,
message: '缺少排期数据'
statusMessage: '缺少排期数据'
})
}
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 })
)
)
const results = await Promise.all(
schedules.map(async (item) => {
return db
.update(schedules)
.set({ sequence: item.sequence })
.where(eq(schedules.id, Number(item.id)))
})
)
if (updatedRows.some((rows) => rows.length === 0)) {
throw createError({ statusCode: 404, message: '部分排期不存在,请刷新后重试' })
}
return updatedRows
})
// 清除排期相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(更新排期顺序)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
@@ -57,11 +49,9 @@ export default defineEventHandler(async (event) => {
} catch (error: any) {
console.error('更新排期顺序失败:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || '更新排期顺序失败'
statusMessage: error.message || '更新排期顺序失败'
})
}
})
+6 -6
View File
@@ -6,16 +6,16 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
statusMessage: '需要管理员权限'
})
}
// 获取学期 ID
// 获取学期ID
const semesterId = parseInt(getRouterParam(event, 'id') || '0')
if (!semesterId || isNaN(semesterId)) {
throw createError({
statusCode: 400,
message: '无效的学期 ID'
statusMessage: '无效的学期ID'
})
}
@@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
if (!semester) {
throw createError({
statusCode: 404,
message: '学期不存在'
statusMessage: '学期不存在'
})
}
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
if (semester.isActive) {
throw createError({
statusCode: 400,
message: '不能删除当前活跃的学期'
statusMessage: '不能删除当前活跃的学期'
})
}
@@ -49,7 +49,7 @@ export default defineEventHandler(async (event) => {
if (songCount > 0) {
throw createError({
statusCode: 400,
message: `该学期下还有 ${songCount} 首歌曲,无法删除`
statusMessage: `该学期下还有 ${songCount} 首歌曲,无法删除`
})
}
+3 -4
View File
@@ -21,9 +21,8 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event)
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (!name) {
if (!body.name) {
throw createError({
statusCode: 400,
message: '学期名称不能为空'
@@ -34,7 +33,7 @@ export default defineEventHandler(async (event) => {
const existingSemesterResult = await db
.select()
.from(semesters)
.where(eq(semesters.name, name))
.where(eq(semesters.name, body.name))
.limit(1)
const existingSemester = existingSemesterResult[0]
@@ -62,7 +61,7 @@ export default defineEventHandler(async (event) => {
const semesterResult = await db
.insert(semesters)
.values({
name,
name: body.name,
isActive: shouldBeActive || false
})
.returning()
+1 -2
View File
@@ -19,8 +19,7 @@ export default defineEventHandler(async (event) => {
try {
const smtpService = SmtpService.getInstance()
// 手动重载必须强制刷新,否则 transporter 已存在时直接早退
const initialized = await smtpService.initializeSmtpConfig(true)
const initialized = await smtpService.initializeSmtpConfig()
return {
success: true,
+5 -15
View File
@@ -1,6 +1,4 @@
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) => {
// 检查请求方法
@@ -54,19 +52,11 @@ export default defineEventHandler(async (event) => {
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密码,请重新填写授权码并保存配置'
}
}
// 如果密码是掩码,则使用原始配置中的密码
const testPassword =
body.smtpPassword === '****************'
? originalConfig?.auth?.pass || body.smtpPassword
: body.smtpPassword
// 设置测试配置
smtpService.smtpConfig = {
+5 -15
View File
@@ -1,7 +1,5 @@
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) => {
// 检查请求方法
@@ -62,19 +60,11 @@ export default defineEventHandler(async (event) => {
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密码,请重新填写授权码并保存配置'
}
}
// 如果密码是掩码,则使用原始配置中的密码
const testPassword =
body.smtpPassword === '****************'
? originalConfig?.auth?.pass || body.smtpPassword
: body.smtpPassword
// 设置测试配置
smtpService.smtpConfig = {
+101
View File
@@ -0,0 +1,101 @@
import { db } from '~/drizzle/db'
import { songs, requestTimes } from '~/drizzle/schema'
import { eq, sql } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
import { createSongRejectedNotification } from '../../../services/notificationService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
const body = await readBody(event)
if (!body.songId || !['approved', 'rejected'].includes(body.status)) {
throw createError({
statusCode: 400,
message: '参数错误'
})
}
try {
let songForNotification = null
const result = await db.transaction(async (tx) => {
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createError({
statusCode: 404,
message: '歌曲不存在'
})
}
songForNotification = song
if (body.status === 'rejected') {
// 如果有 hitRequestId,减少对应时段的已接纳数量
if (song.hitRequestId) {
try {
await tx
.update(requestTimes)
.set({
accepted: sql`GREATEST(0, accepted - 1)`
})
.where(eq(requestTimes.id, song.hitRequestId))
} catch (error) {
console.error(`减少投稿时段接纳数量失败: ${error.message}`)
}
}
const note = song.submissionNote ? `[审核未通过] ${body.reason || ''}\n${song.submissionNote}` : `[审核未通过] ${body.reason || ''}`
// 释放排期序号并更新备注
const updatedSong = await tx.update(songs).set({
expectedPlayDate: null,
expectedPlaySequence: null,
hitRequestId: null,
submissionNote: note
}).where(eq(songs.id, body.songId)).returning()
return { success: true, song: updatedSong[0] }
} else {
const note = song.submissionNote ? `[审核已通过]\n${song.submissionNote}` : `[审核已通过]`
const updatedSong = await tx.update(songs).set({
submissionNote: note
}).where(eq(songs.id, body.songId)).returning()
return { success: true, song: updatedSong[0] }
}
})
if (body.status === 'rejected' && songForNotification && songForNotification.requesterId) {
createSongRejectedNotification(
songForNotification.requesterId,
{ title: songForNotification.title, artist: songForNotification.artist },
body.reason?.trim() || '未过审释放排期',
getClientIP(event)
).catch(e => console.error(e))
}
await cacheService.clearSongsCache()
return {
success: true,
message: body.status === 'rejected' ? '已驳回并释放排期' : '审核已通过',
data: result.song
}
} catch (error) {
console.error('审核歌曲失败:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: '审核失败: ' + error.message
})
}
})
+135
View File
@@ -0,0 +1,135 @@
import { db } from '~/drizzle/db'
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
import { createSongRejectedNotification } from '../../../services/notificationService'
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
const MAX_BATCH_REJECT_COUNT = 1000
export default defineEventHandler(async (event) => {
const user = requireSongAdmin(event)
const body = await readBody(event)
const songIds = Array.isArray(body?.songIds)
? [...new Set(body.songIds.filter((id: any) => Number.isInteger(id) && id > 0))]
: []
const reason = typeof body?.reason === 'string' ? body.reason.trim() : ''
const addToBlacklist = body?.addToBlacklist === true
if (songIds.length === 0) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_BATCH_REJECT_IDS_REQUIRED, '歌曲ID列表不能为空')
}
if (songIds.length > MAX_BATCH_REJECT_COUNT) {
throw createApiError(
400,
SERVER_ERROR_CODES.SONG_BATCH_REJECT_LIMIT_EXCEEDED,
`单次最多驳回 ${MAX_BATCH_REJECT_COUNT} 首歌曲`,
{ params: [MAX_BATCH_REJECT_COUNT] }
)
}
if (!reason) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_BATCH_REJECT_REASON_REQUIRED, '驳回原因不能为空')
}
try {
let rejectedSongs: any[] = []
await db.transaction(async (tx) => {
const existingSongs = await tx.select().from(songs).where(inArray(songs.id, songIds))
if (existingSongs.length === 0) {
throw createApiError(404, SERVER_ERROR_CODES.SONG_NOT_FOUND, '歌曲不存在或已被删除')
}
const existingIds = existingSongs.map((song) => song.id)
console.log(`开始批量驳回歌曲,共 ${existingIds.length} 首`)
if (addToBlacklist) {
const blacklistValues = [...new Set(existingSongs.map((song: any) => `${song.title} - ${song.artist}`))]
const existingBlacklist = await tx
.select({ value: songBlacklists.value })
.from(songBlacklists)
.where(and(eq(songBlacklists.type, 'SONG'), inArray(songBlacklists.value, blacklistValues)))
const existingSet = new Set(existingBlacklist.map((item) => item.value))
const valuesToInsert = blacklistValues.filter((value) => !existingSet.has(value))
for (const value of valuesToInsert) {
try {
await tx.insert(songBlacklists).values({
type: 'SONG',
value,
reason: `歌曲驳回: ${reason}`,
createdBy: user.id
})
} catch (error: any) {
console.log(`黑名单添加失败,可能已存在: ${error.message}`)
}
}
console.log(`黑名单添加处理完成`)
}
await tx.delete(votes).where(inArray(votes.songId, existingIds))
console.log(`删除了投票记录`)
await tx.delete(schedules).where(inArray(schedules.songId, existingIds))
console.log(`删除了排期记录`)
const hitRequestIds = [...new Set(existingSongs.map((song: any) => song.hitRequestId).filter(Boolean))]
for (const hitRequestId of hitRequestIds) {
const count = existingSongs.filter((song: any) => song.hitRequestId === hitRequestId).length
try {
await tx
.update(requestTimes)
.set({
accepted: sql`GREATEST(0, accepted - ${count})`
})
.where(eq(requestTimes.id, hitRequestId as any))
} catch (error: any) {
console.error(`减少投稿时段 ${hitRequestId} 接纳数量失败: ${error.message}`)
}
}
rejectedSongs = await tx.delete(songs).where(inArray(songs.id, existingIds)).returning()
console.log(`歌曲批量删除完成`)
})
const songsToNotify = rejectedSongs.filter((song) => song.requesterId)
for (const song of songsToNotify) {
createSongRejectedNotification(
song.requesterId,
{ title: song.title, artist: song.artist },
reason
).catch((error: any) => {
console.error(`发送驳回通知失败: ${error.message}`)
})
}
await cacheService.clearSongsCache()
await cacheService.clearSchedulesCache()
console.log(`批量驳回操作完成,共 ${rejectedSongs.length} 首`)
return {
success: true,
message: '歌曲批量驳回成功',
data: {
rejected: rejectedSongs.length,
missing: songIds.length - rejectedSongs.length,
notificationSent: songsToNotify.length
}
}
} catch (error: any) {
console.error('批量驳回歌曲失败:', error)
if (error.statusCode) {
throw error
}
throw createApiError(500, SERVER_ERROR_CODES.SONG_OPERATION_FAILED, '批量驳回失败: ' + error.message)
}
})
+12
View File
@@ -1,6 +1,7 @@
import { db } from '~/drizzle/db'
import { schedules, songs, votes, requestTimes } from '~/drizzle/schema'
import { eq, sql } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
@@ -74,6 +75,17 @@ export default defineEventHandler(async (event) => {
}
})
// 清除相关缓存
try {
await cacheService.clearSongsCache()
if (result.deletedSchedules) {
await cacheService.clearSchedulesCache()
}
console.log('[Cache] 歌曲和排期缓存已清除(删除歌曲)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return result
} catch (error: any) {
console.error('删除歌曲时发生错误:', error)
+56 -66
View File
@@ -1,17 +1,10 @@
import { db } from '~/drizzle/db'
import { createSongPlayedNotification } from '~~/server/services/notificationService'
import { CacheService } from '~~/server/services/cacheService'
import { songs, songReplayRequests } from '~/drizzle/schema'
import { eq, and, inArray } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
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) => {
// 检查用户认证
@@ -25,7 +18,7 @@ export default defineEventHandler(async (event) => {
}
// 检查是否是管理员
if (!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员可以标记歌曲为已播放'
@@ -33,24 +26,15 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event)
const validatedDataResult = markPlayedSchema.safeParse(body)
if (!validatedDataResult.success) {
throw createError({
statusCode: 400,
message: validatedDataResult.error.issues[0]?.message || '请求参数无效'
})
let songIds: number[] = []
if (body.songId) {
songIds = [body.songId]
} else if (body.songIds && Array.isArray(body.songIds)) {
songIds = body.songIds
}
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,
@@ -58,15 +42,19 @@ export default defineEventHandler(async (event) => {
})
}
// 撤回已播放
const isUnmark = validatedData.unmark === true
// 检查是否是撤回操作
const isUnmark = body.unmark === true
// 过滤需要更新的歌曲
// 构建更新条件:只更新状态不符合预期的歌曲
// 如果是标记为已播放(!isUnmark),则只更新 played = false 的歌曲
// 如果是撤回(isUnmark),则只更新 played = true 的歌曲
const condition = !isUnmark
? and(inArray(songs.id, songIds), eq(songs.played, false))
: and(inArray(songs.id, songIds), eq(songs.played, true))
// 使用事务确保数据一致性
const { updatedSongsResult, updatedSongIds } = await db.transaction(async (tx) => {
// 更新歌曲状态
const updatedSongsResult = await tx
.update(songs)
.set({
@@ -74,58 +62,60 @@ export default defineEventHandler(async (event) => {
playedAt: isUnmark ? null : getBeijingTime()
})
.where(condition)
.returning({ id: songs.id })
.returning()
const updatedSongIds = updatedSongsResult.map((s) => s.id)
// 获取实际更新的歌曲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 })
// 根据操作类型更新重播申请状态
const targetStatus = !isUnmark ? 'PENDING' : 'FULFILLED'
const newStatus = !isUnmark ? 'FULFILLED' : 'PENDING'
if (updatedRequests.length > 0) {
console.log(`将 ${updatedRequests.length} 个重播申请状态更新为 FULFILLED(歌曲已播放)`)
}
} else {
// 撤回已播放:每人仅恢复最新一条,避免违反部分唯一索引
const restoredCount = await restoreReplayRequestsToPending({
tx,
songIds: updatedSongIds,
at: new Date()
const updatedRequests = await tx
.update(songReplayRequests)
.set({
status: newStatus,
updatedAt: new Date()
})
.where(
and(inArray(songReplayRequests.songId, updatedSongIds), eq(songReplayRequests.status, targetStatus))
)
.returning()
if (restoredCount > 0) {
console.log(`将 ${restoredCount} 个重播申请状态恢复为 PENDING(撤回已播放)`)
}
if (updatedRequests.length > 0) {
const logMessage = !isUnmark
? `将 ${updatedRequests.length} 个重播申请状态更新为 FULFILLED(歌曲已播放)`
: `将 ${updatedRequests.length} 个重播申请状态恢复为 PENDING(撤回已播放)`
console.log(logMessage)
}
}
return { updatedSongsResult, updatedSongIds }
})
// 异步发送通知
// 清除歌曲和排期相关缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
await cacheService.clearSchedulesCache()
} catch (error) {
console.error('清除缓存失败:', error)
}
// 如果是标记为已播放,则异步发送通知(不阻塞响应)
if (!isUnmark && updatedSongIds.length > 0) {
event.waitUntil(
Promise.allSettled(
updatedSongIds.map((songId) =>
createSongPlayedNotification(songId).catch((err) => {
console.error(`发送歌曲(${songId})已播放通知失败:`, err)
})
)
)
)
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 异步发送通知,不等待完成
setImmediate(() => {
Promise.allSettled(updatedSongIds.map(songId =>
createSongPlayedNotification(songId, clientIP).catch((err) => {
console.error(`发送歌曲(${songId})已播放通知失败:`, err)
})
))
})
}
return {
+7 -1
View File
@@ -1,6 +1,7 @@
import { db } from '~/drizzle/db'
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
import { eq, sql } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
import { createSongRejectedNotification } from '../../../services/notificationService'
export default defineEventHandler(async (event) => {
@@ -108,7 +109,8 @@ export default defineEventHandler(async (event) => {
createSongRejectedNotification(
songForNotification.requesterId,
{ title: songForNotification.title, artist: songForNotification.artist },
body.reason.trim()
body.reason.trim(),
getClientIP(event)
)
.then(() => {
console.log(`已发送驳回通知给用户: ${songForNotification.requesterId}`)
@@ -120,6 +122,10 @@ export default defineEventHandler(async (event) => {
notificationSent = true
}
// 清除相关缓存
await cacheService.clearSongsCache()
await cacheService.clearSchedulesCache()
console.log(`歌曲驳回操作完成: ${body.songId}`)
return {
+25 -18
View File
@@ -1,7 +1,8 @@
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 { and, count, eq, gte, lt } from 'drizzle-orm'
import { cacheService } from '../../services/cacheService'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import timezone from 'dayjs/plugin/timezone.js'
@@ -14,7 +15,7 @@ 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)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
@@ -25,6 +26,12 @@ export default defineEventHandler(async (event) => {
const semester = query.semester as string
try {
// 尝试从缓存获取数据
const cachedStats = await cacheService.getAdminStats(semester)
if (cachedStats) {
console.log('[Cache] 管理员统计数据缓存命中')
return cachedStats
}
// 获取当前时间相关的日期
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
@@ -69,12 +76,9 @@ export default defineEventHandler(async (event) => {
return result[0].count
})(),
// 总用户数(排除待审核用户)
// 总用户数
(async () => {
const result = await db
.select({ count: count() })
.from(users)
.where(ne(users.status, 'pending'))
const result = await db.select({ count: count() }).from(users)
return result[0].count
})(),
@@ -170,27 +174,21 @@ export default defineEventHandler(async (event) => {
return result[0].count
})(),
// 本周新增用户(排除待审核)
// 本周新增用户
(async () => {
const result = await db
.select({ count: count() })
.from(users)
.where(and(gte(users.createdAt, weekAgo), ne(users.status, 'pending')))
.where(gte(users.createdAt, weekAgo))
return result[0].count
})(),
// 上周新增用户(排除待审核)
// 上周新增用户
(async () => {
const result = await db
.select({ count: count() })
.from(users)
.where(
and(
gte(users.createdAt, twoWeeksAgo),
lt(users.createdAt, weekAgo),
ne(users.status, 'pending')
)
)
.where(and(gte(users.createdAt, twoWeeksAgo), lt(users.createdAt, weekAgo)))
return result[0].count
})(),
@@ -264,7 +262,7 @@ export default defineEventHandler(async (event) => {
const result = await db
.select({ count: count() })
.from(users)
.where(and(gte(users.createdAt, startOfDay), lt(users.createdAt, endOfDay), ne(users.status, 'pending')))
.where(and(gte(users.createdAt, startOfDay), lt(users.createdAt, endOfDay)))
const countValue = result[0].count
trends.push({
@@ -355,6 +353,15 @@ export default defineEventHandler(async (event) => {
requestsTrend: trendData[3]
}
// 缓存结果
await cacheService.setAdminStats(semester, result)
// 只在Redis可用时显示缓存提示
const { isRedisReady } = await import('../../utils/redis')
if (isRedisReady()) {
console.log('[Cache] 管理员统计数据已缓存')
}
return result
} catch (error) {
console.error('获取统计数据失败:', error)
+42 -17
View File
@@ -1,11 +1,12 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { songs, users, votes } from '~/drizzle/schema'
import { songs, users, votes, userIdentities } from '~/drizzle/schema'
import { CacheService } from '~~/server/services/cacheService'
import { getUserAvatarUrl } 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)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
@@ -17,18 +18,31 @@ export default defineEventHandler(async (event) => {
const limit = parseInt(query.limit as string) || 20
try {
// 构建查询条件 - 如果没有指定学期或学期为'all',查询所有用户
const cacheService = CacheService.getInstance()
const cachedStats = await cacheService.getActiveUsersStats(semester, limit)
if (cachedStats) {
console.log('[Cache] 活跃用户统计数据缓存命中')
return cachedStats
}
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 allIdentities = await db.select().from(userIdentities)
const identitiesMap = new Map<number, typeof allIdentities>()
allIdentities.forEach((identity) => {
if (!identitiesMap.has(identity.userId)) {
identitiesMap.set(identity.userId, [])
}
identitiesMap.get(identity.userId)!.push(identity)
})
// 过滤有投稿歌曲的用户
const activeUsers = allUsers
.filter((user) => {
const userSongs = allSongs.filter((song) => {
@@ -56,37 +70,48 @@ export default defineEventHandler(async (event) => {
}
})
// 如果没有活跃用户,返回空数组
if (activeUsers.length === 0) {
const emptyResult = []
await cacheService.setActiveUsersStats(semester, limit, emptyResult)
console.log('[Cache] 活跃用户统计数据已缓存(空结果)')
return emptyResult
}
// 计算每个用户的活跃度数据
const userStats = activeUsers.map((user) => {
const contributions = user.songs.length
const likes = user.songs.reduce((total, song) => total + song.votes.length, 0)
// 活跃度计算:投稿数 * 2 + 点赞数 * 0.5
const activityScore = Math.round(contributions * 2 + likes * 0.5)
const userIdentitiesData = identitiesMap.get(user.id) || []
const avatarUrl = getUserAvatarUrl(
{
id: user.id,
name: user.name,
username: user.username,
email: user.email,
avatarProvider: user.avatarProvider,
avatarProviderUserId: user.avatarProviderUserId
},
userIdentitiesData
)
const userStat = {
return {
id: user.id,
name: user.name || user.username, // 优先显示真实姓名,如果没有则显示登录名
name: user.name || user.username,
contributions,
likes,
activityScore
activityScore,
avatarUrl
}
return userStat
})
// 按活跃度分数降序排列并限制数量
const sortedUsers = userStats.sort((a, b) => b.activityScore - a.activityScore).slice(0, limit)
await cacheService.setActiveUsersStats(semester, limit, sortedUsers)
console.log('[Cache] 活跃用户统计数据已缓存')
return sortedUsers
} catch (error) {
// 返回空数组而不是抛出错误,避免前端显示错误
console.error('[Stats] 获取活跃用户失败:', error)
return []
}
})
+126 -83
View File
@@ -1,14 +1,26 @@
import { createError, defineEventHandler } from 'h3'
import { db } from '~/drizzle/db'
import { songs, users, userIdentities, votes } from '~/drizzle/schema'
import { CacheService } from '~~/server/services/cacheService'
import { songs, users, votes, userIdentities } from '~/drizzle/schema'
import { count, eq, gte, inArray } from 'drizzle-orm'
import { getBeijingHour, getBeijingStartOfDay } from '~/utils/timeUtils'
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
import { getUserAvatarUrl } from '~~/server/utils/user-avatar'
function formatRelativeTime(date: Date | null | undefined): string {
if (!date) return '未知'
const now = new Date()
const diff = now.getTime() - new Date(date).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
return `${Math.floor(hours / 24)}天前`
}
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
@@ -16,140 +28,172 @@ export default defineEventHandler(async (event) => {
}
try {
// 获取当前时间相关的日期
const cacheService = CacheService.getInstance()
const cachedStats = await cacheService.getRealtimeStats()
if (cachedStats) {
console.log('[Cache] 实时统计数据缓存命中')
return cachedStats
}
const now = new Date()
const today = getBeijingStartOfDay() // 使用北京时间的一天开始
const 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
requesterId2: users.id,
requesterLastActive: songs.createdAt,
requesterAvatarProvider: users.avatarProvider,
requesterAvatarProviderUserId: users.avatarProviderUserId,
requesterEmail: users.email
})
.from(songs)
.innerJoin(users, eq(songs.requesterId, users.id))
.where(gte(songs.createdAt, oneHourAgo))
.groupBy(songs.requesterId, users.id, users.name, users.username)
.groupBy(songs.requesterId, users.id, users.name, users.username, users.lastLogin, users.avatarProvider, users.avatarProviderUserId, users.email, songs.createdAt)
// 获取最近1小时内登录的用户
const recentLoginUsers = await db
.select({
id: users.id,
username: users.username,
name: users.name
name: users.name,
lastLogin: users.lastLogin,
avatarProvider: users.avatarProvider,
avatarProviderUserId: users.avatarProviderUserId,
email: users.email
})
.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
userId2: users.id,
voteCreatedAt: votes.createdAt,
userAvatarProvider: users.avatarProvider,
userAvatarProviderUserId: users.avatarProviderUserId,
userEmail: users.email
})
.from(votes)
.innerJoin(users, eq(votes.userId, users.id))
.where(gte(votes.createdAt, oneHourAgo))
.groupBy(votes.userId, users.id, users.name, users.username)
.groupBy(votes.userId, users.id, users.name, users.username, users.avatarProvider, users.avatarProviderUserId, users.email, votes.createdAt)
// 合并并去重用户列表
const userMap = new Map()
const userMap = new Map<number, any>()
// 添加点歌用户
recentSongUsers.forEach((song) => {
userMap.set(song.requesterId2, {
id: song.requesterId2,
const id = song.requesterId2
const existing = userMap.get(id)
const lastActive = existing?.lastActive
? new Date(existing.lastActive) > new Date(song.requesterLastActive)
? existing.lastActive
: song.requesterLastActive
: song.requesterLastActive
userMap.set(id, {
id,
username: song.requesterUsername,
name: song.requesterName,
avatar: null
email: song.requesterEmail,
avatarProvider: song.requesterAvatarProvider,
avatarProviderUserId: song.requesterAvatarProviderUserId,
lastActive
})
})
// 添加登录用户
recentLoginUsers.forEach((user) => {
userMap.set(user.id, {
id: user.id,
username: user.username,
name: user.name,
avatar: null
recentLoginUsers.forEach((u) => {
const existing = userMap.get(u.id)
const lastActive = existing?.lastActive
? new Date(existing.lastActive) > new Date(u.lastLogin)
? existing.lastActive
: u.lastLogin
: u.lastLogin
userMap.set(u.id, {
id: u.id,
username: u.username,
name: u.name,
email: u.email,
avatarProvider: u.avatarProvider,
avatarProviderUserId: u.avatarProviderUserId,
lastActive
})
})
// 添加点赞用户
recentVoteUsers.forEach((vote) => {
userMap.set(vote.userId2, {
id: vote.userId2,
const id = vote.userId2
const existing = userMap.get(id)
const lastActive = existing?.lastActive
? new Date(existing.lastActive) > new Date(vote.voteCreatedAt)
? existing.lastActive
: vote.voteCreatedAt
: vote.voteCreatedAt
userMap.set(id, {
id,
username: vote.userUsername,
name: vote.userName,
avatar: null
email: vote.userEmail,
avatarProvider: vote.userAvatarProvider,
avatarProviderUserId: vote.userAvatarProviderUserId,
lastActive
})
})
// 解析头像(与用户管理面板同源:优先用户选中的身份,否则按创建顺序取第一个可用头像)
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
})
const userIds = Array.from(userMap.keys())
let identitiesData: any[] = []
if (userIds.length > 0) {
try {
identitiesData = await db
.select()
.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
})
.where(inArray(userIdentities.userId, userIds))
} catch (e) {
identitiesData = []
}
}
const activeUsersList = Array.from(userMap.values())
const identitiesMap = new Map<number, any[]>()
identitiesData.forEach((identity) => {
if (!identitiesMap.has(identity.userId)) {
identitiesMap.set(identity.userId, [])
}
identitiesMap.get(identity.userId)!.push(identity)
})
const activeUsersList = Array.from(userMap.values()).map((u) => {
const userIdentitiesData = identitiesMap.get(u.id) || []
const avatarUrl = getUserAvatarUrl(
{
id: u.id,
name: u.name,
username: u.username,
email: u.email,
avatarProvider: u.avatarProvider,
avatarProviderUserId: u.avatarProviderUserId
},
userIdentitiesData
)
return {
id: u.id,
username: u.username,
name: u.name,
avatarUrl,
lastActive: formatRelativeTime(u.lastActive)
}
})
return {
count: activeUsersList.length,
users: activeUsersList
}
} catch (error) {
console.error('[Realtime] 获取活跃用户失败:', error)
return {
count: 0,
users: []
@@ -157,7 +201,6 @@ export default defineEventHandler(async (event) => {
}
})(),
// 今日点歌数
(async () => {
try {
const todayCountResult = await db
@@ -170,12 +213,10 @@ export default defineEventHandler(async (event) => {
}
})(),
// 热门流派 (Song模型中没有genre字段,返回空数组)
(async () => {
return []
})(),
// 高峰时段 (最近7天的小时统计)
(async () => {
try {
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
@@ -186,7 +227,6 @@ export default defineEventHandler(async (event) => {
.from(songs)
.where(gte(songs.createdAt, sevenDaysAgo))
// 按小时统计
const hourCount = songsData.reduce(
(acc, song) => {
const hour = getBeijingHour(song.createdAt)
@@ -196,7 +236,6 @@ export default defineEventHandler(async (event) => {
{} as Record<number, number>
)
// 返回前3个高峰时段
return Object.entries(hourCount)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
@@ -220,8 +259,12 @@ export default defineEventHandler(async (event) => {
timestamp: now.toISOString()
}
await cacheService.setRealtimeStats(result)
console.log('[Cache] 实时统计数据已缓存')
return result
} catch (error) {
console.error('[Realtime] 获取失败:', error)
throw createError({
statusCode: 500,
message: '获取实时统计数据失败'
@@ -4,7 +4,7 @@ import { and, count, db, eq, exists, schedules, semesters, songs, votes } from '
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
@@ -19,28 +19,30 @@ export default defineEventHandler(async (event) => {
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 [totalSongsResult, totalSchedulesResult, totalRequestsResult] = await Promise.all([
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))
// 统计该学期的排期数量(通过关联歌曲)
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)))
// 统计该学期有投票的歌曲数量
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,
+4 -5
View File
@@ -1,12 +1,12 @@
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'
import { count, 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)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
@@ -30,15 +30,14 @@ export default defineEventHandler(async (event) => {
artist: songs.artist,
requesterName: users.name,
requesterUsername: users.username,
// insert-only 模型下同一用户可能有多条申请,按申请人去重计数
count: countDistinct(songReplayRequests.userId)
count: count(songReplayRequests.id)
})
.from(songs)
.leftJoin(users, eq(songs.requesterId, users.id))
.leftJoin(songReplayRequests, eq(songs.id, songReplayRequests.songId))
.where(semester && semester !== 'all' ? eq(songs.semester, semester) : sql`1=1`)
.groupBy(songs.id, songs.title, songs.artist, users.name, users.username)
.orderBy(desc(countDistinct(songReplayRequests.userId)))
.orderBy(desc(count(songReplayRequests.id)))
.limit(limit)
} else {
// 按投票数排行(默认)
+1 -1
View File
@@ -5,7 +5,7 @@ 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)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
+29 -22
View File
@@ -1,12 +1,12 @@
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'
import { count } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要管理员权限'
@@ -17,40 +17,47 @@ export default defineEventHandler(async (event) => {
const semester = query.semester as string
try {
const songWhereCondition = semester && semester !== 'all' ? eq(songs.semester, semester) : undefined
// 构建查询条件
const where = semester && semester !== 'all' ? { semester: semester } : {}
// 获取用户参与度数据
// 1. 获取总用户数
const totalUsersResult = await db.select({ count: count() }).from(users)
const totalUsers = Number(totalUsersResult[0]?.count || 0)
const totalUsers = totalUsersResult[0].count
// 2. 获取有请求歌曲的用户数
const activeUsersResult = await db
.select({ count: sql<number>`COUNT(DISTINCT ${songs.requesterId})` })
.from(songs)
.where(songWhereCondition)
const activeUsers = Number(activeUsersResult[0]?.count || 0)
const allUsers = await db.select().from(users)
const allSongs = await db.select().from(songs)
const activeUsers = allUsers.filter((user) => {
return allSongs.some((song) => {
if (song.requesterId !== user.id) return false
if (semester && semester !== 'all' && song.semester !== semester) return false
return true
})
}).length
// 3. 获取用户请求歌曲的平均数量
const totalSongRequestsResult = await db
.select({ count: count() })
.from(songs)
.where(songWhereCondition)
const totalSongRequests = Number(totalSongRequestsResult[0]?.count || 0)
const userSongCounts = allUsers.map((user) => {
const songCount = allSongs.filter((song) => song.requesterId === user.id).length
return { ...user, _count: { songs: songCount } }
})
const totalSongRequests = userSongCounts.reduce((sum, user) => sum + user._count.songs, 0)
const averageSongsPerUser = totalUsers > 0 ? totalSongRequests / totalUsers : 0
// 4. 获取最近活跃用户
const oneWeekAgo = new Date()
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)
const 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)
const recentActiveUsers = allUsers.filter((user) => {
return allSongs.some((song) => {
if (song.requesterId !== user.id) return false
if (semester && semester !== 'all' && song.semester !== semester) return false
if (song.createdAt < oneWeekAgo) return false
return true
})
}).length
return {
totalUsers,
File diff suppressed because it is too large Load Diff
+34 -19
View File
@@ -1,7 +1,6 @@
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { maskSystemSettingsSecrets } from './secretMask'
import { MUSIC_SOURCE_PLATFORMS } from '~~/server/config/constants'
import { CacheService } from '../../../services/cacheService'
import { SYSTEM_SETTINGS_DEFAULTS } from '~~/server/utils/system-settings-defaults'
export default defineEventHandler(async (event) => {
@@ -23,27 +22,33 @@ export default defineEventHandler(async (event) => {
}
try {
// 系统设置包含敏感配置,始终从数据库读取。
// 尝试从缓存获取系统设置
const cacheService = CacheService.getInstance()
let settings = await cacheService.getSystemSettings()
if (settings) {
return settings
}
// 缓存中没有,从数据库获取系统设置,如果不存在则创建默认设置
const settingsResult = await db.select().from(systemSettings).limit(1)
let settings = settingsResult[0]
settings = settingsResult[0] || null
if (!settings) {
const newSettingsResult = await db
.insert(systemSettings)
.values({
...SYSTEM_SETTINGS_DEFAULTS,
telemetryEnabled: true,
enablePlayTimeSelection: false,
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || 'VoiceHub',
siteLogoUrl: process.env.NUXT_PUBLIC_SITE_LOGO || '/favicon.ico',
schoolLogoHomeUrl: null,
schoolLogoPrintUrl: null,
siteLogoUrl: process.env.NUXT_PUBLIC_SITE_LOGO || '/images/site-logo.png',
schoolLogoHomeUrl: '/images/school-logo-white.png',
schoolLogoPrintUrl: '/images/school-logo-transparent.png',
siteDescription:
process.env.NUXT_PUBLIC_SITE_DESCRIPTION || '校园广播站点歌系统 - 让你的声音被听见',
siteVersion: process.env.NUXT_PUBLIC_SITE_VERSION || null,
submissionGuidelines: '请遵守校园规定,提交健康向上的歌曲。',
icpNumber: null,
gonganNumber: null,
showBeianIcon: false,
enableSubmissionLimit: false,
dailySubmissionLimit: null,
weeklySubmissionLimit: null,
@@ -51,20 +56,30 @@ export default defineEventHandler(async (event) => {
showBlacklistKeywords: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
enableSubmissionRestriction: false,
submissionRestrictionScope: 'all',
sameSongRestrictionHours: null,
sameArtistRestrictionHours: null,
enabledPlatforms: JSON.stringify([...MUSIC_SOURCE_PLATFORMS]),
platformOrder: JSON.stringify([...MUSIC_SOURCE_PLATFORMS]),
defaultTheme: 'System',
enabledThemes: JSON.stringify(['System', 'ClassicDark', 'ClassicLight', 'ModernLight'])
enableRequestTimeLimitation: false,
forceBlockAllRequests: false,
enableReplayRequests: false,
smtpEnabled: false
})
.returning()
settings = newSettingsResult[0]
}
return maskSystemSettingsSecrets(settings)
// 将设置存入缓存
try {
const merged: any = { ...SYSTEM_SETTINGS_DEFAULTS, ...(settings || {}) }
settings = merged
await cacheService.setSystemSettings(settings)
} catch (cacheError) {
console.warn('缓存系统设置失败:', cacheError)
}
// 隐藏敏感字段
if (settings && settings.smtpPassword) {
settings.smtpPassword = '****************'
}
return settings
} catch (error) {
console.error('获取系统设置失败:', error)
throw createError({
+19 -6
View File
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
statusMessage: '没有权限访问'
})
}
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
if (!existingUser) {
throw createError({
statusCode: 404,
message: '用户不存在'
statusMessage: '用户不存在'
})
}
@@ -35,7 +35,7 @@ export default defineEventHandler(async (event) => {
if (existingUser.id === 1) {
throw createError({
statusCode: 403,
message: '无法删除系统初始超级管理员'
statusMessage: '无法删除系统初始超级管理员'
})
}
@@ -44,7 +44,7 @@ export default defineEventHandler(async (event) => {
if (String(existingUser.id) === String(user.id)) {
throw createError({
statusCode: 400,
message: '不能删除自己的账户'
statusMessage: '不能删除自己的账户'
})
}
@@ -53,13 +53,26 @@ export default defineEventHandler(async (event) => {
if (existingUser.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '权限不足:普通管理员无法删除超级管理员'
statusMessage: '权限不足:普通管理员无法删除超级管理员'
})
}
// 删除用户
await db.delete(users).where(eq(users.id, parseInt(userId)))
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('songs:*')
await cache.deletePattern('schedules:*')
await cache.deletePattern('stats:*')
// 清除该用户的认证缓存
await cache.delete(`auth:user:${existingUser.id}`)
console.log('[Cache] 歌曲、排期、统计和用户认证缓存已清除(用户删除)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
message: '用户删除成功'
@@ -73,7 +86,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: '删除用户失败:' + error.message
statusMessage: '删除用户失败: ' + error.message
})
}
})
+72 -124
View File
@@ -3,27 +3,7 @@ import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { updateUserPassword } from '~~/server/services/userService'
import { createSystemNotification } from '~~/server/services/notificationService'
import {
PASSWORD_AUDIT_ACTIONS,
getPasswordAuditContext
} from '~~/server/services/passwordSecurityService'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { getAdminPasswordViolation } from '~~/server/utils/admin-password-policy'
const normalizeRequiredText = (value: unknown) => String(value || '').trim()
const normalizeOptionalText = (value: unknown) => {
const normalized = String(value || '').trim()
return normalized || null
}
const roleNames: Record<string, string> = {
USER: '用户',
SONG_ADMIN: '歌曲管理员',
ADMIN: '管理员',
SUPER_ADMIN: '超级管理员'
}
import { getRoleLevel, canChangeRoleOf, canResetPasswordFor } from '~~/server/utils/permissions'
export default defineEventHandler(async (event) => {
try {
@@ -32,175 +12,145 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
statusMessage: '没有权限访问'
})
}
const userId = getRouterParam(event, 'id')
const userIdNum = Number.parseInt(String(userId), 10)
if (!Number.isInteger(userIdNum) || userIdNum <= 0) {
const body = await readBody(event)
const { name, username, password, role, grade, class: userClass, status } = body
// 验证必填字段
if (!name || !username) {
throw createError({
statusCode: 400,
message: '无效的用户ID'
statusMessage: '姓名和用户名为必填项'
})
}
const body = await readBody(event)
const { name, username, password, role, grade, class: userClass, status } = body || {}
const normalizedName = normalizeRequiredText(name)
const normalizedUsername = normalizeRequiredText(username)
// 验证必填字段
if (!normalizedName || !normalizedUsername) {
// 验证姓名和用户名只能包含汉字
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(name) || !chineseRegex.test(username)) {
throw createError({
statusCode: 400,
message: '姓名和用户名为必填项'
statusMessage: '姓名和用户名只能包含汉字'
})
}
// 检查用户是否存在
const existingUser = await db.select().from(users).where(eq(users.id, userIdNum)).limit(1)
const existingUser = await db
.select()
.from(users)
.where(eq(users.id, parseInt(userId)))
.limit(1)
if (existingUser.length === 0) {
throw createError({
statusCode: 404,
message: '用户不存在'
statusMessage: '用户不存在'
})
}
const targetUser = existingUser[0]!
const targetUser = existingUser[0]
const currentLevel = getRoleLevel(user.role)
const targetLevel = getRoleLevel(targetUser.role)
// 1. 禁止修改系统初始超级管理员 (ID: 1)
if (targetUser.id === 1) {
throw createError({
statusCode: 403,
message: '无法修改系统初始超级管理员'
statusMessage: '无法修改系统初始超级管理员'
})
}
// 2. 禁止对自身进行任何用户管理操作(包括角色、资料、状态、密码等)
// 使用 String 转换确保 ID 比较的准确性
if (String(userId) === String(user.id)) {
throw createError({
statusCode: 400,
message: '禁止在用户管理中修改自己的账户'
statusMessage: '禁止在用户管理中修改自己的账户'
})
}
// 3. 越级修改保护
// 如果目标用户是 SUPER_ADMIN,操作者必须是 SUPER_ADMIN
if (targetUser.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
// 3. 非超级管理员必须严格高于目标角色级别
if (user.role !== 'SUPER_ADMIN' && currentLevel <= targetLevel) {
throw createError({
statusCode: 403,
message: '权限不足:普通管理员无法修改超级管理员信息'
statusMessage: `权限不足:${user.role} 无法操作同级或更高角色的用户`
})
}
// 检查用户名是否被其他用户使用
if (normalizedUsername !== targetUser.username) {
if (username !== targetUser.username) {
const duplicateUser = await db
.select()
.from(users)
.where(eq(users.username, normalizedUsername))
.where(eq(users.username, username))
.limit(1)
if (duplicateUser.length > 0) {
throw createError({
statusCode: 400,
message: '用户名已被其他用户使用'
statusMessage: '用户名已被其他用户使用'
})
}
}
// 角色权限控制
// 角色权限控制(统一使用 canChangeRoleOf 语义化检查)
let validRole = targetUser.role
if (role) {
if (!['USER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(role)) {
throw createError({
statusCode: 400,
message: '无效的用户角色'
})
}
}
if (role && role !== targetUser.role) {
// 超级管理员可以设置任何角色
if (user.role === 'SUPER_ADMIN') {
validRole = role
}
// 管理员只能设置管理员以下的角色(USER, SONG_ADMIN)
else if (user.role === 'ADMIN') {
if (['USER', 'SONG_ADMIN'].includes(role)) {
validRole = role
} else {
throw createError({
statusCode: 403,
message: '管理员只能设置用户和歌曲管理员角色'
})
}
}
// 其他角色不能设置角色
else {
if (!canChangeRoleOf(user, targetUser, role)) {
throw createError({
statusCode: 403,
message: '没有权限设置用户角色'
statusMessage: user.role === 'SUPER_ADMIN'
? '超级管理员不能将目标用户提升为 SUPER_ADMIN'
: `权限不足:${user.role} 无法将用户角色更改为 ${role}`
})
}
validRole = role
}
// 如果请求中包含密码更新,校验密码重置权限
if (password && !canResetPasswordFor(user, targetUser)) {
throw createError({
statusCode: 403,
statusMessage: `权限不足:${user.role} 无法重置目标用户的密码`
})
}
// 验证status字段的有效性
if (status && !['active', 'withdrawn', 'graduate'].includes(status)) {
throw createError({
statusCode: 400,
message: '用户状态只能是 active, withdrawn 或 graduate'
statusMessage: '用户状态只能是 active, withdrawn 或 graduate'
})
}
// 待审核用户不得通过编辑接口直接置为 active(须走注册审核流程)
if (targetUser.status === 'pending' && status === 'active') {
throw createApiError(400, SERVER_ERROR_CODES.USER_NOT_PENDING, '待审核用户需通过注册审核流程处理')
}
// 准备更新数据
const updateData = {
name: normalizedName,
username: normalizedUsername,
name,
username,
role: validRole,
grade: normalizeOptionalText(grade),
class: normalizeOptionalText(userClass),
...(status && status !== targetUser.status
? {
status,
statusChangedAt: new Date(),
statusChangedBy: user.id
}
: {})
grade,
class: userClass
}
// 如果提供了status,则更新状态相关字段
if (status && status !== existingUser[0].status) {
updateData.status = status
updateData.statusChangedAt = new Date()
updateData.statusChangedBy = user.id
}
// 如果提供了密码,则使用统一服务更新密码
if (password) {
const trimmedPassword = String(password).trim()
// 管理员重置为临时密码且 forceReset 强制用户登录后修改,仅做基础校验不要求完整复杂度
const violation = getAdminPasswordViolation(trimmedPassword)
if (violation) {
throw createApiError(400, violation.code, violation.message)
}
await updateUserPassword(targetUser.id, trimmedPassword, {
forceReset: true,
auditContext: {
action: PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
actorId: user.id,
...getPasswordAuditContext(event)
}
})
await updateUserPassword(parseInt(userId), password, true)
}
// 更新用户其他信息
const updatedUser = await db
.update(users)
.set(updateData)
.where(eq(users.id, userIdNum))
.where(eq(users.id, parseInt(userId)))
.returning({
id: users.id,
name: users.name,
@@ -219,35 +169,33 @@ export default defineEventHandler(async (event) => {
})
// 如果状态发生变更,记录到状态变更日志
if (status && status !== targetUser.status) {
if (status && status !== existingUser[0].status) {
await db.insert(userStatusLogs).values({
userId: targetUser.id,
oldStatus: targetUser.status,
userId: parseInt(userId),
oldStatus: existingUser[0].status,
newStatus: status,
reason: `管理员${user.name || user.username}修改用户状态`,
operatorId: user.id
})
}
if (validRole !== targetUser.role) {
const oldRoleName = roleNames[targetUser.role] || targetUser.role
const newRoleName = roleNames[validRole] || validRole
const notification = await createSystemNotification(
targetUser.id,
'权限变更通知',
`您的账户权限已由系统更新:${oldRoleName} → ${newRoleName}`
)
if (!notification) {
console.warn(`未向用户 ${targetUser.id} 发送权限变更通知`)
}
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('songs:*')
// 清除该用户的认证缓存(角色或权限可能已变更)
await cache.delete(`auth:user:${parseInt(userId)}`)
console.log('[Cache] 歌曲和用户认证缓存已清除(用户更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
user: updatedUser[0]!,
user: updatedUser[0],
message: '用户更新成功'
}
} catch (error: any) {
} catch (error) {
console.error('更新用户失败:', error)
if (error.statusCode) {
@@ -256,7 +204,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: '更新用户失败: ' + error.message
statusMessage: '更新用户失败: ' + error.message
})
}
})
+122
View File
@@ -0,0 +1,122 @@
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
// 安全获取ID参数
const params = event.context.params || {}
const id = parseInt(params.id || '')
if (isNaN(id)) {
throw createError({
statusCode: 400,
message: '无效的用户ID'
})
}
// 处理PUT请求 - 更新用户
if (event.method === 'PUT') {
const body = await readBody(event)
if (!body.name) {
throw createError({
statusCode: 400,
message: '姓名不能为空'
})
}
try {
const updatedUserResult = await db
.update(users)
.set({
name: body.name,
role: body.role,
grade: body.grade,
class: body.class
})
.where(eq(users.id, id))
.returning({
id: users.id,
name: users.name,
role: users.role,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
grade: users.grade,
class: users.class,
username: users.username,
passwordChangedAt: users.passwordChangedAt
})
const updatedUser = updatedUserResult[0]
return updatedUser
} catch (error) {
console.error('更新用户失败:', error)
throw createError({
statusCode: 500,
message: '更新用户失败'
})
}
}
// 处理DELETE请求 - 删除用户
if (event.method === 'DELETE') {
try {
const existingUserResult = await db.select().from(users).where(eq(users.id, id)).limit(1)
if (existingUserResult.length === 0) {
throw createError({
statusCode: 404,
message: '用户不存在'
})
}
const existingUser = existingUserResult[0]
// 1. 禁止删除 ID 为 1 的用户 (系统初始超级管理员)
if (existingUser.id === 1) {
throw createError({
statusCode: 403,
message: '无法删除系统初始超级管理员'
})
}
// 2. 确保不能删除自己
if (String(existingUser.id) === String(user.id)) {
throw createError({
statusCode: 400,
message: '不能删除当前登录的用户'
})
}
// 3. 越级删除保护
if (existingUser.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '权限不足:普通管理员无法删除超级管理员'
})
}
await db.delete(users).where(eq(users.id, id))
return { success: true }
} catch (error) {
console.error('删除用户失败:', error)
throw createError({
statusCode: 500,
message: '删除用户失败'
})
}
}
throw createError({
statusCode: 405,
message: '不支持的请求方法'
})
})
@@ -1,26 +1,18 @@
import { eq } from 'drizzle-orm'
import { updateUserPassword } from '~~/server/services/userService'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { updateUserPassword } from '~~/server/services/userService'
import {
PASSWORD_AUDIT_ACTIONS,
getPasswordAuditContext,
recordPasswordAudit
} from '~~/server/services/passwordSecurityService'
import { createApiError } from '~~/server/utils/apiError'
import { getAdminPasswordViolation } from '~~/server/utils/admin-password-policy'
import { eq } from 'drizzle-orm'
import { canResetPasswordFor, getRoleLevel } from '~~/server/utils/permissions'
export default defineEventHandler(async (event) => {
// 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
const currentUser = event.context.user
if (!currentUser || !['ADMIN', 'SUPER_ADMIN'].includes(currentUser.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
// 安全获取ID参数
const params = event.context.params || {}
const id = parseInt(params.id || '')
@@ -31,39 +23,38 @@ export default defineEventHandler(async (event) => {
})
}
const body = await readBody<Record<string, unknown> | null>(event)
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword.trim() : ''
const body = await readBody(event)
// 管理员重置为临时密码且 forceReset 强制用户登录后修改,仅做基础校验不要求完整复杂度
const violation = getAdminPasswordViolation(newPassword)
if (violation) {
throw createApiError(400, violation.code, violation.message)
if (!body.newPassword) {
throw createError({
statusCode: 400,
message: '新密码不能为空'
})
}
try {
// 查询目标用户用于越级保护
const targetUsers = await db
.select({ id: users.id, role: users.role })
.from(users)
.where(eq(users.id, id))
.limit(1)
const targetUser = targetUsers[0]
if (!targetUser) {
throw createError({
statusCode: 404,
message: '用户不存在'
})
}
// 禁止对自身进行密码重置(通过用户管理界面)
if (id === user.id) {
if (id === currentUser.id) {
throw createError({
statusCode: 400,
message: '禁止在用户管理中重置自己的密码'
})
}
// 禁止重置系统初始超级管理员 (ID: 1)
const targetUserResult = await db
.select({ id: users.id, role: users.role, username: users.username })
.from(users)
.where(eq(users.id, id))
.limit(1)
if (targetUserResult.length === 0) {
throw createError({
statusCode: 404,
message: '用户不存在'
})
}
const targetUser = targetUserResult[0]
if (targetUser.id === 1) {
throw createError({
statusCode: 403,
@@ -71,39 +62,36 @@ export default defineEventHandler(async (event) => {
})
}
// 越级保护:目标用户是 SUPER_ADMIN 时,操作者必须是 SUPER_ADMIN
if (targetUser.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
const currentLevel = getRoleLevel(currentUser.role)
const targetLevel = getRoleLevel(targetUser.role)
if (currentUser.role !== 'SUPER_ADMIN') {
if (currentLevel <= targetLevel) {
throw createError({
statusCode: 403,
message: '权限不足:不能重置角色级别大于等于自己的用户密码'
})
}
}
if (!canResetPasswordFor(currentUser, targetUser)) {
throw createError({
statusCode: 403,
message: '权限不足:普通管理员无法重置超级管理员密码'
message: '权限不足:当前角色无法重置该用户的密码'
})
}
// 使用统一服务重置密码 (forceReset = true)
await updateUserPassword(id, newPassword, {
forceReset: true,
auditContext: {
action: PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
actorId: user.id,
...getPasswordAuditContext(event)
}
})
await updateUserPassword(id, body.newPassword, true)
return {
success: true,
message: '密码重置成功'
}
} catch (error) {
await recordPasswordAudit(
event,
id,
PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
false,
error instanceof Error ? error.message : '管理员重置密码失败',
user.id
)
console.error('重置密码失败:', error)
if (error && typeof error === 'object' && 'statusCode' in error) throw error
console.error('[ResetPassword] 失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '重置密码失败'
+6 -7
View File
@@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => {
if (!currentUser) {
throw createError({
statusCode: 401,
message: '未授权访问'
statusMessage: '未授权访问'
})
}
@@ -18,16 +18,16 @@ export default defineEventHandler(async (event) => {
if (!allowedRoles.includes(currentUser.role)) {
throw createError({
statusCode: 403,
message: '权限不足'
statusMessage: '权限不足'
})
}
// 获取用户 ID
// 获取用户ID
const userId = parseInt(getRouterParam(event, 'id') as string)
if (!userId || isNaN(userId)) {
throw createError({
statusCode: 400,
message: '无效的用户 ID'
statusMessage: '无效的用户ID'
})
}
@@ -49,7 +49,7 @@ export default defineEventHandler(async (event) => {
if (!user) {
throw createError({
statusCode: 404,
message: '用户不存在'
statusMessage: '用户不存在'
})
}
@@ -60,7 +60,6 @@ export default defineEventHandler(async (event) => {
title: songs.title,
artist: songs.artist,
semester: songs.semester,
durationSeconds: songs.durationSeconds,
createdAt: songs.createdAt,
played: songs.played
})
@@ -267,7 +266,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: '服务器内部错误'
statusMessage: '服务器内部错误'
})
}
})
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
statusMessage: '没有权限访问'
})
}
@@ -19,12 +19,12 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event)
const { page = '1', limit = '20' } = query
// 验证用户 ID
// 验证用户ID
const numUserId = parseInt(userId)
if (isNaN(numUserId) || numUserId <= 0) {
throw createError({
statusCode: 400,
message: '无效的用户 ID'
statusMessage: '无效的用户ID'
})
}
@@ -42,7 +42,7 @@ export default defineEventHandler(async (event) => {
if (existingUser.length === 0) {
throw createError({
statusCode: 404,
message: '用户不存在'
statusMessage: '用户不存在'
})
}
@@ -117,7 +117,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: '获取用户状态变更日志失败:' + error.message
statusMessage: '获取用户状态变更日志失败: ' + error.message
})
}
})
+16 -14
View File
@@ -4,8 +4,6 @@ import { users, userStatusLogs } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { getStatusText } from '~~/server/utils/user'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
export default defineEventHandler(async (event) => {
try {
@@ -14,7 +12,7 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
statusMessage: '没有权限访问'
})
}
@@ -26,14 +24,14 @@ export default defineEventHandler(async (event) => {
if (!status || !['active', 'withdrawn', 'graduate'].includes(status)) {
throw createError({
statusCode: 400,
message: '状态必须为 active, withdrawn 或 graduate'
statusMessage: '状态必须为 active, withdrawn 或 graduate'
})
}
if (!reason || reason.trim().length === 0) {
throw createError({
statusCode: 400,
message: '变更原因为必填项'
statusMessage: '变更原因为必填项'
})
}
@@ -53,22 +51,17 @@ export default defineEventHandler(async (event) => {
if (existingUser.length === 0) {
throw createError({
statusCode: 404,
message: '用户不存在'
statusMessage: '用户不存在'
})
}
const targetUser = existingUser[0]
// 待审核用户不得通过状态接口直接置为 active(须走注册审核流程)
if (targetUser.status === 'pending' && status === 'active') {
throw createApiError(400, SERVER_ERROR_CODES.USER_NOT_PENDING, '待审核用户需通过注册审核流程处理')
}
// 检查是否为学生用户
if (targetUser.role !== 'USER') {
throw createError({
statusCode: 400,
message: '只能修改学生用户的状态'
statusMessage: '只能修改学生用户的状态'
})
}
@@ -76,7 +69,7 @@ export default defineEventHandler(async (event) => {
if (targetUser.status === status) {
throw createError({
statusCode: 400,
message: '用户状态未发生变化'
statusMessage: '用户状态未发生变化'
})
}
@@ -106,6 +99,15 @@ export default defineEventHandler(async (event) => {
})
})
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.delete(`auth:user:${parseInt(userId)}`)
console.log('[Cache] 用户认证缓存已清除(状态更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
message: `用户状态已更新为${getStatusText(status)}`,
@@ -126,7 +128,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: '更新用户状态失败: ' + error.message
statusMessage: '更新用户状态失败: ' + error.message
})
}
})
@@ -1,4 +1,5 @@
import { db } from '~/drizzle/db'
import { CacheService } from '~~/server/services/cacheService'
import { users } from '~/drizzle/schema'
import { inArray } from 'drizzle-orm'
@@ -8,7 +9,7 @@ export default defineEventHandler(async (event) => {
if (event.node.req.method !== 'POST') {
throw createError({
statusCode: 405,
message: 'Method Not Allowed'
statusMessage: 'Method Not Allowed'
})
}
@@ -20,33 +21,21 @@ export default defineEventHandler(async (event) => {
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
throw createError({
statusCode: 400,
message: 'userIds is required and must be a non-empty array'
})
}
// 对输入的 userIds 进行去重和类型转换
const uniqueUserIds = [
...new Set(userIds.map((id) => parseInt(id)).filter((id) => !isNaN(id) && id > 0))
]
if (uniqueUserIds.length === 0) {
throw createError({
statusCode: 400,
message: '没有有效的用户ID'
statusMessage: 'userIds is required and must be a non-empty array'
})
}
if (!targetGrade || typeof targetGrade !== 'string') {
throw createError({
statusCode: 400,
message: 'targetGrade is required and must be a string'
statusMessage: 'targetGrade is required and must be a string'
})
}
if (typeof keepClass !== 'boolean') {
throw createError({
statusCode: 400,
message: 'keepClass is required and must be a boolean'
statusMessage: 'keepClass is required and must be a boolean'
})
}
@@ -56,7 +45,7 @@ export default defineEventHandler(async (event) => {
if (!currentUser) {
throw createError({
statusCode: 401,
message: 'Authentication required'
statusMessage: 'Authentication required'
})
}
@@ -64,7 +53,7 @@ export default defineEventHandler(async (event) => {
if (!['ADMIN', 'SUPER_ADMIN'].includes(currentUser.role)) {
throw createError({
statusCode: 403,
message: 'Insufficient permissions'
statusMessage: 'Insufficient permissions'
})
}
@@ -75,50 +64,26 @@ export default defineEventHandler(async (event) => {
name: users.name,
username: users.username,
grade: users.grade,
class: users.class,
role: users.role
class: users.class
})
.from(users)
.where(inArray(users.id, uniqueUserIds))
.where(inArray(users.id, userIds))
const existingUserIds = existingUsers.map((user) => user.id)
const nonExistentUserIds = uniqueUserIds.filter((id) => !existingUserIds.includes(id))
const nonExistentUserIds = userIds.filter((id) => !existingUserIds.includes(id))
let updated = 0
let failed = 0
const errors: Array<{ userId: number | string; error: string }> = []
const errors: string[] = []
// 记录不存在的用户ID
if (nonExistentUserIds.length > 0) {
failed += nonExistentUserIds.length
nonExistentUserIds.forEach((id) => {
errors.push({ userId: id, error: '用户不存在' })
})
}
// 过滤出有权限修改的用户
const validExistingUserIds: number[] = []
for (const user of existingUsers) {
if (user.id === 1) {
failed++
errors.push({ userId: user.id, error: '无法修改系统初始超级管理员' })
continue
}
if (user.id === currentUser.id) {
failed++
errors.push({ userId: user.id, error: '禁止在用户管理中批量更新自己的账户' })
continue
}
if (user.role === 'SUPER_ADMIN' && currentUser.role !== 'SUPER_ADMIN') {
failed++
errors.push({ userId: user.id, error: '权限不足:普通管理员无法修改超级管理员信息' })
continue
}
validExistingUserIds.push(user.id)
errors.push(`用户ID ${nonExistentUserIds.join(', ')} 不存在`)
}
// 批量更新存在的用户
if (validExistingUserIds.length > 0) {
if (existingUserIds.length > 0) {
try {
const updateData: any = {
grade: targetGrade,
@@ -131,16 +96,25 @@ export default defineEventHandler(async (event) => {
const updateResult = await db
.update(users)
.set(updateData)
.where(inArray(users.id, validExistingUserIds))
.where(inArray(users.id, existingUserIds))
.returning({ id: users.id })
updated = updateResult.length
} catch (error) {
console.error('批量更新失败:', error)
failed += validExistingUserIds.length
validExistingUserIds.forEach((id) => {
errors.push({ userId: id, error: '批量更新操作失败' })
})
failed += existingUserIds.length
errors.push('批量更新操作失败')
}
}
// 如果有用户更新成功,清除相关缓存
if (updated > 0) {
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('批量年级更新后缓存清除成功')
} catch (cacheError) {
console.error('批量年级更新后缓存清除失败:', cacheError)
}
}
@@ -159,7 +133,7 @@ export default defineEventHandler(async (event) => {
failed,
errors,
details: {
totalRequested: uniqueUserIds.length,
totalRequested: userIds.length,
existingUsers: existingUsers.length,
nonExistentUsers: nonExistentUserIds.length,
targetGrade,
@@ -175,7 +149,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: 'Internal Server Error'
statusMessage: 'Internal Server Error'
})
}
})
+36 -100
View File
@@ -4,8 +4,6 @@ import { users, userStatusLogs } from '~/drizzle/schema'
import { and, eq, inArray } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { getStatusText } from '~~/server/utils/user'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
export default defineEventHandler(async (event) => {
try {
@@ -14,76 +12,51 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
statusMessage: '没有权限访问'
})
}
const body = await readBody(event)
const { userIds, status, reason, sourceStatus } = body
const sourceStatusFilter = typeof sourceStatus === 'string' ? sourceStatus.trim() : ''
const { userIds, status, reason } = body
// 验证必填字段
if (!Array.isArray(userIds) || userIds.length === 0) {
throw createError({
statusCode: 400,
message: '用户ID列表不能为空'
statusMessage: '用户ID列表不能为空'
})
}
if (!status || !['active', 'withdrawn', 'graduate'].includes(status)) {
throw createError({
statusCode: 400,
message: '状态必须为 active, withdrawn 或 graduate'
statusMessage: '状态必须为 active, withdrawn 或 graduate'
})
}
// 源状态白名单含 pending(支持按待审核筛选批量处理;同时使下方 pending→active 拦截可达)
if (sourceStatusFilter && !['active', 'pending', 'withdrawn', 'graduate'].includes(sourceStatusFilter)) {
throw createError({
statusCode: 400,
message: '前置状态必须为 active, pending, withdrawn 或 graduate'
})
}
if (sourceStatusFilter === status) {
throw createError({
statusCode: 400,
message: '前置状态不能与目标状态相同'
})
}
// 待审核用户不得通过批量状态接口直接置为 active(须走注册审核流程)
if (sourceStatusFilter === 'pending' && status === 'active') {
throw createApiError(400, SERVER_ERROR_CODES.USER_NOT_PENDING, '待审核用户需通过注册审核流程处理')
}
if (!reason || reason.trim().length === 0) {
throw createError({
statusCode: 400,
message: '变更原因为必填项'
statusMessage: '变更原因为必填项'
})
}
// 验证用户ID格式
const validUserIds = [
...new Set(
userIds
.filter((id) => {
const numId = parseInt(id)
return !isNaN(numId) && numId > 0
})
.map((id) => parseInt(id))
)
]
const validUserIds = userIds
.filter((id) => {
const numId = parseInt(id)
return !isNaN(numId) && numId > 0
})
.map((id) => parseInt(id))
if (validUserIds.length === 0) {
throw createError({
statusCode: 400,
message: '没有有效的用户ID'
statusMessage: '没有有效的用户ID'
})
}
// 检查用户是否存在
// 检查用户是否存在且为学生角色
const existingUsers = await db
.select({
id: users.id,
@@ -93,69 +66,23 @@ export default defineEventHandler(async (event) => {
role: users.role
})
.from(users)
.where(inArray(users.id, validUserIds))
.where(and(inArray(users.id, validUserIds), eq(users.role, 'USER')))
const existingUserIds = existingUsers.map((u) => u.id)
const nonExistentUserIds = validUserIds.filter((id) => !existingUserIds.includes(id))
// 筛选出状态需要变更的用户,并加入越权保护,同时记录失败原因
const usersToUpdate = []
const errors: Array<{ userId: number | string; error: string }> = []
if (nonExistentUserIds.length > 0) {
nonExistentUserIds.forEach((id) => {
errors.push({ userId: id, error: '用户不存在' })
if (existingUsers.length === 0) {
throw createError({
statusCode: 404,
statusMessage: '没有找到可更新的学生用户'
})
}
for (const u of existingUsers) {
if (u.id === 1) {
errors.push({ userId: u.id, error: '无法修改系统初始超级管理员' })
continue
}
if (u.id === user.id) {
errors.push({ userId: u.id, error: '禁止在用户管理中批量更新自己的账户' })
continue
}
if (u.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
errors.push({ userId: u.id, error: '权限不足:普通管理员无法修改超级管理员信息' })
continue
}
if (u.role !== 'USER' && user.role !== 'SUPER_ADMIN') {
errors.push({ userId: u.id, error: '权限不足:普通管理员无法批量修改其他管理员状态' })
continue
}
if (sourceStatusFilter && u.status !== sourceStatusFilter) {
errors.push({
userId: u.id,
error: `前置状态不匹配:当前为${getStatusText(u.status)},仅更新${getStatusText(sourceStatusFilter)}`
})
continue
}
// 待审核用户不得通过批量状态接口直接置为 active(须走注册审核流程;即使未传 sourceStatus 也按实际状态拦截)
if (status === 'active' && u.status === 'pending') {
errors.push({ userId: u.id, error: '待审核用户需通过注册审核流程处理' })
continue
}
if (u.status === status) {
errors.push({ userId: u.id, error: '用户状态无需变更' })
continue
}
usersToUpdate.push(u)
}
// 筛选出状态需要变更的用户
const usersToUpdate = existingUsers.filter((u) => u.status !== status)
if (usersToUpdate.length === 0) {
return {
success: false,
message: '没有可更新的合法用户',
errors,
data: {
totalRequested: validUserIds.length,
totalUpdated: 0,
updatedUsers: [],
sourceStatus: sourceStatusFilter || null
}
}
throw createError({
statusCode: 400,
statusMessage: '所选用户的状态均无需变更'
})
}
const currentTime = getBeijingTime()
@@ -194,15 +121,24 @@ export default defineEventHandler(async (event) => {
}
})
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
for (const userId of usersToUpdate.map((u) => u.id)) {
await cache.delete(`auth:user:${userId}`)
}
console.log('[Cache] 批量用户认证缓存已清除(状态更新)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
message: `成功更新 ${results.length} 个用户的状态为${getStatusText(status)}`,
errors,
data: {
totalRequested: validUserIds.length,
totalUpdated: results.length,
updatedUsers: results,
sourceStatus: sourceStatusFilter || null,
changedAt: currentTime,
changedBy: user.name
}
@@ -216,7 +152,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: '批量更新用户状态失败: ' + error.message
statusMessage: '批量更新用户状态失败: ' + error.message
})
}
})
+22 -31
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { db } from '~/drizzle/db'
import { CacheService } from '~~/server/services/cacheService'
import { users, userStatusLogs } from '~/drizzle/schema'
import { and, eq, inArray, ne } from 'drizzle-orm'
@@ -27,7 +28,7 @@ export default defineEventHandler(async (event) => {
if (!currentUser) {
throw createError({
statusCode: 401,
message: 'Authentication required'
statusMessage: 'Authentication required'
})
}
@@ -35,7 +36,7 @@ export default defineEventHandler(async (event) => {
if (!['ADMIN', 'SUPER_ADMIN'].includes(currentUser.role)) {
throw createError({
statusCode: 403,
message: 'Insufficient permissions'
statusMessage: 'Insufficient permissions'
})
}
@@ -46,7 +47,8 @@ export default defineEventHandler(async (event) => {
if (!validationResult.success) {
throw createError({
statusCode: 400,
message: '请求参数无效: ' + validationResult.error.issues.map((e) => e.message).join(', ')
statusMessage:
'请求参数无效: ' + validationResult.error.errors.map((e) => e.message).join(', ')
})
}
@@ -55,7 +57,7 @@ export default defineEventHandler(async (event) => {
// 获取所有要更新的用户ID
const userIds = updates.map((update) => update.userId)
// 验证用户是否存在
// 验证用户是否存在且为学生角色
const existingUsers = await db
.select({
id: users.id,
@@ -65,38 +67,25 @@ export default defineEventHandler(async (event) => {
status: users.status
})
.from(users)
.where(inArray(users.id, userIds))
.where(and(inArray(users.id, userIds), eq(users.role, 'USER')))
// 检查是否所有用户都存在
// 检查是否所有用户都存在且为学生
const existingUserIds = new Set(existingUsers.map((user) => user.id))
const missingUserIds = userIds.filter((id) => !existingUserIds.has(id))
if (missingUserIds.length > 0) {
throw createError({
statusCode: 400,
message: `以下用户ID不存在: ${missingUserIds.join(', ')}`
statusMessage: `以下用户ID不存在或不是学生: ${missingUserIds.join(', ')}`
})
}
// 执行批量更新
const updateResults = []
const errors = []
const userMap = new Map(existingUsers.map((u) => [u.id, u]))
for (const update of updates) {
try {
const targetUser = userMap.get(update.userId)
if (!targetUser) continue
// 保护系统初始超级管理员
if (targetUser.id === 1) {
errors.push({
userId: update.userId,
error: '无法修改系统初始超级管理员'
})
continue
}
// 禁止批量更新包含当前操作者自身
if (update.userId === currentUser.id) {
errors.push({
@@ -105,15 +94,6 @@ export default defineEventHandler(async (event) => {
})
continue
}
// 越级修改保护
if (targetUser.role === 'SUPER_ADMIN' && currentUser.role !== 'SUPER_ADMIN') {
errors.push({
userId: update.userId,
error: '权限不足:普通管理员无法修改超级管理员信息'
})
continue
}
// 构建更新数据
const updateData: any = {}
@@ -162,7 +142,7 @@ export default defineEventHandler(async (event) => {
}
let updatedUser
// 使用事务确保更新和日志记录的原子性
await db.transaction(async (tx) => {
// 更新用户信息
@@ -225,6 +205,17 @@ export default defineEventHandler(async (event) => {
response.message = `批量更新部分完成,成功更新 ${updateResults.length} 个用户,${errors.length} 个用户更新失败`
}
// 如果有用户更新成功,清除相关缓存
if (updateResults.length > 0) {
try {
const cacheService = CacheService.getInstance()
await cacheService.clearSongsCache()
console.log('批量用户更新后缓存清除成功')
} catch (cacheError) {
console.error('批量用户更新后缓存清除失败:', cacheError)
}
}
return response
} catch (error) {
console.error('批量更新用户失败:', error)
@@ -237,7 +228,7 @@ export default defineEventHandler(async (event) => {
// 未知错误
throw createError({
statusCode: 500,
message: '批量更新用户失败: ' + error.message
statusMessage: '批量更新用户失败: ' + error.message
})
}
})
+65 -155
View File
@@ -1,28 +1,8 @@
import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq, inArray } from 'drizzle-orm'
import { getAdminPasswordViolation } from '~~/server/utils/admin-password-policy'
interface UserData {
name: string
username: string
password: string
role?: string
status?: 'active' | 'withdrawn' | 'graduate'
grade?: string | null
class?: string | null
}
interface ValidUserData extends UserData {
index: number
}
const normalizeRequiredText = (value: unknown) => String(value || '').trim()
const normalizeOptionalText = (value: unknown) => {
const normalized = String(value || '').trim()
return normalized || null
}
import { eq } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
export default defineEventHandler(async (event) => {
// 检查认证和权限
@@ -46,129 +26,56 @@ export default defineEventHandler(async (event) => {
const results = {
created: 0,
failed: 0,
total: body.users.length,
errors: [] as Array<{ index: number; username?: string; name?: string; reason: string }>
total: body.users.length
}
// 1. 预处理数据:过滤必填字段,提取要查询的用户名
const validUsers: ValidUserData[] = []
const usernamesToQuery: string[] = []
const usernamesInBatch = new Set<string>()
// 批量添加用户
for (let i = 0; i < body.users.length; i++) {
const userData = body.users[i]
const normalizedUserData = {
...userData,
name: normalizeRequiredText(userData.name),
username: normalizeRequiredText(userData.username),
grade: normalizeOptionalText(userData.grade),
class: normalizeOptionalText(userData.class)
}
if (
!normalizedUserData.name ||
!normalizedUserData.username ||
typeof userData.password !== 'string' ||
!userData.password
) {
results.failed++
results.errors.push({
index: i,
username: userData.username,
name: userData.name,
reason: '缺少必填字段(姓名、账号或密码)'
})
continue
}
try {
// 验证必填字段
if (!userData.name || !userData.username || !userData.password) {
results.failed++
continue
}
const passwordViolation = getAdminPasswordViolation(userData.password)
if (passwordViolation) {
results.failed++
results.errors.push({
index: i,
username: normalizedUserData.username,
name: normalizedUserData.name,
reason: passwordViolation.message
})
continue
}
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(userData.name) || !chineseRegex.test(userData.username)) {
results.failed++
continue
}
if (usernamesInBatch.has(normalizedUserData.username)) {
results.failed++
results.errors.push({
index: i,
username: normalizedUserData.username,
name: normalizedUserData.name,
reason: '批处理中存在重复的账号'
})
continue
}
// 检查用户名是否已存在
const existingUser = await db
.select()
.from(users)
.where(eq(users.username, userData.username))
.limit(1)
usernamesInBatch.add(normalizedUserData.username)
// 限制单次查询 inArray 的长度,如果需要的话。前端分批已经是 50,所以没问题
validUsers.push({ index: i, ...normalizedUserData })
usernamesToQuery.push(normalizedUserData.username)
}
if (existingUser.length > 0) {
results.failed++
continue
}
if (validUsers.length === 0) {
return results
}
// 加密密码
const hashedPassword = await bcrypt.hash(userData.password, 10)
// 2. 批量查询已存在的用户名,减少数据库交互
let existingUsernames = new Set<string>()
try {
const existingUsersList = await db
.select({ username: users.username })
.from(users)
.where(inArray(users.username, usernamesToQuery))
existingUsernames = new Set(existingUsersList.map((u) => u.username))
} catch (error: any) {
console.error('批量查询用户失败:', error)
throw createError({
statusCode: 500,
message: '数据库查询错误'
})
}
// 3. 过滤掉已存在的用户
const usersToInsertData: ValidUserData[] = []
for (const userData of validUsers) {
if (existingUsernames.has(userData.username)) {
results.failed++
results.errors.push({
index: userData.index,
username: userData.username,
name: userData.name,
reason: '账号已存在'
})
continue
}
usersToInsertData.push(userData)
}
if (usersToInsertData.length === 0) {
return results
}
try {
// 4. 并发加密密码 (由于使用 bcrypt.hash 异步方法,使用 Promise.all 并发处理可以显著提速)
const hashedPasswords = await Promise.all(
usersToInsertData.map((userData) => bcrypt.hash(userData.password, 10))
)
// 5. 准备批量插入的数据
const insertValues = usersToInsertData.map((userData, idx) => {
// 角色权限控制
let validRole = 'USER' // 默认角色
if (userData.role) {
const rolePermissions: Record<string, string[]> = {
SUPER_ADMIN: ['USER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'],
ADMIN: ['USER', 'SONG_ADMIN']
}
const allowedRoles = rolePermissions[user.role]
if (allowedRoles?.includes(userData.role)) {
validRole = userData.role
if (user.role === 'SUPER_ADMIN') {
// 超级管理员可以设置任何角色
const allowedRoles = ['USER', 'TEACHER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN']
if (allowedRoles.includes(userData.role)) {
validRole = userData.role
}
} else if (user.role === 'ADMIN') {
// 普通管理员只能设置 USER 和 SONG_ADMIN 角色
const allowedRoles = ['USER', 'TEACHER', 'SONG_ADMIN']
if (allowedRoles.includes(userData.role)) {
validRole = userData.role
}
}
}
@@ -178,31 +85,34 @@ export default defineEventHandler(async (event) => {
validStatus = userData.status
}
return {
name: userData.name,
username: userData.username,
password: hashedPasswords[idx]!,
role: validRole,
status: validStatus as 'active' | 'withdrawn' | 'graduate',
grade: userData.grade,
class: userData.class
}
})
// 创建用户
const newUser = await db
.insert(users)
.values({
name: userData.name,
username: userData.username,
password: hashedPassword,
role: validRole,
status: validStatus as 'active' | 'withdrawn' | 'graduate',
grade: userData.grade,
class: userData.class
})
.returning()
// 6. 执行批量插入
await db.insert(users).values(insertValues)
results.created += insertValues.length
} catch (error: any) {
console.error(`批量插入用户失败:`, error)
// 如果批量插入整体失败,则全部标记为失败
for (const userData of usersToInsertData) {
results.created++
} catch (error) {
console.error(`用户创建失败:`, error)
results.failed++
results.errors.push({
index: userData.index,
username: userData.username,
name: userData.name,
reason: error.message || '数据库批量写入失败'
})
}
}
// 如果有用户创建成功,清除相关缓存
if (results.created > 0) {
try {
await cacheService.clearSongsCache()
console.log('批量用户创建后缓存清除成功')
} catch (cacheError) {
console.error('批量用户创建后缓存清除失败:', cacheError)
}
}
+28 -60
View File
@@ -1,48 +1,25 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { and, asc, desc, count, eq, ilike, inArray, isNull, notInArray, or, sql } from 'drizzle-orm'
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
import { ARCHIVED_USER_STATUSES, resolveArchivedFilter } from '~~/server/utils/user-archive'
const UNSET_FILTER_VALUE = '__UNSET__'
import { and, asc, desc, count, eq, ilike, or, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
try {
// 检查用户是否为管理员
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有系统管理员可以访问用户列表'
})
}
// 获取查询参数
const query = getQuery(event)
const { grade, class: className, search, page = '1', limit = '50', role, status, sortBy = 'id', sortOrder = 'asc', archived } = query
const { grade, class: className, search, page = '1', limit = '50', role, status, sortBy = 'id', sortOrder = 'asc' } = query
// 构建筛选条件
const whereConditions = []
// 年级筛选
if (grade && typeof grade === 'string' && grade.trim()) {
const gradeFilter = grade.trim()
whereConditions.push(
gradeFilter === UNSET_FILTER_VALUE
? or(isNull(users.grade), eq(users.grade, ''))
: eq(users.grade, gradeFilter)
)
whereConditions.push(eq(users.grade, grade.trim()))
}
// 班级筛选
if (className && typeof className === 'string' && className.trim()) {
const classFilter = className.trim()
whereConditions.push(
classFilter === UNSET_FILTER_VALUE
? or(isNull(users.class), eq(users.class, ''))
: eq(users.class, classFilter)
)
whereConditions.push(eq(users.class, className.trim()))
}
// 角色筛选
@@ -52,18 +29,7 @@ export default defineEventHandler(async (event) => {
// 状态筛选
if (status && typeof status === 'string' && status.trim()) {
const statusFilter = status.trim()
if (['active', 'pending', 'withdrawn', 'graduate'].includes(statusFilter)) {
whereConditions.push(eq(users.status, statusFilter as 'active' | 'pending' | 'withdrawn' | 'graduate'))
}
}
// 归档筛选:archived=1 仅查已归档(graduate/withdrawn);archived=0 排除已归档;缺省不限制
const archivedFilter = resolveArchivedFilter(archived)
if (archivedFilter === 'archived') {
whereConditions.push(inArray(users.status, [...ARCHIVED_USER_STATUSES]))
} else if (archivedFilter === 'unarchived') {
whereConditions.push(notInArray(users.status, [...ARCHIVED_USER_STATUSES]))
whereConditions.push(eq(users.status, status.trim()))
}
// 搜索功能(姓名、用户名或IP地址)
@@ -82,12 +48,12 @@ export default defineEventHandler(async (event) => {
// 分页参数
const pageNum = Math.max(1, parseInt(page as string) || 1)
const limitNum = Math.min(Math.max(1, parseInt(limit as string) || 50), 1000)
const limitNum = Math.max(1, parseInt(limit as string) || 50)
const skip = (pageNum - 1) * limitNum
// 获取总数
const totalResult = await db.select({ count: count() }).from(users).where(whereClause)
const total = totalResult[0]?.count || 0
const total = totalResult[0].count
// 排序逻辑
let orderByClause
@@ -129,33 +95,35 @@ export default defineEventHandler(async (event) => {
forcePasswordChange: true,
meowNickname: true,
meowBoundAt: true,
email: true,
emailVerified: true,
remark: true,
createdAt: true,
updatedAt: true,
avatarProvider: true,
avatarProviderUserId: true
avatarProviderUserId: true,
email: true
},
with: {
identities: {
columns: {
provider: true,
providerUsername: true,
providerUserId: true,
avatar: true,
createdAt: true
}
}
identities: true
}
})
// 处理用户列表,添加头像字段
const { getUserAvatarUrl } = await import('~~/server/utils/user-avatar')
const formattedUsers = usersList.map((user) => {
const avatarSource = resolveAvatarSource(user, user.identities || [])
const avatarUrl = getUserAvatarUrl(
{
id: user.id,
name: user.name,
username: user.username,
email: user.email,
avatarProvider: user.avatarProvider,
avatarProviderUserId: user.avatarProviderUserId
},
user.identities || []
)
return {
...user,
avatar: avatarSource?.url ?? null
avatar: avatarUrl,
avatarUrl
}
})
@@ -183,11 +151,11 @@ export default defineEventHandler(async (event) => {
search: search || null
}
}
} catch (error: any) {
} catch (error) {
console.error('获取用户列表失败:', error)
throw createError({
statusCode: 500,
message: '获取用户列表失败: ' + error.message
statusMessage: '获取用户列表失败: ' + error.message
})
}
})
+38 -47
View File
@@ -2,14 +2,7 @@ import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
import { getAdminPasswordViolation } from '~~/server/utils/admin-password-policy'
const normalizeRequiredText = (value: unknown) => String(value || '').trim()
const normalizeOptionalText = (value: unknown) => {
const normalized = String(value || '').trim()
return normalized || null
}
import { getRoleLevel, isRoleHigher, canManageUserRole } from '~~/server/utils/permissions'
export default defineEventHandler(async (event) => {
// 检查认证和权限
@@ -22,21 +15,22 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event)
const normalizedName = normalizeRequiredText(body.name)
const normalizedUsername = normalizeRequiredText(body.username)
const initialPassword = typeof body.password === 'string' ? body.password : ''
// 验证必填字段
if (!normalizedName || !normalizedUsername || !initialPassword) {
if (!body.name || !body.username || !body.password) {
throw createError({
statusCode: 400,
message: '姓名、用户名和密码不能为空'
message: '姓名、账号名和密码不能为空'
})
}
const violation = getAdminPasswordViolation(initialPassword)
if (violation) {
throw createApiError(400, violation.code, violation.message)
// 验证姓名和用户名只能包含汉字
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(body.name) || !chineseRegex.test(body.username)) {
throw createError({
statusCode: 400,
message: '姓名和账号名只能包含汉字'
})
}
try {
@@ -44,45 +38,35 @@ export default defineEventHandler(async (event) => {
const existingUserResult = await db
.select()
.from(users)
.where(eq(users.username, normalizedUsername))
.where(eq(users.username, body.username))
.limit(1)
const existingUser = existingUserResult[0]
if (existingUser) {
throw createError({
statusCode: 400,
message: '用户名已存在'
message: '账号名已存在'
})
}
// 加密密码
const hashedPassword = await bcrypt.hash(initialPassword, 10)
const hashedPassword = await bcrypt.hash(body.password, 10)
// 角色权限控制
// 角色权限控制(统一使用 canManageUserRole 数值化级别检查)
let validRole = 'USER'
if (body.role && ['USER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(body.role)) {
// 超级管理员可以创建任何角色的用户
if (user.role === 'SUPER_ADMIN') {
validRole = body.role
}
// 管理员只能创建管理员以下的角色(USER, SONG_ADMIN)
else if (user.role === 'ADMIN') {
if (['USER', 'SONG_ADMIN'].includes(body.role)) {
validRole = body.role
} else {
throw createError({
statusCode: 403,
message: '管理员只能创建用户和歌曲管理员角色'
})
}
}
// 其他角色不能创建用户
else {
const requestedRole = body.role
if (requestedRole) {
if (!canManageUserRole(user, requestedRole)) {
const currentLevel = getRoleLevel(user.role)
const targetLevel = getRoleLevel(requestedRole)
throw createError({
statusCode: 403,
message: '没有权限创建用户'
message: isRoleHigher(requestedRole, user.role) || currentLevel <= targetLevel
? `权限不足:${user.role} 无法创建级别高于或等于自身的角色(${requestedRole})`
: `没有权限创建 ${requestedRole} 角色的用户`
})
}
validRole = requestedRole
}
// 状态验证
@@ -95,13 +79,13 @@ export default defineEventHandler(async (event) => {
const newUserResult = await db
.insert(users)
.values({
name: normalizedName,
username: normalizedUsername,
name: body.name,
username: body.username,
password: hashedPassword,
role: validRole,
status: validStatus as 'active' | 'withdrawn' | 'graduate',
grade: normalizeOptionalText(body.grade),
class: normalizeOptionalText(body.class)
grade: body.grade,
class: body.class
})
.returning({
id: users.id,
@@ -116,16 +100,23 @@ export default defineEventHandler(async (event) => {
})
const newUser = newUserResult[0]
// 清除相关缓存
try {
const { cache } = await import('~~/server/utils/cache-helpers')
await cache.deletePattern('songs:*')
await cache.deletePattern('stats:*')
console.log('[Cache] 歌曲和统计缓存已清除(用户创建)')
} catch (cacheError) {
console.warn('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
user: newUser,
message: '用户创建成功'
}
} catch (error: any) {
} catch (error) {
console.error('创建用户失败:', error)
if (error?.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '创建用户失败'
+16 -18
View File
@@ -1,7 +1,7 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { and, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm'
import { and, count, desc, eq, ilike, or } from 'drizzle-orm'
import { getStatusText } from '~~/server/utils/user'
export default defineEventHandler(async (event) => {
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
statusMessage: '没有权限访问'
})
}
@@ -46,14 +46,15 @@ export default defineEventHandler(async (event) => {
.select({
id: userStatusLogs.id,
userId: userStatusLogs.userId,
// 优先快照列(用户删除后审计可追溯);存量旧日志快照为 NULL 时回退 join 的用户名
userName: sql`COALESCE(${userStatusLogs.name}, ${users.name})`,
userUsername: sql`COALESCE(${userStatusLogs.username}, ${users.username})`,
userName: users.name,
userUsername: users.username,
oldStatus: userStatusLogs.oldStatus,
newStatus: userStatusLogs.newStatus,
reason: userStatusLogs.reason,
createdAt: userStatusLogs.createdAt,
operatorId: userStatusLogs.operatorId
operatorId: userStatusLogs.operatorId,
operatorName: users.name,
operatorUsername: users.username
})
.from(userStatusLogs)
.leftJoin(users, eq(userStatusLogs.userId, users.id))
@@ -66,13 +67,15 @@ export default defineEventHandler(async (event) => {
.select({
id: userStatusLogs.id,
userId: userStatusLogs.userId,
userName: sql`COALESCE(${userStatusLogs.name}, ${users.name})`,
userUsername: sql`COALESCE(${userStatusLogs.username}, ${users.username})`,
userName: users.name,
userUsername: users.username,
oldStatus: userStatusLogs.oldStatus,
newStatus: userStatusLogs.newStatus,
reason: userStatusLogs.reason,
createdAt: userStatusLogs.createdAt,
operatorId: userStatusLogs.operatorId
operatorId: userStatusLogs.operatorId,
operatorName: users.name,
operatorUsername: users.username
})
.from(userStatusLogs)
.leftJoin(users, eq(userStatusLogs.userId, users.id))
@@ -80,9 +83,6 @@ export default defineEventHandler(async (event) => {
and(
whereClause,
or(
ilike(userStatusLogs.name, `%${searchTerm}%`),
ilike(userStatusLogs.username, `%${searchTerm}%`),
// 存量旧日志快照列为 NULL,回退 join 的用户名列以便可搜
ilike(users.name, `%${searchTerm}%`),
ilike(users.username, `%${searchTerm}%`),
ilike(userStatusLogs.reason, `%${searchTerm}%`)
@@ -106,8 +106,6 @@ export default defineEventHandler(async (event) => {
and(
whereClause,
or(
ilike(userStatusLogs.name, `%${searchTerm}%`),
ilike(userStatusLogs.username, `%${searchTerm}%`),
ilike(users.name, `%${searchTerm}%`),
ilike(users.username, `%${searchTerm}%`),
ilike(userStatusLogs.reason, `%${searchTerm}%`)
@@ -137,7 +135,7 @@ export default defineEventHandler(async (event) => {
username: users.username
})
.from(users)
.where(inArray(users.id, operatorIds))
.where(eq(users.id, operatorIds[0])) // 这里需要用inArray,但先简化处理
const operatorMap = new Map()
for (const op of operators) {
@@ -166,8 +164,8 @@ export default defineEventHandler(async (event) => {
createdAt: log.createdAt,
operator: {
id: log.operatorId,
name: operatorMap.get(log.operatorId)?.name || '未知操作员',
username: operatorMap.get(log.operatorId)?.username || 'unknown'
name: operatorMap.get(log.operatorId)?.name || log.operatorName || '未知操作员',
username: operatorMap.get(log.operatorId)?.username || log.operatorUsername || 'unknown'
}
})),
pagination: {
@@ -193,7 +191,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: '获取状态变更日志失败: ' + error.message
statusMessage: '获取状态变更日志失败: ' + error.message
})
}
})
+81
View File
@@ -0,0 +1,81 @@
import { getQuery, createError } from 'h3'
import axios from 'axios'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const url = query.url as string
if (!url || !url.includes('kugou.com')) {
throw createError({
statusCode: 400,
message: 'Invalid Kugou URL'
})
}
try {
// 1. 获取重定向后的最终URL或HTML内容
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
}
})
// 很多时候链接会跳转,或者HTML里面有 hash 变量
const html = response.data
// 尝试正则匹配 "hash":"xxx" 或者 hash: 'xxx'
const hashMatch = html.match(/"hash"\s*:\s*"([a-zA-Z0-9]+)"/i) || html.match(/hash\s*=\s*"([a-zA-Z0-9]+)"/i) || html.match(/"320hash"\s*:\s*"([a-zA-Z0-9]+)"/i)
const albumMatch = html.match(/"album_id"\s*:\s*(\d+)/i)
if (hashMatch && hashMatch[1]) {
const hash = hashMatch[1]
// 用 hash 再次查询歌曲详情或者直接返回
// 我们可以调用移动端 track info API
const infoUrl = `http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=${hash}`
const infoRes = await axios.get(infoUrl, { validateStatus: () => true })
if (infoRes.data && infoRes.data.songName) {
return {
id: hash,
title: infoRes.data.songName,
artist: infoRes.data.singerName,
cover: infoRes.data.imgUrl ? infoRes.data.imgUrl.replace('{size}', '150') : '',
duration: infoRes.data.timeLength * 1000,
musicPlatform: 'kugou',
musicId: hash,
hasUrl: true,
isVip: infoRes.data.privilege >= 10,
sourceInfo: {
source: 'kugou',
originalId: hash,
fetchedAt: new Date()
}
}
}
// 如果 getSongInfo 失败,我们只返回 hash
return {
id: hash,
title: "解析成功,请点击投稿",
artist: "未知歌手",
musicPlatform: 'kugou',
musicId: hash,
hasUrl: true,
sourceInfo: {
source: 'kugou',
originalId: hash,
fetchedAt: new Date()
}
}
}
throw createError({
statusCode: 404,
message: 'Cannot find song hash in the provided link'
})
} catch (error: any) {
throw createError({
statusCode: 500,
message: error.message || 'Failed to parse link'
})
}
})
+73
View File
@@ -0,0 +1,73 @@
import { getQuery, createError } from 'h3'
import axios from 'axios'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const hash = query.hash as string
if (!hash) {
throw createError({
statusCode: 400,
message: 'Hash is required'
})
}
try {
// 首先尝试通过移动端API获取播放链接
const infoUrl = `http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=${hash}`
const infoRes = await axios.get(infoUrl, { validateStatus: () => true })
if (infoRes.data && infoRes.data.url) {
return {
data: {
play_url: infoRes.data.url
}
}
}
// 如果失败或者需要付费(VIP),通过标题和歌手名从网易云匹配一个试听链接
if (infoRes.data && infoRes.data.songName) {
const keyword = `${infoRes.data.songName} ${infoRes.data.singerName || ''}`.trim()
try {
// 搜索网易云音乐
const searchUrl = `https://music.163.com/api/search/get/web?s=${encodeURIComponent(keyword)}&type=1&offset=0&total=true&limit=1`
const searchRes = await axios.get(searchUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
})
if (searchRes.data?.result?.songs?.length > 0) {
const songId = searchRes.data.result.songs[0].id
// 获取网易云播放链接
const neteaseUrl = `https://music.163.com/song/media/outer/url?id=${songId}.mp3`
return {
data: {
play_url: neteaseUrl
}
}
}
} catch (e) {
console.error('Fallback to Netease failed:', e)
}
}
// 如果都没有,尝试使用原本的 API
const url = `http://www.kugou.com/yy/index.php?r=play/getdata&hash=${hash}`
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Cookie': 'kg_mid=1234567890'
},
validateStatus: () => true
})
return response.data
} catch (error) {
throw createError({
statusCode: 500,
message: 'Music service unavailable'
})
}
})
+54
View File
@@ -0,0 +1,54 @@
import axios from 'axios'
import crypto from 'crypto'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const keyword = query.keyword || query.keywords
if (!keyword) {
throw createError({
statusCode: 400,
message: 'Keyword is required'
})
}
// 我们在这里使用不需要签名的通用接口或移动端旧接口
try {
const url = `http://mobilecdn.kugou.com/api/v3/search/song?format=json&keyword=${encodeURIComponent(keyword as string)}&page=1&pagesize=20&showtype=1`
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
// 允许处理不严谨的 SSL 证书或忽略部分请求错误
validateStatus: () => true
})
const data = response.data
// Process results to map cover images and VIP status correctly
if (data && data.data && data.data.info) {
data.data.info = data.data.info.map((item: any) => {
return {
...item,
isVip: item.pay_type !== 0,
// Extract proper image URL from 'union_cover', '320' size or hash, and use https
pic: item.trans_param?.union_cover
? item.trans_param.union_cover.replace('{size}', '400').replace('http://', 'https://')
: item['320hash']
? `https://imge.kugou.com/stdmusic/400/${item['320hash'].slice(0,2)}/${item['320hash'].slice(2,4)}/${item['320hash']}.jpg`
: item['hash']
? `https://imge.kugou.com/stdmusic/400/${item['hash'].slice(0,2)}/${item['hash'].slice(2,4)}/${item['hash']}.jpg`
: undefined
}
})
}
return data
} catch (error) {
throw createError({
statusCode: 500,
message: 'Music service unavailable'
})
}
})
+38 -31
View File
@@ -1,53 +1,46 @@
import { db } from '~/drizzle/db'
import { songs, users } from '~/drizzle/schema'
import { and, eq, or } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { cacheService } from '~~/server/services/cacheService'
import { normalizeCoverUrl } from '~~/server/utils/url'
export default defineEventHandler(async (event) => {
try {
// 验证请求方法
if (event.node.req.method !== 'POST') {
throw createApiError(405, 'HTTP_METHOD_NOT_ALLOWED', 'Method Not Allowed')
throw createError({
statusCode: 405,
statusMessage: 'Method Not Allowed'
})
}
// 获取已验证的用户信息(由中间件提供)
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
throw createError({
statusCode: 401,
statusMessage: '未授权访问'
})
}
// 检查权限
if (!['ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createApiError(403, 'COMMON_INSUFFICIENT_PERMISSION', '权限不足')
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
// 获取请求体
const body = await readBody(event)
const {
title,
artist,
requester,
semester,
musicPlatform,
musicId,
cover,
playUrl,
durationSeconds,
preferredPlayTimeId
} = body
const { title, artist, requester, semester, musicPlatform, musicId, cover, playUrl } = body
// 验证必填字段
if (!title || !artist) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_TITLE_ARTIST_REQUIRED, 'Title and artist are required')
}
// 校验时长范围(30秒~1小时)
if (durationSeconds !== null && durationSeconds !== undefined) {
const d = Number(durationSeconds)
if (!Number.isFinite(d) || d < 30 || d > 3600) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'Invalid song duration (30s–1h)')
}
throw createError({
statusCode: 400,
statusMessage: 'Title and artist are required'
})
}
// 查找投稿人用户
@@ -89,22 +82,28 @@ export default defineEventHandler(async (event) => {
const existingSong = existingSongResult[0]
if (existingSong) {
throw createApiError(409, 'SONG_ALREADY_EXISTS', 'Song already exists')
throw createError({
statusCode: 409,
statusMessage: 'Song already exists'
})
}
const normalizedCover = normalizeCoverUrl(cover, `add-song:${title || ''}`)
// 创建歌曲
const newSongResult = await db
.insert(songs)
.values({
title: title.trim(),
artist: artist.trim(),
singer: artist.trim(),
requesterId,
semester: semester || null,
preferredPlayTimeId: preferredPlayTimeId || null,
musicPlatform: musicPlatform || null,
musicId: musicId || null,
durationSeconds: durationSeconds ? Number(durationSeconds) : null,
cover: cover || null,
kgHash: musicPlatform === 'kugou' ? musicId : null,
cover: normalizedCover,
coverUrl: normalizedCover,
playUrl: playUrl || null
})
.returning()
@@ -132,6 +131,14 @@ export default defineEventHandler(async (event) => {
requester: requesterInfo
}
// 清除歌曲相关缓存
try {
await cacheService.clearSongsCache()
console.log('[Cache] 歌曲缓存已清除(添加歌曲)')
} catch (error) {
console.error('清除歌曲缓存失败:', error)
}
return {
success: true,
song: songWithRequester
@@ -145,7 +152,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: error.message || 'Internal server error'
statusMessage: error.message || 'Internal server error'
})
}
})
+532 -4
View File
@@ -1,14 +1,542 @@
import { requestSongForUser } from '~~/server/services/songRequestService'
import { createApiError } from '~~/server/utils/apiError'
import {
collaborationLogs,
db,
playTimes,
requestTimes,
semesters,
songBlacklists,
songCollaborators,
songs,
systemSettings
} from '~/drizzle/db'
import { and, eq, gt, gte, inArray, lt, lte, sql } from 'drizzle-orm'
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
import { isLimitReached } from '~~/server/utils/submissionLimit'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
import { normalizeCoverUrl } from '~~/server/utils/url'
export default defineEventHandler(async (event) => {
// 检查用户认证
const user = event.context.user
if (!user) {
throw createApiError(401, 'SONG_LOGIN_REQUIRED_REQUEST', '需要登录才能点歌')
throw createError({
statusCode: 401,
message: '需要登录才能点歌'
})
}
const body = await readBody(event)
return await requestSongForUser(event, user, body)
if (!body.title || !body.artist) {
throw createError({
statusCode: 400,
message: '歌曲名称和艺术家不能为空'
})
}
try {
// 标准化字符串用于精确匹配
const normalizeForMatch = (str: string): string => {
return str
.toLowerCase()
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
.replace(/[&&]/g, 'and')
.replace(/[feat\.?|ft\.?]/gi, '')
.trim()
}
const normalizedTitle = normalizeForMatch(body.title)
const normalizedArtist = normalizeForMatch(body.artist)
// 获取当前学期
const currentSemester = await getCurrentSemesterName()
// 哔哩哔哩平台判定逻辑
const isBilibili =
body.musicPlatform === 'bilibili' ||
String(body.musicId || '').startsWith('BV') ||
String(body.musicId || '').startsWith('av')
// 对于哔哩哔哩平台,如果有 musicId,使用 musicId 进行精确匹配
if (isBilibili && body.musicId) {
// 构建完整的 musicId(包含分P信息)
let fullMusicId = String(body.musicId)
const bvId = fullMusicId.split(':')[0]
if (body.bilibiliCid) {
const musicIdParts = [bvId, body.bilibiliCid]
if (body.bilibiliPage && Number(body.bilibiliPage) > 1) {
musicIdParts.push(String(body.bilibiliPage))
}
fullMusicId = musicIdParts.join(':')
}
// 检查是否已有相同 musicId 的歌曲
const existingSongs = await db
.select({
id: songs.id,
musicId: songs.musicId,
played: songs.played
})
.from(songs)
.where(
and(
eq(songs.semester, currentSemester),
eq(songs.musicPlatform, 'bilibili'),
eq(songs.musicId, fullMusicId)
)
)
if (existingSongs.length > 0) {
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const hasUnplayedDuplicate = existingSongs.some((s) => !s.played)
if (!isSuperAdmin || hasUnplayedDuplicate) {
throw createError({
statusCode: 400,
message: `《${body.title}》已经在列表中,不能重复投稿`
})
}
}
} else {
// 对于其他平台,使用标题和艺术家进行匹配
const allSongs = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist,
semester: songs.semester,
played: songs.played
})
.from(songs)
.where(eq(songs.semester, currentSemester))
const matchingSongs = allSongs.filter((song) => {
const songTitle = normalizeForMatch(song.title)
const songArtist = normalizeForMatch(song.artist)
return songTitle === normalizedTitle && songArtist === normalizedArtist
})
if (matchingSongs.length > 0) {
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const hasUnplayedDuplicate = matchingSongs.some((s) => !s.played)
if (!isSuperAdmin || hasUnplayedDuplicate) {
throw createError({
statusCode: 400,
message: `《${body.title}》已经在列表中,不能重复投稿`
})
}
}
}
// 检查投稿限额(管理员不受限制)
const systemSettingsResult = await db.select().from(systemSettings).limit(1)
const systemSettingsData = systemSettingsResult[0]
const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
// 加载自动审查需要的黑名单(KEYWORD / LANGUAGE / GENRE)
let moderationBlacklists: any[] = []
try {
moderationBlacklists = await db
.select()
.from(songBlacklists)
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
} catch (err) {
console.warn('[Request API] 加载自动审查黑名单失败,将跳过自动审查:', err)
moderationBlacklists = []
}
// 1. 检查强制关闭
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
throw createError({
statusCode: 403,
message: '投稿功能已关闭'
})
}
// 2. 检查投稿时段限制
let hitRequestTime: any = null
if (systemSettingsData?.enableRequestTimeLimitation && !isAdmin) {
// 使用 ISO 字符串格式进行比较,确保数据库能够正确处理
const currentTime = getBeijingTimeISOString()
const hitRequestTimeResult = await db
.select()
.from(requestTimes)
.where(
and(
lte(requestTimes.startTime, currentTime),
gt(requestTimes.endTime, currentTime),
eq(requestTimes.enabled, true)
)
)
.limit(1)
hitRequestTime = hitRequestTimeResult[0]
if (!hitRequestTime) {
throw createError({
statusCode: 403,
message: '当前不在投稿开放时段'
})
}
if (hitRequestTime.expected > 0 && hitRequestTime.accepted >= hitRequestTime.expected) {
throw createError({
statusCode: 403,
message: `当前时段投稿名额已满(${hitRequestTime.accepted}/${hitRequestTime.expected})`
})
}
}
// 确定生效的限额类型(三选一逻辑)
let effectiveLimit: number | null = null
let limitType: 'daily' | 'weekly' | 'monthly' | null = null
if (systemSettingsData?.enableSubmissionLimit && !isAdmin) {
const dailyLimit = systemSettingsData.dailySubmissionLimit
const weeklyLimit = systemSettingsData.weeklySubmissionLimit
const monthlyLimit = systemSettingsData.monthlySubmissionLimit
if (dailyLimit !== null && dailyLimit !== undefined) {
effectiveLimit = dailyLimit
limitType = 'daily'
} else if (weeklyLimit !== null && weeklyLimit !== undefined) {
effectiveLimit = weeklyLimit
limitType = 'weekly'
} else if (monthlyLimit !== null && monthlyLimit !== undefined) {
effectiveLimit = monthlyLimit
limitType = 'monthly'
}
// 如果生效的限额为0,则关闭投稿
if (effectiveLimit === 0) {
throw createError({
statusCode: 403,
message: '投稿功能已关闭'
})
}
// 如果有生效的限额且大于0,检查使用量
// 注意:这里仅作初步检查,严格检查在事务内进行
}
// 检查期望的播出时段是否存在
let preferredPlayTime = null
if (body.preferredPlayTimeId) {
// 检查系统设置是否允许选择播出时段
if (!systemSettingsData?.enablePlayTimeSelection) {
throw createError({
statusCode: 400,
message: '播出时段选择功能未启用'
})
}
// 检查播出时段是否存在且已启用
const playTimeResult = await db
.select()
.from(playTimes)
.where(and(eq(playTimes.id, body.preferredPlayTimeId), eq(playTimes.enabled, true)))
.limit(1)
preferredPlayTime = playTimeResult[0]
if (!preferredPlayTime) {
throw createError({
statusCode: 400,
message: '选择的播出时段不存在或未启用'
})
}
}
const rawSubmissionNote = typeof body.submissionNote === 'string' ? body.submissionNote.trim() : ''
if (rawSubmissionNote.length > 300) {
throw createError({
statusCode: 400,
message: '备注留言不能超过300个字符'
})
}
const submissionNote =
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
const rawSubmissionNotePublic = submissionNote !== null ? body.submissionNotePublic !== false : false
const requiresApproval = Boolean(systemSettingsData?.submissionNoteRequiresApproval)
let finalSubmissionNotePublic = rawSubmissionNotePublic
let submissionNotePublicStatus: string | null = null
if (submissionNote && rawSubmissionNotePublic && requiresApproval) {
const mod = moderateSubmissionNote(
submissionNote,
rawSubmissionNotePublic,
requiresApproval,
{ title: body.title, artist: body.artist, genre: body.genre || null },
moderationBlacklists
)
finalSubmissionNotePublic = mod.notePublic
submissionNotePublicStatus = mod.publicStatus
if (mod.rejectedReasons.length > 0) {
console.log(
`[Request API] 投稿留言自动审查未通过,歌曲=${body.title} - ${body.artist},原因:`,
mod.rejectedReasons
)
}
} else if (submissionNote) {
// 没开自动审查,也记录一下状态
submissionNotePublicStatus = null
}
const submissionNotePublic = finalSubmissionNotePublic
// 准备发送通知的列表
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
// 创建歌曲和更新状态(使用事务)
const song = await db.transaction(async (tx) => {
// 检查投稿限额(在事务内进行,防止并发绕过限制)
if (
systemSettingsData?.enableSubmissionLimit &&
!isAdmin &&
effectiveLimit &&
effectiveLimit > 0 &&
limitType
) {
if (await isLimitReached(tx, user.id, limitType, effectiveLimit)) {
const labelMap: Record<string, string> = { daily: '每日', weekly: '每周', monthly: '每月' }
const timeMap: Record<string, string> = { daily: '今日', weekly: '本周', monthly: '本月' }
throw createError({
statusCode: 400,
message: `${labelMap[limitType]}投稿限额为${effectiveLimit}首,您${timeMap[limitType]}已达到限额`
})
}
}
// 如果有时段限制,再次检查并更新已接纳数量
if (hitRequestTime) {
const latestRequestTimeResult = await tx
.select()
.from(requestTimes)
.where(eq(requestTimes.id, hitRequestTime.id))
.limit(1)
const latestRequestTime = latestRequestTimeResult[0]
if (!latestRequestTime || !latestRequestTime.enabled) {
throw createError({ statusCode: 403, message: '投稿时段已失效' })
}
// 使用原子更新确保并发安全
const updateResult = await tx
.update(requestTimes)
.set({
accepted: sql`${requestTimes.accepted} + 1`
})
.where(
and(
eq(requestTimes.id, hitRequestTime.id),
// 如果有限额,确保更新时未超限(针对 expected > 0 的情况)
latestRequestTime.expected > 0
? lt(requestTimes.accepted, latestRequestTime.expected)
: undefined
)
)
.returning()
if (updateResult.length === 0) {
throw createError({ statusCode: 403, message: '当前时段投稿名额已满' })
}
}
if (!body.expectedPlayDate || body.expectedPlaySequence === undefined || body.expectedPlaySequence === null) {
throw createError({ statusCode: 400, message: '请选择期望播出日期与期望播放序号' })
}
// 检查 expectedPlaySequence 是否已被占用
if (body.expectedPlayDate && body.expectedPlaySequence !== undefined && body.expectedPlaySequence !== null) {
const expectedSequence = Number(body.expectedPlaySequence)
const occupiedCheck = await tx
.select({ id: songs.id })
.from(songs)
.where(
and(
eq(songs.expectedPlayDate, body.expectedPlayDate),
eq(songs.expectedPlaySequence, expectedSequence),
eq(songs.played, false) // 只有未播放的才算被占用
)
)
.limit(1)
if (occupiedCheck.length > 0) {
throw createError({
statusCode: 400,
message: `日期 ${body.expectedPlayDate} 的第 ${expectedSequence} 序号已被占用,请选择其他序号`
})
}
}
// 创建歌曲
let finalMusicId = body.musicId ? String(body.musicId) : null
// 如果是 Bilibili 平台,处理 musicId 格式
if (isBilibili) {
const bvId = finalMusicId?.split(':')[0]
if (bvId) {
const musicIdParts = [bvId]
if (body.bilibiliCid) {
musicIdParts.push(body.bilibiliCid)
if (body.bilibiliPage && Number(body.bilibiliPage) > 1) {
musicIdParts.push(String(body.bilibiliPage))
}
}
finalMusicId = musicIdParts.join(':')
}
}
const normalizedCover = normalizeCoverUrl(body.cover, `request-song:${body.title || ''}`)
const songResult = await tx
.insert(songs)
.values({
title: body.title,
artist: body.artist,
singer: body.artist,
requesterId: user.id,
preferredPlayTimeId: preferredPlayTime?.id || null,
semester: currentSemester, // 使用外部获取的学期名称
cover: normalizedCover,
coverUrl: normalizedCover,
musicPlatform: isBilibili ? 'bilibili' : (body.musicPlatform || null),
musicId: finalMusicId,
kgHash: body.musicPlatform === 'kugou' ? body.musicId : null,
playUrl: body.playUrl || null,
submissionNote,
submissionNotePublic,
submissionNotePublicStatus,
hitRequestId: hitRequestTime?.id || null,
expectedPlayDate: body.expectedPlayDate || null,
expectedPlaySequence: body.expectedPlaySequence !== undefined ? Number(body.expectedPlaySequence) : null
})
.returning()
const newSong = songResult[0]
// 处理联合投稿人
if (
body.collaborators &&
Array.isArray(body.collaborators) &&
body.collaborators.length > 0
) {
// 去重
const uniqueCollaboratorIds = [...new Set(body.collaborators.map((id: any) => Number(id)))]
for (const collaboratorId of uniqueCollaboratorIds) {
// 跳过自己或无效ID
if (isNaN(collaboratorId) || collaboratorId === user.id) continue
try {
// 检查是否已经是联合投稿人
const existingCollab = await tx
.select()
.from(songCollaborators)
.where(
and(
eq(songCollaborators.songId, newSong.id),
eq(songCollaborators.userId, collaboratorId)
)
)
.limit(1)
if (existingCollab.length > 0) continue
// 创建联合投稿记录
const collabResult = await tx
.insert(songCollaborators)
.values({
songId: newSong.id,
userId: collaboratorId,
status: 'PENDING'
})
.returning()
const collab = collabResult[0]
// 记录审计日志
await tx.insert(collaborationLogs).values({
collaboratorId: collab.id,
action: 'INVITE',
operatorId: user.id,
ipAddress:
(event.node.req.headers['x-forwarded-for'] as string) ||
event.node.req.socket.remoteAddress
})
// 添加到通知列表,事务结束后发送
notificationsToSend.push({
userId: collaboratorId,
songId: newSong.id,
songTitle: newSong.title
})
} catch (err) {
console.error(`邀请用户 ${collaboratorId} 失败:`, err)
}
}
}
return newSong
})
// 事务提交成功后,发送通知
// 即使通知发送失败,也不影响点歌结果
for (const notification of notificationsToSend) {
try {
await createCollaborationInvitationNotification(
user.id,
notification.userId,
notification.songId,
notification.songTitle
)
} catch (error) {
console.error(`发送邀请通知给用户 ${notification.userId} 失败:`, error)
}
}
return song
} catch (error: any) {
console.error('点歌失败:', error)
if (error.statusCode) {
throw error
} else if (error.message === '未设置活跃学期') {
throw createError({
statusCode: 400,
message: '系统未设置当前活跃学期,请联系管理员'
})
} else {
throw createError({
statusCode: 500,
message: '点歌失败,请稍后重试'
})
}
}
})
// 获取当前学期名称
async function getCurrentSemesterName() {
try {
// 获取当前活跃的学期
const currentSemesterResult = await db
.select()
.from(semesters)
.where(eq(semesters.isActive, true))
.limit(1)
const currentSemester = currentSemesterResult[0]
if (currentSemester) {
return currentSemester.name
}
// 如果没有活跃学期,抛出错误
throw new Error('未设置活跃学期')
} catch (error) {
console.error('获取当前学期失败:', error)
throw error
}
}