美团券 ETL:prune 防误删护栏 + 每小时定时部署 + meituan_coupon 表文档

- prune 仅在本轮有入库(total>0)时执行,修上游故障时按 last_seen 清空全表的隐患
- 新增 deploy/meituan-etl.{service,timer}:systemd timer 每小时全量灌库(服务器用)
- 新增 docs/database/meituan_coupon.md(字段核对/索引评估/清理策略)并登记索引
This commit is contained in:
chenshuobo
2026-06-10 14:44:40 +08:00
committed by lowmaster-chen
parent 0890e693d7
commit 195fad4511
5 changed files with 118 additions and 4 deletions
+33
View File
@@ -0,0 +1,33 @@
# 美团 CPS 券 ETL —— 单轮抓取入库,由 meituan-etl.timer 每小时触发(Linux 服务器用)。
#
# 仅用于 mentor 的 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
# .venv\Scripts\python -m scripts.pull_meituan_coupons --once --prune-hours 24
#
# 部署(服务器):
# sudo cp deploy/meituan-etl.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now meituan-etl.timer
#
# 前置:DATABASE_URL 必须是 postgresql+psycopg://(ETL 的 upsert 是 PG 专用);
# MT_CPS_APP_KEY/SECRET 已配;服务器上 MT_CPS_PROXY 留空(直连)。
[Unit]
Description=Meituan CPS coupon ETL (one-shot, driven by timer)
After=network-online.target postgresql.service
Wants=network-online.target postgresql.service
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
SyslogIdentifier=meituan-etl
# 脚本自带 30min 文件锁;给 20min 硬超时,防卡死轮次长期占锁。
TimeoutStartSec=1200
# 与主服务 shaguabijia-app-server.service 同款加固。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
+14
View File
@@ -0,0 +1,14 @@
# 每小时触发一次美团 CPS 券 ETL(Linux 服务器用)。
# 见 meituan-etl.service 顶部注释的部署步骤。
[Unit]
Description=Run Meituan CPS coupon ETL hourly
[Timer]
# 每小时第 5 分钟跑,避开整点其它定时任务扎堆。
OnCalendar=*-*-* *:05:00
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等下一个整点)。
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
+7 -2
View File
@@ -3,13 +3,13 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
> 最后更新:2026-06-10(补登 `meituan_coupon` 美团券缓存表;共 23 张业务表 + [OVERVIEW 总览](./OVERVIEW.md))
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
---
## 表总览(22 张业务表 + `alembic_version` 框架表)
## 表总览(23 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
@@ -41,6 +41,11 @@
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
### 美团 CPS 券缓存
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) |
### 首页门面数据
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
+58
View File
@@ -0,0 +1,58 @@
# 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`
+6 -2
View File
@@ -288,8 +288,10 @@ def run_once(prune_hours: int = 24) -> None:
total += up
print(f" {src['label']:18}{len(items):5} 解析{len(parsed):5} "
f"入库{up:5} (本源去重{dup:3}) {time.time() - ts:4.0f}s")
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限
if prune_hours and prune_hours > 0:
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限
# 护栏:仅在本轮确有入库(total>0)时才清理。美团整体故障时本轮可能 0 入库
# (脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 last_seen 把全表删空。
if prune_hours and prune_hours > 0 and total > 0:
cutoff = now - timedelta(hours=prune_hours)
pruned = db.execute(
delete(MeituanCoupon).where(MeituanCoupon.last_seen < cutoff)
@@ -297,6 +299,8 @@ def run_once(prune_hours: int = 24) -> None:
db.commit()
if pruned:
print(f" 清理陈旧券(>{prune_hours}h 未再出现): {pruned}")
elif prune_hours and prune_hours > 0 and total == 0:
print(" 本轮 0 入库(疑似上游故障),跳过清理以防误删全表")
cnt = db.execute(select(func.count()).select_from(MeituanCoupon)).scalar()
print(f"[{datetime.now():%H:%M:%S}] 本轮完成: 入库 {total} 条, 表总计 {cnt} 行, "
f"用时 {time.time() - t0:.0f}s")