docs(welfare): 活跃口径移除 last_login_at 改用 created_at 基线;home_view 事件名待定
按评审结论定稿: - ① 彻底移除 last_login_at:活跃口径 = max(home_view, 比价, 领券);登录/re-login 不再计为活跃 - 连带:用 User.created_at 接替"恒非空基线"位(注册即第1日活跃,只有真实使用才推进)。 无任何信号的用户 = 注册满 15 天即清,新用户有 15 天宽限 - ② home_view 事件名待前端明天加埋点时定,后端以 HOME_VIEW_EVENT 常量占位 - 连带影响:共享口径 admin 侧同步(移除 last_login_at + created_at 基线 + 纳入 home_view), admin 最近活跃/DAU 口径随之变化,回归测试按新口径更新预期 - 同步 §3/§4/§9/§10/§11/§12/§13/§14 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
|
||||
| 决策点 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| **活跃口径** | 与"用户管理"一致:`max(home_view, 比价, 领券)`(+ `last_login_at` 灰度期兜底) | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录动作更新,代表不了"在用 App" |
|
||||
| **活跃口径** | 与"用户管理"一致:`max(home_view, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 |
|
||||
| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 |
|
||||
| **清零范围** | **金币 + 折算现金 + 邀请现金** 三桶全清 | 对应"账户里的金币和现金" |
|
||||
| **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 |
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
### 已知取舍(可接受)
|
||||
|
||||
- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性;`last_login_at` 作为服务端权威信号仍在 `max()` 中兜底。与"用户管理"口径一致。
|
||||
- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性。活跃时间的非空基线由服务端权威的 `User.created_at` 提供(见 §4),不再依赖 `last_login_at`。与"用户管理"口径一致。
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
```
|
||||
last_active = max(
|
||||
last_login_at, # 兜底(灰度期保留,见 §13)
|
||||
User.created_at, # 注册基线(恒非空;re-login 不推进,只有真实使用才推进)
|
||||
max AnalyticsEvent.created_at WHERE event IN ACTIVE_EVENTS,
|
||||
max CouponPromptEngagement.created_at WHERE engage_type == "claim_started",
|
||||
)
|
||||
@@ -76,10 +76,10 @@ last_active = max(
|
||||
### 模块内容
|
||||
|
||||
- 常量:
|
||||
- `ACTIVE_EVENTS = ("home_view", "real_compare_start", "real_coupon_start")` —— **新增 `home_view`**(进首页),并入现有比价/领券事件名。
|
||||
- `ACTIVE_EVENTS = (HOME_VIEW_EVENT, "real_compare_start", "real_coupon_start")` —— 进首页 + 比价 + 领券。**`HOME_VIEW_EVENT` 事件名待前端确认**(明天加埋点时定;暂记 `"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)` 兜底写法。
|
||||
- `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`(PG `func.greatest`/SQLite `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_days−1))`,供下面查询按 `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 OR invite_cash_balance_cents>0`),返回 `(user, account, last_active, inactive_days)`。
|
||||
@@ -210,7 +210,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现
|
||||
|
||||
## 9. 幂等与重新活跃
|
||||
|
||||
- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券/登录,`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。
|
||||
- **重新活跃自动退出**:`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`),避免一次刷屏。
|
||||
@@ -221,7 +221,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| 新用户 | 注册即登录(`last_login_at` 非空)→ 初始活跃;15 天不回归才清 |
|
||||
| 新用户 | `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 |
|
||||
@@ -231,9 +231,9 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现
|
||||
|
||||
## 11. 前端依赖:`home_view` 埋点(跨仓 — Android)
|
||||
|
||||
- **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event="home_view"` 的事件,**携带登录后的 `user_id`**。
|
||||
- **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` 兜底的直接原因。
|
||||
- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故清零总闸必须待 `home_view` 铺满后再开(§13)。
|
||||
|
||||
---
|
||||
|
||||
@@ -241,25 +241,25 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现
|
||||
|
||||
- `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` 带来的预期口径变化外行为不变)。
|
||||
- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net:`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。
|
||||
- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。
|
||||
|
||||
---
|
||||
|
||||
## 13. 灰度与上线顺序(安全优先)
|
||||
|
||||
1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`,`last_login_at` 兜底**保留**。
|
||||
1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。
|
||||
2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。
|
||||
3. **只读灰度**:临时开一个"dry-run/只出名单不清零"路径或看阶段 A 预警日志,核对预警/清零**名单**准确。
|
||||
4. **开清零**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`。
|
||||
5. **收尾(可选)**:`home_view` 覆盖稳定后,若产品要求严格排除登录信号,再从口径移除 `last_login_at` 兜底。
|
||||
5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。
|
||||
|
||||
---
|
||||
|
||||
## 14. 测试计划
|
||||
|
||||
- **活跃口径(共享模块)**:`home_view`/比价/领券/登录各单独命中都算活跃;`max` 取最新;naive/aware 混算不崩。
|
||||
- **admin 回归**:用户列表 / stats 除 `home_view` 预期变化外行为不变。
|
||||
- **活跃口径(共享模块)**:`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_reset`、`balance_after=0`、`ref_id=log.id`;`total_coin_earned` 不变。
|
||||
- **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。
|
||||
|
||||
Reference in New Issue
Block a user