feat: 接入美团联盟 CPS 优惠券推荐模块
- 新增 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user