From ebda316d024af202d75a47657838452693cf6e6e Mon Sep 17 00:00:00 2001 From: unknown <798648091@qq.com> Date: Tue, 21 Jul 2026 15:44:08 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(admin):=20=E8=BF=94=E5=9B=9E=E9=80=90?= =?UTF-8?q?=E5=9C=BA=E9=A2=86=E5=88=B8=E7=82=B9=E4=BD=8D=E5=88=86=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/repositories/coupon_data.py | 55 +++++++++++++++++++++++---- app/admin/schemas/coupon_data.py | 6 +++ tests/test_coupon_point_score.py | 49 ++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 tests/test_coupon_point_score.py diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 0450404..96aa0ce 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -22,6 +22,10 @@ from app.repositories import ad_ecpm as crud_ecpm from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform +_SLOT_OK = ("success", "already_claimed") +_SLOT_TRIED = ("success", "already_claimed", "failed") + + def _cn_hour(dt: datetime) -> int: """started_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。""" if dt.tzinfo is None: @@ -86,7 +90,13 @@ def _success_rates(rows: list) -> dict: } -def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: +def _session_to_row( + r, + phone: str | None = None, + nickname: str | None = None, + ad_revenue_yuan: float = 0.0, + point_score: tuple[int, int] | None = None, +) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { "id": r.id, @@ -104,11 +114,31 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad "app_env": r.app_env, "started_at": r.started_at, "claimed_count": r.claimed_count, + "point_success_count": point_score[0] if point_score is not None else None, + "point_total_count": point_score[1] if point_score is not None else None, "trace_url": r.trace_url, "ad_revenue_yuan": ad_revenue_yuan, } +def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, tuple[int, int]]: + """批量统计逐场券点位分数:成功(含已领) / 尝试(不含 skipped)。""" + if not trace_ids: + return {} + succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)) + tried = func.sum(case((CouponClaimRecord.status.in_(_SLOT_TRIED), 1), else_=0)) + rows = db.execute( + select(CouponClaimRecord.trace_id, succeeded, tried) + .where(CouponClaimRecord.trace_id.in_(trace_ids)) + .group_by(CouponClaimRecord.trace_id) + ).all() + return { + trace_id: (int(success_count or 0), int(total_count or 0)) + for trace_id, success_count, total_count in rows + if trace_id is not None and int(total_count or 0) > 0 + } + + def _empty_result() -> dict: return { "summary": { @@ -249,10 +279,17 @@ def coupon_data_report( ).all() } rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) + point_score_map = _point_scores_by_trace(db, [r.trace_id for r in page]) items = [] for r in page: phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) - items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) + items.append(_session_to_row( + r, + phone, + nickname, + ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), + point_score=point_score_map.get(r.trace_id), + )) return { "summary": summary, @@ -275,16 +312,20 @@ 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() rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) + point_score_map = _point_scores_by_trace(db, [r.trace_id for r in rows]) return { - "items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows], + "items": [ + _session_to_row( + r, + ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), + point_score=point_score_map.get(r.trace_id), + ) + 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: diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index c560397..855d985 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -69,6 +69,12 @@ class CouponDataRow(BaseModel): app_env: str | None = None started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None + point_success_count: int | None = Field( + None, description="本次成功券点位数(success+already_claimed);无逐券埋点为空" + ) + point_total_count: int | None = Field( + None, description="本次尝试券点位数(success+already_claimed+failed,不含 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 new file mode 100644 index 0000000..ac695a4 --- /dev/null +++ b/tests/test_coupon_point_score.py @@ -0,0 +1,49 @@ +"""admin 领券明细逐场点位分数。""" +from datetime import date + +from app.admin.repositories.coupon_data import _point_scores_by_trace +from app.db.session import SessionLocal +from app.models.coupon_state import CouponClaimRecord + + +def test_point_scores_by_trace() -> None: + """已领算成功、失败算尝试、跳过不进分母。""" + db = SessionLocal() + trace = "point-score-trace" + try: + db.add_all([ + CouponClaimRecord( + device_id="score-device", + coupon_id=f"mt-score-{status}", + claim_date=date(2020, 1, 2), + status=status, + trace_id=trace, + ) + for status in ("success", "already_claimed", "failed", "skipped") + ]) + db.flush() + + assert _point_scores_by_trace(db, [trace]) == {trace: (2, 3)} + finally: + db.rollback() + db.close() + + +def test_point_scores_omit_trace_without_attempts() -> None: + """仅有 skipped 或完全无记录时不伪造 0/0。""" + db = SessionLocal() + trace = "point-score-skipped" + try: + db.add(CouponClaimRecord( + device_id="score-device-skipped", + coupon_id="mt-score-skipped-only", + claim_date=date(2020, 1, 2), + status="skipped", + trace_id=trace, + )) + db.flush() + + assert _point_scores_by_trace(db, [trace, "missing-trace"]) == {} + finally: + db.rollback() + db.close() -- 2.52.0 From e43312f9a13f343dd00560a2863a7657781f9a16 Mon Sep 17 00:00:00 2001 From: unknown <798648091@qq.com> Date: Tue, 21 Jul 2026 15:52:23 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(admin):=20=E8=BF=94=E5=9B=9E=E9=A2=86?= =?UTF-8?q?=E5=88=B8=E7=82=B9=E4=BD=8D=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/repositories/coupon_data.py | 52 +++++++++++++++++---------- app/admin/schemas/coupon_data.py | 12 +++++++ tests/test_coupon_point_score.py | 22 +++++++++--- 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 96aa0ce..8b026de 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -95,7 +95,7 @@ def _session_to_row( phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0, - point_score: tuple[int, int] | None = None, + point_stats: dict | None = None, ) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { @@ -114,29 +114,45 @@ def _session_to_row( "app_env": r.app_env, "started_at": r.started_at, "claimed_count": r.claimed_count, - "point_success_count": point_score[0] if point_score is not None else None, - "point_total_count": point_score[1] if point_score is not None else None, + "point_success_count": point_stats["succeeded"] if point_stats and point_stats["tried"] else None, + "point_total_count": point_stats["tried"] if point_stats and point_stats["tried"] else None, + "point_details": point_stats["details"] if point_stats else [], "trace_url": r.trace_url, "ad_revenue_yuan": ad_revenue_yuan, } -def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, tuple[int, int]]: - """批量统计逐场券点位分数:成功(含已领) / 尝试(不含 skipped)。""" +def _point_stats_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict]: + """一次查询批量返回逐场点位分数和逐券明细。""" if not trace_ids: return {} - succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)) - tried = func.sum(case((CouponClaimRecord.status.in_(_SLOT_TRIED), 1), else_=0)) rows = db.execute( - select(CouponClaimRecord.trace_id, succeeded, tried) + select( + CouponClaimRecord.trace_id, + CouponClaimRecord.coupon_id, + CouponClaimRecord.coupon_name, + CouponClaimRecord.status, + CouponClaimRecord.reason, + ) .where(CouponClaimRecord.trace_id.in_(trace_ids)) - .group_by(CouponClaimRecord.trace_id) + .order_by(CouponClaimRecord.trace_id, CouponClaimRecord.id) ).all() - return { - trace_id: (int(success_count or 0), int(total_count or 0)) - for trace_id, success_count, total_count in rows - if trace_id is not None and int(total_count or 0) > 0 - } + result: dict[str, dict] = {} + for trace_id, coupon_id, coupon_name, status, reason in rows: + if trace_id is None: + continue + stats = result.setdefault(trace_id, {"succeeded": 0, "tried": 0, "details": []}) + if status in _SLOT_TRIED: + stats["tried"] += 1 + if status in _SLOT_OK: + stats["succeeded"] += 1 + stats["details"].append({ + "coupon_id": coupon_id, + "coupon_name": coupon_name, + "status": status, + "reason": reason, + }) + return result def _empty_result() -> dict: @@ -279,7 +295,7 @@ def coupon_data_report( ).all() } rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) - point_score_map = _point_scores_by_trace(db, [r.trace_id for r in page]) + point_stats_map = _point_stats_by_trace(db, [r.trace_id for r in page]) items = [] for r in page: phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) @@ -288,7 +304,7 @@ def coupon_data_report( phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), - point_score=point_score_map.get(r.trace_id), + point_stats=point_stats_map.get(r.trace_id), )) return { @@ -312,13 +328,13 @@ 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() rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) - point_score_map = _point_scores_by_trace(db, [r.trace_id for r in rows]) + point_stats_map = _point_stats_by_trace(db, [r.trace_id for r in rows]) return { "items": [ _session_to_row( r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), - point_score=point_score_map.get(r.trace_id), + 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 855d985..0e48d6e 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -49,6 +49,15 @@ class CouponDataHourly(BaseModel): avg_elapsed_ms: int | None = None +class CouponPointDetail(BaseModel): + """一次领券任务中的单券点位结果。""" + + coupon_id: str + coupon_name: str | None = None + status: str = Field(..., description="success / already_claimed / failed / skipped") + reason: str | None = None + + class CouponDataRow(BaseModel): """一条领券明细(一次领券任务)。""" @@ -75,6 +84,9 @@ class CouponDataRow(BaseModel): point_total_count: int | None = Field( None, description="本次尝试券点位数(success+already_claimed+failed,不含 skipped);无逐券埋点为空" ) + point_details: list[CouponPointDetail] = Field( + default_factory=list, description="本次逐券点位结果,用于后台点击分数查看成功/失败明细" + ) 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 ac695a4..b17755f 100644 --- a/tests/test_coupon_point_score.py +++ b/tests/test_coupon_point_score.py @@ -1,7 +1,7 @@ """admin 领券明细逐场点位分数。""" from datetime import date -from app.admin.repositories.coupon_data import _point_scores_by_trace +from app.admin.repositories.coupon_data import _point_stats_by_trace from app.db.session import SessionLocal from app.models.coupon_state import CouponClaimRecord @@ -17,20 +17,28 @@ def test_point_scores_by_trace() -> None: coupon_id=f"mt-score-{status}", claim_date=date(2020, 1, 2), status=status, + coupon_name=f"测试点位-{status}", + reason="测试失败" if status == "failed" else None, trace_id=trace, ) for status in ("success", "already_claimed", "failed", "skipped") ]) db.flush() - assert _point_scores_by_trace(db, [trace]) == {trace: (2, 3)} + stats = _point_stats_by_trace(db, [trace])[trace] + assert stats["succeeded"] == 2 + assert stats["tried"] == 3 + assert [item["status"] for item in stats["details"]] == [ + "success", "already_claimed", "failed", "skipped" + ] + assert stats["details"][2]["reason"] == "测试失败" finally: db.rollback() db.close() -def test_point_scores_omit_trace_without_attempts() -> None: - """仅有 skipped 或完全无记录时不伪造 0/0。""" +def test_point_stats_keep_skipped_detail_without_faking_score() -> None: + """仅有 skipped 时保留明细,但分数仍为 0/0,由 schema 层展示为空。""" db = SessionLocal() trace = "point-score-skipped" try: @@ -43,7 +51,11 @@ def test_point_scores_omit_trace_without_attempts() -> None: )) db.flush() - assert _point_scores_by_trace(db, [trace, "missing-trace"]) == {} + stats = _point_stats_by_trace(db, [trace, "missing-trace"]) + assert stats[trace]["succeeded"] == 0 + assert stats[trace]["tried"] == 0 + assert stats[trace]["details"][0]["status"] == "skipped" + assert "missing-trace" not in stats finally: db.rollback() db.close() -- 2.52.0 From b73d5b76a3ca9df1f78d0297f83ea609a91cb617 Mon Sep 17 00:00:00 2001 From: unknown <798648091@qq.com> Date: Wed, 22 Jul 2026 11:20:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?perf(admin):=20=E6=8C=89=E9=9C=80=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E9=A2=86=E5=88=B8=E7=82=B9=E4=BD=8D=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/repositories/coupon_data.py | 56 ++++++----- app/admin/routers/coupon_data.py | 18 ++++ app/admin/schemas/coupon_data.py | 10 +- tests/test_coupon_point_score.py | 128 +++++++++++++++++++++++--- 4 files changed, 172 insertions(+), 40 deletions(-) diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 8b026de..422136e 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -21,7 +21,6 @@ from app.models.user import User from app.repositories import ad_ecpm as crud_ecpm from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform - _SLOT_OK = ("success", "already_claimed") _SLOT_TRIED = ("success", "already_claimed", "failed") @@ -114,45 +113,58 @@ 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 and point_stats["tried"] else None, - "point_total_count": point_stats["tried"] if point_stats and point_stats["tried"] else None, - "point_details": point_stats["details"] if point_stats else [], + "point_success_count": point_stats["succeeded"] if point_stats else None, + "point_total_count": point_stats["tried"] if point_stats else None, "trace_url": r.trace_url, "ad_revenue_yuan": ad_revenue_yuan, } -def _point_stats_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict]: - """一次查询批量返回逐场点位分数和逐券明细。""" +def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[str, int]]: + """聚合查询批量返回逐场点位分数,不加载逐券明细。""" if not trace_ids: return {} + succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)) rows = db.execute( select( CouponClaimRecord.trace_id, + succeeded.label("succeeded"), + func.count().label("tried"), + ) + .where( + CouponClaimRecord.trace_id.in_(trace_ids), + CouponClaimRecord.status.in_(_SLOT_TRIED), + ) + .group_by(CouponClaimRecord.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 + if trace_id is not None + } + + +def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]: + """按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。""" + rows = db.execute( + select( CouponClaimRecord.coupon_id, CouponClaimRecord.coupon_name, CouponClaimRecord.status, CouponClaimRecord.reason, ) - .where(CouponClaimRecord.trace_id.in_(trace_ids)) - .order_by(CouponClaimRecord.trace_id, CouponClaimRecord.id) + .where(CouponClaimRecord.trace_id == trace_id) + .order_by(CouponClaimRecord.id) ).all() - result: dict[str, dict] = {} - for trace_id, coupon_id, coupon_name, status, reason in rows: - if trace_id is None: - continue - stats = result.setdefault(trace_id, {"succeeded": 0, "tried": 0, "details": []}) - if status in _SLOT_TRIED: - stats["tried"] += 1 - if status in _SLOT_OK: - stats["succeeded"] += 1 - stats["details"].append({ + return [ + { "coupon_id": coupon_id, "coupon_name": coupon_name, "status": status, "reason": reason, - }) - return result + } + for coupon_id, coupon_name, status, reason in rows + ] def _empty_result() -> dict: @@ -295,7 +307,7 @@ def coupon_data_report( ).all() } rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) - point_stats_map = _point_stats_by_trace(db, [r.trace_id for r in page]) + point_stats_map = _point_scores_by_trace(db, [r.trace_id for r in page]) items = [] for r in page: phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) @@ -328,13 +340,11 @@ 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() rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) - point_stats_map = _point_stats_by_trace(db, [r.trace_id for r in rows]) return { "items": [ _session_to_row( r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), - point_stats=point_stats_map.get(r.trace_id), ) for r in rows ], diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py index 82a882d..89a23e2 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, + CouponPointDetail, + CouponPointDetailsOut, CouponSlotRow, CouponSlotsOut, CouponUserRecordsOut, @@ -122,6 +124,22 @@ def get_coupon_slots( ) +@router.get( + "/point-details", + response_model=CouponPointDetailsOut, + summary="按 trace 查询单次领券任务的逐券点位明细", +) +def get_coupon_point_details( + db: AdminDb, + trace_id: Annotated[str, Query(min_length=1, max_length=64, description="领券 trace_id")], +) -> CouponPointDetailsOut: + items = coupon_data.coupon_point_details(db, trace_id=trace_id) + return CouponPointDetailsOut( + trace_id=trace_id, + items=[CouponPointDetail(**item) for item in 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 0e48d6e..f295650 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -84,9 +84,6 @@ class CouponDataRow(BaseModel): point_total_count: int | None = Field( None, description="本次尝试券点位数(success+already_claimed+failed,不含 skipped);无逐券埋点为空" ) - point_details: list[CouponPointDetail] = Field( - default_factory=list, description="本次逐券点位结果,用于后台点击分数查看成功/失败明细" - ) 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" @@ -107,6 +104,13 @@ class CouponDataOut(BaseModel): items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)") +class CouponPointDetailsOut(BaseModel): + """单次领券任务的逐券点位结果,供点击分数时按需加载。""" + + trace_id: str + items: list[CouponPointDetail] = Field(default_factory=list) + + class CouponUserRecordsOut(BaseModel): """某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。""" diff --git a/tests/test_coupon_point_score.py b/tests/test_coupon_point_score.py index b17755f..5108fcf 100644 --- a/tests/test_coupon_point_score.py +++ b/tests/test_coupon_point_score.py @@ -1,9 +1,19 @@ -"""admin 领券明细逐场点位分数。""" -from datetime import date +"""admin 领券明细逐场点位分数与按需明细。""" +from datetime import UTC, date, datetime -from app.admin.repositories.coupon_data import _point_stats_by_trace +from fastapi.testclient import TestClient +from sqlalchemy import delete + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.admin.repositories.coupon_data import ( + _point_scores_by_trace, + coupon_data_report, + coupon_point_details, +) +from app.admin.security import create_admin_token from app.db.session import SessionLocal -from app.models.coupon_state import CouponClaimRecord +from app.models.coupon_state import CouponClaimRecord, CouponSession def test_point_scores_by_trace() -> None: @@ -25,20 +35,21 @@ def test_point_scores_by_trace() -> None: ]) db.flush() - stats = _point_stats_by_trace(db, [trace])[trace] + stats = _point_scores_by_trace(db, [trace])[trace] assert stats["succeeded"] == 2 assert stats["tried"] == 3 - assert [item["status"] for item in stats["details"]] == [ + details = coupon_point_details(db, trace_id=trace) + assert [item["status"] for item in details] == [ "success", "already_claimed", "failed", "skipped" ] - assert stats["details"][2]["reason"] == "测试失败" + assert details[2]["reason"] == "测试失败" finally: db.rollback() db.close() -def test_point_stats_keep_skipped_detail_without_faking_score() -> None: - """仅有 skipped 时保留明细,但分数仍为 0/0,由 schema 层展示为空。""" +def test_skipped_detail_does_not_create_a_score() -> None: + """仅有 skipped 时按需明细仍可查到,但列表没有虚假的 0/0 分数。""" db = SessionLocal() trace = "point-score-skipped" try: @@ -51,11 +62,100 @@ def test_point_stats_keep_skipped_detail_without_faking_score() -> None: )) db.flush() - stats = _point_stats_by_trace(db, [trace, "missing-trace"]) - assert stats[trace]["succeeded"] == 0 - assert stats[trace]["tried"] == 0 - assert stats[trace]["details"][0]["status"] == "skipped" - assert "missing-trace" not in stats + scores = _point_scores_by_trace(db, [trace, "missing-trace"]) + assert trace not in scores + assert "missing-trace" not in scores + assert coupon_point_details(db, trace_id=trace)[0]["status"] == "skipped" finally: db.rollback() db.close() + + +def test_coupon_data_report_returns_scores_without_embedding_details() -> None: + """主列表只返回聚合分数,逐券记录必须走按 trace 的明细查询。""" + db = SessionLocal() + trace = "point-score-report" + report_date = date(2020, 1, 4) + try: + db.add(CouponSession( + trace_id=trace, + device_id="score-report-device", + status="completed", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 4, tzinfo=UTC), + started_date=report_date, + )) + db.add_all([ + CouponClaimRecord( + device_id="score-report-device", + coupon_id=f"mt-report-{status}", + claim_date=report_date, + status=status, + trace_id=trace, + ) + 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", + ) + row = next(item for item in report["items"] if item["trace_id"] == trace) + assert row["point_success_count"] == 1 + assert row["point_total_count"] == 2 + assert "point_details" not in row + assert len(coupon_point_details(db, trace_id=trace)) == 2 + finally: + db.rollback() + db.close() + + +def test_coupon_point_details_endpoint() -> None: + """前端点击使用的接口按约定返回 trace_id 和逐券 items。""" + db = SessionLocal() + trace = "point-details-endpoint" + try: + admin = admin_repo.get_by_username(db, "point_details_admin") + if admin is None: + admin = admin_repo.create_admin( + db, + username="point_details_admin", + password="pass1234", + role="super_admin", + ) + token, _expires_at = create_admin_token(admin_id=admin.id, role=admin.role) + db.add(CouponClaimRecord( + device_id="point-details-endpoint-device", + coupon_id="mt-point-details-endpoint", + coupon_name="接口测试券", + claim_date=date(2020, 1, 5), + status="failed", + reason="接口测试失败", + trace_id=trace, + )) + db.commit() + + response = TestClient(admin_app).get( + "/admin/api/coupon-data/point-details", + params={"trace_id": trace}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "trace_id": trace, + "items": [{ + "coupon_id": "mt-point-details-endpoint", + "coupon_name": "接口测试券", + "status": "failed", + "reason": "接口测试失败", + }], + } + finally: + db.rollback() + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.trace_id == trace)) + db.commit() + db.close() -- 2.52.0