78d970f420
- 新表 coupon_session(一次领券一行,trace_id 唯一):POST /api/v1/coupon/session 两段 upsert(发起 started / 收尾 completed·failed·abandoned),记全程耗时、各平台耗时、 机型/ROM、app_env、trace_url、origin_package(发起来源)。 - admin GET /admin/api/coupon-data:发起/完成数 + 平均耗时 + P5/P50/P95/P99(Python 算分位, SQLite 无 percentile)+ 按天/小时趋势 + 明细分页 + join 用户手机号/昵称,app_env 默认 prod; 另加 GET /admin/api/coupon-data/user-records 供「点手机号看该用户全部领券」抽屉。 - 迁移拆 3 个:建表 coupon_session_table + trace_url 加列 + origin_package 加列 (建表迁移已被某环境应用后改它不重跑,故新列单独加列迁移)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #99 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
226 lines
8.1 KiB
Python
226 lines
8.1 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 为排序后当前页。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, date as _date, datetime
|
||
|
||
from sqlalchemy import func, or_, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core import rewards
|
||
from app.models.coupon_state import CouponSession
|
||
from app.models.user import User
|
||
|
||
|
||
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 _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> 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,
|
||
}
|
||
|
||
|
||
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,
|
||
granularity: str = "day",
|
||
limit: int = 500,
|
||
offset: int = 0,
|
||
sort: str = "time",
|
||
) -> dict:
|
||
"""日期区间(北京自然日 started_date,闭区间)领券数据:汇总卡 + 趋势 + 逐条明细。
|
||
|
||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||
- app_env:prod/dev 精确;None=全部。
|
||
- 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 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),
|
||
}
|
||
|
||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||
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()
|
||
}
|
||
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))
|
||
|
||
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()
|
||
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
|