美团 feed 改用离线库 + 三 tab 降级兜底(替换 main 的实时版 feed) (#40)

把 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)

---------

Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #40
This commit was merged in pull request #40.
This commit is contained in:
2026-06-10 20:05:50 +08:00
parent bf82c68408
commit a753843804
5 changed files with 210 additions and 74 deletions
+142 -48
View File
@@ -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)
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))
# 去重 + 排序 + 分页全在 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:
stmt = stmt.where(MeituanCoupon.platform == req.platform)
stmt = stmt.order_by(
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(),
)
rows = db.execute(stmt).scalars().all()
).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")
+10 -1
View File
@@ -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):
+3 -3
View File
@@ -20,11 +20,11 @@
| `page_size` | int | ❌ | 20 | 120 |
## 出参
响应 `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` 显示「服务繁忙」/「暂无」,不再弹错误
+26 -16
View File
@@ -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 | 120 |
| `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` 仍走库(配置开关不挡纯库查询)。
+23
View File
@@ -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 | 150 |
| `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`