diff --git a/scripts/seed_inactivity_cases.py b/scripts/seed_inactivity_cases.py new file mode 100644 index 0000000..e66e7c6 --- /dev/null +++ b/scripts/seed_inactivity_cases.py @@ -0,0 +1,139 @@ +"""人工验证用:按「金币/现金/邀请」排列组合 + 活跃/新用户对照,造一批账号。 + +用法(仓库根目录,venv 解释器): + .venv/Scripts/python.exe scripts/seed_inactivity_cases.py # 造号(会先清掉上次 vcase*) + .venv/Scripts/python.exe scripts/seed_inactivity_cases.py --clean # 只清理,不造 + +配合默认配置 INACTIVITY_RESET_DAYS=15 / INACTIVITY_WARN_DAYS_BEFORE=7,2 验证。 +造完把 worker 打开(见 README/对话里的 .env),启动服务即会在 RUN_HOUR 后跑一轮。 + +⚠️ worker 清零针对**库里所有**符合条件的用户,不止 vcase*——dev 库里若有其它"老且有余额、 +无近期活跃事件"的用户,也会被一起清。要干净验证建议用一个空/副本 dev 库。 +""" +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import delete, select # noqa: E402 + +from app.db.session import SessionLocal # noqa: E402 +from app.models.analytics_event import AnalyticsEvent # noqa: E402 +from app.models.inactivity import ( # noqa: E402 + InactivityNotificationLog, + InactivityResetLog, +) +from app.models.user import User # noqa: E402 +from app.models.wallet import ( # noqa: E402 + CashTransaction, + CoinAccount, + CoinTransaction, + InviteCashTransaction, +) +from app.repositories import wallet as wallet_repo # noqa: E402 + +MARK = "vcase" # username 前缀,用于清理 + +# label, 创建于N天前, coin, cash, invite, 近期事件(N天前)or None, 预期 +CASES = [ + ("1 三桶全有", 30, 100, 200, 300, None, "清 coin+cash;invite=300 保留;审计1行+2流水"), + ("2 金币+现金", 30, 100, 200, 0, None, "清 coin+cash;审计1行+2流水"), + ("3 金币+邀请", 30, 100, 0, 300, None, "清 coin;invite=300 保留;审计1行+1流水"), + ("4 现金+邀请", 30, 0, 200, 300, None, "清 cash;invite=300 保留;审计1行+1流水"), + ("5 只有金币", 30, 100, 0, 0, None, "清 coin;审计1行+1流水"), + ("6 只有现金", 30, 0, 200, 0, None, "清 cash;审计1行+1流水"), + ("7 只有邀请(红线)", 30, 0, 0, 300, None, "不选中/不清/无审计/无流水;invite=300 原封"), + ("8 预警窗(10天)", 10, 50, 60, 70, None, "不清;发 T-7 预警;notification_log 1行;余额不动"), + ("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_view→last_active 近→不清不警"), + ("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"), +] + + +def _mark_uids(db) -> list[int]: + return list(db.execute(select(User.id).where(User.username.like(f"{MARK}%"))).scalars()) + + +def clean(db) -> int: + uids = _mark_uids(db) + if uids: + for model in ( + InactivityResetLog, InactivityNotificationLog, + CoinTransaction, CashTransaction, InviteCashTransaction, + AnalyticsEvent, CoinAccount, + ): + db.execute(delete(model).where(model.user_id.in_(uids))) + db.execute(delete(User).where(User.id.in_(uids))) + db.commit() + return len(uids) + + +def seed(db) -> None: + now = datetime.now(UTC) + print(f"{'#':>3} {'uid':>5} {'案例':<16} {'coin/cash/invite':<18} {'创建':<7} 预期") + for i, (label, days_ago, coin, cash, invite, ev_days, expected) in enumerate(CASES, 1): + u = User( + phone=f"seed_tmp_{i}", username=f"{MARK}{i}", status="active", + created_at=now - timedelta(days=days_ago), + last_login_at=now, # 登录很新——但登录不算活跃,清零该发生照发生 + ) + db.add(u) + db.flush() # 拿自增 id + u.phone = f"1{u.id:010d}" # 用全局唯一 id 拼 "100…" 段手机号,dev 库里绝不撞 + acc = wallet_repo.get_or_create_account(db, u.id, commit=False) + acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite + acc.total_coin_earned = coin + if ev_days is not None: + db.add(AnalyticsEvent( + event="home_view", device_id=MARK, user_id=u.id, + client_ts=0, created_at=now - timedelta(days=ev_days), + )) + db.flush() + print(f"{i:>3} {u.id:>5} {label:<16} {f'{coin}/{cash}/{invite}':<18} {f'{days_ago}天前':<7} {expected}") + db.commit() + + +def check(db) -> None: + """worker 跑完后:打印每个 vcase 账号的当前三桶余额 + 是否有审计/预警行。""" + rows = db.execute( + select(User.id, User.username).where(User.username.like(f"{MARK}%")).order_by(User.id) + ).all() + if not rows: + print("没有 vcase* 账号(先跑一次不带参数造号)") + return + print(f"{'uid':>5} {'账号':<8} {'coin/cash/invite(现在)':<24} {'审计':<5} 预警") + for uid, uname in rows: + acc = db.get(CoinAccount, uid) + bal = f"{acc.coin_balance}/{acc.cash_balance_cents}/{acc.invite_cash_balance_cents}" if acc else "—" + has_reset = db.execute( + select(InactivityResetLog.id).where(InactivityResetLog.user_id == uid).limit(1) + ).first() + stages = db.execute( + select(InactivityNotificationLog.stage).where(InactivityNotificationLog.user_id == uid) + ).scalars().all() + warn = ",".join(f"T-{s}" for s in stages) if stages else "—" + print(f"{uid:>5} {uname:<8} {bal:<24} {'有' if has_reset else '—':<5} {warn}") + + +def main() -> None: + db = SessionLocal() + try: + if "--check" in sys.argv: + check(db) + return + removed = clean(db) + if removed: + print(f"已清理上次 {removed} 个 {MARK}* 账号") + if "--clean" in sys.argv: + return + seed(db) + print("\n造号完成。打开 worker(INACTIVITY_RESET_ENABLED=true, RUN_HOUR=17)后启动服务," + "≥17:00 首个 tick 即跑一轮。验完 `--clean` 清理。") + finally: + db.close() + + +if __name__ == "__main__": + main()