diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index 7cb1c4d..3cb508c 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -8,12 +8,12 @@ import logging from concurrent.futures import ThreadPoolExecutor, as_completed from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import nullslast, select from sqlalchemy.orm import Session, aliased 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.integrations.meituan import MeituanCpsError, _call, get_referral_link, query_coupon from app.models.meituan_coupon import MeituanCoupon from app.schemas.meituan import ( CouponCard, @@ -93,7 +93,7 @@ def _commission_pct(card: CouponCard) -> float: @router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近") -def feed(req: FeedRequest) -> FeedResponse: +def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: if not settings.mt_cps_configured: return FeedResponse(items=[], has_next=False, page=req.page) lon, lat = req.longitude, req.latitude @@ -110,28 +110,87 @@ def feed(req: FeedRequest) -> FeedResponse: except MeituanCpsError: return [] - # 智能推荐 / 距离最近:拉齐全部榜单轮次(~60 条),后端处理后【一次性返回、不分页】。 - # (池子很小;逐页实时打美团反而让客户端翻页卡顿/滑不动——一次拉齐 → 前端加载一次、滚动顺畅。) - if tab in ("rec", "distance"): - wm_all: list[dict] = [] - dd_all: list[dict] = [] - with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool: - futs = [ - (pool.submit(_fetch_topic, 1, None, wm_topic), - pool.submit(_fetch_topic, 2, 1, dd_topic)) - for wm_topic, dd_topic in _TOPIC_ROUNDS - ] - for fw, fd in futs: - wm_all += fw.result() - dd_all += fd.result() - cards = _interleave(wm_all, dd_all) # from_raw + 去重 + 外卖:到店 2:1 交叉 - if tab == "rec": - # 智能推荐:去掉佣金率 < 3% - cards = [c for c in cards if _commission_pct(c) >= 3.0] - else: - # 距离最近:在完整池上全局按距离由近及远(无距离排最后) - 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) + # 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。 + # 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。 + # 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。 + if tab == "distance": + lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000) + + def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool]: + """顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页)。""" + sid: str | None = None + data: list[dict] = [] + has_next = False + for pg in range(1, n + 1): + body: dict = { + "platform": platform, "searchText": keyword, "sortField": 6, + "longitude": lon_i, "latitude": lat_i, "pageSize": 20, + } + if biz_line is not None: + body["bizLine"] = biz_line + if sid: + body["searchId"] = sid + else: + body["pageNo"] = 1 + try: + r = _call("/cps_open/common/api/v1/query_coupon", body) + except MeituanCpsError: + return [], False + data = r.get("data") or [] + sid = r.get("searchId") + has_next = bool(r.get("hasNext")) and bool(data) + if not data or (not has_next and pg < n): + return [], False # 没那么多页了 + return data, has_next + + with ThreadPoolExecutor(max_workers=2) as pool: + f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page) + f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page) + (wm_data, wm_hn), (dd_data, dd_hn) = f_wm.result(), f_dd.result() + + seen: set[str] = set() + cards: list[CouponCard] = [] + for it in wm_data + dd_data: + 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=wm_hn or dd_hn, page=req.page) + + # 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。 + # 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单" + # 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。 + if tab == "rec": + PAGE = 20 + base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0) + deduped = base.distinct(MeituanCoupon.dedup_key).order_by( + MeituanCoupon.dedup_key, + MeituanCoupon.commission_percent.desc(), + ).subquery() + m = aliased(MeituanCoupon, deduped) + start = (req.page - 1) * PAGE + rows = db.execute( + select(m) + # 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页 + .order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id) + .offset(start) + .limit(PAGE + 1) + ).scalars().all() + has_next = len(rows) > PAGE + cards: list[CouponCard] = [] + for row in rows[:PAGE]: + try: + card = CouponCard.from_raw(row.raw or {}) + except Exception: # noqa: BLE001 + continue + if card.product_view_sign: + # 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。 + # 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。 + card.distance_text = None + card.distance_meters = None + cards.append(card) + return FeedResponse(items=cards, has_next=has_next, page=req.page) # 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛。 page_idx = req.page - 1