Files
shaguabijia-app-server/scripts/reset_guide_video.py
T
zuochenyong 3f7b5167fa 功能:新手引导视频 + 美团券首页分页索引 (#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>
2026-07-24 14:48:40 +08:00

217 lines
9.8 KiB
Python

"""重置指定账号的「领券引导视频」已播次数,让领券等候浮层重新放引导视频,方便反复看这支片。
原理:浮层放不放引导视频只由两件事决定(见 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()