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:
1 parent
9d7cb6f39a
commit
aca7928019
8 files changed
+300
-9
No files matched your search
@@ -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
@@ -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 },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
Reference in new issue
Block a user