Merge branch 'main' into feature/direct-vendor-push-main-20260702
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""重置指定用户的新手引导完成标记,让这个账号重新进新手引导页,方便反复测试引导流程。
|
||||
|
||||
原理:是否跳过引导只由 onboarding_completion 表里 (user_id, device_id) 那一行决定
|
||||
(见 app/models/onboarding.py)。删掉该用户的行 → 登录响应 onboarding_completed=false、
|
||||
GET /api/v1/user/onboarding/status 也返 false → 客户端下次登录/启动重走引导。
|
||||
本地 SharedPreferences 标记卸载即丢、以后端为准,所以删这一行就够,不用重装 App。
|
||||
|
||||
默认删该用户**所有设备**的记录(换机/多设备一起放开);只想放开某一台用 --device-id
|
||||
(device_id = 客户端硬件级 ANDROID_ID,与登录 / onboarding/complete 传的是同一个值)。
|
||||
|
||||
与已有两个入口的分工:
|
||||
- admin「设备维度引导管理」按**设备**重置(该设备上所有账号一起),本脚本按**账号**;
|
||||
- POST /api/v1/user/onboarding/reset 要客户端自己带 device_id 调,本脚本从库里反查设备。
|
||||
|
||||
用法(在项目根、已 pip install -e . 的环境里跑):
|
||||
python scripts/reset_onboarding.py # 默认测试号 11111111111
|
||||
python scripts/reset_onboarding.py 13800138000 # 指定手机号
|
||||
python scripts/reset_onboarding.py --user-id 5 # 直接指定 user_id
|
||||
python scripts/reset_onboarding.py --dry-run # 预览(照常执行再回滚),不落库
|
||||
python scripts/reset_onboarding.py --device-id abc123 # 只放开这一台设备,其余设备照旧跳过
|
||||
|
||||
走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**默认只允许 APP_ENV=dev 改库**
|
||||
(--dry-run 只读,任何环境都能跑)。线上确实要给某个用户开引导时加 --force —— 这张表只存
|
||||
"引导走过没"的标记,删了最坏结果是用户多看一次引导,不涉及金额/账目。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
from app.models.user import User
|
||||
|
||||
# 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 print_state(db, user: User, device_id: str | None, label: str) -> None:
|
||||
"""打印该用户当前的引导完成标记。--device-id 时只看那一台,便于确认没误伤别的设备。"""
|
||||
stmt = (
|
||||
select(OnboardingCompletion.device_id, OnboardingCompletion.completed_at)
|
||||
.where(OnboardingCompletion.user_id == user.id)
|
||||
.order_by(OnboardingCompletion.completed_at.desc())
|
||||
)
|
||||
if device_id:
|
||||
stmt = stmt.where(OnboardingCompletion.device_id == device_id)
|
||||
rows = db.execute(stmt).all()
|
||||
|
||||
print(f"--- {label} ---")
|
||||
scope = f"device_id={device_id}" if device_id else "全部设备"
|
||||
if not rows:
|
||||
print(f" onboarding_completion({scope}): (无) → 该用户会走引导")
|
||||
return
|
||||
print(f" onboarding_completion({scope}): {len(rows)} 条 → 这些设备上会跳过引导")
|
||||
for did, at in rows:
|
||||
print(f" device_id={did} 完成于 {at}")
|
||||
|
||||
|
||||
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("--device-id", default=None,
|
||||
help="只重置这一台设备(硬件级 ANDROID_ID);默认重置该用户所有设备")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览,最后回滚不落库")
|
||||
parser.add_argument("--force", action="store_true", help="非 dev 环境也允许改库(仅删引导标记,不涉及账目)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.dry_run and settings.APP_ENV != "dev" and not args.force:
|
||||
raise SystemExit(
|
||||
f"APP_ENV={settings.APP_ENV},默认只有 dev 能改库。确认要在该环境重置请加 --force"
|
||||
"(--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}")
|
||||
print_state(db, user, args.device_id, "before")
|
||||
|
||||
stmt = delete(OnboardingCompletion).where(OnboardingCompletion.user_id == user.id)
|
||||
if args.device_id:
|
||||
stmt = stmt.where(OnboardingCompletion.device_id == args.device_id)
|
||||
deleted = db.execute(stmt).rowcount
|
||||
|
||||
if not deleted:
|
||||
# 没记录本来就会走引导 —— 常见于:换了新设备、或运营/客户端已经重置过一次。
|
||||
print("该用户(该范围内)本来就没有完成标记,已经会走引导了,无需处理。")
|
||||
db.rollback()
|
||||
else:
|
||||
print_state(db, user, args.device_id, "after")
|
||||
if args.dry_run:
|
||||
db.rollback()
|
||||
print(f"(dry-run:以上 after 为预览,已回滚,库没动;真跑会删 {deleted} 条)")
|
||||
return
|
||||
db.commit()
|
||||
print(f"完成:删掉 {deleted} 条完成标记,{user.phone} 下次登录会重走新手引导。")
|
||||
|
||||
# 测试号无论库里有没有记录都恒走引导(见 app/core/test_account.py),提醒一句免得白跑
|
||||
if settings.test_account_phone and user.phone == settings.test_account_phone:
|
||||
print(f"提示:{user.phone} 是配置的测试账号(TEST_ACCOUNT_PHONE),"
|
||||
"登录响应 onboarding_completed 恒为 false、本就每次都走引导,无需重置。")
|
||||
|
||||
print("提醒:客户端是在登录响应 / 启动时查 onboarding/status 的,已经在首页的 App 不会自动跳转,"
|
||||
"退出登录重进(或杀掉重开)才会看到引导页。")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,218 @@
|
||||
"""撤销指定用户「今天已签到」的状态,让今天可以重新签到,方便反复测试签到流程。
|
||||
|
||||
与 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()
|
||||
@@ -33,6 +33,7 @@ from app.models.wallet import ( # noqa: E402
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
)
|
||||
from app.repositories import activity # noqa: E402
|
||||
from app.repositories import wallet as wallet_repo # noqa: E402
|
||||
|
||||
MARK = "vcase" # username 前缀,用于清理
|
||||
@@ -47,7 +48,7 @@ CASES = [
|
||||
("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 近→不清不警"),
|
||||
("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_visible→last_active 近→不清不警"),
|
||||
("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"),
|
||||
]
|
||||
|
||||
@@ -86,8 +87,8 @@ def seed(db) -> None:
|
||||
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=show + page=home
|
||||
event="show", page="home", device_id=MARK, user_id=u.id,
|
||||
db.add(AnalyticsEvent( # 首页可见 = event=home_visible(单一 event 名,见 activity.ACTIVE_EVENTS)
|
||||
event=activity.HOME_VISIBLE_EVENT, device_id=MARK, user_id=u.id,
|
||||
client_ts=0, created_at=now - timedelta(days=ev_days),
|
||||
))
|
||||
db.flush()
|
||||
|
||||
Reference in New Issue
Block a user