# meituan_coupon — 美团 CPS 券本地缓存

> 模型 `app/models/meituan_coupon.py` · ETL `scripts/pull_meituan_coupons.py` · 接口 [meituan-feed](../api/meituan-feed.md)(rec/distance)/ [meituan-top-sales](../api/meituan-top-sales.md)(sales)· [← 索引](./README.md) · [总览](./OVERVIEW.md)

把美团联盟 CPS 的券**定时抓进本地库**,供「智能推荐(佣金≥3%)」「销量最高」从库里捞、本地去重排序,不每次实时打美团(美团对销量排序支持差、有 402 限流和召回上限)。北京单城试点。

## 用在哪 / 增删改查
- **C/U(ETL upsert)**:`scripts/pull_meituan_coupons.py` 每小时全量抓 3 路(`search_waimai` 搜「外卖」/ `search_meishi` 搜「美食」/ `store_supply` 到店多业务线供给),按 `(source, product_view_sign)` upsert(PG `ON CONFLICT DO UPDATE`),`last_seen` 每轮刷新。
- **R(读)**:
  - rec(智能推荐):`WHERE commission_percent>=3.0` → `DISTINCT ON(dedup_key)` 取佣金最高 → 按 `sale_volume_num` 降序分页。
  - sales(销量最高):`WHERE sale_volume_num IS NOT NULL` → `DISTINCT ON(dedup_key)` 取销量最高 → 按销量降序分页。
- **D(清理)**:见下「过期清理策略」。

## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `source` | String(16) | index, NOT NULL | 召回来源:`search_waimai` / `search_meishi` / `store_supply` |
| `platform` | Integer | NOT NULL | 1 外卖/到家, 2 到店 |
| `biz_line` | Integer | nullable | 到店子类:1到餐 2到综 3酒店 4门票 |
| `city_id` | String(32) | index, NOT NULL | 城市 id(当前恒为北京 `WKV2HMXUEK634WP64CUCUQGM64`) |
| `product_view_sign` | String(128) | NOT NULL | 换链主键;**按召回渠道生成、跨渠道会变**,不能当商品全局 id |
| `sku_view_id` | String(128) | nullable | |
| `name` | String(256) | nullable | 商品名(解析截断 `[:256]`) |
| `brand_name` | String(128) | index, nullable | 品牌名(`[:128]`) |
| `sell_price_cents` | Integer | nullable | 现价(分) |
| `original_price_cents` | Integer | nullable | 原价(分) |
| `head_url` | String(512) | nullable | 头图(去 `@` 后缀,`[:512]`) |
| `sale_volume` | String(32) | nullable | 原始销量档:如「热销1w+」 |
| `sale_volume_num` | Integer | index, nullable | 销量排序数值(取下界):`1w+` → 10000 |
| `commission_percent` | Float | index, nullable | 佣金比例,**数值即百分数**(1.4 = 1.4%);美团原值 140 ÷ 100 |
| `commission_amount_cents` | Integer | nullable | 预估佣金(分) |
| `poi_name` | String(128) | nullable | 最近门店名(`[:128]`) |
| `available_poi_num` | Integer | nullable | 可用门店数 |
| `delivery_distance_m` | Float | nullable | 距离(米);**相对抓取时的城市默认点**,对用户位置无意义,rec 接口置空不返 |
| `dedup_key` | String(64) | index, NOT NULL | 跨源去重键 = `md5(brand_name\|name\|sell_price_cents)` |
| `raw` | JSON / JSONB | NOT NULL, default `{}` | 整条原始返回(避免漏字段重抓);PG 上为 JSONB |
| `first_seen` | DateTime(tz) | server_default now() | 首次抓到 |
| `last_seen` | DateTime(tz) | index, server_default now() | 最近一轮抓到(**过期清理依据**) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |

## 索引与约束
- 唯一约束 `uq_meituan_coupon_source_sign`(`source`, `product_view_sign`)—— ETL upsert 的冲突目标。
- 单列 index:`source`、`city_id`、`brand_name`、`sale_volume_num`、`commission_percent`、`dedup_key`、`last_seen`。
- **字段核对(2026-06-10)**:模型 ↔ 迁移(`meituan_coupon_table`)↔ ETL 解析三处一致;解析截断宽度均 ≤ 列宽(name256 / brand128 / poi128 / head512 / sign128,`dedup_key` = md5 32 字符 ≤ 64)。
- **索引评估**:`rec` / `sales` 查询的理想索引是复合 `(dedup_key, sale_volume_num DESC)` 与 `(dedup_key, commission_percent DESC)`(匹配 `DISTINCT ON`);但当前**单城几千行,seq scan 即亚毫秒**,现有单列索引已够用,复合索引留作多城 / 放量时再加,避免过早优化。

## 过期清理策略
- ETL 每轮(每小时)末尾按 **`last_seen` 的 TTL** 清理:`DELETE WHERE last_seen < now() - prune_hours`(默认 24h,`--prune-hours` 可调,0=不清)。
- 仍在架的券每小时被刷新 `last_seen`,**永不被清**;只有下架 / `sign` 轮换的残留超 24h 宽限才删。宽限期用于吸收几次抓取失败。
- 券自身到期**无需单独处理**:到期后美团不再返回 → 自然 `last_seen` 老化被清。
- **护栏(2026-06-10)**:prune 仅在**本轮确有入库(`total>0`)**时执行。美团整体故障时本轮可能 0 入库(脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 `last_seen` **把全表删空**;加 `total>0` 护栏后,0 入库轮跳过清理并打日志。
- 表只增量 upsert + 少量 delete,PG autovacuum 足以回收死元组,当前规模无需手动 VACUUM。

## 注意
- **仅 PostgreSQL**:ETL upsert(`postgresql.insert(...).on_conflict_do_update`)与读取的 `DISTINCT ON` 都是 PG 专用语法;库若是 sqlite,ETL 灌不进、rec/sales 接口报错。prod `DATABASE_URL` 必须 `postgresql+psycopg://`。
- **`dedup_key` 不含城市**:`md5(brand|name|price)`,单城没问题;将来多城会把异城同品合并,需把 `city_id` 纳入 key。
- `product_view_sign` 跨渠道会变,**不能当商品全局唯一 id**:存储按 `(source, sign)`、查询按 `dedup_key`。
