Files
shaguabijia-app-server/tests/test_coupon_slots.py
T
guke 2ddea4159d feat(coupon-data): 领券成功率看板(整单/点位/分平台 + 按券) (#130)
后台「领券数据」看板此前只有发起/完成数与耗时分位,缺少成功率视角。本 MR 补齐三档平台粒度成功率与一张按券(coupon_id)成功率表,并为看板增加领券状态多选过滤。
服务端埋点
领券每帧按 trace_id 把「成功平台」并集幂等写入 platform_success(merge_session_platform_success;读不到 session 行则静默跳过,不建兜底行;无新平台不写库)。
record_claims 按 session_app_env(trace_id) 反查并打 app_env 标。
新增平台推导:coupon_id_to_platform(前缀 mt_→美团 / tb_·ele_·elm_→淘宝 / jd_→京东,与客户端 couponIdToPlatform 同词表)、succeeded_platforms。成功语义统一为 success + already_claimed。
复用同一 SessionLocal、紧接 record_claims,不新增连接;fire-and-forget,异常已吞。

后台指标与接口
Summary 新增:②整单成功率(勾选平台全领到的 session 占发起数)、③点位成功率(Σ成功平台 / Σ勾选平台)、分平台点位成功率(恒含美团/淘宝/京东三档)。基数与「发起数」一致,含全部 session。
新端点 GET /coupon-data/coupons(coupon_slot_report):按券成功率表,数据源 coupon_claim_record,设备-天粒度,成功率 = 成功 /(成功+失败),skipped 排除,按尝试数倒序。
主表加 status 多选过滤(started/completed/failed/abandoned),汇总/成功率/趋势/明细整体按选中状态算,与 app_env 同级。

兼容性 / 风险
两个新列均可空、旧行按空集/不回填处理,无数据回填;埋点 fire-and-forget,失败不影响领券主流程。
部署务必 alembic upgrade head(因新增了 head 合并迁移)。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #130
2026-07-09 17:31:48 +08:00

138 lines
5.8 KiB
Python

"""每券成功率(§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()