From 32378b1f58d66a832522f9246ecebd6f3ad9b173 Mon Sep 17 00:00:00 2001 From: smallfawn <860562056@QQ.COM> Date: Wed, 19 Aug 2026 09:44:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(wxapp):=20=E6=96=B0=E5=A2=9E=20=E4=BC=81?= =?UTF-8?q?=E8=BF=88qmai=E5=B9=B3=E5=8F=B0=E9=80=9A=E7=94=A8=E7=AD=BE?= =?UTF-8?q?=E5=88=B0=20qmai.js=EF=BC=88=E5=A4=8D=E5=88=BB=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E5=BC=BA=E5=88=B6=E7=9A=84=20AES-GCM=20?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=BD=93=E5=8A=A0=E5=AF=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 企迈(qmai)对 webapi.qmai.cn 全站**强制 AES-GCM 请求体加密**,这是这批里第一个需要 完整复刻加密层的目标。密钥在包里(解包 requestEncryptSdk / 模块93),是加密不是风控,可复刻: - key = 宽松 base64 解码 "mN6KpXq8Sv2WxYz9LdFcRgHjMnBvCtDxZaS3QwE5rT0yU7I4O1A" 取前 32 字节 (解包 M() 归一化:base64ToArrayBuffer 宽松解出→非32字节取前32补零;不是 hex 不走 TextEncoder) - 明文 = JSON.stringify(body),body 自动补 appid(解包 j(): e.appid||(e.appid=x.appid)) - AES-256-GCM,12字节随机 iv,aad=""(aadEnabled 默认关),密文‖16字节tag 再 base64 - 请求体 {"payload": },头 QM-Encrypt-Meta = base64(JSON{version:"1.0.0",timestamp,iv}) - 响应同构:{"payload"} + 响应头 QM-Encrypt-Meta 带解密 iv。keyVersion 1.0.0 来自 ee.master。 平台通用(一份覆盖林里/爷爷不泡茶等所有企迈餐饮店),变量 openid#appid#storeId#activityId: storeId 来自解包 ext.storeId;activityId 是运营给每店固定配的,从签到页抓一次 (爷爷不泡茶=983701274523176960)。登录 POST /account-center/oauth/mini-app-login {code,eVersion:"1.0",appid};状态 /cmk-center/sign/userSignStatistics; 签到 /cmk-center/sign/takePartInSign,都带 {activityId,appid}。固定头 Qm-From:wechat/ Qm-From-Type:catering/store-id/Accept:v=1.0,token 放 Qm-User-Token。 实测(林里 wx26c7aaacfa017719 storeId 212501,第二个 openid):**AES-GCM 加密握手 + 登录 完整实测通过**(服务端成功解密并加密回包,登录 status:true,拿到 token + memberId 1296769222963093505)。签到需 activityId(每店固定),本测试号未持有 linli 店的 activityId,takePartInSign 本身未实测,按契约实现。破译过程中踩到的坑:登录漏了 appid 字段服务端回「参数为空」(j() 会自动补);X-Internal-Bypass 是客户端内部约定服务端不认。 fixture wxwork/test_qmai_mock.js 6 场景 74 条断言全通过 —— 其中服务端侧用独立 Node crypto 实现 AES-GCM 解密/加密,跑通"脚本加密→独立解密→独立加密→脚本解密"整条往返, 双向验证密钥派生与信封格式(多店铺各自 storeId/activityId、缓存重登、不碰营销插件)。 Co-Authored-By: Claude Opus 5 --- wxapp/qmai.js | 311 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 wxapp/qmai.js diff --git a/wxapp/qmai.js b/wxapp/qmai.js new file mode 100644 index 0000000..a9d1230 --- /dev/null +++ b/wxapp/qmai.js @@ -0,0 +1,311 @@ +/* +------------------------------------------ +@Description: 企迈云商 qmai 平台通用 - 微信小程序静默登录 + 每日签到 + 一份脚本覆盖所有用企迈(qmai)的餐饮品牌小程序(林里/爷爷不泡茶/…) +cron: 51 8 * * * +------------------------------------------ +变量名:qmai +变量值:openid#appid#storeId#activityId[#备注],一行一个店铺,多行或 & 分隔 + openid = wx_server 里的账号标识 + appid = 该小程序 appid + storeId = 门店 id,取自解包 app-config.json 的 ext.storeId(例:212501) + activityId = 签到活动 id。运营给每个店固定配一个,从签到页 URL 抓一次即可: + 进小程序签到页,抓 webapi.qmai.cn/web/cmk-center/sign/* 请求体里的 activityId + (例:爷爷不泡茶=983701274523176960)。没有它服务端回「活动id为空」。 + 例:owNAX6ky****#wx26c7aaacfa017719#212501#<活动id>#林里 + +依赖变量: +wx_server_url 默认 http://192.168.31.196:8787 +wx_auth 必填,wx_server 鉴权值 +------------------------------------------ +契约(webapi.qmai.cn/web,信封 {status, code, message, data},成功 status===true): + +★ 这家对 webapi.qmai.cn 全站**强制 AES-GCM 请求体加密**(解包 requestEncryptSdk): + - key = 宽松 base64 解码 "mN6KpXq8Sv2WxYz9LdFcRgHjMnBvCtDxZaS3QwE5rT0yU7I4O1A" + 取前 32 字节(解包模块 93 的 master key + 模块内 M() 的归一化逻辑) + - 明文 = JSON.stringify(body),body 会被自动补 appid(解包 j(): e.appid||(e.appid=x.appid)) + - 12 字节随机 iv,aad="" (aadEnabled 默认关) + - 请求体 = {"payload": base64(ciphertext‖tag)} + - 头 QM-Encrypt-Meta = base64(JSON{version:"1.0.0", timestamp:ms, iv:base64(iv)}) + - 响应同样加密:{"payload":...} + 响应头 QM-Encrypt-Meta 带解密用的 iv + keyVersion "1.0.0" 来自解包 ee.master.keyVersion。这是加密不是风控,可复刻。 + +固定头:Qm-From:wechat / Qm-From-Type:catering / store-id: / Accept:v=1.0 + +登录 POST /account-center/oauth/mini-app-login 明文 {code, eVersion:"1.0", appid} + -> data.{token, user, store};token 放后续请求头 Qm-User-Token +状态 POST /cmk-center/sign/userSignStatistics {activityId, appid} -> 今日签到/连签 +签到 POST /cmk-center/sign/takePartInSign {activityId, appid} -> status===true 即成功 + +⚠️ 实测:AES-GCM 加密握手 + 登录**已实测通过**(token/memberId 到手,登录 status:true)。 +签到接口要 activityId(每店固定),本测试号未持有 linli 店的 activityId,所以 +takePartInSign 本身未实测,只按契约实现。填对 storeId+activityId 的号跑起来即可验证。 +不做:邀请/抽奖/兑换等营销插件。 +------------------------------------------ +*/ + +const { Env } = require("../tools/env.js"); +const $ = new Env("企迈qmai平台签到"); +const axios = require("axios"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const WeChatServer = require("./wcs.js"); + +const ckName = "qmai"; +const BASE = "https://webapi.qmai.cn/web"; +const KEY_RAW = "mN6KpXq8Sv2WxYz9LdFcRgHjMnBvCtDxZaS3QwE5rT0yU7I4O1A"; +const KEY_VERSION = "1.0.0"; +const META_HEADER = "QM-Encrypt-Meta"; +const TOKEN_CACHE_FILE = path.join(__dirname, "qmai_token_cache.json"); +const USER_AGENT = + "Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.220303.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Version/4.0 Chrome/134.0.6998.136 Mobile Safari/537.36 MicroMessenger/8.0.48.2580(0x28003036) MiniProgramEnv/android"; + +const EP_LOGIN = "/account-center/oauth/mini-app-login"; +const EP_STATS = "/cmk-center/sign/userSignStatistics"; +const EP_SIGN = "/cmk-center/sign/takePartInSign"; + +/** 解包 M(): 宽松 base64 解码,非 32 字节则取前 32 补零 */ +function deriveKey(raw) { + let b; + try { + b = Buffer.from(raw.replace(/^key-/i, ""), "base64"); + } catch (e) { + b = Buffer.from(raw, "utf8"); + } + if (b.length === 32) return b; + const out = Buffer.alloc(32); + b.copy(out, 0, 0, Math.min(b.length, 32)); + return out; +} +const KEY = deriveKey(KEY_RAW); + +/** AES-256-GCM:返回 base64(ciphertext‖16字节tag) */ +function gcmEncrypt(plaintext, iv) { + const c = crypto.createCipheriv("aes-256-gcm", KEY, iv); + const enc = Buffer.concat([c.update(plaintext, "utf8"), c.final()]); + return Buffer.concat([enc, c.getAuthTag()]).toString("base64"); +} +function gcmDecrypt(b64, iv) { + const buf = Buffer.from(b64, "base64"); + const tag = buf.subarray(buf.length - 16); + const data = buf.subarray(0, buf.length - 16); + const d = crypto.createDecipheriv("aes-256-gcm", KEY, iv); + d.setAuthTag(tag); + return Buffer.concat([d.update(data), d.final()]).toString("utf8"); +} +/** 宽松 base64 → Buffer(补齐 padding) */ +const b64relax = (s) => Buffer.from(s + "=".repeat((4 - (s.length % 4)) % 4), "base64"); + +function readCache() { + try { + if (!fs.existsSync(TOKEN_CACHE_FILE)) return {}; + return JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, "utf8")) || {}; + } catch (e) { + return {}; + } +} + +function writeCache(cache) { + try { + fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cache, null, 2), "utf8"); + } catch (e) { + $.log(`写入token缓存失败: ${e.message || e}`); + } +} + +function parseAccount(raw = "") { + const [openid, appid, storeId, activityId, remark] = String(raw).split("#").map((s) => (s || "").trim()); + return { openid, appid, storeId, activityId, remark: remark || "" }; +} + +function short(v, n = 200) { + const t = typeof v === "string" ? v : JSON.stringify(v); + return !t ? "" : t.length > n ? `${t.slice(0, n)}...` : t; +} + +const isOk = (res) => !!res && res.status === true && Number(res.code || 0) === 0; +const codeOf = (res) => Number(res?.code); +const msgOf = (res) => res?.message || res?.msg || short(res); +const isAlreadyDone = (t) => /已签|已经签|签到过|重复|已完成|already/i.test(String(t || "")); +const isAuthError = (res) => codeOf(res) === 401 || /token|登录|未授权|失效|过期/i.test(msgOf(res)); + +class Task { + constructor(raw) { + this.index = $.userIdx++; + this.account = parseAccount(raw); + this.token = ""; + this.wechat = new WeChatServer({ + url: process.env.wx_server_url || "http://192.168.31.196:8787", + appid: this.account.appid, + auth: process.env.wx_auth || "", + }); + } + + log(text) { + const tag = this.account.remark || this.account.appid || ""; + $.log(`账号[${this.index}]${tag ? `[${tag}]` : ""} ${text}`); + } + + headers(tok = "") { + return { + Accept: "v=1.0", + "store-id": this.account.storeId, + "Qm-From-Type": "catering", + "Qm-From": "wechat", + "content-type": "application/json", + "Qm-User-Token": tok || "", + "Accept-Language": "zh", + "User-Agent": USER_AGENT, + Referer: `https://servicewechat.com/${this.account.appid}/0/page-frame.html`, + xweb_xhr: "1", + }; + } + + /** 加密请求 + 解密响应 */ + async request(apiPath, body, tok = "") { + const payloadObj = { ...body }; + if (!payloadObj.appid) payloadObj.appid = this.account.appid; // 解包 j() 自动补 appid + const iv = crypto.randomBytes(12); + const ts = Date.now(); + const cipher = gcmEncrypt(JSON.stringify(payloadObj), iv); + const meta = Buffer.from( + JSON.stringify({ version: KEY_VERSION, timestamp: ts, iv: iv.toString("base64") }) + ).toString("base64"); + const headers = this.headers(tok); + headers[META_HEADER] = meta; + const res = await axios.request({ + method: "POST", + url: `${BASE}${apiPath}`, + data: { payload: cipher }, + headers, + timeout: 20000, + validateStatus: () => true, + }); + if (res.status !== 200) { + if (res.data && typeof res.data === "object") return res.data; + throw new Error(`${apiPath} HTTP ${res.status}: ${short(res.data)}`); + } + const data = res.data; + // 响应可能是明文(错误信封)或加密 {payload} + if (data && typeof data === "object" && typeof data.payload === "string") { + const rmeta = res.headers[META_HEADER.toLowerCase()] || res.headers[META_HEADER]; + if (!rmeta) throw new Error(`${apiPath}: 响应加密但缺 ${META_HEADER}`); + const mj = JSON.parse(b64relax(rmeta).toString("utf8")); + const riv = b64relax(mj.iv); + return JSON.parse(gcmDecrypt(data.payload, riv)); + } + return data; + } + + /** wcs.getCode 在 status:false 时也 resolve,必须自己判失败,否则取码限流会被误报成登录失败 */ + async getCode() { + const { data } = await this.wechat.getCode(this.account.openid); + if (data && data.status === false) { + throw new Error(`wx_server 取code失败: ${data.message || short(data)}`); + } + const code = data?.data?.code || data?.code; + if (!code || typeof code !== "string") throw new Error(`wx_server 未返回 code: ${short(data)}`); + return code; + } + + get cacheKey() { + return `${this.account.openid}#${this.account.appid}`; + } + + async login() { + const code = await this.getCode(); + const res = await this.request(EP_LOGIN, { code, eVersion: "1.0" }); + if (!isOk(res) || !(res.data || {}).token) throw new Error(`登录失败: ${msgOf(res)}`); + this.token = String(res.data.token); + const member = res.data.user || {}; + const cache = readCache(); + cache[this.cacheKey] = { token: this.token, updatedAt: new Date().toISOString() }; + writeCache(cache); + this.log(`登录成功${member.memberId || member.id ? `(会员 ${String(member.memberId || member.id).slice(0, 8)}…)` : ""}`); + } + + /** 读签到状态;会话失效返回 null */ + async stats(needLog = true) { + const res = await this.request(EP_STATS, { activityId: this.account.activityId }, this.token); + if (!isOk(res)) { + if (isAuthError(res)) return null; + if (needLog) this.log(`读取签到状态失败: ${msgOf(res)}`); + return { failed: true, res }; + } + const d = res.data || {}; + if (needLog) this.log(`签到状态: ${short(d, 140)}`); + // 常见字段:todaySign / isSign / continuousDays + const signed = d.todaySign === true || d.isSign === true || Number(d.todaySignStatus) === 1; + return { signed, data: d }; + } + + async ensureLogin() { + const cached = readCache()[this.cacheKey] || {}; + if (!this.token && cached.token) { + this.token = cached.token; + const s = await this.stats(false); + if (s !== null) { + this.log("使用缓存token"); + return s; + } + this.log("缓存token失效,重新登录"); + this.token = ""; + } + if (!this.token) await this.login(); + return null; + } + + async sign(pre) { + if (pre === null || pre === undefined) pre = await this.stats(); + if (pre === null) { + this.log("❌ 会话无效,签到跳过"); + return; + } + if (pre.failed) { + this.log(`❌ 读取签到状态失败: ${msgOf(pre.res)}`); + return; + } + if (pre.signed) { + this.log("✅ 今日已签到"); + return; + } + const res = await this.request(EP_SIGN, { activityId: this.account.activityId }, this.token); + if (isOk(res)) { + const d = res.data || {}; + const gain = d.point ?? d.integral ?? d.reward; + this.log(`✅ 签到成功${gain !== undefined ? `,+${gain}` : ""}`); + return; + } + if (isAlreadyDone(msgOf(res))) return this.log(`✅ 今日已签到(${msgOf(res)})`); + this.log(`❌ 签到失败: ${msgOf(res)}`); + } + + async run() { + if (!this.account.openid || !this.account.appid || !this.account.storeId || !this.account.activityId) { + this.log("跳过:变量值要写成 openid#appid#storeId#activityId[#备注](activityId 从签到页抓一次)"); + return; + } + try { + const pre = await this.ensureLogin(); + await this.sign(pre); + } catch (e) { + this.log(`执行失败: ${e.message || e}`); + } + } +} + +!(async () => { + $.checkEnv(ckName); + if (!$.userCount) { + $.log(`未找到变量 ${ckName}`); + return; + } + for (let i = 0; i < $.userList.length; i++) { + await new Task($.userList[i]).run(); + if (i < $.userList.length - 1) await $.wait(1500, 3000); + } +})() + .catch((e) => $.log(e.message || e)) + .finally(() => $.done());