diff --git a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md new file mode 100644 index 0000000..4045ace --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -0,0 +1,287 @@ +# 15 天不活跃自动清零(金币 + 现金)设计 + +- **日期**:2026-07-16 +- **状态**:Draft — 待评审 +- **所属**:app-server(`app/`),含一处 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.py`,admin 与 worker 共用(§4、§12) | +| R5 | 预警时机完全可配置 | `INACTIVITY_*` 配置项(§8) | + +--- + +## 3. 决策记录(来自评审问答) + +| 决策点 | 结论 | 理由 | +|---|---|---| +| **活跃口径** | 与"用户管理"一致:`max(home_view, 比价, 领券)`(+ `last_login_at` 灰度期兜底) | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录动作更新,代表不了"在用 App" | +| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | +| **清零范围** | **金币 + 折算现金 + 邀请现金** 三桶全清 | 对应"账户里的金币和现金" | +| **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 | +| **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 | +| **触发方式** | **进程内每日 worker**,仿 `daily_exchange_worker` | 与项目最新模式一致,无需外部 cron | +| **admin 共享口径** | 共享模块 + **重构 admin 改用它** | 单一真源,永不漂移(R4) | + +### 已知取舍(可接受) + +- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性;`last_login_at` 作为服务端权威信号仍在 `max()` 中兜底。与"用户管理"口径一致。 + +--- + +## 4. 活跃口径与共享模块 `app/repositories/activity.py`(新建) + +活跃口径的**唯一真源**。app 侧模块,admin 可 import(`app.main` 不 import `app.admin`,反向允许)。 + +### 口径 + +``` +last_active = max( + last_login_at, # 兜底(灰度期保留,见 §13) + max AnalyticsEvent.created_at WHERE event IN ACTIVE_EVENTS, + max CouponPromptEngagement.created_at WHERE engage_type == "claim_started", +) +不活跃 ⟺ last_active < now - INACTIVITY_RESET_DAYS 天 +``` + +### 模块内容 + +- 常量: + - `ACTIVE_EVENTS = ("home_view", "real_compare_start", "real_coupon_start")` —— **新增 `home_view`**(进首页),并入现有比价/领券事件名。 + - `ACTIVE_ENGAGE_TYPE = "claim_started"` +- `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id` 的 `GROUP BY max(created_at)` 聚合子查询。 +- `last_active_expr(login_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`(PG 用 `func.greatest`,SQLite 用 `func.max`),保留现有 `coalesce(..., last_login_at)` 兜底写法。 +- `_norm_utc()` —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。 +- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**:join `CoinAccount`,筛 `last_active < cutoff` 且(`coin_balance>0 OR cash_balance_cents>0 OR invite_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-146`(`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集)。这些改为从 `activity.py` 导入(§12)。 + +--- + +## 5. 数据模型(2 张新表,不动 `User`) + +两表均登记进 `app/models/__init__.py`;一个 Alembic 迁移建两表(`render_as_batch`,SQLite 兼容)。 + +### ① `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` —— 预警记录 + 去重 + 占位 outbox(R3) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `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.py`](../../../app/core/daily_exchange_worker.py)**:App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发,总闸 `INACTIVITY_RESET_ENABLED`(默认关)。 + +### 调度 + +- 每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 秒醒一次;`last_run: date` 守卫**北京日**,保证每日只跑一轮。 +- 仅当 `cn_today() != last_run` 且当前北京小时 `>= INACTIVITY_RESET_RUN_HOUR` 时执行(启动补跑同 daily_exchange 语义)。 +- lifespan 里 `start_inactivity_reset_worker()` / `stop_...`(仿 `start_daily_exchange_worker` 在 `app/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=now-RESET_DAYS): + 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 before == (0,0,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天不活跃清零") + if acc.invite_cash_balance_cents: wallet.grant_invite_cash(db, user.id, -acc.invite_cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零") + db.commit() + except SQLAlchemyError: + db.rollback(); stats["failed"] += 1 +``` + +- `grant_*` 负数出账、`balance_after=0`、写三条流水;`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。 +- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {scanned, warned, cleared, failed}` 并 `logger.info`。 + +--- + +## 7. 预警与可插拔通知器 + +`app/integrations/notifier.py` 定义协议(外部投递属 integrations 层): + +```python +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 `LogNotifier`**(`channel="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 # 总闸,默认关;灰度验证后再开 +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` 语义:档位 `k` ⟹ 当 `inactive_days >= RESET_DAYS-k` 且 `< RESET_DAYS` 且本 streak 未推过档 `k` 时预警(漏跑某天时补发最紧急未推档,§9)。 + +--- + +## 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. 边界与安全 + +| 场景 | 处理 | +|---|---| +| 新用户 | 注册即登录(`last_login_at` 非空)→ 初始活跃;15 天不回归才清 | +| 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 | +| 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 | +| 时区 | 统一北京(`rewards.cn_today()`/`CN_TZ`);流水 `created_at` 沿用北京 wall-clock naive | +| 误清防护 | 总闸默认关;先灰度只看预警/清零**名单**对不对,再开清零(§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` 全量覆盖前,"进首页"信号缺失,仅比价/领券/`last_login_at` 兜底生效 —— 这是 §13 灰度顺序与保留 `last_login_at` 兜底的直接原因。 + +--- + +## 12. admin 重构范围与影响(R4) + +- `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users` 的 `greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。 +- `app/admin/repositories/stats.py`:`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。 +- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"因新增 `home_view` 会**更准**(真正把"开首页"算进活跃),历史数字口径随之变化。 +- **回归底线**:现有 admin 用户列表 / stats 测试必须保持通过(除 `home_view` 带来的预期口径变化外行为不变)。 + +--- + +## 13. 灰度与上线顺序(安全优先) + +1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`,`last_login_at` 兜底**保留**。 +2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。 +3. **只读灰度**:临时开一个"dry-run/只出名单不清零"路径或看阶段 A 预警日志,核对预警/清零**名单**准确。 +4. **开清零**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`。 +5. **收尾(可选)**:`home_view` 覆盖稳定后,若产品要求严格排除登录信号,再从口径移除 `last_login_at` 兜底。 + +--- + +## 14. 测试计划 + +- **活跃口径(共享模块)**:`home_view`/比价/领券/登录各单独命中都算活跃;`max` 取最新;naive/aware 混算不崩。 +- **admin 回归**:用户列表 / stats 除 `home_view` 预期变化外行为不变。 +- **不活跃判定**:`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。 +- **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset`、`balance_after=0`、`ref_id=log.id`;`total_coin_earned` 不变。 +- **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。 +- **worker**:总闸关不启动;文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`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.py` — `INACTIVITY_*` 配置 +- `app/main.py` — lifespan 接线 start/stop worker +- `app/admin/repositories/queries.py`、`stats.py` — 改用 `activity.py`(§12)