From 57a49ab7f591b167ea22c061b20e3817cabc0ae2 Mon Sep 17 00:00:00 2001 From: zhuhjay Date: Sat, 7 Nov 2026 18:50:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(cmcc):=20parse=5Fcurl=20=E5=A4=B4=E9=94=AE?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99=E4=B8=8D=E6=95=8F=E6=84=9F=EF=BC=88?= =?UTF-8?q?Reqable=20=E7=AD=89=E5=AF=BC=E5=87=BA=20Cookie=20=E5=A4=A7?= =?UTF-8?q?=E5=86=99=20C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写), parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。 改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。 --- cmcc/scrip_cmcc_mark.py | 6 +- qqmusic/README.md | 61 ++++++++ qqmusic/scrip_qqmusic_checkin.js | 193 ++++++++++++++++++++++++ qqmusic/test_coin.js | 243 +++++++++++++++++++++++++++++++ 4 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 qqmusic/test_coin.js diff --git a/cmcc/scrip_cmcc_mark.py b/cmcc/scrip_cmcc_mark.py index b499b1a..54b3a92 100644 --- a/cmcc/scrip_cmcc_mark.py +++ b/cmcc/scrip_cmcc_mark.py @@ -233,12 +233,14 @@ def parse_curl(raw): if not url or not body: raise ValueError("cURL 里没解析到 URL 或 body") headers = {k: v for k, v in headers.items() if k.lower() not in STRIP_HEADERS} + # 大小写不敏感视图(Reqable 等导出的头键大小写不定:Cookie/COOKIE) + lower = {k.lower(): v for k, v in headers.items()} problems = [] # 解析后自检:坏 cURL 在这里就报,别到线上当素材失效 if "autoLogin" not in url: problems.append("URL 不含 autoLogin") - if not headers.get("xs"): + if not lower.get("xs"): problems.append("缺 xs 头") - if not headers.get("cookie"): + if not lower.get("cookie"): problems.append("缺 cookie 头") if not (1300 <= len(body) <= 1700): problems.append("body 长度 %d 不在 1472/1516±10%% 内" % len(body)) diff --git a/qqmusic/README.md b/qqmusic/README.md index 78c7025..2964296 100644 --- a/qqmusic/README.md +++ b/qqmusic/README.md @@ -16,6 +16,10 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node | `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) | | `QM_SHARE_CARD` | 可选 | `on` 才让**定时任务**自动建卡。默认 off = 只读报数。手动取卡走 `-- card`,不受此开关限制 | | `QM_CARD` | 可选 | `1`/`on` 等价于 `-- card`(不方便传参时用) | +| `QM_COIN_SIGN` | 可选 | 金币中心签到开关,**默认 on**。`off` 整体跳过(纯签到+领奖,不花金币、不伪造行为) | +| `QM_COIN_ACT_ID` | 可选 | 金币签到 ActID,默认 `Z25hHGi`。**活动换季会变**,失效时抓包改这里 | +| `QM_COIN_SCENE_ID` | 可选 | 金币签到 ScenesID,默认 `2` | +| `QM_COIN_LOTTERY_ACT_ID` | 可选 | 金币抽奖签到 ActID,默认 `Z156KEu`,同样会换季 | | `WEBHOOK_URL` | 可选 | 自定义 webhook(如平台 `/api/hooks/ql`)。与 `PUSH_PLUS_TOKEN` 至少配一个 | | `WEBHOOK_METHOD` | 可选 | 默认 `POST` | | `WEBHOOK_CONTENT_TYPE` | 可选 | 默认 `application/json` | @@ -314,6 +318,7 @@ x-sh-hook-token: <平台 QL_HOOK_TOKEN> |---|---|---| | 任务中心 | 签到(15) / 挂件(5) / 装扮(5) / 听书(5) / 节目StartTask | 低(等价 App 点击) | | 会员福利站 | 每日签到(15) / 礼券(+10心愿值) / 旅程激励 / 月里程碑 / 月度好礼 / 臻享抽奖 | 低(先探后做,无硬编码 ID) | +| **金币中心** | 金币签到 + 领当日奖励 + 金币抽奖签到(`QM_COIN_SIGN`,默认 on) | 低(纯签到领奖,不花金币、不伪造行为) | | VIP 体验券 | 分享卡配额查询 / 建卡(`QM_SHARE_CARD=on`) | 低(只读默认,不消耗配额) | | 播放上报 | 任务20 完整 +10 需 App 正常收听(伪造统计默认关) | 中(默认关闭) | | 支付流水任务 | 4/5/6/12/14/18/21 | 不可伪造,不碰 | @@ -321,6 +326,62 @@ x-sh-hook-token: <平台 QL_HOOK_TOKEN> 状态三态机:`Y`=已完成零请求(防风控)/ `W`=进行中直接领 / `N`=执行动作。 错误码处置:`20020` 冷却3s重领 · `30005` 只读探测后补领 · `10006` 写锁即停次日恢复 · `2000` 登录态失效推送提示。 +## 金币中心(第三套体系) + +**与任务中心、福利站完全独立**:金币体系走 `musicu.fcg` + 小程序 comm,另外两套走 `u6.y.qq.com` + zzc 签名。 +给的是**金币**,不是成长值,所以不计入通知里的「成长值 +N」。 + +| 项目 | 值 | +|---|---| +| 网关 | `https://u.y.qq.com/cgi-bin/musicu.fcg`(**不签名**,裸 JSON) | +| 模块 | `music.actCenter.ActCenterSignNewSvr` | +| comm | `{ uin, authst, mina:1, appid:'wxada7aab80ba27074', ct:29, cv:0, format:'json' }` | +| 方法 | `GetSignInSummary` / `GetSignInTaskList` / `SignIn` / `AwardPrize` | + +`authst` 就是 `qm_keyst`,放在 body 里(不走 Cookie)。**没有 `g_tk`,也不签名。** + +### 流程 + +``` +① GetSignInSummary + GetSignInTaskList → info.IsSignIn / ContinueTaskList +② 未签 → SignIn { ActID, ScenesID } +③ 找 State===2 的 TaskList 项 → AwardPrize { ActID, TaskID } +``` + +`retCode` 口径:`0` 成功 · `100004` 已领过(幂等,也算成功)· `1000` 未登录 · 其他失败。 + +### 不做的部分 + +| 功能 | 为什么不做 | +|---|---| +| **金币抽奖**(`drawCoinLottery`) | 消耗抽奖券,属于「花金币」不是「赚金币」 | +| **红包雨** | 要卡 6 个时段各一次,青龙 cron 得加 6 条 | +| **每日任务列表**(浏览页 +3 豆那类) | 完成上报发生在跳转页内部埋点,要逆向各 H5 协议 | +| **定时金币** | 服务端按各自间隔下发,要维护下次查询时间,收益低 | + +### ActID 换季 + +`ActID` 是活动实例 ID,**活动换季就变**。当前硬编码值: + +``` +COIN_ACT_ID = Z25hHGi (金币中心签到) +COIN_SCENE_ID = 2 +LOTTERY_ACT_ID = Z156KEu (金币抽奖签到) +``` + +失效时脚本会打: + +``` +[金币] 状态查询失败 code=... ret=... +[金币] 若持续失败,多半是活动换季导致 ActID 失效 —— 抓包看 ActID,改 QM_COIN_ACT_ID +``` + +并记 `warn`(不是 `fail` —— 不是登录态问题,也不是脚本 bug)。 + +**修法**:App 抓包看 `ActCenterSignNewSvr` 请求里的 `ActID` 参数,改环境变量 `QM_COIN_ACT_ID`, +不用改代码。 + + ## 本地测试 ```bash diff --git a/qqmusic/scrip_qqmusic_checkin.js b/qqmusic/scrip_qqmusic_checkin.js index 63a380b..5a4610b 100644 --- a/qqmusic/scrip_qqmusic_checkin.js +++ b/qqmusic/scrip_qqmusic_checkin.js @@ -36,6 +36,10 @@ const crypto = require('crypto') /* ---------------- 协议底座(与 qmcommon.py 逐字节对齐) ---------------- */ const API = 'https://u6.y.qq.com/cgi-bin/musics.fcg' +// 金币中心走另一套网关:musicu.fcg 用小程序 comm(authst 在 body 里,不签名、不带 Cookie) +const API_U = 'https://u.y.qq.com/cgi-bin/musicu.fcg' +const MINA_APPID = 'wxada7aab80ba27074' // QQ音乐微信小程序 appid(金币体系的 comm.appid) +const M_COIN = 'music.actCenter.ActCenterSignNewSvr' // 金币签到/领奖的唯一模块 const UA = 'QQMusic 20090008(android 15)' const SCRAMBLE = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179] const APP_COMM = { ct: '11', cv: '20090008' } // cosmeticcgi 系专用 comm @@ -50,6 +54,23 @@ const CARD_CALLBACK = String(process.env.QM_CARD_CALLBACK || '').trim() // 回传鉴权 token:与平台 QL_HOOK_TOKEN 同一枚(平台 hookGuard 校验它) const CARD_TOKEN = String(process.env.QM_CARD_TOKEN || process.env.QL_HOOK_TOKEN || '').trim() +/* ---------------- 金币中心(music.actCenter.ActCenterSignNewSvr) ---------------- + * 2026-11-07 从 MaYIHEI/paperclip 的 qqmusic.js 逆出并实测校准。 + * 与任务中心/福利站完全独立:这是「金币」体系,签到给金币不给成长值,因此不计入 total。 + * + * ActID 会随活动换季变化。硬编码值只是兜底,优先用环境变量覆盖: + * QM_COIN_ACT_ID 默认 Z25hHGi(金币中心签到) + * QM_COIN_SCENE_ID 默认 2 + * 换季时从 App 抓包看 `music.actCenter.ActCenterSignNewSvr` 的 `ActID` 参数,改 env 即可, + * 不用改代码。查询失败会打日志提示改哪个变量。 */ +const COIN_ACT_ID = String(process.env.QM_COIN_ACT_ID || 'Z25hHGi').trim() +const COIN_SCENE_ID = String(process.env.QM_COIN_SCENE_ID || '2').trim() +// 金币抽奖签到是另一个 ActID(独立活动,同样随季节变) +const LOTTERY_ACT_ID = String(process.env.QM_COIN_LOTTERY_ACT_ID || 'Z156KEu').trim() +// 默认开启:纯签到 + 领奖,不消耗金币、不伪造行为,风险等价 App 点击。 +// 置 off 可整体跳过(比如不想让日志变长) +const COIN_SIGN = String(process.env.QM_COIN_SIGN || 'on').toLowerCase() !== 'off' + /* ---------------- 通知:复用仓库根 common.js 的 notify() ---------------- * 为什么不 require 青龙的 sendNotify.js(2026-11-07 真机定位): * shell/update.sh 的 gen_list_repo 复制顺序是三步 —— @@ -207,6 +228,51 @@ class QMClient { return out } + /** 金币中心的 api:走 musicu.fcg,comm 用小程序形态(authst 在 body 里,不签名、不带 Cookie)。 + * 与上面 api() 是**两套完全不同的协议**,别混用 —— 金币模块在 u6 网关下会返回空。 + * reqs 形如 [['req_0', method, param], ...],一次可并发多个 method(查询用两个)。 */ + async coinApi(reqs) { + const comm = { + uin: Number(this.uin), authst: this.key, mina: 1, appid: MINA_APPID, + ct: 29, cv: 0, format: 'json', + } + const body = { comm } + for (const [key, method, param] of reqs) body[key] = { module: M_COIN, method, param } + const payload = JSON.stringify(body) + // 这一路不签名(musicu.fcg 接受裸 JSON),headers 也只带 JSON 头 + const r = await request('POST', API_U, { + 'Content-Type': 'application/json', + 'User-Agent': UA, + }, payload) + let d + try { d = JSON.parse(r.text) } catch { throw new Error(`金币响应非 JSON(http ${r.status}): ${r.text.slice(0, 120)}`) } + const first = d && (d.req_0 || d.req1) + if (first && (first.code === 2000 || first.code === 1000)) { + throw new QmDead('金币中心返回未登录,请重抓 qm_keyst') + } + return d || {} + } + + /** 金币签到状态查询:一次拿 Summary(IsSignIn)+ TaskList(ContinueTaskList 里 State===2 可领)。 */ + async coinSignState(actId, sceneId) { + const d = await this.coinApi([ + ['req_0', 'GetSignInSummary', sceneId ? { ActID: actId, ScenesID: sceneId } : { ActID: actId }], + ['req_1', 'GetSignInTaskList', sceneId ? { ActID: actId, ScenesID: sceneId } : { ActID: actId }], + ]) + const s = (d.req_0 || {}), t = (d.req_1 || {}) + const sd = s.data || {}, td = t.data || {} + // retCode 各接口口径不同:0 是成功,部分接口缺这个字段也算成功 + const bad = v => v != null && Number(v) !== 0 + if (d.code !== 0 || s.code !== 0 || t.code !== 0 || bad(sd.retCode) || bad(td.retCode)) { + return { ok: false, sCode: s.code, tCode: t.code, sRet: sd.retCode, tRet: td.retCode } + } + return { + ok: true, + info: td.Info || sd.Info || {}, + taskList: (((td.TaskListInfo || {}).TaskList || {}).ContinueTaskList) || {}, + } + } + /** 续期(GET 形态,req1 结构)。一级失败用缓存的 openid+access_token 二级救活。 */ async qqLogin(param) { const ds = JSON.stringify({ req1: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param } }) @@ -524,6 +590,126 @@ class QMClient { return gained } + /** 金币中心签到 + 领奖 —— music.actCenter.ActCenterSignNewSvr,走 musicu.fcg 小程序协议。 + * 与任务中心/福利站完全独立:给的是**金币**,不是成长值,所以返回值恒 0(不计入 total)。 + * + * 流程(与 paperclip/qqmusic.js 的 checkCoinSignIn 同构): + * ① GetSignInSummary + GetSignInTaskList → info.IsSignIn / ContinueTaskList + * ② 未签 → SignIn(ActID + ScenesID) + * ③ 找 State===2 的 TaskList 项 → AwardPrize(领当日奖励) + * + * 不做的:金币抽奖(drawCoinLottery,消耗金币抽奖券)、红包雨、每日任务列表、 + * 定时金币 —— 那些要么花金币要么要伪造播放/浏览行为,风险与收益不成比例。 */ + async coinCenter() { + if (!COIN_SIGN) { this.step('金币签到', 'skip', 'QM_COIN_SIGN=off'); return 0 } + + let st = await this.coinSignState(COIN_ACT_ID, COIN_SCENE_ID) + if (!st.ok) { + // ActID 会随活动换季失效。这里不打 fail(不是登录态问题,也不是脚本 bug), + // 用 warn 并把「改哪个 env」写进 detail,看到通知就知道怎么修。 + log(`[金币] 状态查询失败 code=${st.sCode}/${st.tCode} ret=${st.sRet}/${st.tRet}`) + log(`[金币] 若持续失败,多半是活动换季导致 ActID 失效 —— 抓包看 ActID,改 QM_COIN_ACT_ID`) + this.step('金币签到', 'warn', `查询失败 code ${st.sCode}/${st.tCode}(可能 ActID 过期)`) + return 0 + } + + let signedNow = false + if (!st.info.IsSignIn) { + const d = await this.coinApi([['req_0', 'SignIn', { ActID: COIN_ACT_ID, ScenesID: COIN_SCENE_ID }]]) + const req = d.req_0 || {}, data = req.data || {} + const okSign = d.code === 0 && req.code === 0 && Number(data.retCode ?? 0) === 0 + && data.Info && data.Info.IsSignIn + if (!okSign) { + log(`[金币] 签到失败 code=${req.code} ret=${data.retCode}`) + this.step('金币签到', 'warn', `签到失败 code ${req.code} ret ${data.retCode}`) + return 0 + } + signedNow = true + log('[金币] 签到成功') + st = await this.coinSignState(COIN_ACT_ID, COIN_SCENE_ID) + if (!st.ok) { this.step('金币签到', 'done', '已签(状态复查失败)'); return 0 } + } + + // 连续签到第几天 → 用于在 TaskList 里定位「今天该领的那个」 + const day = Number(st.info.ContinueSignInCount || 0) + const map = st.taskList || {} + const task = Object.values(map).find(x => x && Number(x.State) === 2) || map[String(day)] + const rewardName = (() => { + const p = task && Array.isArray(task.PrizeList) ? task.PrizeList[0] : null + if (!p) return '' + if (p.Name) return p.Name + if (p.Value) return `${p.Value} 金币` + return '' + })() + + if (task && Number(task.State) === 2) { + const d = await this.coinApi([['req_0', 'AwardPrize', { ActID: COIN_ACT_ID, TaskID: task.ID }]]) + const req = d.req_0 || {}, data = req.data || {} + const ret = Number(data.retCode ?? 0) + // 100004 = 已领过(幂等),算成功 + const okAward = d.code === 0 && req.code === 0 && (ret === 0 || ret === 100004) + if (okAward) { + log(`[金币] ${signedNow ? '签到并领取' : '补领'}成功${rewardName ? `:${rewardName}` : ''}`) + this.step('金币签到', 'done', `${signedNow ? '已签 + ' : ''}${rewardName || '已领'}`) + } else { + log(`[金币] 领奖失败 code=${req.code} ret=${ret}`) + this.step('金币签到', 'warn', `已签,领奖失败 ret ${ret}`) + } + } else if (st.info.IsSignIn) { + const nth = day ? `连续第 ${day} 天` : '' + log(`[金币] 今日已签到${nth ? `(${nth})` : ''}`) + this.step('金币签到', 'skip', `今日已签${nth ? `(${nth})` : ''}`) + } else { + log('[金币] 签到状态未确认') + this.step('金币签到', 'warn', '签到状态未确认') + } + + // ---- 金币抽奖签到(另一个 ActID,独立活动)---- + // 只做「签到 + 领奖」,不碰 drawCoinLottery(那个要消耗抽奖券去抽,属于花金币) + try { + let ls = await this.coinSignState(LOTTERY_ACT_ID, '') + if (!ls.ok) { + log(`[金币抽奖] 状态查询失败 code=${ls.sCode}/${ls.tCode},跳过`) + } else { + let lotterySigned = false + if (!ls.info.IsSignIn) { + const d = await this.coinApi([['req_0', 'SignIn', { ActID: LOTTERY_ACT_ID }]]) + const req = d.req_0 || {} + if (d.code === 0 && req.code === 0) { + lotterySigned = true + ls = await this.coinSignState(LOTTERY_ACT_ID, '') + } + } + const lt = ls.ok + ? Object.values(ls.taskList || {}).find(x => x && Number(x.State) === 2) + : null + if (lt) { + const d = await this.coinApi([['req_0', 'AwardPrize', { ActID: LOTTERY_ACT_ID, TaskID: lt.ID }]]) + const req = d.req_0 || {}, data = req.data || {} + const ret = Number(data.retCode ?? 0) + if (d.code === 0 && req.code === 0 && (ret === 0 || ret === 100004)) { + this.step('金币抽奖签到', 'done', lotterySigned ? '已签 + 已领' : '已领') + } else { + this.step('金币抽奖签到', 'warn', `领奖失败 ret ${ret}`) + } + } else if (lotterySigned) { + // 签了但当场没查到可领项(接口有延迟)→ 也记一笔,别让「做过的事」在通知里消失 + this.step('金币抽奖签到', 'done', '已签(奖励待下次领)') + } else if (ls.ok && ls.info.IsSignIn) { + this.step('金币抽奖签到', 'skip', '今日已签') + } else { + this.step('金币抽奖签到', 'skip', '无需处理') + } + } + } catch (e) { + if (e instanceof QmDead) throw e + log(`[金币抽奖] 异常: ${e.message}`) + this.step('金币抽奖签到', 'warn', e.message) + } + + return 0 // 金币不计入成长值合计 + } + /** VIP 体验券(分享卡)—— music.vip.VipFreeCardSvr,2026-11-04 从体验券落地页 * index.036b2e716.js 逆出,接口已在 u6 网关实测路由通(GetCanNumConf 用假 key * 也返回真实配额表,说明该 method 不校验登录;CreatCard/CheckCurrentCardList 返回 1000)。 @@ -664,6 +850,13 @@ class QMClient { log(`[福利站] 运行异常: ${e.message}`) this.step('福利站', 'fail', e.message) } + // 金币中心:独立协议(musicu.fcg + 小程序 comm),给金币不给成长值。 + // 放在福利站之后、分享卡之前 —— 金币体系挂了不影响前两套,也不拖累取卡。 + try { await this.coinCenter() } catch (e) { + if (e instanceof QmDead) throw e + log(`[金币] 运行异常: ${e.message}`) + this.step('金币签到', 'fail', e.message) + } try { await this.shareCard() } catch (e) { if (e instanceof QmDead) throw e // 登录态失效照常上抛 log(`[分享卡] 运行异常: ${e.message}`) // 接口改版/风控不拖垮主流程 diff --git a/qqmusic/test_coin.js b/qqmusic/test_coin.js new file mode 100644 index 0000000..14487a5 --- /dev/null +++ b/qqmusic/test_coin.js @@ -0,0 +1,243 @@ +#!/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) +})()