a86688ccfb
背景 / 目标 连续 15 天(北京自然日)没有「首页可见 / 发起比价 / 发起领券」行为的用户判定为流失,每日自动清零其金币 + 折算现金;清零前按可配置节奏预警;全程留审计。纯登录不算活跃;邀请奖励金物理隔离、不清(产品红线)。 改了什么 活跃口径共享模块 app/repositories/activity.py:worker(清零/预警)与 admin(最近活跃)单一真源,防漂移。 清零业务逻辑 inactivity.py:选取 / 逐用户清零(行锁 + 幂等)/ 预警分档 + streak 去重 / run_once 组合,预警故障逐用户隔离、绝不阻塞清零。 每日 worker inactivity_reset_worker.py(仿 daily_exchange:文件锁 + 北京日守卫 + RUN_HOUR 门槛 + 总闸)+ main.py lifespan 接线。 可插拔通知器 notifier.py(v1 LogNotifier 日志占位,预留 JPush/短信)。 配置 INACTIVITY_*(阈值 / 预警档 / 执行点 / 通道 / 开关)。 admin 最近活跃口径改用共享模块(移除 last_login_at、纳入 show/home、以 created_at 为基线)。 审计:inactivity_reset_log + inactivity_notification_log 两表 + 钱包流水双写(biz_type=inactivity_reset,ref_id 交叉)。 文档:设计 spec / 实现 plan / docs/database/ 两表字典。 关键产品决策 邀请金不清:只清金币 + 折算现金,invite_cash_balance_cents 原封(仅快照入审计)。 活跃口径 = max(created_at, 首页可见, 比价, 领券),不含 last_login_at;首页可见 = event=show + page=home。 时间边界:北京自然日 0 点对齐(见 activity.reset_cutoff)。 总闸默认关,灰度验证后再开。 数据库变更 新表:inactivity_reset_log、inactivity_notification_log。 analytics_event 新增复合覆盖索引 ix_analytics_event_active (event, page, user_id, created_at)(活跃口径聚合热点)。 修复了 base 上的迁移多头(135e79414fd0 与 phone_rebind_log 同从 comparison_llm_cost 分叉)→ 加空 merge 修订,alembic upgrade head 恢复单头正常。 ⚠️ 上线注意(合并后 / 开总闸前) INACTIVITY_RESET_ENABLED 默认 false;开启前提 = show/home 埋点全量铺满——否则"只登录不操作"且注册满 15 天的老用户会落到 created_at 基线被误清。 前端依赖:Android 端需在首页可见上报 event=show + page=home(携带登录后的 user_id)。 admin「最近活跃」口径变化(去登录、纳入 home_view、created_at 基线):属预期变化,需产品/运营知会;与 DAU(_period_active_user_ids,仍含登录)是两套指标。 清零对 C 端「金币流水」可见(biz_type=inactivity_reset,备注「15天不活跃清零」)。 灰度:先只看预警/清零名单对不对,再开总闸。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #143
140 lines
6.0 KiB
Python
140 lines
6.0 KiB
Python
"""人工验证用:按「金币/现金/邀请」排列组合 + 活跃/新用户对照,造一批账号。
|
|
|
|
用法(仓库根目录,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=show + page=home
|
|
event="show", page="home", 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()
|