From 195fad45119333cc9f23eebcf9df3892da9f6517 Mon Sep 17 00:00:00 2001 From: chenshuobo <1119780489@qq.com> Date: Wed, 10 Jun 2026 14:44:40 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BE=8E=E5=9B=A2=E5=88=B8=20ETL:prune=20?= =?UTF-8?q?=E9=98=B2=E8=AF=AF=E5=88=A0=E6=8A=A4=E6=A0=8F=20+=20=E6=AF=8F?= =?UTF-8?q?=E5=B0=8F=E6=97=B6=E5=AE=9A=E6=97=B6=E9=83=A8=E7=BD=B2=20+=20me?= =?UTF-8?q?ituan=5Fcoupon=20=E8=A1=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prune 仅在本轮有入库(total>0)时执行,修上游故障时按 last_seen 清空全表的隐患 - 新增 deploy/meituan-etl.{service,timer}:systemd timer 每小时全量灌库(服务器用) - 新增 docs/database/meituan_coupon.md(字段核对/索引评估/清理策略)并登记索引 --- deploy/meituan-etl.service | 33 +++++++++++++++++++ deploy/meituan-etl.timer | 14 ++++++++ docs/database/README.md | 9 +++-- docs/database/meituan_coupon.md | 58 +++++++++++++++++++++++++++++++++ scripts/pull_meituan_coupons.py | 8 +++-- 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 deploy/meituan-etl.service create mode 100644 deploy/meituan-etl.timer create mode 100644 docs/database/meituan_coupon.md diff --git a/deploy/meituan-etl.service b/deploy/meituan-etl.service new file mode 100644 index 0000000..8d17bd2 --- /dev/null +++ b/deploy/meituan-etl.service @@ -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 diff --git a/deploy/meituan-etl.timer b/deploy/meituan-etl.timer new file mode 100644 index 0000000..5085df2 --- /dev/null +++ b/deploy/meituan-etl.timer @@ -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 diff --git a/docs/database/README.md b/docs/database/README.md index 9220687..4d8429c 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -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) | + ### 首页门面数据 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/meituan_coupon.md b/docs/database/meituan_coupon.md new file mode 100644 index 0000000..fe763a5 --- /dev/null +++ b/docs/database/meituan_coupon.md @@ -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`。 diff --git a/scripts/pull_meituan_coupons.py b/scripts/pull_meituan_coupons.py index 7bd6837..8d0de94 100644 --- a/scripts/pull_meituan_coupons.py +++ b/scripts/pull_meituan_coupons.py @@ -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")