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
+96
View File
@@ -0,0 +1,96 @@
import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { eq } from 'drizzle-orm'
import { cardCodes, cardCodeRedeemLogs } from '~/drizzle/schema'
import { CARD_CODE_STATUSES } from '../../../card-codes/statuses'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
const id = Number(getRouterParam(event, 'id'))
if (!Number.isInteger(id) || id <= 0) {
throw createError({ statusCode: 400, message: '点歌券ID无效' })
}
const body = await readBody(event) ?? {}
const status = typeof body.status === 'string' ? body.status.trim().toUpperCase() : ''
const note = typeof body.note === 'string' ? body.note.trim() || null : undefined
if (!status && note === undefined) {
throw createError({ statusCode: 400, message: '没有需要更新的字段' })
}
try {
const updated = await db.transaction(async (tx) => {
// 获取当前状态
const rows = await tx.select().from(cardCodes).where(eq(cardCodes.id, id)).limit(1)
const current = rows[0]
if (!current) throw createError({ statusCode: 404, message: '点歌券未找到' })
const newValues: any = { updatedAt: new Date() }
const now = new Date()
if (status) {
if (!CARD_CODE_STATUSES.includes(status as any)) {
throw createError({ statusCode: 400, message: '不支持的状态值' })
}
if (status === 'REDEEMED') {
newValues.status = 'REDEEMED'
newValues.redeemedBy = user.id
newValues.redeemedAt = now
// 清理锁定信息
newValues.lockedBy = null
newValues.lockedAt = null
} else if (status === 'AVAILABLE') {
newValues.status = 'AVAILABLE'
newValues.lockedBy = null
newValues.lockedAt = null
newValues.redeemedBy = null
newValues.redeemedAt = null
} else if (status === 'LOCKED') {
newValues.status = 'LOCKED'
newValues.lockedBy = user.id
newValues.lockedAt = now
newValues.redeemedBy = null
newValues.redeemedAt = null
} else if (status === 'INVALID') {
newValues.status = 'INVALID'
// 作废时清理锁定与核销信息,与批量更新行为一致
newValues.lockedBy = null
newValues.lockedAt = null
newValues.redeemedBy = null
newValues.redeemedAt = null
}
}
if (note !== undefined) newValues.note = note
const res = await tx.update(cardCodes).set(newValues).where(eq(cardCodes.id, id)).returning()
const newRow = res[0]
if (status === 'REDEEMED' && current.status !== status) {
await tx.insert(cardCodeRedeemLogs).values({
cardCodeId: id,
codeSnapshot: newRow.code,
redeemedBy: user.id,
redeemedAt: newRow.redeemedAt || now,
source: 'ADMIN_MANUAL',
songId: null
})
}
return newRow
})
return { success: true, data: updated }
} catch (err: any) {
console.error('更新点歌券失败:', err)
throw createError({ statusCode: err.statusCode || 500, message: err.message || '更新失败' })
}
})
+117
View File
@@ -0,0 +1,117 @@
import { db } from '~/drizzle/db'
import { cardCodes } from '~/drizzle/schema'
import { inArray } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
const body = await readBody(event) ?? {}
const rawCodes = Array.isArray(body.codes) ? body.codes : body.codes ? [body.codes] : []
const codes = rawCodes.map((c: string) => String(c).trim().toUpperCase()).filter(Boolean)
const MAX_BATCH_COUNT = 10000
const parsedCount = Number(body.count)
const requestedBatchCount = Number.isInteger(parsedCount) ? parsedCount : 0
if (requestedBatchCount > MAX_BATCH_COUNT) {
throw createError({
statusCode: 400,
message: `单次最多生成 ${MAX_BATCH_COUNT} 个点歌券`
})
}
const batchCount = requestedBatchCount
const prefix = typeof body.prefix === 'string' ? body.prefix.trim().toUpperCase() : ''
const parsedLength = Number(body.length)
const length = Number.isInteger(parsedLength) ? parsedLength : 12
if (length > 64) {
throw createError({ statusCode: 400, message: '点歌券随机长度不能超过 64 位' })
}
const charsetInput = typeof body.charset === 'string' && body.charset.trim() ? body.charset.trim().toUpperCase() : 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const charset = [...new Set(charsetInput.split(''))].join('')
if (!charset) {
throw createError({ statusCode: 400, message: '字符集不能为空' })
}
const manualCodeSet = new Set(codes)
const generatedCodeSet = new Set<string>()
if (batchCount > 0) {
const actualLength = Math.max(4, length)
const maxPossibleCodes = charset.length ** actualLength
if (maxPossibleCodes < batchCount) {
throw createError({
statusCode: 400,
message: '可用字符集过小或长度不足,无法生成足够数量的唯一点歌券,请增大字符集或长度'
})
}
const makeRandom = () => {
let code = prefix
for (let i = 0; i < actualLength; i++) {
code += charset.charAt(Math.floor(Math.random() * charset.length))
}
return code
}
let attempts = 0
const maxAttempts = batchCount * 100
while (generatedCodeSet.size < batchCount && attempts < maxAttempts) {
attempts++
const next = makeRandom()
if (!generatedCodeSet.has(next) && !manualCodeSet.has(next)) {
generatedCodeSet.add(next)
}
}
if (generatedCodeSet.size < batchCount) {
throw createError({
statusCode: 400,
message: '可用字符集过小或长度不足,无法生成足够数量的唯一点歌券,请增大字符集或长度'
})
}
}
const finalCodes = [...codes, ...generatedCodeSet]
if (!finalCodes.length) {
throw createError({ statusCode: 400, message: '请提供要创建的点歌券或生成数量' })
}
if (finalCodes.length > MAX_BATCH_COUNT) {
throw createError({
statusCode: 400,
message: `单次最多创建 ${MAX_BATCH_COUNT} 个点歌券`
})
}
try {
const uniqueCodes = [...new Set(finalCodes)]
const existingRows = await db
.select({ code: cardCodes.code })
.from(cardCodes)
.where(inArray(cardCodes.code, uniqueCodes))
const existingCodes = new Set(existingRows.map((row) => row.code))
const insertCodes = uniqueCodes.filter((code) => !existingCodes.has(code))
if (!insertCodes.length) {
throw createError({ statusCode: 400, message: '这些点歌券已经存在,无需重复创建' })
}
const inserts = insertCodes.map((c: string) => ({
code: c,
status: 'AVAILABLE',
note: typeof body.note === 'string' ? body.note.trim() || null : null
}))
const res = await db.insert(cardCodes).values(inserts).returning()
return {
success: true,
data: res,
skipped: uniqueCodes.length - insertCodes.length
}
} catch (err: any) {
console.error('创建点歌券失败', err)
throw createError({ statusCode: err.statusCode || 500, message: err.message || '创建点歌券失败' })
}
})
@@ -0,0 +1,48 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { deleteCardCodesByIds } from '~~/server/services/cardCodeDeleteService'
const parseIds = (body: any): number[] => {
const rawIds = Array.isArray(body?.ids) ? body.ids : body?.id !== undefined ? [body.id] : []
return Array.from(new Set(
rawIds
.filter((id: any) => (typeof id === 'number' || typeof id === 'string') && id !== '')
.map((id: any) => Number(id))
.filter((id: number) => Number.isInteger(id) && id > 0)
))
}
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
const body = await readBody(event).catch(() => ({})) ?? {}
const ids = parseIds(body)
if (!ids.length) {
throw createError({ statusCode: 400, message: '缺少有效点歌券ID' })
}
if (ids.length > 500) {
throw createError({ statusCode: 400, message: '单次最多删除 500 个点歌券' })
}
try {
const deletedRows = await deleteCardCodesByIds(ids)
return {
success: true,
message: '点歌券删除成功',
data: {
cardCodes: deletedRows,
deleted: deletedRows.length,
requested: ids.length
}
}
} catch (err: any) {
if (err.statusCode) throw err
console.error('删除点歌券失败', err)
throw createError({ statusCode: 500, message: '删除点歌券失败' })
}
})
+85
View File
@@ -0,0 +1,85 @@
import { db } from '~/drizzle/db'
import { cardCodes } from '~/drizzle/schema'
import { and, desc, eq, ilike, or, inArray } from 'drizzle-orm'
import { setHeader } from 'h3'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
try {
const query = getQuery(event)
const idsParam = typeof query.ids === 'string' ? query.ids.trim() : ''
const status = typeof query.status === 'string' ? query.status.trim().toUpperCase() : ''
const keyword = typeof query.q === 'string' ? query.q.trim() : ''
const conditions: any[] = []
if (status) conditions.push(eq(cardCodes.status, status as any))
if (keyword) {
conditions.push(
or(
ilike(cardCodes.code, `%${keyword}%`),
ilike(cardCodes.note, `%${keyword}%`)
)
)
}
let rows
if (idsParam) {
const ids = idsParam.split(',').map((s) => Number(s)).filter((id) => Number.isInteger(id) && id > 0)
if (ids.length === 0) {
throw createError({ statusCode: 400, message: '无效的点歌券ID' })
}
rows = await db.select().from(cardCodes).where(inArray(cardCodes.id, ids)).orderBy(desc(cardCodes.createdAt))
} else {
let qb = db.select().from(cardCodes)
if (conditions.length) qb = qb.where(and(...conditions))
rows = await qb.orderBy(desc(cardCodes.createdAt))
}
const statusLabelMap: Record<string, string> = {
AVAILABLE: '可用',
LOCKED: '已锁定',
REDEEMED: '已核销',
INVALID: '已作废'
}
const header = ['ID', '点歌券', '状态', '备注', '创建时间', '更新时间', '锁定用户ID', '锁定时间', '核销用户ID', '核销时间']
const csvRows = [header.join(',')]
for (const r of rows) {
const esc = (v: any) => {
if (v === null || typeof v === 'undefined') return ''
let s = v instanceof Date ? v.toISOString() : String(v)
if (/^[=+\-@]/.test(s)) {
s = `\t${s}`
}
return s.includes(',') || s.includes('\n') || s.includes('"') ? '"' + s.replace(/"/g, '""') + '"' : s
}
csvRows.push([
esc(r.id),
esc(r.code),
esc(statusLabelMap[r.status] || r.status),
esc(r.note),
esc(r.createdAt),
esc(r.updatedAt),
esc(r.lockedBy),
esc(r.lockedAt),
esc(r.redeemedBy),
esc(r.redeemedAt)
].join(','))
}
const csv = '\ufeff' + csvRows.join('\n')
setHeader(event, 'Content-Type', 'text/csv; charset=utf-8')
setHeader(event, 'Content-Disposition', 'attachment; filename="card-codes.csv"')
return csv
} catch (err: any) {
console.error('导出点歌券失败', err)
throw createError({ statusCode: err.statusCode || 500, message: err.message || '导出点歌券失败' })
}
})
+74
View File
@@ -0,0 +1,74 @@
import { db } from '~/drizzle/db'
import { cardCodes } from '~/drizzle/schema'
import { and, count, desc, eq, ilike, or } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
try {
const query = getQuery(event)
const status = typeof query.status === 'string' ? query.status.trim().toUpperCase() : ''
const keyword = typeof query.q === 'string' ? query.q.trim() : ''
const page = Math.max(1, Number.parseInt(String(query.page || '1'), 10) || 1)
const limitInput = Number.parseInt(String(query.limit || '10'), 10) || 10
const limit = Math.min(100, Math.max(1, limitInput))
const offset = (page - 1) * limit
const conditions = [] as any[]
if (status) {
conditions.push(eq(cardCodes.status, status as any))
}
if (keyword) {
conditions.push(
or(
ilike(cardCodes.code, `%${keyword}%`),
ilike(cardCodes.note, `%${keyword}%`)
)
)
}
let queryBuilder = db.select().from(cardCodes)
let countQueryBuilder = db.select({ count: count() }).from(cardCodes)
if (conditions.length > 0) {
queryBuilder = queryBuilder.where(and(...conditions))
countQueryBuilder = countQueryBuilder.where(and(...conditions))
}
const [result, totalResult, statsRows] = await Promise.all([
queryBuilder.orderBy(desc(cardCodes.createdAt)).limit(limit).offset(offset),
countQueryBuilder,
db.select({ status: cardCodes.status, count: count() }).from(cardCodes).groupBy(cardCodes.status)
])
const stats = { total: 0, available: 0, locked: 0, redeemed: 0 }
for (const row of statsRows) {
const value = Number(row.count || 0)
stats.total += value
if (row.status === 'AVAILABLE') stats.available = value
if (row.status === 'LOCKED') stats.locked = value
if (row.status === 'REDEEMED') stats.redeemed = value
}
const total = Number(totalResult[0]?.count || 0)
return {
success: true,
data: result,
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit))
},
stats
}
} catch (err) {
console.error('获取点歌券列表失败', err)
throw createError({ statusCode: 500, message: '获取点歌券列表失败' })
}
})
@@ -0,0 +1,126 @@
import { db } from '~/drizzle/db'
import { cardCodeRedeemLogs, cardCodes, songs, users } from '~/drizzle/schema'
import { and, desc, eq, gte, ilike, lte, or } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
try {
const query = getQuery(event)
const limitParam = Number(query.limit)
const limit = Number.isInteger(limitParam) ? Math.min(Math.max(limitParam, 1), 200) : 50
const keyword = typeof query.q === 'string' ? query.q.trim() : ''
const source = typeof query.source === 'string' ? query.source.trim().toUpperCase() : ''
const redeemer = typeof query.redeemer === 'string' ? query.redeemer.trim() : ''
const cardCode = typeof query.cardCode === 'string' ? query.cardCode.trim() : ''
const songKeyword = typeof query.song === 'string' ? query.song.trim() : ''
const startDate = typeof query.startDate === 'string' ? query.startDate.trim() : ''
const endDate = typeof query.endDate === 'string' ? query.endDate.trim() : ''
const conditions: any[] = []
if (source) {
conditions.push(eq(cardCodeRedeemLogs.source, source as any))
}
if (cardCode) {
conditions.push(or(ilike(cardCodes.code, `%${cardCode}%`), ilike(cardCodeRedeemLogs.codeSnapshot, `%${cardCode}%`)))
}
if (redeemer) {
conditions.push(or(ilike(users.name, `%${redeemer}%`), ilike(users.username, `%${redeemer}%`)))
}
if (songKeyword) {
conditions.push(or(ilike(songs.title, `%${songKeyword}%`), ilike(songs.artist, `%${songKeyword}%`)))
}
if (keyword) {
conditions.push(
or(
ilike(cardCodes.code, `%${keyword}%`),
ilike(cardCodeRedeemLogs.codeSnapshot, `%${keyword}%`),
ilike(users.name, `%${keyword}%`),
ilike(users.username, `%${keyword}%`),
ilike(songs.title, `%${keyword}%`),
ilike(songs.artist, `%${keyword}%`),
ilike(cardCodeRedeemLogs.source, `%${keyword}%`)
)
)
}
const startDateValue = startDate ? new Date(startDate) : null
if (startDateValue && !Number.isNaN(startDateValue.getTime())) {
conditions.push(gte(cardCodeRedeemLogs.redeemedAt, startDateValue))
}
const endDateValue = endDate ? new Date(endDate) : null
if (endDateValue && !Number.isNaN(endDateValue.getTime())) {
const adjustedEndDate = new Date(endDateValue)
adjustedEndDate.setHours(23, 59, 59, 999)
conditions.push(lte(cardCodeRedeemLogs.redeemedAt, adjustedEndDate))
}
let queryBuilder = db
.select({
id: cardCodeRedeemLogs.id,
cardCodeId: cardCodeRedeemLogs.cardCodeId,
codeSnapshot: cardCodeRedeemLogs.codeSnapshot,
redeemedAt: cardCodeRedeemLogs.redeemedAt,
source: cardCodeRedeemLogs.source,
songId: cardCodeRedeemLogs.songId,
currentCode: cardCodes.code,
songTitle: songs.title,
songArtist: songs.artist,
redeemerId: users.id,
redeemerName: users.name,
redeemerUsername: users.username
})
.from(cardCodeRedeemLogs)
.leftJoin(users, eq(cardCodeRedeemLogs.redeemedBy, users.id))
.leftJoin(cardCodes, eq(cardCodeRedeemLogs.cardCodeId, cardCodes.id))
.leftJoin(songs, eq(cardCodeRedeemLogs.songId, songs.id))
if (conditions.length > 0) {
queryBuilder = queryBuilder.where(and(...conditions))
}
const result = await queryBuilder
.orderBy(desc(cardCodeRedeemLogs.redeemedAt), desc(cardCodeRedeemLogs.id))
.limit(limit)
return {
success: true,
data: result.map((item) => ({
id: item.id,
cardCodeId: item.cardCodeId,
code: item.currentCode || item.codeSnapshot,
codeSnapshot: item.codeSnapshot,
redeemedAt: item.redeemedAt,
source: item.source,
song: item.songId
? {
id: item.songId,
title: item.songTitle || '未知歌曲',
artist: item.songArtist || ''
}
: null,
redeemer: {
id: item.redeemerId,
name: item.redeemerName || '未知用户',
username: item.redeemerUsername || 'unknown'
}
}))
}
} catch (err) {
console.error('获取点歌券兑换日志失败', err)
throw createError({ statusCode: 500, message: '获取点歌券兑换日志失败' })
}
})
+109
View File
@@ -0,0 +1,109 @@
import { db } from '~/drizzle/db'
import { cardCodes, cardCodeRedeemLogs } from '~/drizzle/schema'
import { inArray, eq } from 'drizzle-orm'
import { CARD_CODE_STATUSES } from '../../../card-codes/statuses'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '权限不足' })
}
const body = await readBody(event) ?? {}
const ids = Array.isArray(body.ids) ? body.ids : body.id ? [body.id] : []
const status = typeof body.status === 'string' ? body.status.trim().toUpperCase() : ''
if (!ids.length) {
throw createError({ statusCode: 400, message: '缺少点歌券ID' })
}
try {
const normalizedIds = ids.map((id: any) => Number(id)).filter((id: number) => Number.isInteger(id) && id > 0)
if (!normalizedIds.length) {
throw createError({ statusCode: 400, message: '点歌券ID无效' })
}
const beforeRows = await db
.select({
id: cardCodes.id,
code: cardCodes.code,
status: cardCodes.status
})
.from(cardCodes)
.where(inArray(cardCodes.id, normalizedIds))
const beforeMap = new Map(beforeRows.map((row) => [row.id, row]))
const updateObj: any = {}
if (status) {
if (!CARD_CODE_STATUSES.includes(status as any)) {
throw createError({ statusCode: 400, message: '不支持的状态值' })
}
updateObj.status = status
}
if (status === 'REDEEMED') {
updateObj.redeemedBy = user.id
updateObj.redeemedAt = new Date()
updateObj.lockedBy = null
updateObj.lockedAt = null
}
if (status === 'LOCKED') {
updateObj.lockedBy = user.id
updateObj.lockedAt = new Date()
updateObj.redeemedBy = null
updateObj.redeemedAt = null
}
if (status === 'AVAILABLE') {
updateObj.lockedBy = null
updateObj.lockedAt = null
updateObj.redeemedBy = null
updateObj.redeemedAt = null
}
if (status === 'INVALID') {
updateObj.lockedBy = null
updateObj.lockedAt = null
updateObj.redeemedBy = null
updateObj.redeemedAt = null
}
if (typeof body.note !== 'undefined') {
updateObj.note = typeof body.note === 'string' ? body.note.trim() || null : body.note
}
if (Object.keys(updateObj).length === 0) {
throw createError({ statusCode: 400, message: '没有需要更新的字段' })
}
updateObj.updatedAt = new Date()
const res = await db.transaction(async (tx) => {
const updatedRows = await tx.update(cardCodes).set(updateObj).where(inArray(cardCodes.id, normalizedIds)).returning()
if (status === 'REDEEMED' && updatedRows.length > 0) {
const logsToInsert = updatedRows
.filter((row) => {
const before = beforeMap.get(row.id)
return before && before.status !== status
})
.map((row) => ({
cardCodeId: row.id,
codeSnapshot: row.code,
redeemedBy: user.id,
redeemedAt: row.redeemedAt || new Date(),
source: 'ADMIN_MANUAL',
songId: null
}))
if (logsToInsert.length > 0) {
await tx.insert(cardCodeRedeemLogs).values(logsToInsert)
}
}
return updatedRows
})
return { success: true, data: res }
} catch (err: any) {
console.error('更新点歌券失败', err)
throw createError({ statusCode: err.statusCode || 500, message: err.message || '更新点歌券失败' })
}
})