mirror of
https://github.com/smallfawn/QLScriptPublic.git
synced 2026-11-05 22:36:53 +08:00
feat(wxapp): 新增 企迈qmai平台通用签到 qmai.js(复刻服务端强制的 AES-GCM 请求体加密)
企迈(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": <base64>},头 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
551eadf3d2
commit
32378b1f58
1 file changed
+311
+311
@@ -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:<storeId> / 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());
|
||||
Reference in new issue
Block a user