From f984b07a0c172c7d72bafc32fb2582abc3bb17ac Mon Sep 17 00:00:00 2001 From: guke Date: Wed, 8 Jul 2026 15:26:52 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(coupon-data):=20=E9=A2=86=E5=88=B8?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E7=8E=87=E7=9C=8B=E6=9D=BF(=E6=95=B4?= =?UTF-8?q?=E5=8D=95/=E7=82=B9=E4=BD=8D/=E5=88=86=E5=B9=B3=E5=8F=B0=20+=20?= =?UTF-8?q?=E6=8C=89=E5=88=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - coupon_session.platform_success:整单成功率②/点位成功率③ + 分平台点位成功率 - coupon_claim_record.app_env:按 coupon_id「按券成功率」聚合 + 子端点 /admin/api/coupon-data/coupons - /step 复用 record_claims 同一 SessionLocal 写入(fire-and-forget),pricebot 转发/负载不变 - 迁移 coupon_session_platform_success + coupon_claim_app_env;新增 14 测试 - 设计见 docs/guides/领券成功率指标-设计与埋点.md Co-Authored-By: Claude Opus 4.8 (1M context) --- alembic/versions/coupon_claim_app_env.py | 32 ++ .../coupon_session_platform_success.py | 37 +++ app/admin/repositories/coupon_data.py | 99 +++++- app/admin/routers/coupon_data.py | 31 ++ app/admin/schemas/coupon_data.py | 29 ++ app/api/v1/coupon.py | 11 +- app/models/coupon_state.py | 7 + app/repositories/coupon_state.py | 84 ++++- docs/database/coupon_state.md | 52 +++- docs/guides/领券成功率指标-设计与埋点.md | 294 ++++++++++++++++++ tests/test_coupon_platform_success.py | 272 ++++++++++++++++ tests/test_coupon_slots.py | 137 ++++++++ 12 files changed, 1076 insertions(+), 9 deletions(-) create mode 100644 alembic/versions/coupon_claim_app_env.py create mode 100644 alembic/versions/coupon_session_platform_success.py create mode 100644 docs/guides/领券成功率指标-设计与埋点.md create mode 100644 tests/test_coupon_platform_success.py create mode 100644 tests/test_coupon_slots.py diff --git a/alembic/versions/coupon_claim_app_env.py b/alembic/versions/coupon_claim_app_env.py new file mode 100644 index 0000000..1b220c1 --- /dev/null +++ b/alembic/versions/coupon_claim_app_env.py @@ -0,0 +1,32 @@ +"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev) + +Revision ID: coupon_claim_app_env +Revises: coupon_session_platform_success +Create Date: 2026-07-08 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "coupon_claim_app_env" +down_revision: str | Sequence[str] | None = "coupon_session_platform_success" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op: + batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True)) + batch_op.create_index( + "ix_coupon_claim_record_app_env", ["app_env"], unique=False + ) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op: + batch_op.drop_index("ix_coupon_claim_record_app_env") + batch_op.drop_column("app_env") diff --git a/alembic/versions/coupon_session_platform_success.py b/alembic/versions/coupon_session_platform_success.py new file mode 100644 index 0000000..af14669 --- /dev/null +++ b/alembic/versions/coupon_session_platform_success.py @@ -0,0 +1,37 @@ +"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表) + +供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧 +按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL +视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。 + +Revision ID: coupon_session_platform_success +Revises: admin_user_plain_password +Create Date: 2026-07-07 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_platform_success" +down_revision: str | Sequence[str] | None = "admin_user_plain_password" +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: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.drop_column("platform_success") diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 05b8fbd..0b932cd 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -5,17 +5,20 @@ - 发起数 = 区间内全部 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, date as _date, datetime +from datetime import UTC, datetime +from datetime import date as _date -from sqlalchemy import func, or_, select +from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session from app.core import rewards -from app.models.coupon_state import CouponSession +from app.models.coupon_state import CouponClaimRecord, CouponSession from app.models.user import User +from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform def _cn_hour(dt: datetime) -> int: @@ -42,6 +45,46 @@ 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) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { @@ -131,6 +174,7 @@ def coupon_data_report( "p50_ms": _percentile(completed_elapsed, 50), "p95_ms": _percentile(completed_elapsed, 95), "p99_ms": _percentile(completed_elapsed, 99), + **_success_rates(rows), } # ── 按天趋势(柱=发起/完成数,线=平均耗时)── @@ -223,3 +267,52 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: 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)} + + +_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} diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py index f3d2548..fe30cad 100644 --- a/app/admin/routers/coupon_data.py +++ b/app/admin/routers/coupon_data.py @@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import ( CouponDataOut, CouponDataRow, CouponDataSummary, + CouponSlotRow, + CouponSlotsOut, CouponUserRecordsOut, ) from app.core.rewards import cn_today @@ -87,6 +89,35 @@ def get_coupon_data( ) +@router.get( + "/coupons", + response_model=CouponSlotsOut, + summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)", +) +def get_coupon_slots( + 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, + app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod", +) -> CouponSlotsOut: + 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} 天") + env = None if app_env == "all" else app_env + result = coupon_data.coupon_slot_report( + db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env + ) + return CouponSlotsOut( + date_from=d_from.isoformat(), + date_to=d_to.isoformat(), + items=[CouponSlotRow(**r) for r in result["items"]], + ) + + @router.get( "/user-records", response_model=CouponUserRecordsOut, diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index c94938d..ec4c56e 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -19,6 +19,16 @@ class CouponDataSummary(BaseModel): 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)") + # 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。 + full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)") + full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空") + point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)") + point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)") + point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空") + per_platform: dict[str, float | None] = Field( + default_factory=dict, + description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None", + ) class CouponDataDaily(BaseModel): @@ -81,3 +91,22 @@ class CouponUserRecordsOut(BaseModel): items: list[CouponDataRow] total: int + + +class CouponSlotRow(BaseModel): + """按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。""" + + coupon_id: str + coupon_name: str | None = None + platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空") + tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)") + succeeded: int = Field(..., description="成功数(success+already_claimed)") + success_rate: float | None = Field(None, description="成功率=成功/尝试") + + +class CouponSlotsOut(BaseModel): + """按券成功率表响应(§13)。""" + + date_from: str + date_to: str + items: list[CouponSlotRow] diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 1a5368d..bd2127a 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -81,7 +81,16 @@ def _record_claims_blocking( device_id: str, user_id: int | None, trace_id: str | None, results: list[dict] ) -> None: with SessionLocal() as db: - coupon_repo.record_claims(db, device_id, user_id, trace_id, results) + # 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。 + app_env = coupon_repo.session_app_env(db, trace_id) + coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env) + # 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率; + # 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims, + # 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。 + if trace_id: + coupon_repo.merge_session_platform_success( + db, trace_id, coupon_repo.succeeded_platforms(results) + ) def _mark_completed_blocking( diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index c7ba0b9..d461613 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -66,6 +66,9 @@ class CouponClaimRecord(Base): # success / already_claimed / failed / skipped(原样取 pricebot coupon 结果) status: Mapped[str] = mapped_column(String(24), nullable=False) + # 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。 + # 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。 + app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) vendor: Mapped[str | None] = mapped_column(String(48), nullable=True) coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # 这张领到几张(pricebot display_count;给不出时为 None) @@ -240,6 +243,10 @@ class CouponSession(Base): platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True) # 领到总张数(收尾帧带)。 claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。 + # admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入 + # (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。 + platform_success: Mapped[list | None] = mapped_column(_JSON, 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) diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index 336ca0c..508da74 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -147,12 +147,22 @@ def reset_today_completion(db: Session, device_id: str) -> int: # ===== 领券记录(coupon_claim_record)===== +def session_app_env(db: Session, trace_id: str | None) -> str | None: + """按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。""" + if not trace_id: + return None + return db.execute( + select(CouponSession.app_env).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + + def record_claims( db: Session, device_id: str, user_id: int | None, trace_id: str | None, results: list[dict], + app_env: str | None = None, ) -> int: """一批券领取结果幂等写入,返回写入(新增 + 更新)条数。 @@ -186,12 +196,15 @@ def record_claims( row.user_id = user_id if count is not None: row.claimed_count = count + if app_env is not None: + row.app_env = app_env row.extra = r else: db.add(CouponClaimRecord( device_id=device_id, user_id=user_id, coupon_id=coupon_id, claim_date=today, - status=status, vendor=r.get("vendor"), coupon_name=r.get("name"), + status=status, app_env=app_env, + vendor=r.get("vendor"), coupon_name=r.get("name"), claimed_count=count, trace_id=trace_id, reason=r.get("reason"), extra=r, )) @@ -235,6 +248,47 @@ def sum_claimed_count(db: Session, user_id: int) -> int: return int(total or 0) +# ===== 领券平台推导(coupon_id → 平台;成功平台集)===== + +# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了); +# failed / skipped 不算。与 sum_claimed_count 同口径。 +_SUCCESS_STATUSES = frozenset({"success", "already_claimed"}) + +# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。 +DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai") + + +def coupon_id_to_platform(coupon_id: str | None) -> str | None: + """coupon_id 前缀 → 平台 id;无法识别 / 空 → None。 + + 与客户端 `CouponForegroundService.couponIdToPlatform` 同词表: + mt_→美团外卖 / tb_·ele_·elm_→淘宝闪购 / jd_→京东外卖。 + """ + if not coupon_id: + return None + if coupon_id.startswith("mt_"): + return "meituan-waimai" + if coupon_id.startswith(("tb_", "ele_", "elm_")): + return "taobao-shanguang" + if coupon_id.startswith("jd_"): + return "jd-waimai" + return None + + +def succeeded_platforms(results: list[dict]) -> list[str]: + """一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。 + + 只取 status∈{success, already_claimed} 的券;失败/跳过、无法识别平台的券跳过。 + """ + ok: set[str] = set() + for r in results: + if r.get("status") in _SUCCESS_STATUSES: + platform = coupon_id_to_platform(r.get("coupon_id")) + if platform is not None: + ok.add(platform) + return [p for p in DEFAULT_PLATFORMS if p in ok] + + # ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)===== def upsert_coupon_session( @@ -321,3 +375,31 @@ def upsert_coupon_session( except IntegrityError: # 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。 db.rollback() + + +def merge_session_platform_success( + db: Session, trace_id: str, platforms: list[str] +) -> None: + """把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。 + + - 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)。 + - 读不到该 trace_id 的行 → **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)。 + - 并集无变化(该平台已记过)→ 不写库,省一次 UPDATE。 + - fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略。 + """ + if not platforms: + return + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + if row is None: + return + merged = set(row.platform_success or []) | set(platforms) + new_list = [p for p in DEFAULT_PLATFORMS if p in merged] + if new_list == (row.platform_success or []): + return # 幂等:无新平台,不写 + row.platform_success = new_list + try: + db.commit() + except IntegrityError: + db.rollback() diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md index c6559b8..d68d64f 100644 --- a/docs/database/coupon_state.md +++ b/docs/database/coupon_state.md @@ -1,4 +1,4 @@ -# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录) +# coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session) > 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) @@ -7,8 +7,9 @@ - **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。 - **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。 - **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。 +- **`coupon_session`** — admin「领券数据」看板数据源(**独立流水表,不是「今日状态」表**)。按 `trace_id` 一次领券一行,走 `POST /api/v1/coupon/session` 两段上报(发起/收尾),记全程耗时 + 各平台耗时 + `platform_success`(成功平台,算整单②/点位③成功率)。详见下方专节。 -三表共同口径: +前三张「今日状态」表的共同口径: - **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。 - **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。 - **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。 @@ -90,7 +91,7 @@ ### 用在哪 / 增删改查 - **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。 - **U / D**:无业务删除。 -- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。 +- **R**:`GET /admin/api/coupon-data/coupons`(`coupon_slot_report`)—— admin「按券成功率」表,按 `coupon_id` 聚合 成功/(成功+失败)(`skipped` 排除,设备-天口径,按 `app_env` 过滤)。见设计 §13。 ### 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -101,6 +102,7 @@ | `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) | | `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 | | `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) | +| `app_env` | String(16) | index, 可空 | 领券所属 session 环境 `prod`/`dev`(`/step` 按 `trace_id` 取 `coupon_session.app_env` 打标);旧行 NULL(不回填)。admin「按券成功率」表按它过滤。见设计 §13 | | `vendor` | String(48) | 可空 | 券提供方 | | `coupon_name` | String(128) | 可空 | 取 pricebot `name` | | `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) | @@ -120,7 +122,49 @@ --- -## 三表共性小结 +## coupon_session — 领券任务流水(一次领券一行,admin「领券数据」看板数据源) + +`trace_id` 唯一,一次领券一行。与上面三张「今日状态」表不同:本表走 `POST /api/v1/coupon/session`(客户端**两段上报**:发起 `started` 建行、收尾 `completed`/`failed`/`abandoned` 按 `trace_id` 更新同一行),记从发起到收尾的全程耗时 + 各平台耗时 + 机型/ROM。发起即落库 → admin 可算「发起数」与中途流失(started 无终态 = 未完成)。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`upsert_coupon_session`,由 `POST /api/v1/coupon/session` 两段上报。**状态只前进**(started 帧重复到不覆盖已有终态);终态补 `finished_at`。 +- **U(并集写)**:`merge_session_platform_success`,由 `POST /api/v1/coupon/step` 每逢**带券结果的帧**调用——把本帧「成功平台」(`status∈{success,already_claimed}` 的券 → `coupon_id` 前缀映射平台)**并入** `platform_success`(并集幂等,无新平台不写;读不到该 trace 行则跳过)。复用 `record_claims` 的同一 `SessionLocal`,不新增连接。 +- **R**:admin `GET /admin/api/coupon-data`(`coupon_data_report`)—— 发起/完成数、耗时分位、**整单成功率②/点位成功率③**、按天/小时趋势、逐条明细;`GET /admin/api/coupon-data/user-records` 某用户全部领券。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `trace_id` | String(64) | NOT NULL, UNIQUE | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键 | +| `device_id` | String(64) | NOT NULL | | +| `user_id` | Integer | index, 可空 | 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空 | +| `status` | String(16) | NOT NULL | `started` / `completed` / `failed` / `abandoned`;started 无终态 = 中途流失 | +| `app_env` | String(16) | index, 可空 | `prod` / `dev`;admin 报表默认只看 prod(防测试串台) | +| `platforms` | JSON | 可空 | 发起勾选平台 `["meituan-waimai",…]`(空 = 全领三档);**③点位成功率的分母来源** | +| `origin_package` | String(64) | 可空 | 发起来源外卖 App 包名;null = App 内(傻瓜比价首页)发起,非空 = 外卖侧弹券 | +| `device_model` | String(128) | 可空 | Build.MANUFACTURER + MODEL | +| `rom` | String(64) | 可空 | OemDetector,如 "ColorOS 14" | +| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟);明细「时间」列、趋势 X 轴 | +| `started_date` | **Date** | NOT NULL | 发起的**北京**自然日;admin 按天聚合/筛选(索引) | +| `finished_at` | DateTime(tz) | 可空 | 收尾时刻(服务端 now);未收尾(流失)为空 | +| `elapsed_ms` | Integer | 可空 | 全程耗时(ms,客户端点发起→收尾);均值/分位只统计 completed | +| `platform_elapsed` | JSON | 可空 | 各平台领券耗时 `{"meituan-waimai":3200,…}`(ms) | +| `claimed_count` | Integer | 可空 | 领到总张数(收尾帧带) | +| `platform_success` | JSON(PG JSONB) | 可空 | **本次至少领到一张(`status∈{success,already_claimed}`)的平台 id 列表**,如 `["meituan-waimai","jd-waimai"]`。`/step` 逐帧按 `trace_id` **并集**写入(`merge_session_platform_success`);旧行 NULL 视作空集。admin 据此算整单成功率②(`platforms`⊆`platform_success`)/点位成功率③(Σ交集/Σ勾选)。设计:[领券成功率指标](../guides/领券成功率指标-设计与埋点.md) | +| `trace_url` | String(512) | 可空 | pricebot done 帧回传的公网 trace 链接;未到 done(failed/abandoned)为空 | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`app_env`;UNIQUE(`trace_id`) = `uq_coupon_session_trace`;Index(`started_date`, `app_env`) = `ix_coupon_session_date_env`(admin 主聚合/筛选)。 + +### 注意 +- `platform_success` 是**布尔性质**的平台集,跨帧**并集**天然幂等 → `/step` 每帧并入不重复计;失败/中途退出的 session 也能拿到崩溃前已成的平台。写放大 ≈ 领券券数(仅带券结果的帧写)。 +- 成功率**基数 = 区间全部 session**(含 abandoned/failed),与「发起数」同基数(设计 §3)。`coupon_id → 平台` 用前缀(`mt_`/`tb_`·`ele_`·`elm_`/`jd_`),与客户端 `couponIdToPlatform` 同词表。 + +--- + +## 今日状态三表共性小结 - 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。 - 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 - 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。 diff --git a/docs/guides/领券成功率指标-设计与埋点.md b/docs/guides/领券成功率指标-设计与埋点.md new file mode 100644 index 0000000..a1bb0cb --- /dev/null +++ b/docs/guides/领券成功率指标-设计与埋点.md @@ -0,0 +1,294 @@ +# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点 + +- 日期:2026-07-07 +- 状态:待评审 +- 涉及仓库:`shaguabijia-app-server`(**纯服务端**;客户端零改动) +- 数据源表:`coupon_session`(admin「领券数据」看板) + +## 1. 背景与目标 + +admin「领券数据」看板(数据源 `coupon_session`,见 `app/admin/repositories/coupon_data.py`)当前能算:**领券发起数、完成数、全程耗时均值/分位**。产品还想要两个成功率指标,现有埋点算不出来: + +- **② 整单成功率** = 一次发起里勾选的平台**全部**领到券的次数 / 领券发起数 +- **③ 点位成功率** = 平台维度的领取成功率(每个平台「点位」成没成功) + +> 口径决定(2026-07-07):「点位」= **平台粒度**(美团 / 淘宝闪购 / 京东),不是「每张券」。 + +耗时中位数、发起数已由 `coupon_session.elapsed_ms` / `status=started` 计数满足,本设计只补 ②③。 + +## 2. 关键结论:per-slot 信号已在库,缺的是「按 session 可靠归因 + 看板可过滤」 + +服务端 `/api/v1/coupon/step`(`app/api/v1/coupon.py`)每帧都调 `record_claims`,把**每张券**的结果(`status ∈ success / already_claimed / failed / skipped`)写进 `coupon_claim_record`,还带 `trace_id`。原始成败信号**已经落库**。 + +但该表**不能**直接支撑本指标: + +1. 唯一键是 `(device_id, coupon_id, claim_date)`,**不含 trace_id**;且 `record_claims` 冲突更新时**不更新 trace_id**(`app/repositories/coupon_state.py` 的 `record_claims`,仅 INSERT 时写 trace_id)。→ 同一张券当天跨多次 session 会折叠成一行、只归属**最早**那次 → **按 session 归因不可靠**(直接砸 ②「整单全成功」)。 +2. `coupon_claim_record` 无 `app_env` / `origin_package` → 无法像看板那样只看 prod、也无法拆 Path A(App 内发起)/ Path B(外卖侧弹券)。 + +因此采用 **route B**:在 `/step` 里服务端推导「本次 session 哪些平台成功」,直接写到 `coupon_session` 行——该表按 `trace_id` 唯一、已带 `app_env` / `origin_package` / `platforms`,指标干净可过滤、可拆路径。 + +## 3. 指标口径(平台粒度,已定) + +记一次 session 为 `s`: + +- `sel(s)` = **勾选平台集** = `coupon_session.platforms`;为空表示「全领」→ 取默认 `{meituan-waimai, taobao-shanguang, jd-waimai}`。`|sel(s)|` = 该次「点位数」。 +- `succ(s)` = **成功平台集** = 本次 session 里**至少领到一张**(`status ∈ {success, already_claimed}`)的平台集合。 + +指标: + +- **③ 点位成功率** = `Σ_s |succ(s) ∩ sel(s)|` / `Σ_s |sel(s)|` + (分母即「发起数 × 各自点位数」;全部全领时等于 发起数 × 3) +- **② 整单成功率** = `#{ s : sel(s) ⊆ succ(s) 且 sel(s) ≠ ∅ }` / `发起数` + +已定边界: + +1. **③ 分母用「勾选平台」`sel(s)`**。勾了淘宝但淘宝没领到 = 该点位未成功(不特判「平台没券」)。若日后要「真没券的平台不计入分母」,再引入 `platform_attempted`(见 §8)。 +2. **②③ 基数 = 区间内全部 session**(含 `started` / `failed` / `abandoned`),与看板「发起数」同基数。**失败 / 中途退出的 session 的 `succ(s)` 取它崩溃前真领到的平台**(不一律算 0)。 +3. **成功语义**:`status ∈ {success, already_claimed}` = 成功;`failed` / `skipped` = 未成功。与 `sum_claimed_count`(`app/repositories/coupon_state.py`)一致。 + > pricebot 协议文档把 `status` enum 写作 `success|failed|skipped`,但**代码实际还会 emit `already_claimed`**(pricebot `app/services/coupon_provider.py` 等多处)——以代码为准,含 already_claimed 是对的。`skipped` 目前 MVP 阶段基本不出现。 + +## 4. 数据模型改动 + +`coupon_session`(`app/models/coupon_state.py::CouponSession`)新增一列: + +| 列 | 类型 | 说明 | +|----|------|------| +| `platform_success` | `_JSON`(PG→JSONB / SQLite→JSON),nullable | 本次 session **至少领到一张**的平台 id 列表,如 `["meituan-waimai","jd-waimai"]`。旧行 = `NULL` → 视作空集。 | + +- alembic 新迁移:`add_column coupon_session.platform_success`,nullable、无 server_default。 +- **分母 `sel(s)` 复用已有 `platforms` 列,不新增字段。** 净新增仅此一列。 +- 同步更新表字典 `docs/database/coupon_state.md`。 + +## 5. 服务端推导逻辑(`/step`) + +在 `app/api/v1/coupon.py::coupon_step` 内(已有 `results = _extract_coupon_results(resp_json)`)新增: + +1. 对每条 result 求平台:**按 `coupon_id` 前缀映射**(与客户端 `CouponForegroundService.couponIdToPlatform` 对齐,且与 `platforms` / `platform_elapsed` 用同一套平台 id 词表): + - `mt_` → `meituan-waimai` + - `tb_` / `ele_` / `elm_` → `taobao-shanguang` + - `jd_` → `jd-waimai` + - 其余 → 跳过(无法识别) + > 不用券的 `vendor` 字段做映射:vendor 词表未必等于这三档平台 id,而 `sel(s)` 用的就是这三档,`succ(s)` 必须同词表。 +2. 收集 `status ∈ {success, already_claimed}` 的平台集合 `ok_platforms`。 +3. 若 `device_id` 且 `ok_platforms` 非空:调用新 repo 函数把 `ok_platforms` **并入** `coupon_session.platform_success`(按 `trace_id`)。 + +新增 `app/repositories/coupon_state.py::merge_session_platform_success(db, trace_id, ok_platforms)`: + +- 读现有行 → `platform_success = 现有 ∪ ok_platforms`(去重、保序)→ 写回、commit;`IntegrityError` 回滚忽略(同现有 upsert 兜底)。 +- **并集幂等**:跨帧多次并入同一平台不会重复;done 帧的全量 `coupon_results` 保证完整;失败 / 中途退出的 session 靠中间帧 `last_coupon_result` 已并入的平台拿到「部分成功」。 +- **fire-and-forget**:包 `run_in_threadpool` + 整段 try/except 只 log,绝不连累 `/step` 返回(与现有 `record_claims` / `mark_completed` 同规格)。 + +**建行 / 时序(已定:行不存在则跳过本次并入)**: + +- 客户端 `/session started` 在 `start()`(任务发起那刻)就发,而首个带券的 `/step` 要等领券循环跑起来、晚几秒;到 `/step` 有 `ok_platforms` 时,`coupon_session` 行几乎必然已存在。故 `merge_session_platform_success` **读不到行就跳过**,不建兜底行——实现最简,也不引入 `started_at` 不精确的脏行。 +- 残留丢数窗口:仅当「`started` 上报丢失」**且**「`/step` done 先于 `/session` terminal 落库」两者同时成立,该 session 的 `platform_success` 才会缺(terminal 帧会建行但那之后没有 `/step` 再并入)。两条件叠加概率极低,且指标是聚合口径、可容忍个别缺失。 +- 若上线后观测到该缺失不可忽略,再降级为「读不到行则 upsert 建最小兜底行(`started_at=now()`)」——届时改 `merge_session_platform_success` 一处即可,不影响其余设计。 + +**写放大(重要,非每 step)**:`merge_session_platform_success` **只在「本帧带券结果」时触发**——即 pricebot 在**单券完成帧**给 `last_coupon_result`、**最终 done 帧**给全量 `coupon_results` 的那些帧;领单张券途中的导航/点击帧(占 step 大头)`_extract_coupon_results` 返回空 → **不写**。所以写频次 ≈ **本次领的券数**(通常个位数),且落在**服务端今天已有的** `record_claims` 写的**同一批帧**上,不新增写的帧。 + +实现:把 merge 放进**现有 `_record_claims_blocking` 的同一个 `SessionLocal`**(紧接 `record_claims`),边际成本 = 每张券完成时多一条 `UPDATE coupon_session`(按 `trace_id` 唯一索引),不新增连接 / 不新增 `run_in_threadpool` 调用。 + +可选降级(若要「一次 session 只写一次」):只在 done 帧写 `platform_success`(全量 `coupon_results` 一次算完)。代价:`failed` / `abandoned`(没 done 帧)拿不到「崩溃前已成平台」→ 失败单部分成功丢失,与 §3「失败单取实际成的平台」相悖。**默认取每券帧并入**(失败单也如实统计),此降级留作观测到写压力后再启用。 + +## 6. admin 聚合与呈现 + +`app/admin/repositories/coupon_data.py::coupon_data_report` 的 `summary` 增加(沿用「全量拉区间 → Python 聚合」风格,与分位一致): + +- `full_success_rate`(②)、`point_success_rate`(③) +- 可选 `per_platform`:`{platform: rate}`(各平台点位成功率,拆美团/淘宝/京东) + +计算:对区间内 sessions,`sel = platforms or 默认三档`,`succ = set(platform_success) ∩ sel`;按 §3 公式汇总。 + +- 过滤:默认 `app_env == 'prod'`(同现有分位口径,防测试串台)。 +- 拆路径:`origin_package` 已在表上 → 看板后续可加「Path A / Path B」筛选项(本设计不含前端图表细节)。 +- schema:`app/admin/schemas/coupon_data.py` 的 summary 加对应字段(+ 可选 `per_platform`)。 + +## 7. 改动清单 + +- [x] `app/models/coupon_state.py`:`CouponSession` 加 `platform_success` +- [x] `alembic/versions/`:新迁移 add column `coupon_session.platform_success` +- [x] `app/repositories/coupon_state.py`:新增 `merge_session_platform_success` +- [x] `app/api/v1/coupon.py`:`/step` 推导 `ok_platforms` 并 union(前缀映射 + fire-and-forget) +- [x] `app/admin/repositories/coupon_data.py`:`summary` 加 ②③(+ 可选 `per_platform`) +- [x] `app/admin/schemas/coupon_data.py`:`summary` schema 加字段 +- [x] `docs/database/coupon_state.md`:补 `platform_success` 列说明(并补 coupon_session 整节) +- [x] `tests/`:`tests/test_coupon_platform_success.py`(10 测试,见 §9) + +## 8. 不做(YAGNI / 边界) + +- **客户端不改、历史不回填**:`platform_success` 只对新 session 生效(route B 的固有取舍,用户已接受)。 +- **不引入 `platform_attempted`**:③ 分母用勾选平台。若日后要「排除真没券的平台」,注意 `platform_elapsed.keys()` 已近似「被处理过的平台」,可作 attempted 的现成来源,多半仍不必加列。 +- **不动 `coupon_claim_record`** 的去重 / 归因:本指标绕开它,避免牵动频控 / 资产 / CPS 语义。 +- **不做券级(每张券)成功率**:已选平台粒度。 + +## 9. 测试口径要点 + +- **union 幂等**:同一 `trace_id` 多帧并入同一平台,`platform_success` 不重复、保序。 +- **失败单部分成功**:session `failed`,但美团已成 → `succ = {meituan-waimai}`,计入 ③ 分子;② 仅当 `sel ⊆ succ` 才算整单成功。 +- **空 `platforms` → `sel` 取默认三档**(全领)。 +- **成功语义**:`already_claimed` 计成功;`skipped` 不计。 +- **基数**:`abandoned` 计入 ②③ 基数,`succ` 取实际成的平台。 +- **prod 过滤**:`dev` 环境 session 不进指标。 + +## 10. 对 pricebot-backend 的影响 + +**结论:不需要改 pricebot,也不改发往 pricebot 的请求 / 不加调用 / 不加负载。** + +- `/step` 仍原样透传请求 bytes 给 pricebot;本设计只**多解析 pricebot 的响应**(`coupon_results` / `last_coupon_result`),而这两个字段服务端**今天已在** `_extract_coupon_results` / `record_claims` 里解析。零新增字段需求、零额外上游调用、pricebot 负载不变。 +- **只读依赖(既有耦合,非新引入)**:平台映射靠 pricebot 的 `coupon_id` 前缀约定(`mt_` / `tb_` / `ele_` / `elm_` / `jd_`)。客户端 `couponIdToPlatform` 早就依赖同一套;本设计只是加了这份映射的第二个消费者。维护耦合:pricebot 若改 `coupon_id` 前缀,客户端与本指标会**一起**失效——但这是既有风险,依赖方向不变。无法识别前缀的券按「跳过」处理(与客户端一致)。 +- 认账的 pricebot 事实(源:`app/models/response.py` + `docs/projects/领券-客户端对接协议.md`): + - `CouponResult = {coupon_id, name, vendor, status, reason?, duration_ms?}`;`coupon_results` 仅最终 done 帧全量,中间帧走 `last_coupon_result`(单张)。 + - `vendor` 是来源标签(`meituan_internal` / `dianping_cps` …),**不等于**三档平台 id;且 `mt_dianping_xxx`(大众点评 CPS)也带 `mt_` 前缀归美团 → 印证「用 `coupon_id` 前缀、不用 `vendor`」正确。 + +--- + +## 11. 实现状态(交付记录 · 2026-07-07) + +**状态:实现完成、TDD 全绿、未提交、迁移未应用。** 纯服务端(shaguabijia-app-server),客户端 / pricebot 未动。 + +### 已交付改动 +| 文件 | 改动 | +|---|---| +| `app/models/coupon_state.py` | `CouponSession` 加 `platform_success`(`_JSON`, nullable) | +| `app/repositories/coupon_state.py` | `coupon_id_to_platform` / `succeeded_platforms` / `merge_session_platform_success` + 常量 `DEFAULT_PLATFORMS` / `_SUCCESS_STATUSES` | +| `app/api/v1/coupon.py` | `_record_claims_blocking` 内、`record_claims` 之后并入本帧成功平台(同一 `SessionLocal`) | +| `app/admin/repositories/coupon_data.py` | `_success_rates(rows)` → summary 加 `full_success_rate②` / `point_success_rate③` + 3 个支撑计数 | +| `app/admin/schemas/coupon_data.py` | `CouponDataSummary` 加 5 字段 | +| `alembic/versions/coupon_session_platform_success.py` | add column;revision=`coupon_session_platform_success`,down=`admin_user_plain_password`(当前 head) | +| `docs/database/coupon_state.md` | 补 `coupon_session` 整节(表原本无文档)+ 新列 | +| `tests/test_coupon_platform_success.py` | 10 个测试(TDD) | + +> §7 里「可选 `per_platform`」原标 YAGNI 延后;**已在 §12(2026-07-08)补做**(随 admin 前端看板卡一并接入,见下)。 + +### 测试与验证 +- 本特性:`Set-Location e:\project\shaguabijia-app-server; & .\.venv\Scripts\python.exe -m pytest tests\test_coupon_platform_success.py -q` → **10 passed**。 +- 全量 `pytest -q`:**306 passed / 5 failed**。5 个失败**全部预存、与本次无关**: + - `test_coupon_proxy.py::test_coupon_step_passes_body_through`(测试断言 `json=` 但 handler 用 `content=` 转发;写代码前 sanity run 就红)。 + - `test_invite.py` + `test_invite_compare_reward.py` 共 4 个(单独跑也红;本次改动集零 invite 文件)。 +- 迁移:临时库 `alembic upgrade head` 通过、列已建、单一 head。 +- lint:新增代码 `ruff` 全清;`coupon_state.py` 剩 3 处 pre-existing UP017(`upsert_coupon_session` 的 `timezone.utc`)未动。 + +### 待办(在后端项目里继续) +1. **应用迁移**:`alembic upgrade head`(DDL 已验;线上只前进统计、历史不回填)。 +2. **提交**:尚未提交;建议先开分支再提交。 +3. **admin 前端图表**:后端指标已就绪(summary 的 `full_success_rate` / `point_success_rate` 等),看板卡 / 趋势展示待接前端。 +4. (可选)修预存 `test_coupon_step_passes_body_through`(一行:capture `content` 而非 `json`)。 + +### 续开发须知 +- `platform_success` 只在**带券结果的帧**写(`/step` 里 `succeeded_platforms(results)` 非空才 merge),≈ 领券券数量级、非每 step;复用 `record_claims` 同一 `SessionLocal`、不新增连接。 +- merge **读不到 session 行则跳过**(不建兜底行);并集幂等、无新平台不写。 +- 口径:成功=`status∈{success,already_claimed}`;基数含 `abandoned`/`failed`;③ 分母=`platforms`(空→全领三档);`coupon_id`→平台走前缀,与客户端 `couponIdToPlatform` 同词表。 + +--- + +## 12. 续做:admin 前端看板卡 + 分平台点位成功率(设计 · 2026-07-08) + +承 §11 待办 #3(前端图表)与 §7「可选 `per_platform`」。本轮把 ②③ 接入 admin「领券数据」页,并把 ③ 按平台拆(`per_platform`)。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。 + +- 状态:设计已评审通过(2026-07-08),待实现。 +- 涉及仓库:`shaguabijia-app-server`(后端)+ `shaguabijia-admin-web`(admin 前端,Next.js + antd)。 + +### 12.1 后端:`per_platform` 分平台点位成功率 + +`app/admin/repositories/coupon_data.py::_success_rates(rows)` 在现有合计基础上,对每个 `p ∈ DEFAULT_PLATFORMS`(美团/淘宝/京东)累加: + +- 分母 `per_total[p]` = 勾选了 p 的 session 数(`p ∈ sel`,空勾选 `sel` 按全领三档); +- 分子 `per_succ[p]` = 其中 `p ∈ succ`(该平台至少领到一张)的 session 数; +- `per_platform[p]` = `round(per_succ[p] / per_total[p], 4)`;分母 0 → `None`。 + +**不变量**:`Σ_p per_succ[p] == point_success_count`、`Σ_p per_total[p] == point_total_count`(三档词表下恒成立;非三档平台 id 不计入 `per_platform`,由 `if p in per_total` 守卫)。用 §9 / `test_coupon_data_success_rates` 数据自检:美团 3/4=0.75、淘宝 2/3=0.6667、京东 1/2=0.5;合计仍 6/9=0.6667。 + +- summary 加一项 `per_platform`:**恒含三档键**,如 `{"meituan-waimai":0.75,"taobao-shanguang":0.6667,"jd-waimai":0.5}`(区间内无人勾选的平台 → 值 `None`)。 +- schema `app/admin/schemas/coupon_data.py::CouponDataSummary` 加 `per_platform: dict[str, float | None]`。 + +### 12.2 前端:admin-web「领券数据」汇总卡补一段 + +`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`(单文件,`CouponDataSummary` 为该页内联类型): + +- 内联 `CouponDataSummary` 补:`full_success_count` / `full_success_rate` / `point_success_count` / `point_total_count` / `point_success_rate` + `per_platform: Record`。 +- 新 helper `fmtPct(v) = v == null ? '-' : ${(v*100).toFixed(1)}%`(沿用本页 `-` 空值风格,数学同大盘 `pct`);antd 导入补 `Tooltip`,新增 `import { InfoCircleOutlined } from '@ant-design/icons'`。 +- 汇总卡「耗时分位」行之后,`Divider` + 两行 `Statistic`(各 `Col flex="1 1 0"`): + - 行1:**整单成功率** `fmtPct(full_success_rate)` · **点位成功率(合计)** `fmtPct(point_success_rate)`;标题各带 ⓘ `Tooltip`(口径说明;合计率注明「平台粒度、= 三档之和,与『数据大盘』券粒度口径不同」)。 + - 行2:**美团 / 淘宝 / 京东 点位成功率** `fmtPct(per_platform['meituan-waimai' | 'taobao-shanguang' | 'jd-waimai'])`(平台名同「美团耗时」列既有叫法)。 +- 不显示支撑数(合计与分平台均纯百分比);tooltip 不带分母。 + +### 12.3 测试 + +- 扩 `tests/test_coupon_platform_success.py::test_coupon_data_success_rates`:断言 `per_platform == {"meituan-waimai":0.75,"taobao-shanguang":round(2/3,4),"jd-waimai":0.5}`,并断言和不变量(`Σ 分子 == point_success_count == 6`、`Σ 分母 == point_total_count == 9`)。 +- 前端 `shaguabijia-admin-web` lint / type-check 通过。 +- 端到端:随下一步「真实 /step 实测」在跑起来的 admin-web + 后端页面上核对卡片渲染。 + +### 12.4 不做(YAGNI) + +- 成功率**趋势线**:后端 `daily` / `hourly` 不含率字段,加趋势要另改聚合,超出本轮范围。 +- 合计 / 分平台的**支撑数副文本**、tooltip 带分母。 +- 客户端 / pricebot 改动;历史回填。 + +--- + +## 13. 续做:每券(coupon_id)成功率明细表(设计 · 2026-07-08) + +产品要更细粒度:到**具体领券点位**(如「美团外卖红包天天领」「美团甄选好店」),即按 `coupon_id` 算成功/失败率,比 §12 的平台粒度再细一层。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。 + +- 状态:设计已评审通过(2026-07-08),待实现。 +- 关键取舍(已定):数据源用 **`coupon_claim_record`**(它本就是「单张券一天一条」的系统记录,已带 `coupon_name` / `status` / `vendor` / `trace_id`),唯一缺 `app_env` → 补一列即可。§2 当初绕开它是因为**平台指标要按 session 归因**;而**每券成功率是全局聚合、不需要 session 归因**,`(device,券,天)` 折叠反而天然去重防刷,故这里用它是对的。 + +### 13.1 指标口径(已定) + +- **成功** = `status ∈ {success, already_claimed}`;**尝试(分母)** = `status ∈ {success, already_claimed, failed}`;**`skipped` 排除**(无券可领/不适用,不算尝试、不进分母、不展示)。 +- **成功率** = 成功 / 尝试(某券区间内无 tried 行 → 不出现在表里,无除零)。 +- **粒度 = 「设备-天」**(非「每次点击」):`coupon_claim_record` 唯一键 `(device, coupon_id, claim_date)`,同设备当天同券只留最后状态。所以「尝试」= 有多少**设备-天**尝试过该券,「成功」= 其中最终领到的。要每次点击级须换源(route B / 原始事件),本轮不做。 +- **环境**:跟随页面「环境」(prod/dev/全部),按 `app_env` 过滤;旧行 `app_env=NULL` **不回填** → 仅「全部」视图可见。 +- **日期**:按 `claim_date`(Asia/Shanghai 自然日),与页面日期范围一致。 + +### 13.2 数据模型 + +`CouponClaimRecord`(`app/models/coupon_state.py`)加一列: + +| 列 | 类型 | 说明 | +|----|------|------| +| `app_env` | `String(16)`,index,nullable | prod / dev。`/step` 落库时按 session 的 app_env 打标;旧行 NULL(不回填)。 | + +- alembic 新迁移:`add_column coupon_claim_record.app_env`,nullable + index,无 server_default。 +- 同步更新 `docs/database/coupon_state.md` 的 coupon_claim_record 节。 + +### 13.3 写路径(`/step`) + +- `app/repositories/coupon_state.py::record_claims` 加参数 `app_env: str | None = None`;INSERT 时写入,UPDATE 时 `if app_env is not None: row.app_env = app_env`(不用 None 覆盖已有)。 +- 新增轻量 repo 助手 `session_app_env(db, trace_id) -> str | None`(按 trace_id 取 `coupon_session.app_env`,查不到返回 None)。 +- `app/api/v1/coupon.py::_record_claims_blocking`:`trace_id` 存在则先 `app_env = coupon_repo.session_app_env(db, trace_id)`,传给 `record_claims(..., app_env=app_env)`;`merge_session_platform_success` 保持原样(其自身 select 不变)。查不到 session / 无 trace_id → `app_env=None`(行为同旧)。 + > 写放大:仅「带券结果的帧」触发(同 §5),每帧多一次 `session_app_env` 小查询(按 trace_id 唯一索引),可忽略。 + +### 13.4 admin 聚合(新 repo 函数) + +`app/admin/repositories/coupon_data.py` 新增 `coupon_slot_report(db, *, date_from, date_to, app_env)`(与 `coupon_user_records` 同居本文件,同属「领券数据」看板;更新模块 docstring 注明本文件现读 `coupon_session` + `coupon_claim_record` 两源): + +- `SELECT coupon_id, MAX(coupon_name) AS coupon_name, COUNT(*) AS tried, SUM(CASE WHEN status IN (success,already_claimed) THEN 1 ELSE 0 END) AS succeeded` + `WHERE claim_date ∈ [from,to] AND status IN (success,already_claimed,failed)` (+ `AND app_env = :env` 当 env 非「全部」) `GROUP BY coupon_id`。 +- 每行:`platform = coupon_id_to_platform(coupon_id)`(复用前缀映射,无法识别→None),`success_rate = round(succeeded/tried, 4)`。 +- 按 `tried` 倒序、`coupon_id` 次序返回 `{"items": [...]}`。 + +### 13.5 接口 + schema(新子端点) + +- 路由 `app/admin/routers/coupon_data.py`:加 `@router.get("/coupons")` → `get_coupon_slots`,参数 `date_from` / `date_to` / `app_env`(同主 report 的解析与 `_MAX_RANGE_DAYS` 校验,`app_env="all"→None`),调 `coupon_slot_report`。路径全称 `/admin/api/coupon-data/coupons`(与现有 `/coupon-data/user-records` 同款子端点)。 +- schema `app/admin/schemas/coupon_data.py`: + - `CouponSlotRow{coupon_id: str, coupon_name: str|None, platform: str|None, tried: int, succeeded: int, success_rate: float|None}` + - `CouponSlotsOut{date_from: str, date_to: str, items: list[CouponSlotRow]}` + +### 13.6 前端 + +`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`: + +- 汇总卡下方(或趋势图下方)新增一张「按券成功率」表:列 **券名**(`coupon_name || coupon_id`)/ **平台**(美团/淘宝/京东/其他)/ **尝试** / **成功** / **成功率**(`fmtPct`)。默认按尝试倒序;支持 antd 列排序。 +- 点「查询」时,除主 report 外并行 `api.get('/admin/api/coupon-data/coupons', {params:{date_from,date_to,app_env}})`;空则不显示表。 +- 新增内联类型 `CouponSlotRow`;平台名复用映射(`meituan-waimai→美团` 等,null→其他)。 + +### 13.7 测试 + +- 后端:`record_claims` stamp `app_env`(INSERT/UPDATE 两路);`session_app_env` 助手;`/step` 集成写入 `app_env`;`coupon_slot_report` 聚合(多券 × success/already_claimed/failed/skipped × 两 env:断言 tried/succeeded/rate、**skipped 排除**、env 过滤、排序);schema 契约。 +- 前端:`tsc --noEmit`。 + +### 13.8 不做(YAGNI) + +- 每次点击级成功率(需换数据源);成功率趋势线;`app_env` 历史回填(可选一次性 `join trace_id→session.app_env`,默认不做);券级 tooltip / 明细下钻。 +- 券表为**全局聚合**,**不随页面「用户」搜索框过滤**(用户维度非本需求;`coupon_claim_record` 虽有 `user_id`,YAGNI)。与主明细表按用户过滤的行为不同,属有意为之。 diff --git a/tests/test_coupon_platform_success.py b/tests/test_coupon_platform_success.py new file mode 100644 index 0000000..712758a --- /dev/null +++ b/tests/test_coupon_platform_success.py @@ -0,0 +1,272 @@ +"""领券「平台成功率」埋点:coupon_id→平台映射 / 成功平台推导 / session platform_success 并集。 + +设计:docs/guides/领券成功率指标-设计与埋点.md +""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import delete, func, select + +from app.admin.repositories.coupon_data import coupon_data_report +from app.db.session import SessionLocal +from app.models.coupon_state import CouponSession +from app.repositories.coupon_state import ( + coupon_id_to_platform, + merge_session_platform_success, + succeeded_platforms, +) + + +def _agg_session( + trace: str, platforms, platform_success, *, status: str = "completed" +) -> CouponSession: + """构造一条聚合测试用 session(started_date 固定 2020-01-02、app_env=prod,不 commit)。""" + return CouponSession( + trace_id=trace, + device_id="d-agg", + status=status, + app_env="prod", + platforms=platforms, + platform_success=platform_success, + started_at=datetime(2020, 1, 2, tzinfo=UTC), + started_date=date(2020, 1, 2), + ) + + +def _make_session(db, trace_id: str, **kw) -> CouponSession: + row = CouponSession( + trace_id=trace_id, + device_id="dev-merge", + status=kw.pop("status", "started"), + started_at=datetime(2020, 1, 1, tzinfo=UTC), + started_date=date(2020, 1, 1), + **kw, + ) + db.add(row) + db.commit() + return row + + +def test_coupon_id_to_platform_prefix_mapping() -> None: + """coupon_id 前缀 → 三档平台 id(与客户端 couponIdToPlatform 同词表)。""" + assert coupon_id_to_platform("mt_banjia_zhoumo") == "meituan-waimai" + assert coupon_id_to_platform("mt_cps_waimai_redpacket") == "meituan-waimai" # 大众点评CPS 也挂 mt_ → 归美团 + assert coupon_id_to_platform("tb_vip_shangou_voucher") == "taobao-shanguang" + assert coupon_id_to_platform("ele_hongbao") == "taobao-shanguang" + assert coupon_id_to_platform("elm_hongbao") == "taobao-shanguang" + assert coupon_id_to_platform("jd_redpacket") == "jd-waimai" + + +def test_coupon_id_to_platform_unknown_returns_none() -> None: + """无法识别的前缀 / 空 → None(调用方跳过,不计入平台)。""" + assert coupon_id_to_platform("weird_xxx") is None + assert coupon_id_to_platform("") is None + assert coupon_id_to_platform(None) is None # type: ignore[arg-type] + + +def test_succeeded_platforms_filters_status_and_dedups() -> None: + """只取 status∈{success,already_claimed} 的券,映射平台后去重;失败/跳过/无法识别的不计。""" + results = [ + {"coupon_id": "mt_a", "status": "success"}, + {"coupon_id": "mt_b", "status": "already_claimed"}, # 也算成功 → 仍是美团 + {"coupon_id": "mt_c", "status": "failed"}, # 不算 + {"coupon_id": "tb_a", "status": "success"}, + {"coupon_id": "jd_a", "status": "skipped"}, # 不算 + {"coupon_id": "weird", "status": "success"}, # 无法识别平台 → 跳过 + ] + assert set(succeeded_platforms(results)) == {"meituan-waimai", "taobao-shanguang"} + + +def test_succeeded_platforms_empty() -> None: + assert succeeded_platforms([]) == [] + + +def test_merge_platform_success_union_idempotent() -> None: + """按 trace_id 把成功平台并入 platform_success:并集去重 + 按 DEFAULT_PLATFORMS 保序 + 重复并入幂等。""" + db = SessionLocal() + trace = "merge-union-1" + try: + _make_session(db, trace) + merge_session_platform_success(db, trace, ["meituan-waimai"]) + merge_session_platform_success(db, trace, ["jd-waimai", "meituan-waimai"]) # 并集 + 已有幂等 + db.expire_all() + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == trace) + ).scalar_one() + # 美团→淘宝→京东固定序;只含实际成功的美团/京东 + assert row.platform_success == ["meituan-waimai", "jd-waimai"] + finally: + db.execute(delete(CouponSession).where(CouponSession.trace_id == trace)) + db.commit() + db.close() + + +def test_merge_platform_success_missing_row_skips() -> None: + """trace_id 无对应行 → 静默跳过:不建兜底行、不抛异常(设计 §5)。""" + db = SessionLocal() + try: + merge_session_platform_success(db, "no-such-trace", ["meituan-waimai"]) + n = db.execute( + select(func.count()).select_from(CouponSession).where( + CouponSession.trace_id == "no-such-trace" + ) + ).scalar_one() + assert n == 0 + finally: + db.close() + + +def test_coupon_data_success_rates() -> None: + """admin 聚合 ②整单成功率 / ③点位成功率:平台粒度,含空 platforms→全领三档、abandoned 入基数。 + + A 勾美团+淘宝、两个都成 → 整单成功;点位 2/2 + B 勾三档、只成美团 → 非整单;点位 1/3 + C 全领(platforms空→3)、三档全成 → 整单成功;点位 3/3 + D abandoned、勾美团、无成功平台 → 非整单;点位 0/1(仍进基数) + 发起数=4;整单成功=2 → 0.5;点位 6/9 → 0.6667 + """ + db = SessionLocal() + try: + db.add_all([ + _agg_session("agg-A", ["meituan-waimai", "taobao-shanguang"], + ["meituan-waimai", "taobao-shanguang"]), + _agg_session("agg-B", ["meituan-waimai", "taobao-shanguang", "jd-waimai"], + ["meituan-waimai"]), + _agg_session("agg-C", [], ["meituan-waimai", "taobao-shanguang", "jd-waimai"]), + _agg_session("agg-D", ["meituan-waimai"], None, status="abandoned"), + ]) + db.flush() # 同会话可见,不 commit(finally 回滚保持隔离) + s = coupon_data_report( + db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod" + )["summary"] + assert s["started_count"] == 4 + assert s["full_success_count"] == 2 + assert s["full_success_rate"] == 0.5 + assert s["point_success_count"] == 6 + assert s["point_total_count"] == 9 + assert s["point_success_rate"] == round(6 / 9, 4) + # ③ 分平台点位成功率(§12):美团 3/4、淘宝 2/3、京东 1/2。 + # 分平台(成功,总)= 美团(3,4)+淘宝(2,3)+京东(1,2) = (6,9), + # 其和正好等于上面已断言的 point_success_count=6 / point_total_count=9(和不变量)。 + assert s["per_platform"] == { + "meituan-waimai": 0.75, + "taobao-shanguang": round(2 / 3, 4), + "jd-waimai": 0.5, + } + finally: + db.rollback() + db.close() + + +def test_coupon_data_success_rates_empty_range() -> None: + """区间无 session → 发起数 0、两个率为 None(不除零)。""" + db = SessionLocal() + try: + s = coupon_data_report( + db, date_from="2019-01-01", date_to="2019-01-01", app_env="prod" + )["summary"] + assert s["started_count"] == 0 + assert s["full_success_rate"] is None + assert s["point_success_rate"] is None + finally: + db.close() + + +def test_coupon_data_summary_schema_exposes_rates() -> None: + """schema 契约:CouponDataSummary 暴露 ②③ 字段,键名与 repo 输出一致(router 直接 **summary 构造)。""" + from app.admin.schemas.coupon_data import CouponDataSummary + + db = SessionLocal() + try: + db.add(_agg_session("agg-schema-1", ["meituan-waimai"], ["meituan-waimai"])) + db.flush() + summary = coupon_data_report( + db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod" + )["summary"] + dumped = CouponDataSummary(**summary).model_dump() + assert dumped["full_success_rate"] == summary["full_success_rate"] + assert dumped["point_success_rate"] == summary["point_success_rate"] + assert dumped["full_success_count"] == summary["full_success_count"] + assert dumped["point_success_count"] == summary["point_success_count"] + assert dumped["point_total_count"] == summary["point_total_count"] + assert dumped["per_platform"] == summary["per_platform"] + finally: + db.rollback() + db.close() + + +def test_step_writes_platform_success_to_session(client) -> None: + """/step 集成:pricebot 返 coupon_results → 本 trace 的 coupon_session.platform_success 落库(仅成功平台)。""" + from unittest.mock import MagicMock, patch + + import httpx + + from app.models.coupon_state import CouponClaimRecord, CouponDailyCompletion + + trace = "int-trace-1" + device = "dev-int-1" + + # 预置一条 started session(模拟 /session started 已先落库) + db = SessionLocal() + try: + db.add(CouponSession( + trace_id=trace, device_id=device, status="started", app_env="dev", + platforms=["meituan-waimai", "taobao-shanguang"], + started_at=datetime(2020, 1, 5, tzinfo=UTC), + started_date=date(2020, 1, 5), + )) + db.commit() + finally: + db.close() + + fake_resp = { + "success": True, + "action": {"command": "done", "params": { + "information": "已领 1 张", + "coupon_results": [ + {"coupon_id": "mt_x", "name": "美团券", "vendor": "meituan_internal", "status": "success"}, + {"coupon_id": "tb_y", "name": "淘宝券", "vendor": "taobao", "status": "failed"}, + ], + }}, + "continue": False, + } + + async def fake_post(self, url, **kw): + m = MagicMock() + m.status_code = 200 + m.json = lambda: fake_resp + return m + + body = { + "device_id": device, "trace_id": trace, "step": 5, + "screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0}, + "foreground": {"package": "x", "activity": ""}, "windows": []}, + } + + try: + with patch.object(httpx.AsyncClient, "post", fake_post): + r = client.post("/api/v1/coupon/step", json=body) + assert r.status_code == 200, r.text + + db = SessionLocal() + try: + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == trace) + ).scalar_one() + assert row.platform_success == ["meituan-waimai"] # tb 失败不计入 + claims = db.execute( + select(CouponClaimRecord).where(CouponClaimRecord.device_id == device) + ).scalars().all() + assert claims and all(c.app_env == "dev" for c in claims) # session app_env 打标 + finally: + db.close() + finally: + db = SessionLocal() + try: + db.execute(delete(CouponSession).where(CouponSession.trace_id == trace)) + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device)) + db.execute(delete(CouponDailyCompletion).where(CouponDailyCompletion.device_id == device)) + db.commit() + finally: + db.close() diff --git a/tests/test_coupon_slots.py b/tests/test_coupon_slots.py new file mode 100644 index 0000000..b4f2a5b --- /dev/null +++ b/tests/test_coupon_slots.py @@ -0,0 +1,137 @@ +"""每券成功率(§13):app_env 落库 + coupon_slot_report 聚合。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import delete, select + +from app.admin.repositories.coupon_data import coupon_slot_report +from app.db.session import SessionLocal +from app.models.coupon_state import CouponClaimRecord, CouponSession +from app.repositories.coupon_state import record_claims, session_app_env + + +def test_session_app_env_lookup() -> None: + db = SessionLocal() + trace = "slot-env-1" + try: + db.add(CouponSession( + trace_id=trace, device_id="d-slot", status="started", app_env="dev", + started_at=datetime(2020, 2, 1, tzinfo=UTC), started_date=date(2020, 2, 1), + )) + db.commit() + assert session_app_env(db, trace) == "dev" + assert session_app_env(db, "no-such-trace") is None + assert session_app_env(db, None) is None + finally: + db.execute(delete(CouponSession).where(CouponSession.trace_id == trace)) + db.commit() + db.close() + + +def test_record_claims_stamps_app_env() -> None: + db = SessionLocal() + dev = "d-slot-stamp" + try: + record_claims(db, dev, None, "t-stamp", + [{"coupon_id": "mt_x", "status": "success", "name": "美团券"}], + app_env="prod") + row = db.execute(select(CouponClaimRecord).where( + CouponClaimRecord.device_id == dev)).scalar_one() + assert row.app_env == "prod" + # UPDATE 路:app_env=None 不覆盖已有值 + record_claims(db, dev, None, "t-stamp", + [{"coupon_id": "mt_x", "status": "already_claimed", "name": "美团券"}], + app_env=None) + db.expire_all() + row = db.execute(select(CouponClaimRecord).where( + CouponClaimRecord.device_id == dev)).scalar_one() + assert row.app_env == "prod" + assert row.status == "already_claimed" + finally: + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == dev)) + db.commit() + db.close() + + +def _claim(db, device, coupon_id, status, app_env, name=None, d=date(2020, 2, 2)): + db.add(CouponClaimRecord( + device_id=device, coupon_id=coupon_id, claim_date=d, + status=status, coupon_name=name, app_env=app_env, + )) + + +def test_coupon_slot_report_aggregates() -> None: + """按 coupon_id 聚合:tried=成功+失败(skipped 排除)、rate、env 过滤、tried 倒序。""" + db = SessionLocal() + try: + # mt_a(prod):dev1 success + dev2 already_claimed + dev3 failed → 2/3;dev4 skipped 不计 + _claim(db, "sdev1", "mt_a", "success", "prod", "美团外卖红包天天领") + _claim(db, "sdev2", "mt_a", "already_claimed", "prod", "美团外卖红包天天领") + _claim(db, "sdev3", "mt_a", "failed", "prod", "美团外卖红包天天领") + _claim(db, "sdev4", "mt_a", "skipped", "prod", "美团外卖红包天天领") + _claim(db, "sdev1", "tb_b", "success", "prod", "淘宝券") # 1/1 + _claim(db, "sdev9", "mt_a", "failed", "dev", "美团外卖红包天天领") # 仅 dev/全部 + db.commit() + + prod = coupon_slot_report( + db, date_from="2020-02-02", date_to="2020-02-02", app_env="prod" + )["items"] + by_id = {r["coupon_id"]: r for r in prod} + assert by_id["mt_a"]["tried"] == 3 # skipped 排除 + assert by_id["mt_a"]["succeeded"] == 2 + assert by_id["mt_a"]["success_rate"] == round(2 / 3, 4) + assert by_id["mt_a"]["platform"] == "meituan-waimai" + assert by_id["mt_a"]["coupon_name"] == "美团外卖红包天天领" + assert by_id["tb_b"]["success_rate"] == 1.0 + assert by_id["tb_b"]["platform"] == "taobao-shanguang" + assert [r["coupon_id"] for r in prod] == ["mt_a", "tb_b"] # tried 倒序 + + dev = coupon_slot_report( + db, date_from="2020-02-02", date_to="2020-02-02", app_env="dev" + )["items"] + assert {r["coupon_id"]: r["tried"] for r in dev} == {"mt_a": 1} + allenv = coupon_slot_report( + db, date_from="2020-02-02", date_to="2020-02-02", app_env=None + )["items"] + assert {r["coupon_id"]: r["tried"] for r in allenv}["mt_a"] == 4 + finally: + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.claim_date == date(2020, 2, 2))) + db.commit() + db.close() + + +def test_coupon_slots_endpoint() -> None: + from fastapi.testclient import TestClient + + from app.admin.main import admin_app + from app.admin.repositories import admin_user as admin_repo + from app.admin.security import create_admin_token + + db = SessionLocal() + try: + _claim(db, "epdev1", "mt_ep", "success", "prod", "端点券", d=date(2020, 2, 3)) + _claim(db, "epdev2", "mt_ep", "failed", "prod", "端点券", d=date(2020, 2, 3)) + admin = admin_repo.create_admin( + db, username="slot_admin", password="pass1234", role="super_admin" + ) + token, _exp = create_admin_token(admin_id=admin.id, role=admin.role) + db.commit() + finally: + db.close() + try: + c = TestClient(admin_app) + r = c.get( + "/admin/api/coupon-data/coupons", + params={"date_from": "2020-02-03", "date_to": "2020-02-03", "app_env": "prod"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 200, r.text + row = next(x for x in r.json()["items"] if x["coupon_id"] == "mt_ep") + assert row["tried"] == 2 and row["succeeded"] == 1 and row["success_rate"] == 0.5 + assert row["coupon_name"] == "端点券" and row["platform"] == "meituan-waimai" + finally: + db = SessionLocal() + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.claim_date == date(2020, 2, 3))) + db.commit() + db.close() -- 2.52.0 From ffe2ba91fd41733d64a863e36fd0af1b8f4e0172 Mon Sep 17 00:00:00 2001 From: guke Date: Wed, 8 Jul 2026 16:00:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(coupon-data):=20=E9=A2=86=E5=88=B8?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9C=8B=E6=9D=BF=E5=8A=A0=E9=A2=86=E5=88=B8?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=A4=9A=E9=80=89=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - coupon_data_report 加 statuses 参(status.in_ 过滤,与 app_env 同级),整视图联动 - 路由 /coupon-data 加多值 status 参(不传=全部);不影响 /coupons 按券表 - 新增 test_coupon_data_status_filter Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/repositories/coupon_data.py | 5 +++++ app/admin/routers/coupon_data.py | 6 +++++- tests/test_coupon_platform_success.py | 27 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 0b932cd..a290df1 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -127,6 +127,7 @@ def coupon_data_report( 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, @@ -136,6 +137,8 @@ def coupon_data_report( - user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。 - app_env:prod/dev 精确;None=全部。 + - statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图 + (汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。 - sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。 """ by_hour = granularity == "hour" @@ -158,6 +161,8 @@ def coupon_data_report( ) 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()) diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py index fe30cad..82a882d 100644 --- a/app/admin/routers/coupon_data.py +++ b/app/admin/routers/coupon_data.py @@ -54,6 +54,10 @@ def get_coupon_data( 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", + status: Annotated[ + list[str] | None, + Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"), + ] = None, granularity: Annotated[ str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day") ] = "day", @@ -75,7 +79,7 @@ def get_coupon_data( 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, + user=user, app_env=env, statuses=status, granularity=granularity, limit=limit, offset=offset, sort=sort, ) return CouponDataOut( diff --git a/tests/test_coupon_platform_success.py b/tests/test_coupon_platform_success.py index 712758a..adc04a9 100644 --- a/tests/test_coupon_platform_success.py +++ b/tests/test_coupon_platform_success.py @@ -270,3 +270,30 @@ def test_step_writes_platform_success_to_session(client) -> None: db.commit() finally: db.close() + + +def test_coupon_data_status_filter() -> None: + """状态多选过滤(方案 A):整个视图按选中状态算;None/空=全部。""" + db = SessionLocal() + try: + db.add_all([ + _agg_session("st-A", ["meituan-waimai"], ["meituan-waimai"], status="completed"), + _agg_session("st-B", ["meituan-waimai"], ["meituan-waimai"], status="started"), + _agg_session("st-C", ["meituan-waimai"], None, status="failed"), + _agg_session("st-D", ["meituan-waimai"], ["meituan-waimai"], status="abandoned"), + ]) + db.flush() + base = dict(date_from="2020-01-02", date_to="2020-01-02", app_env="prod") + # None = 全部 4 发起 + assert coupon_data_report(db, **base)["summary"]["started_count"] == 4 + # 排除 started → 3 发起(整个视图,发起数也随之变) + sub = coupon_data_report(db, **base, statuses=["completed", "failed", "abandoned"])["summary"] + assert sub["started_count"] == 3 + assert sub["completed_count"] == 1 + # 只 completed → 发起数=1、整单成功率基数=1(该 completed 是整单成功) + comp = coupon_data_report(db, **base, statuses=["completed"])["summary"] + assert comp["started_count"] == 1 + assert comp["full_success_rate"] == 1.0 + finally: + db.rollback() + db.close() -- 2.52.0 From dca1bc0764688f48130de3313a700b4e6bd37665 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 9 Jul 2026 14:05:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore(alembic):=20=E5=90=88=E5=B9=B6=20page?= =?UTF-8?q?s=5Foverride=20=E4=B8=8E=E9=A2=86=E5=88=B8=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E7=8E=87=E4=B8=A4=E4=B8=AA=E8=BF=81=E7=A7=BB=20head,=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20upgrade=20head(=E5=8D=95=E6=95=B0)=E5=A4=9A=20head?= =?UTF-8?q?=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pull main 带入 #126 的 admin_user_pages_override,与本分支领券成功率的 coupon_session_platform_success → coupon_claim_app_env 都从 admin_user_plain_password 分叉,形成两个 alembic head。多 head 会让部署/run.sh 用的 `alembic upgrade head`(单数) 报 "Multiple head revisions are present" 而中断。 本迁移为纯 merge:upgrade/downgrade 均空,不含任何表结构或数据改动,仅把两个 head 收敛为单 head(merge_pages_override_coupon_slot),使 `alembic upgrade head` 恢复正常。 --- .../merge_pages_override_coupon_slot.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 alembic/versions/merge_pages_override_coupon_slot.py diff --git a/alembic/versions/merge_pages_override_coupon_slot.py b/alembic/versions/merge_pages_override_coupon_slot.py new file mode 100644 index 0000000..1fb6fc4 --- /dev/null +++ b/alembic/versions/merge_pages_override_coupon_slot.py @@ -0,0 +1,28 @@ +"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。 + +两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增—— +于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用) +恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_pages_override_coupon_slot +Revises: admin_user_pages_override, coupon_claim_app_env +Create Date: 2026-07-09 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_pages_override_coupon_slot" +down_revision: str | Sequence[str] | None = ( + "admin_user_pages_override", + "coupon_claim_app_env", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" -- 2.52.0