"""撤销指定用户「今天已签到」的状态,让今天可以重新签到,方便反复测试签到流程。 与 reset_signin.py 的区别:那个删**全部**签到历史(连续天数从头再来);本脚本只精确撤销 **今天**这一次,昨天及以前的记录原样保留 —— 所以重签后 cycle_day / streak 会接着昨天继续, 7 天循环的档位不会被打乱,可以连着好几天测「第 N 档」的奖励。 默认是**完整撤销**(等于今天这次签到从没发生过): 1. 删 signin_record 今天这行 → 今天变回未签到 2. 删今天的 signin 金币流水,并把金币从 coin_account 余额 / 累计收益里扣回 金币默认要退:签到流水**没有**唯一索引拦重复(ux_coin_transaction_task_ref 只覆盖 biz_type LIKE 'task%'),不退的话每测一轮余额就白涨一次奖励,coin_transaction 里还会堆出 同一 ref_id(日期)的重复流水,收益明细页会看到两条今天的签到。真想留着奖励用 --keep-coins。 例外:签到的金币若已被兑换成现金(余额已不够退),**自动跳过退款**并保留今天的签到流水。 因为 coin_balance 必须恒等于流水总和,硬退会把余额退成负数 —— 夹到 0 又会吃掉别处赚的金币, 两种做法都会让账对不上。这时重签会再发一次奖励,余额多涨一档,属可接受的测试噪音。 用法(在项目根、已 pip install -e . 的环境里跑): python scripts/reset_signin_today.py # 默认测试号 11111111111 python scripts/reset_signin_today.py 13800138000 # 指定手机号 python scripts/reset_signin_today.py --user-id 5 # 直接指定 user_id python scripts/reset_signin_today.py --dry-run # 预览(照常执行再回滚),不落库 python scripts/reset_signin_today.py --keep-coins # 只删签到记录,保留已发金币 「今天」直接复用 app.core.rewards.cn_today(北京时间),与签到判重同源,不自己算时区。 走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**只允许 APP_ENV=dev 时改库** (--dry-run 只读,任何环境都能跑)。 """ from __future__ import annotations import argparse import sys from sqlalchemy import select from app.core.config import settings from app.core.rewards import cn_today from app.db.session import SessionLocal, engine from app.models.signin import SigninRecord from app.models.user import User from app.models.wallet import CoinAccount, CoinTransaction from app.repositories import signin as crud_signin # Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码 if hasattr(sys.stdout, "reconfigure"): sys.stdout.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 print_state(db, user: User, today, label: str) -> None: print(f"--- {label} ---") rec = db.execute( select(SigninRecord).where( SigninRecord.user_id == user.id, SigninRecord.signin_date == today ) ).scalar_one_or_none() if rec is None: print(f" signin_record {today}: (无)") else: print(f" signin_record {today}: 第{rec.cycle_day}档 连续{rec.streak}天 +{rec.coin_awarded}金币") last = db.execute( select(SigninRecord.signin_date) .where(SigninRecord.user_id == user.id) .order_by(SigninRecord.signin_date.desc()) .limit(1) ).scalar_one_or_none() print(f" 最近一次签到: {last or '(从未签到)'}") rows = db.execute( select(CoinTransaction).where( CoinTransaction.user_id == user.id, CoinTransaction.biz_type == "signin", CoinTransaction.ref_id == today.isoformat(), ) ).scalars().all() print(f" coin_transaction(signin, 今天): {len(rows)} 条 / {sum(r.amount for r in rows)} 金币") 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}") # 用 App 自己的 get_status 复核,而不是脚本里重算一遍规则 —— 这行就是客户端会看到的 st = crud_signin.get_status(db, user.id) print(f" [签到接口] can_claim={st.can_claim} today_signed={st.today_signed} " f"今天第{st.today_cycle_day}档({st.today_coin}金币) 已连续{st.consecutive_days}天") def refund_today(db, user_id: int, today) -> None: """退回今天签到(含膨胀)发的金币:删流水 + 扣余额。 不变量:coin_balance 必须恒等于流水总和。所以余额不够退时**整笔跳过**,而不是硬退成 负数、或夹到 0 —— 夹到 0 会吃掉用户在别处赚的金币,两种做法都会让余额和流水对不上。 """ rows = list(db.execute( select(CoinTransaction).where( CoinTransaction.user_id == user_id, CoinTransaction.biz_type == "signin", CoinTransaction.ref_id == today.isoformat(), ) ).scalars().all()) if not rows: 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: # 典型场景:签完就把金币兑换成现金了(exchange_out),这笔奖励已经变成 cash_balance_cents, # 余额里已经没有它了。硬退会把余额退成负数 / 夹到 0 又会吃掉别处赚的金币,两者都会让账对不上。 print(f" ⚠️ 还有 {stuck} 笔今天的签到流水退不掉(金币已被兑换/花掉,余额 {acc.coin_balance} 兜不住)," f"原样保留 —— 硬退会让余额和流水总和对不上。") print(" → 收益明细今天会多出几条签到记录,不影响签到功能测试;想彻底清干净用 reset_signin.py --with-coins。") 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 可任意环境)") today = cn_today() db = SessionLocal() try: user = resolve_user(db, args.phone, args.user_id) print(f"DB: {settings.DATABASE_URL}") print(f"用户: id={user.id} phone={user.phone} 今天(北京): {today} keep_coins: {args.keep_coins}") print_state(db, user, today, "before") # 今天的签到记录 —— 只删今天,昨天及以前保留,重签后 streak 接着涨 rec = db.execute( select(SigninRecord).where( SigninRecord.user_id == user.id, SigninRecord.signin_date == today ) ).scalar_one_or_none() if rec is not None: db.delete(rec) if rec is None: print("今天本来就没签到,无需处理。") db.rollback() return # 退金币 if args.keep_coins: print("(--keep-coins:保留已发金币,流水和余额不动)") else: refund_today(db, user.id, today) # 注:更早流水的 balance_after 是当时的快照,不回改 —— 收益明细里历史行的 # 余额列会与现余额对不上,dev 测试库无妨。 # SessionLocal 是 autoflush=False,不 flush 的话下面 print_state 的 select # 读到的还是删之前的旧行,"after" 会骗人 db.flush() print_state(db, user, today, "after") if args.dry_run: db.rollback() print("(dry-run:以上 after 为预览,已回滚,库没动)") return db.commit() print(f"完成:{user.phone} 今天({today})可以重新签到了。" f"提醒:App 内存状态不会自动同步,杀掉重进福利页(当天未签到)会重新自动弹签到弹窗。") finally: db.close() if __name__ == "__main__": main()