中国移动爱购商城混合下单流程拆解与Python实现思路

中国移动爱购商城「混合下单」流程拆解与 Python 实现思路(卡券自动抢购 / 库迪 / 星巴克 / 奈雪 / 和平精英)

关键词:爱购商城、卡券自动下单、混合下单、星巴克券、库迪券、奈雪券、和平精英、移动积分、和包、Python 爬虫、HTTP 自动化


一、前言

最近在研究中国移动 爱购商城 / 权益市场(域名 *.coc.10086.cn)上的卡券自动抢购方案,涉及的商品包括但不限于:

  • 库迪咖啡券
  • 星巴克券
  • 奈雪的茶券
  • 和平精英道具 / 礼包
  • 以及一些常见的"五折购"卡券

这套系统下单流程里最复杂的一块就是所谓的「混合下单(mixedOrder)」——它不像普通商品那样走简单的 submitOrder,而是要在真正下单之前完成一次完整的"风控握手"。下面把整条链路按照"用浏览器抓包"的角度拆开讲一遍,给有同样需求的同学一个整体实现思路

⚠️ 本文只讲流程 + 伪代码 + 整体架构,不暴露任何具体加密算法、公钥、硬编码环境指纹。
⚠️ 仅供学习研究使用,请勿用于任何商业 / 灰产用途。


二、整体下单链路(一张图看懂)

把整个流程画成时间轴,从登录到下单成功一共 9 步:

登录 (smsLogin / appLogin)
   ↓
详情页预热 (getProductDetailById × 2)
   ↓
积分计算 (bestPriceCalculate)
   ↓
积分抵扣查询 (hasDeductionSku)
   ↓
优惠券选择 (selectCoupon)
   ↓
权益账户查询 (projectAccount/info)
   ↓
风控黑名单校验 (checkBlacklist)
   ↓
风控握手 (wasmparams ← gettoken)
   ↓
短信验证码 + mixedOrder 下单

可以分成 3 个阶段:

  1. 预热阶段:模拟用户进详情页
  2. 风控阶段:算 digitalBean、扣减额度、选券
  3. 下单阶段:验证码 + 加密风控参数 + 真正下单

三、阶段一:登录

爱购商城有两种登录方式

登录方式 入口 备注
App 登录 LoginDevCocByMethodAsync 复用移动 App 已有的 token
短信登录 LoginDevCocWithSmsAsync 抓短信 + RSA 加密登录

伪代码:

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 必须保留

  • token-DevCoc:所有 dev.coc 接口的身份凭证
  • User-Token:部分接口额外需要

四、阶段二:详情页预热

进详情页之前浏览器会先发两次完全相同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 模块(前端采集指纹 + 加密 + 验签一体),它会做两件事:

  1. 在浏览器侧采集一堆前端环境指纹(50+ 个字段,覆盖 canvas、webgl、cookie、错误栈等)
  2. 用这些指纹 + 一个 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

九、常见坑 & 排查思路

现象 大概率原因 排查方向
返回 火爆 / risk pageUrl 拼错 / wasmparams 加密不对 抓浏览器真包,对比 query / env / verification
返回 code != 0 但没具体原因 token 过期 / 风控黑名单 重新登录;连续 3 次换 IP
gettoken 返回 200 但没 deviceToken RSA 公钥不匹配 / JSON 序列化转义不一致 公钥从浏览器 wheel 包里再抠一次;确认 ensure_ascii=False, separators=(",", ":")
verification 一直对不上 字段顺序 / 特殊字段序列化方式 在浏览器抓到的真 JSON 上逐字段对比
下单时返回 smsCode invalid 短信超时 / 验证码没及时拿到 短信猫和并发数要匹配

十、扩展:哪些商品能下?

  • 库迪咖啡(各种满减券、5 折券)
  • 星巴克(中杯升杯券、买一赠一券)
  • 奈雪的茶(满减券、饮品券)
  • 和平精英(皮肤、点券、礼包)
  • 各种五折购活动(paytype=7)
  • 各种视频会员(腾讯/爱奇艺/优酷 月卡)

只要 skuId + 配置对得上,都是同一套流程。只是不同活动的 ruleCode / channelCode / tc 不一样,pageUrl 要跟着改。


十一、写在最后

  • 本文不提供 RSA 公钥、不提供 verification 用的盐值、不提供浏览器侧的固定指纹字符串 —— 真正在生产环境里跑,必须自己拿浏览器真包反推,因为服务端升级时这些细节会变。
  • 真要批量跑,请控制并发 + 控制好 cookie 池 + 控制好短信猫节奏,别把账号搞炸了
  • 这套东西有 50% 的难度在风控,剩下 50% 在登录态维护和验证码延迟。

如果你在实现过程中遇到具体问题(比如某个 verification 怎么算的、或者抓包怎么定位字段),可以评论区交流。也可以地球联系 chaego1
企鹅 MTQ1NDA0MjExNw==
觉得有帮助的可以点赞 + 收藏


版权声明:本文为技术研究文章,所有接口均来自公开可访问的移动爱购商城,未涉及任何后门、内鬼数据。所有代码片段均为伪代码 / 占位实现。严禁用于任何商业 / 灰产用途。

posted @ 2026-06-30 10:07  小螺软件宝  阅读(2)  评论(0)    收藏  举报