feat: 迁移自定义功能至 1.5.9.9 版本
包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
@@ -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: '需要管理员权限'
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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访问日志失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 || '删除备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 || '下载备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 || '创建数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: '获取备份文件列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '恢复数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 || '上传备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: '黑名单项删除成功'
|
||||
|
||||
@@ -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: '黑名单项更新成功',
|
||||
|
||||
@@ -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: '黑名单项添加成功',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { changelogEntries } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({ statusCode: 403, message: '只有管理员才能删除更新日志' })
|
||||
}
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
throw createError({ statusCode: 400, message: '无效的 id' })
|
||||
}
|
||||
|
||||
const rows = await db.delete(changelogEntries).where(eq(changelogEntries.id, id)).returning()
|
||||
if (!rows[0]) {
|
||||
throw createError({ statusCode: 404, message: '更新日志不存在' })
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { changelogEntries } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({ statusCode: 403, message: '只有管理员才能更新更新日志' })
|
||||
}
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
throw createError({ statusCode: 400, message: '无效的 id' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const updateData: any = { updatedAt: new Date() }
|
||||
|
||||
if (body?.version !== undefined) {
|
||||
if (body.version !== null && typeof body.version !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'version 必须是字符串' })
|
||||
}
|
||||
updateData.version = (body.version || '').trim()
|
||||
}
|
||||
|
||||
if (body?.title !== undefined) {
|
||||
if (body.title !== null && typeof body.title !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'title 必须是字符串' })
|
||||
}
|
||||
updateData.title = (body.title || '').trim()
|
||||
}
|
||||
|
||||
if (body?.content !== undefined) {
|
||||
if (body.content !== null && typeof body.content !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'content 必须是字符串' })
|
||||
}
|
||||
updateData.content = (body.content || '').trim()
|
||||
}
|
||||
|
||||
if (body?.published !== undefined) {
|
||||
if (typeof body.published !== 'boolean') {
|
||||
throw createError({ statusCode: 400, message: 'published 必须是布尔值' })
|
||||
}
|
||||
updateData.published = body.published
|
||||
updateData.publishedAt = body.published ? new Date() : null
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length <= 1) {
|
||||
throw createError({ statusCode: 400, message: '没有可更新的字段' })
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.update(changelogEntries)
|
||||
.set(updateData)
|
||||
.where(eq(changelogEntries.id, id))
|
||||
.returning()
|
||||
|
||||
if (!rows[0]) {
|
||||
throw createError({ statusCode: 404, message: '更新日志不存在' })
|
||||
}
|
||||
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { changelogEntries } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({ statusCode: 403, message: '只有管理员才能查看更新日志' })
|
||||
}
|
||||
|
||||
return db.select().from(changelogEntries).orderBy(desc(changelogEntries.createdAt))
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { changelogEntries } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({ statusCode: 403, message: '只有管理员才能创建更新日志' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const version = typeof body?.version === 'string' ? body.version.trim() : ''
|
||||
const title = typeof body?.title === 'string' ? body.title.trim() : ''
|
||||
const content = typeof body?.content === 'string' ? body.content.trim() : ''
|
||||
const published = body?.published === true
|
||||
|
||||
if (!version) {
|
||||
throw createError({ statusCode: 400, message: 'version 不能为空' })
|
||||
}
|
||||
if (!title) {
|
||||
throw createError({ statusCode: 400, message: 'title 不能为空' })
|
||||
}
|
||||
if (!content) {
|
||||
throw createError({ statusCode: 400, message: 'content 不能为空' })
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.insert(changelogEntries)
|
||||
.values({
|
||||
version,
|
||||
title,
|
||||
content,
|
||||
published,
|
||||
publishedAt: published ? new Date() : null,
|
||||
createdBy: user.id
|
||||
})
|
||||
.returning()
|
||||
|
||||
return rows[0]
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 || '服务器内部错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 || '保存失败' })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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: '需要管理员权限'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '投稿开放时段已成功删除'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '只有管理员才能管理投稿开放时段'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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: '角色配置已保存' }
|
||||
})
|
||||
@@ -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 || '创建排期失败'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 // 只有已播放过且有重播申请的才标记为重播
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '发布排期失败'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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} 首歌曲,无法删除`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, requestTimes } from '~/drizzle/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { cacheService } from '../../../services/cacheService'
|
||||
import { createSongRejectedNotification } from '../../../services/notificationService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '没有权限访问'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body.songId || !['approved', 'rejected'].includes(body.status)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '参数错误'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
let songForNotification = null
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
|
||||
const song = songResult[0]
|
||||
|
||||
if (!song) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '歌曲不存在'
|
||||
})
|
||||
}
|
||||
|
||||
songForNotification = song
|
||||
|
||||
if (body.status === 'rejected') {
|
||||
// 如果有 hitRequestId,减少对应时段的已接纳数量
|
||||
if (song.hitRequestId) {
|
||||
try {
|
||||
await tx
|
||||
.update(requestTimes)
|
||||
.set({
|
||||
accepted: sql`GREATEST(0, accepted - 1)`
|
||||
})
|
||||
.where(eq(requestTimes.id, song.hitRequestId))
|
||||
} catch (error) {
|
||||
console.error(`减少投稿时段接纳数量失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const note = song.submissionNote ? `[审核未通过] ${body.reason || ''}\n${song.submissionNote}` : `[审核未通过] ${body.reason || ''}`
|
||||
|
||||
// 释放排期序号并更新备注
|
||||
const updatedSong = await tx.update(songs).set({
|
||||
expectedPlayDate: null,
|
||||
expectedPlaySequence: null,
|
||||
hitRequestId: null,
|
||||
submissionNote: note
|
||||
}).where(eq(songs.id, body.songId)).returning()
|
||||
|
||||
return { success: true, song: updatedSong[0] }
|
||||
} else {
|
||||
const note = song.submissionNote ? `[审核已通过]\n${song.submissionNote}` : `[审核已通过]`
|
||||
const updatedSong = await tx.update(songs).set({
|
||||
submissionNote: note
|
||||
}).where(eq(songs.id, body.songId)).returning()
|
||||
|
||||
return { success: true, song: updatedSong[0] }
|
||||
}
|
||||
})
|
||||
|
||||
if (body.status === 'rejected' && songForNotification && songForNotification.requesterId) {
|
||||
createSongRejectedNotification(
|
||||
songForNotification.requesterId,
|
||||
{ title: songForNotification.title, artist: songForNotification.artist },
|
||||
body.reason?.trim() || '未过审释放排期',
|
||||
getClientIP(event)
|
||||
).catch(e => console.error(e))
|
||||
}
|
||||
|
||||
await cacheService.clearSongsCache()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: body.status === 'rejected' ? '已驳回并释放排期' : '审核已通过',
|
||||
data: result.song
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审核歌曲失败:', error)
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '审核失败: ' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
// 按投票数排行(默认)
|
||||
|
||||
@@ -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: '需要管理员权限'
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: '重置密码失败'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: '创建用户失败'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user