feat(login): kg 扫码/短信验证码登录、QQ 扫码登录

- kg:sdk/auth 新增 qrKey/qrCheck 扫码与 sendCaptcha/cellphoneLogin 短信登录,路由接线
- tx:新增 /login/tx/qr(/check)(移植 sansenjian/qq-music-api):ptlogin 扫码 →
  check_sig 换 p_skey → OAuth authorize 换 code → QQConnectLogin 换凭据;
  状态码对齐 wy(800 过期/801 等待/802 已扫/803 成功),qrsig 失效 403 映射 800;
  成功凭据入库(通常含 openid/refresh_key,可走自动续期)
This commit is contained in:
snowy-yang committed 2026-10-01 16:35:08 +08:00
1 parent 96f6a7fe91
commit 0f9989b26f
4 files changed
+479 -25

No files matched your search

+196 -3
View File
@@ -1,9 +1,12 @@
// QQ音乐凭据 —— cookie 解析、VIP 检测与登录态续期(music.login.LoginServer)
// 与 lx-music-api-server 的 refresh/tx.py 对齐,但走本项目既有的 musicu.fcg 未签名通道
// (与 apis/tx.ts 一致);Login/VipLogin 模块若拒绝未签名请求则以 code != 0 失败,
// QQ音乐凭据 —— cookie 解析、扫码登录、VIP 检测与登录态续期(music.login.LoginServer)
// 登录态续期走本项目既有的 musicu.fcg 未签名通道(与 apis/tx.ts 一致);
// Login/VipLogin 模块若拒绝未签名请求则以 code != 0 失败,
// 此时保留原凭据并抛错,由调用方记录日志。
// 续期条件:仅浏览器 cookie(无 openId 等抓包字段)时只带 musicid/musickey 尝试,
// 失败率较高;完整凭据(/login/tx 可选传入 openId/accessToken/refreshToken/refreshKey)最可靠。
// 扫码登录(参考 sansenjian/qq-music-api):ptlogin 扫码 → check_sig →
// graph.qq.com OAuth authorize 换 code → QQConnectLogin.LoginServer/QQLogin 换凭据,
// 成功响应体一般携带 refresh_key 等完整字段,可走自动续期。
import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from './store.ts'
@@ -99,3 +102,193 @@ export const refreshLogin = async() => {
const vipType = await checkVip().catch(() => null)
return { refreshed: true, ...(vipType ? { vipType } : {}) }
}
// ---------------- 扫码登录 ----------------
/** ptqrtoken:hash33(qrsig) */
const hash33 = (str: string): number => {
let hash = 0
for (let i = 0; i < str.length; ++i) hash += (hash << 5) + str.charCodeAt(i)
return 2147483647 & hash
}
/** g_tk:p_skey 的 5381 哈希 */
const getGtk = (skey: string): number => {
let hash = 5381
for (let i = 0; i < skey.length; ++i) hash += (hash << 5) + skey.charCodeAt(i)
return 2147483647 & hash
}
const randomGuid = (): string => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16).toUpperCase()
})
/** 登录链路共享的 cookie 罐:同名后到优先,存「name=value」对 */
class CookieJar {
private jar = new Map<string, string>()
merge(setCookie: string[] | undefined) {
for (const item of setCookie ?? []) {
const pair = item.split(';')[0]
const name = pair.split('=')[0]?.trim()
if (name && pair.includes('=')) this.jar.set(name, pair)
}
}
get(name: string) { return this.jar.get(name)?.split('=').slice(1).join('=') ?? '' }
entries() { return this.jar.entries() }
header() { return [...this.jar.values()].join('; ') }
}
const ptloginRequest = async(path: string, query: Record<string, string>, cookie: string) => {
const r = await httpFetch(`https://ssl.ptlogin2.qq.com/${path}?${new URLSearchParams(query)}`, {
method: 'get',
...(cookie ? { headers: { cookie } } : {}),
timeout: 15000,
}).promise
if (r.statusCode !== 200) {
const err = new Error(`ptlogin ${path} 失败(HTTP ${r.statusCode})`) as any
err.statusCode = r.statusCode
throw err
}
return r
}
/** 创建扫码登录二维码(客户端扫码后轮询 qrCheck),qrsig 需原样回传 */
export const qrShow = async() => {
const r = await httpFetch(
'https://ssl.ptlogin2.qq.com/ptqrshow?appid=716027609&e=2&l=M&s=3&d=72&v=4' +
`&t=${Math.random()}&daid=383&pt_3rd_aid=100497308&u1=${encodeURIComponent('https://graph.qq.com/oauth2.0/login_jump')}`,
{ method: 'get', timeout: 15000 },
).promise
const qrsig = (r.headers['set-cookie'] ?? []).map(c => c.match(/qrsig=([^;]+)/)?.[1]).find(Boolean)
if (!qrsig) throw new Error('获取二维码失败(响应缺少 qrsig)')
return { qrimg: `data:image/png;base64,${r.raw.toString('base64')}`, qrsig }
}
/**
* 轮询扫码状态(与 wy 扫码一致的状态码语义)
* @returns {{ code: 800|801|802|803, message?: string, uin?, vipType? }} 800 过期 / 801 等待 / 802 已扫码 / 803 成功(凭据已保存)
*/
export const qrCheck = async(qrsig: string) => {
if (!qrsig) {
const err = new Error('缺少 qrsig') as any
err.status = 422
throw err
}
const jar = new CookieJar()
jar.merge([`qrsig=${qrsig}`])
// 1. 轮询扫码状态:0 成功 / 65 已失效 / 66 未扫描 / 67 已扫描 / 68 授权中
// qrsig 无效/已被 ptlogin 下线时返回 403,按二维码过期处理
let poll
try {
poll = await ptloginRequest('ptqrlogin', {
u1: 'https://graph.qq.com/oauth2.0/login_jump',
ptqrtoken: String(hash33(qrsig)),
ptredirect: '0', h: '1', t: '1', g: '1',
from_ui: '1', ptlang: '2052',
action: `0-0-${Date.now()}`,
js_ver: '23111510', js_type: '1',
pt_uistyle: '40', aid: '716027609', daid: '383',
pt_3rd_aid: '100497308',
o1vId: randomGuid(),
}, jar.header())
} catch (err: any) {
if (err.statusCode === 403) return { code: 800, message: '二维码已失效' }
throw err
}
jar.merge(poll.headers['set-cookie'])
const text = typeof poll.body === 'string' ? poll.body : ''
const cb = text.match(/ptuiCB\('(\d+)','[^']*','([^']*)'/)
const state = cb?.[1] ?? ''
if (state !== '0') {
const states: Record<string, [number, string]> = {
65: [800, '二维码已失效'],
66: [801, '等待扫码'],
67: [802, '已扫码,待确认'],
68: [802, '授权中'],
}
const [code, message] = states[state] ?? [801, '等待扫码']
return { code, message }
}
// 2. check_sig 换 p_skey(g_tk 需要)
const checkSigUrl = cb?.[2]
if (!checkSigUrl?.startsWith('http')) throw new Error('登录检查失败(未返回 check_sig 地址)')
const checkSig = await httpFetch(checkSigUrl, {
method: 'get',
headers: { cookie: jar.header() },
timeout: 15000,
}).promise
jar.merge(checkSig.headers['set-cookie'])
const pSkey = jar.get('p_skey')
if (!pSkey) throw new Error('登录检查失败(响应缺少 p_skey)')
// 3. OAuth authorize 换 code(appid 100497308 为 QQ 音乐开放平台)
const authorize = await httpFetch('https://graph.qq.com/oauth2.0/authorize', {
method: 'post',
headers: { Referer: 'https://graph.qq.com/oauth2.0/authorize', cookie: jar.header() },
form: {
response_type: 'code',
client_id: '100497308',
redirect_uri: 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/',
scope: 'get_user_info,get_app_friends',
state: 'state',
switch: '',
from_ptlogin: '1',
src: '1',
update_auth: '1',
openapi: '1010_1030',
g_tk: String(getGtk(pSkey)),
auth_time: String(Date.now()),
ui: randomGuid(),
},
timeout: 15000,
}).promise
jar.merge(authorize.headers['set-cookie'])
const code = String(authorize.headers.location ?? '').match(/[?&]code=([^&]+)/)?.[1]
if (!code) throw new Error('OAuth 授权失败(未取得 code)')
// 4. QQLogin 换凭据:响应体 req.data 携带 musickey/musicid(及 openid 等续期字段),
// 同时响应 Set-Cookie 也会下发 y.qq.com 登录 cookie
const login = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: { ...HEADERS, cookie: jar.header() },
body: {
comm: { g_tk: getGtk(pSkey), platform: 'yqq', ct: 24, cv: 0 },
req: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param: { code } },
},
timeout: 15000,
}).promise
jar.merge(login.headers['set-cookie'])
const data = login.body?.req?.data ?? {}
const musickey = data.musickey || jar.get('qm_keyst')
if (!musickey) throw new Error('登录失败(未取得 musickey)')
// ptlogin 域的 uin 可能带 o 前缀(如 o0123456),统一剥掉
const uin = String(data.musicid || jar.get('uin')).replace(/^o0*/, '') || '0'
// 最终 cookie:uin/qm_keyst 用凭据值放最前,其余链路 cookie 原样保留(排除 ptlogin 域噪声)
const pairs = [`uin=${uin}`, `qm_keyst=${musickey}`]
if (data.encryptUin) pairs.push(`euin=${data.encryptUin}`)
for (const [name, pair] of jar.entries()) {
if (!['qrsig', 'ptui_loginuin', 'uin', 'qm_keyst', 'euin'].includes(name)) pairs.push(pair)
}
setCredentials('tx', {
cookie: pairs.join('; '),
uin,
...(data.openid ? { openId: String(data.openid) } : {}),
...(data.access_token ? { accessToken: String(data.access_token) } : {}),
...(data.refresh_token ? { refreshToken: String(data.refresh_token) } : {}),
...(data.refresh_key ? { refreshKey: String(data.refresh_key) } : {}),
})
const vipType = await checkVip().catch(() => null)
return { code: 803, message: '登录成功', uin, ...(vipType ? { vipType } : {}) }
}