- coupon_session.platform_success:整单成功率②/点位成功率③ + 分平台点位成功率 - coupon_claim_record.app_env:按 coupon_id「按券成功率」聚合 + 子端点 /admin/api/coupon-data/coupons - /step 复用 record_claims 同一 SessionLocal 写入(fire-and-forget),pricebot 转发/负载不变 - 迁移 coupon_session_platform_success + coupon_claim_app_env;新增 14 测试 - 设计见 docs/guides/领券成功率指标-设计与埋点.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
24 KiB
领券「整单成功率」与「点位成功率」指标 — 设计与埋点
- 日期: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。原始成败信号已经落库。
但该表不能直接支撑本指标:
- 唯一键是
(device_id, coupon_id, claim_date),不含 trace_id;且record_claims冲突更新时不更新 trace_id(app/repositories/coupon_state.py的record_claims,仅 INSERT 时写 trace_id)。→ 同一张券当天跨多次 session 会折叠成一行、只归属最早那次 → 按 session 归因不可靠(直接砸 ②「整单全成功」)。 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) ≠ ∅ }/发起数
已定边界:
- ③ 分母用「勾选平台」
sel(s)。勾了淘宝但淘宝没领到 = 该点位未成功(不特判「平台没券」)。若日后要「真没券的平台不计入分母」,再引入platform_attempted(见 §8)。 - ②③ 基数 = 区间内全部 session(含
started/failed/abandoned),与看板「发起数」同基数。失败 / 中途退出的 session 的succ(s)取它崩溃前真领到的平台(不一律算 0)。 - 成功语义:
status ∈ {success, already_claimed}= 成功;failed/skipped= 未成功。与sum_claimed_count(app/repositories/coupon_state.py)一致。pricebot 协议文档把
statusenum 写作success|failed|skipped,但代码实际还会 emitalready_claimed(pricebotapp/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))新增:
- 对每条 result 求平台:按
coupon_id前缀映射(与客户端CouponForegroundService.couponIdToPlatform对齐,且与platforms/platform_elapsed用同一套平台 id 词表):mt_→meituan-waimaitb_/ele_/elm_→taobao-shanguangjd_→jd-waimai- 其余 → 跳过(无法识别)
不用券的
vendor字段做映射:vendor 词表未必等于这三档平台 id,而sel(s)用的就是这三档,succ(s)必须同词表。 - 收集
status ∈ {success, already_claimed}的平台集合ok_platforms。 - 若
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上报丢失」且「/stepdone 先于/sessionterminal 落库」两者同时成立,该 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. 改动清单
app/models/coupon_state.py:CouponSession加platform_successalembic/versions/:新迁移 add columncoupon_session.platform_successapp/repositories/coupon_state.py:新增merge_session_platform_successapp/api/v1/coupon.py:/step推导ok_platforms并 union(前缀映射 + fire-and-forget)app/admin/repositories/coupon_data.py:summary加 ②③(+ 可选per_platform)app/admin/schemas/coupon_data.py:summaryschema 加字段docs/database/coupon_state.md:补platform_success列说明(并补 coupon_session 整节)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)未动。
待办(在后端项目里继续)
- 应用迁移:
alembic upgrade head(DDL 已验;线上只前进统计、历史不回填)。 - 提交:尚未提交;建议先开分支再提交。
- admin 前端图表:后端指标已就绪(summary 的
full_success_rate/point_success_rate等),看板卡 / 趋势展示待接前端。 - (可选)修预存
test_coupon_step_passes_body_through(一行:capturecontent而非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'])(平台名同「美团耗时」列既有叫法)。
- 行1:整单成功率
- 不显示支撑数(合计与分平台均纯百分比);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-weblint / 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 succeededWHERE 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_claimsstampapp_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)。与主明细表按用户过滤的行为不同,属有意为之。