From a0184fdeb01a084af9f49a078956545d9f38b508 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sat, 19 Sep 2026 11:57:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin+server):=20=E9=85=B7=E7=8B=97?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5=20-=20=E6=89=AB=E7=A0=81=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E8=B4=A6=E5=8F=B7=20/=20=E6=8C=89?= =?UTF-8?q?=E6=8E=92=E6=9C=9F=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA=E9=85=B7?= =?UTF-8?q?=E7=8B=97=E6=97=A5=E6=AD=8C=E5=8D=95=20+=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=AD=8C=E6=9B=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据模型: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 覆盖。 --- app/components/Admin/KuGouImporter.client.vue | 630 ++++++++++++++++++ .../20260919143000_add_kugou_integration.sql | 28 + app/drizzle/schema.ts | 25 + app/pages/dashboard.vue | 6 +- server/api/admin/kugou/qr-check.post.ts | 64 ++ server/api/admin/kugou/qr-create.get.ts | 39 ++ server/api/admin/kugou/status.get.ts | 51 ++ server/api/admin/kugou/sync-history.get.ts | 21 + .../kugou/sync-schedule-playlists.post.ts | 259 +++++++ server/api/admin/kugou/unbind.post.ts | 28 + server/services/kugouService.ts | 102 +++ server/utils/permissions.js | 10 +- 12 files changed, 1255 insertions(+), 8 deletions(-) create mode 100644 app/components/Admin/KuGouImporter.client.vue create mode 100644 app/drizzle/migrations/20260919143000_add_kugou_integration.sql create mode 100644 server/api/admin/kugou/qr-check.post.ts create mode 100644 server/api/admin/kugou/qr-create.get.ts create mode 100644 server/api/admin/kugou/status.get.ts create mode 100644 server/api/admin/kugou/sync-history.get.ts create mode 100644 server/api/admin/kugou/sync-schedule-playlists.post.ts create mode 100644 server/api/admin/kugou/unbind.post.ts create mode 100644 server/services/kugouService.ts diff --git a/app/components/Admin/KuGouImporter.client.vue b/app/components/Admin/KuGouImporter.client.vue new file mode 100644 index 0000000..b2800c1 --- /dev/null +++ b/app/components/Admin/KuGouImporter.client.vue @@ -0,0 +1,630 @@ + + + diff --git a/app/drizzle/migrations/20260919143000_add_kugou_integration.sql b/app/drizzle/migrations/20260919143000_add_kugou_integration.sql new file mode 100644 index 0000000..3c77677 --- /dev/null +++ b/app/drizzle/migrations/20260919143000_add_kugou_integration.sql @@ -0,0 +1,28 @@ +-- 酷狗导入:管理员账号凭证 + 按日歌单同步记录表 +ALTER TABLE "SystemSettings" + ADD COLUMN IF NOT EXISTS "kugouApiBaseUrl" text DEFAULT 'http://127.0.0.1:3000', + ADD COLUMN IF NOT EXISTS "kugouAdminCookie" text, + ADD COLUMN IF NOT EXISTS "kugouAdminToken" text, + ADD COLUMN IF NOT EXISTS "kugouAdminUser" jsonb, + ADD COLUMN IF NOT EXISTS "kugouAdminBoundAt" timestamp; + +CREATE TABLE IF NOT EXISTS "KuGouPlaylistSync" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "playDate" date NOT NULL, + "semesterId" integer, + "playlistId" text, + "playlistName" text NOT NULL, + "playlistUrl" text, + "totalSongs" integer DEFAULT 0 NOT NULL, + "addedSongs" integer DEFAULT 0 NOT NULL, + "missingSongs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "error" text, + "triggeredByUserId" integer, + "rawResponse" jsonb DEFAULT '{}'::jsonb NOT NULL +); + +CREATE INDEX IF NOT EXISTS "KuGouPlaylistSync_playDate_idx" + ON "KuGouPlaylistSync" ("playDate" DESC); diff --git a/app/drizzle/schema.ts b/app/drizzle/schema.ts index 2e1579f..3f36c2d 100644 --- a/app/drizzle/schema.ts +++ b/app/drizzle/schema.ts @@ -354,8 +354,33 @@ export const systemSettings = pgTable('SystemSettings', { enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(), enabledPlatforms: text('enabledPlatforms'), platformOrder: text('platformOrder'), + kugouApiBaseUrl: text('kugouApiBaseUrl').default('http://127.0.0.1:3000'), + kugouAdminCookie: text('kugouAdminCookie'), + kugouAdminToken: text('kugouAdminToken'), + kugouAdminUser: jsonb('kugouAdminUser').$type>(), + kugouAdminBoundAt: timestamp('kugouAdminBoundAt'), }); +export const kuGouPlaylistSyncs = pgTable('KuGouPlaylistSync', { + id: serial('id').primaryKey(), + createdAt: timestamp('createdAt').defaultNow().notNull(), + updatedAt: timestamp('updatedAt').defaultNow().notNull(), + playDate: timestamp('playDate').notNull(), + semesterId: integer('semesterId'), + playlistId: text('playlistId'), + playlistName: text('playlistName').notNull(), + playlistUrl: text('playlistUrl'), + totalSongs: integer('totalSongs').default(0).notNull(), + addedSongs: integer('addedSongs').default(0).notNull(), + missingSongs: jsonb('missingSongs').default('[]').$type().notNull(), + status: text('status').default('pending').notNull(), + error: text('error'), + triggeredByUserId: integer('triggeredByUserId'), + rawResponse: jsonb('rawResponse').default('{}').$type>().notNull(), +}, (table) => [ + index('kugou_playlist_sync_play_date_idx').on(table.playDate), +]); + // 歌曲黑名单表 export const songBlacklists = pgTable('SongBlacklist', { id: serial('id').primaryKey(), diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue index 7cd8cba..3ad283c 100644 --- a/app/pages/dashboard.vue +++ b/app/pages/dashboard.vue @@ -102,12 +102,12 @@ - +
- +
diff --git a/server/api/admin/kugou/qr-check.post.ts b/server/api/admin/kugou/qr-check.post.ts new file mode 100644 index 0000000..d510c0f --- /dev/null +++ b/server/api/admin/kugou/qr-check.post.ts @@ -0,0 +1,64 @@ +import { eq } from 'drizzle-orm' +import { db } from '~/drizzle/db' +import { systemSettings } from '~/drizzle/schema' +import { CacheService } from '~~/server/services/cacheService' +import { kuGouQrLoginCheck } from '~~/server/services/kugouService' + +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 { qr_id, login_id, key, cookie } = body || {} + if (!(qr_id || login_id || key)) { + throw createError({ statusCode: 400, message: '缺少 qr_id 或 key' }) + } + + const res = await kuGouQrLoginCheck({ qr_id, login_id, key, cookie }) + const state = Number(res?.data?.status ?? 0) + const token = (res as any)?.data?.token || (res as any)?.token || '' + const kugouCookie = (res as any)?.data?.cookie || (res as any)?.cookie || cookie || '' + const kugouUser = (res as any)?.data?.userinfo || (res as any)?.userinfo || null + + let bound = false + if (state === 1 && (token || kugouCookie)) { + const rows = await db.select().from(systemSettings).limit(1) + const patch: Record = { + kugouAdminToken: token || null, + kugouAdminCookie: kugouCookie || null, + kugouAdminUser: kugouUser || null, + kugouAdminBoundAt: new Date(), + updatedAt: new Date(), + } + if (!rows[0]) { + await db.insert(systemSettings).values(patch as any) + } else { + await db.update(systemSettings).set(patch).where(eq(systemSettings.id, rows[0].id)) + } + try { + const cs = CacheService.getInstance() + await cs.setSystemSettings?.({ ...(rows[0] || {}), ...patch }) + } catch {} + bound = true + } + + return { + ok: true, + state, // 0 未扫码 / 1 已完成 / 2 已扫码未确认 / -1 过期 + bound, + user: kugouUser, + hint: + state === 0 + ? '请用酷狗 App 扫二维码' + : state === 2 + ? '扫码成功,请在手机端确认登录' + : state === 1 + ? '绑定成功' + : state === -1 + ? '二维码已过期,请刷新重试' + : res?.error || '等待中', + raw: res, + } +}) diff --git a/server/api/admin/kugou/qr-create.get.ts b/server/api/admin/kugou/qr-create.get.ts new file mode 100644 index 0000000..89bb705 --- /dev/null +++ b/server/api/admin/kugou/qr-create.get.ts @@ -0,0 +1,39 @@ +import { kuGouQrLoginCreate } from '~~/server/services/kugouService' + +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 query = getQuery(event) + const platform = (query.platform as string) || 'web' + + try { + const res = await kuGouQrLoginCreate( + platform === 'lite' ? 'lite' : platform === 'android' ? 'android' : 'web', + ) + if (res && res.status !== 1 && (res as any).errcode) { + return { + ok: false, + error: (res as any).error || '二维码生成失败', + data: null, + } + } + return { + ok: true, + data: res?.data || null, + raw: res, + } + } catch (e: any) { + throw createError({ + statusCode: 502, + message: + '连接酷狗 sidecar 失败:请先启动 KuGouMusicApi 服务 (默认 http://127.0.0.1:3000),详细: ' + + (e?.message || String(e)), + }) + } +}) diff --git a/server/api/admin/kugou/status.get.ts b/server/api/admin/kugou/status.get.ts new file mode 100644 index 0000000..62bdd0a --- /dev/null +++ b/server/api/admin/kugou/status.get.ts @@ -0,0 +1,51 @@ +import { db } from '~/drizzle/db' +import { systemSettings } from '~/drizzle/schema' +import { kuGouFetch } from '~~/server/services/kugouService' + +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 query = getQuery(event) + const base = String(query.baseUrl || '').trim() + + if (base && !/^https?:\/\//i.test(base)) { + throw createError({ statusCode: 400, message: 'baseUrl 需要是合法的 http(s) URL' }) + } + + const rows = await db.select().from(systemSettings).limit(1) + const settings = rows[0] as any + const usedBase = base || settings?.kugouApiBaseUrl || process.env.KUGOU_API_BASE || 'http://127.0.0.1:3000' + + let serviceOk: boolean + let serviceMessage: string + let loginStatus: any = null + try { + const r = await kuGouFetch('/login/status', { timestamp: Date.now() }) + loginStatus = r + serviceOk = Number((r as any)?.status ?? 0) === 1 || !!((r as any)?.data?.profile || (r as any)?.data?.account) + serviceMessage = serviceOk ? '服务在线,且账号已绑定登录' : 'sidecar 在线,但酷狗账号未登录' + } catch (e: any) { + serviceOk = false + serviceMessage = + '无法连接 sidecar (' + usedBase + '):' + (e?.message || String(e)) + } + + return { + ok: true, + service: { + baseUrl: usedBase, + serviceOk, + serviceMessage, + loginStatus, + }, + bound: { + hasToken: !!settings?.kugouAdminToken, + hasCookie: !!settings?.kugouAdminCookie, + boundAt: settings?.kugouAdminBoundAt || null, + user: settings?.kugouAdminUser || null, + }, + } +}) diff --git a/server/api/admin/kugou/sync-history.get.ts b/server/api/admin/kugou/sync-history.get.ts new file mode 100644 index 0000000..6e40f7e --- /dev/null +++ b/server/api/admin/kugou/sync-history.get.ts @@ -0,0 +1,21 @@ +import { desc } from 'drizzle-orm' +import { db } from '~/drizzle/db' +import { kuGouPlaylistSyncs } 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 q = getQuery(event) + const limit = Math.min(500, Number(q.limit || 100)) + const rows = await db + .select() + .from(kuGouPlaylistSyncs) + .orderBy(desc(kuGouPlaylistSyncs.playDate), desc(kuGouPlaylistSyncs.createdAt)) + .limit(limit) + return { + ok: true, + rows, + } +}) diff --git a/server/api/admin/kugou/sync-schedule-playlists.post.ts b/server/api/admin/kugou/sync-schedule-playlists.post.ts new file mode 100644 index 0000000..0cd6ee5 --- /dev/null +++ b/server/api/admin/kugou/sync-schedule-playlists.post.ts @@ -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('/cloudsearch', { + keywords: q, + limit: 10, + timestamp: Date.now(), + }).catch(() => + kuGouFetch('/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('/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('/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, + } +}) diff --git a/server/api/admin/kugou/unbind.post.ts b/server/api/admin/kugou/unbind.post.ts new file mode 100644 index 0000000..aeef94f --- /dev/null +++ b/server/api/admin/kugou/unbind.post.ts @@ -0,0 +1,28 @@ +import { eq } from 'drizzle-orm' +import { db } from '~/drizzle/db' +import { systemSettings } from '~/drizzle/schema' +import { CacheService } from '~~/server/services/cacheService' + +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 rows = await db.select().from(systemSettings).limit(1) + const patch: Record = { + kugouAdminToken: null, + kugouAdminCookie: null, + kugouAdminUser: null, + kugouAdminBoundAt: null, + updatedAt: new Date(), + } + if (rows[0]) { + await db.update(systemSettings).set(patch).where(eq(systemSettings.id, rows[0].id)) + } + try { + const cs = CacheService.getInstance() + if (rows[0]) await cs.setSystemSettings?.({ ...rows[0], ...patch }) + } catch (_) {} + return { ok: true } +}) diff --git a/server/services/kugouService.ts b/server/services/kugouService.ts new file mode 100644 index 0000000..34235d1 --- /dev/null +++ b/server/services/kugouService.ts @@ -0,0 +1,102 @@ +import { ofetch } from 'ofetch' +import { db } from '~/drizzle/db' +import { systemSettings } from '~/drizzle/schema' +import { CacheService } from './cacheService' + +export interface KuGouQrLoginStep1 { + status?: number + data?: { + qr_id?: string + login_id?: string + qrimg?: string + img?: string + extra?: any + } + error?: string + errcode?: number +} + +export interface KuGouQrLoginStep2 { + status?: number + data?: { + status?: number + token?: string + cookie?: string + userinfo?: any + auth_params?: any + } + error?: string + errcode?: number + message?: string +} + +let cacheService: CacheService | null = null +function getCache() { + if (!cacheService) cacheService = CacheService.getInstance() + return cacheService +} + +async function readAdminSettings() { + try { + const c = getCache() + const cached = await c.getSystemSettings?.() + if (cached && (cached.kugouApiBaseUrl || cached.kugouAdminToken || cached.kugouAdminCookie)) { + return cached + } + } catch (_) {} + const rows = await db.select().from(systemSettings).limit(1) + return rows[0] || null +} + +export interface KuGouCallOptions { + forceCookie?: string + forceToken?: string +} + +export async function kuGouFetch( + path: string, + query?: Record, + options?: KuGouCallOptions, +): Promise { + const settings = (await readAdminSettings()) as any + const base = (options?.forceToken ? null : settings?.kugouApiBaseUrl) || process.env.KUGOU_API_BASE || 'http://127.0.0.1:3000' + const headers: Record = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128 Safari/537.36', + } + const cookie = options?.forceCookie || settings?.kugouAdminCookie + if (cookie) headers.Cookie = cookie + const url = base + path + return await ofetch(url, { + query, + headers, + retry: 1, + timeout: 20000, + onRequest({ options }) { + const token = options?.forceToken || settings?.kugouAdminToken + if (token && options.query) { + ;(options.query as any).token = token + } + }, + }) +} + +export async function kuGouQrLoginCreate(platform: 'web' | 'android' | 'lite' = 'web') { + return await kuGouFetch( + platform === 'lite' ? '/login/qr/lite/create' : '/login/qr/create', + { timestamp: Date.now() }, + ) +} + +export async function kuGouQrLoginCheck(params: { qr_id?: string; login_id?: string; key?: string; cookie?: string }) { + return await kuGouFetch( + '/login/qr/check', + { + qr_id: params.qr_id, + login_id: params.login_id, + key: params.key, + timestamp: Date.now(), + }, + { forceCookie: params.cookie }, + ) +} diff --git a/server/utils/permissions.js b/server/utils/permissions.js index 2a3ac1e..8345dc3 100644 --- a/server/utils/permissions.js +++ b/server/utils/permissions.js @@ -22,11 +22,11 @@ try { if (!ROLES.USER) { ROLES = { USER: { displayName: '学生(普通用户)', pages: [], canAccessAdmin: false }, - SONG_ADMIN: { displayName: '工作人员', pages: ['schedule', 'songs', 'print'], canAccessAdmin: true }, - VICE_MINISTER: { displayName: '副部', pages: ['overview', 'schedule', 'songs', 'print', 'users'], canAccessAdmin: true }, - ADMIN: { displayName: '部长', pages: ['overview', 'schedule', 'songs', 'print', 'users', 'data-analysis', 'notifications', 'playtimes', 'semesters'], canAccessAdmin: true }, - RESIDENT: { displayName: '常驻', pages: ['schedule', 'songs', 'print'], canAccessAdmin: true }, - SUPER_ADMIN: { displayName: '技术运维', pages: ['overview', 'workflow-tutorial', 'schedule', 'songs', 'print', 'users', 'roles', 'blacklist', 'api-keys', 'notifications', 'smtp-config', 'playtimes', 'request-times', 'semesters', 'settings', 'site-config', 'database', 'data-analysis'], canAccessAdmin: true } + SONG_ADMIN: { displayName: '工作人员', pages: ['schedule', 'songs', 'print', 'playlist-card'], canAccessAdmin: true }, + VICE_MINISTER: { displayName: '副部', pages: ['overview', 'schedule', 'songs', 'print', 'playlist-card', 'users'], canAccessAdmin: true }, + ADMIN: { displayName: '部长', pages: ['overview', 'schedule', 'songs', 'print', 'playlist-card', 'users', 'data-analysis', 'notifications', 'playtimes', 'semesters'], canAccessAdmin: true }, + RESIDENT: { displayName: '常驻', pages: ['schedule', 'songs', 'print', 'playlist-card'], canAccessAdmin: true }, + SUPER_ADMIN: { displayName: '技术运维', pages: ['overview', 'workflow-tutorial', 'schedule', 'songs', 'print', 'playlist-card', 'users', 'roles', 'blacklist', 'api-keys', 'notifications', 'smtp-config', 'playtimes', 'request-times', 'semesters', 'settings', 'site-config', 'database', 'data-analysis'], canAccessAdmin: true } } }