Files
shaguabijia-app-server/app/integrations/meituan.py
T
marco 277f9b16a2 feat(cps): 群发券分发与对账 + 短链点击追踪
群(sid)/活动池/转链(带sid)/美团 query_order 按 sid 对账/按群统计;
短链 /c/{code} 记点击(PV/UV)→302 跳美团,点击→下单漏斗。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:00:29 +08:00

182 lines
5.8 KiB
Python

"""美团联盟 CPS 开放接口客户端。
签名方式:类阿里云网关 S-Ca 头,但 stringToSign **只有 4 段**——
METHOD\n + Content-MD5\n + Headers + Url
没有 Accept / Content-Type / Date 行(加了就签名失败)。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.meituan")
class MeituanCpsError(Exception):
"""美团 CPS 接口调用失败。"""
def _content_md5(body: bytes) -> str:
return base64.b64encode(hashlib.md5(body).digest()).decode()
def _sign(secret: str, method: str, content_md5: str, path: str,
signed_headers: dict[str, str]) -> str:
block = "".join(
f"{k}:{signed_headers[k]}\n"
for k in sorted(signed_headers)
)
sts = f"{method}\n{content_md5}\n{block}{path}"
return base64.b64encode(
hmac.new(secret.encode(), sts.encode(), hashlib.sha256).digest()
).decode()
def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
if not settings.MT_CPS_APP_KEY or not settings.MT_CPS_APP_SECRET:
raise MeituanCpsError("MT_CPS_APP_KEY / MT_CPS_APP_SECRET not configured")
body = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
content_md5 = _content_md5(body)
ts = str(int(time.time() * 1000))
signed_headers = {"S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts}
signature = _sign(settings.MT_CPS_APP_SECRET, "POST", content_md5, path, signed_headers)
headers = {
"Content-Type": "application/json",
"Content-MD5": content_md5,
"S-Ca-App": settings.MT_CPS_APP_KEY,
"S-Ca-Timestamp": ts,
"S-Ca-Signature-Headers": "S-Ca-Timestamp,S-Ca-App",
"S-Ca-Signature": signature,
}
url = f"{settings.MT_CPS_HOST}{path}"
# 美团调用走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
proxy = settings.MT_CPS_PROXY or None
try:
with httpx.Client(proxy=proxy, trust_env=False, timeout=settings.MT_CPS_TIMEOUT_SEC) as client:
resp = client.post(url, content=body, headers=headers)
except httpx.HTTPError as e:
logger.exception("[MT] http error calling %s", url)
raise MeituanCpsError(f"meituan http error: {e}") from e
if resp.status_code != 200:
logger.error("[MT] HTTP %s body=%s", resp.status_code, resp.text[:500])
raise MeituanCpsError(f"meituan http {resp.status_code}")
data = resp.json()
if data.get("code") != 0:
logger.error("[MT] api error code=%s message=%s", data.get("code"), data.get("message"))
raise MeituanCpsError(f"code={data.get('code')} {data.get('message')}")
return data
# ────────────────────── 业务方法 ──────────────────────
def query_coupon(
*,
longitude: float,
latitude: float,
platform: int = 1,
biz_line: int | None = None,
list_topic_id: int | None = 3,
search_text: str | None = None,
search_id: str | None = None,
sort_field: int | None = None,
page_no: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
body: dict[str, Any] = {
"platform": platform,
"longitude": int(longitude * 1_000_000),
"latitude": int(latitude * 1_000_000),
"pageNo": page_no,
"pageSize": page_size,
}
if biz_line is not None:
body["bizLine"] = biz_line
if search_text:
body["searchText"] = search_text
if sort_field is None:
sort_field = 6
elif list_topic_id is not None:
body["listTopiId"] = list_topic_id
if sort_field is not None:
body["sortField"] = sort_field
if search_id:
body["searchId"] = search_id
return _call("/cps_open/common/api/v1/query_coupon", body)
def get_referral_link(
*,
product_view_sign: str | None = None,
act_id: str | None = None,
platform: int = 1,
biz_line: int | None = None,
sid: str | None = None,
link_type_list: list[int] | None = None,
) -> dict[str, Any]:
"""换推广链接。actId(活动物料)与 productViewSign(商品券)二选一。
用 actId 入参时,出参 data 为 null,链接全在 referralLinkMap(实测)。
"""
if not act_id and not product_view_sign:
raise MeituanCpsError("act_id 或 product_view_sign 必填其一")
body: dict[str, Any] = {}
if act_id:
body["actId"] = act_id
else:
body["productViewSign"] = product_view_sign
if platform == 2:
body["platform"] = 2
if biz_line is not None:
body["bizLine"] = biz_line
body["sid"] = sid or settings.MT_CPS_DEFAULT_SID
body["linkTypeList"] = link_type_list or [1, 3]
return _call("/cps_open/common/api/v1/get_referral_link", body)
def query_order(
*,
sid: str | None = None,
start_time: int,
end_time: int,
query_time_type: int = 1,
page: int = 1,
limit: int = 100,
) -> dict[str, Any]:
"""按时间窗(+可选 sid)拉 CPS 订单做对账。实测要点:
- 分页是 page / limit(不是 pageNo/pageSize);startTime/endTime 是 10 位秒级时间戳
- query_time_type: 1 按支付时间 2 按更新时间
- 响应 data.dataList[],每条含 orderId / sid / payPrice(元) / profit(元) /
commissionRate / status(2付款 3完成 4取消 5风控 6结算) / payTime(秒) / actId 等
"""
body: dict[str, Any] = {
"queryTimeType": query_time_type,
"startTime": start_time,
"endTime": end_time,
"page": page,
"limit": limit,
}
if sid:
body["sid"] = sid
return _call("/cps_open/common/api/v1/query_order", body)