功能:新手引导视频 + 美团券首页分页索引
新手引导视频:运营后台上传 MP4(上限 100MB,魔数校验只认 ISO BMFF), App 端在领券等候浮层前 N 次以引导视频替代广告。新增 guide_video 的 model/schema/repository/router(App 侧 + 后台侧)与播放记录表迁移。 美团券:首页「销量最高 / 智能推荐」两个 tab 改游标分页,配套两条 (city_id, dedup_key, 排序键 DESC) 复合索引,让 Postgres 顺着索引流式 去重,免掉每翻一页重排整城券的开销。美团 CPS client 在 lifespan 预热 并在关闭时释放连接池。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"""新手引导视频(领券等候浮层前 N 次替代广告)。
|
||||
|
||||
路由前缀 `/api/v1/guide-video`(均需 Bearer):
|
||||
POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token
|
||||
POST /reward 播完 / 中途关闭都调,按 play_token 幂等发固定金币
|
||||
|
||||
发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额,
|
||||
所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.repositories import guide_video as crud_guide
|
||||
from app.schemas.guide_video import (
|
||||
GuideVideoRewardIn,
|
||||
GuideVideoRewardOut,
|
||||
GuideVideoStartIn,
|
||||
GuideVideoStartOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.guide_video")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/start",
|
||||
response_model=GuideVideoStartOut,
|
||||
summary="领券浮层是否放新手引导视频(命中即计次)",
|
||||
dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))],
|
||||
)
|
||||
def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> GuideVideoStartOut:
|
||||
"""开播即计数:返回 should_play=True 时服务端已写下这一次,客户端必须真的播。
|
||||
|
||||
没配视频 / 开关关 / 次数用完 → should_play=False,客户端照旧走广告链路(行为不变)。
|
||||
"""
|
||||
result = crud_guide.start_play(db, user.id, scene=payload.scene or "coupon")
|
||||
logger.info(
|
||||
"guide video start user_id=%d scene=%s should_play=%s seq=%d remaining=%d",
|
||||
user.id, payload.scene, result["should_play"], result["seq"], result["remaining"],
|
||||
)
|
||||
return GuideVideoStartOut(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reward",
|
||||
response_model=GuideVideoRewardOut,
|
||||
summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)",
|
||||
dependencies=[Depends(rate_limit(60, 60, "guide-video-reward"))],
|
||||
)
|
||||
def reward(payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession) -> GuideVideoRewardOut:
|
||||
result = crud_guide.grant_play(
|
||||
db, user.id, play_token=payload.play_token, completed=payload.completed
|
||||
)
|
||||
logger.info(
|
||||
"guide video reward user_id=%d token=%s completed=%s granted=%s coin=%d",
|
||||
user.id, payload.play_token[:12], payload.completed, result["granted"], result["coin"],
|
||||
)
|
||||
return GuideVideoRewardOut(**result)
|
||||
+154
-75
@@ -5,11 +5,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import nullslast, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
@@ -25,8 +26,14 @@ from app.schemas.meituan import (
|
||||
ReferralLinkResponse,
|
||||
TopSalesRequest,
|
||||
)
|
||||
from app.utils import mt_search_cursor
|
||||
from app.utils.meituan_city import get_meituan_city
|
||||
|
||||
if TYPE_CHECKING: # 仅供类型标注(本模块已开 from __future__ import annotations)
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy import ColumnElement
|
||||
|
||||
logger = logging.getLogger("shagua.meituan")
|
||||
|
||||
|
||||
@@ -109,6 +116,86 @@ def _commission_pct(card: CouponCard) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
# ────────────── 离线库分页(智能推荐 / 销量最高 共用) ──────────────
|
||||
# 去重+排序阶段**只投影这几列**:够 DISTINCT ON 分组、够排序、够回表定位,且全是定长小字段。
|
||||
# ⚠️ 关键性能点:`raw` 是整条美团原始返回(JSONB,每行数 KB)。原实现用 select(MeituanCoupon)
|
||||
# 做子查询,等于把整城几千行连 raw 一起塞进两次排序(DISTINCT ON 一次 + 分页一次),
|
||||
# 体量轻松超过 work_mem → Postgres 落盘做外部归并排序,而且**每翻一页都要重来一遍**。
|
||||
# 拆成「先在小列上排出本页 id,再按 id 回表取 raw」后,排序数据量降到原来的百分之几,
|
||||
# JSONB 只解析当前页 ~20 行。
|
||||
_DEDUP_COLS = (
|
||||
MeituanCoupon.id,
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.sale_volume_num,
|
||||
MeituanCoupon.commission_percent,
|
||||
)
|
||||
|
||||
|
||||
def _paged_dedup_ids(
|
||||
db: Session,
|
||||
*,
|
||||
conds: list[ColumnElement[bool]],
|
||||
dedup_order: list[ColumnElement],
|
||||
page_order: Callable[[Any], list[ColumnElement]],
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[int], bool]:
|
||||
"""DISTINCT ON(dedup_key) 跨源去重 → 整体排序 → 分页,返回 (本页 id 列表, 是否还有下一页)。
|
||||
|
||||
- `dedup_order`:同一个 dedup_key 的多条里留哪条(如销量最高/佣金最高)。
|
||||
- `page_order`:接收去重子查询的列集合(`sub.c`),返回去重后的整体排序。
|
||||
多取 1 条用于判断 has_next。
|
||||
"""
|
||||
deduped = (
|
||||
select(*_DEDUP_COLS)
|
||||
.where(*conds)
|
||||
.distinct(MeituanCoupon.dedup_key)
|
||||
.order_by(MeituanCoupon.dedup_key, *dedup_order)
|
||||
.subquery()
|
||||
)
|
||||
ids = db.execute(
|
||||
select(deduped.c.id)
|
||||
.order_by(*page_order(deduped.c))
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size + 1)
|
||||
).scalars().all()
|
||||
return list(ids[:page_size]), len(ids) > page_size
|
||||
|
||||
|
||||
def _load_raws(db: Session, ids: list[int]) -> list[dict]:
|
||||
"""按给定 id 顺序取 raw(只回表本页 ~20 行)。缺行(被 ETL 清掉)静默跳过。"""
|
||||
if not ids:
|
||||
return []
|
||||
raw_by_id = {
|
||||
row_id: raw
|
||||
for row_id, raw in db.execute(
|
||||
select(MeituanCoupon.id, MeituanCoupon.raw).where(MeituanCoupon.id.in_(ids))
|
||||
).all()
|
||||
}
|
||||
return [raw_by_id[i] for i in ids if i in raw_by_id]
|
||||
|
||||
|
||||
def _cards_from_raws(raws: list[dict], *, hide_distance: bool) -> list[CouponCard]:
|
||||
"""raw → CouponCard;解析失败的单条跳过,不整页失败。
|
||||
|
||||
hide_distance:离线库里的距离是相对「城市默认点」算的,对用户无意义且误导 —— 智能推荐 /
|
||||
销量最高两个 tab 一律置空,前端「距离 店名」那行只剩店名、自动顶到最左。
|
||||
"""
|
||||
cards: list[CouponCard] = []
|
||||
for raw in raws:
|
||||
try:
|
||||
card = CouponCard.from_raw(raw or {})
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not card.product_view_sign:
|
||||
continue
|
||||
if hide_distance:
|
||||
card.distance_text = None
|
||||
card.distance_meters = None
|
||||
cards.append(card)
|
||||
return cards
|
||||
|
||||
|
||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
|
||||
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
lon, lat = req.longitude, req.latitude
|
||||
@@ -133,17 +220,21 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
return [], True
|
||||
|
||||
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。
|
||||
# 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。
|
||||
# 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
|
||||
# 搜索翻页必须用 searchId(pageNo 翻不动),而接口是无状态的(客户端只传页码)—— 原实现因此
|
||||
# 每次都从第 1 页顺序重放到第 N 页,取第 N 页要向美团发 N 次请求,越往下滑越慢。
|
||||
# 现在把沿途 searchId 记进 [mt_search_cursor],稳态下每翻一页恒定 1 次请求;两路仍并行。
|
||||
# 按你位置实时算距离(库里没存 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, bool]:
|
||||
"""顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。"""
|
||||
sid: str | None = None
|
||||
def _replay(
|
||||
platform: int, biz_line: int | None, keyword: str,
|
||||
key: mt_search_cursor.RouteKey, start: int, sid: str | None, n: int,
|
||||
) -> tuple[list[dict], bool, bool]:
|
||||
"""从第 start 页(用 sid 取)顺序翻到第 n 页。start==1 时 sid 应为 None(走 pageNo=1)。"""
|
||||
data: list[dict] = []
|
||||
has_next = False
|
||||
for pg in range(1, n + 1):
|
||||
for pg in range(start, n + 1):
|
||||
body: dict = {
|
||||
"platform": platform, "searchText": keyword, "sortField": 6,
|
||||
"longitude": lon_i, "latitude": lat_i, "pageSize": 20,
|
||||
@@ -161,10 +252,27 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
data = r.get("data") or []
|
||||
sid = r.get("searchId")
|
||||
has_next = bool(r.get("hasNext")) and bool(data)
|
||||
# 记下「下一页要用哪个 searchId」;没有下一页就别记,免得存进死游标。
|
||||
if sid and has_next:
|
||||
mt_search_cursor.remember(key, pg + 1, sid)
|
||||
if not data or (not has_next and pg < n):
|
||||
return [], False, False # 没那么多页了(非错误)
|
||||
return data, has_next, False
|
||||
|
||||
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
|
||||
"""取第 n 页,返回(第 n 页 items, 是否还有下一页, 是否调用失败)。
|
||||
|
||||
优先用缓存游标一发直达;缓存未命中/过期才从最近的已知页往后重放,并把沿途游标补进缓存。
|
||||
"""
|
||||
key = mt_search_cursor.route_key(lat, lon, platform, keyword)
|
||||
start, sid = mt_search_cursor.lookup(key, n)
|
||||
data, has_next, failed = _replay(platform, biz_line, keyword, key, start, sid, n)
|
||||
# 用缓存游标却打不通,多半是上游 searchId 过期:作废整条路线,回到第 1 页重放一次。
|
||||
if failed and start > 1:
|
||||
mt_search_cursor.drop(key)
|
||||
data, has_next, failed = _replay(platform, biz_line, keyword, key, 1, None, n)
|
||||
return data, has_next, failed
|
||||
|
||||
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)
|
||||
@@ -194,39 +302,25 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||
PAGE = 20
|
||||
try:
|
||||
base = select(MeituanCoupon).where(
|
||||
MeituanCoupon.commission_percent >= 3.0,
|
||||
MeituanCoupon.city_id == city_id,
|
||||
)
|
||||
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)
|
||||
ids, has_next = _paged_dedup_ids(
|
||||
db,
|
||||
conds=[
|
||||
MeituanCoupon.commission_percent >= 3.0,
|
||||
MeituanCoupon.city_id == city_id,
|
||||
],
|
||||
# 同一去重键留佣金最高那条
|
||||
dedup_order=[MeituanCoupon.commission_percent.desc()],
|
||||
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
|
||||
.order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id)
|
||||
.offset(start)
|
||||
.limit(PAGE + 1)
|
||||
).scalars().all()
|
||||
page_order=lambda c: [
|
||||
nullslast(c.sale_volume_num.desc()), c.commission_percent.desc(), c.id,
|
||||
],
|
||||
page=req.page, page_size=PAGE,
|
||||
)
|
||||
raws = _load_raws(db, ids)
|
||||
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)
|
||||
cards = _cards_from_raws(raws, hide_distance=True)
|
||||
if not cards and req.page == 1:
|
||||
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
|
||||
logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
||||
@@ -282,51 +376,36 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList
|
||||
if not city_id:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
|
||||
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
|
||||
# 去重 + 排序 + 分页全在 SQL 做,每页只回表并解析当前页 ~20 条(见 _paged_dedup_ids 的性能说明)。
|
||||
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
|
||||
conds = [
|
||||
MeituanCoupon.sale_volume_num.isnot(None),
|
||||
MeituanCoupon.city_id == city_id,
|
||||
]
|
||||
if req.platform is not None:
|
||||
conds.append(MeituanCoupon.platform == req.platform)
|
||||
try:
|
||||
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||
base = select(MeituanCoupon).where(
|
||||
MeituanCoupon.sale_volume_num.isnot(None),
|
||||
MeituanCoupon.city_id == city_id,
|
||||
)
|
||||
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()
|
||||
|
||||
# 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw
|
||||
m = aliased(MeituanCoupon, deduped)
|
||||
start = (req.page - 1) * req.page_size
|
||||
rows = db.execute(
|
||||
select(m)
|
||||
ids, has_next = _paged_dedup_ids(
|
||||
db,
|
||||
conds=conds,
|
||||
# 每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||
dedup_order=[
|
||||
MeituanCoupon.sale_volume_num.desc(),
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
],
|
||||
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
|
||||
.order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id)
|
||||
.offset(start)
|
||||
.limit(req.page_size + 1)
|
||||
).scalars().all()
|
||||
page_order=lambda c: [
|
||||
c.sale_volume_num.desc(), c.commission_percent.desc(), c.id,
|
||||
],
|
||||
page=req.page, page_size=req.page_size,
|
||||
)
|
||||
raws = _load_raws(db, ids)
|
||||
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[:req.page_size]:
|
||||
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)
|
||||
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导),与推荐流口径一致。
|
||||
cards = _cards_from_raws(raws, hide_distance=True)
|
||||
if not cards and req.page == 1:
|
||||
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
|
||||
logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
||||
|
||||
Reference in New Issue
Block a user