ece41086cd
背景:美团搜索/供给对销量排序支持差(实测乱序)、且有 402 限流和单次召回上限,不适合 每次实时打接口排序。改为定时把券抓进本地 meituan_coupon 表,查询时从库里捞、自己按 销量/佣金排。 本次内容: - app/models/meituan_coupon.py:新表模型。字段尽量全(售价/原价/销量档/佣金率/佣金额/ 门店/距离/品牌/图 等),raw 列存整条原始返回避免漏字段;(source, product_view_sign) 唯一。 - alembic/versions/meituan_coupon_table.py:建表迁移(挂在 withdraw_review_ad_watch 之后)。 - scripts/pull_meituan_coupons.py:ETL,抓 3 路并按 (source, product_view_sign) upsert: 1) 外卖·搜"外卖"(platform=1)翻到尽头 2) 外卖·搜"美食"(platform=1)翻到尽头 3) 到店·多业务线供给(到餐+到综+酒店+门票)翻到尽头 支持 --once(单轮,给 cron;线上每 1h)/ --loop --interval(本地循环,默认 10min) + 运行锁防重叠 + --prune-hours 清理陈旧券(默认 24h)。 - app/models/__init__.py:注册新模型。 实测:北京一轮约 2.5 分钟、入库约 2700 条;销量/佣金可直接从库里排序。 给接手同事: - 仅北京(city_id 固定);v2 再加 per商圈 union 突破全城单次召回上限。 - 销量仅粗档位且约 51% 的券才有,查询需 WHERE sale_volume_num IS NOT NULL (PG 的 DESC 默认把 NULL 排最前);佣金、价格 100% 有值。 - productViewSign/skuViewId 跨渠道会变,不能当全局 id;跨源去重用 dedup_key=md5(品牌|名|价)。 - 待做:查询接口(从库捞 + dedup_key 去重 + 按销量/佣金排,返回前端)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: chenshuobo <1119780489@qq.com> Reviewed-on: #18
145 lines
4.6 KiB
Python
145 lines
4.6 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,
|
|
platform: int = 1,
|
|
biz_line: int | None = None,
|
|
sid: str | None = None,
|
|
link_type_list: list[int] | None = None,
|
|
) -> dict[str, Any]:
|
|
body: dict[str, Any] = {"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)
|