feat(login): kg/tx 登录态自动续期(移植 lx-music-api-server 方案)

- kg: login_by_token 续期(AES-256-CBC + RSA 裸加密,参考其 refresh/kg.py),新 token 回写 data/auth.json
- tx: 新增 sdk/auth/tx.ts —— cookie 解析、VIP 检测(vip_login_base)、
  music.login.LoginServer 续期(走既有 musicu.fcg 未签名通道,最佳努力)
- 调度:启动 10s 后首刷 + 每 24h 一次(对齐参考项目 86400s + next_run_time=now)
- /login/tx 支持可选 App 抓包凭据(openId/accessToken/refreshToken/refreshKey)
- 新增 POST /login/refresh 手动续期;/login/status 增补 tx 字段与更新时间
- 补充 /login/tx 文档行与 smoke 测试
This commit is contained in:
snowy-yang committed 2026-09-29 20:16:33 +08:00
1 parent 9d7cb6f39a
commit aca7928019
8 files changed
+300 -9

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
// 登录态自动续期调度 —— 启动即刷一轮 + 每 24h 一次(与 lx-music-api-server 的
// APScheduler interval 86400s + next_run_time=now 对齐)
// 各平台凭据独立处理:未登录跳过,单个平台失败不影响另一个。
import { refreshLogin as kgRefreshLogin } from '../sdk/auth/kg.ts'
import { refreshLogin as txRefreshLogin } from '../sdk/auth/tx.ts'
const INTERVAL_MS = 24 * 60 * 60 * 1000
// 启动后延迟触发,避开启动期与 smoke 测试的快速起停
const STARTUP_DELAY_MS = 10_000
let running = false
let startupTimer: NodeJS.Timeout | null = null
let intervalTimer: NodeJS.Timeout | null = null
const log = (msg: string) => console.log(`[${new Date().toISOString()}] [登录续期] ${msg}`)
/** 手动/定时共用的续期入口,返回各平台结果(skipped / refreshed / error) */
export const refreshAll = async(): Promise<Record<string, any>> => {
if (running) return { skipped: true }
running = true
try {
const result: Record<string, any> = {}
for (const [name, fn] of [['kg', kgRefreshLogin], ['tx', txRefreshLogin]] as const) {
try {
const r = await fn()
result[name] = r
if (r.skipped) log(`${name}: 未登录,跳过`)
else log(`${name}: 登录态已刷新`)
} catch (err: any) {
result[name] = { error: err.message }
log(`${name}: ${err.message}`)
}
}
return result
} finally {
running = false
}
}
/** 注册定时任务(须在 fastify.listen 之前调用以便挂 onClose 钩子) */
export const startRefreshScheduler = (fastify) => {
fastify.addHook('onClose', () => {
if (startupTimer) clearTimeout(startupTimer)
if (intervalTimer) clearInterval(intervalTimer)
})
startupTimer = setTimeout(() => {
void refreshAll()
intervalTimer = setInterval(() => void refreshAll(), INTERVAL_MS)
intervalTimer.unref()
}, STARTUP_DELAY_MS)
startupTimer.unref()
log(`已启动自动续期(启动后 ${STARTUP_DELAY_MS / 1000}s 首刷,此后每 ${INTERVAL_MS / 3600000}h 一次)`)
}
+23 -5
View File
@@ -4,6 +4,7 @@
import * as wyAuth from '../sdk/auth/wy.ts'
import * as kgAuth from '../sdk/auth/kg.ts'
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
import { refreshAll } from './refresh.ts'
const fail = (reply, err) => {
const status = err.status ?? 502
@@ -107,12 +108,17 @@ export const loginRoutes = async(fastify) => {
fastify.post('/login/tx', {
schema: {
summary: '绑定 QQ 音乐 cookie',
description: 'cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),用于解锁 tx 取流的 320k/无损音质。',
description: 'cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),用于解锁 tx 取流的 320k/无损音质。' +
'可选提供 App 抓包的 openId/accessToken/refreshToken/refreshKey,供定时任务自动续期登录态。',
tags: ['登录'],
body: {
type: 'object',
properties: {
cookie: { type: 'string' },
openId: { type: 'string' },
accessToken: { type: 'string' },
refreshToken: { type: 'string' },
refreshKey: { type: 'string' },
},
required: ['cookie'],
},
@@ -123,7 +129,8 @@ export const loginRoutes = async(fastify) => {
if (!uin || !/qm_keyst=\S+/.test(cookie)) {
return reply.code(422).send({ code: 422, msg: 'cookie 需包含 uin 与 qm_keyst 字段' })
}
setCredentials('tx', { cookie })
const { openId, accessToken, refreshToken, refreshKey } = request.body
setCredentials('tx', { cookie, uin, openId, accessToken, refreshToken, refreshKey })
return { logged: true, uin }
})
@@ -136,19 +143,30 @@ export const loginRoutes = async(fastify) => {
// ---------- 通用 ----------
fastify.post('/login/refresh', {
schema: {
summary: '手动触发登录态续期',
description: '对已登录的 kg/tx 执行 token/cookie 续期(与自动任务同一逻辑)。' +
'未登录的平台跳过;失败的平台在对应字段返回 error,不影响其他平台。',
tags: ['登录'],
},
}, async() => refreshAll())
fastify.get('/login/status', {
schema: {
summary: '查看登录状态',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据。',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间)。',
tags: ['登录'],
},
}, async() => {
const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())])
const tx = getTxAuth()
const txCred = getCredentials('tx') as { vipType?: string; updatedAt?: string } | null
const kgCred = getCredentials('kg') as { updatedAt?: string } | null
return {
wy: wy ? { logged: true, ...wy } : { logged: false },
kg: kg ? { logged: true, ...kg } : { logged: false },
tx: tx ? { logged: true, uin: tx.uin } : { logged: false },
kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false },
tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false },
}
})
}
+4
View File
@@ -30,6 +30,7 @@ import musicSdk from '../sdk/index.ts'
import { ApiError } from './http/api-error.ts'
import { musicRoutes } from './routes.ts'
import { loginRoutes } from './routes-login.ts'
import { startRefreshScheduler } from './refresh.ts'
import { COMPONENT_SCHEMAS } from './openapi/components.ts'
import { redocHtml } from './openapi/ui.ts'
@@ -184,6 +185,9 @@ fastify.get('/', {
fastify.register(musicRoutes)
fastify.register(loginRoutes)
// 登录态自动续期(onClose 钩子需在 listen 前注册,故这里而非 startServer 内启动)
startRefreshScheduler(fastify)
export const startServer = async(port = PORT) => {
await fastify.listen({ port: Number(port), host: '0.0.0.0' })
console.log(`音乐服务器已启动: http://localhost:${port}`)