Files
shaguabijia-app-server/app/utils/mt_search_cursor.py
T
左辰勇 a2270ee1b2 功能:新手引导视频 + 美团券首页分页索引
新手引导视频:运营后台上传 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>
2026-07-23 22:50:44 +08:00

98 lines
4.2 KiB
Python

"""美团搜索翻页游标(searchId)缓存 —— 「距离最近」tab 翻页提速。
**问题**:美团 query_coupon 的搜索结果只能靠 searchId 续页(pageNo 翻不动),而我们的 HTTP 接口
是无状态的、客户端只传页码。原实现因此每次都从第 1 页顺序重放到第 N 页 —— 取第 N 页要向美团发
N 次请求,用户越往下滑越慢(第 5 页 ≈ 第 1 页的 5 倍耗时,且每次调用都可能撞 402 限流)。
**做法**:把「翻到第 n 页所需的 searchId」按 (量化坐标, platform, 关键词) 记下来,
下次请求第 n 页直接一发命中;未命中才从缓存里最深的那一页往后重放,并把沿途游标补进缓存。
稳态下每翻一页恒定 1 次请求。
**坐标量化**到 2 位小数(~1km),与 `utils/meituan_city` 同口径:原始 GPS 每次抖动到小数点后 5~6 位,
不量化几乎不命中缓存;而同一路搜索的排序原点相差 1km 以内,对券列表的实际顺序无感知差别。
**TTL** 取 10 分钟:searchId 属于上游会话态,放太久会翻到过期游标(调用失败 → 调用方回退重放)。
缓存是**进程内**的,多 worker 各存一份、不共享,这没问题:命中率只影响快慢,不影响正确性。
"""
from __future__ import annotations
import threading
import time
# 缓存键:(量化纬度, 量化经度, platform, 搜索词)
RouteKey = tuple[float, float, int, str]
_TTL_SEC = 10 * 60
_MAX_ROUTES = 512 # 最多缓存多少条搜索路线(每条 = 一个坐标×平台×关键词)
_MAX_PAGES_PER_ROUTE = 60 # 单条路线最多记多少页游标,防某个坐标被无限翻页撑爆
# {route_key: {page_no: (search_id, 写入时刻)}};page_no 表示「用这个 searchId 能取到第几页」。
# 第 1 页不需要 searchId(直接 pageNo=1),故永远不入表。
_cursors: dict[RouteKey, dict[int, tuple[str, float]]] = {}
_lock = threading.Lock()
def route_key(latitude: float, longitude: float, platform: int, keyword: str) -> RouteKey:
"""构造缓存键(坐标量化到 ~1km)。"""
return (round(latitude, 2), round(longitude, 2), platform, keyword)
def lookup(key: RouteKey, page: int) -> tuple[int, str | None]:
"""找到能最省调用地拿到第 `page` 页的起点。
返回 (起始页, 该页要用的 search_id):
- (page, "xxx") 缓存命中 → 调用方一发直达第 page 页
- (m, "xxx") 命中更浅的第 m 页(1 < m < page)→ 从第 m 页重放到第 page 页
- (1, None) 全未命中 → 从第 1 页(pageNo=1)重放
"""
if page <= 1:
return 1, None
now = time.time()
with _lock:
pages = _cursors.get(key)
if not pages:
return 1, None
for p in range(page, 1, -1):
hit = pages.get(p)
if hit is None:
continue
search_id, wrote_at = hit
if now - wrote_at > _TTL_SEC:
pages.pop(p, None) # 过期即清,继续往浅了找
continue
return p, search_id
return 1, None
def remember(key: RouteKey, page: int, search_id: str) -> None:
"""记下「用 search_id 可取到第 page 页」。page<=1 无意义(第 1 页不用游标)。"""
if page <= 1 or not search_id:
return
now = time.time()
with _lock:
pages = _cursors.get(key)
if pages is None:
if len(_cursors) >= _MAX_ROUTES:
_evict_oldest_route_locked()
pages = _cursors[key] = {}
pages[page] = (search_id, now)
if len(pages) > _MAX_PAGES_PER_ROUTE:
# 越浅的页越容易被重新走到,优先丢最深的那些(它们下次多半也过期了)
for p in sorted(pages, reverse=True)[: len(pages) - _MAX_PAGES_PER_ROUTE]:
pages.pop(p, None)
def drop(key: RouteKey) -> None:
"""整条路线作废。用在「拿缓存游标去请求却失败」时(多半是上游 searchId 过期)。"""
with _lock:
_cursors.pop(key, None)
def _evict_oldest_route_locked() -> None:
"""淘汰最久没更新过的一条路线(调用方须已持锁)。"""
oldest_key = min(
_cursors,
key=lambda k: max((w for _, w in _cursors[k].values()), default=0.0),
)
_cursors.pop(oldest_key, None)