perf(admin): 按需加载领券点位明细

This commit is contained in:
unknown
2026-07-22 11:20:59 +08:00
parent e43312f9a1
commit b73d5b76a3
4 changed files with 172 additions and 40 deletions
+33 -23
View File
@@ -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
],
+18
View File
@@ -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,
+7 -3
View File
@@ -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 渲染)。"""
+114 -14
View File
@@ -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()