中国移动爱购商城「混合下单」流程拆解与 Python 实现思路(卡券自动抢购 / 库迪 / 星巴克 / 奈雪 / 和平精英)
关键词:爱购商城、卡券自动下单、混合下单、星巴克券、库迪券、奈雪券、和平精英、移动积分、和包、Python 爬虫、HTTP 自动化
一、前言
最近在研究中国移动 爱购商城 / 权益市场(域名 *.coc.10086.cn)上的卡券自动抢购方案,涉及的商品包括但不限于:
这套系统下单流程里最复杂的一块就是所谓的「混合下单(mixedOrder)」——它不像普通商品那样走简单的 submitOrder,而是要在真正下单之前完成一次完整的"风控握手"。下面把整条链路按照"用浏览器抓包"的角度拆开讲一遍,给有同样需求的同学一个整体实现思路。
⚠️ 本文只讲流程 + 伪代码 + 整体架构,不暴露任何具体加密算法、公钥、硬编码环境指纹。 ⚠️ 仅供学习研究使用,请勿用于任何商业 / 灰产用途。
二、整体下单链路(一张图看懂)
把整个流程画成时间轴,从登录到下单成功一共 9 步:
登录 (smsLogin / appLogin) ↓详情页预热 (getProductDetailById × 2) ↓积分计算 (bestPriceCalculate) ↓积分抵扣查询 (hasDeductionSku) ↓优惠券选择 (selectCoupon) ↓权益账户查询 (projectAccount/info) ↓风控黑名单校验 (checkBlacklist) ↓风控握手 (wasmparams ← gettoken) ↓短信验证码 + mixedOrder 下单
可以分成 3 个阶段:
- 预热阶段
- 风控阶段
- 下单阶段
三、阶段一:登录
爱购商城有两种登录方式:
| | |
|---|
| LoginDevCocByMethodAsync | |
| LoginDevCocWithSmsAsync | |
伪代码:
def login(phone: str, method: str) -> str: """ 返回 token-DevCoc (写进 cookie, 后面所有请求都要带) """ if method == "sms": # 1) 请求下发短信验证码 send_sms(phone, encrypt_phone=encrypt_rsa(phone)) # 2) 等用户输入验证码 (或从短信猫/通知拿到) sms_code = wait_for_sms(phone, timeout=60) # 3) 用 RSA 加密手机号 + 验证码, 登录 resp = post("/coc3/gr/login/ssoRights/userLoginV2", { "m1": encrypt_rsa(phone), "m2": md5(reverse_base64(phone) + SALT + phone), "s1": encrypt_rsa(sms_code), "s2": md5(sms_code + SALT + reverse_base64(sms_code)), "ticket": "MOBILE", "ticketType": 7, "businessType": "RIGHTS_MARKET", }) return parse_token_from_cookie(resp) else: # app return use_existing_app_token(phone)
登录成功之后,两个 cookie 必须保留:
四、阶段二:详情页预热
进详情页之前浏览器会先发两次完全相同的 getProductDetailById 请求,这是为了模拟"用户从列表页点进来"的行为。服务端不会因此校验更多东西,跳过也大概率能下,但偶尔会被风控挡。
def warmup_product_page(sku_id: int) -> str: """ 返回 pageUrl (后续 wasmparams 里要原样塞回, 不能乱改) """ # 拼一个标准 detail 页面 URL, 这个 URL 必须和浏览器一致 page_url = build_detail_url( mid=sku_id, paytype=7, # 五折购固定 7 rule_code=config.rule_code, # 配置里的活动规则码 channel_code=config.channel_code, member_id="153", # 浏览器默认 memberId page_recorded="true", ) # 浏览器实际会请求两次, 第一次是为了初始化, 第二次是真正拿数据 for _ in range(2): get( url="/coc3/coc3-market/arrange/getProductDetailById", params={"id": sku_id, "isNeedDesc": "false", "t": now_unix()}, headers={ "phone": token_dev_coc, "referer": page_url, "user-agent": "<固定 UA>", ... } ) return page_url
💡 坑点:pageUrl 后面 wasmparams 校验里要原样塞回去,任何 query 顺序 / 编码方式不一致都会被风控打回"火爆"。建议直接用浏览器抓包抓的 URL 当作模板。
五、阶段三:积分 & 抵扣 & 选券
这 4 个接口是一组预计算请求,目的是告诉服务端"我准备用多少 digitalBean + 哪张券"。
def prepare_order(sku_id: int, page_url: str) -> PreparedOrder: # 1) 算最划算的 digitalBean 用量 + 实际应付 best_price = post( "/coc3/coc3-market/arrange/bestPriceCalculate", params={"skuId": sku_id, "operationType": 0, "isApp": 1, "t": now_unix()}, headers={"phone": token_dev_coc, "referer": page_url, ...} ) digital_bean = best_price["canUseSzd"] or best_price["canMaxAib"] or 0 pay_amount = best_price["discountsPrice"] or best_price["price"] # 2) 查这个 SKU 能不能积分抵扣 has_deduct = post( "/coc3/coc3-market/api/integralAccount/hasDeductionSku", body={"skuId": sku_id, "projectAccountId": 2}, headers={"content-type": "application/json", "referer": page_url, ...} ) # 3) 选券 (没券就发个空请求, 让服务端知道"我选过了") post( "/coc3/coc3-market-card/api/card/selectCoupon", headers={ "phone": token_dev_coc, "skuid": str(sku_id), # 注意是 "skuid" 不是 "skuId" "content-length": "0", "referer": page_url, ... } ) # 4) 查权益账户余额 post( "/coc3/gr-integral-ability/api/external/score/projectAccount/info", body={"accountType": "QUAN_YI_JIN", "businessType": 2}, headers={"token": token_dev_coc, ...} ) # 5) 风控黑名单 post( "/coc3/coc3-market-order/api/external/black/checkBlacklist", body={"skuId": sku_id}, headers={"phone": token_dev_coc, ...} ) return PreparedOrder( digital_bean=digital_bean, pay_amount=pay_amount, page_url=page_url, )
4 个请求之间的先后顺序是固定的(服务端会按调用顺序锁账号状态),不能并发。
六、阶段四:风控握手 wasmparams(最难的一步)
这是整条链路里最恶心的部分。爱购商城在浏览器里跑了一个 WASM 模块(前端采集指纹 + 加密 + 验签一体),它会做两件事:
- 在浏览器侧采集一堆前端环境指纹(50+ 个字段,覆盖 canvas、webgl、cookie、错误栈等)
- 用这些指纹 + 一个
verification 签名 + AES-256-GCM 加密 + RSA 加密 AES key,最终产出 wasmparams = ["cocAurora", "cipherText"]
服务端拿到这两个值之后,会用同一套规则反算 verification、再用 RSA 解开 AES key、再用 AES-GCM 解开密文,最后比对指纹。
实现思路(伪代码):
def build_wasmparams(phone_token: str, page_url: str) -> list: """ 复刻浏览器 WASM 模块的两步加密流程 返回 ["<RSA-Base64>", "<AES-GCM-Base64>"] """ # === Step 1: 构造前端环境指纹 env (50+ 字段) === # 这些字段在浏览器里是实时采集的, 服务端会校验关键几个: # - page_url (必须和上面 warmup 时拼的一致) # - user_agent # - time_elapsed (从进入页面到现在的毫秒数, 30~60s 之间最稳) # - 浏览器渲染相关指纹 (canvas / webgl / cookie / 错误栈 等) # 其它字段 (mime_types / plugins / hardware_concurrency ...) 大多可以固定 env = build_browser_env(page_url, time_elapsed=randint(30000, 60000)) # === Step 2: 计算 verification 签名 === # 算法核心 (用伪代码描述): # - 拼接一个 canonical 字符串 (按 env 字段的固定顺序) # - 再选其中一个字段算 selected digest # - 最终 sha256(canonical + ts + selected_digest + <服务端固定盐值>) # 关键坑: # - 字段顺序敏感, env.keys() 的插入顺序就是 canonical 拼接顺序 # - 模数是 min(10, len(env)), 不是 len(env) # - time_elapsed 走整数无引号 # - device_pixel_ratio 走 float 必须带小数点 # - 其它值直接 json.dumps, dict / 嵌套对象必须直接 dump ts = int(time.time() * 1000) verification = compute_verification(env, ts) # === Step 3: gettoken 那一步 === # 把 { data, environment, timestamp, verification } 整个 JSON 加密: # AES-256-GCM -> 输出 nonce(12) || ct || tag(16) # RSA-PKCS1v15 加密 AES key -> cocAurora payload = {"data": {...}, "environment": env, "timestamp": ts, "verification": verification} coc_aurora_1, cipher_text_1 = rsa_aesgcm_encrypt(payload, pub_key=RSA_PUBKEY) # 发请求拿 deviceToken device_token = post( "/coc3/gr/risk-ability/wasm/gettoken", body={ "benefit_phone": phone_token, "encryptedText": cipher_text_1, "timestamp": ts, }, headers={"coc-aurora": coc_aurora_1, "referer": page_url, ...} ) # === Step 4: mixedOrder 加密 === # env 这次只有 5 个字段: deviceToken / platform / time_elapsed / url / user_agent env2 = { "deviceToken": device_token, "platform": "Linux aarch64", "time_elapsed": time_elapsed + 139, # 注意 +139 "url": page_url, "user_agent": UA, } payload2 = {"data": {}, "environment": env2, "timestamp": ts + 139, "verification": compute_verification(env2, ts + 139)} coc_aurora_2, cipher_text_2 = rsa_aesgcm_encrypt(payload2, pub_key=RSA_PUBKEY) return [coc_aurora_2, cipher_text_2]
wasmparams 最终就是 json.dumps([cocAurora, cipherText]),直接当字符串塞 HTTP header 的 wasmparams 字段。
6.1 RSA + AES-GCM 加密
def rsa_aesgcm_encrypt(plaintext: dict, pub_key_pem: str) -> tuple: """ 复刻 C# RSA.Encrypt(..., RSAEncryptionPadding.Pkcs1) + AesGcm """ aes_key = os.urandom(32) nonce = os.urandom(12) # RSA 加密 AES key rsa = RSA.import_key(pub_key_pem) coc_aurora = base64.b64encode( PKCS1_v1_5.new(rsa).encrypt(aes_key) ).decode() # AES-GCM 加密 cipher = AES.new(aes_key, AES.MODE_GCM, nonce=nonce) ct, tag = cipher.encrypt_and_digest(json.dumps(plaintext, ensure_ascii=False, separators=(",", ":")).encode()) # 输出: nonce(12) || ct(N) || tag(16), 整体 base64 cipher_text = base64.b64encode(nonce + ct + tag).decode() return coc_aurora, cipher_text
6.2 verification 计算
def compute_verification(env: dict, ts: int) -> str: keys = list(env.keys()) # 顺序敏感! ts_text = str(ts) modulus = min(10, len(keys)) # 模数 = min(10, 字段数) idx = ts % modulus if idx < 0: idx += modulus selected_key = keys[idx] selected_value = env[selected_key] # canonical: 拼 key + json(key, value) canonical = "".join(f"{k}{_serialize(k, env[k])}" for k in keys) # selected digest sel_str = f"{selected_key}{_serialize(selected_key, selected_value)}{ts_text}" selected_digest = hashlib.sha256(sel_str.encode("utf-8")).digest() # final # 注: 末尾要拼一个服务端写死的"魔数"字符串 (盐值), 整个 verification 才算完整 # 真实盐值需要从浏览器 WASM 模块里反推, 这里不写出来 merged = canonical.encode("utf-8") + ts_text.encode("utf-8") + selected_digest + b"<SALT>" return hashlib.sha256(merged).hexdigest()def _serialize(key: str, value) -> str: if key == "time_elapsed": # 整数无引号 return str(int(value)) if key == "device_pixel_ratio": # 浮点必须带小数点 s = repr(float(value)) return s if "." in s or "e" in s else s + ".0" return json.dumps(value, ensure_ascii=False) # 其它 (含 dict) 直接 dump
上面只展示了算法骨架和关键约束(顺序、模数、特殊字段、加密套路),真正能从浏览器里抠出来的指纹字段值、错误栈字符串、verification 校验用到的"魔数盐值"、RSA 公钥这些都没在本文放出来——大家自己拿浏览器 DevTools 抓 WASM 模块的导出函数反推就行。
七、阶段五:验证码 + 下单
风控参数拿到后,剩下的就是正常下单流程:
def place_order(sku_id: int, sms_code: str, wasmparams: list, digital_bean: int): resp = post( "/coc3/coc3-market-order/api/external/mixedOrder", body={ "receivers": phone_token, "skuId": encrypt_sku_id(sku_id), # skuId 也要 RSA 加密 "member": True, "smsCode": sms_code, "digitalBean": digital_bean, "couponBatchId": "...", # 可选 ... }, headers={ "wasmparams": json.dumps(wasmparams), # ← 第六步算出来的 "phone": phone_token, ... } ) return resp
返回 code=0 就说明下单成功,下一个短信猫收验证码 → 循环跑下一个手机号。
八、整体调度(伪代码)
def run_one_phone(phone: str, config: OrderConfig): # 1) 登录 token = login(phone, method=config.login_method) phone_token = token # 2) 详情页预热 page_url = warmup_product_page(config.sku_id) # 3) 预计算 digitalBean / 选券 prep = prepare_order(config.sku_id, page_url) # 4) 风控握手 wasmparams = build_wasmparams(phone_token, page_url) # 5) 请求发短信 request_sms_code(config.sku_id, phone_token, prep.digital_bean) # 6) 等短信 sms_code = wait_for_sms(phone, timeout=60) # 7) 真正下单 result = place_order(config.sku_id, sms_code, wasmparams, prep.digital_bean) return result
九、常见坑 & 排查思路
| | |
|---|
| pageUrl 拼错 / wasmparams 加密不对 | 抓浏览器真包,对比 query / env / verification |
| | |
| gettoken 返回 200 但没 deviceToken | RSA 公钥不匹配 / JSON 序列化转义不一致 | 公钥从浏览器 wheel 包里再抠一次;确认 ensure_ascii=False, separators=(",", ":") |
| | |
| | |
十、扩展:哪些商品能下?
只要 skuId + 配置对得上,都是同一套流程。只是不同活动的 ruleCode / channelCode / tc 不一样,pageUrl 要跟着改。
十一、写在最后
- 本文不提供 RSA 公钥、不提供 verification 用的盐值、不提供浏览器侧的固定指纹字符串 —— 真正在生产环境里跑,必须自己拿浏览器真包反推,因为服务端升级时这些细节会变。
- 真要批量跑,请控制并发 + 控制好 cookie 池 + 控制好短信猫节奏,别把账号搞炸了。
- 这套东西有 50% 的难度在风控,剩下 50% 在登录态维护和验证码延迟。
如果你在实现过程中遇到具体问题(比如某个 verification 怎么算的、或者抓包怎么定位字段),可以评论区交流。也可以地球联系 chaego1 企鹅 MTQ1NDA0MjExNw== 觉得有帮助的可以点赞 + 收藏。
版权声明:本文为技术研究文章,所有接口均来自公开可访问的移动爱购商城,未涉及任何后门、内鬼数据。所有代码片段均为伪代码 / 占位实现。严禁用于任何商业 / 灰产用途。