# 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)——维度与本文三张「设备×日」状态表不同,单独成文。

领券（优惠券自动化）联动产生的三张「今日状态」表，都挂在领券透传端点 `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_date`，`repositories/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（幂等 upsert）**：`mark_engagement`。三条触发：
  - `POST /api/v1/coupon/prompt/shown`（引导窗弹出那刻上报）→ 记 `shown`（频控主判据：弹出即占用今天这个 App 的「一次」，领/拒/无视都算用掉）；
  - `POST /api/v1/coupon/step` 且 `step==0`（领券首帧=用户已发起领券）→ 记 `claim_started`；
  - `POST /api/v1/coupon/prompt/dismiss`（用户点关闭引导窗；server 在透传链路看不到「拒绝」，必须客户端通知）→ 记 `dismissed`。
  - 已有今天那条则覆盖 `engage_type`（并补 `user_id`），否则插入。`step` 帧的 `package` 取 step body 的 `package`，缺/空则退化为 `""` 占位（全局态）。
- **D**：`POST /api/v1/coupon/prompt/reset`（`reset_today_engagement`）—— 删这台设备今天**所有 App** 的 engagement（不按 package 过滤：重置=从头测，清全部 App 最符预期），开发设置「重置今日领券弹窗状态」按钮调；删后今天又都能弹。
- **R**：`GET /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 `id`；index `user_id`；UNIQUE(`device_id`, `package`, `engage_date`) = `uq_coupon_engage_device_pkg_date`（一台设备、一个 App、一天一条）。
  - 历史:原为 UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`，2026-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（幂等 upsert）**：`mark_completed_today`，由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`，只有整套全跑完那帧才保留 `command=="done"`，故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`，否则插入。
- **D**：`POST /api/v1/coupon/completed-today/reset`（`reset_today_completion`）—— 删这台设备今天那条，首页「去领取」卡恢复可点。与 `/prompt/reset` 配套,开发设置「重置今日领券弹窗状态」一键把今日状态全清；MVP 不鉴权。
- **R**：`GET /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 `id`；index `user_id`、`trace_id`；UNIQUE(`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（幂等 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**：`GET /admin/api/coupon-data/coupons`（`coupon_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`（`/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`） |
| `trace_id` | String(64) | index, 可空 | 哪次任务领的，回指 pricebot work_logs / 排查 |
| `reason` | String(255) | 可空 | failed / skipped 原因 |
| `extra` | JSON（PG JSONB） | 可空 | 杂项兜底：券的结构化信息（面额/入口/关键节点摘要等），免得加字段就迁移；当前直接存这帧 pricebot 单券结果 dict |
| `created_at` | DateTime(tz) | server_default now() | |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |

### 索引与约束
- PK `id`；index `user_id`、`trace_id`；UNIQUE(`device_id`, `coupon_id`, `claim_date`) = `uq_coupon_claim_device_coupon_date`；Index(`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` 集合双重防御。
- `extra` 是 `JSON().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`/`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)）。
