4680277c2a
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
331 lines
13 KiB
Python
331 lines
13 KiB
Python
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
|
||
|
||
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
|
||
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
|
||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
from datetime import date as _date
|
||
|
||
from sqlalchemy import case, func, or_, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core import rewards
|
||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||
from app.models.user import User
|
||
from app.repositories import ad_ecpm as crud_ecpm
|
||
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
|
||
|
||
|
||
def _cn_hour(dt: datetime) -> int:
|
||
"""started_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=UTC)
|
||
return dt.astimezone(rewards.CN_TZ).hour
|
||
|
||
|
||
def _percentile(sorted_vals: list[int], q: float) -> int | None:
|
||
"""线性插值分位(q=0..100,numpy 默认法)。sorted_vals 须已升序;空返回 None。"""
|
||
if not sorted_vals:
|
||
return None
|
||
if len(sorted_vals) == 1:
|
||
return sorted_vals[0]
|
||
idx = (len(sorted_vals) - 1) * q / 100.0
|
||
lo = int(idx)
|
||
hi = min(lo + 1, len(sorted_vals) - 1)
|
||
frac = idx - lo
|
||
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
|
||
|
||
|
||
def _avg(vals: list[int]) -> int | None:
|
||
return round(sum(vals) / len(vals)) if vals else None
|
||
|
||
|
||
def _success_rates(rows: list) -> dict:
|
||
"""平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12):
|
||
|
||
- sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS);
|
||
- succ(s) = `platform_success` ∩ sel(至少领到一张的平台);
|
||
- ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数;
|
||
- ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。
|
||
基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。
|
||
"""
|
||
started = len(rows)
|
||
full_success = 0
|
||
point_success = 0
|
||
point_total = 0
|
||
per_succ = {p: 0 for p in DEFAULT_PLATFORMS}
|
||
per_total = {p: 0 for p in DEFAULT_PLATFORMS}
|
||
for r in rows:
|
||
sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS)
|
||
succ = set(r.platform_success or []) & sel
|
||
point_success += len(succ)
|
||
point_total += len(sel)
|
||
if sel and succ == sel:
|
||
full_success += 1
|
||
for p in sel:
|
||
if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform
|
||
per_total[p] += 1
|
||
if p in succ:
|
||
per_succ[p] += 1
|
||
return {
|
||
"full_success_count": full_success,
|
||
"full_success_rate": round(full_success / started, 4) if started else None,
|
||
"point_success_count": point_success,
|
||
"point_total_count": point_total,
|
||
"point_success_rate": round(point_success / point_total, 4) if point_total else None,
|
||
"per_platform": {
|
||
p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None)
|
||
for p in DEFAULT_PLATFORMS
|
||
},
|
||
}
|
||
|
||
|
||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
|
||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||
return {
|
||
"id": r.id,
|
||
"trace_id": r.trace_id,
|
||
"user_id": r.user_id,
|
||
"user_phone": phone,
|
||
"user_nickname": nickname,
|
||
"status": r.status,
|
||
"platforms": r.platforms,
|
||
"origin_package": r.origin_package,
|
||
"elapsed_ms": r.elapsed_ms,
|
||
"platform_elapsed": r.platform_elapsed,
|
||
"device_model": r.device_model,
|
||
"rom": r.rom,
|
||
"app_env": r.app_env,
|
||
"started_at": r.started_at,
|
||
"claimed_count": r.claimed_count,
|
||
"trace_url": r.trace_url,
|
||
"ad_revenue_yuan": ad_revenue_yuan,
|
||
}
|
||
|
||
|
||
def _empty_result() -> dict:
|
||
return {
|
||
"summary": {
|
||
"started_count": 0, "completed_count": 0, "avg_elapsed_ms": None,
|
||
"p5_ms": None, "p50_ms": None, "p95_ms": None, "p99_ms": None,
|
||
},
|
||
"daily": [],
|
||
"hourly": [],
|
||
"total": 0,
|
||
"items": [],
|
||
}
|
||
|
||
|
||
def coupon_data_report(
|
||
db: Session,
|
||
*,
|
||
date_from: str,
|
||
date_to: str,
|
||
user: str | None = None,
|
||
app_env: str | None = None,
|
||
statuses: list[str] | None = None,
|
||
granularity: str = "day",
|
||
limit: int = 500,
|
||
offset: int = 0,
|
||
sort: str = "time",
|
||
) -> dict:
|
||
"""日期区间(北京自然日 started_date,闭区间)领券数据:汇总卡 + 趋势 + 逐条明细。
|
||
|
||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||
- app_env:prod/dev 精确;None=全部。
|
||
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
|
||
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
|
||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
||
"""
|
||
by_hour = granularity == "hour"
|
||
d_from = _date.fromisoformat(date_from)
|
||
d_to = _date.fromisoformat(date_to)
|
||
|
||
# user 模糊 → 先定位匹配用户 id;匹配不到直接空结果(不全表扫)。
|
||
user_ids: set[int] | None = None
|
||
if user:
|
||
like = f"%{user}%"
|
||
user_ids = set(db.execute(
|
||
select(User.id).where(or_(User.phone.like(like), User.nickname.like(like)))
|
||
).scalars().all())
|
||
if not user_ids:
|
||
return _empty_result()
|
||
|
||
stmt = select(CouponSession).where(
|
||
CouponSession.started_date >= d_from,
|
||
CouponSession.started_date <= d_to,
|
||
)
|
||
if app_env is not None:
|
||
stmt = stmt.where(CouponSession.app_env == app_env)
|
||
if statuses:
|
||
stmt = stmt.where(CouponSession.status.in_(statuses))
|
||
if user_ids is not None:
|
||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
||
rows = list(db.execute(stmt).scalars())
|
||
|
||
# ── 汇总卡 ──
|
||
completed_elapsed = sorted(
|
||
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
|
||
)
|
||
summary = {
|
||
"started_count": len(rows),
|
||
"completed_count": sum(1 for r in rows if r.status == "completed"),
|
||
"avg_elapsed_ms": _avg(completed_elapsed),
|
||
"p5_ms": _percentile(completed_elapsed, 5),
|
||
"p50_ms": _percentile(completed_elapsed, 50),
|
||
"p95_ms": _percentile(completed_elapsed, 95),
|
||
"p99_ms": _percentile(completed_elapsed, 99),
|
||
**_success_rates(rows),
|
||
}
|
||
|
||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||
daily_map: dict[str, dict] = {}
|
||
for r in rows:
|
||
d = r.started_date.isoformat()
|
||
b = daily_map.get(d)
|
||
if b is None:
|
||
b = {"date": d, "started_count": 0, "completed_count": 0, "_elapsed": []}
|
||
daily_map[d] = b
|
||
b["started_count"] += 1
|
||
if r.status == "completed":
|
||
b["completed_count"] += 1
|
||
if r.elapsed_ms is not None:
|
||
b["_elapsed"].append(r.elapsed_ms)
|
||
daily = [
|
||
{
|
||
"date": b["date"],
|
||
"started_count": b["started_count"],
|
||
"completed_count": b["completed_count"],
|
||
"avg_elapsed_ms": _avg(b["_elapsed"]),
|
||
}
|
||
for b in sorted(daily_map.values(), key=lambda x: x["date"])
|
||
]
|
||
|
||
# ── 按小时趋势(单日 hour 粒度)──
|
||
hourly: list[dict] = []
|
||
if by_hour:
|
||
hour_map: dict[int, dict] = {}
|
||
for r in rows:
|
||
h = _cn_hour(r.started_at)
|
||
b = hour_map.get(h)
|
||
if b is None:
|
||
b = {"hour": h, "started_count": 0, "completed_count": 0, "_elapsed": []}
|
||
hour_map[h] = b
|
||
b["started_count"] += 1
|
||
if r.status == "completed":
|
||
b["completed_count"] += 1
|
||
if r.elapsed_ms is not None:
|
||
b["_elapsed"].append(r.elapsed_ms)
|
||
hourly = [
|
||
{
|
||
"hour": b["hour"],
|
||
"started_count": b["started_count"],
|
||
"completed_count": b["completed_count"],
|
||
"avg_elapsed_ms": _avg(b["_elapsed"]),
|
||
}
|
||
for b in sorted(hour_map.values(), key=lambda x: x["hour"])
|
||
]
|
||
|
||
# ── 明细:排序 + 分页 + 补用户手机号/昵称(批量,防 N+1)──
|
||
if sort == "elapsed":
|
||
rows.sort(key=lambda r: (r.elapsed_ms is None, -(r.elapsed_ms or 0)))
|
||
else: # time:发起时刻倒序
|
||
rows.sort(key=lambda r: r.started_at, reverse=True)
|
||
page = rows[offset:offset + limit]
|
||
|
||
uids = {r.user_id for r in page if r.user_id is not None}
|
||
user_map: dict[int, tuple[str | None, str | None]] = {}
|
||
if uids:
|
||
user_map = {
|
||
uid: (phone, nickname)
|
||
for uid, phone, nickname in db.execute(
|
||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||
).all()
|
||
}
|
||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
|
||
items = []
|
||
for r in page:
|
||
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
|
||
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
|
||
|
||
return {
|
||
"summary": summary,
|
||
"daily": daily,
|
||
"hourly": hourly,
|
||
"total": len(rows),
|
||
"items": items,
|
||
}
|
||
|
||
|
||
def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||
"""某用户全部领券记录(点手机号抽屉用):按发起时刻倒序、不限日期,total=该用户领券总次数。"""
|
||
rows = list(db.execute(
|
||
select(CouponSession)
|
||
.where(CouponSession.user_id == user_id)
|
||
.order_by(CouponSession.started_at.desc())
|
||
.limit(limit)
|
||
).scalars())
|
||
total = db.execute(
|
||
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
||
).scalar_one()
|
||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
|
||
return {
|
||
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows],
|
||
"total": int(total),
|
||
}
|
||
|
||
|
||
_SLOT_OK = ("success", "already_claimed")
|
||
_SLOT_TRIED = ("success", "already_claimed", "failed")
|
||
|
||
|
||
def coupon_slot_report(
|
||
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
|
||
) -> dict:
|
||
"""按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。
|
||
|
||
数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。
|
||
- 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除);
|
||
- 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试;
|
||
- claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。
|
||
"""
|
||
d_from = _date.fromisoformat(date_from)
|
||
d_to = _date.fromisoformat(date_to)
|
||
ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)
|
||
stmt = (
|
||
select(
|
||
CouponClaimRecord.coupon_id,
|
||
func.max(CouponClaimRecord.coupon_name).label("coupon_name"),
|
||
func.count().label("tried"),
|
||
func.sum(ok).label("succeeded"),
|
||
)
|
||
.where(
|
||
CouponClaimRecord.claim_date >= d_from,
|
||
CouponClaimRecord.claim_date <= d_to,
|
||
CouponClaimRecord.status.in_(_SLOT_TRIED),
|
||
)
|
||
.group_by(CouponClaimRecord.coupon_id)
|
||
)
|
||
if app_env is not None:
|
||
stmt = stmt.where(CouponClaimRecord.app_env == app_env)
|
||
items = []
|
||
for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all():
|
||
tried = int(tried or 0)
|
||
succeeded = int(succeeded or 0)
|
||
items.append({
|
||
"coupon_id": coupon_id,
|
||
"coupon_name": coupon_name,
|
||
"platform": coupon_id_to_platform(coupon_id),
|
||
"tried": tried,
|
||
"succeeded": succeeded,
|
||
"success_rate": round(succeeded / tried, 4) if tried else None,
|
||
})
|
||
items.sort(key=lambda x: (-x["tried"], x["coupon_id"]))
|
||
return {"items": items}
|