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] =?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()