#!/usr/bin/env node /** * test_coin.js —— 金币中心签到逻辑的离线验证(mock https,不联网) * * 覆盖 coinCenter() 的六条分支: * ① 未签 → SignIn 成功 → AwardPrize 成功 * ② 未签 → SignIn 成功 → 无 State=2 的 task(只签不领) * ③ 已签 → 有 State=2 的 task(补领) * ④ 已签 → 无 State=2 的 task(skip) * ⑤ 状态查询失败(ActID 过期)→ warn,不 fail * ⑥ QM_COIN_SIGN=off → skip * ⑦ 领奖返回 100004(幂等)→ 算成功 * ⑧ QmDead 透传(code 2000) * * 用法: node qqmusic/test_coin.js */ 'use strict' const Module = require('module') const path = require('path') const FILE = path.join(__dirname, 'scrip_qqmusic_checkin.js') let pass = 0, fail = 0 const ok = (n, c, e = '') => { if (c) { pass++; console.log(` ✓ ${n}`) } else { fail++; console.log(` ✗ ${n}${e ? ' — ' + e : ''}`) } } /* ── mock https.request ── */ let respond = () => ({ status: 200, text: '{}' }) const origRequest = require('https').request require('https').request = function (opts, cb) { const chunks = [] const req = { on() { return req }, write(d) { chunks.push(Buffer.from(d)) }, end() { const body = Buffer.concat(chunks).toString('utf8') const r = respond(opts, body) const res = { statusCode: r.status, on(ev, fn) { if (ev === 'data') fn(Buffer.from(r.text)) if (ev === 'end') setImmediate(fn) return res }, } setImmediate(() => cb(res)) }, destroy() {}, } return req } const { QMClient } = require(FILE) /* 构造一个直接可用(跳过 refresh/缓存)的 client */ function mkClient() { const c = new QMClient('1006764347', 'Q_H_L_faketoken_for_shape', '/tmp/__nonexistent_qm_cache.json') c.key = 'Q_H_L_faketoken_for_shape' return c } const STATE_OK = (isSignIn, day, tasks) => JSON.stringify({ code: 0, req_0: { code: 0, data: { retCode: 0, Info: { IsSignIn: isSignIn } } }, req_1: { code: 0, data: { retCode: 0, Info: { IsSignIn: isSignIn, ContinueSignInCount: day }, TaskListInfo: { TaskList: { ContinueTaskList: tasks } }, }, }, }) const SIGN_OK = JSON.stringify({ code: 0, req_0: { code: 0, data: { retCode: 0, Info: { IsSignIn: true } } }, }) const AWARD_OK = JSON.stringify({ code: 0, req_0: { code: 0, data: { retCode: 0 } } }) const AWARD_DUP = JSON.stringify({ code: 0, req_0: { code: 0, data: { retCode: 100004 } } }) /* 按 method 分派的 mock:金币主活动与抽奖活动用不同 ActID 区分 */ function route(table) { return (opts, body) => { let j = {} try { j = JSON.parse(body) } catch { /* */ } const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID) const act = acts[0] const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method) const key = `${act}:${methods.join('+')}` const hit = table[key] return { status: 200, text: hit !== undefined ? hit : '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' } } } const COIN = 'Z25hHGi' const LOT = 'Z156KEu' const cases = [ { name: '① 未签 → 签到成功 → 领奖成功', table: { [`${COIN}:GetSignInSummary+GetSignInTaskList`]: STATE_OK(false, 0, {}), [`${COIN}:SignIn`]: SIGN_OK, [`${COIN}:AwardPrize`]: AWARD_OK, [`${LOT}:GetSignInSummary+GetSignInTaskList`]: STATE_OK(true, 5, {}), }, // 第二次查询(签完后复查)也要返回带 task 的状态 → 用序列化响应 seq: true, want: { status: 'done', has: '已签' }, }, ] /* 由于「签完复查」需要同一 key 返回不同响应,用计数器实现 */ async function runCase(name, script, want) { const calls = [] respond = (opts, body) => { let j = {} try { j = JSON.parse(body) } catch { /* */ } calls.push({ url: opts.host + opts.path, reqs: [j.req_0, j.req_1].filter(Boolean).map(r => `${(r.param || {}).ActID}:${r.method}`), comm: j.comm, }) return script(j, calls.length) } const c = mkClient() const t = await c.coinCenter() console.log(`\n${name}`) ok(`返回 0(金币不计入成长值)`, t === 0, String(t)) for (const [k, v] of Object.entries(want)) { if (k === 'status') { const s = c.steps.find(x => x.name === '金币签到') ok(`step 存在且 status=${v}`, s && s.status === v, JSON.stringify(c.steps.map(x => `${x.name}:${x.status}`))) } else if (k === 'has') { const s = c.steps.find(x => x.name === '金币签到') ok(`detail 含「${v}」`, s && s.detail.includes(v), s && s.detail) } else if (k === 'calls') { ok(`请求次数 = ${v}`, calls.length === v, String(calls.length)) } else if (k === 'comm') { const c0 = calls[0] && calls[0].comm ok(`comm 是小程序形态(mina=1 appid=…)`, c0 && c0.mina === 1 && String(c0.appid).startsWith('wx'), JSON.stringify(c0)) ok(`comm.authst = key`, c0 && c0.authst === 'Q_H_L_faketoken_for_shape') ok(`comm 无 g_tk(金币体系不签名)`, c0 && c0.g_tk === undefined, JSON.stringify(c0)) } } return calls } ;(async () => { /* ① 未签 → 签 → 领 */ await runCase('── ① 未签 → 签到 → 领奖 ──', (j, n) => { const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID) const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method) const act = acts[0] if (act === LOT) return { status: 200, text: STATE_OK(true, 3, {}) } if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') { // 第一次查询:未签;第二次(签后复查):已签 + 有可领 task return { status: 200, text: n === 1 ? STATE_OK(false, 0, {}) : STATE_OK(true, 1, { '1': { ID: 'T1', State: 2, PrizeList: [{ Value: 30 }] } }) } } if (methods[0] === 'SignIn') return { status: 200, text: SIGN_OK } if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK } return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' } }, { status: 'done', has: '已签', comm: true }) /* ② 已签 + 有 task → 补领 */ await runCase('── ② 已签 + 有可领 task → 补领 ──', (j) => { const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID) const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method) if (acts[0] === LOT) return { status: 200, text: STATE_OK(true, 3, {}) } if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') { return { status: 200, text: STATE_OK(true, 2, { '2': { ID: 'T2', State: 2, PrizeList: [{ Name: '10金币' }] } }) } } if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK } return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' } }, { status: 'done', has: '10金币' }) /* ③ 已签 + 无可领 → skip */ await runCase('── ③ 已签 + 无可领 → skip ──', (j) => { const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID) return { status: 200, text: STATE_OK(true, 4, { '1': { ID: 'T1', State: 1 } }) } }, { status: 'skip', has: '连续第 4 天' }) /* ④ 状态查询失败 → warn(不是 fail) */ await runCase('── ④ ActID 过期(查询失败)→ warn ──', () => ({ status: 200, text: JSON.stringify({ code: 0, req_0: { code: -1, data: { retCode: 100001 } }, req_1: { code: -1, data: {} } }), }), { status: 'warn', has: 'ActID' }) /* ⑤ 领奖返回 100004(幂等)→ 算成功 */ await runCase('── ⑤ 领奖 retCode=100004(已领过)→ done ──', (j) => { const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID) const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method) if (acts[0] === LOT) return { status: 200, text: STATE_OK(true, 3, {}) } if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') { return { status: 200, text: STATE_OK(true, 2, { '2': { ID: 'T2', State: 2, PrizeList: [{ Value: 20 }] } }) } } if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_DUP } return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' } }, { status: 'done' }) /* ⑥ 金币抽奖签到(独立 ActID) */ { console.log('\n── ⑥ 金币抽奖签到(独立 ActID)──') respond = (opts, body) => { const j = JSON.parse(body) const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID) const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method) if (acts[0] === COIN) { if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') return { status: 200, text: STATE_OK(true, 5, {}) } return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' } } // 抽奖活动:未签 → 签 → 有 task 可领 if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') { return { status: 200, text: STATE_OK(false, 0, {}) } } if (methods[0] === 'SignIn') return { status: 200, text: SIGN_OK } if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK } return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' } } const c = mkClient() await c.coinCenter() const s = c.steps.find(x => x.name === '金币抽奖签到') ok('金币抽奖签到有独立 step', !!s, JSON.stringify(c.steps.map(x => x.name))) } /* ⑦ QmDead 透传 */ { console.log('\n── ⑦ 未登录(code 2000)→ 抛 QmDead ──') respond = () => ({ status: 200, text: JSON.stringify({ code: 0, req_0: { code: 2000, data: {} } }) }) const c = mkClient() let threw = null try { await c.coinCenter() } catch (e) { threw = e } ok('抛了 QmDead', threw && threw.constructor.name === 'QmDead', threw && threw.constructor.name) } require('https').request = origRequest console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`) process.exit(fail === 0 ? 0 : 1) })()