From cc9d9f03f7f61089cebd780bcb03d5b8f20e744b Mon Sep 17 00:00:00 2001 From: lowmaster-chen <1119780489@qq.com> Date: Tue, 26 May 2026 20:29:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=BE=8E=E5=9B=A2?= =?UTF-8?q?=E8=81=94=E7=9B=9F=20CPS=20=E4=BC=98=E6=83=A0=E5=88=B8=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 app/core/meituan.py: 美团联盟 API 客户端 (S-Ca 签名, query_coupon, get_referral_link) - 新增 app/api/v1/meituan.py: /api/v1/meituan/coupons 和 /feed 路由, 支持按经纬度推荐 - 新增 app/schemas/meituan.py: 请求/响应 Pydantic Schema - app/main.py: 注册 meituan_router - app/core/config.py: 新增 MT_CPS_* 配置项, .env 路径改为绝对路径避免 CWD 问题 - .env.example: 补充美团联盟 CPS 配置模板 Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 7 ++ app/api/v1/meituan.py | 125 ++++++++++++++++++++++++++++++++ app/core/config.py | 12 +++- app/core/meituan.py | 140 ++++++++++++++++++++++++++++++++++++ app/main.py | 2 + app/schemas/meituan.py | 157 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 app/api/v1/meituan.py create mode 100644 app/core/meituan.py create mode 100644 app/schemas/meituan.py diff --git a/.env.example b/.env.example index 83a6cf8..6df02b5 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,13 @@ SMS_MOCK=true SMS_CODE_TTL_SEC=300 SMS_SEND_INTERVAL_SEC=60 +# ===== 美团联盟 CPS ===== +# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥 +MT_CPS_APP_KEY= +MT_CPS_APP_SECRET= +# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据 +MT_CPS_DEFAULT_SID=sgbjia + # ===== CORS ===== # 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类 CORS_ALLOW_ORIGINS= diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py new file mode 100644 index 0000000..a24364a --- /dev/null +++ b/app/api/v1/meituan.py @@ -0,0 +1,125 @@ +"""美团 CPS 券列表 + 换链。 + +不需要登录,客户端传经纬度即可。 +""" +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed + +from fastapi import APIRouter, HTTPException + +from app.core.meituan import MeituanCpsError, get_referral_link, query_coupon +from app.schemas.meituan import ( + CouponCard, + CouponListRequest, + CouponListResponse, + FeedRequest, + FeedResponse, + ReferralLinkRequest, + ReferralLinkResponse, +) + +logger = logging.getLogger("shagua.meituan") + +router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"]) + + +@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)") +def list_coupons(req: CouponListRequest) -> CouponListResponse: + logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id) + try: + raw = query_coupon( + longitude=req.longitude, + latitude=req.latitude, + platform=req.platform, + biz_line=req.biz_line, + list_topic_id=req.list_topic_id, + search_text=req.keyword, + search_id=req.search_id, + sort_field=req.sort_field, + page_no=req.page, + page_size=req.page_size, + ) + except MeituanCpsError as e: + logger.error("query_coupon failed: %s", e) + raise HTTPException(status_code=502, detail=f"meituan: {e}") from e + + items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])] + return CouponListResponse( + items=items, + has_next=raw.get("hasNext", False), + search_id=raw.get("searchId"), + ) + + +_TOPIC_ROUNDS = [ + (3, 3), # 爆款筛选 + (2, 2), # 今日必推 + (1, 5), # 精选 + 限时筛选 +] + +def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]: + items: list[CouponCard] = [] + seen: set[str] = set() + i = j = 0 + while i < len(waimai) or j < len(daodian): + for _ in range(2): + if i < len(waimai): + card = CouponCard.from_raw(waimai[i]); i += 1 + if card.product_view_sign not in seen: + seen.add(card.product_view_sign); items.append(card) + if j < len(daodian): + card = CouponCard.from_raw(daodian[j]); j += 1 + if card.product_view_sign not in seen: + seen.add(card.product_view_sign); items.append(card) + return items + + +@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)") +def feed(req: FeedRequest) -> FeedResponse: + page_idx = req.page - 1 + lon, lat = req.longitude, req.latitude + logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat) + + def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]: + try: + return (query_coupon( + longitude=lon, latitude=lat, + platform=platform, biz_line=biz_line, + list_topic_id=topic, page_size=20, + ).get("data") or []) + except MeituanCpsError: + return [] + + if page_idx >= len(_TOPIC_ROUNDS): + return FeedResponse(items=[], has_next=False, page=req.page) + + wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx] + with ThreadPoolExecutor(max_workers=2) as pool: + f_wm = pool.submit(_fetch_topic, 1, None, wm_topic) + f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic) + waimai, daodian = f_wm.result(), f_dd.result() + + items = _interleave(waimai, daodian) + has_next = page_idx + 1 < len(_TOPIC_ROUNDS) + return FeedResponse(items=items, has_next=has_next, page=req.page) + + +@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)") +def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse: + try: + raw = get_referral_link( + product_view_sign=req.product_view_sign, + platform=req.platform, + biz_line=req.biz_line, + sid=req.sid, + link_type_list=req.link_type_list, + ) + except MeituanCpsError as e: + logger.error("get_referral_link failed: %s", e) + raise HTTPException(status_code=502, detail=f"meituan: {e}") from e + + link_map = raw.get("referralLinkMap") or {} + link = raw.get("data") or link_map.get("1") or link_map.get("3") or next(iter(link_map.values()), "") + return ReferralLinkResponse(link=link, link_map=link_map) diff --git a/app/core/config.py b/app/core/config.py index 0cd99e4..2b0c812 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,15 +6,18 @@ from __future__ import annotations from functools import lru_cache +from pathlib import Path from typing import Literal from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", + env_file=str(_PROJECT_ROOT / ".env"), env_file_encoding="utf-8", case_sensitive=False, extra="ignore", @@ -46,6 +49,13 @@ class Settings(BaseSettings): SMS_CODE_TTL_SEC: int = 300 SMS_SEND_INTERVAL_SEC: int = 60 + # ===== 美团联盟 CPS ===== + MT_CPS_APP_KEY: str = "" + MT_CPS_APP_SECRET: str = "" + MT_CPS_HOST: str = "https://media.meituan.com" + MT_CPS_TIMEOUT_SEC: int = 15 + MT_CPS_DEFAULT_SID: str = "sgbjia" + # ===== CORS ===== CORS_ALLOW_ORIGINS: str = "" diff --git a/app/core/meituan.py b/app/core/meituan.py new file mode 100644 index 0000000..4bb7188 --- /dev/null +++ b/app/core/meituan.py @@ -0,0 +1,140 @@ +"""美团联盟 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}" + try: + resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC) + 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) diff --git a/app/main.py b/app/main.py index 2a6c7ff..c4fe096 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.v1.auth import router as auth_router +from app.api.v1.meituan import router as meituan_router from app.core.config import settings from app.core.logging import setup_logging @@ -57,3 +58,4 @@ def health() -> dict[str, str]: app.include_router(auth_router) +app.include_router(meituan_router) diff --git a/app/schemas/meituan.py b/app/schemas/meituan.py new file mode 100644 index 0000000..b8cf1ab --- /dev/null +++ b/app/schemas/meituan.py @@ -0,0 +1,157 @@ +"""美团 CPS 券列表 / 换链相关 schemas。""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +# ───────────────── 券卡片(归一化后给客户端) ───────────────── + +class CouponCard(BaseModel): + product_view_sign: str + platform: int = Field(..., description="1=外卖/到家, 2=到店") + biz_line: int | None = Field(None, description="到店子类: 1到餐 2到综 3酒店 4门票") + + name: str + head_image_url: str + brand_name: str | None = None + brand_logo_url: str | None = None + + sell_price: str + original_price: str + discount_amount: str = Field(..., description="优惠金额 = 原价 - 现价") + + commission_rate: str = Field(..., description="佣金比例, 如 1.4%") + commission_amount: str | None = Field(None, description="预估佣金元") + + sale_volume: str | None = None + poi_name: str | None = Field(None, description="最近门店名") + distance_text: str | None = Field(None, description="格式化距离, 如 600m / 1.5km") + distance_meters: float | None = Field(None, description="原始距离(米)") + available_poi_num: int | None = Field(None, description="可用门店数") + + coupon_num: int | None = Field(None, description="券张数(外卖兑换券)") + valid_days: int | None = Field(None, description="券有效天数") + price_label: str | None = Field(None, description="如 15天低价") + rank_label: str | None = Field(None, description="如 2小时北京外卖销量榜第1名") + rating_label: str | None = Field(None, description="如 4.6分") + + @classmethod + def from_raw(cls, item: dict[str, Any]) -> CouponCard: + cpd = item.get("couponPackDetail") or {} + brand = item.get("brandInfo") or {} + comm = item.get("commissionInfo") or {} + poi = item.get("deliverablePoiInfo") or {} + avail = item.get("availablePoiInfo") or {} + label = cpd.get("productLabel") or {} + price_lbl = label.get("pricePowerLabel") or {} + valid_info = item.get("couponValidTimeInfo") or {} + + platform = cpd.get("platform") or 1 + biz_line = cpd.get("bizLine") + + head_url = cpd.get("headUrl") or "" + if "@" in head_url: + head_url = head_url.split("@")[0] + + sell = cpd.get("sellPrice") or "0" + orig = cpd.get("originalPrice") or "0" + try: + discount = f"{float(orig) - float(sell):.2f}" + except (ValueError, TypeError): + discount = "0" + + cp = comm.get("commissionPercent") or "0" + try: + rate = f"{float(cp) / 100:.1f}%" + except (ValueError, TypeError): + rate = "0%" + + raw_dist = poi.get("deliveryDistance") + dist_meters: float | None = None + dist_text: str | None = None + if raw_dist not in (None, "null", ""): + try: + d = float(raw_dist) + dist_meters = d * 1000 if platform == 1 else d + if dist_meters < 1000: + dist_text = f"{int(dist_meters)}m" + else: + dist_text = f"{dist_meters / 1000:.1f}km" + except (ValueError, TypeError): + pass + + return cls( + product_view_sign=cpd.get("productViewSign") or cpd.get("skuViewId") or "", + platform=platform, + biz_line=biz_line, + name=cpd.get("name") or "", + head_image_url=head_url, + brand_name=brand.get("brandName"), + brand_logo_url=brand.get("brandLogoUrl"), + sell_price=sell, + original_price=orig, + discount_amount=discount, + commission_rate=rate, + commission_amount=comm.get("commission"), + sale_volume=cpd.get("saleVolume"), + poi_name=poi.get("poiName"), + distance_text=dist_text, + distance_meters=dist_meters, + available_poi_num=avail.get("availablePoiNum"), + coupon_num=cpd.get("couponNum"), + valid_days=valid_info.get("couponValidDay"), + price_label=price_lbl.get("historyPriceLabel") or price_lbl.get("beatMTLabel"), + rank_label=label.get("productRankLabel"), + rating_label=label.get("dianPingRankLabel"), + ) + + +# ───────────────── 券列表 请求 / 响应 ───────────────── + +class CouponListRequest(BaseModel): + longitude: float = Field(..., description="经度, 如 116.404") + latitude: float = Field(..., description="纬度, 如 39.928") + platform: int = Field(1, description="1=外卖/到家, 2=到店") + biz_line: int | None = Field(None, description="到店子类: 1到餐 2到综 3酒店 4门票; 外卖不填") + list_topic_id: int | None = Field(3, description="榜单: 1精选 2今日必推 3爆款筛选 5限时筛选") + keyword: str | None = Field(None, description="搜索关键词(填了会忽略 list_topic_id)") + sort_field: int | None = Field(None, description="1价格 2销量 6离我最近; 搜索默认6") + search_id: str | None = Field(None, description="翻页token, 首页不填") + page: int = Field(1, ge=1) + page_size: int = Field(20, ge=1, le=20) + + +class CouponListResponse(BaseModel): + items: list[CouponCard] + has_next: bool = False + search_id: str | None = None + + +class FeedRequest(BaseModel): + longitude: float = Field(..., description="经度") + latitude: float = Field(..., description="纬度") + page: int = Field(1, ge=1) + page_size: int = Field(20, ge=1, le=20) + + +class FeedResponse(BaseModel): + items: list[CouponCard] + has_next: bool = False + page: int = 1 + + +# ───────────────── 换链 请求 / 响应 ───────────────── + +class ReferralLinkRequest(BaseModel): + product_view_sign: str = Field(..., min_length=1) + platform: int = Field(1, description="1=外卖/到家, 2=到店") + biz_line: int | None = None + sid: str | None = Field(None, description="渠道追踪标识, 不填用默认") + link_type_list: list[int] | None = Field(None, description="1=H5长链 2=H5短链 3=deeplink; 不填默认[1,3]") + + +class ReferralLinkResponse(BaseModel): + link: str = Field(..., description="默认推广链接") + link_map: dict[str, str] = Field(default_factory=dict, description="各类型链接 {linkType: url}")