From 3aa06006fe5376858402e7cab0ff679190a111a4 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Sat, 11 Apr 2026 21:07:26 +0800 Subject: [PATCH] fix(qq): repair QR code login flow against @sansenjian/qq-music-api 2.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR login against QQ Music has been silently broken: every call to checkQrCodeStatus returned "expired", so the scan-and-confirm cycle never completed even when the user successfully scanned the code. The root cause was four independent bugs in our wrapper talking past the library's actual HTTP shape. 1. getQrCode lost ptqrtoken. /getQQLoginQr returns { img, qrsig, ptqrtoken }, but we stored only one of them in the single `key` field (picking qrsig, falling back to ptqrtoken). The polling endpoint needs BOTH — passing only one fails with 400 "参数错误". Fix: pack both into the opaque `key` as "qrsig|ptqrtoken" and split on the receive side. 2. checkQrCodeStatus used GET. @sansenjian/qq-music-api 2.x registers /checkQQLoginQr as POST only (router.js: `router.post('/checkQQLoginQr', ...)`). GET returns 405 Method Not Allowed, axios throws, the catch returns "expired". Fix: api.post(url, null, { params }). 3. checkQrCodeStatus parsed the wrong response shape. The endpoint uses customResponse, not successResponse, so axios sees the body directly (no { response: ... } wrapper). The actual shape for each state is: waiting: { isOk: false, refresh: false, message: '未扫描二维码' } expired: { isOk: false, refresh: true, message: '二维码已失效' } success: { isOk: true, message: '登录成功', session: { cookie } } We were looking for a numeric `code === 0/1/2` field that does not exist, so every state fell through to "expired". Fix: switch on isOk / refresh / message. 4. Cookie read from the wrong path on success. On isOk=true the cookie lives at res.data.session.cookie, not res.data.cookie — so even if everything else had worked, the cookie would never have been saved. Fix: read session.cookie. Also rewrites getAuthStatus to actually validate the cookie: 5. getAuthStatus hit a non-validating endpoint. /getUserAvatar is not registered on the library's main router; the real route is /user/getUserAvatar, and even that just builds a static avatar URL from a uin without round-tripping through QQ Music with the cookie. The result: the bot happily persisted any user-supplied cookie to disk and sent it on every request while every downstream login check returned "not logged in". Fix: parse uin from the cookie, call /user/getUserPlaylists (which actually hits QQ Music with the cookie), and derive the avatar URL from the uin via q.qlogo.cn/headimg_dl. This is the same getAuthStatus fix that was sitting on the claude/bot-shutdown-disconnect-cwFvF branch, now combined with the QR login repairs. Verification: - tsc --noEmit clean - vitest: 93/93 pass - Live: POST /api/auth/qrcode platform=qq returns both tokens packed into `key`; polling a freshly-issued QR returns {"status":"waiting"} instead of the old {"status":"expired"}; raw library response is {"isOk":false,"refresh":false,"message":"未扫描二维码"} as expected. - Regression: netease and bilibili QR flows still produce non-empty qrUrl/key — no collateral damage to the other providers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/music/qq.ts | 77 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/src/music/qq.ts b/src/music/qq.ts index 4b9d9e7..064fff7 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -152,30 +152,53 @@ export class QQMusicProvider implements MusicProvider { } async getQrCode(): Promise { + // @sansenjian/qq-music-api 2.x returns { img, qrsig, ptqrtoken } via + // customResponse (no { response: ... } wrapping). /checkQQLoginQr + // requires BOTH qrsig AND ptqrtoken — passing only one gives a 400 + // "参数错误". Pack both into the opaque `key` field so the polling + // endpoint can split them back out. Separator "|" is safe: QQ tokens + // are alphanumeric. const res = await this.api.get("/getQQLoginQr"); + const qrsig: string = res.data?.qrsig ?? ""; + const ptqrtoken: string = String(res.data?.ptqrtoken ?? ""); return { qrUrl: "", qrImg: res.data?.img ?? "", - key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "", + key: `${qrsig}|${ptqrtoken}`, }; } async checkQrCodeStatus( key: string ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { - const res = await this.api.get("/checkQQLoginQr", { - params: { qrsig: key }, - }); - const code = res.data?.code ?? res.data?.response?.code; - if (code === 0) { - if (res.data?.cookie) { - this.cookie = res.data.cookie; - } + const [qrsig, ptqrtoken] = key.split("|"); + if (!qrsig || !ptqrtoken) return "expired"; + + // NOTE: /checkQQLoginQr is registered as POST only in + // @sansenjian/qq-music-api 2.x. GET returns 405 Method Not Allowed. + let res; + try { + res = await this.api.post("/checkQQLoginQr", null, { + params: { qrsig, ptqrtoken }, + }); + } catch { + return "expired"; + } + + // customResponse shape: + // success: { isOk: true, message: '登录成功', session: { cookie, ... } } + // scanning: { isOk: false, refresh: false, message: '未扫描二维码' } + // expired: { isOk: false, refresh: true, message: '二维码已失效' } + const body = res.data; + if (body?.isOk === true) { + const cookie: string = body.session?.cookie ?? ""; + if (cookie) this.cookie = cookie; return "confirmed"; } - if (code === 1) return "scanned"; - if (code === 2) return "waiting"; - return "expired"; + if (body?.refresh === true) return "expired"; + if (typeof body?.message === "string" && body.message.includes("未扫描")) + return "waiting"; + return "waiting"; } setCookie(cookie: string): void { @@ -188,20 +211,30 @@ export class QQMusicProvider implements MusicProvider { async getAuthStatus(): Promise { if (!this.cookie) return { loggedIn: false }; + // /getUserAvatar in @sansenjian/qq-music-api 2.x is NOT registered on + // the main router; the real endpoint is /user/getUserAvatar, and even + // that just builds a static URL from a uin without validating the + // cookie against QQ. Round-trip through /user/getUserPlaylists which + // actually hits QQ Music with the cookie; if we get playlists back, + // the cookie is valid. Derive nickname/avatar from the uin parsed out + // of the cookie. try { - const res = await this.api.get("/getUserAvatar", { + const uinMatch = /\buin=o?0?(\d+)/.exec(this.cookie); + const uin = uinMatch ? uinMatch[1] : ""; + const res = await this.api.get("/user/getUserPlaylists", { params: { ...this.cookieParams }, }); - if (res.data?.response?.data) { - return { - loggedIn: true, - nickname: res.data.response.data.nickname, - avatarUrl: res.data.response.data.headpic, - }; - } + const ok = res.data?.response?.data || res.data?.data; + if (!ok) return { loggedIn: false }; + return { + loggedIn: true, + nickname: uin ? `QQ ${uin}` : "QQ Music", + avatarUrl: uin + ? `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=100` + : undefined, + }; } catch { - // ignore + return { loggedIn: false }; } - return { loggedIn: false }; } }