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>
223 lines
11 KiB
Python
223 lines
11 KiB
Python
"""本地 mock:往 meituan_coupon 灌一批假券,让首页「智能推荐 / 销量最高」在无美团凭证时也有数据。
|
||
|
||
为什么需要:这两个 tab 不实时打美团,只查 `meituan_coupon` 表,数据靠 scripts/pull_meituan_coupons.py
|
||
定时抓。本地既没 MT_CPS 凭证、也没线上导出的 TSV 时,表是空的 → 接口返 status=empty,页面永远空白,
|
||
分页 / 左右滑动 / 触底加载全都没法验。本脚本纯造数,不联网、不需要任何凭证。
|
||
|
||
覆盖不到的:「距离最近」tab 必须实时打美团搜索接口(库里没存 POI 经纬度),假数据救不了;
|
||
点「抢」换推广链接也会失败(productViewSign 是假的)。要验这两条只能配 MT_CPS_APP_KEY/SECRET。
|
||
|
||
图片:生成纯色 PNG 落到 data/media/mock_coupon/,由后端 /media 静态服务出图。
|
||
文件名**带 FEED_THUMB_PARAM 后缀**是故意的 —— schemas/meituan.py 的 feed_image_url 会给每个
|
||
headUrl 无条件拼上该后缀(美团 CDN 的缩放参数),本地静态服务不认参数、只会当成文件名的一部分,
|
||
所以磁盘上的文件必须叫 `xxx.png@375w_375h_1e_1c.webp` 才取得到。
|
||
|
||
city_id 必须与端上定位反查出的一致:rec/top-sales 都按 city_id 过滤,而 city_id 由经纬度经
|
||
app/utils/meituan_city.py 反查。默认灌北京,测试机的模拟定位也要设成北京,否则照样是空。
|
||
|
||
幂等:重跑先按 MOCK_SIGN_PREFIX 清掉旧 mock 行再重建,不碰真实抓取的数据。
|
||
|
||
python -m scripts.seed_meituan_coupon_mock # 默认北京 400 条
|
||
python -m scripts.seed_meituan_coupon_mock --count 800
|
||
python -m scripts.seed_meituan_coupon_mock --city-id 2QSF6IG3KMDXWO5VP7FXHMMKXA # 上海
|
||
python -m scripts.seed_meituan_coupon_mock --clean-only # 只清 mock 数据
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import random
|
||
import struct
|
||
import sys
|
||
import zlib
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy import delete
|
||
|
||
from app.core.config import settings
|
||
from app.db.session import SessionLocal
|
||
from app.models.meituan_coupon import MeituanCoupon
|
||
from app.schemas.meituan import FEED_THUMB_PARAM
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文
|
||
|
||
# mock 行的 productViewSign 前缀:清理时按此删,保证不误伤真实抓取的券。
|
||
MOCK_SIGN_PREFIX = "MOCK-"
|
||
|
||
# 北京。其他城市的 id 见 app/integrations/data/meituan_cities.json。
|
||
DEFAULT_CITY_ID = "WKV2HMXUEK634WP64CUCUQGM64"
|
||
|
||
_IMG_DIR = Path(settings.MEDIA_ROOT) / "mock_coupon"
|
||
_IMG_COUNT = 16
|
||
|
||
_BRANDS = [
|
||
"蜜雪冰城", "瑞幸咖啡", "肯德基", "麦当劳", "华莱士", "塔斯汀",
|
||
"沪上阿姨", "古茗", "茶百道", "霸王茶姬", "正新鸡排", "杨国福麻辣烫",
|
||
"绝味鸭脖", "喜茶", "奈雪的茶", "汉堡王", "德克士", "必胜客",
|
||
"老乡鸡", "南城香",
|
||
]
|
||
_WAIMAI_ITEMS = [
|
||
"单人套餐", "双人超值餐", "3选1套餐", "招牌奶茶券", "全场通用券",
|
||
"汉堡可乐套餐", "炸鸡拼盘", "麻辣烫单人餐", "早餐组合", "夜宵拼盘",
|
||
]
|
||
_DAODIAN_ITEMS = [
|
||
"10元代金券", "20元代金券", "50元代金券", "双人自助餐", "四人聚餐套餐",
|
||
"下午茶双人套餐", "火锅双人餐", "烤肉四人餐",
|
||
]
|
||
# (saleVolume 文案, 排序用下界) —— 与 pull 脚本的 _sale_volume_num 解析口径一致
|
||
_SALE_VOLUMES = [
|
||
("月售99+", 99), ("月售199+", 199), ("月售500+", 500), ("月售999+", 999),
|
||
("热销2000+", 2000), ("热销5000+", 5000), ("热销1w+", 10000), ("热销3w+", 30000),
|
||
]
|
||
_PRICE_LABELS = [None, "15天低价", "30天低价", "近期低价"]
|
||
|
||
|
||
def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes:
|
||
"""生成一张纯色 PNG(truecolor RGB)的字节,无需 Pillow。"""
|
||
def _chunk(typ: bytes, data: bytes) -> bytes:
|
||
body = typ + data
|
||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||
|
||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB
|
||
row = b"\x00" + bytes(rgb) * width # 每行前缀 filter byte 0
|
||
idat = zlib.compress(row * height, 9)
|
||
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
|
||
|
||
|
||
def _hsv_rgb(i: int, total: int) -> tuple[int, int, int]:
|
||
"""色相均匀铺开,出一组肉眼可区分的高饱和色(免得整屏一个色、看不出卡片边界)。"""
|
||
h = 6.0 * i / total
|
||
f = h - int(h)
|
||
v, p, q, t = 235, 90, int(235 - 145 * f), int(90 + 145 * f)
|
||
return [(v, t, p), (q, v, p), (p, v, t), (p, q, v), (t, p, v), (v, p, q)][int(h) % 6]
|
||
|
||
|
||
def _write_images() -> list[str]:
|
||
"""写 mock 头图,返回可直接塞进 raw.headUrl 的 URL 列表(不含缩放后缀)。"""
|
||
_IMG_DIR.mkdir(parents=True, exist_ok=True)
|
||
urls: list[str] = []
|
||
for i in range(_IMG_COUNT):
|
||
name = f"mock_coupon_{i:02d}.png"
|
||
# 落盘名带后缀,原因见模块 docstring
|
||
(_IMG_DIR / f"{name}{FEED_THUMB_PARAM}").write_bytes(_solid_png(375, 375, _hsv_rgb(i, _IMG_COUNT)))
|
||
urls.append(name)
|
||
return urls
|
||
|
||
|
||
def _clean(db, city_id: str | None) -> int:
|
||
"""删掉本脚本造的 mock 行(可限定城市),返回删除条数。"""
|
||
stmt = delete(MeituanCoupon).where(MeituanCoupon.product_view_sign.like(f"{MOCK_SIGN_PREFIX}%"))
|
||
if city_id:
|
||
stmt = stmt.where(MeituanCoupon.city_id == city_id)
|
||
n = db.execute(stmt).rowcount or 0
|
||
db.commit()
|
||
for f in _IMG_DIR.glob(f"mock_coupon_*.png{FEED_THUMB_PARAM}"):
|
||
f.unlink()
|
||
return n
|
||
|
||
|
||
def _build_row(i: int, rng: random.Random, city_id: str, img_urls: list[str], base_url: str) -> MeituanCoupon:
|
||
to_store = rng.random() < 0.35 # 三成半到店,其余外卖
|
||
platform = 2 if to_store else 1
|
||
biz_line = rng.choice([1, 2]) if to_store else None
|
||
source = "store_supply" if to_store else rng.choice(["search_waimai", "search_meishi"])
|
||
|
||
brand = rng.choice(_BRANDS)
|
||
item = rng.choice(_DAODIAN_ITEMS if to_store else _WAIMAI_ITEMS)
|
||
# 名字带序号:保证 dedup_key(brand|name|price) 唯一,不会被 DISTINCT ON 折叠掉,分页才铺得开
|
||
name = f"{brand}{item}#{i:04d}"
|
||
|
||
sell_cents = rng.randrange(590, 8900, 10)
|
||
orig_cents = int(sell_cents * rng.uniform(1.35, 2.6))
|
||
sell, orig = f"{sell_cents / 100:.2f}", f"{orig_cents / 100:.2f}"
|
||
|
||
# 六成券佣金 ≥3%(智能推荐的门槛),其余低佣金:两个 tab 的结果集才有明显区别
|
||
pct = round(rng.uniform(3.0, 8.5), 1) if rng.random() < 0.6 else round(rng.uniform(0.3, 2.9), 1)
|
||
comm_cents = int(sell_cents * pct / 100)
|
||
|
||
sv_text, sv_num = rng.choice(_SALE_VOLUMES)
|
||
head_url = f"{base_url}{settings.MEDIA_URL_PREFIX}/mock_coupon/{rng.choice(img_urls)}"
|
||
poi_name = f"{brand}(mock{rng.randrange(1, 60):02d}店)"
|
||
dist_km = round(rng.uniform(0.2, 8.0), 2) # 外卖侧单位是 km,到店是 m(见 CouponCard.from_raw)
|
||
sign = f"{MOCK_SIGN_PREFIX}{i:06d}"
|
||
|
||
raw = {
|
||
"couponPackDetail": {
|
||
"productViewSign": sign,
|
||
"skuViewId": f"{sign}-SKU",
|
||
"platform": platform,
|
||
"bizLine": biz_line,
|
||
"name": name,
|
||
"headUrl": head_url,
|
||
"sellPrice": sell,
|
||
"originalPrice": orig,
|
||
"saleVolume": sv_text,
|
||
"couponNum": rng.choice([1, 1, 1, 3, 5]),
|
||
"productLabel": {
|
||
"pricePowerLabel": {"historyPriceLabel": rng.choice(_PRICE_LABELS)},
|
||
"productRankLabel": f"2小时北京销量榜第{rng.randrange(1, 30)}名" if rng.random() < 0.25 else None,
|
||
"dianPingRankLabel": f"{rng.uniform(3.8, 4.9):.1f}分" if rng.random() < 0.5 else None,
|
||
},
|
||
},
|
||
"brandInfo": {"brandName": brand, "brandLogoUrl": head_url},
|
||
# commissionPercent 是「百分比 ×100」(140 → 1.4%),与 pull 脚本的换算保持一致
|
||
"commissionInfo": {"commissionPercent": int(pct * 100), "commission": f"{comm_cents / 100:.2f}"},
|
||
"deliverablePoiInfo": {"poiName": poi_name, "deliveryDistance": dist_km if platform == 1 else dist_km * 1000},
|
||
"availablePoiInfo": {"availablePoiNum": rng.randrange(1, 200)},
|
||
"couponValidTimeInfo": {"couponValidDay": rng.choice([7, 15, 30, 90])},
|
||
}
|
||
|
||
return MeituanCoupon(
|
||
source=source, platform=platform, biz_line=biz_line, city_id=city_id,
|
||
product_view_sign=sign, sku_view_id=f"{sign}-SKU",
|
||
name=name, brand_name=brand,
|
||
sell_price_cents=sell_cents, original_price_cents=orig_cents,
|
||
head_url=head_url, image_size=None, image_type="image/png",
|
||
sale_volume=sv_text, sale_volume_num=sv_num,
|
||
commission_percent=pct, commission_amount_cents=comm_cents,
|
||
poi_name=poi_name, available_poi_num=raw["availablePoiInfo"]["availablePoiNum"],
|
||
delivery_distance_m=dist_km * 1000,
|
||
dedup_key=hashlib.md5(f"{brand}|{name}|{sell_cents}".encode()).hexdigest(),
|
||
raw=raw,
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description="往 meituan_coupon 灌 mock 券(本地无凭证时用)")
|
||
ap.add_argument("--city-id", default=DEFAULT_CITY_ID, help=f"美团 cityId,默认北京 {DEFAULT_CITY_ID}")
|
||
ap.add_argument("--count", type=int, default=400, help="造多少条(默认 400,约六成过智能推荐的佣金门槛)")
|
||
ap.add_argument("--base-url", default="http://127.0.0.1:8770",
|
||
help="头图用的后端地址,须为测试机可达(默认 http://127.0.0.1:8770,对齐 local.properties)")
|
||
ap.add_argument("--clean-only", action="store_true", help="只清 mock 数据,不重建")
|
||
ap.add_argument("--seed", type=int, default=20260723, help="随机种子(固定则每次造出同一批)")
|
||
args = ap.parse_args()
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
removed = _clean(db, None if args.clean_only else args.city_id)
|
||
print(f"清理旧 mock: {removed} 条")
|
||
if args.clean_only:
|
||
return
|
||
|
||
rng = random.Random(args.seed)
|
||
img_urls = _write_images()
|
||
print(f"头图: {_IMG_COUNT} 张 -> {_IMG_DIR}")
|
||
|
||
rows = [_build_row(i, rng, args.city_id, img_urls, args.base_url.rstrip("/"))
|
||
for i in range(args.count)]
|
||
db.add_all(rows)
|
||
db.commit()
|
||
|
||
rec = sum(1 for r in rows if (r.commission_percent or 0) >= 3.0)
|
||
print(f"入库: {len(rows)} 条 city_id={args.city_id}")
|
||
print(f" 智能推荐(佣金≥3%): {rec} 条 ≈ {(rec + 19) // 20} 页")
|
||
print(f" 销量最高(有销量): {len(rows)} 条 ≈ {(len(rows) + 19) // 20} 页")
|
||
print("\n测试机的模拟定位记得设成对应城市,否则 city_id 对不上仍然是空。")
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|