feat(welfare): 首页可见活跃信号定名 event=show + page=home(替换 home_view 占位)
前端确定"首页可见"埋点为 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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**)。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user