"""美团 CPS 券列表 + 换链。 不需要登录,客户端传经纬度即可。 """ from __future__ import annotations import logging from concurrent.futures import ThreadPoolExecutor, as_completed from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from app.core.config import settings from app.db.session import get_db from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon from app.models.meituan_coupon import MeituanCoupon from app.schemas.meituan import ( CouponCard, CouponListRequest, CouponListResponse, FeedRequest, FeedResponse, ReferralLinkRequest, ReferralLinkResponse, TopSalesRequest, ) 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: if not settings.mt_cps_configured: return CouponListResponse(items=[], has_next=False, search_id=None) 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 def _commission_pct(card: CouponCard) -> float: """'1.4%' → 1.4;解析失败按 0(会被智能推荐过滤掉)。""" try: return float(card.commission_rate.rstrip("%")) except (ValueError, AttributeError): return 0.0 @router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近") def feed(req: FeedRequest) -> FeedResponse: if not settings.mt_cps_configured: return FeedResponse(items=[], has_next=False, page=req.page) lon, lat = req.longitude, req.latitude tab = (req.tab or "").strip() logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", 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 tab == "distance": with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool: futs = [] for wm_topic, dd_topic in _TOPIC_ROUNDS: futs.append(pool.submit(_fetch_topic, 1, None, wm_topic)) futs.append(pool.submit(_fetch_topic, 2, 1, dd_topic)) raws = [f.result() for f in futs] seen: set[str] = set() cards: list[CouponCard] = [] for raw_list in raws: for it in raw_list: card = CouponCard.from_raw(it) if card.product_view_sign and card.product_view_sign not in seen: seen.add(card.product_view_sign) cards.append(card) cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf")) return FeedResponse(items=cards, has_next=False, page=1) # 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。 page_idx = req.page - 1 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) if tab == "rec": items = [c for c in items if _commission_pct(c) >= 3.0] 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: if not settings.mt_cps_configured: return ReferralLinkResponse(link="", link_map={}) 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) @router.post("/top-sales", response_model=CouponListResponse, summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)") def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse: # 只取有销量档的(美团很多券没销量,排不了);按销量降序、同销量再按佣金降序 stmt = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None)) if req.platform is not None: stmt = stmt.where(MeituanCoupon.platform == req.platform) stmt = stmt.order_by( MeituanCoupon.sale_volume_num.desc(), MeituanCoupon.commission_percent.desc(), ) rows = db.execute(stmt).scalars().all() # 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。 # 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。 seen: set[str] = set() cards: list[CouponCard] = [] for row in rows: if row.dedup_key in seen: continue seen.add(row.dedup_key) try: card = CouponCard.from_raw(row.raw or {}) except Exception: # noqa: BLE001 continue if card.product_view_sign: cards.append(card) start = (req.page - 1) * req.page_size page_items = cards[start:start + req.page_size] has_next = start + req.page_size < len(cards) return CouponListResponse(items=page_items, has_next=has_next, search_id=None)