diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 3256812..8333b00 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 @@ -428,6 +434,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), @@ -508,6 +606,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/schemas/dashboard.py b/app/admin/schemas/dashboard.py index b91c6b9..b3e008a 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -60,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 @@ -88,6 +106,7 @@ class DashboardPeriod(BaseModel): date_to: date users: DashboardPeriodUsers comparison: DashboardPeriodComparison + coupon: DashboardPeriodCoupon = DashboardPeriodCoupon() coins: DashboardPeriodCoins cash: DashboardPeriodCash trend: list[DashboardTrendPoint] = []