a86688ccfb
背景 / 目标 连续 15 天(北京自然日)没有「首页可见 / 发起比价 / 发起领券」行为的用户判定为流失,每日自动清零其金币 + 折算现金;清零前按可配置节奏预警;全程留审计。纯登录不算活跃;邀请奖励金物理隔离、不清(产品红线)。 改了什么 活跃口径共享模块 app/repositories/activity.py:worker(清零/预警)与 admin(最近活跃)单一真源,防漂移。 清零业务逻辑 inactivity.py:选取 / 逐用户清零(行锁 + 幂等)/ 预警分档 + streak 去重 / run_once 组合,预警故障逐用户隔离、绝不阻塞清零。 每日 worker inactivity_reset_worker.py(仿 daily_exchange:文件锁 + 北京日守卫 + RUN_HOUR 门槛 + 总闸)+ main.py lifespan 接线。 可插拔通知器 notifier.py(v1 LogNotifier 日志占位,预留 JPush/短信)。 配置 INACTIVITY_*(阈值 / 预警档 / 执行点 / 通道 / 开关)。 admin 最近活跃口径改用共享模块(移除 last_login_at、纳入 show/home、以 created_at 为基线)。 审计:inactivity_reset_log + inactivity_notification_log 两表 + 钱包流水双写(biz_type=inactivity_reset,ref_id 交叉)。 文档:设计 spec / 实现 plan / docs/database/ 两表字典。 关键产品决策 邀请金不清:只清金币 + 折算现金,invite_cash_balance_cents 原封(仅快照入审计)。 活跃口径 = max(created_at, 首页可见, 比价, 领券),不含 last_login_at;首页可见 = event=show + page=home。 时间边界:北京自然日 0 点对齐(见 activity.reset_cutoff)。 总闸默认关,灰度验证后再开。 数据库变更 新表:inactivity_reset_log、inactivity_notification_log。 analytics_event 新增复合覆盖索引 ix_analytics_event_active (event, page, user_id, created_at)(活跃口径聚合热点)。 修复了 base 上的迁移多头(135e79414fd0 与 phone_rebind_log 同从 comparison_llm_cost 分叉)→ 加空 merge 修订,alembic upgrade head 恢复单头正常。 ⚠️ 上线注意(合并后 / 开总闸前) INACTIVITY_RESET_ENABLED 默认 false;开启前提 = show/home 埋点全量铺满——否则"只登录不操作"且注册满 15 天的老用户会落到 created_at 基线被误清。 前端依赖:Android 端需在首页可见上报 event=show + page=home(携带登录后的 user_id)。 admin「最近活跃」口径变化(去登录、纳入 home_view、created_at 基线):属预期变化,需产品/运营知会;与 DAU(_period_active_user_ids,仍含登录)是两套指标。 清零对 C 端「金币流水」可见(biz_type=inactivity_reset,备注「15天不活跃清零」)。 灰度:先只看预警/清零名单对不对,再开总闸。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #143
102 lines
4.3 KiB
Python
102 lines
4.3 KiB
Python
"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。
|
||
|
||
口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])。
|
||
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。
|
||
清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, timedelta, timezone
|
||
|
||
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
|
||
|
||
# —— 活跃口径事件(与"用户管理"口径一致)——
|
||
# 首页可见:前端埋点 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" # 发起领券
|
||
# 纯 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)。"""
|
||
if value.tzinfo is None:
|
||
return value.replace(tzinfo=timezone.utc)
|
||
return value.astimezone(timezone.utc)
|
||
|
||
|
||
def norm_utc(dt: datetime | None) -> datetime | None:
|
||
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
|
||
if dt is None:
|
||
return None
|
||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||
|
||
|
||
def cn_midnight_utc(d: date) -> datetime:
|
||
"""北京 d 日 00:00 → tz-aware UTC datetime。"""
|
||
return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ))
|
||
|
||
|
||
def reset_cutoff(reset_days: int, today: date | None = None) -> datetime:
|
||
"""应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。
|
||
= 北京 00:00 of (today − (reset_days − 1))。例:reset_days=15、today=1/20 → 北京 1/6 00:00。"""
|
||
today = today or cn_today()
|
||
return cn_midnight_utc(today - timedelta(days=reset_days - 1))
|
||
|
||
|
||
def last_active_subqueries(db: Session):
|
||
"""两个按 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), active_event_condition())
|
||
.group_by(AnalyticsEvent.user_id)
|
||
.subquery()
|
||
)
|
||
eng_sub = (
|
||
select(
|
||
CouponPromptEngagement.user_id.label("user_id"),
|
||
func.max(CouponPromptEngagement.created_at).label("last_at"),
|
||
)
|
||
.where(
|
||
CouponPromptEngagement.user_id.is_not(None),
|
||
CouponPromptEngagement.engage_type == ACTIVE_ENGAGE_TYPE,
|
||
)
|
||
.group_by(CouponPromptEngagement.user_id)
|
||
.subquery()
|
||
)
|
||
return ev_sub, eng_sub
|
||
|
||
|
||
def last_active_expr(base_col, ev_sub, eng_sub, dialect: str):
|
||
"""max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。
|
||
子聚合缺失(未命中)时 coalesce 到 base_col(= User.created_at,恒非空基线)。"""
|
||
greatest = func.greatest if dialect == "postgresql" else func.max
|
||
return greatest(
|
||
base_col,
|
||
func.coalesce(ev_sub.c.last_at, base_col),
|
||
func.coalesce(eng_sub.c.last_at, base_col),
|
||
)
|