Files
shaguabijia-app-server/scripts/seed_meituan_coupon_mock.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

223 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""本地 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()