功能:新手引导视频 + 美团券首页分页索引 (#167)
Co-authored-by: guke <guke@wonderable.ai> Co-authored-by: 左辰勇 <exinglang@gmail.com> Reviewed-on: #167 Co-authored-by: zuochenyong <zuochenyong@wonderable.ai> Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
This commit was merged in pull request #167.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"""重置指定账号的「领券引导视频」已播次数,让领券等候浮层重新放引导视频,方便反复看这支片。
|
||||
|
||||
原理:浮层放不放引导视频只由两件事决定(见 app/repositories/guide_video.py `start_play`):
|
||||
1. 运营配置 app_config(key=coupon_guide_video):enabled / video_url / max_plays;
|
||||
2. 该账号 guide_video_play 的**行数**(开播即计次)—— 行数 >= max_plays 就不再下发,
|
||||
`/api/v1/guide-video/start` 返 should_play=false,客户端照旧放广告。
|
||||
所以「想再看一遍」= 删掉这个账号的 guide_video_play 行。配置本脚本**只读不改**(要换片子 /
|
||||
开关去运营后台「配置 - 领券引导视频」;次数 3 / 金币 120 是服务端默认值,后台不开放调整),
|
||||
这样不会把别的账号的线上行为一起动了。
|
||||
|
||||
默认连**当时发的金币一起退**(每次 reward_coin,现配置 120):这些流水 biz_type='guide_video',
|
||||
不退的话每重置一轮余额就白涨一轮,收益明细里还会堆出一串「新手引导视频奖励」。想留着用 --keep-coins。
|
||||
例外:金币已被兑换成现金、余额兜不住时**整笔跳过不退** —— coin_balance 必须恒等于流水总和,
|
||||
硬退会退成负数,夹到 0 又会吃掉别处赚的金币,两种做法都会让账对不上(同 reset_signin_today.py)。
|
||||
|
||||
用法(在项目根、已 pip install -e . 的环境里跑):
|
||||
python scripts/reset_guide_video.py # 默认测试号 11111111111
|
||||
python scripts/reset_guide_video.py 13800138000 # 指定手机号
|
||||
python scripts/reset_guide_video.py --user-id 5 # 直接指定 user_id
|
||||
python scripts/reset_guide_video.py --dry-run # 预览(照常执行再回滚),不落库
|
||||
python scripts/reset_guide_video.py --keep-coins # 只清次数,已发金币不退
|
||||
|
||||
走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**只允许 APP_ENV=dev 改库**
|
||||
(--dry-run 只读,任何环境都能跑)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.guide_video import GuideVideoPlay
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, CoinTransaction
|
||||
from app.repositories import guide_video as crud_guide
|
||||
|
||||
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码。stderr 也要设:
|
||||
# SystemExit(如"用户不存在")的中文提示走的是 stderr。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
_stream.reconfigure(encoding="utf-8")
|
||||
|
||||
# dev 下 engine 是 echo=True(APP_DEBUG),几十行 SQL 会把前后对比刷没。echo 走 SQLAlchemy 自己的
|
||||
# InstanceLogger,不吃 logging.setLevel,只能改 engine.echo。
|
||||
engine.echo = False
|
||||
|
||||
DEFAULT_PHONE = "11111111111"
|
||||
|
||||
|
||||
def resolve_user(db, phone: str, user_id: int | None) -> User:
|
||||
if user_id is not None:
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise SystemExit(f"user_id={user_id} 不存在")
|
||||
return user
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise SystemExit(f"手机号 {phone} 没有对应用户(注意 phone 才是登录账号,username 是展示 ID)")
|
||||
return user
|
||||
|
||||
|
||||
def load_plays(db, user_id: int) -> list[GuideVideoPlay]:
|
||||
return list(db.execute(
|
||||
select(GuideVideoPlay)
|
||||
.where(GuideVideoPlay.user_id == user_id)
|
||||
.order_by(GuideVideoPlay.id)
|
||||
).scalars().all())
|
||||
|
||||
|
||||
def print_config(db) -> dict:
|
||||
"""打印运营配置 + 片子是否真在盘上(配了地址但文件丢了,客户端会黑屏/加载失败)。"""
|
||||
cfg = crud_guide.get_config(db)
|
||||
video_url = (cfg.get("video_url") or "").strip()
|
||||
print("--- 运营配置(app_config: coupon_guide_video,本脚本不改) ---")
|
||||
print(f" enabled={cfg['enabled']} max_plays={cfg['max_plays']} reward_coin={cfg['reward_coin']}")
|
||||
print(f" video_url={video_url or '(未配片)'}")
|
||||
if video_url.startswith(settings.MEDIA_URL_PREFIX + "/"):
|
||||
rel = video_url[len(settings.MEDIA_URL_PREFIX) + 1:]
|
||||
f = Path(settings.MEDIA_ROOT) / rel
|
||||
if f.is_file():
|
||||
print(f" 文件: {f} ({f.stat().st_size / 1024 / 1024:.1f} MB) ✓")
|
||||
else:
|
||||
print(f" ⚠️ 文件不存在: {f} —— 客户端会加载失败,请去运营后台重新上传")
|
||||
return cfg
|
||||
|
||||
|
||||
def print_state(db, user: User, cfg: dict, label: str) -> None:
|
||||
plays = load_plays(db, user.id)
|
||||
max_plays = int(cfg.get("max_plays") or 0)
|
||||
used = len(plays)
|
||||
print(f"--- {label} ---")
|
||||
print(f" guide_video_play: {used} 行(已用次数,开播即计),上限 {max_plays}")
|
||||
for p in plays:
|
||||
print(f" #{p.id} 第{p.seq}次 {p.status} +{p.coin}金币 "
|
||||
f"completed={p.completed} 开播于 {p.started_at} token={p.play_token[:12]}…")
|
||||
|
||||
# 用仓储层原样复算一遍"下次会不会放",而不是脚本里自己判规则 —— 这就是客户端会拿到的结果
|
||||
enabled = bool(cfg.get("enabled"))
|
||||
has_video = bool((cfg.get("video_url") or "").strip())
|
||||
will_play = enabled and has_video and max_plays > 0 and used < max_plays
|
||||
reason = (
|
||||
"会放引导视频" if will_play
|
||||
else "开关关着" if not enabled
|
||||
else "没配片" if not has_video
|
||||
else "max_plays=0" if max_plays <= 0
|
||||
else f"次数已用完({used}/{max_plays})"
|
||||
)
|
||||
print(f" [下次点一键领取] should_play={will_play} —— {reason}")
|
||||
|
||||
acc = db.get(CoinAccount, user.id)
|
||||
if acc is None:
|
||||
print(" coin_account: (无)")
|
||||
else:
|
||||
print(f" coin_account: coin={acc.coin_balance} earned={acc.total_coin_earned}")
|
||||
|
||||
|
||||
def refund_plays(db, user_id: int, tokens: list[str]) -> None:
|
||||
"""退回这些播放发的金币:删流水 + 扣余额。余额兜不住的整笔跳过(见模块 docstring)。"""
|
||||
if not tokens:
|
||||
return
|
||||
rows = list(db.execute(
|
||||
select(CoinTransaction).where(
|
||||
CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == crud_guide.BIZ_TYPE,
|
||||
CoinTransaction.ref_id.in_(tokens),
|
||||
)
|
||||
).scalars().all())
|
||||
if not rows:
|
||||
print(" 没有可退的金币流水(可能是 reward_coin=0,或本来就没发过)")
|
||||
return
|
||||
acc = db.get(CoinAccount, user_id)
|
||||
if acc is None:
|
||||
return
|
||||
|
||||
# 从最近一笔往回退,退到余额兜不住为止 —— 保证"最新发的那笔"一定被退掉。
|
||||
rows.sort(key=lambda r: r.id, reverse=True)
|
||||
refundable: list[CoinTransaction] = []
|
||||
total = 0
|
||||
for r in rows:
|
||||
if total + r.amount > acc.coin_balance:
|
||||
break
|
||||
refundable.append(r)
|
||||
total += r.amount
|
||||
|
||||
for r in refundable:
|
||||
db.delete(r)
|
||||
if total:
|
||||
acc.coin_balance -= total
|
||||
acc.total_coin_earned = max(0, acc.total_coin_earned - total)
|
||||
print(f" 已退回 {total} 金币({len(refundable)}/{len(rows)} 笔引导视频奖励)")
|
||||
|
||||
stuck = len(rows) - len(refundable)
|
||||
if stuck:
|
||||
print(f" ⚠️ 还有 {stuck} 笔退不掉(金币已被兑换/花掉,余额 {acc.coin_balance} 兜不住),原样保留 —— "
|
||||
"硬退会让余额和流水总和对不上。收益明细里会多留几条,不影响再看视频。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="重置账号的领券引导视频次数,让浮层重新放引导视频")
|
||||
parser.add_argument("phone", nargs="?", default=DEFAULT_PHONE,
|
||||
help=f"手机号(默认 {DEFAULT_PHONE})")
|
||||
parser.add_argument("--user-id", type=int, default=None, help="直接按 user_id 定位,优先于 phone")
|
||||
parser.add_argument("--keep-coins", action="store_true",
|
||||
help="不退已发金币(余额会越测越高,收益明细堆重复流水)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览,最后回滚不落库")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.dry_run and settings.APP_ENV != "dev":
|
||||
raise SystemExit(f"APP_ENV={settings.APP_ENV},拒绝改库(只有 dev 能改;--dry-run 可任意环境)")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = resolve_user(db, args.phone, args.user_id)
|
||||
print(f"DB: {settings.DATABASE_URL} APP_ENV: {settings.APP_ENV}")
|
||||
print(f"用户: id={user.id} phone={user.phone} username={user.username} keep_coins: {args.keep_coins}")
|
||||
cfg = print_config(db)
|
||||
print_state(db, user, cfg, "before")
|
||||
|
||||
plays = load_plays(db, user.id)
|
||||
if not plays:
|
||||
print("该账号没有任何播放记录,次数本来就是满的,无需重置。")
|
||||
db.rollback()
|
||||
return
|
||||
|
||||
tokens = [p.play_token for p in plays]
|
||||
for p in plays:
|
||||
db.delete(p)
|
||||
db.flush()
|
||||
|
||||
if args.keep_coins:
|
||||
print("(--keep-coins:保留已发金币,流水和余额不动)")
|
||||
else:
|
||||
refund_plays(db, user.id, tokens)
|
||||
# 注:更早流水的 balance_after 是当时的快照,不回改 —— 收益明细里历史行的余额列
|
||||
# 会与现余额对不上,dev 测试库无妨。
|
||||
|
||||
print_state(db, user, cfg, "after")
|
||||
|
||||
if args.dry_run:
|
||||
db.rollback()
|
||||
print(f"(dry-run:以上 after 为预览,已回滚,库没动;真跑会删 {len(plays)} 条播放记录)")
|
||||
return
|
||||
db.commit()
|
||||
print(f"完成:删掉 {len(plays)} 条播放记录,{user.phone} 下次点「一键自动领取」浮层会重新放引导视频。")
|
||||
print("提醒:客户端是在浮层建窗时调 /guide-video/start 的,不用重装、不用重登,直接再点一次一键领取即可;"
|
||||
"但「广告显示」开关关掉时整块浮层不建窗(连引导视频也不放),测之前先确认它是开的。")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,222 @@
|
||||
"""本地 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()
|
||||
Reference in New Issue
Block a user