功能:新手引导视频 + 美团券首页分页索引 (#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:
2026-07-24 14:48:40 +08:00
committed by guke
parent 9e88ca72d3
commit 3f7b5167fa
26 changed files with 1855 additions and 83 deletions
+216
View File
@@ -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()
+222
View File
@@ -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()