中国移动爱购商城混合下单流程拆解与Python实现思路
中国移动爱购商城「混合下单」流程拆解与 Python 实现思路(卡券自动抢购 / 库迪 / 星巴克 / 奈雪 / 和平精英)
关键词:爱购商城、卡券自动下单、混合下单、星巴克券、库迪券、奈雪券、和平精英、移动积分、和包、Python 爬虫、HTTP 自动化
一、前言
最近在研究中国移动 爱购商城 / 权益市场(域名 *.coc.10086.cn)上的卡券自动抢购方案,涉及的商品包括但不限于:
- 库迪咖啡券
- 星巴克券
- 奈雪的茶券
- 和平精英道具 / 礼包
- 以及一些常见的"五折购"卡券
这套系统下单流程里最复杂的一块就是所谓的「混合下单(mixedOrder)」——它不像普通商品那样走简单的 submitOrder,而是要在真正下单之前完成一次完整的"风控握手"。下面把整条链路按照"用浏览器抓包"的角度拆开讲一遍,给有同样需求的同学一个整体实现思路。
⚠️ 本文只讲流程 + 伪代码 + 整体架构,不暴露任何具体加密算法、公钥、硬编码环境指纹。
⚠️ 仅供学习研究使用,请勿用于任何商业 / 灰产用途。
二、整体下单链路(一张图看懂)
把整个流程画成时间轴,从登录到下单成功一共 9 步:
登录 (smsLogin / appLogin)
↓
详情页预热 (getProductDetailById × 2)
↓
积分计算 (bestPriceCalculate)
↓
积分抵扣查询 (hasDeductionSku)
↓
优惠券选择 (selectCoupon)
↓
权益账户查询 (projectAccount/info)
↓
风控黑名单校验 (checkBlacklist)
↓
风控握手 (wasmparams ← gettoken)
↓
短信验证码 + mixedOrder 下单
可以分成 3 个阶段:
- 预热阶段:模拟用户进详情页
- 风控阶段:算 digitalBean、扣减额度、选券
- 下单阶段:验证码 + 加密风控参数 + 真正下单
三、阶段一:登录
爱购商城有两种登录方式:
| 登录方式 | 入口 | 备注 |
|---|---|---|
| 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 模块(前端采集指纹 + 加密 + 验签一体),它会做两件事:
- 在浏览器侧采集一堆前端环境指纹(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
九、常见坑 & 排查思路
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
返回 火爆 / 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==
觉得有帮助的可以点赞 + 收藏。
版权声明:本文为技术研究文章,所有接口均来自公开可访问的移动爱购商城,未涉及任何后门、内鬼数据。所有代码片段均为伪代码 / 占位实现。严禁用于任何商业 / 灰产用途。
浙公网安备 33010602011771号