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
204 lines
8.1 KiB
Python
204 lines
8.1 KiB
Python
"""美团 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)
|