From 37fba3cffce25995ba1c1073d5f9659e1bde3e5c Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Sat, 1 Aug 2026 13:52:22 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E9=A2=86=E5=88=B8=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E7=8E=87=E5=89=94=E9=99=A4=E4=B8=AD=E9=80=94=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/repositories/stats.py | 10 +++- app/admin/schemas/dashboard.py | 3 ++ tests/test_admin_read.py | 83 ++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 26e13df..e2b1ba9 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -566,6 +566,10 @@ def dashboard_overview( ) ).all() coupon_started = len(period_coupon_sessions) + coupon_abandoned = sum(s.status == "abandoned" for s in period_coupon_sessions) + # 用户主动中途退出不代表领券流程失败,不进入整场成功率样本。 + # started / failed 仍留在分母:前者是尚未形成终态的流失,后者是实际执行失败。 + coupon_success_denominator = coupon_started - coupon_abandoned coupon_completed_elapsed = sorted( s.elapsed_ms for s in period_coupon_sessions @@ -731,9 +735,13 @@ def dashboard_overview( }, "coupon": { "started": coupon_started, + "abandoned": coupon_abandoned, + "success_denominator": coupon_success_denominator, "all_success": coupon_all_success, "success_rate": ( - round(coupon_all_success / coupon_started, 4) if coupon_started else None + round(coupon_all_success / coupon_success_denominator, 4) + if coupon_success_denominator + else None ), "point_success": coupon_point_success, "points_per_session": coupon_points_per_session, diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index bb8a281..f52f11e 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -70,6 +70,9 @@ class DashboardPeriodCoupon(BaseModel): 成功口径 success+already_claimed(与「我的」页累计领券一致)。""" started: int = 0 + # 用户主动中途退出,不计入整场成功率分母。 + abandoned: int = 0 + success_denominator: int = 0 # 全部领成功的次数:completed 且当日该设备全部点位成功 all_success: int = 0 success_rate: float | None = None diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 806faed..638ca19 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -1,7 +1,7 @@ """Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。""" from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime import pytest from fastapi.testclient import TestClient @@ -12,6 +12,7 @@ from app.admin.repositories import admin_user as admin_repo from app.admin.repositories import queries from app.db.session import SessionLocal, engine from app.models.comparison import ComparisonRecord +from app.models.coupon_state import CouponClaimRecord, CouponSession from app.models.feedback import Feedback from app.models.invite import InviteRelation from app.models.savings import SavingsRecord @@ -120,6 +121,86 @@ def test_dashboard_period_comparison_is_aggregated_by_backend( assert comparison["token_cost_total_yuan"] == pytest.approx(1.0) +def test_dashboard_coupon_success_rate_excludes_abandoned_sessions( + admin_client: TestClient, admin_token: str +) -> None: + started_date = date(2038, 1, 16) + started_at = datetime(2038, 1, 16, 8, tzinfo=UTC) + sessions = [ + ("coupon-rate-completed-1", "coupon-rate-device-1", "completed"), + ("coupon-rate-completed-2", "coupon-rate-device-2", "completed"), + ("coupon-rate-failed", "coupon-rate-device-3", "failed"), + ("coupon-rate-abandoned", "coupon-rate-device-4", "abandoned"), + ] + db = SessionLocal() + try: + for trace_id, device_id, status in sessions: + db.add( + CouponSession( + trace_id=trace_id, + device_id=device_id, + status=status, + app_env="prod", + platforms=["meituan-waimai"], + started_at=started_at, + started_date=started_date, + ) + ) + for index, device_id in enumerate(("coupon-rate-device-1", "coupon-rate-device-2")): + db.add( + CouponClaimRecord( + device_id=device_id, + coupon_id=f"mt_dashboard_rate_{index}", + claim_date=started_date, + status="success", + app_env="prod", + ) + ) + db.commit() + finally: + db.close() + + response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": "2038-01-16", "date_to": "2038-01-16"}, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + coupon = response.json()["period"]["coupon"] + assert coupon["started"] == 4 + assert coupon["abandoned"] == 1 + assert coupon["success_denominator"] == 3 + assert coupon["all_success"] == 2 + assert coupon["success_rate"] == pytest.approx(2 / 3, abs=0.0001) + + db = SessionLocal() + try: + db.add( + CouponSession( + trace_id="coupon-rate-only-abandoned", + device_id="coupon-rate-device-only-abandoned", + status="abandoned", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2038, 1, 17, 8, tzinfo=UTC), + started_date=date(2038, 1, 17), + ) + ) + db.commit() + finally: + db.close() + + empty_denominator_response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": "2038-01-17", "date_to": "2038-01-17"}, + headers=_auth(admin_token), + ) + assert empty_denominator_response.status_code == 200 + only_abandoned = empty_denominator_response.json()["period"]["coupon"] + assert only_abandoned["success_denominator"] == 0 + assert only_abandoned["success_rate"] is None + + def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None: uid = _seed_user_with_data("13800000002") r = admin_client.get("/admin/api/users", headers=_auth(admin_token)) -- 2.52.0 From f37d800da33db6353368abe24814aa7b20f9377f Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Sat, 1 Aug 2026 14:06:46 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E4=B8=AD=E9=80=94?= =?UTF-8?q?=E9=80=80=E5=87=BA=E5=8D=95=E5=88=B8=E6=88=90=E5=8A=9F=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/repositories/coupon_data.py | 20 ++++++- app/admin/schemas/coupon_data.py | 6 +- tests/test_coupon_point_score.py | 82 +++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index f7f8179..eb022fa 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -166,6 +166,17 @@ def _session_to_row( point_stats: dict | None = None, ) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" + # 中途退出可能发生在第一张券产生终态之前,此时没有逐券事件。 + # 明确返回 0/0,让前端区分「退出前无单券结果」与其它状态的埋点缺失。 + if point_stats is not None: + point_success_count = point_stats["succeeded"] + point_total_count = point_stats["tried"] + elif r.status == "abandoned": + point_success_count = 0 + point_total_count = 0 + else: + point_success_count = None + point_total_count = None return { "id": r.id, "trace_id": r.trace_id, @@ -182,8 +193,8 @@ def _session_to_row( "app_env": r.app_env, "started_at": r.started_at, "claimed_count": r.claimed_count, - "point_success_count": point_stats["succeeded"] if point_stats else None, - "point_total_count": point_stats["tried"] if point_stats else None, + "point_success_count": point_success_count, + "point_total_count": point_total_count, "trace_url": r.trace_url, "ad_revenue_yuan": ad_revenue_yuan, } @@ -400,12 +411,15 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: total = db.execute( select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id) ).scalar_one() - rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) + trace_ids = [r.trace_id for r in rows] + rev_map = crud_ecpm.revenue_yuan_by_trace(db, trace_ids) + point_stats_map = _point_scores_by_trace(db, trace_ids) return { "items": [ _session_to_row( r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), + point_stats=point_stats_map.get(r.trace_id), ) for r in rows ], diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index 6b19fd7..fc063e0 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -79,10 +79,12 @@ class CouponDataRow(BaseModel): started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None point_success_count: int | None = Field( - None, description="本次成功单券数(success+already_claimed);无逐券事件为空" + None, + description="本次成功单券数(success+already_claimed);中途退出且无逐券结果为0,其它无事件为空", ) point_total_count: int | None = Field( - None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空" + None, + description="本次尝试单券数(success+already_claimed+failed,不含 skipped);中途退出且无逐券结果为0,其它无事件为空", ) trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") ad_revenue_yuan: float = Field( diff --git a/tests/test_coupon_point_score.py b/tests/test_coupon_point_score.py index e238f6d..d0fba6a 100644 --- a/tests/test_coupon_point_score.py +++ b/tests/test_coupon_point_score.py @@ -10,6 +10,7 @@ from app.admin.repositories.coupon_data import ( _point_scores_by_trace, coupon_data_report, coupon_point_details, + coupon_user_records, ) from app.admin.security import create_admin_token from app.db.session import SessionLocal @@ -114,6 +115,87 @@ def test_coupon_data_report_returns_scores_without_embedding_details() -> None: db.close() +def test_coupon_data_report_marks_abandoned_without_point_results() -> None: + """中途退出且没有逐券终态时返回0/0,其他状态缺埋点仍保持为空。""" + db = SessionLocal() + report_date = date(2020, 1, 6) + user_id = 910006 + try: + db.add_all([ + CouponSession( + trace_id="point-score-abandoned-without-result", + device_id="score-abandoned-device", + user_id=user_id, + status="abandoned", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 6, tzinfo=UTC), + started_date=report_date, + ), + CouponSession( + trace_id="point-score-completed-without-result", + device_id="score-completed-device", + user_id=user_id, + status="completed", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 6, 1, tzinfo=UTC), + started_date=report_date, + ), + CouponSession( + trace_id="point-score-abandoned-with-result", + device_id="score-abandoned-result-device", + user_id=user_id, + status="abandoned", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 6, 2, tzinfo=UTC), + started_date=report_date, + ), + ]) + db.add_all([ + CouponClaimEvent( + trace_id="point-score-abandoned-with-result", + device_id="score-abandoned-result-device", + coupon_id=f"mt-abandoned-{status}", + claim_date=report_date, + status=status, + ) + for status in ("success", "failed") + ]) + db.flush() + + report = coupon_data_report( + db, + date_from=report_date.isoformat(), + date_to=report_date.isoformat(), + app_env="prod", + ) + rows = {item["trace_id"]: item for item in report["items"]} + abandoned = rows["point-score-abandoned-without-result"] + assert abandoned["point_success_count"] == 0 + assert abandoned["point_total_count"] == 0 + + abandoned_with_result = rows["point-score-abandoned-with-result"] + assert abandoned_with_result["point_success_count"] == 1 + assert abandoned_with_result["point_total_count"] == 2 + + completed = rows["point-score-completed-without-result"] + assert completed["point_success_count"] is None + assert completed["point_total_count"] is None + + user_rows = { + item["trace_id"]: item + for item in coupon_user_records(db, user_id=user_id)["items"] + } + assert user_rows["point-score-abandoned-without-result"]["point_total_count"] == 0 + assert user_rows["point-score-abandoned-with-result"]["point_total_count"] == 2 + assert user_rows["point-score-completed-without-result"]["point_total_count"] is None + finally: + db.rollback() + db.close() + + def test_coupon_point_details_endpoint() -> None: """前端点击使用的接口按约定返回 trace_id 和逐券 items。""" db = SessionLocal() -- 2.52.0 From 8fc2cd411c11047baf4b72ee5421bddf4f74feb8 Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Sat, 1 Aug 2026 14:12:30 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E5=8C=BA=E5=88=86=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=B8=8E=E6=97=A0=E9=80=90=E5=88=B8=E7=BB=93?= =?UTF-8?q?=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/repositories/coupon_data.py | 19 ++++++++++------ app/admin/schemas/coupon_data.py | 4 ++++ tests/test_coupon_point_score.py | 32 ++++++++++++++++++++++++--- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index eb022fa..6fb6772 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -177,6 +177,7 @@ def _session_to_row( else: point_success_count = None point_total_count = None + point_event_count = point_stats["events"] if point_stats is not None else 0 return { "id": r.id, "trace_id": r.trace_id, @@ -195,6 +196,7 @@ def _session_to_row( "claimed_count": r.claimed_count, "point_success_count": point_success_count, "point_total_count": point_total_count, + "point_event_count": point_event_count, "trace_url": r.trace_url, "ad_revenue_yuan": ad_revenue_yuan, } @@ -205,21 +207,24 @@ def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[ if not trace_ids: return {} succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 1), else_=0)) + tried = func.sum(case((CouponClaimEvent.status.in_(_SLOT_TRIED), 1), else_=0)) rows = db.execute( select( CouponClaimEvent.trace_id, succeeded.label("succeeded"), - func.count().label("tried"), - ) - .where( - CouponClaimEvent.trace_id.in_(trace_ids), - CouponClaimEvent.status.in_(_SLOT_TRIED), + tried.label("tried"), + func.count().label("events"), ) + .where(CouponClaimEvent.trace_id.in_(trace_ids)) .group_by(CouponClaimEvent.trace_id) ).all() return { - trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)} - for trace_id, success_count, tried in rows + trace_id: { + "succeeded": int(success_count or 0), + "tried": int(tried_count or 0), + "events": int(event_count or 0), + } + for trace_id, success_count, tried_count, event_count in rows if trace_id is not None } diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index fc063e0..3cad3c9 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -86,6 +86,10 @@ class CouponDataRow(BaseModel): None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);中途退出且无逐券结果为0,其它无事件为空", ) + point_event_count: int = Field( + 0, + description="本次全部逐券事件数(含 skipped);用于区分无有效计分事件与完全无事件", + ) trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") ad_revenue_yuan: float = Field( 0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" diff --git a/tests/test_coupon_point_score.py b/tests/test_coupon_point_score.py index d0fba6a..b7c322a 100644 --- a/tests/test_coupon_point_score.py +++ b/tests/test_coupon_point_score.py @@ -39,6 +39,7 @@ def test_point_scores_by_trace() -> None: stats = _point_scores_by_trace(db, [trace])[trace] assert stats["succeeded"] == 2 assert stats["tried"] == 3 + assert stats["events"] == 4 details = coupon_point_details(db, trace_id=trace) assert [item["status"] for item in details] == [ "success", "already_claimed", "failed", "skipped" @@ -49,8 +50,8 @@ def test_point_scores_by_trace() -> None: db.close() -def test_skipped_detail_does_not_create_a_score() -> None: - """仅有 skipped 时按需明细仍可查到,但列表没有虚假的 0/0 分数。""" +def test_skipped_detail_is_distinguished_from_no_events() -> None: + """仅有 skipped 时分数仍为0/0,但保留事件数供前端开放明细。""" db = SessionLocal() trace = "point-score-skipped" try: @@ -64,7 +65,7 @@ def test_skipped_detail_does_not_create_a_score() -> None: db.flush() scores = _point_scores_by_trace(db, [trace, "missing-trace"]) - assert trace not in scores + assert scores[trace] == {"succeeded": 0, "tried": 0, "events": 1} assert "missing-trace" not in scores assert coupon_point_details(db, trace_id=trace)[0]["status"] == "skipped" finally: @@ -152,6 +153,16 @@ def test_coupon_data_report_marks_abandoned_without_point_results() -> None: started_at=datetime(2020, 1, 6, 2, tzinfo=UTC), started_date=report_date, ), + CouponSession( + trace_id="point-score-abandoned-skipped-only", + device_id="score-abandoned-skipped-device", + user_id=user_id, + status="abandoned", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 6, 3, tzinfo=UTC), + started_date=report_date, + ), ]) db.add_all([ CouponClaimEvent( @@ -163,6 +174,13 @@ def test_coupon_data_report_marks_abandoned_without_point_results() -> None: ) for status in ("success", "failed") ]) + db.add(CouponClaimEvent( + trace_id="point-score-abandoned-skipped-only", + device_id="score-abandoned-skipped-device", + coupon_id="mt-abandoned-skipped", + claim_date=report_date, + status="skipped", + )) db.flush() report = coupon_data_report( @@ -175,10 +193,17 @@ def test_coupon_data_report_marks_abandoned_without_point_results() -> None: abandoned = rows["point-score-abandoned-without-result"] assert abandoned["point_success_count"] == 0 assert abandoned["point_total_count"] == 0 + assert abandoned["point_event_count"] == 0 abandoned_with_result = rows["point-score-abandoned-with-result"] assert abandoned_with_result["point_success_count"] == 1 assert abandoned_with_result["point_total_count"] == 2 + assert abandoned_with_result["point_event_count"] == 2 + + abandoned_skipped = rows["point-score-abandoned-skipped-only"] + assert abandoned_skipped["point_success_count"] == 0 + assert abandoned_skipped["point_total_count"] == 0 + assert abandoned_skipped["point_event_count"] == 1 completed = rows["point-score-completed-without-result"] assert completed["point_success_count"] is None @@ -190,6 +215,7 @@ def test_coupon_data_report_marks_abandoned_without_point_results() -> None: } assert user_rows["point-score-abandoned-without-result"]["point_total_count"] == 0 assert user_rows["point-score-abandoned-with-result"]["point_total_count"] == 2 + assert user_rows["point-score-abandoned-skipped-only"]["point_event_count"] == 1 assert user_rows["point-score-completed-without-result"]["point_total_count"] is None finally: db.rollback() -- 2.52.0