feat(admin+server): 酷狗对接 - 扫码绑定管理员账号 / 按排期自动创建酷狗日歌单 + 同步歌曲

数据模型:SystemSettings 新增 kugouApiBaseUrl / kugouAdminCookie / kugouAdminToken / kugouAdminUser / kugouAdminBoundAt 5 字段;新建 KuGouPlaylistSync 同步历史表(12 字段 + playDate 倒序索引)。迁移脚本:app/drizzle/migrations/20260919143000_add_kugou_integration.sql

后端 Server:新增 server/services/kugouService.ts 封装 KuGouMusicApi sidecar 转发(自动拼 Cookie / Token header);新增 6 个 admin/kugou 路由:status / qr-create / qr-check / unbind / sync-schedule-playlists / sync-history。同步核心:逐日出非草稿排期 → kgHash 直取优先 → 失败用 cloudsearch/search 按标题+歌手三级召回 → /playlist/create + /playlist/tracks op=add 每 100 首分批;写同步历史(status、added/missing、歌单 URL)。

后台 UI:新建 Admin/KuGouImporter.client.vue 三栏布局——左栏 sidecar 健康卡+部署说明+扫码绑定/解绑弹窗;中栏多选排期日期+逐日出排期预览卡+按日同步;下栏最近 100 条同步历史(7 种状态 badge + 歌单链接跳转)。dashboard.vue L105 playlist-card 挂载点由 PlaylistCardPage 替换为 KuGouImporter;权限 permissions.js 5 个 ADMIN 族角色开放 playlist-card 页面访问(接口内部绑定/同步再强校验 ADMIN/SUPER_ADMIN)。

侧car要求:部署 KuGouMusicApi(参考文档 https://kugoumusicapi-docs.4everland.app),默认本机 http://127.0.0.1:3000,可通过 SystemSettings.kugouApiBaseUrl 或环境变量 KUGOU_API_BASE 覆盖。
This commit is contained in:
2026-09-19 11:57:02 +08:00
parent 573e168702
commit a0184fdeb0
12 changed files with 1255 additions and 8 deletions
@@ -0,0 +1,259 @@
import { and, asc, eq, sql } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { schedules, songs, systemSettings, kuGouPlaylistSyncs } from '~/drizzle/schema'
import { kuGouFetch } from '~~/server/services/kugouService'
const WEEKDAY_ZH = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function pad2(n: number) {
return n < 10 ? '0' + n : '' + n
}
function dayKey(d: Date) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
function parseDate(s: string | Date) {
const d = s instanceof Date ? new Date(s) : new Date(s)
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
async function readSettings() {
const rows = await db.select().from(systemSettings).limit(1)
return rows[0] as any
}
async function fetchScheduleForDate(dayStart: Date) {
const dayEnd = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate() + 1)
return await db
.select({
scheduleId: schedules.id,
sequence: schedules.sequence,
songId: schedules.songId,
playDate: schedules.playDate,
title: songs.title,
singer: songs.singer,
artist: songs.artist,
kgHash: songs.kgHash,
musicId: songs.musicId,
coverUrl: songs.coverUrl,
cover: songs.cover,
})
.from(schedules)
.innerJoin(songs, eq(songs.id, schedules.songId))
.where(and(sql`${schedules.playDate} >= ${dayStart}`, sql`${schedules.playDate} < ${dayEnd}`, eq(schedules.isDraft, false)))
.orderBy(asc(schedules.sequence), asc(schedules.playDate))
}
async function ensureLogin(settings: any) {
if (!settings?.kugouAdminToken && !settings?.kugouAdminCookie) {
throw createError({
statusCode: 412,
message: '尚未绑定酷狗管理员账号,请先在本页扫码绑定',
})
}
try {
const res = await kuGouFetch('/login/status', { timestamp: Date.now() })
const ok = Number((res as any)?.status ?? 0) === 1 || !!(res as any)?.data?.profile || !!(res as any)?.data?.account
if (!ok) {
throw new Error('酷狗账号未登录或已过期,请重新扫码绑定')
}
} catch (e: any) {
throw createError({
statusCode: 412,
message: '酷狗登录状态无效:' + (e?.message || String(e)),
})
}
}
async function searchKugouTrackFor(item: { title: string; singer?: string | null; artist?: string | null; kgHash?: string | null }) {
const q = [item.title, item.singer || item.artist].filter(Boolean).join(' - ')
const res = await kuGouFetch<any>('/cloudsearch', {
keywords: q,
limit: 10,
timestamp: Date.now(),
}).catch(() =>
kuGouFetch<any>('/search', {
keywords: q,
limit: 10,
timestamp: Date.now(),
}),
)
const list: any[] =
(res as any)?.result?.songs ||
(res as any)?.data?.info ||
(res as any)?.songs ||
[]
if (!list.length) return null
const targetL = item.title.trim().toLowerCase()
const targetArtist = (item.singer || item.artist || '').trim().toLowerCase()
const best =
list.find((it) => {
const nameL = (it.name || it.fileName || it.songname || '').toString().trim().toLowerCase()
const artistL = ((it.ar && it.ar[0]?.name) || it.singerName || it.singername || '')
.toString()
.trim()
.toLowerCase()
return nameL === targetL && (!targetArtist || artistL.includes(targetArtist))
}) || list[0]
const name = best.name || best.fileName || best.songname || item.title
const hash = best.hash || best.fileHash || ''
const albumId = best.album?.id ?? best.album_id ?? best.albumId ?? ''
const audioId = best.id ?? best.album_audio_id ?? best.albumAudioId ?? ''
return {
name,
hash,
albumId: String(albumId || ''),
audioId: String(audioId || ''),
raw: best,
}
}
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 rawDates: (string | Date)[] = Array.isArray(body?.dates) && body.dates.length ? body.dates : []
const playlistUrlTemplate = body.playlistUrlTemplate || 'https://www.kugou.com/yy/special/single/{listid}.html'
if (!rawDates.length) throw createError({ statusCode: 400, message: '请选择至少一个排期日期' })
const settings = await readSettings()
await ensureLogin(settings)
const results: any[] = []
for (const d of rawDates) {
const dayStart = parseDate(d as string)
const key = dayKey(dayStart)
const playlistName = `BYW广播 ${key} (${WEEKDAY_ZH[dayStart.getDay()]})`
const scheduled = await fetchScheduleForDate(dayStart)
const totalSongs = scheduled.length
let playlistId = ''
let playlistUrl = ''
const missing: any[] = []
const addBatches: string[] = []
let added = 0
let status = 'pending'
let error = ''
let rawCreate: any = null
let rawAdd: any = null
try {
if (!totalSongs) {
status = 'skipped_empty'
error = '当日没有已发布排期的歌曲'
} else {
const createRes = await kuGouFetch<any>('/playlist/create', {
name: playlistName,
timestamp: Date.now(),
})
rawCreate = createRes
const createOk = Number((createRes as any)?.code ?? 0) === 200 || Number((createRes as any)?.status ?? 0) === 1
const listid =
(createRes as any)?.playlist?.id ||
(createRes as any)?.data?.id ||
(createRes as any)?.id ||
(createRes as any)?.listid ||
''
if (!createOk || !listid) {
status = 'failed_create'
error =
(createRes as any)?.message ||
(createRes as any)?.error ||
'歌单创建失败,请检查 sidecar / 酷狗账号权限'
} else {
playlistId = String(listid)
playlistUrl = playlistUrlTemplate.replace('{listid}', playlistId)
const tracks: string[] = []
for (const s of scheduled) {
// 先直接用 kgHash / musicId 构造 track;没有就搜索
const hash = s.kgHash || ''
const name = s.title
const albId = ''
const audioId = s.musicId || ''
if (hash) {
tracks.push([name, hash, albId, audioId].join('|'))
} else {
const hit = await searchKugouTrackFor(s)
if (hit && hit.hash) tracks.push([hit.name, hit.hash, hit.albumId, hit.audioId].join('|'))
else
missing.push({
songId: s.songId,
title: s.title,
singer: s.singer || s.artist,
reason: '酷狗未匹配到可加歌曲',
})
}
}
added = tracks.length
const CHUNK = 100
for (let i = 0; i < tracks.length; i += CHUNK) {
const data = tracks.slice(i, i + CHUNK).join(',')
const r = await kuGouFetch<any>('/playlist/tracks', {
op: 'add',
pid: playlistId,
trackIds: tracks[i],
data,
timestamp: Date.now(),
})
rawAdd = rawAdd || []
rawAdd.push(r)
const ok = Number((r as any)?.code ?? 0) === 200 || Number((r as any)?.status ?? 0) === 1
if (!ok) {
status = 'partial_add_failed'
error = (r as any)?.message || (r as any)?.error || '歌单添加歌曲失败'
break
}
addBatches.push(`${i + 1}-${Math.min(i + CHUNK, tracks.length)}`)
}
if (status === 'pending') status = missing.length ? 'partial' : 'success'
}
}
} catch (e: any) {
status = 'error'
error = e?.message || String(e)
}
const inserted = (
await db
.insert(kuGouPlaylistSyncs)
.values({
playDate: dayStart,
playlistName,
playlistId: playlistId || null,
playlistUrl: playlistUrl || null,
totalSongs,
addedSongs: added,
missingSongs: missing as any,
status,
error: error || null,
triggeredByUserId: Number(user.id) || null,
rawResponse: {
create: rawCreate,
add: rawAdd,
batches: addBatches,
} as any,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
)[0]
results.push({
id: inserted.id,
date: key,
playlistName,
playlistId,
playlistUrl,
totalSongs,
addedSongs: added,
missingSongs: missing,
status,
error,
})
}
return {
ok: true,
results,
}
})