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

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

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

---------

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

17 KiB
Raw Permalink Blame History

coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session)

模型 app/models/coupon_state.py · 仓库 app/repositories/coupon_state.py · 接口 app/api/v1/coupon.py(prefix /api/v1/coupon) · ← 索引 · 总览 同模型文件里还有第四张表 coupon_session(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。

领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 POST /api/v1/coupon/step 这条链路上(pricebot 跑领券,结果回 app-server 落库;领券过程本身在 pricebot 内存态跑、不落库)。三表各管一件事:

  • 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_daterepositories/coupon_state.today_cn())。每日可领的券(签到/天天红包)靠这天然每天一条。
  • user_id 可空:领券登录态有就记(资产/画像),可空、不进唯一键、不阻塞判断
  • trace_id 可空:回指 pricebot work_logs,供排查(哪次任务领的)。
  • engagement vs completion vs claim 的区别engagement = 用户表达过意向(点了领或拒,不管跑没跑完);completion = 这一轮真跑到了 done(整套流程走完);claim = 每张券一条的结果留痕。

写库全部 best-effort:在 /coupon/step 里写库失败只 logger.warning绝不连累领券主流程/返回(见 app/api/v1/coupon.py)。


coupon_prompt_engagement — 弹窗频控(今日这个 App 是否已对引导窗表达意向)

(device_id, package, engage_date) 唯一,一台设备、一个 App、一天一条;今天这个 App engage 过(弹/领/拒)就不再弹该 App。各 App 独立:美团弹过不压淘宝/京东。

用在哪 / 增删改查

  • C / U(幂等 upsertmark_engagement。三条触发:
    • POST /api/v1/coupon/prompt/shown(引导窗弹出那刻上报)→ 记 shown(频控主判据:弹出即占用今天这个 App 的「一次」,领/拒/无视都算用掉);
    • POST /api/v1/coupon/stepstep==0(领券首帧=用户已发起领券)→ 记 claim_started
    • POST /api/v1/coupon/prompt/dismiss(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 dismissed
    • 已有今天那条则覆盖 engage_type(并补 user_id),否则插入。step 帧的 package 取 step body 的 package,缺/空则退化为 "" 占位(全局态)。
  • DPOST /api/v1/coupon/prompt/resetreset_today_engagement)—— 删这台设备今天所有 App 的 engagement(不按 package 过滤:重置=从头测,清全部 App 最符预期),开发设置「重置今日领券弹窗状态」按钮调;删后今天又都能弹。
  • RGET /api/v1/coupon/prompt/should-show?device_id=…&package=…has_engaged_today)→ should_show = not 今天该 App 已 engage。客户端切外卖 App 前带 package 查(老客户端不带 → "" 全局态)。

字段

类型 约束 / 默认 说明(取值 / join)
id Integer PK, autoincrement
device_id String(64) NOT NULL 判断/聚合维度;客户端 getOrCreateDeviceId,重装会变
package String(64) NOT NULL, server_default "" 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。频控维度,各 App 独立。旧行(2026-06-14 改造前)无此值 → 迁移用占位 ""
user_id Integer index, 可空 登录态有就记(资产);不进唯一键、不阻塞判断
engage_date Date NOT NULL 北京时间自然日(today_cn()
engage_type String(16) NOT NULL shown(自动弹出即记)/ claim_started(点一键领取)/ dismissed(点拒绝关闭);仅记录区分,判断只看「今天这个 App 有没有这条」,type 不影响弹不弹
created_at DateTime(tz) server_default now()
updated_at DateTime(tz) server_default now(), onupdate now()

索引与约束

  • PK idindex user_idUNIQUE(device_id, package, engage_date) = uq_coupon_engage_device_pkg_date(一台设备、一个 App、一天一条)。
    • 历史:原为 UNIQUE(device_id, engage_date) = uq_coupon_engage_device_date2026-06-14 迁移 coupon_engage_per_package 加入 package 维度改成现状(修「任一 App 弹过就把整台设备当天标记、压住其余 App」的 bug)。

注意

  • device_id 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。
  • 判断只看「今天这台设备这个 App 有没有这条」,不看 engage_type(弹/领/拒都算 engage 过、都不再弹该 App)。

coupon_daily_completion — 首页置灰(今日是否已跑完整轮领券)

(device_id, complete_date) 唯一,一台设备一天一条;今天跑完整轮(到 done 帧)就把首页「去领取」卡置灰。

用在哪 / 增删改查

  • C / U(幂等 upsertmark_completed_today,由 POST /api/v1/coupon/step 在 pricebot 返回 action.command == "done" 那帧调。pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留 command=="done",故 done 已等价「整轮完成」。已有今天那条则补 user_id/trace_id,否则插入。
  • DPOST /api/v1/coupon/completed-today/resetreset_today_completion)—— 删这台设备今天那条,首页「去领取」卡恢复可点。与 /prompt/reset 配套,开发设置「重置今日领券弹窗状态」一键把今日状态全清;MVP 不鉴权。
  • RGET /api/v1/coupon/completed-today?device_id=…has_completed_today)→ completed。客户端据此把首页「去领取」卡置灰、不可点。

字段

类型 约束 / 默认 说明(取值 / join)
id Integer PK, autoincrement
device_id String(64) NOT NULL 判断维度,与 engagement/claim 一致;客户端两端都用 ANDROID_ID
user_id Integer index, 可空 登录态有就记(资产)
complete_date Date NOT NULL 北京时间自然日(today_cn()
trace_id String(64) index, 可空 哪次任务跑到 done,回指 pricebot work_logs / 排查
created_at DateTime(tz) server_default now()
updated_at DateTime(tz) server_default now(), onupdate now()

索引与约束

  • PK idindex user_idtrace_idUNIQUE(device_id, complete_date) = uq_coupon_completion_device_date(一台设备一天一条)。

注意

  • 口径(用户决策 2026-06-10 A 方案):到 done 即算完成,不管单券成败——失败/跳过常是无障碍/环境问题,重复点也补不回来。
  • 与 engagement 区别:engagement 是「表达过意向」(点了就记,不管跑没跑完);completion 是「真跑到了 done」。

coupon_claim_record — 领券记录(每张券一天一条,资产沉淀层)

(device_id, coupon_id, claim_date) 唯一,同设备同券同一天只一条;纯沉淀,当前不参与判断,留作以后按券去重 / CPS 归因 / 用户画像的数据源。

用在哪 / 增删改查

  • C / U(幂等 upsertrecord_claims,由 POST /api/v1/coupon/step 写入。一帧的券结果来自 pricebot 的 last_coupon_result(最后一张)+ action.params.coupon_results(全量)——会重复带同一张券,端点 _extract_coupon_resultscoupon_id 去重(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 status/reason/claimed_count/extra(以最后一次为准),否则插入。
  • U / D:无业务删除。
  • RGET /admin/api/coupon-data/couponscoupon_slot_report)—— admin「按券成功率」表,按 coupon_id 聚合 成功/(成功+失败)skipped 排除,设备-天口径,按 app_env 过滤)。见设计 §13。

字段

类型 约束 / 默认 说明(取值 / join)
id Integer PK, autoincrement
device_id String(64) NOT NULL 聚合维度;客户端 getOrCreateDeviceId,重装会变
user_id Integer index, 可空 登录态有就记(资产/画像);不进唯一键
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/steptrace_idcoupon_session.app_env 打标);旧行 NULL(不回填)。admin「按券成功率」表按它过滤。见设计 §13
vendor String(48) 可空 券提供方
coupon_name String(128) 可空 取 pricebot name
claimed_count Integer 可空 这张领到几张(pricebot display_count,给不出时 None;兼容 claimed_count
trace_id String(64) index, 可空 哪次任务领的,回指 pricebot work_logs / 排查
reason String(255) 可空 failed / skipped 原因
extra JSONPG JSONB 可空 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移;当前直接存这帧 pricebot 单券结果 dict
created_at DateTime(tz) server_default now()
updated_at DateTime(tz) server_default now(), onupdate now()

索引与约束

  • PK idindex user_idtrace_idUNIQUE(device_id, coupon_id, claim_date) = uq_coupon_claim_device_coupon_dateIndex(device_id, claim_date) = ix_coupon_claim_device_date(按 device+日 取一天所有券)。

注意

  • extra 别塞原始无障碍树(几十 KB → 行膨胀);原始大树看 trace_id 指过去的 work_logs。
  • 同批去重很关键:autoflush=False 下同 coupon_id 两次 add 会撞唯一约束、IntegrityError 回滚整批(done/单券记录全丢),故端点 _extract_coupon_results + 仓库 seen 集合双重防御。
  • extraJSON().with_variant(JSONB(), "postgresql")PG 用 JSONB(可建 GIN 索引),SQLite 退化通用 JSON(同 price_observation / comparison_record)。

coupon_session — 领券任务流水(一次领券一行,admin「领券数据」看板数据源)

trace_id 唯一,一次领券一行。与上面三张「今日状态」表不同:本表走 POST /api/v1/coupon/session(客户端两段上报:发起 started 建行、收尾 completed/failed/abandonedtrace_id 更新同一行),记从发起到收尾的全程耗时 + 各平台耗时 + 机型/ROM。发起即落库 → admin 可算「发起数」与中途流失(started 无终态 = 未完成)。

用在哪 / 增删改查

  • C / U(幂等 upsertupsert_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,不新增连接。
  • Radmin GET /admin/api/coupon-datacoupon_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 据此算整单成功率②(platformsplatform_success)/点位成功率③(Σ交集/Σ勾选)。设计:领券成功率指标
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 idindex user_idapp_envUNIQUE(trace_id) = uq_coupon_session_traceIndex(started_date, app_env) = ix_coupon_session_date_envadmin 主聚合/筛选)。

注意

  • 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.idtrace_id 软指 pricebot work_logs(详见 OVERVIEW → 表间关系 & Join Key)。