From 900b64d4f9ac2200a554665fe720cb63d4f7be23 Mon Sep 17 00:00:00 2001 From: chenshuobo <1119780489@qq.com> Date: Wed, 10 Jun 2026 18:05:26 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BE=8E=E5=9B=A2=20feed=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E5=BA=93=20+=20=E4=B8=89=20tab=20=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E5=85=9C=E5=BA=95(=E6=9B=BF=E6=8D=A2=20main=20?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E6=97=B6=E7=89=88=20feed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 main 的实时 feed 换成离线库版本 + status 降级兜底: - rec 智能推荐:改走离线库 meituan_coupon 筛佣金≥3%(SQL 去重+排序+分页),不再实时撞限流 - distance 距离最近:改实时搜索翻页(sortField=6),按用户真实位置由近及远排 - top_sales 销量最高:改 SQL DISTINCT ON 分页,修原全表拉取+全量解析的翻页卡顿 - 四路加 status(ok/empty/degraded):空库/上游失败均返 200 不再 5xx;/coupons 502 软化为降级 - FeedResponse/CouponListResponse 加 status 字段(默认 ok,向后兼容) 注意:替换 main #18 的实时 feed 实现,需 review 行为变更;依赖的离线库由 ETL 灌(见 meituan-etl PR) --- app/api/v1/meituan.py | 202 +++++++++++++++++++++++++--------- app/schemas/meituan.py | 11 +- docs/api/meituan-coupons.md | 6 +- docs/api/meituan-feed.md | 42 ++++--- docs/api/meituan-top-sales.md | 23 ++++ 5 files changed, 210 insertions(+), 74 deletions(-) create mode 100644 docs/api/meituan-top-sales.md diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index 9d7510e..5bb0031 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.orm import Session +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, @@ -34,7 +34,7 @@ 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) + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id) try: raw = query_coupon( @@ -50,14 +50,21 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse: 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 + # 软降级:不再抛 502(避免前端弹报错 toast),返空 + degraded,前端显示「服务繁忙」。 + logger.warning("[coupons] query_coupon 失败,降级返空: %s", e) + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") - items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])] + items: list[CouponCard] = [] + for it in (raw.get("data") or []): + try: + items.append(CouponCard.from_raw(it)) + except Exception: # noqa: BLE001 + continue return CouponListResponse( items=items, has_next=raw.get("hasNext", False), search_id=raw.get("searchId"), + status="ok" if items else "empty", ) @@ -93,59 +100,136 @@ def _commission_pct(card: CouponCard) -> float: @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) +def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: 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]: + # rec 是纯离线库查询,不依赖 MT 凭证、不实时打美团;其余 tab(distance / 默认)要实时打美团, + # 未配凭证直接降级返空(degraded),让前端区分「服务暂不可用」而非「暂无」。 + if tab != "rec" and not settings.mt_cps_configured: + return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") + + def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> tuple[list[dict], bool]: + """返回(items, 是否调用失败)。失败标志用于把默认 feed 整页标 degraded。""" try: - return (query_coupon( + data = (query_coupon( longitude=lon, latitude=lat, platform=platform, biz_line=biz_line, list_topic_id=topic, page_size=20, ).get("data") or []) + return data, False except MeituanCpsError: - return [] + return [], True - # 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回。 - # (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。) + # 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。 + # 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。 + # 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。 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] + 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, 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, True # 调用失败(用于标 degraded) + 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, False # 没那么多页了(非错误) + return data, has_next, False + + 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, wm_fail), (dd_data, dd_hn, dd_fail) = f_wm.result(), f_dd.result() + seen: set[str] = set() cards: list[CouponCard] = [] - for raw_list in raws: - for it in raw_list: + for it in wm_data + dd_data: + try: 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) + except Exception: # noqa: BLE001 + continue + 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) + # 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty + status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty") + return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status) - # 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。 + # 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。 + # 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单" + # 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。 + if tab == "rec": + PAGE = 20 + try: + 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() + except Exception: # noqa: BLE001 + logger.exception("[feed] rec 库查询失败,降级返空") + return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") + 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, + status="ok" if cards else "empty") + + # 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛。 page_idx = req.page - 1 if page_idx >= len(_TOPIC_ROUNDS): - return FeedResponse(items=[], has_next=False, page=req.page) + return FeedResponse(items=[], has_next=False, page=req.page, status="empty") 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() + (waimai, wm_fail), (daodian, dd_fail) = 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) + # 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty + status = "degraded" if (not items and wm_fail and dd_fail) else ("ok" if items else "empty") + return FeedResponse(items=items, has_next=has_next, page=req.page, status=status) @router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)") @@ -172,32 +256,42 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse: @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() + # 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。 + # (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。) + # 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。 + try: + # 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) + base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None)) + if req.platform is not None: + base = base.where(MeituanCoupon.platform == req.platform) + deduped = base.distinct(MeituanCoupon.dedup_key).order_by( + MeituanCoupon.dedup_key, + MeituanCoupon.sale_volume_num.desc(), + MeituanCoupon.commission_percent.desc(), + ).subquery() - # 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。 - # 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。 - seen: set[str] = set() + # 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw + m = aliased(MeituanCoupon, deduped) + start = (req.page - 1) * req.page_size + rows = db.execute( + select(m) + # 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项 + .order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id) + .offset(start) + .limit(req.page_size + 1) + ).scalars().all() + except Exception: # noqa: BLE001 + logger.exception("[top-sales] 库查询失败,降级返空") + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") + + has_next = len(rows) > req.page_size cards: list[CouponCard] = [] - for row in rows: - if row.dedup_key in seen: - continue - seen.add(row.dedup_key) + for row in rows[:req.page_size]: 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) + return CouponListResponse(items=cards, has_next=has_next, search_id=None, + status="ok" if cards else "empty") diff --git a/app/schemas/meituan.py b/app/schemas/meituan.py index 67cf36c..14ce264 100644 --- a/app/schemas/meituan.py +++ b/app/schemas/meituan.py @@ -1,10 +1,17 @@ """美团 CPS 券列表 / 换链相关 schemas。""" from __future__ import annotations -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, Field +# feed / 列表类接口的结果状态,供前端区分占位文案: +# ok = 正常有数据 +# empty = 后端正常、查询成功但确实没有数据(显示「暂无优惠券」) +# degraded = 上游(美团)或库查询失败,已降级返空(显示「服务繁忙,下拉重试」) +# 老客户端不读该字段,默认 ok,向后兼容。 +FeedStatus = Literal["ok", "empty", "degraded"] + # ───────────────── 券卡片(归一化后给客户端) ───────────────── @@ -127,6 +134,7 @@ class CouponListResponse(BaseModel): items: list[CouponCard] has_next: bool = False search_id: str | None = None + status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级") class FeedRequest(BaseModel): @@ -146,6 +154,7 @@ class FeedResponse(BaseModel): items: list[CouponCard] has_next: bool = False page: int = 1 + status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级") class TopSalesRequest(BaseModel): diff --git a/docs/api/meituan-coupons.md b/docs/api/meituan-coupons.md index 582708e..c38966b 100644 --- a/docs/api/meituan-coupons.md +++ b/docs/api/meituan-coupons.md @@ -20,11 +20,11 @@ | `page_size` | int | ❌ | 20 | 1–20 | ## 出参 -响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 +响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。 ## 错误码 -- `502` 美团接口失败(仅在**已配置 `MT_CPS_APP_KEY/APP_SECRET` 但调用失败**时;未配凭证场景见下) +无业务级错误码:未配凭证 / 美团调用失败都返 `200` + 空 `items` + `status=degraded`(**2026-06-10 起不再抛 502**,避免前端弹报错 toast)。 ## 说明 - 当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整 -- **未配置 MT_CPS 凭证时降级**:`settings.mt_cps_configured == false` 时直接返 `{ items: [], has_next: false, search_id: null }`,**不报 502**。设计目的是让新开发机首屏不炸——后端业务层先不依赖 CPS。已配凭证但调美团失败时才走 502 +- **统一软降级(2026-06-10)**:未配 `MT_CPS_APP_KEY/SECRET`、或已配但调美团失败,都返空 + `status=degraded`(此前后者抛 502);成功但无结果 → `status=empty`。前端按 `status` 显示「服务繁忙」/「暂无」,不再弹错误 diff --git a/docs/api/meituan-feed.md b/docs/api/meituan-feed.md index 5c44bd5..9377ab8 100644 --- a/docs/api/meituan-feed.md +++ b/docs/api/meituan-feed.md @@ -1,31 +1,41 @@ -# POST /api/v1/meituan/feed — 首页混合推荐流 +# POST /api/v1/meituan/feed — 首页推荐流(多 tab) -> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) > -> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。 +> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑);离线库见 [database/meituan_coupon](../database/meituan_coupon.md)。 ## 入参 - | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| | `longitude` | float | ✅ | — | 经度 | | `latitude` | float | ✅ | — | 纬度 | | `page` | int | ❌ | 1 | ≥1 | | `page_size` | int | ❌ | 20 | 1–20 | +| `tab` | string | ❌ | `""` | `rec` 智能推荐 / `distance` 距离最近 / 空=默认混合 feed(老客户端兼容) | + +> 销量最高(sales)tab **不在本接口**,走 [`/top-sales`](./meituan-top-sales.md)。 ## 出参 -响应 `200`:`{ items: CouponCard[], has_next: bool, page: int }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 +响应 `200`:`{ items: CouponCard[], has_next: bool, page: int, status: "ok"|"empty"|"degraded" }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 + +### `status` 字段(前端据此显示占位) +| 值 | 含义 | 前端建议 | +|---|---|---| +| `ok` | 有数据 | 正常渲染 | +| `empty` | 后端正常、查询成功但确实没数据 | 显示「暂无优惠券」 | +| `degraded` | 上游(美团)或库查询失败,已降级返空 | 显示「服务繁忙,下拉重试」 | + +> 老客户端不读 `status`(字段缺省即兼容),仍可只按 `items` 是否为空展示。 + +## 各 tab 行为 +- **`rec` 智能推荐**:走【离线库 `meituan_coupon`】筛佣金率 ≥ 3%,`DISTINCT ON(dedup_key)` 去重后按销量降序分页。**纯库查询、不打美团、不依赖 MT 凭证**。库为空(prod 刚部署 / ETL 未跑完)→ `empty`;库异常 → `degraded`。**不显示距离**(库里距离相对城市默认点,对用户无意义)。 + - 为何不实时:实测同城热销榜中位佣金 ~0.8%,筛佣金≥3% 后每页剩 0–1 条,既撞 402 又填不满,故从库出;库空时也**不回退实时**(回退同样填不满)。 +- **`distance` 距离最近**:外卖搜「外卖」+ 到店搜「美食」(均 `sortField=6`),两路并行顺序翻页,实时按你坐标算距离、由近及远。两路**都失败**且无结果 → `degraded`;否则有结果 `ok` / 无结果 `empty`。 +- **默认(空 tab)**:逐轮分页的混合 feed(2 外卖 + 1 到店交叉,写死 3 页:爆款 / 今日必推 / 精选+限时),第 4 页返空。两路榜单都失败 → `degraded`。 ## 错误码 -无(任何失败场景都返 200 + 空 items,见"说明"中的可观测性盲区) +无业务级错误码:任何失败场景都返 `200` + 空 `items` + `status=degraded`(不再抛 5xx,避免前端弹报错 toast)。 -## 说明 -后端在 `coupons` 之上封装的"伪推荐"——每页并发拉一次外卖、一次到店(到餐)榜单,按 **2 外卖 + 1 到店** 交叉去重。榜单组合写死 3 页(第1页爆款、第2页今日必推、第3页外卖精选+到店限时),**第 3 页起 `has_next=false`、第 4 页返空**。 - -**两种空结果路径**: -- **未配置 `MT_CPS_APP_KEY/APP_SECRET`**:开头 `settings.mt_cps_configured` 判定后**直接返空**,完全不调美团 -- **已配置但美团调用失败**:`_fetch_topic` 的 `except MeituanCpsError` **静默返空**(不报 502、不打日志) - -⚠️ **可观测性盲区**:两种路径在响应上**无法区分**。若 `items` 为空想区分: -- 看 `settings.mt_cps_configured`(`/health` 接口有暴露此字段)→ 区分"配置缺失" vs "调用失败" -- 改调 `coupons` 接口——它在"已配凭证但调用失败"时会 502 暴露错误细节(未配凭证它也降级返空,跟 feed 一致) +## 兜底说明(1.0 内测) +- rec / sales 是库查询:**prod 刚部署、ETL 首灌未完成**时库为空 → 返 `empty`。最佳实践是**开内测前先手动跑一次 ETL 灌满库**(见 `scripts/pull_meituan_coupons.py` 与 `deploy/meituan-etl.{service,timer}`)。 +- 未配 `MT_CPS_APP_KEY/SECRET` 时:`distance` / 默认 直接 `degraded`;`rec` 仍走库(配置开关不挡纯库查询)。 diff --git a/docs/api/meituan-top-sales.md b/docs/api/meituan-top-sales.md new file mode 100644 index 0000000..3f91f66 --- /dev/null +++ b/docs/api/meituan-top-sales.md @@ -0,0 +1,23 @@ +# POST /api/v1/meituan/top-sales — 销量最高(离线库) + +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> +> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。 + +## 入参 +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `page` | int | ❌ | 1 | ≥1 | +| `page_size` | int | ❌ | 20 | 1–50 | +| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) | + +## 出参 +响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。 + +## 说明 +- 从 `meituan_coupon` 取 `sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。 +- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。 +- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。 + +## 错误码 +无业务级错误码:库空 / 异常都返 `200` + 空 `items` + 对应 `status`。