# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点

- 日期：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)。与主明细表按用户过滤的行为不同,属有意为之。
