diff --git a/alembic/versions/coupon_session_origin_package.py b/alembic/versions/coupon_session_origin_package.py new file mode 100644 index 0000000..5287e93 --- /dev/null +++ b/alembic/versions/coupon_session_origin_package.py @@ -0,0 +1,32 @@ +"""coupon_session 加 origin_package 列(发起来源 App 包名 → admin「发起平台」) + +null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。与 trace_url 同理单独成迁移, +已建表环境靠它补列、全新环境顺序应用,不重复加列。 + +Revision ID: coupon_session_origin_package +Revises: coupon_session_trace_url +Create Date: 2026-06-30 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "coupon_session_origin_package" +down_revision: str | Sequence[str] | None = "coupon_session_trace_url" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.add_column(sa.Column("origin_package", sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.drop_column("origin_package") diff --git a/alembic/versions/coupon_session_table.py b/alembic/versions/coupon_session_table.py new file mode 100644 index 0000000..db0e355 --- /dev/null +++ b/alembic/versions/coupon_session_table.py @@ -0,0 +1,81 @@ +"""coupon session table(领券任务全程流水 → admin「领券数据」看板数据源) + +一次领券一行(trace_id 唯一):客户端 POST /api/v1/coupon/session 两段上报 —— 发起建行 +(status=started)、收尾(completed/failed/abandoned)按 trace_id 更新同一行。记全程耗时 +elapsed_ms + 各平台耗时 platform_elapsed + 机型/ROM,供 admin 算发起/完成数、耗时分位、机型维度。 + +Revision ID: coupon_session_table +Revises: feedback_submit_env +Create Date: 2026-06-30 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "coupon_session_table" +down_revision: str | Sequence[str] | None = "feedback_submit_env" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant)。 +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + op.create_table( + "coupon_session", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("trace_id", sa.String(length=64), nullable=False), + sa.Column("device_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("app_env", sa.String(length=16), nullable=True), + sa.Column("platforms", _JSON, nullable=True), + sa.Column("device_model", sa.String(length=128), nullable=True), + sa.Column("rom", sa.String(length=64), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("started_date", sa.Date(), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("elapsed_ms", sa.Integer(), nullable=True), + sa.Column("platform_elapsed", _JSON, nullable=True), + sa.Column("claimed_count", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("trace_id", name="uq_coupon_session_trace"), + ) + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_coupon_session_user_id"), ["user_id"], unique=False + ) + batch_op.create_index( + batch_op.f("ix_coupon_session_app_env"), ["app_env"], unique=False + ) + # admin 主聚合/筛选:按上海自然日 + 环境。 + batch_op.create_index( + "ix_coupon_session_date_env", ["started_date", "app_env"], unique=False + ) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.drop_index("ix_coupon_session_date_env") + batch_op.drop_index(batch_op.f("ix_coupon_session_app_env")) + batch_op.drop_index(batch_op.f("ix_coupon_session_user_id")) + op.drop_table("coupon_session") diff --git a/alembic/versions/coupon_session_trace_url.py b/alembic/versions/coupon_session_trace_url.py new file mode 100644 index 0000000..ce95c73 --- /dev/null +++ b/alembic/versions/coupon_session_trace_url.py @@ -0,0 +1,32 @@ +"""coupon_session 加 trace_url 列(pricebot done 帧公网调试链接) + +建表迁移 coupon_session_table 落地后才追加本列,故单独成一个迁移:已建表的环境(本地/已 upgrade 过) +靠它补列,全新环境则「建表(无 trace_url)→ 本迁移加列」,两条路一致、不重复加列。 + +Revision ID: coupon_session_trace_url +Revises: coupon_session_table +Create Date: 2026-06-30 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "coupon_session_trace_url" +down_revision: str | Sequence[str] | None = "coupon_session_table" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.add_column(sa.Column("trace_url", sa.String(length=512), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.drop_column("trace_url") diff --git a/app/admin/main.py b/app/admin/main.py index 57d044a..ca3a374 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -21,6 +21,7 @@ from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router from app.admin.routers.comparison import router as comparison_router from app.admin.routers.config import router as config_router +from app.admin.routers.coupon_data import router as coupon_data_router from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.device_liveness import router as device_liveness_router @@ -101,6 +102,7 @@ admin_app.include_router(audit_router) admin_app.include_router(config_router) admin_app.include_router(comparison_router) admin_app.include_router(cps_router) +admin_app.include_router(coupon_data_router) admin_app.include_router(ad_audit_router) admin_app.include_router(ad_config_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py new file mode 100644 index 0000000..05b8fbd --- /dev/null +++ b/app/admin/repositories/coupon_data.py @@ -0,0 +1,225 @@ +"""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)} diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py new file mode 100644 index 0000000..f3d2548 --- /dev/null +++ b/app/admin/routers/coupon_data.py @@ -0,0 +1,106 @@ +"""admin「领券数据」看板:发起/完成数 + 领券耗时(均值 + P5/P50/P95/P99)+ 按天趋势 + 逐条明细。 + +任意已登录 admin 可看(只读)。聚合逻辑在 app/admin/repositories/coupon_data.py。 +数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报)。 +""" +from __future__ import annotations + +from datetime import date as _date +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import coupon_data +from app.admin.schemas.coupon_data import ( + CouponDataDaily, + CouponDataHourly, + CouponDataOut, + CouponDataRow, + CouponDataSummary, + CouponUserRecordsOut, +) +from app.core.rewards import cn_today + +router = APIRouter( + prefix="/admin/api/coupon-data", + tags=["admin-coupon-data"], + dependencies=[Depends(get_current_admin)], +) + +# 区间最大跨度(天);超出拒绝,避免一次拉过多天拖垮接口(对齐广告收益报表)。 +_MAX_RANGE_DAYS = 92 + + +def _parse_day(value: str | None, *, field: str, default: _date) -> _date: + if value is None: + return default + try: + return _date.fromisoformat(value) + except ValueError as e: + raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e + + +@router.get( + "", + response_model=CouponDataOut, + summary="领券数据看板(发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细)", +) +def get_coupon_data( + db: AdminDb, + date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None, + date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None, + user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None, + app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod", + granularity: Annotated[ + str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day") + ] = "day", + limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500, + offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过条数)=(页码-1)×每页条数")] = 0, + sort: Annotated[ + str, Query(description="排序:time=发起时间倒序(默认) / elapsed=耗时倒序") + ] = "time", +) -> CouponDataOut: + today = cn_today() + d_from = _parse_day(date_from, field="date_from", default=today) + d_to = _parse_day(date_to, field="date_to", default=d_from) + if d_to < d_from: + raise HTTPException(status_code=422, detail="date_to 不能早于 date_from") + if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS: + raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天") + + # 报表默认只看 prod(对齐广告报表防串台口径);app_env=all 时不过滤、看全部环境。 + env = None if app_env == "all" else app_env + result = coupon_data.coupon_data_report( + db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), + user=user, app_env=env, granularity=granularity, + limit=limit, offset=offset, sort=sort, + ) + return CouponDataOut( + date_from=d_from.isoformat(), + date_to=d_to.isoformat(), + summary=CouponDataSummary(**result["summary"]), + daily=[CouponDataDaily(**d) for d in result["daily"]], + hourly=[CouponDataHourly(**h) for h in result["hourly"]], + total=result["total"], + items=[CouponDataRow(**r) for r in result["items"]], + ) + + +@router.get( + "/user-records", + response_model=CouponUserRecordsOut, + summary="某用户全部领券记录(点手机号抽屉:领券次数 + 记录列表)", +) +def get_user_coupon_records( + db: AdminDb, + user_id: Annotated[int, Query(description="用户 id")], + limit: Annotated[int, Query(ge=1, le=500, description="最多返回条数")] = 100, + sort_by: Annotated[str, Query(description="兼容 UserRecordsDrawer 参数;固定按发起时间倒序")] = "created_at", + sort_order: Annotated[str, Query(description="兼容参数,忽略")] = "desc", +) -> CouponUserRecordsOut: + result = coupon_data.coupon_user_records(db, user_id=user_id, limit=limit) + return CouponUserRecordsOut( + items=[CouponDataRow(**r) for r in result["items"]], + total=result["total"], + ) diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py new file mode 100644 index 0000000..c94938d --- /dev/null +++ b/app/admin/schemas/coupon_data.py @@ -0,0 +1,83 @@ +"""admin「领券数据」看板 schemas:汇总卡 + 按天/小时趋势 + 逐条领券明细。 + +数据源 coupon_session(一次领券一行)。耗时单位 ms(前端按需折秒);均值/分位只统计 completed。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class CouponDataSummary(BaseModel): + """汇总卡:发起/完成数 + 耗时均值与分位(P5/P50/P95/P99,基于 completed 的 elapsed_ms)。""" + + started_count: int = Field(..., description="发起数(区间内所有领券 session)") + completed_count: int = Field(..., description="完成数(status=completed)") + avg_elapsed_ms: int | None = Field(None, description="平均耗时(ms,仅 completed;无数据为空)") + p5_ms: int | None = Field(None, description="耗时 5 分位(ms)") + p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)") + p95_ms: int | None = Field(None, description="耗时 95 分位(ms)") + p99_ms: int | None = Field(None, description="耗时 99 分位(ms)") + + +class CouponDataDaily(BaseModel): + """按天趋势(全量,不受分页影响):柱=发起/完成数,线=平均耗时。""" + + date: str = Field(..., description="北京时间 YYYY-MM-DD") + started_count: int + completed_count: int + avg_elapsed_ms: int | None = Field(None, description="当天平均耗时(ms,仅 completed)") + + +class CouponDataHourly(BaseModel): + """按北京小时(0–23)趋势(单日 granularity=hour 时非空)。""" + + hour: int = Field(..., description="北京时间小时 0–23") + started_count: int + completed_count: int + avg_elapsed_ms: int | None = None + + +class CouponDataRow(BaseModel): + """一条领券明细(一次领券任务)。""" + + id: int = Field(..., description="coupon_session 主键(抽屉 rowKey 用)") + trace_id: str + user_id: int | None = None + user_phone: str | None = Field(None, description="手机号(admin 展示;匿名领券/查不到为空)") + user_nickname: str | None = Field(None, description="昵称") + status: str = Field(..., description="started / completed / failed / abandoned") + platforms: list[str] | None = Field(None, description="发起勾选平台") + origin_package: str | None = Field(None, description="发起来源 App 包名;null=App 内(傻瓜比价首页)发起") + elapsed_ms: int | None = Field(None, description="全程耗时(ms)") + platform_elapsed: dict[str, int] | None = Field( + None, description="各平台耗时 {meituan-waimai/taobao-shanguang/jd-waimai: ms}" + ) + device_model: str | None = None + rom: str | None = None + app_env: str | None = None + started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") + claimed_count: int | None = None + trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") + + +class CouponDataOut(BaseModel): + """领券数据看板响应:汇总卡 + 趋势 + 明细分页。""" + + date_from: str + date_to: str + summary: CouponDataSummary + daily: list[CouponDataDaily] = Field(default_factory=list, description="按天趋势(全量)") + hourly: list[CouponDataHourly] = Field( + default_factory=list, description="按小时趋势(单日 hour 粒度时非空)" + ) + total: int = Field(..., description="明细总条数(全量,不受分页)") + items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)") + + +class CouponUserRecordsOut(BaseModel): + """某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。""" + + items: list[CouponDataRow] + total: int diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 1734c79..1a5368d 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -29,6 +29,7 @@ from app.schemas.coupon_state import ( CouponPromptDismissIn, CouponPromptShouldShowOut, CouponPromptShownIn, + CouponSessionIn, CouponStatsOut, ) @@ -194,6 +195,34 @@ async def coupon_step( return resp_json +@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)") +def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]: + """客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert + 到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算 + 发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。""" + try: + coupon_repo.upsert_coupon_session( + db, + trace_id=payload.trace_id, + device_id=payload.device_id, + status=payload.status, + started_at_ms=payload.started_at_ms, + user_id=payload.user_id, + platforms=payload.platforms, + origin_package=payload.origin_package, + device_model=payload.device_model, + rom=payload.rom, + app_env=payload.app_env, + elapsed_ms=payload.elapsed_ms, + platform_elapsed=payload.platform_elapsed, + claimed_count=payload.claimed_count, + trace_url=payload.trace_url, + ) + except Exception as e: # noqa: BLE001 + logger.warning("coupon session write failed: %s", e) + return {"ok": True} + + @router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)") def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]: """客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。 diff --git a/app/models/__init__.py b/app/models/__init__.py index 7f61400..9e5446c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -19,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401 CouponClaimRecord, CouponDailyCompletion, CouponPromptEngagement, + CouponSession, ) from app.models.feedback import Feedback # noqa: F401 from app.models.invite import InviteRelation # noqa: F401 diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index 3f9364d..c7ba0b9 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -181,3 +181,79 @@ class CouponPromptEngagement(Base): f"" ) + + +class CouponSession(Base): + """一次领券任务的全程流水(admin「领券数据」看板数据源,2026-06-30)。 + + 与 coupon_claim_record(按券一天一条去重)、coupon_daily_completion(按设备一天一条)都不同: + 本表**一次领券一条**(trace_id 唯一),记从发起(started)到收尾(completed/failed/abandoned)的 + 全程耗时 + 各平台耗时 + 机型/ROM。客户端 POST /api/v1/coupon/session 两段上报:发起建行、 + 收尾按 trace_id 更新同一行。发起即落库 → admin 可算「发起数」与中途流失(started 无终态=未完成)。 + + 口径:elapsed_ms 由客户端全程计时(点发起→收尾)、权威;started_at/finished_at 为时刻留痕。 + started_date = started_at 的 Asia/Shanghai 自然日,供 admin 按天聚合 / 日期筛选(索引)。 + """ + + __tablename__ = "coupon_session" + __table_args__ = ( + # 一次领券一行:trace_id 幂等 upsert(发起建、收尾更新同一行)。 + UniqueConstraint("trace_id", name="uq_coupon_session_trace"), + # admin 主聚合/筛选:按上海自然日 + 环境(报表默认只看 prod,避免测试数据串台)。 + Index("ix_coupon_session_date_env", "started_date", "app_env"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键。 + trace_id: Mapped[str] = mapped_column(String(64), nullable=False) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + # 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空。 + user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + + # started / completed / failed / abandoned。started 无终态 = 中途流失(未完成)。 + status: Mapped[str] = mapped_column(String(16), nullable=False) + # prod / dev(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod(对齐广告报表防串台口径)。 + app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) + + # 发起勾选平台 ["meituan-waimai", ...](空=全领)。 + platforms: Mapped[list | None] = mapped_column(_JSON, nullable=True) + # 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。 + # admin「发起平台」列据此区分(空→傻瓜比价,包名→对应平台)。 + origin_package: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 机型(Build.MANUFACTURER + MODEL)与 ROM(OemDetector,如 "ColorOS 14")。明细「机型/ROM」列 + 维度筛选。 + device_model: Mapped[str | None] = mapped_column(String(128), nullable=True) + rom: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # 发起时刻(客户端墙钟):明细「时间」列、趋势 X 轴。 + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + # 发起的 Asia/Shanghai 自然日:按天聚合 / 日期范围筛选(索引)。 + started_date: Mapped[date] = mapped_column(Date, nullable=False) + # 收尾时刻(服务端 now);未收尾(流失)为空。 + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # 全程耗时(ms,客户端点发起→收尾):平均 / 分位都基于它(只统计 completed)。 + elapsed_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 各平台领券耗时 {"meituan-waimai":3200,...}(ms)。明细美团/淘宝/京东耗时列。 + platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + # 领到总张数(收尾帧带)。 + claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + # pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存 + # (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。 + trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index e3e2765..336ca0c 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from datetime import date, datetime +from datetime import date, datetime, timezone from zoneinfo import ZoneInfo from sqlalchemy import delete, func, select @@ -17,6 +17,7 @@ from app.models.coupon_state import ( CouponClaimRecord, CouponDailyCompletion, CouponPromptEngagement, + CouponSession, ) logger = logging.getLogger("shagua.coupon_state") @@ -232,3 +233,91 @@ def sum_claimed_count(db: Session, user_id: int) -> int: ) ).scalar_one() return int(total or 0) + + +# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)===== + +def upsert_coupon_session( + db: Session, + *, + trace_id: str, + device_id: str, + status: str, + started_at_ms: int, + user_id: int | None = None, + platforms: list[str] | None = None, + origin_package: str | None = None, + device_model: str | None = None, + rom: str | None = None, + app_env: str | None = None, + elapsed_ms: int | None = None, + platform_elapsed: dict | None = None, + claimed_count: int | None = None, + trace_url: str | None = None, +) -> None: + """一条领券流水按 trace_id 幂等 upsert(发起 started 建行、收尾终态更新同一行)。 + + - 乱序/重复兜底:终态(completed/failed/abandoned)先到也建行;started 重复到不覆盖已有终态 + (状态只前进,不降级)。 + - started_at 由客户端墙钟毫秒转;started_date 取其 Asia/Shanghai 自然日(admin 按天聚合/筛选)。 + - 终态帧补 finished_at=服务端 now;各字段非空才写(避免 started 帧的 None 抹掉收尾值,反之亦然)。 + 并发 IntegrityError 回滚忽略(本就幂等)。 + """ + started_at = datetime.fromtimestamp(started_at_ms / 1000, tz=timezone.utc) + started_date = started_at.astimezone(_CN_TZ).date() + is_terminal = status in ("completed", "failed", "abandoned") + + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + + if row is None: + db.add(CouponSession( + trace_id=trace_id, + device_id=device_id, + user_id=user_id, + status=status, + app_env=app_env, + platforms=platforms, + origin_package=origin_package, + device_model=device_model, + rom=rom, + started_at=started_at, + started_date=started_date, + finished_at=datetime.now(timezone.utc) if is_terminal else None, + elapsed_ms=elapsed_ms, + platform_elapsed=platform_elapsed, + claimed_count=claimed_count, + trace_url=trace_url, + )) + else: + # 状态只前进:started 帧重复到(如 START_STICKY 重启)不把已有终态降级回 started。 + if not (status == "started" and row.status in ("completed", "failed", "abandoned")): + row.status = status + if is_terminal: + row.finished_at = datetime.now(timezone.utc) + if user_id is not None: + row.user_id = user_id + if platforms is not None: + row.platforms = platforms + if origin_package is not None: + row.origin_package = origin_package + if device_model is not None: + row.device_model = device_model + if rom is not None: + row.rom = rom + if app_env is not None: + row.app_env = app_env + if elapsed_ms is not None: + row.elapsed_ms = elapsed_ms + if platform_elapsed is not None: + row.platform_elapsed = platform_elapsed + if claimed_count is not None: + row.claimed_count = claimed_count + if trace_url is not None: + row.trace_url = trace_url + try: + db.commit() + except IntegrityError: + # 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。 + db.rollback() diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index 236d311..35d2cca 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -49,3 +49,28 @@ class CouponStatsOut(BaseModel): """ coupon_count: int + + +class CouponSessionIn(BaseModel): + """客户端领券流水上报体(admin「领券数据」看板数据源,POST /api/v1/coupon/session)。 + + 一次领券两段上报,按 trace_id upsert 到 coupon_session: + - 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。 + - 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。 + 不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。 + """ + + trace_id: str + device_id: str + status: str # started / completed / failed / abandoned + started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis) + user_id: int | None = None + platforms: list[str] | None = None + origin_package: str | None = None + device_model: str | None = None + rom: str | None = None + app_env: str | None = None + elapsed_ms: int | None = None + platform_elapsed: dict[str, int] | None = None + claimed_count: int | None = None + trace_url: str | None = None