Files
shaguabijia-app-server/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md
T
guke 130a7dff29 docs(welfare): 15天不活跃清零金币/现金 设计文档(spec) (#144)
修改INACTIVITY_RESET_ENABLED语义,为false时清空金币操作只记录审计日志,不执行操作。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #144
2026-07-19 00:14:03 +08:00

21 KiB
Raw Blame History

15 天不活跃自动清零(金币 + 现金)设计

  • 日期2026-07-16
  • 状态Draft — 待评审
  • 所属app-serverapp/),含一处 admin 侧重构 + 一项 Android 端埋点依赖
  • 一句话:连续 15 天不活跃的用户,自动清零其金币与现金;清零前按可配置节奏预警;全过程留审计以备纠纷排查。

1. 背景与目标

运营需要对长期不活跃用户的钱包余额做清理。两条硬性要求:

  1. 可审计:记录清零原因与清零前的三桶余额,便于后续排查与处理客户纠纷。
  2. 临清预警:在临近清零前推送信息告知用户"因账号不活跃,账户里的 xx 金币和 xx 现金将被清零"。

非目标(本期不做)

  • 不做真实推送通道(极光 JPush / 短信)的对接 —— 仅做可插拔通知器 + 日志占位,接口预留、后续无缝替换。
  • 不改动提现(WithdrawOrder)流程。
  • 不新增 User.last_active_at 列、不改鉴权热路径。

2. 需求

# 需求 落地
R1 连续 15 天不活跃 → 清零金币 + 现金 每日 worker 扫描 + 逐用户事务清零(§6)
R2 记录清零原因 + 清零前余额 inactivity_reset_log 审计表 + 3 条钱包流水(§5、§7)
R3 临清前预警"xx 金币 xx 现金将清零" 阶段 A 预警 + inactivity_notification_log(§6、§7
R4 活跃口径与"用户管理"一致 抽共享模块 activity.pyadmin 与 worker 共用(§4、§12
R5 预警时机完全可配置 INACTIVITY_* 配置项(§8

3. 决策记录(来自评审问答)

决策点 结论 理由
活跃口径 与"用户管理"一致:max(首页可见 show/home, 比价, 领券)不含 last_login_at;无任何信号时以 created_at 为非空基线 比价可从浮窗触发、不进首页;last_login_at 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除
"进首页"信号落地 方案 A:前端上报 home_view 埋点(复用 /analytics/events),非新接口 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度
清零范围 金币 + 折算现金邀请现金不清——产品红线,仅快照入审计) 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 wallet.CoinAccount 注释
预警推送 可插拔通知器 + 日志占位v1),后续接 JPush/短信 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞
预警时机 完全可配置(提前天数列表 + 次数 + 执行点 + 通道) R5
触发方式 进程内每日 worker,仿 daily_exchange_worker 与项目最新模式一致,无需外部 cron
admin 共享口径 共享模块 + 重构 admin 改用它 单一真源,永不漂移(R4

已知取舍(可接受)

  • analytics 的 user_id端上报、未鉴权(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性。活跃时间的非空基线由服务端权威的 User.created_at 提供(见 §4),不再依赖 last_login_at。与"用户管理"口径一致。

4. 活跃口径与共享模块 app/repositories/activity.py(新建)

活跃口径的唯一真源。app 侧模块,admin 可 importapp.main 不 import app.admin,反向允许)。

口径

last_active = max(
    User.created_at,                                         # 注册基线(恒非空;re-login 不推进,只有真实使用才推进)
    max AnalyticsEvent.created_at  WHERE event IN ACTIVE_EVENTS,
    max CouponPromptEngagement.created_at WHERE engage_type == "claim_started",
)
不活跃判定:按北京自然日、0 点对齐(非从末次活跃时刻滚动 15×24h
  last_active_date = 北京(last_active).date()            # 末次活跃的北京日,记为「第 1 日」
  清零边界 = 北京 00:00 of (last_active_date + RESET_DAYS 天) =「第 (RESET_DAYS+1) 日 0 点」  # 15 → 第16日0点
  应清零 ⟺ (cn_today()  last_active_date).days ≥ RESET_DAYS
        ⟺ last_active < cutoff cutoff = 北京 00:00 of (cn_today()  (RESET_DAYS1))   # 供 SQL 比较
  inactive_days = (cn_today()  last_active_date).days   # 清零当日恰 = RESET_DAYS
  例:末次活跃 1/1 → 1/16 00:00(第16日0点)清零,当日 inactive_days=151/15 及之前不清

模块内容

  • 常量:
    • 首页可见活跃信号已定名:event=show + page=home(前端确认,原占位 home_view;下文出现的 home_view 均指此信号)。活跃行为过滤见 activity.active_event_condition():首页可见 比价 real_compare_start 领券 real_coupon_startACTIVE_EVENTS 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。
    • ACTIVE_ENGAGE_TYPE = "claim_started"
  • last_active_subqueries(db) —— 复刻现 admin queries._last_active_parts():两个按 user_idGROUP BY max(created_at) 聚合子查询。
  • last_active_expr(base_col, ev_sub, eng_sub, dialect) —— 生成 greatest/maxPG func.greatestSQLite func.max);子聚合缺失时 coalesce(子聚合, User.created_at) 兜底(注册基线恒非空,替代原 last_login_at)。
  • _norm_utc() —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。
  • reset_cutoff(reset_days) / warn_cutoff(reset_days, k) —— 生成北京 0 点对齐的边界 datetime(见口径):reset_cutoff = 北京 00:00 of (cn_today() (reset_days1)),供下面查询按 last_active < cutoff 比较。
  • select_inactive_users(db, *, cutoff, with_balance=True) —— worker 专用join CoinAccount,筛 last_active < cutoff(cutoff = 北京 0 点对齐边界,见口径)且(coin_balance>0 OR cash_balance_cents>0邀请现金不清、不计入候选),返回 (user, account, last_active, inactive_days)
  • select_warn_targets(db, *, reset_days, warn_days_before) —— worker 专用:返回 (user, account, last_active, inactive_days, stage) 元组——各"提前天数"窗口内、有余额、本 streak 未推过档 stage 的用户(去重结合 notification_log,逻辑见 §9)。

参考现状:现口径散落在 app/admin/repositories/queries.py:38,91-124,199-204_ACTIVE_EVENTS/_last_active_parts/greatest)与 app/admin/repositories/stats.py:51-52,138-146COMPARE_START_EVENT/COUPON_START_EVENT/活跃用户集)。这些改为从 activity.py 导入(§12)。


5. 数据模型(2 张新表,不动 User

两表均登记进 app/models/__init__.py;一个 Alembic 迁移建两表(render_as_batchSQLite 兼容)。

inactivity_reset_log —— 清零审计(R2

仿 app/models/phone_rebind_log.py 的简单审计表风格。

字段 类型 说明
id int PK autoincrement
user_id int, index, not null
coin_balance_before int, not null 清零前金币
cash_balance_cents_before int, not null 清零前折算现金(分)
invite_cash_balance_cents_before int, not null 清零前邀请现金(分)
last_active_at DateTime(tz), nullable 判定时的最近活跃时间
inactive_days int, not null 判定时不活跃天数
reason String(32), not null "inactive_15d"
reset_at DateTime(tz), server_default now(), index, not null 清零时刻

inactivity_notification_log —— 预警记录 + 去重 + 占位 outboxR3

字段 类型 说明
id int PK autoincrement
user_id int, index, not null
stage int, not null 提前天数档(如 7 / 2
inactive_days int, not null 推送时不活跃天数
coin_balance int, not null 推送快照:告知用户的金币数
cash_balance_cents int, not null 推送快照:折算现金
invite_cash_balance_cents int, not null 推送快照:邀请现金
channel String(16), not null "log" / "jpush" / "sms"
status String(16), not null "placeholder" / "sent" / "failed"
created_at DateTime(tz), server_default now(), index, not null 去重锚点(见 §9

备注:不新增 User.last_active_at 列,不改 get_current_user。活跃时间由 §4 口径实时计算


6. 清零 worker app/core/inactivity_reset_worker.py(新建)

完全仿 app/core/daily_exchange_worker.pyApp 启动自带 asyncio 任务,文件锁(data/inactivity_reset.lock)防同机多进程并发。worker 常驻INACTIVITY_RESET_ENABLED 只决定是否真清:false(默认)= 只记审计名单、不动钱(dry-run),true = 真清。

调度

  • INACTIVITY_RESET_CHECK_INTERVAL_SEC 秒醒一次;last_run: date 守卫北京日,保证每日只跑一轮。
  • 仅当 cn_today() != last_run 且当前北京小时 >= INACTIVITY_RESET_RUN_HOUR 时执行(启动补跑同 daily_exchange 语义)。
  • 清零资格边界 = 第 16 日 0 点(北京,见 §4),与 worker 执行点解耦worker 于当日 RUN_HOUR(默认 3 点)跑,把已过边界者一并清;若要严格 0 点触发可置 RUN_HOUR=0,但注意与 daily_auto_exchange 的 0 点任务错峰。
  • lifespan 里 start_inactivity_reset_worker() / stop_...(仿 start_daily_exchange_workerapp/main.py 的接线)。

一轮 run_once(db) 两阶段(同一次运行、各自逐用户独立 commit)

阶段 A — 预警

for user, acc, last_active, inactive_days, stage in activity.select_warn_targets(...):
    notifier.send_inactivity_warning(user, balances=snapshot(acc), stage=stage, days_until_reset=RESET_DAYS-inactive_days)
    db.add(InactivityNotificationLog(..., channel=notifier.channel, status=notifier.last_status))
    db.commit()   # 逐条独立

阶段 B — 清零biz_type="inactivity_reset"

for user, acc, last_active, inactive_days in activity.select_inactive_users(db, cutoff=activity.reset_cutoff(RESET_DAYS)):  # 北京 00:00 of (今天−(RESET_DAYS1))
    try:
        acc = wallet.get_or_create_account(db, user.id, commit=False, lock=True)   # 行锁
        before = (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents)
        if acc.coin_balance == 0 and acc.cash_balance_cents == 0: continue   # 邀请现金不清,不算可清余额
        log = InactivityResetLog(user_id=user.id, coin_balance_before=before[0],
              cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2],  # 邀请现金仅快照
              last_active_at=last_active, inactive_days=inactive_days, reason=f"inactive_{RESET_DAYS}d")
        db.add(log); db.flush()   # 拿 log.id 作 ref_id 交叉链接
        if acc.coin_balance:              wallet.grant_coins(db, user.id, -acc.coin_balance, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
        if acc.cash_balance_cents:        wallet.grant_cash(db, user.id, -acc.cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
        # 邀请现金(invite_cash_balance_cents)不清:产品红线、两本账物理隔离,仅快照记入审计。
        db.commit()
    except SQLAlchemyError:
        db.rollback(); stats["failed"] += 1
  • grant_* 负数出账、balance_after=0、写两条流水(金币 + 折算现金;邀请现金不清);grant_coins 负数total_coin_earned(历史累计保留)。
  • 逐用户独立 commit:一个失败不影响其余。返回 stats = {warned, warn_skipped, warn_failed, scanned, cleared, failed}logger.info预警逐用户 try/except 隔离、且预警整段异常也绝不阻塞清零(清零是不可逆资金操作,不能被通知故障拖住)。

7. 预警与可插拔通知器

app/integrations/notifier.py 定义协议(外部投递属 integrations 层):

class InactivityNotifier(Protocol):
    channel: str          # "log" / "jpush" / "sms"
    last_status: str      # "placeholder" / "sent" / "failed"
    def send_inactivity_warning(self, user, *, balances, stage, days_until_reset) -> None: ...
  • v1 LogNotifierchannel="log"):logger.warning("[inactivity-warn] user=%s coin=%s cash=%s invite=%s T-%s", ...)last_status="placeholder"。参照 heartbeat_monitor_worker 先例("本期先不接推送,用终端打印代替")。
  • 未来 JPushNotifier / SmsNotifier:实现同协议即可替换,worker 不改。
  • 选择:INACTIVITY_NOTIFY_CHANNEL → 工厂返回对应实现(未配到真实实现时回退 LogNotifier)。
  • 预警文案数据来自快照 balances,满足 R3"告知 xx 金币 xx 现金"。

8. 配置项(app/core/config.py

INACTIVITY_RESET_ENABLED       = False    # false(默认)=只记审计名单(dry-run,不动钱);true=真清
INACTIVITY_RESET_DAYS          = 15       # 不活跃阈值(天)
INACTIVITY_WARN_DAYS_BEFORE    = "7,2"    # 清零前几天各推一次;空串=不推。逗号分隔,降序解析
INACTIVITY_RESET_RUN_HOUR      = 3        # 北京时间每日执行点(0-23
INACTIVITY_NOTIFY_CHANNEL      = "log"    # log(占位) / jpush / sms
INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800  # worker 唤醒间隔(可复用现有间隔常量)
  • 清零范围(三桶)固定为常量,不做配置。
  • INACTIVITY_WARN_DAYS_BEFORE 语义(inactive_days 为北京自然日,见 §4):档位 k ⟹ 当 inactive_days >= RESET_DAYS-k< RESET_DAYS 且本 streak 未推过档 k 时预警,即在北京日 last_active_date + (RESET_DAYSk) 触发(漏跑某天时补发最紧急未推档,§9)。
  • INACTIVITY_RESET_RUN_HOUR 只决定 worker 每日执行点,不改变"第 16 日 0 点"这一资格边界(§4/§6)。

9. 幂等与重新活跃

  • 重新活跃自动退出inactive_days 由 §4 口径实时算。用户一有 home_view/比价/领券(登录本身不算),last_active 前移,自动移出预警与清零队列。无需显式"重置标记"。
  • 预警去重inactivity_notification_log 中存在 stage==k 且 created_at > last_active 的行 ⟹ 本 streak 已推过档 k,不重推。用户回归后 last_active 前移,旧预警行自然"失效",开启新 streak。
  • 清零幂等:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。
  • 稳健补发:worker 漏跑数天后,某用户可能同时满足多档;只补发最紧急的未推档(最小 k),避免一次刷屏。

10. 边界与安全

场景 处理
新用户 created_at 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清
在途提现 提现申请时现金已扣入 WithdrawOrder,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常
daily_auto_exchange 并存 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖
时区/日界 统一北京(rewards.cn_today()/CN_TZ);清零/预警按北京自然日 0 点对齐(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 created_at 沿用北京 wall-clock naive
误清防护 worker 常驻默认 dry-runENABLED=false 只记审计名单、不动钱);看准名单再置 true 真清(§13

11. 前端依赖:home_view 埋点(跨仓 — Android

  • Android 端shaguabijia-app-android)需在首页可见onResume/Tab 切入)时,向现有 POST /api/v1/analytics/events 批量上报里加一条 event=<首页可见事件名>(名称明天加埋点时定,暂记 "home_view" 的事件,携带登录后的 user_id
  • 客户端按会话/前台去重即可(服务端只取 max(created_at),多报无害)。
  • 上线顺序依赖home_view 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 created_at 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故开真清(ENABLED=true)必须待 home_view 铺满后再开(§13);dry-run 只记名单不动钱、可先开着看。

12. admin 重构范围与影响(R4

  • app/admin/repositories/queries.py:删本地 _ACTIVE_EVENTS/_last_active_parts(),改用 activity.py 的常量与子查询构造;list_usersgreatest(...) 排序/筛选、_attach_last_active 均改走共享构造器。
  • app/admin/repositories/stats.pyCOMPARE_START_EVENT/COUPON_START_EVENT/活跃用户集(:138-146)改用共享常量与口径。
  • 行为变化(预期内、需产品知会)admin 的"最近活跃 / DAU"口径变化——移除 last_login_at(登录不再计为活跃)、以 created_at 为基线、纳入 home_view。nethome_view 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。
  • 回归底线:现有 admin 用户列表 / stats 测试按新口径更新预期last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。

13. 灰度与上线顺序(安全优先)

  1. 后端先行:合入共享模块 + 两表 + worker + 通知器,INACTIVITY_RESET_ENABLED=False;活跃口径以 created_at 为非空基线、不含 last_login_at
  2. Android 发版:上报 home_view;观察 analytics 覆盖率。
  3. dry-run 灰度(默认即是)INACTIVITY_RESET_ENABLED=False 时 worker 常驻只写审计名单(reason=inactive_Nd_dryrun)、不动钱、不预警;核对名单准确。
  4. 开真清:确认无误后置 INACTIVITY_RESET_ENABLED=True(转为真清 + 预警)。
  5. 收尾/监控:持续观察 home_view 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。

14. 测试计划

  • 活跃口径(共享模块)home_view/比价/领券 各单独命中都算活跃;纯登录不算;无信号用户以 created_at 计;max 取最新;naive/aware 混算不崩。
  • admin 回归:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。
  • 不活跃判定last_active 分别 <15d / =15d / >15d × 有/无余额 的命中矩阵。
  • 清零:三桶归零;inactivity_reset_log 清前值正确;三条流水 biz_type=inactivity_resetbalance_after=0ref_id=log.idtotal_coin_earned 不变。
  • 预警:命中窗口调 notifier + 写 notification_log;同 streak 不重推;回归后 last_active 前移可再次预警;漏跑补发最紧急档。
  • worker:常驻;ENABLED=false 走 dry-run(只记审计名单、不清、不预警);文件锁互斥;逐用户失败隔离(一个抛错不影响其余,failed 计数);重复跑幂等。
  • 配置INACTIVITY_WARN_DAYS_BEFORE 解析(含空串=不推);RESET_DAYS/RUN_HOUR 生效。
  • 沿用 tests/conftest.py(临时 SQLite、RATE_LIMIT_ENABLED=false);外部通知 monkeypatch。

15. 未来工作

  • 接真实 JPushNotifier(需用户级 registration_id 覆盖 + JPush push API/ SmsNotifier
  • 如需 admin 后台可视化:不活跃/预警/清零名单与历史查询接口。
  • 如量级增长导致每日 join 扫描变慢:再考虑物化 last_active_at(当前每日一次可接受)。

附:涉及文件清单

新增

  • app/repositories/activity.py — 活跃口径唯一真源
  • app/models/inactivity_reset_log.py — 审计表
  • app/models/inactivity_notification_log.py — 预警/占位表
  • app/core/inactivity_reset_worker.py — 每日 worker(仿 daily_exchange_worker
  • app/integrations/notifier.py — 通知器协议 + LogNotifier(真实 JPush/短信后续同层扩展)
  • alembic/versions/<...>_add_inactivity_tables.py — 建两表迁移
  • docs/database/inactivity_reset_log.md / inactivity_notification_log.md — 表字典(随实现补)
  • 对应 tests/test_inactivity_reset.py

改动

  • app/models/__init__.py — 注册两模型
  • app/core/config.pyINACTIVITY_* 配置
  • app/main.py — lifespan 接线 start/stop worker
  • app/admin/repositories/queries.pystats.py — 改用 activity.py(§12