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
This commit was merged in pull request #130.
This commit is contained in:
2026-07-09 17:31:48 +08:00
parent 37fd51a498
commit 2ddea4159d
13 changed files with 1141 additions and 10 deletions
+32
View File
@@ -0,0 +1,32 @@
"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev)
Revision ID: coupon_claim_app_env
Revises: coupon_session_platform_success
Create Date: 2026-07-08 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "coupon_claim_app_env"
down_revision: str | Sequence[str] | None = "coupon_session_platform_success"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True))
batch_op.create_index(
"ix_coupon_claim_record_app_env", ["app_env"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
batch_op.drop_index("ix_coupon_claim_record_app_env")
batch_op.drop_column("app_env")
@@ -0,0 +1,37 @@
"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表)
供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧
按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL
视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。
Revision ID: coupon_session_platform_success
Revises: admin_user_plain_password
Create Date: 2026-07-07 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_platform_success"
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant / 建表迁移)。
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("platform_success")
@@ -0,0 +1,28 @@
"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。
两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增——
于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)
恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。
Revision ID: merge_pages_override_coupon_slot
Revises: admin_user_pages_override, coupon_claim_app_env
Create Date: 2026-07-09 00:00:00.000000
"""
from collections.abc import Sequence
revision: str = "merge_pages_override_coupon_slot"
down_revision: str | Sequence[str] | None = (
"admin_user_pages_override",
"coupon_claim_app_env",
)
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""纯合并 head,无 schema 改动。"""
def downgrade() -> None:
"""拆回两个 head,无 schema 改动。"""
+101 -3
View File
@@ -5,17 +5,20 @@
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
"""
from __future__ import annotations
from datetime import UTC, date as _date, datetime
from datetime import UTC, datetime
from datetime import date as _date
from sqlalchemy import func, or_, select
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponSession
from app.models.coupon_state import CouponClaimRecord, CouponSession
from app.models.user import User
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
def _cn_hour(dt: datetime) -> int:
@@ -42,6 +45,46 @@ def _avg(vals: list[int]) -> int | None:
return round(sum(vals) / len(vals)) if vals else None
def _success_rates(rows: list) -> dict:
"""平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12):
- sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS);
- succ(s) = `platform_success` ∩ sel(至少领到一张的平台);
- ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数;
- ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。
基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。
"""
started = len(rows)
full_success = 0
point_success = 0
point_total = 0
per_succ = {p: 0 for p in DEFAULT_PLATFORMS}
per_total = {p: 0 for p in DEFAULT_PLATFORMS}
for r in rows:
sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS)
succ = set(r.platform_success or []) & sel
point_success += len(succ)
point_total += len(sel)
if sel and succ == sel:
full_success += 1
for p in sel:
if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform
per_total[p] += 1
if p in succ:
per_succ[p] += 1
return {
"full_success_count": full_success,
"full_success_rate": round(full_success / started, 4) if started else None,
"point_success_count": point_success,
"point_total_count": point_total,
"point_success_rate": round(point_success / point_total, 4) if point_total else None,
"per_platform": {
p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None)
for p in DEFAULT_PLATFORMS
},
}
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
@@ -84,6 +127,7 @@ def coupon_data_report(
date_to: str,
user: str | None = None,
app_env: str | None = None,
statuses: list[str] | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
@@ -93,6 +137,8 @@ def coupon_data_report(
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
- app_env:prod/dev 精确;None=全部。
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
"""
by_hour = granularity == "hour"
@@ -115,6 +161,8 @@ def coupon_data_report(
)
if app_env is not None:
stmt = stmt.where(CouponSession.app_env == app_env)
if statuses:
stmt = stmt.where(CouponSession.status.in_(statuses))
if user_ids is not None:
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
rows = list(db.execute(stmt).scalars())
@@ -131,6 +179,7 @@ def coupon_data_report(
"p50_ms": _percentile(completed_elapsed, 50),
"p95_ms": _percentile(completed_elapsed, 95),
"p99_ms": _percentile(completed_elapsed, 99),
**_success_rates(rows),
}
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
@@ -223,3 +272,52 @@ 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()
return {"items": [_session_to_row(r) 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:
"""按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。
数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。
- 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除);
- 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试;
- claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。
"""
d_from = _date.fromisoformat(date_from)
d_to = _date.fromisoformat(date_to)
ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)
stmt = (
select(
CouponClaimRecord.coupon_id,
func.max(CouponClaimRecord.coupon_name).label("coupon_name"),
func.count().label("tried"),
func.sum(ok).label("succeeded"),
)
.where(
CouponClaimRecord.claim_date >= d_from,
CouponClaimRecord.claim_date <= d_to,
CouponClaimRecord.status.in_(_SLOT_TRIED),
)
.group_by(CouponClaimRecord.coupon_id)
)
if app_env is not None:
stmt = stmt.where(CouponClaimRecord.app_env == app_env)
items = []
for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all():
tried = int(tried or 0)
succeeded = int(succeeded or 0)
items.append({
"coupon_id": coupon_id,
"coupon_name": coupon_name,
"platform": coupon_id_to_platform(coupon_id),
"tried": tried,
"succeeded": succeeded,
"success_rate": round(succeeded / tried, 4) if tried else None,
})
items.sort(key=lambda x: (-x["tried"], x["coupon_id"]))
return {"items": items}
+36 -1
View File
@@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import (
CouponDataOut,
CouponDataRow,
CouponDataSummary,
CouponSlotRow,
CouponSlotsOut,
CouponUserRecordsOut,
)
from app.core.rewards import cn_today
@@ -52,6 +54,10 @@ def get_coupon_data(
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
status: Annotated[
list[str] | None,
Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
] = "day",
@@ -73,7 +79,7 @@ def get_coupon_data(
env = None if app_env == "all" else app_env
result = coupon_data.coupon_data_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user=user, app_env=env, granularity=granularity,
user=user, app_env=env, statuses=status, granularity=granularity,
limit=limit, offset=offset, sort=sort,
)
return CouponDataOut(
@@ -87,6 +93,35 @@ def get_coupon_data(
)
@router.get(
"/coupons",
response_model=CouponSlotsOut,
summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)",
)
def get_coupon_slots(
db: AdminDb,
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
) -> CouponSlotsOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
d_to = _parse_day(date_to, field="date_to", default=d_from)
if d_to < d_from:
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS}")
env = None if app_env == "all" else app_env
result = coupon_data.coupon_slot_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env
)
return CouponSlotsOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
items=[CouponSlotRow(**r) for r in result["items"]],
)
@router.get(
"/user-records",
response_model=CouponUserRecordsOut,
+29
View File
@@ -19,6 +19,16 @@ class CouponDataSummary(BaseModel):
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
# 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。
full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)")
full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空")
point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)")
point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)")
point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空")
per_platform: dict[str, float | None] = Field(
default_factory=dict,
description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None",
)
class CouponDataDaily(BaseModel):
@@ -81,3 +91,22 @@ class CouponUserRecordsOut(BaseModel):
items: list[CouponDataRow]
total: int
class CouponSlotRow(BaseModel):
"""按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。"""
coupon_id: str
coupon_name: str | None = None
platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空")
tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)")
succeeded: int = Field(..., description="成功数(success+already_claimed)")
success_rate: float | None = Field(None, description="成功率=成功/尝试")
class CouponSlotsOut(BaseModel):
"""按券成功率表响应(§13)。"""
date_from: str
date_to: str
items: list[CouponSlotRow]
+10 -1
View File
@@ -81,7 +81,16 @@ def _record_claims_blocking(
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
with SessionLocal() as db:
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。
app_env = coupon_repo.session_app_env(db, trace_id)
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
# 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims,
# 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。
if trace_id:
coupon_repo.merge_session_platform_success(
db, trace_id, coupon_repo.succeeded_platforms(results)
)
def _mark_completed_blocking(
+7
View File
@@ -66,6 +66,9 @@ class CouponClaimRecord(Base):
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
status: Mapped[str] = mapped_column(String(24), nullable=False)
# 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。
# 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 这张领到几张(pricebot display_count;给不出时为 None)
@@ -240,6 +243,10 @@ class CouponSession(Base):
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# 领到总张数(收尾帧带)。
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。
# admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入
# (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。
platform_success: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
+83 -1
View File
@@ -147,12 +147,22 @@ def reset_today_completion(db: Session, device_id: str) -> int:
# ===== 领券记录(coupon_claim_record)=====
def session_app_env(db: Session, trace_id: str | None) -> str | None:
"""按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。"""
if not trace_id:
return None
return db.execute(
select(CouponSession.app_env).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
def record_claims(
db: Session,
device_id: str,
user_id: int | None,
trace_id: str | None,
results: list[dict],
app_env: str | None = None,
) -> int:
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
@@ -186,12 +196,15 @@ def record_claims(
row.user_id = user_id
if count is not None:
row.claimed_count = count
if app_env is not None:
row.app_env = app_env
row.extra = r
else:
db.add(CouponClaimRecord(
device_id=device_id, user_id=user_id,
coupon_id=coupon_id, claim_date=today,
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
status=status, app_env=app_env,
vendor=r.get("vendor"), coupon_name=r.get("name"),
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
extra=r,
))
@@ -235,6 +248,47 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
return int(total or 0)
# ===== 领券平台推导(coupon_id → 平台;成功平台集)=====
# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了);
# failed / skipped 不算。与 sum_claimed_count 同口径。
_SUCCESS_STATUSES = frozenset({"success", "already_claimed"})
# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。
DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai")
def coupon_id_to_platform(coupon_id: str | None) -> str | None:
"""coupon_id 前缀 → 平台 id;无法识别 / 空 → None。
与客户端 `CouponForegroundService.couponIdToPlatform` 同词表:
mt_美团外卖 / tb_·ele_·elm_淘宝闪购 / jd_京东外卖
"""
if not coupon_id:
return None
if coupon_id.startswith("mt_"):
return "meituan-waimai"
if coupon_id.startswith(("tb_", "ele_", "elm_")):
return "taobao-shanguang"
if coupon_id.startswith("jd_"):
return "jd-waimai"
return None
def succeeded_platforms(results: list[dict]) -> list[str]:
"""一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。
只取 status{success, already_claimed} 的券;失败/跳过无法识别平台的券跳过
"""
ok: set[str] = set()
for r in results:
if r.get("status") in _SUCCESS_STATUSES:
platform = coupon_id_to_platform(r.get("coupon_id"))
if platform is not None:
ok.add(platform)
return [p for p in DEFAULT_PLATFORMS if p in ok]
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
def upsert_coupon_session(
@@ -321,3 +375,31 @@ def upsert_coupon_session(
except IntegrityError:
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
def merge_session_platform_success(
db: Session, trace_id: str, platforms: list[str]
) -> None:
"""把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。
- 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)
- 读不到该 trace_id 的行 **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)
- 并集无变化(该平台已记过) 不写库,省一次 UPDATE
- fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略
"""
if not platforms:
return
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
if row is None:
return
merged = set(row.platform_success or []) | set(platforms)
new_list = [p for p in DEFAULT_PLATFORMS if p in merged]
if new_list == (row.platform_success or []):
return # 幂等:无新平台,不写
row.platform_success = new_list
try:
db.commit()
except IntegrityError:
db.rollback()
+48 -4
View File
@@ -1,4 +1,4 @@
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
# coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session)
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 同模型文件里还有第四张表 [`coupon_session`](./coupon_session.md)(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。
@@ -8,8 +8,9 @@
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
- **`coupon_session`** — admin「领券数据」看板数据源(**独立流水表,不是「今日状态」表**)。按 `trace_id` 一次领券一行,走 `POST /api/v1/coupon/session` 两段上报(发起/收尾),记全程耗时 + 各平台耗时 + `platform_success`(成功平台,算整单②/点位③成功率)。详见下方专节。
三表共同口径:
前三张「今日状态」表的共同口径:
- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。
- **日期 = `Asia/Shanghai` 自然日**`claim_date` / `engage_date` / `complete_date``repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。
- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。
@@ -91,7 +92,7 @@
### 用在哪 / 增删改查
- **C / U(幂等 upsert**`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。
- **U / D**:无业务删除。
- **R****当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)
- **R**`GET /admin/api/coupon-data/coupons``coupon_slot_report`)—— admin「按券成功率」表,按 `coupon_id` 聚合 成功/(成功+失败)`skipped` 排除,设备-天口径,按 `app_env` 过滤)。见设计 §13
### 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -102,6 +103,7 @@
| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) |
| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 |
| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) |
| `app_env` | String(16) | index, 可空 | 领券所属 session 环境 `prod`/`dev``/step``trace_id``coupon_session.app_env` 打标);旧行 NULL(不回填)。admin「按券成功率」表按它过滤。见设计 §13 |
| `vendor` | String(48) | 可空 | 券提供方 |
| `coupon_name` | String(128) | 可空 | 取 pricebot `name` |
| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count` |
@@ -121,7 +123,49 @@
---
## 三表共性小结
## coupon_session — 领券任务流水(一次领券一行,admin「领券数据」看板数据源)
`trace_id` 唯一,一次领券一行。与上面三张「今日状态」表不同:本表走 `POST /api/v1/coupon/session`(客户端**两段上报**:发起 `started` 建行、收尾 `completed`/`failed`/`abandoned``trace_id` 更新同一行),记从发起到收尾的全程耗时 + 各平台耗时 + 机型/ROM。发起即落库 → admin 可算「发起数」与中途流失(started 无终态 = 未完成)。
### 用在哪 / 增删改查
- **C / U(幂等 upsert**`upsert_coupon_session`,由 `POST /api/v1/coupon/session` 两段上报。**状态只前进**(started 帧重复到不覆盖已有终态);终态补 `finished_at`
- **U(并集写)**`merge_session_platform_success`,由 `POST /api/v1/coupon/step` 每逢**带券结果的帧**调用——把本帧「成功平台」(`status∈{success,already_claimed}` 的券 → `coupon_id` 前缀映射平台)**并入** `platform_success`(并集幂等,无新平台不写;读不到该 trace 行则跳过)。复用 `record_claims` 的同一 `SessionLocal`,不新增连接。
- **R**admin `GET /admin/api/coupon-data``coupon_data_report`)—— 发起/完成数、耗时分位、**整单成功率②/点位成功率③**、按天/小时趋势、逐条明细;`GET /admin/api/coupon-data/user-records` 某用户全部领券。
### 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trace_id` | String(64) | NOT NULL, UNIQUE | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键 |
| `device_id` | String(64) | NOT NULL | |
| `user_id` | Integer | index, 可空 | 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空 |
| `status` | String(16) | NOT NULL | `started` / `completed` / `failed` / `abandoned`;started 无终态 = 中途流失 |
| `app_env` | String(16) | index, 可空 | `prod` / `dev`;admin 报表默认只看 prod(防测试串台) |
| `platforms` | JSON | 可空 | 发起勾选平台 `["meituan-waimai",…]`(空 = 全领三档);**③点位成功率的分母来源** |
| `origin_package` | String(64) | 可空 | 发起来源外卖 App 包名;null = App 内(傻瓜比价首页)发起,非空 = 外卖侧弹券 |
| `device_model` | String(128) | 可空 | Build.MANUFACTURER + MODEL |
| `rom` | String(64) | 可空 | OemDetector,如 "ColorOS 14" |
| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟);明细「时间」列、趋势 X 轴 |
| `started_date` | **Date** | NOT NULL | 发起的**北京**自然日;admin 按天聚合/筛选(索引) |
| `finished_at` | DateTime(tz) | 可空 | 收尾时刻(服务端 now);未收尾(流失)为空 |
| `elapsed_ms` | Integer | 可空 | 全程耗时(ms,客户端点发起→收尾);均值/分位只统计 completed |
| `platform_elapsed` | JSON | 可空 | 各平台领券耗时 `{"meituan-waimai":3200,…}`ms |
| `claimed_count` | Integer | 可空 | 领到总张数(收尾帧带) |
| `platform_success` | JSONPG JSONB | 可空 | **本次至少领到一张(`status∈{success,already_claimed}`)的平台 id 列表**,如 `["meituan-waimai","jd-waimai"]``/step` 逐帧按 `trace_id` **并集**写入(`merge_session_platform_success`;旧行 NULL 视作空集。admin 据此算整单成功率②(`platforms``platform_success`)/点位成功率③(Σ交集/Σ勾选)。设计:[领券成功率指标](../guides/领券成功率指标-设计与埋点.md) |
| `trace_url` | String(512) | 可空 | pricebot done 帧回传的公网 trace 链接;未到 donefailed/abandoned)为空 |
| `created_at` | DateTime(tz) | server_default now() | |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
### 索引与约束
- PK `id`index `user_id``app_env`UNIQUE(`trace_id`) = `uq_coupon_session_trace`Index(`started_date`, `app_env`) = `ix_coupon_session_date_env`admin 主聚合/筛选)。
### 注意
- `platform_success` 是**布尔性质**的平台集,跨帧**并集**天然幂等 → `/step` 每帧并入不重复计;失败/中途退出的 session 也能拿到崩溃前已成的平台。写放大 ≈ 领券券数(仅带券结果的帧写)。
- 成功率**基数 = 区间全部 session**(含 abandoned/failed,与「发起数」同基数(设计 §3)。`coupon_id → 平台` 用前缀(`mt_`/`tb_`·`ele_`·`elm_`/`jd_`,与客户端 `couponIdToPlatform` 同词表。
---
## 今日状态三表共性小结
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
- 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
- 无硬外键:`user_id` 软指 `user.id``trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
@@ -0,0 +1,294 @@
# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点
- 日期:2026-07-07
- 状态:待评审
- 涉及仓库:`shaguabijia-app-server`**纯服务端**;客户端零改动)
- 数据源表:`coupon_session`admin「领券数据」看板)
## 1. 背景与目标
admin「领券数据」看板(数据源 `coupon_session`,见 `app/admin/repositories/coupon_data.py`)当前能算:**领券发起数、完成数、全程耗时均值/分位**。产品还想要两个成功率指标,现有埋点算不出来:
- **② 整单成功率** = 一次发起里勾选的平台**全部**领到券的次数 / 领券发起数
- **③ 点位成功率** = 平台维度的领取成功率(每个平台「点位」成没成功)
> 口径决定(2026-07-07):「点位」= **平台粒度**(美团 / 淘宝闪购 / 京东),不是「每张券」。
耗时中位数、发起数已由 `coupon_session.elapsed_ms` / `status=started` 计数满足,本设计只补 ②③。
## 2. 关键结论:per-slot 信号已在库,缺的是「按 session 可靠归因 + 看板可过滤」
服务端 `/api/v1/coupon/step``app/api/v1/coupon.py`)每帧都调 `record_claims`,把**每张券**的结果(`status ∈ success / already_claimed / failed / skipped`)写进 `coupon_claim_record`,还带 `trace_id`。原始成败信号**已经落库**。
但该表**不能**直接支撑本指标:
1. 唯一键是 `(device_id, coupon_id, claim_date)`**不含 trace_id**;且 `record_claims` 冲突更新时**不更新 trace_id**`app/repositories/coupon_state.py``record_claims`,仅 INSERT 时写 trace_id)。→ 同一张券当天跨多次 session 会折叠成一行、只归属**最早**那次 → **按 session 归因不可靠**(直接砸 ②「整单全成功」)。
2. `coupon_claim_record``app_env` / `origin_package` → 无法像看板那样只看 prod、也无法拆 Path AApp 内发起)/ Path B(外卖侧弹券)。
因此采用 **route B**:在 `/step` 里服务端推导「本次 session 哪些平台成功」,直接写到 `coupon_session` 行——该表按 `trace_id` 唯一、已带 `app_env` / `origin_package` / `platforms`,指标干净可过滤、可拆路径。
## 3. 指标口径(平台粒度,已定)
记一次 session 为 `s`
- `sel(s)` = **勾选平台集** = `coupon_session.platforms`;为空表示「全领」→ 取默认 `{meituan-waimai, taobao-shanguang, jd-waimai}``|sel(s)|` = 该次「点位数」。
- `succ(s)` = **成功平台集** = 本次 session 里**至少领到一张**`status ∈ {success, already_claimed}`)的平台集合。
指标:
- **③ 点位成功率** = `Σ_s |succ(s) ∩ sel(s)|` / `Σ_s |sel(s)|`
(分母即「发起数 × 各自点位数」;全部全领时等于 发起数 × 3)
- **② 整单成功率** = `#{ s : sel(s) ⊆ succ(s) 且 sel(s) ≠ ∅ }` / `发起数`
已定边界:
1. **③ 分母用「勾选平台」`sel(s)`**。勾了淘宝但淘宝没领到 = 该点位未成功(不特判「平台没券」)。若日后要「真没券的平台不计入分母」,再引入 `platform_attempted`(见 §8)。
2. **②③ 基数 = 区间内全部 session**(含 `started` / `failed` / `abandoned`),与看板「发起数」同基数。**失败 / 中途退出的 session 的 `succ(s)` 取它崩溃前真领到的平台**(不一律算 0)。
3. **成功语义**`status ∈ {success, already_claimed}` = 成功;`failed` / `skipped` = 未成功。与 `sum_claimed_count``app/repositories/coupon_state.py`)一致。
> pricebot 协议文档把 `status` enum 写作 `success|failed|skipped`,但**代码实际还会 emit `already_claimed`**pricebot `app/services/coupon_provider.py` 等多处)——以代码为准,含 already_claimed 是对的。`skipped` 目前 MVP 阶段基本不出现。
## 4. 数据模型改动
`coupon_session``app/models/coupon_state.py::CouponSession`)新增一列:
| 列 | 类型 | 说明 |
|----|------|------|
| `platform_success` | `_JSON`PG→JSONB / SQLite→JSON),nullable | 本次 session **至少领到一张**的平台 id 列表,如 `["meituan-waimai","jd-waimai"]`。旧行 = `NULL` → 视作空集。 |
- alembic 新迁移:`add_column coupon_session.platform_success`nullable、无 server_default。
- **分母 `sel(s)` 复用已有 `platforms` 列,不新增字段。** 净新增仅此一列。
- 同步更新表字典 `docs/database/coupon_state.md`
## 5. 服务端推导逻辑(`/step`
`app/api/v1/coupon.py::coupon_step` 内(已有 `results = _extract_coupon_results(resp_json)`)新增:
1. 对每条 result 求平台:**按 `coupon_id` 前缀映射**(与客户端 `CouponForegroundService.couponIdToPlatform` 对齐,且与 `platforms` / `platform_elapsed` 用同一套平台 id 词表):
- `mt_``meituan-waimai`
- `tb_` / `ele_` / `elm_``taobao-shanguang`
- `jd_``jd-waimai`
- 其余 → 跳过(无法识别)
> 不用券的 `vendor` 字段做映射:vendor 词表未必等于这三档平台 id,而 `sel(s)` 用的就是这三档,`succ(s)` 必须同词表。
2. 收集 `status ∈ {success, already_claimed}` 的平台集合 `ok_platforms`
3. 若 `device_id``ok_platforms` 非空:调用新 repo 函数把 `ok_platforms` **并入** `coupon_session.platform_success`(按 `trace_id`)。
新增 `app/repositories/coupon_state.py::merge_session_platform_success(db, trace_id, ok_platforms)`
- 读现有行 → `platform_success = 现有 ok_platforms`(去重、保序)→ 写回、commit`IntegrityError` 回滚忽略(同现有 upsert 兜底)。
- **并集幂等**:跨帧多次并入同一平台不会重复;done 帧的全量 `coupon_results` 保证完整;失败 / 中途退出的 session 靠中间帧 `last_coupon_result` 已并入的平台拿到「部分成功」。
- **fire-and-forget**:包 `run_in_threadpool` + 整段 try/except 只 log,绝不连累 `/step` 返回(与现有 `record_claims` / `mark_completed` 同规格)。
**建行 / 时序(已定:行不存在则跳过本次并入)**:
- 客户端 `/session started``start()`(任务发起那刻)就发,而首个带券的 `/step` 要等领券循环跑起来、晚几秒;到 `/step``ok_platforms` 时,`coupon_session` 行几乎必然已存在。故 `merge_session_platform_success` **读不到行就跳过**,不建兜底行——实现最简,也不引入 `started_at` 不精确的脏行。
- 残留丢数窗口:仅当「`started` 上报丢失」**且**「`/step` done 先于 `/session` terminal 落库」两者同时成立,该 session 的 `platform_success` 才会缺(terminal 帧会建行但那之后没有 `/step` 再并入)。两条件叠加概率极低,且指标是聚合口径、可容忍个别缺失。
- 若上线后观测到该缺失不可忽略,再降级为「读不到行则 upsert 建最小兜底行(`started_at=now()`)」——届时改 `merge_session_platform_success` 一处即可,不影响其余设计。
**写放大(重要,非每 step)**:`merge_session_platform_success` **只在「本帧带券结果」时触发**——即 pricebot 在**单券完成帧**给 `last_coupon_result`、**最终 done 帧**给全量 `coupon_results` 的那些帧;领单张券途中的导航/点击帧(占 step 大头)`_extract_coupon_results` 返回空 → **不写**。所以写频次 ≈ **本次领的券数**(通常个位数),且落在**服务端今天已有的** `record_claims` 写的**同一批帧**上,不新增写的帧。
实现:把 merge 放进**现有 `_record_claims_blocking` 的同一个 `SessionLocal`**(紧接 `record_claims`),边际成本 = 每张券完成时多一条 `UPDATE coupon_session`(按 `trace_id` 唯一索引),不新增连接 / 不新增 `run_in_threadpool` 调用。
可选降级(若要「一次 session 只写一次」):只在 done 帧写 `platform_success`(全量 `coupon_results` 一次算完)。代价:`failed` / `abandoned`(没 done 帧)拿不到「崩溃前已成平台」→ 失败单部分成功丢失,与 §3「失败单取实际成的平台」相悖。**默认取每券帧并入**(失败单也如实统计),此降级留作观测到写压力后再启用。
## 6. admin 聚合与呈现
`app/admin/repositories/coupon_data.py::coupon_data_report``summary` 增加(沿用「全量拉区间 → Python 聚合」风格,与分位一致):
- `full_success_rate`(②)、`point_success_rate`(③)
- 可选 `per_platform``{platform: rate}`(各平台点位成功率,拆美团/淘宝/京东)
计算:对区间内 sessions`sel = platforms or 默认三档``succ = set(platform_success) ∩ sel`;按 §3 公式汇总。
- 过滤:默认 `app_env == 'prod'`(同现有分位口径,防测试串台)。
- 拆路径:`origin_package` 已在表上 → 看板后续可加「Path A / Path B」筛选项(本设计不含前端图表细节)。
- schema`app/admin/schemas/coupon_data.py` 的 summary 加对应字段(+ 可选 `per_platform`)。
## 7. 改动清单
- [x] `app/models/coupon_state.py``CouponSession``platform_success`
- [x] `alembic/versions/`:新迁移 add column `coupon_session.platform_success`
- [x] `app/repositories/coupon_state.py`:新增 `merge_session_platform_success`
- [x] `app/api/v1/coupon.py``/step` 推导 `ok_platforms` 并 union(前缀映射 + fire-and-forget
- [x] `app/admin/repositories/coupon_data.py``summary` 加 ②③(+ 可选 `per_platform`
- [x] `app/admin/schemas/coupon_data.py``summary` schema 加字段
- [x] `docs/database/coupon_state.md`:补 `platform_success` 列说明(并补 coupon_session 整节)
- [x] `tests/``tests/test_coupon_platform_success.py`10 测试,见 §9
## 8. 不做(YAGNI / 边界)
- **客户端不改、历史不回填**`platform_success` 只对新 session 生效(route B 的固有取舍,用户已接受)。
- **不引入 `platform_attempted`**:③ 分母用勾选平台。若日后要「排除真没券的平台」,注意 `platform_elapsed.keys()` 已近似「被处理过的平台」,可作 attempted 的现成来源,多半仍不必加列。
- **不动 `coupon_claim_record`** 的去重 / 归因:本指标绕开它,避免牵动频控 / 资产 / CPS 语义。
- **不做券级(每张券)成功率**:已选平台粒度。
## 9. 测试口径要点
- **union 幂等**:同一 `trace_id` 多帧并入同一平台,`platform_success` 不重复、保序。
- **失败单部分成功**session `failed`,但美团已成 → `succ = {meituan-waimai}`,计入 ③ 分子;② 仅当 `sel ⊆ succ` 才算整单成功。
- **空 `platforms` → `sel` 取默认三档**(全领)。
- **成功语义**`already_claimed` 计成功;`skipped` 不计。
- **基数**`abandoned` 计入 ②③ 基数,`succ` 取实际成的平台。
- **prod 过滤**`dev` 环境 session 不进指标。
## 10. 对 pricebot-backend 的影响
**结论:不需要改 pricebot,也不改发往 pricebot 的请求 / 不加调用 / 不加负载。**
- `/step` 仍原样透传请求 bytes 给 pricebot;本设计只**多解析 pricebot 的响应**`coupon_results` / `last_coupon_result`),而这两个字段服务端**今天已在** `_extract_coupon_results` / `record_claims` 里解析。零新增字段需求、零额外上游调用、pricebot 负载不变。
- **只读依赖(既有耦合,非新引入)**:平台映射靠 pricebot 的 `coupon_id` 前缀约定(`mt_` / `tb_` / `ele_` / `elm_` / `jd_`)。客户端 `couponIdToPlatform` 早就依赖同一套;本设计只是加了这份映射的第二个消费者。维护耦合:pricebot 若改 `coupon_id` 前缀,客户端与本指标会**一起**失效——但这是既有风险,依赖方向不变。无法识别前缀的券按「跳过」处理(与客户端一致)。
- 认账的 pricebot 事实(源:`app/models/response.py` + `docs/projects/领券-客户端对接协议.md`):
- `CouponResult = {coupon_id, name, vendor, status, reason?, duration_ms?}``coupon_results` 仅最终 done 帧全量,中间帧走 `last_coupon_result`(单张)。
- `vendor` 是来源标签(`meituan_internal` / `dianping_cps` …),**不等于**三档平台 id;且 `mt_dianping_xxx`(大众点评 CPS)也带 `mt_` 前缀归美团 → 印证「用 `coupon_id` 前缀、不用 `vendor`」正确。
---
## 11. 实现状态(交付记录 · 2026-07-07
**状态:实现完成、TDD 全绿、未提交、迁移未应用。** 纯服务端(shaguabijia-app-server),客户端 / pricebot 未动。
### 已交付改动
| 文件 | 改动 |
|---|---|
| `app/models/coupon_state.py` | `CouponSession``platform_success``_JSON`, nullable |
| `app/repositories/coupon_state.py` | `coupon_id_to_platform` / `succeeded_platforms` / `merge_session_platform_success` + 常量 `DEFAULT_PLATFORMS` / `_SUCCESS_STATUSES` |
| `app/api/v1/coupon.py` | `_record_claims_blocking` 内、`record_claims` 之后并入本帧成功平台(同一 `SessionLocal` |
| `app/admin/repositories/coupon_data.py` | `_success_rates(rows)` → summary 加 `full_success_rate②` / `point_success_rate③` + 3 个支撑计数 |
| `app/admin/schemas/coupon_data.py` | `CouponDataSummary` 加 5 字段 |
| `alembic/versions/coupon_session_platform_success.py` | add column;revision=`coupon_session_platform_success`down=`admin_user_plain_password`(当前 head |
| `docs/database/coupon_state.md` | 补 `coupon_session` 整节(表原本无文档)+ 新列 |
| `tests/test_coupon_platform_success.py` | 10 个测试(TDD |
> §7 里「可选 `per_platform`」原标 YAGNI 延后;**已在 §122026-07-08)补做**(随 admin 前端看板卡一并接入,见下)。
### 测试与验证
- 本特性:`Set-Location e:\project\shaguabijia-app-server; & .\.venv\Scripts\python.exe -m pytest tests\test_coupon_platform_success.py -q`**10 passed**
- 全量 `pytest -q`:**306 passed / 5 failed**。5 个失败**全部预存、与本次无关**:
- `test_coupon_proxy.py::test_coupon_step_passes_body_through`(测试断言 `json=` 但 handler 用 `content=` 转发;写代码前 sanity run 就红)。
- `test_invite.py` + `test_invite_compare_reward.py` 共 4 个(单独跑也红;本次改动集零 invite 文件)。
- 迁移:临时库 `alembic upgrade head` 通过、列已建、单一 head。
- lint:新增代码 `ruff` 全清;`coupon_state.py` 剩 3 处 pre-existing UP017`upsert_coupon_session``timezone.utc`)未动。
### 待办(在后端项目里继续)
1. **应用迁移**:`alembic upgrade head`(DDL 已验;线上只前进统计、历史不回填)。
2. **提交**:尚未提交;建议先开分支再提交。
3. **admin 前端图表**:后端指标已就绪(summary 的 `full_success_rate` / `point_success_rate` 等),看板卡 / 趋势展示待接前端。
4. (可选)修预存 `test_coupon_step_passes_body_through`(一行:capture `content` 而非 `json`)。
### 续开发须知
- `platform_success` 只在**带券结果的帧**写(`/step``succeeded_platforms(results)` 非空才 merge),≈ 领券券数量级、非每 step;复用 `record_claims` 同一 `SessionLocal`、不新增连接。
- merge **读不到 session 行则跳过**(不建兜底行);并集幂等、无新平台不写。
- 口径:成功=`status∈{success,already_claimed}`;基数含 `abandoned`/`failed`;③ 分母=`platforms`(空→全领三档);`coupon_id`→平台走前缀,与客户端 `couponIdToPlatform` 同词表。
---
## 12. 续做:admin 前端看板卡 + 分平台点位成功率(设计 · 2026-07-08
承 §11 待办 #3(前端图表)与 §7「可选 `per_platform`」。本轮把 ②③ 接入 admin「领券数据」页,并把 ③ 按平台拆(`per_platform`)。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。
- 状态:设计已评审通过(2026-07-08),待实现。
- 涉及仓库:`shaguabijia-app-server`(后端)+ `shaguabijia-admin-web`(admin 前端,Next.js + antd)。
### 12.1 后端:`per_platform` 分平台点位成功率
`app/admin/repositories/coupon_data.py::_success_rates(rows)` 在现有合计基础上,对每个 `p ∈ DEFAULT_PLATFORMS`(美团/淘宝/京东)累加:
- 分母 `per_total[p]` = 勾选了 p 的 session 数(`p ∈ sel`,空勾选 `sel` 按全领三档);
- 分子 `per_succ[p]` = 其中 `p ∈ succ`(该平台至少领到一张)的 session 数;
- `per_platform[p]` = `round(per_succ[p] / per_total[p], 4)`;分母 0 → `None`
**不变量**:`Σ_p per_succ[p] == point_success_count``Σ_p per_total[p] == point_total_count`(三档词表下恒成立;非三档平台 id 不计入 `per_platform`,由 `if p in per_total` 守卫)。用 §9 / `test_coupon_data_success_rates` 数据自检:美团 3/4=0.75、淘宝 2/3=0.6667、京东 1/2=0.5;合计仍 6/9=0.6667。
- summary 加一项 `per_platform`:**恒含三档键**,如 `{"meituan-waimai":0.75,"taobao-shanguang":0.6667,"jd-waimai":0.5}`(区间内无人勾选的平台 → 值 `None`)。
- schema `app/admin/schemas/coupon_data.py::CouponDataSummary``per_platform: dict[str, float | None]`
### 12.2 前端:admin-web「领券数据」汇总卡补一段
`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`(单文件,`CouponDataSummary` 为该页内联类型):
- 内联 `CouponDataSummary` 补:`full_success_count` / `full_success_rate` / `point_success_count` / `point_total_count` / `point_success_rate` + `per_platform: Record<string, number | null>`
- 新 helper `fmtPct(v) = v == null ? '-' : ${(v*100).toFixed(1)}%`(沿用本页 `-` 空值风格,数学同大盘 `pct`);antd 导入补 `Tooltip`,新增 `import { InfoCircleOutlined } from '@ant-design/icons'`
- 汇总卡「耗时分位」行之后,`Divider` + 两行 `Statistic`(各 `Col flex="1 1 0"`):
- 行1:**整单成功率** `fmtPct(full_success_rate)` · **点位成功率(合计)** `fmtPct(point_success_rate)`;标题各带 ⓘ `Tooltip`(口径说明;合计率注明「平台粒度、= 三档之和,与『数据大盘』券粒度口径不同」)。
- 行2:**美团 / 淘宝 / 京东 点位成功率** `fmtPct(per_platform['meituan-waimai' | 'taobao-shanguang' | 'jd-waimai'])`(平台名同「美团耗时」列既有叫法)。
- 不显示支撑数(合计与分平台均纯百分比);tooltip 不带分母。
### 12.3 测试
- 扩 `tests/test_coupon_platform_success.py::test_coupon_data_success_rates`:断言 `per_platform == {"meituan-waimai":0.75,"taobao-shanguang":round(2/3,4),"jd-waimai":0.5}`,并断言和不变量(`Σ 分子 == point_success_count == 6``Σ 分母 == point_total_count == 9`)。
- 前端 `shaguabijia-admin-web` lint / type-check 通过。
- 端到端:随下一步「真实 /step 实测」在跑起来的 admin-web + 后端页面上核对卡片渲染。
### 12.4 不做(YAGNI)
- 成功率**趋势线**:后端 `daily` / `hourly` 不含率字段,加趋势要另改聚合,超出本轮范围。
- 合计 / 分平台的**支撑数副文本**、tooltip 带分母。
- 客户端 / pricebot 改动;历史回填。
---
## 13. 续做:每券(coupon_id)成功率明细表(设计 · 2026-07-08)
产品要更细粒度:到**具体领券点位**(如「美团外卖红包天天领」「美团甄选好店」),即按 `coupon_id` 算成功/失败率,比 §12 的平台粒度再细一层。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。
- 状态:设计已评审通过(2026-07-08),待实现。
- 关键取舍(已定):数据源用 **`coupon_claim_record`**(它本就是「单张券一天一条」的系统记录,已带 `coupon_name` / `status` / `vendor` / `trace_id`),唯一缺 `app_env` → 补一列即可。§2 当初绕开它是因为**平台指标要按 session 归因**;而**每券成功率是全局聚合、不需要 session 归因**,`(device,券,天)` 折叠反而天然去重防刷,故这里用它是对的。
### 13.1 指标口径(已定)
- **成功** = `status ∈ {success, already_claimed}`;**尝试(分母)** = `status ∈ {success, already_claimed, failed}`;**`skipped` 排除**(无券可领/不适用,不算尝试、不进分母、不展示)。
- **成功率** = 成功 / 尝试(某券区间内无 tried 行 → 不出现在表里,无除零)。
- **粒度 = 「设备-天」**(非「每次点击」):`coupon_claim_record` 唯一键 `(device, coupon_id, claim_date)`,同设备当天同券只留最后状态。所以「尝试」= 有多少**设备-天**尝试过该券,「成功」= 其中最终领到的。要每次点击级须换源(route B / 原始事件),本轮不做。
- **环境**:跟随页面「环境」(prod/dev/全部),按 `app_env` 过滤;旧行 `app_env=NULL` **不回填** → 仅「全部」视图可见。
- **日期**:按 `claim_date`(Asia/Shanghai 自然日),与页面日期范围一致。
### 13.2 数据模型
`CouponClaimRecord`(`app/models/coupon_state.py`)加一列:
| 列 | 类型 | 说明 |
|----|------|------|
| `app_env` | `String(16)`,index,nullable | prod / dev。`/step` 落库时按 session 的 app_env 打标;旧行 NULL(不回填)。 |
- alembic 新迁移:`add_column coupon_claim_record.app_env`,nullable + index,无 server_default。
- 同步更新 `docs/database/coupon_state.md` 的 coupon_claim_record 节。
### 13.3 写路径(`/step`)
- `app/repositories/coupon_state.py::record_claims` 加参数 `app_env: str | None = None`;INSERT 时写入,UPDATE 时 `if app_env is not None: row.app_env = app_env`(不用 None 覆盖已有)。
- 新增轻量 repo 助手 `session_app_env(db, trace_id) -> str | None`(按 trace_id 取 `coupon_session.app_env`,查不到返回 None)。
- `app/api/v1/coupon.py::_record_claims_blocking`:`trace_id` 存在则先 `app_env = coupon_repo.session_app_env(db, trace_id)`,传给 `record_claims(..., app_env=app_env)`;`merge_session_platform_success` 保持原样(其自身 select 不变)。查不到 session / 无 trace_id → `app_env=None`(行为同旧)。
> 写放大:仅「带券结果的帧」触发(同 §5),每帧多一次 `session_app_env` 小查询(按 trace_id 唯一索引),可忽略。
### 13.4 admin 聚合(新 repo 函数)
`app/admin/repositories/coupon_data.py` 新增 `coupon_slot_report(db, *, date_from, date_to, app_env)`(与 `coupon_user_records` 同居本文件,同属「领券数据」看板;更新模块 docstring 注明本文件现读 `coupon_session` + `coupon_claim_record` 两源):
- `SELECT coupon_id, MAX(coupon_name) AS coupon_name, COUNT(*) AS tried, SUM(CASE WHEN status IN (success,already_claimed) THEN 1 ELSE 0 END) AS succeeded`
`WHERE claim_date ∈ [from,to] AND status IN (success,already_claimed,failed)` (+ `AND app_env = :env` 当 env 非「全部」) `GROUP BY coupon_id`
- 每行:`platform = coupon_id_to_platform(coupon_id)`(复用前缀映射,无法识别→None),`success_rate = round(succeeded/tried, 4)`
- 按 `tried` 倒序、`coupon_id` 次序返回 `{"items": [...]}`
### 13.5 接口 + schema(新子端点)
- 路由 `app/admin/routers/coupon_data.py`:加 `@router.get("/coupons")``get_coupon_slots`,参数 `date_from` / `date_to` / `app_env`(同主 report 的解析与 `_MAX_RANGE_DAYS` 校验,`app_env="all"→None`),调 `coupon_slot_report`。路径全称 `/admin/api/coupon-data/coupons`(与现有 `/coupon-data/user-records` 同款子端点)。
- schema `app/admin/schemas/coupon_data.py`:
- `CouponSlotRow{coupon_id: str, coupon_name: str|None, platform: str|None, tried: int, succeeded: int, success_rate: float|None}`
- `CouponSlotsOut{date_from: str, date_to: str, items: list[CouponSlotRow]}`
### 13.6 前端
`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`:
- 汇总卡下方(或趋势图下方)新增一张「按券成功率」表:列 **券名**(`coupon_name || coupon_id`)/ **平台**(美团/淘宝/京东/其他)/ **尝试** / **成功** / **成功率**(`fmtPct`)。默认按尝试倒序;支持 antd 列排序。
- 点「查询」时,除主 report 外并行 `api.get('/admin/api/coupon-data/coupons', {params:{date_from,date_to,app_env}})`;空则不显示表。
- 新增内联类型 `CouponSlotRow`;平台名复用映射(`meituan-waimai→美团` 等,null→其他)。
### 13.7 测试
- 后端:`record_claims` stamp `app_env`(INSERT/UPDATE 两路);`session_app_env` 助手;`/step` 集成写入 `app_env`;`coupon_slot_report` 聚合(多券 × success/already_claimed/failed/skipped × 两 env:断言 tried/succeeded/rate、**skipped 排除**、env 过滤、排序);schema 契约。
- 前端:`tsc --noEmit`
### 13.8 不做(YAGNI)
- 每次点击级成功率(需换数据源);成功率趋势线;`app_env` 历史回填(可选一次性 `join trace_id→session.app_env`,默认不做);券级 tooltip / 明细下钻。
- 券表为**全局聚合**,**不随页面「用户」搜索框过滤**(用户维度非本需求;`coupon_claim_record` 虽有 `user_id`,YAGNI)。与主明细表按用户过滤的行为不同,属有意为之。
+299
View File
@@ -0,0 +1,299 @@
"""领券「平台成功率」埋点:coupon_id→平台映射 / 成功平台推导 / session platform_success 并集。
设计:docs/guides/领券成功率指标-设计与埋点.md
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from sqlalchemy import delete, func, select
from app.admin.repositories.coupon_data import coupon_data_report
from app.db.session import SessionLocal
from app.models.coupon_state import CouponSession
from app.repositories.coupon_state import (
coupon_id_to_platform,
merge_session_platform_success,
succeeded_platforms,
)
def _agg_session(
trace: str, platforms, platform_success, *, status: str = "completed"
) -> CouponSession:
"""构造一条聚合测试用 session(started_date 固定 2020-01-02、app_env=prod,不 commit)。"""
return CouponSession(
trace_id=trace,
device_id="d-agg",
status=status,
app_env="prod",
platforms=platforms,
platform_success=platform_success,
started_at=datetime(2020, 1, 2, tzinfo=UTC),
started_date=date(2020, 1, 2),
)
def _make_session(db, trace_id: str, **kw) -> CouponSession:
row = CouponSession(
trace_id=trace_id,
device_id="dev-merge",
status=kw.pop("status", "started"),
started_at=datetime(2020, 1, 1, tzinfo=UTC),
started_date=date(2020, 1, 1),
**kw,
)
db.add(row)
db.commit()
return row
def test_coupon_id_to_platform_prefix_mapping() -> None:
"""coupon_id 前缀 → 三档平台 id(与客户端 couponIdToPlatform 同词表)。"""
assert coupon_id_to_platform("mt_banjia_zhoumo") == "meituan-waimai"
assert coupon_id_to_platform("mt_cps_waimai_redpacket") == "meituan-waimai" # 大众点评CPS 也挂 mt_ → 归美团
assert coupon_id_to_platform("tb_vip_shangou_voucher") == "taobao-shanguang"
assert coupon_id_to_platform("ele_hongbao") == "taobao-shanguang"
assert coupon_id_to_platform("elm_hongbao") == "taobao-shanguang"
assert coupon_id_to_platform("jd_redpacket") == "jd-waimai"
def test_coupon_id_to_platform_unknown_returns_none() -> None:
"""无法识别的前缀 / 空 → None(调用方跳过,不计入平台)。"""
assert coupon_id_to_platform("weird_xxx") is None
assert coupon_id_to_platform("") is None
assert coupon_id_to_platform(None) is None # type: ignore[arg-type]
def test_succeeded_platforms_filters_status_and_dedups() -> None:
"""只取 status∈{success,already_claimed} 的券,映射平台后去重;失败/跳过/无法识别的不计。"""
results = [
{"coupon_id": "mt_a", "status": "success"},
{"coupon_id": "mt_b", "status": "already_claimed"}, # 也算成功 → 仍是美团
{"coupon_id": "mt_c", "status": "failed"}, # 不算
{"coupon_id": "tb_a", "status": "success"},
{"coupon_id": "jd_a", "status": "skipped"}, # 不算
{"coupon_id": "weird", "status": "success"}, # 无法识别平台 → 跳过
]
assert set(succeeded_platforms(results)) == {"meituan-waimai", "taobao-shanguang"}
def test_succeeded_platforms_empty() -> None:
assert succeeded_platforms([]) == []
def test_merge_platform_success_union_idempotent() -> None:
"""按 trace_id 把成功平台并入 platform_success:并集去重 + 按 DEFAULT_PLATFORMS 保序 + 重复并入幂等。"""
db = SessionLocal()
trace = "merge-union-1"
try:
_make_session(db, trace)
merge_session_platform_success(db, trace, ["meituan-waimai"])
merge_session_platform_success(db, trace, ["jd-waimai", "meituan-waimai"]) # 并集 + 已有幂等
db.expire_all()
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace)
).scalar_one()
# 美团→淘宝→京东固定序;只含实际成功的美团/京东
assert row.platform_success == ["meituan-waimai", "jd-waimai"]
finally:
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
db.commit()
db.close()
def test_merge_platform_success_missing_row_skips() -> None:
"""trace_id 无对应行 → 静默跳过:不建兜底行、不抛异常(设计 §5)。"""
db = SessionLocal()
try:
merge_session_platform_success(db, "no-such-trace", ["meituan-waimai"])
n = db.execute(
select(func.count()).select_from(CouponSession).where(
CouponSession.trace_id == "no-such-trace"
)
).scalar_one()
assert n == 0
finally:
db.close()
def test_coupon_data_success_rates() -> None:
"""admin 聚合 ②整单成功率 / ③点位成功率:平台粒度,含空 platforms→全领三档、abandoned 入基数。
A 勾美团+淘宝两个都成 整单成功;点位 2/2
B 勾三档只成美团 非整单;点位 1/3
C 全领(platforms空3)三档全成 整单成功;点位 3/3
D abandoned勾美团无成功平台 非整单;点位 0/1(仍进基数)
发起数=4;整单成功=2 0.5;点位 6/9 0.6667
"""
db = SessionLocal()
try:
db.add_all([
_agg_session("agg-A", ["meituan-waimai", "taobao-shanguang"],
["meituan-waimai", "taobao-shanguang"]),
_agg_session("agg-B", ["meituan-waimai", "taobao-shanguang", "jd-waimai"],
["meituan-waimai"]),
_agg_session("agg-C", [], ["meituan-waimai", "taobao-shanguang", "jd-waimai"]),
_agg_session("agg-D", ["meituan-waimai"], None, status="abandoned"),
])
db.flush() # 同会话可见,不 commit(finally 回滚保持隔离)
s = coupon_data_report(
db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod"
)["summary"]
assert s["started_count"] == 4
assert s["full_success_count"] == 2
assert s["full_success_rate"] == 0.5
assert s["point_success_count"] == 6
assert s["point_total_count"] == 9
assert s["point_success_rate"] == round(6 / 9, 4)
# ③ 分平台点位成功率(§12):美团 3/4、淘宝 2/3、京东 1/2。
# 分平台(成功,总)= 美团(3,4)+淘宝(2,3)+京东(1,2) = (6,9),
# 其和正好等于上面已断言的 point_success_count=6 / point_total_count=9(和不变量)。
assert s["per_platform"] == {
"meituan-waimai": 0.75,
"taobao-shanguang": round(2 / 3, 4),
"jd-waimai": 0.5,
}
finally:
db.rollback()
db.close()
def test_coupon_data_success_rates_empty_range() -> None:
"""区间无 session → 发起数 0、两个率为 None(不除零)。"""
db = SessionLocal()
try:
s = coupon_data_report(
db, date_from="2019-01-01", date_to="2019-01-01", app_env="prod"
)["summary"]
assert s["started_count"] == 0
assert s["full_success_rate"] is None
assert s["point_success_rate"] is None
finally:
db.close()
def test_coupon_data_summary_schema_exposes_rates() -> None:
"""schema 契约:CouponDataSummary 暴露 ②③ 字段,键名与 repo 输出一致(router 直接 **summary 构造)。"""
from app.admin.schemas.coupon_data import CouponDataSummary
db = SessionLocal()
try:
db.add(_agg_session("agg-schema-1", ["meituan-waimai"], ["meituan-waimai"]))
db.flush()
summary = coupon_data_report(
db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod"
)["summary"]
dumped = CouponDataSummary(**summary).model_dump()
assert dumped["full_success_rate"] == summary["full_success_rate"]
assert dumped["point_success_rate"] == summary["point_success_rate"]
assert dumped["full_success_count"] == summary["full_success_count"]
assert dumped["point_success_count"] == summary["point_success_count"]
assert dumped["point_total_count"] == summary["point_total_count"]
assert dumped["per_platform"] == summary["per_platform"]
finally:
db.rollback()
db.close()
def test_step_writes_platform_success_to_session(client) -> None:
"""/step 集成:pricebot 返 coupon_results → 本 trace 的 coupon_session.platform_success 落库(仅成功平台)。"""
from unittest.mock import MagicMock, patch
import httpx
from app.models.coupon_state import CouponClaimRecord, CouponDailyCompletion
trace = "int-trace-1"
device = "dev-int-1"
# 预置一条 started session(模拟 /session started 已先落库)
db = SessionLocal()
try:
db.add(CouponSession(
trace_id=trace, device_id=device, status="started", app_env="dev",
platforms=["meituan-waimai", "taobao-shanguang"],
started_at=datetime(2020, 1, 5, tzinfo=UTC),
started_date=date(2020, 1, 5),
))
db.commit()
finally:
db.close()
fake_resp = {
"success": True,
"action": {"command": "done", "params": {
"information": "已领 1 张",
"coupon_results": [
{"coupon_id": "mt_x", "name": "美团券", "vendor": "meituan_internal", "status": "success"},
{"coupon_id": "tb_y", "name": "淘宝券", "vendor": "taobao", "status": "failed"},
],
}},
"continue": False,
}
async def fake_post(self, url, **kw):
m = MagicMock()
m.status_code = 200
m.json = lambda: fake_resp
return m
body = {
"device_id": device, "trace_id": trace, "step": 5,
"screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0},
"foreground": {"package": "x", "activity": ""}, "windows": []},
}
try:
with patch.object(httpx.AsyncClient, "post", fake_post):
r = client.post("/api/v1/coupon/step", json=body)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace)
).scalar_one()
assert row.platform_success == ["meituan-waimai"] # tb 失败不计入
claims = db.execute(
select(CouponClaimRecord).where(CouponClaimRecord.device_id == device)
).scalars().all()
assert claims and all(c.app_env == "dev" for c in claims) # session app_env 打标
finally:
db.close()
finally:
db = SessionLocal()
try:
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device))
db.execute(delete(CouponDailyCompletion).where(CouponDailyCompletion.device_id == device))
db.commit()
finally:
db.close()
def test_coupon_data_status_filter() -> None:
"""状态多选过滤(方案 A):整个视图按选中状态算;None/空=全部。"""
db = SessionLocal()
try:
db.add_all([
_agg_session("st-A", ["meituan-waimai"], ["meituan-waimai"], status="completed"),
_agg_session("st-B", ["meituan-waimai"], ["meituan-waimai"], status="started"),
_agg_session("st-C", ["meituan-waimai"], None, status="failed"),
_agg_session("st-D", ["meituan-waimai"], ["meituan-waimai"], status="abandoned"),
])
db.flush()
base = dict(date_from="2020-01-02", date_to="2020-01-02", app_env="prod")
# None = 全部 4 发起
assert coupon_data_report(db, **base)["summary"]["started_count"] == 4
# 排除 started → 3 发起(整个视图,发起数也随之变)
sub = coupon_data_report(db, **base, statuses=["completed", "failed", "abandoned"])["summary"]
assert sub["started_count"] == 3
assert sub["completed_count"] == 1
# 只 completed → 发起数=1、整单成功率基数=1(该 completed 是整单成功)
comp = coupon_data_report(db, **base, statuses=["completed"])["summary"]
assert comp["started_count"] == 1
assert comp["full_success_rate"] == 1.0
finally:
db.rollback()
db.close()
+137
View File
@@ -0,0 +1,137 @@
"""每券成功率(§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()