From 3d84a7c63454e3c43e867efa4716c8f6e62216cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E4=B8=96=E7=9D=BF?= <2839904623@qq.com> Date: Sun, 5 Jul 2026 15:02:06 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E7=99=BB=E5=BD=95=E6=94=B9=E4=B8=BA=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E6=B4=BB=E8=B7=83,=E6=8C=89=E7=99=BB=E5=BD=95=E3=80=81?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=E6=AF=94=E4=BB=B7=E3=80=81=E5=8F=91=E8=B5=B7?= =?UTF-8?q?=E9=A2=86=E5=88=B8=E5=8F=96=E6=9C=80=E8=BF=91=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/repositories/queries.py | 120 +++++++++++++++++++++++++++++- app/admin/routers/users.py | 8 +- app/admin/schemas/user.py | 3 + 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index ac2be15..7c9f752 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo from sqlalchemy import Select, asc, case, desc, func, or_, select from sqlalchemy.orm import Session +from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT from app.core import rewards from app.core.config import settings from app.models.ad_feed_reward import AdFeedRewardRecord @@ -18,6 +19,7 @@ from app.models.ad_reward import AdRewardRecord from app.models.admin import AdminAuditLog from app.models.analytics_event import AnalyticsEvent from app.models.comparison import ComparisonRecord +from app.models.coupon_state import CouponPromptEngagement from app.models.device import DeviceLiveness from app.models.feedback import Feedback from app.models.onboarding import OnboardingCompletion @@ -25,6 +27,9 @@ from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder +# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券) +_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) + # 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取") _NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct") @@ -76,6 +81,86 @@ def offset_paginate( return items, next_cursor, total +def _last_active_parts(): + """「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。 + + 活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at + + 发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。 + 用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan + (排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口; + 预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。 + """ + ev_agg = ( + 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), + ) + .group_by(AnalyticsEvent.user_id) + .subquery() + ) + eng_agg = ( + 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 == "claim_started", + ) + .group_by(CouponPromptEngagement.user_id) + .subquery() + ) + return ev_agg, eng_agg + + +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 _attach_last_active(db: Session, users: list[User]) -> None: + """给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。 + + 口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。 + """ + uids = [u.id for u in users] + if not uids: + return + ev_map = dict( + db.execute( + select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at)) + .where( + AnalyticsEvent.user_id.in_(uids), + AnalyticsEvent.event.in_(_ACTIVE_EVENTS), + ) + .group_by(AnalyticsEvent.user_id) + ).all() + ) + eng_map = dict( + db.execute( + select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at)) + .where( + CouponPromptEngagement.user_id.in_(uids), + CouponPromptEngagement.engage_type == "claim_started", + ) + .group_by(CouponPromptEngagement.user_id) + ).all() + ) + for u in users: + candidates = [ + _norm_utc(u.last_login_at), + _norm_utc(ev_map.get(u.id)), + _norm_utc(eng_map.get(u.id)), + ] + u.last_active_at = max((c for c in candidates if c is not None), default=None) + + def list_users( db: Session, *, @@ -87,16 +172,34 @@ def list_users( created_to: datetime | None = None, last_login_from: datetime | None = None, last_login_to: datetime | None = None, + last_active_from: datetime | None = None, + last_active_to: datetime | None = None, sort_by: str = "id", sort_order: str = "desc", limit: int = 20, cursor: int | None = None, ) -> tuple[list[User], int | None, int]: - """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选, - 按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一, + """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃 + 时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at + (口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一, 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" - stmt = select(User) + # 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max() + # 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底 + # (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数, + # offset_paginate 的 count 不受影响。 + ev_agg, eng_agg = _last_active_parts() + greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max + last_active = greatest( + User.last_login_at, + func.coalesce(ev_agg.c.last_at, User.last_login_at), + func.coalesce(eng_agg.c.last_at, User.last_login_at), + ) + stmt = ( + select(User) + .outerjoin(ev_agg, ev_agg.c.user_id == User.id) + .outerjoin(eng_agg, eng_agg.c.user_id == User.id) + ) if phone: stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 if register_channel: @@ -113,16 +216,25 @@ def list_users( stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from)) if last_login_to is not None: stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to)) + if last_active_from is not None: + stmt = stmt.where(last_active >= _as_utc(last_active_from)) + if last_active_to is not None: + stmt = stmt.where(last_active <= _as_utc(last_active_to)) sort_cols = { "id": User.id, "created_at": User.created_at, "last_login_at": User.last_login_at, + "last_active_at": last_active, } sort_col = sort_cols.get(sort_by, User.id) order_fn = asc if sort_order == "asc" else desc id_order = asc(User.id) if sort_order == "asc" else desc(User.id) - return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) + items, next_cursor, total = offset_paginate( + db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor + ) + _attach_last_active(db, items) + return items, next_cursor, total def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None: diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 7e4a81b..fce483a 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -42,7 +42,12 @@ def list_users( created_to: Annotated[datetime | None, Query()] = None, last_login_from: Annotated[datetime | None, Query()] = None, last_login_to: Annotated[datetime | None, Query()] = None, - sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id", + # 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序 + last_active_from: Annotated[datetime | None, Query()] = None, + last_active_to: Annotated[datetime | None, Query()] = None, + sort_by: Annotated[ + str, Query(pattern="^(id|created_at|last_login_at|last_active_at)$") + ] = "id", sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, @@ -51,6 +56,7 @@ def list_users( db, phone=phone, register_channel=register_channel, status=status, nickname=nickname, created_from=created_from, created_to=created_to, last_login_from=last_login_from, last_login_to=last_login_to, + last_active_from=last_active_from, last_active_to=last_active_to, sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor, ) return CursorPage( diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 2e554a8..7d673a6 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -20,6 +20,9 @@ class AdminUserListItem(BaseModel): wechat_nickname: str | None = None created_at: datetime last_login_at: datetime + # 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active + # 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。 + last_active_at: datetime | None = None class AdminUserOverview(BaseModel):