diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index c0d8c94..b1e2162 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -385,6 +385,27 @@ def ad_revenue_report( for k, v in type_map.items() } + # 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events—— + # 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算, + # 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0; + # 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 / + # 旧数据)不计入任何场景桶。 + scene_map: dict[str, dict] = {} + for e in events: + sc = e.get("feed_scene") + if not sc: + continue + s = scene_map.get(sc) + if s is None: + s = {"impressions": 0, "revenue_yuan": 0.0} + scene_map[sc] = s + s["impressions"] += e["impressions"] + s["revenue_yuan"] += e["revenue_yuan"] + scene_stats = { + k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)} + for k, v in scene_map.items() + } + # DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间 # 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径, # 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。 @@ -418,6 +439,7 @@ def ad_revenue_report( "daily": daily, "hourly": hourly, "type_stats": type_stats, + "scene_stats": scene_stats, "dau": dau, "items": main_rows[offset:offset + limit], } diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index b678c38..419c62f 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -51,7 +51,27 @@ def _yuan_to_cents(v: object) -> int | None: def _ts_to_dt(ts: object) -> datetime | None: """秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。""" - if not ts: + if ts is None: + return None + if isinstance(ts, datetime): + return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc) + s = str(ts).strip() + if not s or s.lower() == "null": + return None + try: + seconds = float(Decimal(s)) + except (InvalidOperation, ValueError): + return None + if seconds == 0: + return None + # 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。 + if abs(seconds) > 10_000_000_000_000: + seconds /= 1_000_000 + elif abs(seconds) > 10_000_000_000: + seconds /= 1_000 + try: + return datetime.fromtimestamp(seconds, tz=timezone.utc) + except (OverflowError, OSError, ValueError): return None @@ -83,10 +103,6 @@ def _pick(row: dict[str, Any], *keys: str) -> Any: if key in row and row[key] is not None: return row[key] return None - try: - return datetime.fromtimestamp(int(ts), tz=timezone.utc) - except (ValueError, OSError, TypeError): - return None # ───────────── 群 ───────────── 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/repositories/stats.py b/app/admin/repositories/stats.py index f799826..42e7998 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -5,17 +5,23 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索 """ from __future__ import annotations +from collections import Counter from datetime import date, datetime, time, timedelta, timezone from decimal import Decimal, InvalidOperation -from sqlalchemy import func, select +from sqlalchemy import case, func, select from sqlalchemy.orm import Session +from app.admin.repositories.coupon_data import _percentile from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.models.analytics_event import AnalyticsEvent from app.models.comparison import ComparisonRecord -from app.models.coupon_state import CouponPromptEngagement +from app.models.coupon_state import ( + CouponClaimRecord, + CouponPromptEngagement, + CouponSession, +) from app.models.cps_order import CpsOrder from app.models.feedback import Feedback from app.models.savings import SavingsRecord @@ -299,12 +305,12 @@ def dashboard_overview( period_from=period_from, period_to=period_to, ) - period_retained_new_user_ids = period_new_user_ids & period_active_user_ids - period_retention_rate = ( - round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4) - if period_new_user_ids - else None - ) + # 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户, + # 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即 + # 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义 + # (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。 + retention_cohort_total = 0 + retention_retained_total = 0 trend_points: list[dict] = [] for cur_date in _date_range(period_from, period_to): day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds( @@ -314,18 +320,26 @@ def dashboard_overview( ComparisonRecord.created_at >= day_start_local, ComparisonRecord.created_at < day_end_local, ) + daily_active_user_ids = _period_active_user_ids( + db, + start_utc=day_start_utc, + end_utc=day_end_utc, + period_from=cur_date, + period_to=cur_date, + ) + # 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。 + cohort_ids = _user_id_set( + select(User.id).where( + User.created_at >= day_start_utc - timedelta(days=1), + User.created_at < day_end_utc - timedelta(days=1), + ) + ) + retention_cohort_total += len(cohort_ids) + retention_retained_total += len(cohort_ids & daily_active_user_ids) trend_points.append( { "date": cur_date, - "active_users": len( - _period_active_user_ids( - db, - start_utc=day_start_utc, - end_utc=day_end_utc, - period_from=cur_date, - period_to=cur_date, - ) - ), + "active_users": len(daily_active_user_ids), "new_users": _count( User, User.created_at >= day_start_utc, @@ -334,6 +348,11 @@ def dashboard_overview( "comparisons": _count(ComparisonRecord, *daily_comparison_conds), } ) + period_retention_rate = ( + round(retention_retained_total / retention_cohort_total, 4) + if retention_cohort_total + else None + ) period_coin_conds = ( CoinTransaction.created_at >= start_local, @@ -417,6 +436,98 @@ def dashboard_overview( else None ) + # ===== 领券核心数据(2026-07-05 产品新增)===== + # 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record + # (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联—— + # record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按 + # (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。 + period_coupon_sessions = db.execute( + select( + CouponSession.device_id, + CouponSession.started_date, + CouponSession.status, + CouponSession.elapsed_ms, + ).where( + CouponSession.started_date >= period_from, + CouponSession.started_date <= period_to, + # 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台; + # 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经 + # 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。 + CouponSession.app_env == "prod", + ) + ).all() + coupon_started = len(period_coupon_sessions) + coupon_completed_elapsed = sorted( + s.elapsed_ms + for s in period_coupon_sessions + if s.status == "completed" and s.elapsed_ms is not None + ) + # 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致 + # (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。 + point_buckets: dict[tuple[str, date], tuple[int, int]] = { + (dev, d): (int(total), int(succ or 0)) + for dev, d, total, succ in db.execute( + select( + CouponClaimRecord.device_id, + CouponClaimRecord.claim_date, + func.count(), + func.sum( + case( + (CouponClaimRecord.status.in_(("success", "already_claimed")), 1), + else_=0, + ) + ), + ) + .where( + CouponClaimRecord.claim_date >= period_from, + # 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1, + # 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。 + CouponClaimRecord.claim_date <= period_to + timedelta(days=1), + ) + .group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date) + ).all() + } + # 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。 + coupon_all_success = 0 + completed_bucket_totals: list[int] = [] + session_bucket_keys: set[tuple[str, date]] = set() + for s in period_coupon_sessions: + key = (s.device_id, s.started_date) + if key not in point_buckets: + # 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。 + # 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。 + next_key = (s.device_id, s.started_date + timedelta(days=1)) + if next_key in point_buckets: + key = next_key + bucket = point_buckets.get(key) + if bucket is not None: + session_bucket_keys.add(key) + if s.status != "completed" or bucket is None: + continue + total, succ = bucket + completed_bucket_totals.append(total) + if total > 0 and succ == total: + coupon_all_success += 1 + # 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态, + # 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场 + # 时给不出,点位成功率置空。 + coupon_points_per_session = ( + Counter(completed_bucket_totals).most_common(1)[0][0] + if completed_bucket_totals + else None + ) + # 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。 + coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys) + # 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录, + # 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。 + coupon_point_success_rate = ( + round( + min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4 + ) + if coupon_started and coupon_points_per_session + else None + ) + return { "users": { "total": _count(User), @@ -480,11 +591,13 @@ def dashboard_overview( "users": { "new": len(period_new_user_ids), "active": len(period_active_user_ids), - "retained_new_users": len(period_retained_new_user_ids), + "retained_new_users": retention_retained_total, + "retention_cohort": retention_cohort_total, "retention_rate": period_retention_rate, "retention_note": ( - "口径:登录(last_login_at)+开始比价(real_compare_start)+" - "开始领券(real_coupon_start/claim_started),按用户去重" + "次日留存:窗口内每天取前一日新增用户,统计其当日活跃" + "(登录/开始比价/开始领券,按用户去重)比例,逐日累加;" + "默认窗口=昨日,即前日新增用户的昨日留存" ), }, "comparison": { @@ -495,6 +608,17 @@ def dashboard_overview( "average_duration_ms": period_avg_duration_ms, "average_saved_cents": period_avg_saved_cents, }, + "coupon": { + "started": coupon_started, + "all_success": coupon_all_success, + "success_rate": ( + round(coupon_all_success / coupon_started, 4) if coupon_started else None + ), + "point_success": coupon_point_success, + "points_per_session": coupon_points_per_session, + "point_success_rate": coupon_point_success_rate, + "median_elapsed_ms": _percentile(coupon_completed_elapsed, 50), + }, "coins": { "granted_total": _sum(CoinTransaction.amount, *period_coin_conds), "reward_video_coin_total": period_reward_video_coin_total, diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py index dc6129f..77685d4 100644 --- a/app/admin/routers/ad_revenue.py +++ b/app/admin/routers/ad_revenue.py @@ -91,6 +91,7 @@ def get_ad_revenue_report( daily=[AdRevenueDaily(**d) for d in result["daily"]], hourly=[AdRevenueHourly(**h) for h in result["hourly"]], type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()}, + scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()}, dau=result["dau"], total=result["total"], truncated=result["truncated"], 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/ad_revenue.py b/app/admin/schemas/ad_revenue.py index cf875c3..6f9f49a 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -136,6 +136,11 @@ class AdRevenueReportOut(BaseModel): default_factory=dict, description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘", ) + scene_stats: dict[str, AdRevenueTypeStat] = Field( + default_factory=dict, + description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};" + "全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入", + ) dau: int | None = Field( None, description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + " diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index 56f2c15..b3e008a 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -43,7 +43,10 @@ class DashboardComparison(BaseModel): class DashboardPeriodUsers(BaseModel): new: int active: int + # 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和, + # retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。 retained_new_users: int + retention_cohort: int = 0 retention_rate: float | None = None retention_note: str @@ -57,6 +60,24 @@ class DashboardPeriodComparison(BaseModel): average_saved_cents: int | None = None +class DashboardPeriodCoupon(BaseModel): + """领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态); + 成功口径 success+already_claimed(与「我的」页累计领券一致)。""" + + started: int = 0 + # 全部领成功的次数:completed 且当日该设备全部点位成功 + all_success: int = 0 + success_rate: float | None = None + # 本期 session 触达的点位中成功的条数(同设备同日去重) + point_success: int = 0 + # 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空) + points_per_session: int | None = None + # 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败 + point_success_rate: float | None = None + # 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径) + median_elapsed_ms: int | None = None + + class DashboardPeriodCoins(BaseModel): granted_total: int reward_video_coin_total: int = 0 @@ -85,6 +106,7 @@ class DashboardPeriod(BaseModel): date_to: date users: DashboardPeriodUsers comparison: DashboardPeriodComparison + coupon: DashboardPeriodCoupon = DashboardPeriodCoupon() coins: DashboardPeriodCoins cash: DashboardPeriodCash trend: list[DashboardTrendPoint] = [] 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):