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:
@@ -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 改动。"""
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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` | JSON(PG 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 链接;未到 done(failed/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 A(App 内发起)/ 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 延后;**已在 §12(2026-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)。与主明细表按用户过滤的行为不同,属有意为之。
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user