diff --git a/alembic/versions/analytics_active_idx_active_composite_index.py b/alembic/versions/analytics_active_idx_active_composite_index.py index 8cdaa5c..9a2b75a 100644 --- a/alembic/versions/analytics_active_idx_active_composite_index.py +++ b/alembic/versions/analytics_active_idx_active_composite_index.py @@ -5,8 +5,10 @@ Revises: 135e79414fd0 Create Date: 2026-07-18 17:35:00.000000 给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at): -activity.active_event_condition 按 (event=show & page=home) ∪ 比价 ∪ 领券 过滤后 -group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。 +activity.active_event_condition 按 event IN (home_visible ∪ 比价 ∪ 领券) 过滤后 +group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频活跃事件全表扫。 +(历史:早期首页可见用 event=show+page=home 组合,故索引含 page 列;现改单一 home_visible、 +不再按 page 过滤 → page 列成冗余,索引仍靠 event 前缀生效;如需更优可后续新迁移瘦成 (event,user_id,created_at)。) ⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从 comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0 diff --git a/app/models/analytics_event.py b/app/models/analytics_event.py index 21f03c5..394f4ff 100644 --- a/app/models/analytics_event.py +++ b/app/models/analytics_event.py @@ -25,8 +25,9 @@ class AnalyticsEvent(Base): __tablename__ = "analytics_event" __table_args__ = ( # 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries): - # 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取 - # max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。 + # 按 event IN (home_visible∪比价∪领券) 过滤,再 group by user_id 取 max(created_at)。 + # 覆盖索引 → 该聚合走 index-only。注:page 列是早期 show+home 组合的遗留,现不再按 page + # 过滤(索引靠 event 前缀仍生效);后续可新迁移瘦成 (event,user_id,created_at)。 Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"), ) diff --git a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md index 98de060..62df6a5 100644 --- a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -38,8 +38,8 @@ | 决策点 | 结论 | 理由 | |---|---|---| -| **活跃口径** | 与"用户管理"一致:`max(首页可见 show/home, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | -| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | +| **活跃口径** | 与"用户管理"一致:`max(首页可见 home_visible, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | +| **"进首页"信号落地** | **方案 A:前端上报 `home_visible` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | | **清零范围** | **金币 + 折算现金**(**邀请现金不清**——产品红线,仅快照入审计) | 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 `wallet.CoinAccount` 注释 | | **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 | | **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 | @@ -76,7 +76,7 @@ last_active = max( ### 模块内容 - 常量: - - **首页可见活跃信号已定名:`event=show` + `page=home`**(前端确认,原占位 `home_view`;下文出现的 `home_view` 均指此信号)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`;`ACTIVE_EVENTS` 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。 + - **首页可见活跃信号已定名:`event=home_visible`**(前端最终确认;曾用过渡期 `show`+`page=home` 组合,已废弃)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 `home_visible` ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`——三者均为纯 event 名,全部收进 `ACTIVE_EVENTS`。 - `ACTIVE_ENGAGE_TYPE = "claim_started"` - `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id` 的 `GROUP BY max(created_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**)。 @@ -210,7 +210,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 ## 9. 幂等与重新活跃 -- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。 +- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_visible`/比价/领券(**登录本身不算**),`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 唤醒间隔(可复用现 | 场景 | 处理 | |---|---| -| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 | +| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_visible/比价/领券 才清 | | 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 | | 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 | | 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive | @@ -229,11 +229,11 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 --- -## 11. 前端依赖:`home_view` 埋点(跨仓 — Android) +## 11. 前端依赖:`home_visible` 埋点(跨仓 — 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_visible`(前端已定名)的事件,**携带登录后的 `user_id`**。 - 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。 -- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_view` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。 +- **上线顺序依赖**:`home_visible` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_visible` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。 --- @@ -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"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net:`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。 -- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。 +- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_visible`**。net:`home_visible` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。 +- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_visible 纳入);非活跃口径部分行为不变。 --- ## 13. 灰度与上线顺序(安全优先) 1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。 -2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。 +2. **Android 发版**:上报 `home_visible`;观察 analytics 覆盖率。 3. **dry-run 灰度(默认即是)**:`INACTIVITY_RESET_ENABLED=False` 时 worker 常驻只写审计名单(`reason=inactive_Nd_dryrun`)、不动钱、不预警;核对名单准确。 4. **开真清**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`(转为真清 + 预警)。 -5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 +5. **收尾/监控**:持续观察 `home_visible` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 --- ## 14. 测试计划 -- **活跃口径(共享模块)**:`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。 -- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。 +- **活跃口径(共享模块)**:`home_visible`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。 +- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_visible)。 - **不活跃判定**:`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` 前移可再次预警;漏跑补发最紧急档。