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

+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 },
}
})
}