From b4fe20aacad189fcbd6d0772f7f9703cf8f80365 Mon Sep 17 00:00:00 2001 From: guke Date: Sat, 18 Jul 2026 18:04:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(welfare):=20=E9=A6=96=E9=A1=B5=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=B4=BB=E8=B7=83=E4=BF=A1=E5=8F=B7=E5=AE=9A=E5=90=8D?= =?UTF-8?q?=20event=3Dshow=20+=20page=3Dhome(=E6=9B=BF=E6=8D=A2=20home=5Fv?= =?UTF-8?q?iew=20=E5=8D=A0=E4=BD=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端确定"首页可见"埋点为 event=show + page=home(组合,非单一 event 名),故活跃口径 不能只改一个常量: - activity.active_event_condition():首页可见(event=show & page=home)∪ 比价 ∪ 领券, worker 子查询(last_active_subqueries)与 admin _attach_last_active 共用(单一真源); ACTIVE_EVENTS 收窄为仅两个纯 event 名,HOME_VIEW_EVENT="show" + 新增 HOME_VIEW_PAGE="home" - 测试:新增 show/home 算活跃、show/其他页不算的用例;_add_event 支持 page;口径常量断言更新 - seed 脚本 / spec / plan 同步 home_view → show+page=home Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/repositories/queries.py | 2 +- app/repositories/activity.py | 30 ++++++++++---- .../plans/2026-07-16-inactivity-reset.md | 2 +- .../2026-07-16-inactivity-reset-design.md | 4 +- scripts/seed_inactivity_cases.py | 4 +- tests/test_inactivity_reset.py | 41 +++++++++++++++++-- 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 0426b3e..c9e0b74 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -97,7 +97,7 @@ def _attach_last_active(db: Session, users: list[User]) -> None: select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at)) .where( AnalyticsEvent.user_id.in_(uids), - AnalyticsEvent.event.in_(activity.ACTIVE_EVENTS), + activity.active_event_condition(), ) .group_by(AnalyticsEvent.user_id) ).all() diff --git a/app/repositories/activity.py b/app/repositories/activity.py index db9cff7..f3e83b1 100644 --- a/app/repositories/activity.py +++ b/app/repositories/activity.py @@ -1,6 +1,6 @@ """活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。 -口径 = max(User.created_at, AnalyticsEvent[ACTIVE_EVENTS], CouponPromptEngagement[claim_started])。 +口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])。 **不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。 清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。 """ @@ -8,22 +8,34 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone -from sqlalchemy import func, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session from app.core.rewards import CN_TZ, cn_today from app.models.analytics_event import AnalyticsEvent from app.models.coupon_state import CouponPromptEngagement -# —— 活跃事件名(与"用户管理"口径一致)—— -# home_view:进首页(前端埋点,名称前端明天敲定,此处占位;定名后仅改这一常量)。 -HOME_VIEW_EVENT = "home_view" +# —— 活跃口径事件(与"用户管理"口径一致)—— +# 首页可见:前端埋点 event=show + page=home(组合判定,单个 event 名不足以区分,见 +# active_event_condition);其余为纯 event 名。 +HOME_VIEW_EVENT = "show" +HOME_VIEW_PAGE = "home" COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发) COUPON_START_EVENT = "real_coupon_start" # 发起领券 -ACTIVE_EVENTS = (HOME_VIEW_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT) +# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列) +ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取 +def active_event_condition(): + """analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home) + ∪ 发起比价 ∪ 发起领券。worker 子查询与 admin 展示共用,单一真源。""" + return or_( + and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE), + AnalyticsEvent.event.in_(ACTIVE_EVENTS), + ) + + def as_utc(value: datetime) -> datetime: """任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较, 比较绝对时刻、与会话时区无关(口径同 admin queries._as_utc)。""" @@ -52,14 +64,14 @@ def reset_cutoff(reset_days: int, today: date | None = None) -> datetime: def last_active_subqueries(db: Session): - """两个按 user_id 预聚合的派生表:最近活跃事件(ACTIVE_EVENTS)、最近领券发起(claim_started)。 - 返回 (ev_sub, eng_sub)。口径同 admin,LEFT JOIN 用,借事件索引只扫活跃事件。""" + """两个按 user_id 预聚合的派生表:最近活跃事件(见 active_event_condition)、 + 最近领券发起(claim_started)。返回 (ev_sub, eng_sub)。口径同 admin,LEFT JOIN 用。""" ev_sub = ( select( AnalyticsEvent.user_id.label("user_id"), func.max(AnalyticsEvent.created_at).label("last_at"), ) - .where(AnalyticsEvent.user_id.is_not(None), AnalyticsEvent.event.in_(ACTIVE_EVENTS)) + .where(AnalyticsEvent.user_id.is_not(None), active_event_condition()) .group_by(AnalyticsEvent.user_id) .subquery() ) diff --git a/docs/superpowers/plans/2026-07-16-inactivity-reset.md b/docs/superpowers/plans/2026-07-16-inactivity-reset.md index e715de9..94fcddd 100644 --- a/docs/superpowers/plans/2026-07-16-inactivity-reset.md +++ b/docs/superpowers/plans/2026-07-16-inactivity-reset.md @@ -4,7 +4,7 @@ **Goal:** 连续 15 天不活跃(北京自然日、第 16 日 0 点对齐)的用户,自动清零金币+折算现金(**邀请现金不清**——产品红线),清零前按可配置节奏预警,全程留审计。 -> **更新(2026-07-18):** 邀请现金(invite_cash_balance_cents)由"三桶全清"改为**不清**——遵循 wallet.CoinAccount 的"物理隔离/产品红线"、与另一实现分支对齐。**以 `inactivity.clear_user` 现码为准**;下方 Task 7 的历史代码片段仍写三桶归零,阅读时以现码为准。 +> **更新(2026-07-18):** 邀请现金(invite_cash_balance_cents)由"三桶全清"改为**不清**——遵循 wallet.CoinAccount 的"物理隔离/产品红线"、与另一实现分支对齐。**以 `inactivity.clear_user` 现码为准**;下方 Task 7 的历史代码片段仍写三桶归零,阅读时以现码为准。另:**首页可见事件已定名 `event=show` + `page=home`**(原占位 `home_view`),见 `activity.active_event_condition`;下文/片段的 `home_view` 均指此信号。 **Architecture:** 活跃口径抽成共享模块 `app/repositories/activity.py`(worker 与 admin 共用,单一真源);业务逻辑在 `app/repositories/inactivity.py`(纯同步、可单测);进程内每日 asyncio worker `app/core/inactivity_reset_worker.py`(仿 `daily_exchange_worker`,文件锁 + 北京日守卫 + 总闸)。预警走可插拔 `InactivityNotifier`,v1 为日志占位。审计双写:`inactivity_reset_log` 专表 + 钱包流水 `biz_type=inactivity_reset`。 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 91cef0c..3df388d 100644 --- a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -38,7 +38,7 @@ | 决策点 | 结论 | 理由 | |---|---|---| -| **活跃口径** | 与"用户管理"一致:`max(home_view, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | +| **活跃口径** | 与"用户管理"一致:`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/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 | @@ -76,7 +76,7 @@ last_active = max( ### 模块内容 - 常量: - - `ACTIVE_EVENTS = (HOME_VIEW_EVENT, "real_compare_start", "real_coupon_start")` —— 进首页 + 比价 + 领券。**`HOME_VIEW_EVENT` 事件名待前端确认**(明天加埋点时定;暂记 `"home_view"`)。 + - **首页可见活跃信号已定名:`event=show` + `page=home`**(前端确认,原占位 `home_view`;下文出现的 `home_view` 均指此信号)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`;`ACTIVE_EVENTS` 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。 - `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**)。 diff --git a/scripts/seed_inactivity_cases.py b/scripts/seed_inactivity_cases.py index e66e7c6..a1293d6 100644 --- a/scripts/seed_inactivity_cases.py +++ b/scripts/seed_inactivity_cases.py @@ -86,8 +86,8 @@ def seed(db) -> None: 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="home_view", device_id=MARK, user_id=u.id, + 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() diff --git a/tests/test_inactivity_reset.py b/tests/test_inactivity_reset.py index d25a4b2..6860d2c 100644 --- a/tests/test_inactivity_reset.py +++ b/tests/test_inactivity_reset.py @@ -42,7 +42,9 @@ def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None: def test_active_event_constants() -> None: - assert activity.HOME_VIEW_EVENT in activity.ACTIVE_EVENTS + # 首页可见 = event=show + page=home 组合,不在纯 event 名集合里 + assert activity.HOME_VIEW_EVENT == "show" and activity.HOME_VIEW_PAGE == "home" + assert activity.HOME_VIEW_EVENT not in activity.ACTIVE_EVENTS assert "real_compare_start" in activity.ACTIVE_EVENTS assert "real_coupon_start" in activity.ACTIVE_EVENTS assert activity.ACTIVE_ENGAGE_TYPE == "claim_started" @@ -99,8 +101,9 @@ def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int: return u.id -def _add_event(db, user_id, event, when: datetime) -> None: - db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0, created_at=when)) +def _add_event(db, user_id, event, when: datetime, page=None) -> None: + db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0, + created_at=when, page=page)) def _add_engage(db, user_id, when: datetime, engage_type="claim_started") -> None: @@ -130,6 +133,36 @@ def test_last_active_expr_takes_max_of_baseline_and_events() -> None: db.close() +def test_home_signal_uses_show_event_on_home_page() -> None: + """首页可见活跃口径 = event=show + page=home 组合;show 但非 home 页不算活跃。""" + from sqlalchemy import select + db = SessionLocal() + try: + base = datetime(2026, 1, 1, tzinfo=timezone.utc) + seen = _new_user(db, created_at=base) # show/home → 活跃 + _add_event(db, seen, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="home") + other = _new_user(db, created_at=base) # show/其他页 → 不算活跃 + _add_event(db, other, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="coupon") + db.commit() + + ev_sub, eng_sub = activity.last_active_subqueries(db) + dialect = db.get_bind().dialect.name + expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) + + def last_active(uid): + stmt = (select(expr).select_from(User) + .outerjoin(ev_sub, ev_sub.c.user_id == User.id) + .outerjoin(eng_sub, eng_sub.c.user_id == User.id) + .where(User.id == uid)) + return activity.norm_utc(db.execute(stmt).scalar_one()) + + assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc) # show/home 算 + assert last_active(other) == base # show/其他页 不算 + finally: + db.rollback() + db.close() + + def test_inactivity_warn_stages_parsing() -> None: from app.core.config import Settings s = Settings(INACTIVITY_WARN_DAYS_BEFORE="7,2", INACTIVITY_RESET_DAYS=15) @@ -163,7 +196,7 @@ def test_run_reset_clears_coin_and_cash_but_preserves_invite_cash() -> None: coin=100, cash=200, invite=300) # 活跃用户:昨天有 home_view → 不清 fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50) - _add_event(db, fresh, "home_view", datetime(2026, 1, 31, tzinfo=timezone.utc)) + _add_event(db, fresh, "show", datetime(2026, 1, 31, tzinfo=timezone.utc), page="home") db.commit() stats = inactivity.run_reset_once(db, reset_days=15, today=today)