feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { defineEventHandler } from 'h3'
import { prepareBackup, executeUploads, acquireBackupLock, releaseBackupLock } from '~~/server/services/autoBackupService'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
export default defineEventHandler(async () => {
acquireBackupLock()
try {
const prepared = await prepareBackup()
// 必须等待上传完成再返回响应:
// Vercel Serverless 在响应返回后会冻结/回收函数实例,
// 若以 fire-and-forget 后台执行,上传会被中途掐断(socket closed)。
const result = await executeUploads(prepared)
return {
success: result.success,
message: result.success ? '备份完成' : '备份部分或全部失败',
backupId: prepared.historyId,
results: result.results
}
} catch (error: any) {
if (error.statusCode) throw error
throw createApiError(500, SERVER_ERROR_CODES.BACKUP_FAILED, error.message || '备份执行失败')
} finally {
releaseBackupLock()
}
})
+72
View File
@@ -0,0 +1,72 @@
import { defineEventHandler, getRouterParam } from 'h3'
import { eq, desc } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { backupHistory } from '~/drizzle/schema'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
/** 60 秒超时阈值 */
const TIMEOUT_THRESHOLD_MS = 60_000
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id')
if (!idParam) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少备份 ID')
}
let record
if (idParam === 'latest') {
const [latest] = await db
.select()
.from(backupHistory)
.orderBy(desc(backupHistory.createdAt))
.limit(1)
record = latest
} else {
const backupId = parseInt(idParam, 10)
if (isNaN(backupId)) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '无效的备份 ID')
}
const [found] = await db
.select()
.from(backupHistory)
.where(eq(backupHistory.id, backupId))
record = found
}
if (!record) {
throw createApiError(404, 'BACKUP_NOT_FOUND', '备份记录不存在')
}
const methods: Array<{ method: string; success: boolean; error?: string | null }> = JSON.parse(record.methods)
const allCompleted = methods.every(m => m.success || m.error != null)
const age = Date.now() - new Date(record.createdAt).getTime()
const completedCount = methods.filter(m => m.success || m.error != null).length
const succeededCount = methods.filter(m => m.success).length
const failedCount = methods.filter(m => m.error != null).length
const status = allCompleted
? (succeededCount === methods.length ? 'completed' : failedCount === methods.length ? 'failed' : 'partial')
: (age > TIMEOUT_THRESHOLD_MS ? 'timeout' : 'running')
return {
success: true,
data: {
id: record.id,
filename: record.filename,
status,
totalRecords: record.totalRecords,
backupSize: record.backupSize,
createdAt: record.createdAt,
progress: {
total: methods.length,
completed: completedCount,
succeeded: succeededCount,
failed: failedCount
},
methods
}
}
})
+4
View File
@@ -0,0 +1,4 @@
import { defineEventHandler } from 'h3'
import { handleOpenCardCodeDelete } from '~~/server/utils/card-code-delete-handler'
export default defineEventHandler(handleOpenCardCodeDelete)
+98
View File
@@ -0,0 +1,98 @@
import { db } from '~/drizzle/db'
import { cardCodes } from '~/drizzle/schema'
import { and, count, desc, eq, ilike, or } from 'drizzle-orm'
import { CARD_CODE_STATUSES } from '../../card-codes/statuses'
const MAX_KEYWORD_LENGTH = 100
export default defineEventHandler(async (event) => {
const apiKey = event.context.apiKey
if (!apiKey) {
throw createError({ statusCode: 401, message: 'API认证失败' })
}
try {
const query = getQuery(event)
const status = typeof query.status === 'string' ? query.status.trim().toUpperCase() : ''
const keyword = typeof query.q === 'string' ? query.q.trim() : ''
const includeStats = ['1', 'true', 'yes'].includes(String(query.includeStats || query.stats || '').toLowerCase())
const page = Math.max(1, Number.parseInt(String(query.page || '1'), 10) || 1)
const limitInput = Number.parseInt(String(query.limit || '20'), 10) || 20
const limit = Math.min(100, Math.max(1, limitInput))
const offset = (page - 1) * limit
if (status && !CARD_CODE_STATUSES.includes(status as any)) {
throw createError({ statusCode: 400, message: '不支持的状态值' })
}
if (keyword.length > MAX_KEYWORD_LENGTH) {
throw createError({ statusCode: 400, message: `搜索关键词不能超过 ${MAX_KEYWORD_LENGTH} 个字符` })
}
const conditions = [] as any[]
if (status) {
conditions.push(eq(cardCodes.status, status as any))
}
if (keyword) {
conditions.push(
or(
ilike(cardCodes.code, `%${keyword}%`),
ilike(cardCodes.note, `%${keyword}%`)
)
)
}
let queryBuilder = db.select().from(cardCodes)
let countQueryBuilder = db.select({ count: count() }).from(cardCodes)
if (conditions.length > 0) {
queryBuilder = queryBuilder.where(and(...conditions))
countQueryBuilder = countQueryBuilder.where(and(...conditions))
}
const statsQuery = includeStats
? db.select({ status: cardCodes.status, count: count() }).from(cardCodes).groupBy(cardCodes.status)
: Promise.resolve([])
const [items, totalResult, statsRows] = await Promise.all([
queryBuilder.orderBy(desc(cardCodes.createdAt)).limit(limit).offset(offset),
countQueryBuilder,
statsQuery
])
let stats
if (includeStats) {
stats = { total: 0, available: 0, locked: 0, redeemed: 0, invalid: 0 }
for (const row of statsRows) {
const value = Number(row.count || 0)
stats.total += value
if (row.status === 'AVAILABLE') stats.available = value
if (row.status === 'LOCKED') stats.locked = value
if (row.status === 'REDEEMED') stats.redeemed = value
if (row.status === 'INVALID') stats.invalid = value
}
}
const total = Number(totalResult[0]?.count || 0)
return {
success: true,
data: {
cardCodes: items,
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit))
},
...(includeStats ? { stats } : {}),
filters: {
status: status || null,
q: keyword || null,
includeStats
}
}
}
} catch (err: any) {
if (err.statusCode) throw err
console.error('开放API获取点歌券列表失败', err)
throw createError({ statusCode: 500, message: '获取点歌券列表失败' })
}
})
+129
View File
@@ -0,0 +1,129 @@
import { db } from '~/drizzle/db'
import { cardCodes, cardCodeRedeemLogs } from '~/drizzle/schema'
import { inArray } from 'drizzle-orm'
import { z } from 'zod'
import { CARD_CODE_STATUSES } from '../../card-codes/statuses'
const MAX_ID_LENGTH = 32
const MAX_NOTE_LENGTH = 500
const updateCardCodesSchema = z.object({
id: z.union([z.number().int().positive(), z.string().max(MAX_ID_LENGTH)]).optional(),
ids: z.array(z.union([z.number().int().positive(), z.string().max(MAX_ID_LENGTH)])).max(500, '单次最多更新 500 个点歌券').optional(),
status: z.enum(CARD_CODE_STATUSES).optional(),
note: z.union([z.string().max(MAX_NOTE_LENGTH), z.null()]).optional()
})
export default defineEventHandler(async (event) => {
const apiKey = event.context.apiKey
if (!apiKey) {
throw createError({ statusCode: 401, message: 'API认证失败' })
}
const operatorId = Number(apiKey.createdByUserId)
if (!Number.isInteger(operatorId) || operatorId <= 0) {
throw createError({ statusCode: 400, message: 'API Key 缺少有效创建者,无法执行点歌券状态变更' })
}
try {
const body = await readBody(event) ?? {}
const validatedData = updateCardCodesSchema.parse(body)
const rawIds = [
...(validatedData.id !== undefined ? [validatedData.id] : []),
...(validatedData.ids || [])
]
const ids = Array.from(new Set(
rawIds.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0)
))
if (!ids.length) {
throw createError({ statusCode: 400, message: '缺少有效点歌券ID' })
}
const updateObj: any = { updatedAt: new Date() }
const status = validatedData.status
if (status) {
updateObj.status = status
if (status === 'REDEEMED') {
updateObj.redeemedBy = operatorId
updateObj.redeemedAt = new Date()
updateObj.lockedBy = null
updateObj.lockedAt = null
}
if (status === 'LOCKED') {
updateObj.lockedBy = operatorId
updateObj.lockedAt = new Date()
updateObj.redeemedBy = null
updateObj.redeemedAt = null
}
if (status === 'AVAILABLE') {
updateObj.lockedBy = null
updateObj.lockedAt = null
updateObj.redeemedBy = null
updateObj.redeemedAt = null
}
}
if ('note' in validatedData) {
updateObj.note = typeof validatedData.note === 'string' ? validatedData.note.trim() || null : validatedData.note
}
if (!status && !('note' in validatedData)) {
throw createError({ statusCode: 400, message: '没有需要更新的字段' })
}
const updatedCardCodes = await db.transaction(async (tx) => {
const beforeRows = await tx
.select({
id: cardCodes.id,
code: cardCodes.code,
status: cardCodes.status
})
.from(cardCodes)
.where(inArray(cardCodes.id, ids))
const beforeMap = new Map(beforeRows.map((row) => [row.id, row]))
const updatedRows = await tx.update(cardCodes).set(updateObj).where(inArray(cardCodes.id, ids)).returning()
if (status === 'REDEEMED' && updatedRows.length > 0) {
const logsToInsert = updatedRows
.filter((row) => {
const before = beforeMap.get(row.id)
return before && before.status !== status
})
.map((row) => ({
cardCodeId: row.id,
codeSnapshot: row.code,
redeemedBy: operatorId,
redeemedAt: row.redeemedAt || new Date(),
source: 'OPEN_API_MANUAL',
songId: null
}))
if (logsToInsert.length > 0) {
await tx.insert(cardCodeRedeemLogs).values(logsToInsert)
}
}
return updatedRows
})
return {
success: true,
message: '点歌券更新成功',
data: {
cardCodes: updatedCardCodes,
updated: updatedCardCodes.length,
requested: ids.length
}
}
} catch (err: any) {
if (err.statusCode) throw err
if (err.name === 'ZodError') {
throw createError({
statusCode: 400,
message: `请求参数验证失败:${(err.issues || err.errors).map((issue: any) => issue.message).join(', ')}`
})
}
console.error('开放API更新点歌券失败', err)
throw createError({ statusCode: 500, message: '更新点歌券失败' })
}
})
+151
View File
@@ -0,0 +1,151 @@
import { db } from '~/drizzle/db'
import { cardCodes } from '~/drizzle/schema'
import { z } from 'zod'
import crypto from 'crypto'
const MAX_BATCH_COUNT = 10000
const DB_CHUNK_SIZE = 500
const MAX_CODE_LENGTH = 128
const MAX_PREFIX_LENGTH = 32
const MAX_CHARSET_LENGTH = 128
const MAX_NOTE_LENGTH = 500
const MIN_RANDOM_SPACE_MULTIPLIER = 10
const DEFAULT_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const createCardCodesSchema = z.object({
codes: z
.union([
z.string().max(MAX_CODE_LENGTH),
z.array(z.string().max(MAX_CODE_LENGTH)).max(MAX_BATCH_COUNT)
])
.optional(),
count: z.number().int().min(0).max(MAX_BATCH_COUNT).optional(),
prefix: z.string().max(MAX_PREFIX_LENGTH).optional(),
length: z.number().int().min(4).max(64).optional(),
charset: z.string().max(MAX_CHARSET_LENGTH).optional(),
note: z.union([z.string().max(MAX_NOTE_LENGTH), z.null()]).optional()
})
export default defineEventHandler(async (event) => {
const apiKey = event.context.apiKey
if (!apiKey) {
throw createError({ statusCode: 401, message: 'API认证失败' })
}
try {
const body = await readBody(event) ?? {}
const validatedData = createCardCodesSchema.parse(body)
const rawCodes = Array.isArray(validatedData.codes)
? validatedData.codes
: validatedData.codes
? [validatedData.codes]
: []
const codes = rawCodes.map((code) => String(code).trim().toUpperCase()).filter(Boolean)
const manualCodeSet = new Set(codes)
const generatedCodeSet = new Set<string>()
const batchCount = validatedData.count || 0
const prefix = typeof validatedData.prefix === 'string' ? validatedData.prefix.trim().toUpperCase() : ''
const length = validatedData.length || 12
const charsetInput = typeof validatedData.charset === 'string' && validatedData.charset.trim()
? validatedData.charset.trim().toUpperCase()
: DEFAULT_CHARSET
const charset = [...new Set(charsetInput.split(''))].join('')
if (!charset) {
throw createError({ statusCode: 400, message: '字符集不能为空' })
}
if (manualCodeSet.size + batchCount > MAX_BATCH_COUNT) {
throw createError({ statusCode: 400, message: `单次最多创建 ${MAX_BATCH_COUNT} 个点歌券` })
}
if (batchCount > 0) {
const charsetSet = new Set(charset.split(''))
const generatedNamespaceManualCount = codes.filter((code) => {
if (!code.startsWith(prefix) || code.length !== prefix.length + length) return false
return code.slice(prefix.length).split('').every((char) => charsetSet.has(char))
}).length
const maxPossibleCodes = BigInt(charset.length) ** BigInt(length)
const requiredSpace = BigInt(batchCount + generatedNamespaceManualCount) * BigInt(MIN_RANDOM_SPACE_MULTIPLIER)
if (maxPossibleCodes < requiredSpace) {
throw createError({
statusCode: 400,
message: '可用字符集空间过小或长度不足,容易导致生成碰撞和性能问题,请增大字符集或长度'
})
}
const makeRandom = () => {
let code = prefix
for (let i = 0; i < length; i++) {
code += charset.charAt(crypto.randomInt(0, charset.length))
}
return code
}
let attempts = 0
const maxAttempts = batchCount * 100
while (generatedCodeSet.size < batchCount && attempts < maxAttempts) {
attempts++
const next = makeRandom()
if (!generatedCodeSet.has(next) && !manualCodeSet.has(next)) {
generatedCodeSet.add(next)
}
}
if (generatedCodeSet.size < batchCount) {
throw createError({
statusCode: 400,
message: '可用字符集过小或长度不足,无法生成足够数量的唯一点歌券,请增大字符集或长度'
})
}
}
const finalCodes = [...codes, ...generatedCodeSet]
if (!finalCodes.length) {
throw createError({ statusCode: 400, message: '请提供要创建的点歌券或生成数量' })
}
if (finalCodes.length > MAX_BATCH_COUNT) {
throw createError({ statusCode: 400, message: `单次最多创建 ${MAX_BATCH_COUNT} 个点歌券` })
}
const uniqueCodes = [...new Set(finalCodes)]
const note = typeof validatedData.note === 'string' ? validatedData.note.trim() || null : null
const inserts = uniqueCodes.map((code) => ({
code,
status: 'AVAILABLE' as const,
note
}))
const createdCardCodes = await db.transaction(async (tx) => {
const rows: any[] = []
for (let i = 0; i < inserts.length; i += DB_CHUNK_SIZE) {
const chunk = inserts.slice(i, i + DB_CHUNK_SIZE)
const result = await tx.insert(cardCodes).values(chunk).onConflictDoNothing().returning()
rows.push(...result)
}
return rows
})
if (!createdCardCodes.length) {
throw createError({ statusCode: 400, message: '这些点歌券已经存在,无需重复创建' })
}
return {
success: true,
message: '点歌券创建成功',
data: {
cardCodes: createdCardCodes,
created: createdCardCodes.length,
skipped: uniqueCodes.length - createdCardCodes.length
}
}
} catch (err: any) {
if (err.statusCode) throw err
if (err.name === 'ZodError') {
throw createError({
statusCode: 400,
message: `请求参数验证失败:${(err.issues || err.errors).map((issue: any) => issue.message).join(', ')}`
})
}
console.error('开放API创建点歌券失败', err)
throw createError({ statusCode: 500, message: '创建点歌券失败' })
}
})
@@ -0,0 +1,4 @@
import { defineEventHandler } from 'h3'
import { handleOpenCardCodeDelete } from '~~/server/utils/card-code-delete-handler'
export default defineEventHandler(handleOpenCardCodeDelete)
+243
View File
@@ -0,0 +1,243 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { playTimes, schedules, songCollaborators, songs, users } from '~/drizzle/schema'
import { and, asc, desc, eq, gte, inArray, like, lt, or, sql } from 'drizzle-orm'
import { formatDateTime } from '~/utils/timeUtils'
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const apiKey = event.context.apiKey
console.log(`[Schedules API] 接收到请求,API Key context: ${apiKey ? '存在' : '不存在'}`)
// API认证中间件已经验证了权限,这里只需要确保有API Key信息
if (!apiKey) {
console.log(`[Schedules API] API认证失败 - 缺少API Key context`)
throw createError({
statusCode: 401,
message: 'API认证失败'
})
}
const semester = (query.semester as string) || ''
const date = (query.date as string) || ''
const playTimeId = (query.playTimeId as string) || ''
const search = (query.search as string) || ''
const page = parseInt(query.page as string) || 1
const limit = Math.min(parseInt(query.limit as string) || 20, 100) // 最大100条
const sortBy = (query.sortBy as string) || 'playDate'
const sortOrder = (query.sortOrder as string) || 'asc'
// 构建查询条件
const conditions = [
eq(schedules.isDraft, false) // 只查询已发布的排期
]
if (semester) {
conditions.push(eq(songs.semester, semester))
}
if (date) {
const targetDate = new Date(date)
const startOfDay = new Date(
targetDate.getFullYear(),
targetDate.getMonth(),
targetDate.getDate()
)
const endOfDay = new Date(
targetDate.getFullYear(),
targetDate.getMonth(),
targetDate.getDate() + 1
)
conditions.push(and(gte(schedules.playDate, startOfDay)!, lt(schedules.playDate, endOfDay)!)!)
}
if (playTimeId) {
conditions.push(eq(schedules.playTimeId, parseInt(playTimeId)))
}
if (search) {
conditions.push(or(like(songs.title, `%${search}%`)!, like(songs.artist, `%${search}%`)!)!)
}
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
// 计算偏移量
const offset = (page - 1) * limit
// 查询排期数据
const schedulesData = await db
.select({
id: schedules.id,
playDate: schedules.playDate,
createdAt: schedules.createdAt,
updatedAt: schedules.updatedAt,
song: {
id: songs.id,
title: songs.title,
artist: songs.artist,
cover: songs.cover,
musicPlatform: songs.musicPlatform,
musicId: songs.musicId,
durationSeconds: songs.durationSeconds,
played: songs.played,
playedAt: songs.playedAt,
createdAt: songs.createdAt,
semester: songs.semester
},
requester: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
},
playTime: {
id: playTimes.id,
name: playTimes.name,
startTime: playTimes.startTime,
endTime: playTimes.endTime,
enabled: playTimes.enabled
}
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.leftJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(whereCondition)
.orderBy(
sortBy === 'playDate'
? sortOrder === 'desc'
? desc(schedules.playDate)
: asc(schedules.playDate)
: sortBy === 'createdAt'
? sortOrder === 'desc'
? desc(schedules.createdAt)
: asc(schedules.createdAt)
: sortBy === 'title'
? sortOrder === 'desc'
? desc(songs.title)
: asc(songs.title)
: asc(schedules.playDate)
)
.limit(limit)
.offset(offset)
// 获取总数
const totalResult = await db
.select({ count: sql`count(*)` })
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.leftJoin(users, eq(songs.requesterId, users.id))
.where(whereCondition)
const total = Number(totalResult[0].count)
// 获取联合投稿人信息
const songIds = schedulesData.map((s) => s.song.id)
const collaboratorsMap = new Map()
if (songIds.length > 0) {
const collaboratorsData = await db
.select({
songId: songCollaborators.songId,
user: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
}
})
.from(songCollaborators)
.leftJoin(users, eq(songCollaborators.userId, users.id))
.where(
and(inArray(songCollaborators.songId, songIds), eq(songCollaborators.status, 'ACCEPTED'))
)
collaboratorsData.forEach((c) => {
if (!collaboratorsMap.has(c.songId)) {
collaboratorsMap.set(c.songId, [])
}
if (c.user) {
collaboratorsMap.get(c.songId).push(c.user)
}
})
}
// 格式化数据
const formattedSchedules = schedulesData.map((schedule) => {
const collaborators = collaboratorsMap.get(schedule.song.id) || []
return {
id: schedule.id,
playDate: schedule.playDate,
playDateFormatted: formatDateTime(schedule.playDate),
semester: schedule.song.semester,
song: {
id: schedule.song.id,
title: schedule.song.title,
artist: schedule.song.artist,
cover: schedule.song.cover,
musicPlatform: schedule.song.musicPlatform,
musicId: schedule.song.musicId,
durationSeconds: schedule.song.durationSeconds || null,
played: schedule.song.played,
playedAt: schedule.song.playedAt,
requestedAt: formatDateTime(schedule.song.createdAt),
collaborators: collaborators.map((c: any) => ({
id: c.id,
name: c.name,
grade: c.grade,
class: c.class
}))
},
requester: schedule.requester
? {
id: schedule.requester.id,
name: schedule.requester.name,
grade: schedule.requester.grade,
class: schedule.requester.class
}
: null,
playTime: schedule.playTime
? {
id: schedule.playTime.id,
name: schedule.playTime.name,
startTime: schedule.playTime.startTime,
endTime: schedule.playTime.endTime,
enabled: schedule.playTime.enabled
}
: null,
createdAt: schedule.createdAt,
updatedAt: schedule.updatedAt
}
})
const result = {
success: true,
data: {
schedules: formattedSchedules,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
}
}
return result
} catch (error: any) {
console.error('[Open API] 获取排期列表失败:', error)
if (error.statusCode) {
throw error
} else {
throw createError({
statusCode: 500,
message: '获取排期列表失败'
})
}
}
})
+282
View File
@@ -0,0 +1,282 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { playTimes, schedules, songs, users, votes } from '~/drizzle/schema'
import { and, asc, count, desc, eq, inArray, like, or } from 'drizzle-orm'
import { formatDateTime } from '~/utils/timeUtils'
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const apiKey = event.context.apiKey
// API认证中间件已经验证了权限,这里只需要确保有API Key信息
if (!apiKey) {
throw createError({
statusCode: 401,
message: 'API认证失败'
})
}
const search = (query.search as string) || ''
const semester = (query.semester as string) || ''
const grade = (query.grade as string) || ''
const played = query.played as string // 'true', 'false', 或空字符串
const scheduled = query.scheduled as string // 'true', 'false', 或空字符串
const page = parseInt(query.page as string) || 1
const limit = Math.min(parseInt(query.limit as string) || 20, 100) // 最大100条
const sortBy = (query.sortBy as string) || 'createdAt'
const sortOrder = (query.sortOrder as string) || 'desc'
// 构建查询条件
const conditions = []
if (search) {
conditions.push(or(like(songs.title, `%${search}%`), like(songs.artist, `%${search}%`)))
}
if (semester) {
conditions.push(eq(songs.semester, semester))
}
if (grade) {
conditions.push(eq(users.grade, grade))
}
if (played === 'true') {
conditions.push(eq(songs.played, true))
} else if (played === 'false') {
conditions.push(eq(songs.played, false))
}
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
// 计算偏移量
const offset = (page - 1) * limit
// 查询歌曲总数
const totalResult = await db
.select({ count: count() })
.from(songs)
.leftJoin(users, eq(songs.requesterId, users.id))
.where(whereCondition)
const total = totalResult[0]?.count || 0
// 获取歌曲数据
const songsData = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist,
played: songs.played,
playedAt: songs.playedAt,
semester: songs.semester,
createdAt: songs.createdAt,
updatedAt: songs.updatedAt,
cover: songs.cover,
playUrl: songs.playUrl,
musicPlatform: songs.musicPlatform,
musicId: songs.musicId,
durationSeconds: songs.durationSeconds,
preferredPlayTimeId: songs.preferredPlayTimeId,
requester: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
}
})
.from(songs)
.leftJoin(users, eq(songs.requesterId, users.id))
.where(whereCondition)
.orderBy(
sortBy === 'createdAt'
? sortOrder === 'desc'
? desc(songs.createdAt)
: asc(songs.createdAt)
: sortBy === 'title'
? sortOrder === 'desc'
? desc(songs.title)
: asc(songs.title)
: sortBy === 'artist'
? sortOrder === 'desc'
? desc(songs.artist)
: asc(songs.artist)
: sortBy === 'playedAt'
? sortOrder === 'desc'
? desc(songs.playedAt)
: asc(songs.playedAt)
: desc(songs.createdAt)
)
.limit(limit)
.offset(offset)
// 获取每首歌的投票数
const songIds = songsData.map((song) => song.id)
const voteCountsQuery =
songIds.length > 0
? await db
.select({
songId: votes.songId,
count: count(votes.id)
})
.from(votes)
.where(inArray(votes.songId, songIds))
.groupBy(votes.songId)
: []
const voteCounts = new Map(voteCountsQuery.map((v) => [v.songId, v.count]))
// 获取每首歌的排期状态
// 只查询已发布的排期,草稿不算作已排期
const schedulesQuery =
songIds.length > 0
? await db
.select({
songId: schedules.songId
})
.from(schedules)
.where(and(inArray(schedules.songId, songIds), eq(schedules.isDraft, false)))
: []
const scheduledSongs = new Set(schedulesQuery.map((s) => s.songId))
// 获取期望播放时段信息
const playTimesQuery = await db.select().from(playTimes)
const playTimesMap = new Map(playTimesQuery.map((pt) => [pt.id, pt]))
// 获取所有用户的姓名列表,用于检测同名用户
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((u) => {
if (u.name) {
if (!nameToUsers.has(u.name)) {
nameToUsers.set(u.name, [])
}
nameToUsers.get(u.name).push(u)
}
})
// 转换数据格式
let formattedSongs = songsData.map((song) => {
// 处理投稿人姓名,如果是同名用户则添加后缀
let requesterName = song.requester?.name || '未知用户'
// 检查是否有同名用户
const sameNameUsers = nameToUsers.get(requesterName)
if (sameNameUsers && sameNameUsers.length > 1) {
const requesterWithGradeClass = song.requester
// 如果有年级信息,则添加年级后缀
if (requesterWithGradeClass?.grade) {
// 检查同一个年级是否有同名
const sameGradeUsers = sameNameUsers.filter(
(u: { grade?: string; class?: string }) => u.grade === requesterWithGradeClass.grade
)
if (sameGradeUsers.length > 1 && requesterWithGradeClass.class) {
// 同一个年级有同名,添加班级后缀
requesterName = `${requesterName}(${requesterWithGradeClass.grade} ${requesterWithGradeClass.class})`
} else {
// 只添加年级后缀
requesterName = `${requesterName}(${requesterWithGradeClass.grade})`
}
}
}
// 创建歌曲对象
const songObject: any = {
id: song.id,
title: song.title,
artist: song.artist,
requester: requesterName,
requesterId: song.requester?.id,
voteCount: voteCounts.get(song.id) || 0,
played: song.played,
playedAt: song.playedAt,
playedAtFormatted: song.playedAt ? formatDateTime(song.playedAt) : null,
semester: song.semester,
createdAt: song.createdAt,
updatedAt: song.updatedAt,
requestedAt: formatDateTime(song.createdAt),
scheduled: scheduledSongs.has(song.id),
cover: song.cover || null,
musicPlatform: song.musicPlatform || null,
musicId: song.musicId || null,
durationSeconds: song.durationSeconds || null,
playUrl: song.playUrl || null,
preferredPlayTimeId: song.preferredPlayTimeId
}
// 添加期望播放时段相关字段
if (song.preferredPlayTimeId) {
const preferredPlayTime = playTimesMap.get(song.preferredPlayTimeId)
if (preferredPlayTime) {
songObject.preferredPlayTime = {
id: preferredPlayTime.id,
name: preferredPlayTime.name,
startTime: preferredPlayTime.startTime,
endTime: preferredPlayTime.endTime,
enabled: preferredPlayTime.enabled
}
}
}
return songObject
})
// 如果需要按排期状态筛选
if (scheduled === 'true') {
formattedSongs = formattedSongs.filter((song) => song.scheduled)
} else if (scheduled === 'false') {
formattedSongs = formattedSongs.filter((song) => !song.scheduled)
}
// 如果按投票数排序,需要重新排序
if (sortBy === 'votes') {
formattedSongs.sort((a, b) => {
const diff = sortOrder === 'desc' ? b.voteCount - a.voteCount : a.voteCount - b.voteCount
if (diff === 0) {
// 投票数相同时,按提交时间排序(较早提交的优先)
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
}
return diff
})
}
const result = {
success: true,
data: {
songs: formattedSongs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
}
}
return result
} catch (error: any) {
console.error('[Open API] 获取歌曲列表失败:', error)
if (error.statusCode) {
throw error
} else {
throw createError({
statusCode: 500,
message: '获取歌曲列表失败'
})
}
}
})
+118
View File
@@ -0,0 +1,118 @@
import { db } from '~/drizzle/db'
import { createSongPlayedNotification } from '~~/server/services/notificationService'
import { songs, songReplayRequests } from '~/drizzle/schema'
import { eq, and, inArray } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
import { restoreReplayRequestsToPending } from '~~/server/utils/scheduleReplayBinding'
import { z } from 'zod'
const markPlayedSchema = z.object({
songId: z.number().optional(),
songIds: z.array(z.number()).max(100, '批量操作单次最多支持 100 首歌曲').optional(),
unmark: z.boolean().optional()
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const validatedDataResult = markPlayedSchema.safeParse(body)
if (!validatedDataResult.success) {
throw createError({
statusCode: 400,
message: validatedDataResult.error.issues[0]?.message || '请求参数无效'
})
}
const validatedData = validatedDataResult.data
const songIds = Array.from(
new Set([
...(validatedData.songId !== undefined ? [validatedData.songId] : []),
...(validatedData.songIds || [])
])
)
if (songIds.length === 0) {
throw createError({
statusCode: 400,
message: '歌曲ID列表不能为空'
})
}
// 撤回已播放
const isUnmark = validatedData.unmark === true
// 过滤需要更新的歌曲
const condition = !isUnmark
? and(inArray(songs.id, songIds), eq(songs.played, false))
: and(inArray(songs.id, songIds), eq(songs.played, true))
const { updatedSongsResult, updatedSongIds } = await db.transaction(async (tx) => {
const updatedSongsResult = await tx
.update(songs)
.set({
played: !isUnmark,
playedAt: isUnmark ? null : getBeijingTime()
})
.where(condition)
.returning({ id: songs.id })
const updatedSongIds = updatedSongsResult.map((s) => s.id)
if (updatedSongIds.length > 0) {
if (!isUnmark) {
// 标记已播放:履行全部待处理的重播申请
const updatedRequests = await tx
.update(songReplayRequests)
.set({
status: 'FULFILLED',
updatedAt: getBeijingTime()
})
.where(
and(
inArray(songReplayRequests.songId, updatedSongIds),
eq(songReplayRequests.status, 'PENDING')
)
)
.returning({ id: songReplayRequests.id })
if (updatedRequests.length > 0) {
console.log(`将 ${updatedRequests.length} 个重播申请状态更新为 FULFILLED(歌曲已播放)`)
}
} else {
// 撤回已播放:每人仅恢复最新一条,避免违反部分唯一索引
const restoredCount = await restoreReplayRequestsToPending({
tx,
songIds: updatedSongIds,
at: getBeijingTime()
})
if (restoredCount > 0) {
console.log(`将 ${restoredCount} 个重播申请状态恢复为 PENDING(撤回已播放)`)
}
}
}
return { updatedSongsResult, updatedSongIds }
})
// 异步发送通知
if (!isUnmark && updatedSongIds.length > 0) {
event.waitUntil(
Promise.allSettled(
updatedSongIds.map((songId) =>
createSongPlayedNotification(songId).catch((err) => {
console.error(`发送歌曲(${songId})已播放通知失败:`, err)
})
)
)
)
}
return {
message: isUnmark ? '歌曲已成功撤回已播放状态' : '歌曲已成功标记为已播放',
count: updatedSongsResult.length,
updatedSongIds: updatedSongIds
}
})
+58
View File
@@ -0,0 +1,58 @@
import { db, users } from '~/drizzle/db'
import { eq } from 'drizzle-orm'
import { requestSongForUser } from '~~/server/services/songRequestService'
const ALLOWED_FIELDS = [
'title',
'artist',
'cover',
'musicPlatform',
'musicId',
'bilibiliCid',
'bilibiliPage',
'playUrl',
'submissionNote',
'submissionNotePublic',
'preferredPlayTimeId',
'cardCode',
'collaborators'
]
export default defineEventHandler(async (event) => {
const apiKey = event.context.apiKey
if (!apiKey) {
throw createError({
statusCode: 401,
message: 'API认证失败'
})
}
const userResult = await db
.select({
id: users.id,
role: users.role,
status: users.status
})
.from(users)
.where(eq(users.id, apiKey.createdByUserId))
.limit(1)
const user = userResult[0]
if (!user || user.status !== 'active') {
throw createError({
statusCode: 403,
message: '令牌所属用户不可用'
})
}
const body = await readBody(event)
const payload = ALLOWED_FIELDS.reduce((result: Record<string, any>, field) => {
if (Object.prototype.hasOwnProperty.call(body || {}, field)) {
result[field] = body[field]
}
return result
}, {})
return await requestSongForUser(event, user, payload)
})