修首页 feed 翻页卡顿 / 翻不动

1. /top-sales(销量最高):之前每翻一页都全表拉取(~1400 条)+ 全量 from_raw 解析,翻页慢 → 滑动卡顿。
   改为 SQL 侧 DISTINCT ON(dedup_key) 去重 + 排序 + 分页,每页只取并解析当前页 ~20 条
   (实测 14ms/页 vs 之前每页全量);并加 id 作稳定 tiebreaker,修同销量同佣金并列项的跨页重复。
2. /feed(智能推荐/距离最近):池子很小(~60/117 条),之前 rec 逐页实时打美团(每页 2 调用 ~1s)→
   翻页反复触发 + 列表重组卡顿。改为两 tab 都拉齐全部轮次后【一次性返回 has_next=False】,
   前端加载一次即不再翻页,滚动顺畅。默认(老客户端不传 tab)仍逐页,不受影响。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chenshuobo
2026-06-08 15:06:58 +08:00
parent c76be6116f
commit 3d2b85d319
+44 -39
View File
@@ -9,7 +9,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, aliased
from app.core.config import settings
from app.db.session import get_db
@@ -110,27 +110,30 @@ def feed(req: FeedRequest) -> FeedResponse:
except MeituanCpsError:
return []
# 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回
# (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。)
if tab == "distance":
# 智能推荐 / 距离最近:拉齐全部榜单轮次(~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 = []
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"))
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)
# 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%
# 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛
page_idx = req.page - 1
if page_idx >= len(_TOPIC_ROUNDS):
return FeedResponse(items=[], has_next=False, page=req.page)
@@ -142,8 +145,6 @@ def feed(req: FeedRequest) -> FeedResponse:
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)
@@ -172,32 +173,36 @@ 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 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
# 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()
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)