Files
shaguabijia-app-server/app/schemas/meituan.py
T
marco ece41086cd 新增美团 CPS 券定时抓取入库(北京试点),作为"销量/佣金排序"的本地数据源 (#18)
背景:美团搜索/供给对销量排序支持差(实测乱序)、且有 402 限流和单次召回上限,不适合
每次实时打接口排序。改为定时把券抓进本地 meituan_coupon 表,查询时从库里捞、自己按
销量/佣金排。

本次内容:
- app/models/meituan_coupon.py:新表模型。字段尽量全(售价/原价/销量档/佣金率/佣金额/
  门店/距离/品牌/图 等),raw 列存整条原始返回避免漏字段;(source, product_view_sign) 唯一。
- alembic/versions/meituan_coupon_table.py:建表迁移(挂在 withdraw_review_ad_watch 之后)。
- scripts/pull_meituan_coupons.py:ETL,抓 3 路并按 (source, product_view_sign) upsert:
  1) 外卖·搜"外卖"(platform=1)翻到尽头
  2) 外卖·搜"美食"(platform=1)翻到尽头
  3) 到店·多业务线供给(到餐+到综+酒店+门票)翻到尽头
  支持 --once(单轮,给 cron;线上每 1h)/ --loop --interval(本地循环,默认 10min)
  + 运行锁防重叠 + --prune-hours 清理陈旧券(默认 24h)。
- app/models/__init__.py:注册新模型。

实测:北京一轮约 2.5 分钟、入库约 2700 条;销量/佣金可直接从库里排序。

给接手同事:
- 仅北京(city_id 固定);v2 再加 per商圈 union 突破全城单次召回上限。
- 销量仅粗档位且约 51% 的券才有,查询需 WHERE sale_volume_num IS NOT NULL
  (PG 的 DESC 默认把 NULL 排最前);佣金、价格 100% 有值。
- productViewSign/skuViewId 跨渠道会变,不能当全局 id;跨源去重用 dedup_key=md5(品牌|名|价)。
- 待做:查询接口(从库捞 + dedup_key 去重 + 按销量/佣金排,返回前端)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #18
2026-06-08 00:16:25 +08:00

171 lines
7.2 KiB
Python

"""美团 CPS 券列表 / 换链相关 schemas。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
class CouponCard(BaseModel):
product_view_sign: str
platform: int = Field(..., description="1=外卖/到家, 2=到店")
biz_line: int | None = Field(None, description="到店子类: 1到餐 2到综 3酒店 4门票")
name: str
head_image_url: str
brand_name: str | None = None
brand_logo_url: str | None = None
sell_price: str
original_price: str
discount_amount: str = Field(..., description="优惠金额 = 原价 - 现价")
commission_rate: str = Field(..., description="佣金比例, 如 1.4%")
commission_amount: str | None = Field(None, description="预估佣金元")
sale_volume: str | None = None
poi_name: str | None = Field(None, description="最近门店名")
distance_text: str | None = Field(None, description="格式化距离, 如 600m / 1.5km")
distance_meters: float | None = Field(None, description="原始距离(米)")
available_poi_num: int | None = Field(None, description="可用门店数")
coupon_num: int | None = Field(None, description="券张数(外卖兑换券)")
valid_days: int | None = Field(None, description="券有效天数")
price_label: str | None = Field(None, description="如 15天低价")
rank_label: str | None = Field(None, description="如 2小时北京外卖销量榜第1名")
rating_label: str | None = Field(None, description="如 4.6分")
@classmethod
def from_raw(cls, item: dict[str, Any]) -> CouponCard:
cpd = item.get("couponPackDetail") or {}
brand = item.get("brandInfo") or {}
comm = item.get("commissionInfo") or {}
poi = item.get("deliverablePoiInfo") or {}
avail = item.get("availablePoiInfo") or {}
label = cpd.get("productLabel") or {}
price_lbl = label.get("pricePowerLabel") or {}
valid_info = item.get("couponValidTimeInfo") or {}
platform = cpd.get("platform") or 1
biz_line = cpd.get("bizLine")
head_url = cpd.get("headUrl") or ""
if "@" in head_url:
head_url = head_url.split("@")[0]
sell = cpd.get("sellPrice") or "0"
orig = cpd.get("originalPrice") or "0"
try:
discount = f"{float(orig) - float(sell):.2f}"
except (ValueError, TypeError):
discount = "0"
cp = comm.get("commissionPercent") or "0"
try:
rate = f"{float(cp) / 100:.1f}%"
except (ValueError, TypeError):
rate = "0%"
raw_dist = poi.get("deliveryDistance")
dist_meters: float | None = None
dist_text: str | None = None
if raw_dist not in (None, "null", ""):
try:
d = float(raw_dist)
dist_meters = d * 1000 if platform == 1 else d
if dist_meters < 1000:
dist_text = f"{int(dist_meters)}m"
else:
dist_text = f"{dist_meters / 1000:.1f}km"
except (ValueError, TypeError):
pass
return cls(
product_view_sign=cpd.get("productViewSign") or cpd.get("skuViewId") or "",
platform=platform,
biz_line=biz_line,
name=cpd.get("name") or "",
head_image_url=head_url,
brand_name=brand.get("brandName"),
brand_logo_url=brand.get("brandLogoUrl"),
sell_price=sell,
original_price=orig,
discount_amount=discount,
commission_rate=rate,
commission_amount=comm.get("commission"),
sale_volume=cpd.get("saleVolume"),
poi_name=poi.get("poiName"),
distance_text=dist_text,
distance_meters=dist_meters,
available_poi_num=avail.get("availablePoiNum"),
coupon_num=cpd.get("couponNum"),
valid_days=valid_info.get("couponValidDay"),
price_label=price_lbl.get("historyPriceLabel") or price_lbl.get("beatMTLabel"),
rank_label=label.get("productRankLabel"),
rating_label=label.get("dianPingRankLabel"),
)
# ───────────────── 券列表 请求 / 响应 ─────────────────
class CouponListRequest(BaseModel):
longitude: float = Field(..., description="经度, 如 116.404")
latitude: float = Field(..., description="纬度, 如 39.928")
platform: int = Field(1, description="1=外卖/到家, 2=到店")
biz_line: int | None = Field(None, description="到店子类: 1到餐 2到综 3酒店 4门票; 外卖不填")
list_topic_id: int | None = Field(3, description="榜单: 1精选 2今日必推 3爆款筛选 5限时筛选")
keyword: str | None = Field(None, description="搜索关键词(填了会忽略 list_topic_id)")
sort_field: int | None = Field(None, description="1价格 2销量 6离我最近; 搜索默认6")
search_id: str | None = Field(None, description="翻页token, 首页不填")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=20)
class CouponListResponse(BaseModel):
items: list[CouponCard]
has_next: bool = False
search_id: str | None = None
class FeedRequest(BaseModel):
longitude: float = Field(..., description="经度")
latitude: float = Field(..., description="纬度")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=20)
# 筛选/排序口径,后端据此处理后返回(前端不再自己筛/排):
# rec = 智能推荐:榜单混合 feed 去掉佣金率 < 3%(分页)
# distance = 距离最近:拉齐全部轮次后全局按距离由近及远(一次性返回, has_next=False)
# 留空/其它 = 原混合 feed 不筛(老客户端兼容,新 app 会显式传 tab)
# (销量最高 sales 走 /coupons 同城热销,不在本接口)
tab: str = Field("", description="rec 智能推荐 / distance 距离最近 / 空=不筛(兼容)")
class FeedResponse(BaseModel):
items: list[CouponCard]
has_next: bool = False
page: int = 1
class TopSalesRequest(BaseModel):
"""销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。"""
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=50)
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(全城销量)")
# ───────────────── 换链 请求 / 响应 ─────────────────
class ReferralLinkRequest(BaseModel):
product_view_sign: str = Field(..., min_length=1)
platform: int = Field(1, description="1=外卖/到家, 2=到店")
biz_line: int | None = None
sid: str | None = Field(None, description="渠道追踪标识, 不填用默认")
link_type_list: list[int] | None = Field(None, description="1=H5长链 2=H5短链 3=deeplink; 不填默认[1,3]")
class ReferralLinkResponse(BaseModel):
link: str = Field(..., description="默认推广链接")
link_map: dict[str, str] = Field(default_factory=dict, description="各类型链接 {linkType: url}")