Files
ql-scripts/qqmusic/test_coin.js
zhuhjay d081ac66da feat(qqmusic): 日志与通知按体系分组,每条都标清是什么签到
原来 63 处 step 和 60+ 处 log 全平铺,看日志分不清哪条属于哪套体系:
[签到] / [金币] / [福利站] 混在一起,通知里也是「✓ 签到 +15」这种没有上下文的行。

日志前缀统一成「体系·子项」两段:
  [任务中心·签到] [任务中心·挂件] [任务中心·装扮] [任务中心·听书] [任务中心·节目]
  [任务中心·领奖]  (原 [任务])
  [福利站·签到] [福利站·礼券] [福利站·激励] [福利站·月里程碑] [福利站·月礼]
  [福利站·抽奖] [福利站·周里程碑]
  [金币·签到] [金币·抽奖签到]

run() 里加四段分隔标题,日志一眼能看出走到哪套:
  ───── ① 任务中心(成长值)─────
  ───── ② 会员福利站(成长值 + 权益)─────
  ───── ③ 金币中心(金币,不计入成长值)─────
  ───── ④ VIP 体验券(分享卡)─────

末尾补一行说明:金币中心为独立体系,不产成长值(明细见上方 ③ 段)。
之前只有「本次新增成长值合计: 45」,看不出金币那部分跑没跑。

step 名同样统一成「体系·子项」(63 处),renderAccount 按体系分组渲染:
  🟢 1006764347:成长值 +45
     📈 任务中心(成长值)
        ✓ 签到 +15
        ✓ 挂件 已佩戴
        · 无需处理 1 项:装扮
     🎁 会员福利站(成长值 + 权益)
        ✓ 签到 15成长值(本月 8 天)
        ✓ 礼券 +10 心愿值
     💰 金币中心(金币,不计成长值)
        ✓ 签到 已签 + 30 金币
        · 无需处理 1 项:抽奖签到

分组规则:
- 组内每条都是「子项名 + detail」,不再出现只有 detail 的行
- 整组全 skip 时收成一行「💰 金币中心(金币,不计成长值):签到、抽奖签到 今日无需处理」
- 未知前缀落到「📌 其他」组,名字原样保留
- 分享卡链接改成「🔗 领卡链接 <url>」单独一行

coinCenter / 金币抽奖签到开头各加一行开始日志,带 ActID 值 —— 换季排查时
直接能看到脚本用的是哪个 ActID。

新增 test_render.js:24 条断言冻结分组契约
  ① 三套体系各自成组 + 组标题 emoji + 金币标注不计成长值
  ② 组内每条都带子项名(断言没有「只有 detail」的行)
  ③ 整组 skip 收成一行
  ④ ActID 过期 → warn 单独列且 detail 含 ActID
  ⑤ 分享卡链接单独一行
  ⑥ 空 steps 不崩
  ⑦ 未知前缀 → 其他组

test_coin.js 里 3 处 step 名断言同步改成新名(金币·签到 / 金币·抽奖签到)。
2026-11-07 18:58:15 +08:00

244 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)
})()