docs(database): 补充数据库表文档(cps/invite/price_observation/store_mapping 等)
(工作区既有改动, 非本次会话产出)
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# cps_activity — CPS 可推广活动池(运营预存的券/物料)
|
||||
|
||||
> 模型 `app/models/cps_activity.py` · 仓库 `app/admin/repositories/cps.py`(`create_activity` / `list_activities` / `get_activity` / `delete_activity` / `list_activity_images`) · 接口 admin `POST /admin/api/cps/activities`、`GET /admin/api/cps/activities`、`DELETE /admin/api/cps/activities/{id}`、`POST /admin/api/cps/upload-image`、`GET /admin/api/cps/activity-images` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
运营把常推的活动(券)预存成池,生成群发链接时从池里挑,省得每次记物料 ID。数据**全由运营在后台手填/上传**,无外部同步。在 CPS 群发漏斗里它是**最上游的"卖什么"**:活动(本表)× 群([`cps_group`](./cps_group.md))经"批量生成短链"组合出一条条 [`cps_link`](./cps_link.md)。一条活动可被多个群、多次复用生成 link(一对多,无外键)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /admin/api/cps/activities`(`create_activity`,需 `operator` 角色)。按 `platform` 分支校验:`meituan` 必须填 `act_id` 或 `product_view_sign` 之一;`taobao`/`jd` 必须填 `payload`,且 `taobao` 还必须有 `image_url`(落地页图)。淘宝图经 `POST /admin/api/cps/upload-image`(`media.save_cps_image`)上传得绝对 URL,或经 `GET /admin/api/cps/activity-images` 选已有。无幂等键,重复提交建多条。
|
||||
- **U(更新)**:**无更新端点**。改活动 = 删了重建。`status`(active/archived)列存在但目前没有切换它的路由(待确认是否前端规划)。
|
||||
- **R(读)**:`GET /admin/api/cps/activities`(`list_activities`,游标分页,可按 `platform`/`status` 过滤,登录即可);生成 link 时 `get_activity` 按 id 取;淘宝落地页渲染时 `cps_redirect` 用 `db.get(CpsActivity, link.activity_id)` 取 `image_url`;`list_activity_images` 取所有 distinct 非空 `image_url` 供新建复用。
|
||||
- **D(删除)**:`DELETE /admin/api/cps/activities/{id}`(`delete_activity`,`operator`)。**硬删,不级联** `cps_link`——已发出去的短链继续可用;淘宝落地页若取不到活动(被删),`cps_redirect` 用兜底默认图 `/media/taobao_landing.jpg`。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 被 `cps_link.activity_id` 语义引用(非 FK) |
|
||||
| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定转链/落地方式,也决定下面哪些字段必填 |
|
||||
| `name` | String(128) | NOT NULL | 活动名,运营自定,后台列表/审计展示 |
|
||||
| `act_id` | String(64) | nullable | **美团**联盟「我要推广-活动推广」第一列的物料 ID(actId)。转链 `get_referral_link` 入参 |
|
||||
| `product_view_sign` | String(128) | nullable | **美团**商品券 productViewSign,与 `act_id` 二选一转链(多数活动用 `act_id`) |
|
||||
| `payload` | Text | nullable | **淘宝**=整段淘口令文本(落地页原样供复制);**京东**=推广链接(落地页 302 跳)。美团不用此列(用 `act_id`)。生成 link 时直接作 `target_url` |
|
||||
| `image_url` | String(512) | nullable | **淘宝**落地页主视觉图,存绝对 URL(基于 `CPS_REDIRECT_BASE`,跨域 admin-web 可显示)。落地页按活动展示对应图 |
|
||||
| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`(目前无切换入口,主要靠删) |
|
||||
| `remark` | String(256) | nullable | 运营备注 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间。**无 `updated_at`**(本表无更新路径) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `id` ← `cps_link.activity_id`(一活动多 link,**语义关联,无 ForeignKey**;删活动不级联删 link)。
|
||||
- `id` ← `cps_redirect` 渲染淘宝落地页时按 `link.activity_id` 反查本表取 `image_url`。
|
||||
- 与 [`cps_group`](./cps_group.md) 无直接列关系:两者在"批量生成短链"(`generate_referral_links`)时按 **platform 是否在群范围内**组合,产物是 [`cps_link`](./cps_link.md)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- **无唯一约束、无额外索引、无外键**(运营低频、量级小,按 id 主键访问即可)。
|
||||
|
||||
## 注意
|
||||
- **`platform` 是多平台演进的核心**:初版(commit `277f9b1`)只有美团,字段就是 `act_id` + `product_view_sign`;`3a40f61` 接淘宝/京东时**复用本表**,只加了 `payload`(异构标识塞一列);`d55f47f` 再加 `image_url` 把淘宝落地页图"做活"(此前是写死一张图)。所以三平台的"卖点"挤在同一张表,靠 `platform` 分流——这是省表的刻意取舍。
|
||||
- **美团二选一**:`act_id` 与 `product_view_sign` 对应 `get_referral_link` 的两种转链入参,router 层强制至少填一个。
|
||||
- **淘宝必须有图**:`taobao` 落地页是我们自渲染的 H5(`cps_redirect._taobao_landing_html`),没图页面就空,故建活动时强制。`d55f47f` 的迁移把存量淘宝活动的 `image_url` 回填成此前写死的 `/media/taobao_landing.jpg`,无缝衔接。
|
||||
- **`image_url` 存绝对 URL 的原因**:落地页域(`CPS_REDIRECT_BASE`)与 admin-web 跨域,存绝对值两边都能取(`media.to_abs_media_url`)。dev 未配 `CPS_REDIRECT_BASE` 时退化为相对路径。
|
||||
@@ -0,0 +1,37 @@
|
||||
# cps_click — CPS 短链点击事件(/c/{code} 每次访问/复制记一条)
|
||||
|
||||
> 模型 `app/models/cps_link.py`(`CpsClick`,与 `CpsLink` 同文件) · 仓库 `app/repositories/cps_link.py`(`record_click` / `click_stats_by_group`) · 接口 用户 `GET /c/{code}`(记 `visit`)、`POST /c/{code}/copy`(记 `copy`,`app/api/v1/cps_redirect.py`);统计 admin `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
群发短链 [`cps_link`](./cps_link.md) 每被点一次就在此记一条。在 CPS 群发漏斗里它是**"用户点了"这一步**:用户点 `/c/{code}` 进落地/被跳转记 `visit`,淘宝落地页点"复制口令"记 `copy`。据此统计点击 PV/UV(按群/活动/时段),配合 [`cps_order`](./cps_order.md) 的下单/佣金构成"点击→下单→佣金"漏斗。`group_id`/`sid` 在本表**冗余一份**(来自 link),统计直接按群聚合、不必 join `cps_link`。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`record_click`(`app/repositories/cps_link.py`),由 `cps_redirect` 两处触发——`GET /c/{code}`(`cps_landing`)记 `event_type=visit`;`POST /c/{code}/copy`(`cps_copy`,淘宝落地页点复制按钮 fetch 回调)记 `event_type=copy`。写入时把 link 的 `group_id`/`sid` 冗余进来,`ua` 截断到 500。**记点击失败被 `_record` 吞掉绝不阻断用户跳转/落地**(公网无鉴权,任何人点都要能跳)。无幂等——每次点都写新行(PV 即行数)。
|
||||
- **U / D**:**无**。点击是只追加的事件流。
|
||||
- **R(读)**:`click_stats_by_group`(`GET /admin/api/cps/stats` 经 `group_stats` 调用)按 `group_id` 聚合 → `{pv, uv, copy_pv, copy_uv}`;UV 按 `(ip, ua)` 近似去重,`visit` 计 pv/uv、`copy` 计 copy_pv/copy_uv。聚合在 Python 侧做(量级小、跨 PG/SQLite 无方言坑)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `link_id` | Integer | index, NOT NULL | 来源短链 = `cps_link.id`(语义关联,非 FK) |
|
||||
| `group_id` | Integer | index, NOT NULL | **冗余**自 link.group_id;按群聚合点击快,免 join cps_link |
|
||||
| `sid` | String(64) | index, **nullable** | **冗余**自 link.sid;美团有、淘宝/京东无 |
|
||||
| `event_type` | String(16) | NOT NULL, default/server_default `visit` | `visit`(进落地页/被 302 跳) / `copy`(淘宝落地页点了"复制口令") |
|
||||
| `ip` | String(64) | nullable | 客户端 IP(取 `X-Forwarded-For` 首段,否则 `request.client.host`),UV 去重键之一 |
|
||||
| `ua` | String(512) | nullable | User-Agent,入库截断到 500;UV 去重键之一 |
|
||||
| `clicked_at` | DateTime(tz) | server_default now(), index, NOT NULL | 点击时间,统计按时间窗过滤 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `link_id` → `cps_link.id`(多对一,**语义关联,无 ForeignKey**)。
|
||||
- `group_id` → `cps_group.id`、`sid` ≈ `cps_group.sid`——均**冗余字段**,直接按群聚合用,非 FK。
|
||||
- 与 [`cps_order`](./cps_order.md) 无直接关联:点击与订单在 `group_stats` 里**各按 `group_id`/`sid` 聚合后并到同一群行**,共同组成漏斗,但两表之间无 join key(无法把某次点击对到某笔订单——CPS 联盟不回传点击 id)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `link_id`、`group_id`、`sid`、`clicked_at`。
|
||||
- **无唯一约束、无外键**(纯事件流,PV 即行数,允许重复)。
|
||||
|
||||
## 注意
|
||||
- **`event_type` 是接淘宝时新增的**:初版(`277f9b1`)只有 visit(点了就 302 跳美团,记一条即可);`3a40f61` 接淘宝后,淘宝落地页是 H5、用户要点按钮"复制口令"才算转化,故迁移 `cps_v2_platforms` 加 `event_type`(server_default `visit` 回填存量),`copy` 单独统计成 `copy_pv/copy_uv`。同迁移把 `sid` 改 nullable(淘宝/京东点击无 sid)。
|
||||
- **`group_id`/`sid` 冗余的理由**:统计是高频读路径,直接 `WHERE group_id=?` 聚合,省掉 `cps_click → cps_link` 的 join;代价是写入时多带两列(`record_click` 从 link 拷)。这与 model docstring 明说的"统计直接按群聚合、不必 join cps_link"一致。
|
||||
- **UV 是近似值**:按 `(ip, ua)` 去重,无 Cookie/设备指纹;同一人换网络/浏览器会重复计 UV。够运营看趋势,不作精确口径。
|
||||
- **记点击绝不阻断**:`_record` 用 `try/except: pass` 包住,落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。
|
||||
@@ -0,0 +1,38 @@
|
||||
# cps_group — CPS 推广群(一个微信群一条,sid 是美团渠道追踪位)
|
||||
|
||||
> 模型 `app/models/cps_group.py` · 仓库 `app/admin/repositories/cps.py`(`create_group` / `update_group` / `delete_group` / `get_group` / `get_group_by_sid` / `list_groups`、统计 `group_stats`) · 接口 admin `POST/GET/PATCH/DELETE /admin/api/cps/groups`、统计 `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每个发券的微信群一条记录。核心字段是 `sid`——**美团联盟二级渠道追踪位**:发链接时塞进推广链接(`get_referral_link`),订单回来按 `sid` 归到这个群对账(`query_order` → [`cps_order`](./cps_order.md))。在 CPS 群发漏斗里它是**"发给谁/在哪个渠道"**:群(本表)× 活动([`cps_activity`](./cps_activity.md))组合生成 [`cps_link`](./cps_link.md);点击([`cps_click`](./cps_click.md))与订单都靠 `sid`/`group_id` 归到本群,统计接口 `group_stats` 即以群为行做"点击→下单→佣金"漏斗。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /admin/api/cps/groups`(`create_group`,`operator`)。`platforms` 至少选一个,非法平台 400。**`sid` 仅当 `platforms` 含 `meituan` 时才有**:运营填了就用(校验全局唯一,重复 409),没填则先占位 `tmp<uuid>`、flush 拿到 id 后回填成 `g<id>`;纯淘宝/京东群 `sid` 恒 `None`(那俩平台不支持 sid)。无业务幂等键(同名可重复建),唯一性只在 `sid` 上。
|
||||
- **U(更新)**:`PATCH /admin/api/cps/groups/{id}`(`update_group`,`operator`)。可改 `name`/`platforms`/`member_count`/`status`/`remark`。**`sid` 不可改**(改了会断开历史订单的归群)。
|
||||
- **R(读)**:`GET /admin/api/cps/groups`(`list_groups`,游标分页,`keyword` 模糊匹配 name 或 sid、`status` 过滤);生成 link 时 `get_group` 取群拿 `sid`;统计 `group_stats` 遍历所有群、按 `sid` 关联订单、按 `group_id` 关联点击。
|
||||
- **D(删除)**:`DELETE /admin/api/cps/groups/{id}`(`delete_group`,`operator`)。**硬删,不级联**已生成的 [`cps_link`](./cps_link.md)(已发链接继续可用);该群历史 [`cps_click`](./cps_click.md)/[`cps_order`](./cps_order.md) 按 `sid`/`group_id` 仍在库,删群后这些 sid 在统计里降级为"未归群"行(`group_id=None`)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 被 `cps_link.group_id`、`cps_click.group_id` 语义引用(非 FK) |
|
||||
| `sid` | String(64) | **UNIQUE, index, nullable** | 美团渠道追踪位,仅字母+数字 ≤64(美团限制,schema 正则 `^[A-Za-z0-9]+$`)。**仅含 meituan 的群有**;留空自动 `g<id>`;纯淘宝/京东群为 `None`。订单/点击按它归群 |
|
||||
| `name` | String(128) | NOT NULL | 群名,运营自定 |
|
||||
| `platforms` | JSON / JSONB | NOT NULL, default `[]` | 该群发哪些平台(多选):`["meituan","taobao","jd"]`。含 `meituan` 才需要 `sid`。生成 link 时校验"活动平台必须在群范围内" |
|
||||
| `member_count` | Integer | nullable | 群人数,运营填,作转化率分母(可空) |
|
||||
| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`。统计 `group_stats` 只列 `active` 群 |
|
||||
| `remark` | String(256) | nullable | 运营备注 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间(无 `updated_at`) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `id` ← `cps_link.group_id`、`cps_click.group_id`(一群多 link/多点击,**语义关联,无 ForeignKey**)。
|
||||
- `sid` ← `cps_order.sid`(订单按 sid 归群对账,**语义关联,非 FK**);`sid` ← `cps_link.sid` / `cps_click.sid`(生成 link 时把群 sid 冗余进 link/click)。
|
||||
- 与 [`cps_activity`](./cps_activity.md) 无直接列关系,经"批量生成短链"按平台组合。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;**UNIQUE+index `sid`**(`uq_cps_group_sid` + `ix_cps_group_sid`)。
|
||||
- **无外键**。
|
||||
|
||||
## 注意
|
||||
- **`sid` 从"必填"放宽到"可空"是多平台演进的关键改动**:初版(`277f9b1`)`sid` NOT NULL + UNIQUE(每个美团群必有渠道位);`3a40f61` 接淘宝/京东后,这俩平台**没有 sid 概念**,迁移 `cps_v2_platforms` 把 `sid` 改 nullable,并加 `platforms` 列(存量群 server_default 回填 `["meituan"]`)。理解本表必须记住:**sid 只对美团有意义**,对账/归群全链路只走美团群。
|
||||
- **`g<id>` 自动 sid**:运营不填 sid 时服务端生成 `g<群id>`,既保证唯一又人类可读。先写 `tmp<uuid>` 占位是因为建群事务里 id 还没生成(flush 前拿不到),拿到 id 后二次 flush 回填。
|
||||
- **删群不级联**是刻意的:已发到微信群里的短链不能因为后台删群就失效;历史订单/点击也要保留用于对账。代价是删群后其 `sid` 的订单在 `group_stats` 里变成"未归群"独立行(如历史的 `wonderableai`,见 [`cps_order`](./cps_order.md) 注意)。
|
||||
- **`platforms` 用 JSON 列**:遵循全库约定 `JSON().with_variant(JSONB(), "postgresql")`——PG 上 JSONB,SQLite 退化通用 JSON。
|
||||
@@ -0,0 +1,38 @@
|
||||
# cps_link — CPS 群发短链(发群用的 /c/{code},外套一层我们的短链)
|
||||
|
||||
> 模型 `app/models/cps_link.py`(`CpsLink`) · 仓库 `app/repositories/cps_link.py`(`create_link` / `get_by_code`) · 接口 admin 生成 `POST /admin/api/cps/referral-links`、用户落地 `GET /c/{code}`(`app/api/v1/cps_redirect.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
群发券链接外套一层我们自己的短链 `/c/{code}`,目的是**统计点击 + 控制落地**。在 CPS 群发漏斗里它是**"发出去的那条链接"**:由 群([`cps_group`](./cps_group.md))× 活动([`cps_activity`](./cps_activity.md))在后台"批量生成短链"产出,运营把 `/c/{code}` 复制到微信群。用户一点 → 记一条 [`cps_click`](./cps_click.md) → 按平台 302 跳 `target_url`(美团短链/京东链接)或返回淘宝落地页。`cps_click` 与 `cps_link` 同文件(`models/cps_link.py`),详见 [`cps_click.md`](./cps_click.md)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /admin/api/cps/referral-links`(`generate_referral_links`,`operator`)批量生成——按 `group_id` + 一组 `activity_ids`,对每个活动调 `_gen_one_link`:美团走 `meituan.get_referral_link(act_id/product_view_sign, sid=群.sid)` 拿短链作 `target_url`(无群 sid 则 400);淘宝/京东直接用活动 `payload` 作 `target_url`。落库走 `create_link`,短码 `code` 由 `_gen_code` 生成(去易混字符,冲突重试 5 次仍冲突用 12 位兜底)。`code` 全局唯一即天然幂等键,但**每次调用都新建 link**(同群同活动重复生成会产生多条不同 code)。
|
||||
- **U(更新)**:**无**。link 一旦生成不改(改了等于换链接)。
|
||||
- **R(读)**:`GET /c/{code}`(`cps_landing`)落地时 `get_by_code` 取 link → 决定跳转/落地;`POST /c/{code}/copy` 同样按 code 取。后台无 link 列表端点(运营只看生成结果与按群统计)。
|
||||
- **D(删除)**:**无删除路径**。删群/删活动都不级联删 link(已发链接保持可用)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | 被 `cps_click.link_id` 语义引用(非 FK) |
|
||||
| `code` | String(16) | **UNIQUE, index, NOT NULL** | 短码,发群链接 `/c/{code}` 的 code。字母表去掉 `0/O/1/l/I`(肉眼复制不易错),默认 8 位 |
|
||||
| `group_id` | Integer | index, NOT NULL | 归属群 = `cps_group.id`(语义关联,非 FK) |
|
||||
| `activity_id` | Integer | index, NOT NULL | 对应活动 = `cps_activity.id`(语义关联,非 FK);淘宝落地页反查活动取图 |
|
||||
| `sid` | String(64) | index, **nullable** | 渠道追踪位,**仅美团 link 有**(= 群 sid);淘宝/京东无 sid。生成时从群冗余进来 |
|
||||
| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定 `cps_landing` 是 302 跳还是渲染淘宝落地页 |
|
||||
| `target_url` | String(2048) | NOT NULL | 跳转/落地目标:美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 生成时间(无 `updated_at`) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `group_id` → `cps_group.id`;`activity_id` → `cps_activity.id`;`sid` ≈ `cps_group.sid`——**全是语义关联,无 ForeignKey**(删群/删活动都不报错、不级联)。
|
||||
- `id` ← `cps_click.link_id`(一 link 多点击);记点击时 `record_click` 把 link 的 `group_id`/`sid` 冗余进 [`cps_click`](./cps_click.md)。
|
||||
- `target_url` 间接关联 [`cps_order`](./cps_order.md):美团 `target_url` 携带群 sid,用户经它下单后订单回来的 `sid` 即此群 sid——但 link 与 order **没有直接列关联**,只经 `sid` 在群维度汇合。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;**UNIQUE+index `code`**(`uq_cps_link_code` + `ix_cps_link_code`);index `group_id`、`activity_id`、`sid`。
|
||||
- **无外键**。
|
||||
|
||||
## 注意
|
||||
- **`target_url` 从 1024 加长到 2048**:初版(`277f9b1`)只装美团短链(够短),`3a40f61` 接淘宝后要装**整段淘口令文本**(较长),迁移 `cps_v2_platforms` 把 `target_url` 放宽到 2048,同时 `sid` 改 nullable(淘宝/京东 link 无 sid)。
|
||||
- **`sid` 冗余进 link 又冗余进 click**:统计时按群聚合点击不必 `join cps_link`(见 `click_stats_by_group` 直接读 `cps_click.group_id`);`sid` 留在 link 上则是为了生成时一次性带齐,跳转无需再查群。
|
||||
- **短码去易混字符**:`_CODE_ALPHABET` 排除 `0/O/1/l/I`,因为短链会被运营/用户肉眼复制粘贴,降低抄错率;碰撞重试 5 次后降级 12 位,概率近 0。
|
||||
- **三平台落地分叉**(`cps_redirect.cps_landing`):美团/京东 → 302 跳 `target_url`;淘宝 → 不能 302(淘口令不是 URL),改返回自渲染 H5 落地页(图来自活动 `image_url`,按钮复制 `target_url` 淘口令)。code 不存在时兜底跳 `https://www.meituan.com/`。
|
||||
@@ -0,0 +1,51 @@
|
||||
# cps_order — CPS 对账订单(美团联盟 query_order 拉回的订单明细)
|
||||
|
||||
> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile`、`GET /admin/api/cps/orders`、`GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
从美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}`。`order_id` 全局唯一即幂等键。
|
||||
- **R(读)**:`GET /admin/api/cps/orders`(`list_orders`,游标分页,可按 `sid`/`mt_status` 过滤);统计 `group_stats` 拉全部订单按 `sid` 分桶,算 `order_count`/`gmv`/`est_commission`/`settled_commission` 等。
|
||||
- **D**:**无**。
|
||||
|
||||
## 字段
|
||||
> 字段对齐 `query_order` 实测返回:金额(payPrice/profit)是「元」字符串 → 入库 `_yuan_to_cents` 转「分」(全站口径);时间(payTime/updateTime)是秒级时间戳 → `_ts_to_dt` 转 tz-aware UTC。
|
||||
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 |
|
||||
| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 |
|
||||
| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) |
|
||||
| `biz_line` | Integer | nullable | 业务线,源 `businessLine`。`1=外卖` |
|
||||
| `trade_type` | Integer | nullable | 源 `tradeType`。`1=cps 2=cpa` |
|
||||
| `pay_price_cents` | Integer | nullable | 付款金额(分),源 `payPrice`(元)。统计算 GMV |
|
||||
| `commission_cents` | Integer | nullable | **预估佣金**(分),源 `profit`(元)。统计算预估/已结算佣金 |
|
||||
| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate`。`"300"=3%`、`"10"=0.1%`(原样字符串,前端解释) |
|
||||
| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) |
|
||||
| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) |
|
||||
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 |
|
||||
| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` |
|
||||
| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) |
|
||||
| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 |
|
||||
| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) |
|
||||
| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 |
|
||||
| `first_seen` | DateTime(tz) | server_default now(), NOT NULL | 首次拉到入库时间(本地) |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最近 upsert 时间(本地) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `sid` ≈ `cps_group.sid`(订单按 sid 归群,**语义关联,无 ForeignKey**)。`group_stats` 即据此把订单挂到群行。
|
||||
- 与 [`cps_link`](./cps_link.md)/[`cps_click`](./cps_click.md) **无直接列关联**:下单链路是"美团短链(带 sid)→ 美团 App 下单",订单只回传 `sid`、不回传我们的 `link.code` 或某次点击 id。所以点击与订单只能在**群(sid)维度**汇合,**无法把单笔订单对到单次点击**。
|
||||
- **本表无 `user_id`**:CPS 联盟订单是别人(被推广的微信群用户)在美团下的单,与本 App 的 `user` 表无关,纯渠道佣金对账,不归属本 App 用户。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;**UNIQUE+index `order_id`**(`uq_cps_order_order_id` + `ix_cps_order_order_id`);index `sid`、`act_id`、`mt_status`、`pay_time`。
|
||||
- **无外键**。
|
||||
|
||||
## 注意
|
||||
- **状态决定佣金口径**(`group_stats._order_agg`):`4`取消/`5`风控**不计佣金**(`_INVALID_STATUS`);`6`结算才是佣金**真正到账**(`_SETTLED_STATUS`)。统计区分 `order_count`(有效=非取消非风控)、`settled_count`(已结算)、`canceled_count`,佣金分 `est_commission`(有效单预估)与 `settled_commission`(结算单)。
|
||||
- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。
|
||||
- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。
|
||||
- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。
|
||||
- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计里对账列直接给 `None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。
|
||||
@@ -0,0 +1,39 @@
|
||||
# invite_fingerprint — 邀请指纹归因(剪贴板兜底)
|
||||
|
||||
> 模型 `app/models/invite_fingerprint.py`(`InviteFingerprint`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/landing-track`(写,无鉴权)、`POST /api/v1/invite/bind`(读,指纹反查分支) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
一行 = 一次落地页访问的设备指纹快照。**剪贴板归因的兜底**:剪贴板 deferred-deeplink 是邀请归因主路径,但从"点下载"到"App 首启"之间剪贴板可能被覆盖、归因丢失。此表在落地页访问时先把 `(inviter, ip, screen, device_model, ua)` 记下来,被邀请人登录后剪贴板没拿到邀请码时,用 `(ip, device_model)` 反查 7 天内最近一条匹配 → 拿出邀请人 → 走 `bind(channel='fingerprint')`。任务来源见 #31 好友邀请任务 3。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /api/v1/invite/landing-track`(`landing_track`,**无需鉴权**,浏览器没 token)→ `invite_repo.record_fingerprint()`。B 浏览器打开 `dl.html?ref=<码>` 时 JS POST,服务端从 HTTP 头拿 `ip`(`_client_ip`,优先 `X-Forwarded-For` 第一段)、解析 UA 拿 `device_model`(`_parse_device_model`,Android 正则抓 `;...Build/` 前 token = `Build.MODEL`),连同 JS 上报的 `screen` 一起入库。`record_fingerprint` 自行 commit(调用方此前无其它写)。无效码 / 拿不到 IP 走 silent 返回(`invalid_code`/`no_ip`,不抛 5xx 影响下载流程)。
|
||||
- **R(反查)**:`POST /api/v1/invite/bind`(`bind_invite`)的指纹分支 → `resolve_inviter_by_fingerprint()`。仅在请求**无邀请码、有 `fingerprint`** 时触发:`WHERE ip=? AND device_model=? AND created_at > now - INVITE_FP_WINDOW_DAYS(7d)` `ORDER BY created_at DESC LIMIT 1` → 命中则取 `inviter_user_id` 对应用户 → 用其 `invite_code` 走原 `bind()`(同样过幂等/自邀/新人闸/发币逻辑);未命中返回 `fp_not_found`。
|
||||
- **U / D**:无。只增不改不删。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 落地页对应的邀请人(由 `?ref=<码>` 解析得到) |
|
||||
| `ip` | String(64) | NOT NULL | 访问者 IP(服务端从 HTTP 头拿;nginx 反代时走 `X-Forwarded-For`)。入库 `[:64]`。**反查参与匹配** |
|
||||
| `device_model` | String(64) | NOT NULL, default `""` | 设备型号(如 `PJF110`、`23046PNC9C`)。浏览器侧由 UA 正则解析,客户端侧 = `Build.MODEL`,跨端对齐。入库 `[:64]`。**反查参与匹配** |
|
||||
| `screen` | String(32) | NOT NULL, default `""` | 屏幕分辨率 `"1080x2400"`(浏览器 `screen.w×h`,客户端 `DisplayMetrics`)。入库 `[:32]`。**只入库不反查**(见注意) |
|
||||
| `user_agent` | Text | NOT NULL, default `""` | 完整 UA 串(debug / 排查"匹配不上"用,不参与匹配)。入库 `[:2000]` 防异常长 UA |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 落地页访问时间(= 7 天窗口判据 + 反查倒序键) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `inviter_user_id` → `user.id`(硬 FK,多对一):一个邀请人的落地页可被多次访问。
|
||||
- **反查组合键 = `(ip, device_model)` + `created_at > cutoff`**:等值匹配 IP 与型号、再取窗口内最近一条。注意 `screen` 虽进了组合索引最左前缀,但 SQL 不拿它做 WHERE 条件。
|
||||
- 反查结果不直接 join 本表→`user`,而是 `db.get(User, fp.inviter_user_id)` 拿邀请人,再回 `invite_relation` 走 `bind()`。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- `ix_invite_fp_match`(`ip, screen, device_model, created_at`):反查主索引。`/bind` 兜底分支按 `ip + device_model + created_at` 范围扫 + 倒序 LIMIT 1,SQLite/PG 优化器都能用上该复合索引。
|
||||
- `ix_invite_fp_inviter`(`inviter_user_id`):统计/排查用("这个邀请人的落地页被谁访问过")。
|
||||
|
||||
## 注意
|
||||
- **`screen` 只入库不反查(关键设计,`resolve_inviter_by_fingerprint` docstring)**:浏览器算物理像素走 `CSS × devicePixelRatio`、Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移(DPR 不严格是整数)→ 严格匹配注定撞不上。而**同 `device_model` 必同 `screen`**(同型号同硬件),故 `screen` 是冗余字段,删它不损精度。所以匹配只用 `(ip, device_model)`。
|
||||
- **组合索引为何含 `screen`**:索引列序是 `(ip, screen, device_model, created_at)`,但 WHERE 只用 `ip`/`device_model`/`created_at`。`screen` 夹在中间会打断"`ip` 之后直接接 `device_model`"的最左连续前缀,理论上略损此查询的索引效率(待确认:实测扫描量是否受影响;当前数据量下无感)。若后续优化,建议把索引调成 `(ip, device_model, created_at)`。
|
||||
- **7 天窗口(`rewards.INVITE_FP_WINDOW_DAYS`)的取舍**:太短(24h)覆盖不到"晚上看链接、第二天装";太宽(30d)IP/设备会漂、匹配错率上升;7 天兼顾"看广告→使用"周期与匹配精度。
|
||||
- **撞错概率**:只有"同一 IP 下、同型号手机、7 天内同时被不同人邀请"才会归因到错的邀请人,中国家庭/公司 WiFi 场景概率极低;属可接受的工程取舍而非 bug。
|
||||
- **与 invite_relation 的关系**:本表只是**线索库**,不发奖、不是结果。真正落地是反查出邀请人后回到 [invite_relation](./invite_relation.md) 的 `bind()`,写一行 `channel='fingerprint'` 并发币;本表本身不参与发奖与幂等。
|
||||
- **迁移**:`invite_fingerprint_table`(`down_revision=0cf18d590b1d`),晚于 `invite_code_and_relation`(#24 的表先建)。
|
||||
@@ -0,0 +1,46 @@
|
||||
# invite_relation — 好友邀请绑定关系(注册即生效)
|
||||
|
||||
> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子。
|
||||
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
|
||||
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active` → `invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at` 在 `rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。只有全过才插行 + 发奖。
|
||||
- **U / D**:无。这张表只增不改不删(纯账本)。
|
||||
- **R**:
|
||||
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。
|
||||
- `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins` 取 `inviter_coin`。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
|
||||
| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 |
|
||||
| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 |
|
||||
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 |
|
||||
| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) |
|
||||
| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `inviter_user_id` → `user.id`(硬 FK,多对一):一个 A 可邀多个 B。
|
||||
- `invitee_user_id` → `user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。
|
||||
- 发金币时落 `coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账。
|
||||
- `get_invitees` 用 `InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count` 算 `has_more`。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- `ix_invite_relation_inviter_user_id`(`inviter_user_id`):`get_stats` / `get_invitees` 按邀请人查。
|
||||
- `ix_invite_relation_invitee_user_id`(`invitee_user_id`,**UNIQUE**):幂等键 + 加速 `_relation_of_invitee` 反查。
|
||||
- `ix_invite_relation_created_at`(`created_at`):列表倒序。
|
||||
|
||||
## 注意
|
||||
- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。
|
||||
- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入。
|
||||
- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账。
|
||||
- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。
|
||||
- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡。
|
||||
- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head` 撞 `Multiple head revisions`(#24 设计文档已警示)。
|
||||
@@ -0,0 +1,71 @@
|
||||
# price_observation — 比价沉淀的价格事实(平台/门店视角的价格资产层)
|
||||
|
||||
> 模型 `app/models/price_observation.py` · 仓库 `app/repositories/price_observation.py` · 上报端点 `app/api/internal/price.py`(`POST /internal/price-observation`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每完成一次比价,pricebot 在 done 帧把「在某平台某店、这一篮菜/这单到手价多少钱」按平台逐条算出来,**server→server 内部上报**落这里(一次外卖比价 = 源平台 1 条 + N 个目标平台各 1 条,`scope='order'`)。**与登录无关、不依赖客户端鉴权**——比价透传链路(`compare.py`)当前不鉴权,只要比价跑到 done 就记,**匿名用户也记**(来源记 `source_device_id`,`source_user_id` 客户端带上时才有)。
|
||||
|
||||
与 [`comparison_record`](./comparison_record.md) 的区别(model docstring 明示,两表独立、互不影响):
|
||||
- `comparison_record` = **用户视角**的「我的比价记录」,客户端登录后用带 JWT 的通道(`POST /compare/record`)主动上报,按 `(user_id, trace_id)` 存。
|
||||
- `price_observation` = **平台/门店视角的客观价格事实**,server 侧**无条件沉淀**,按 (平台, 门店, 菜, 时间) 组织。是未来「别人查过同店 → 直接秒回价格」价格大数据的源头(#21,「先存数据、用法后续」)。
|
||||
|
||||
> raw trace(逐 step 全过程)另存 pricebot 本地 `jsonl.gz`,本表只存**提炼后的价格事实**(本表可从 raw 重算);`trace_id` 软指 pricebot work_logs 供溯源/重算。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(批量插入,幂等)**:`POST /internal/price-observation`(`report_price_observation` → `repo.insert_batch`)。pricebot 在 done 帧把整批观测 POST 过来落库。幂等键 = `(trace_id, platform, scope)`:仓库**先查该 trace 已有的 `(platform, scope)`,只插新的**(不依赖 `ON CONFLICT`,跨方言 PG/SQLite 都安全),并发撞唯一约束则 `db.rollback()` 后整批跳过(本批本就幂等,**不重试**,见 P0 约定)。同批内还会去重一次(防 pricebot 同帧给重复平台)。返回 `{inserted, skipped}`。
|
||||
- **U / D**:无。append-only,无更新无删除。
|
||||
- **R**:**当前无读取端点**(纯写入沉淀层,未来做「秒回价格」/聚合时再用)。
|
||||
|
||||
> 上报端点是 **server→server 专用**:不走用户 JWT,靠共享密钥头 `X-Internal-Secret`(== `settings.INTERNAL_API_SECRET`,常量时间比较 `hmac.compare_digest`)。**密钥未配置(默认空)→ 直接 503**(挡住裸奔的写端点);不匹配 → 401。该密钥须与 pricebot 侧同值。
|
||||
|
||||
## 字段
|
||||
|
||||
批级字段(门店/菜篮/地理/来源,一次比价共享,由 `insert_batch` 灌进每条)vs 逐平台字段(`observations[]` 各自带)见下表「来源」列。
|
||||
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `observed_at` | DateTime(tz) | server_default now(), index, NOT NULL | 观测时刻(≈比价 done 时;服务端落库时取 now)。按时间查「最近有效价」用 |
|
||||
| `trace_id` | String(64) | index, NOT NULL | pricebot 侧 trace_id:**幂等去重键**(含于唯一约束)+ 回指 A 层原始 trace(溯源/重算)。软指 pricebot work_logs,无硬 FK |
|
||||
| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(外卖,当前唯一接通)/ `ecom`(电商,二期)。**批级共享** |
|
||||
| **门店维度** | | | denormalize 成文本,不建维度表;实体归一二期再做 |
|
||||
| `platform` | String(32) | index, NOT NULL | 平台标识。**逐平台**(`observations[].platform`),含于唯一约束 |
|
||||
| `platform_store_id` | String(64) | 可空 | 平台内门店 ID(抓得到才存,将来归一最稳的 key;**现在多为空**)。**逐平台** |
|
||||
| `store_name` | String(128) | index, 可空 | 门店名。**批级共享**(源/目标同名场景下整批一份) |
|
||||
| `city` | String(64) | 可空 | **批级共享** |
|
||||
| `geohash` | String(16) | index, 可空 | 地理(**现在拿不到→留空**;二期把 device 经纬度透传进比价请求后回填)。**批级共享** |
|
||||
| `lng` / `lat` | Float | 可空 | 同上,**批级共享** |
|
||||
| **价格事实** | | | |
|
||||
| `is_source` | Boolean | NOT NULL, default false | 是否源平台(发起比价那家)。源平台价也是真实观测,照记。**逐平台** |
|
||||
| `scope` | String(16) | NOT NULL, default `order` | 口径:`order`(整单到手价,外卖当前唯一)/ `dish`(单菜单价,引擎暂不逐菜读,二期)。含于唯一约束。**逐平台** |
|
||||
| `price_cents` | Integer | 可空 | 该口径到手价(分);**None = 该平台采集失败 / 门店打烊,无有效价**。**逐平台** |
|
||||
| `coupon_saved_cents` | Integer | 可空 | 红包/券省了多少(分)。**逐平台** |
|
||||
| `coupon_name` | String(64) | 可空 | 优惠来源名。**逐平台** |
|
||||
| `store_closed` | String(32) | 可空 | 目标店打烊原因(**非空时 `price_cents` 为 None**)。**逐平台** |
|
||||
| `rank` | Integer | 可空 | 本次比价全局名次(1=最便宜;按到手价升序,源/目标统一排)。**逐平台** |
|
||||
| **明细(JSON)** | | | |
|
||||
| `dishes` | JSON(PG JSONB) | 可空 | 菜篮 `[{name, qty, subtotal?}]`(外卖)。dish 级单价二期从这里 + 引擎增强推。**批级共享** |
|
||||
| `attrs` | JSON(PG JSONB) | 可空 | 灵活字段兜底(配送费/起送/活动名/跳过菜数 等),免得加字段就迁移。**逐平台**(`observations[].attrs`) |
|
||||
| **来源(溯源/去重/置信)** | | | |
|
||||
| `source_device_id` | String(64) | index, 可空 | 来源设备;匿名比价也有。**批级共享** |
|
||||
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,先可空、不进唯一键。软指 `user.id`。**批级共享** |
|
||||
| `confidence` | Float | NOT NULL, default 1.0 | 置信度,预留 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 落库时间 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- **无硬外键**。
|
||||
- `trace_id` **软指** pricebot work_logs(溯源/重算/排查),并作幂等去重键;与 `comparison_record.trace_id` 同源(同一次比价的 pricebot trace),但两表不互相 join、各存各的视角。
|
||||
- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。
|
||||
- `source_device_id` 与不鉴权期 `comparison_record.device_id` / 领券三表 `device_id` 同源,可用于对账(无 SQL 级约束)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- **UNIQUE(`trace_id`, `platform`, `scope`) = `uq_price_obs_trace_platform_scope`**:一次比价(trace)里同一平台、同一口径只一条 → pricebot 重试 / 客户端 replay 重复上报幂等去重。
|
||||
- index:`observed_at`、`trace_id`、`business_type`、`platform`、`store_name`、`geohash`、`source_device_id`、`source_user_id`(为将来按 平台/门店/菜/时间/地理 查询聚合预埋)。
|
||||
|
||||
## 注意
|
||||
- **资产层定位「先存后用」**:当前**只写不读**(无 R 端点、不参与任何业务判断),是为「别人查过同店→秒回价格」大数据预先沉淀的原始事实。结构化列预埋查询/聚合维度,用法二期再定。
|
||||
- **`attrs` / `dishes` 是 JSONB 兜底,别塞原始无障碍树**(几十 KB → 行膨胀);原始大树/逐 step 看 `trace_id` 指过去的 pricebot 落盘。`_JSON = JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN),SQLite dev 退化通用 JSON(同 `comparison_record` / `coupon_claim_record`)。
|
||||
- **`price_cents=None` 是有意义的观测**:表示该平台采集失败 / 门店打烊(看 `store_closed`),不是「没记」。统计「最低价」时要先过滤 None。
|
||||
- **当前数据多为半空**:`platform_store_id` 多为空(门店 ID 抓不全)、`geohash/lng/lat` 留空(device 经纬度二期才透传进比价请求)、`source_user_id` 多为空(链路不鉴权)。这些是**二期回填**计划,不是 bug。
|
||||
- **幂等/并发**:不重试、失败就失败(P0 约定);同批去重 + 唯一键 + `IntegrityError` 回滚三重防御,但回滚是**整批**回滚(撞约束则本批全跳过)。
|
||||
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(不是 401),这是「内部端点默认关闭」的安全默认态——本地 dev / 未启用时该表不会有新数据。
|
||||
@@ -0,0 +1,85 @@
|
||||
# store_mapping — 跨平台「同一家店」的 id/名/deeplink 身份映射(门店资产层)
|
||||
|
||||
> 模型 `app/models/store_mapping.py` · 仓库 `app/repositories/store_mapping.py` · 上报/反查端点 `app/api/internal/store.py`(`POST /internal/store-mapping`、`GET /internal/store-mapping/lookup`、`POST /internal/store-mapping/invalidate`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每完成一次比价,pricebot 把同一家物理店在 **淘宝/美团/京东** 各自的店铺 id、店名、可复跳 deeplink **server→server 内部上报**落这里一行(一次比价 = 一个 trace = 一行,各平台「腿」解析出 id 后填空合并进同一行)。**与登录无关、不依赖客户端鉴权**(同 [`price_observation`](./price_observation.md),`source_user_id` 客户端带上时才有,匿名也记)。
|
||||
|
||||
与 [`price_observation`](./price_observation.md) 的区别(两表平行、独立):
|
||||
- `price_observation` = 平台/门店视角的**价格事实**(某店这单多少钱)。
|
||||
- `store_mapping` = 平台/门店视角的**身份映射**(同一家店在各平台的店铺 id/名/deeplink)。是未来「我见过这家店 → 跳过重新搜索/匹配,直接 deeplink 进店内搜索」的源头(#48)。
|
||||
|
||||
与 [`comparison_record`](./comparison_record.md)(用户视角、带 JWT、按 `user_id` 存)也无关:本表是 server 无条件沉淀的客观映射、匿名也记。
|
||||
|
||||
> **⚠️ 数据质量**:跨平台「同一家店」的连接来自 agent 的 **LLM 店铺匹配**,匹配错则一行里 `id_taobao` 与 `id_jd`/`name_meituan` **连到不同店**。本表是 **append-only 原始记录**(每比价一行、`trace_id` 幂等防重试重复),「一行里多平台 id 共存」只表示「两条腿搜同一个源店名各自匹配到了某家店」= name-match 置信度、**非已核实同一实体**;清洗/归一/精确匹配二期由下游做。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert,trace_id 维度幂等 + 填空合并)**:`POST /internal/store-mapping`(`report_store_mapping` → `repo.upsert`)。合并键 = `trace_id`(唯一约束)。一次跨平台比价 = 一个 trace = 一个真实店铺:某条腿解析出 id 先建行(填自己那几列),后到的腿(将来)再 upsert 进**同一行**。**合并策略 = 填空(fill-the-blanks)**:`_merge_fill_blanks` 只写该行当前为 NULL 的列、**绝不覆盖已有非空值**;共享列(geo/source/溯源)先到先得。首写用逐列 `setattr`(而非硬编码构造器,否则首写是美团/京东腿时其列会漏)。不依赖 `ON CONFLICT`,跨方言安全;并发撞唯一约束则 `rollback()` 后转填空合并路径。返回 `{inserted, row_id}`(inserted=1 新建 / 0 合并)。
|
||||
- **R(lookup 反查,命中即省一整段现场反查)**:`GET /internal/store-mapping/lookup?source_platform=&name=&lat=&lng=`(`lookup_nearest`)。**比价前**按「源平台店名」反查各目标平台已沉淀的店铺 id,命中就让 pricebot **直接 deeplink 跳店内搜索**,省掉「开平台→进店→分享→反查」整段。机制:匹配键 = 源平台对应的 name 列精确等于 `name`;每个目标**分别**取「含该目标 id 的同名候选里最近一条」(有入参 geo 且候选带 geo → haversine 取最近,否则取 `created_at` 最新);**排除源平台自己**(不复用源平台 id);**过滤掉 deeplink 已失效的候选**(见下)。返回 `{target_key: {id..., deeplink, row_id, dist_km?}}`,空 = 没缓存、pricebot 走现场反查老路。
|
||||
- **U(deeplink 失效标记)**:`POST /internal/store-mapping/invalidate`(`{platform, shop_id}`)。pricebot 撞错误页回退时上报,按 `shop_id` 把**所有**匹配行的对应 `*_deeplink_invalid_at` 置 `now()`(幂等:已标记的跳过)。`taobao`→`mark_taobao_deeplink_invalid`、`jd`→`mark_jd_deeplink_invalid`;**其它平台(含 meituan)当前 no-op 返回 `affected=0`、不报错**(向后兼容 pricebot 将来扩展)。返回 `{ok, affected}`。
|
||||
- **D**:无业务删除。
|
||||
|
||||
> 上报/反查/失效端点全是 **server→server 专用**,复用 `price.py` 的 `_check_secret`(同一 `X-Internal-Secret` 密钥,未配置→503,不匹配→401)。
|
||||
|
||||
## 字段
|
||||
|
||||
各平台列**按比价角色稀疏填充**(淘宝当目标才有 `*_taobao`,依此类推)。
|
||||
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| **跨平台身份** | | | 同一物理店在各平台的 id/名 |
|
||||
| `id_taobao` | String(64) | index, 可空 | 淘宝 = 分享短链解析出的 **shopId**(淘宝当目标、走完取 id 流程才有)。门店稳定数字主键 |
|
||||
| `name_taobao` | String(128) | 可空 | 店铺页 a11y `content_desc`「店铺标题:xxx」剥前缀 |
|
||||
| `id_meituan` | String(64) | index, 可空 | **留给将来 CPS API 的稳定数字 poi_id**;当前无 share→稳定 id 机制 → 多为空(可复跳票据见 `meituan_poi_id_str`) |
|
||||
| `name_meituan` | String(128) | 可空 | 源平台店名(来自 intent/agent 匹配名) |
|
||||
| `id_jd` | String(64) | index, 可空 | 京东 = **storeId**(门店稳定数字主键,同 taobao shopId;3.cn 短链反查)。`venderId` 另存 `jd_vender_id` |
|
||||
| `name_jd` | String(128) | 可空 | 京东店铺页店名 |
|
||||
| **地理** | | | 同名店异地区分 / 地理分桶匹配主要燃料 |
|
||||
| `city` | String(64) | 可空 | |
|
||||
| `geohash` | String(16) | index, 可空 | |
|
||||
| `lng` / `lat` | Float | 可空 | lookup 时按入参 geo 取最近候选用 |
|
||||
| `taobao_address` | String(256) | 可空 | 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配) |
|
||||
| **溯源 / 画像** | | | |
|
||||
| `source_platform` | String(32) | index, 可空 | 发起比价那家:`meituan` / `taobao_flash` / `jd_waimai` … lookup 据此选 name 列、排除源平台自己 |
|
||||
| `business_type` | String(16) | NOT NULL, default `food` | |
|
||||
| `trace_id` | String(64) | NOT NULL | **幂等合并键**(唯一约束)+ 回指原始 trace(溯源)。软指 pricebot work_logs,无硬 FK |
|
||||
| `source_device_id` | String(64) | index, 可空 | |
|
||||
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,软指 `user.id` |
|
||||
| **淘宝原料**(可复跳/重解析/调试) | | | |
|
||||
| `taobao_share_url` | String(256) | 可空 | `m.tb.cn` 短链 |
|
||||
| `taobao_resolved_url` | Text | 可空 | 解析出的目标 URL(含 shopId)。Text 因 URL 可能很长 |
|
||||
| `taobao_deeplink` | Text | 可空 | 拼好的 et-store/search deeplink |
|
||||
| `taobao_deeplink_invalid_at` | DateTime(tz) | 可空 | **淘宝 deeplink 失效标记**(#53)。撞「页面出错了」降级页时被置;**NULL=有效**。lookup 反查过滤掉非 NULL 的淘宝候选 |
|
||||
| **美团原料**(dpurl.cn 短链 → 302 反查 → imeituan://) | | | |
|
||||
| `meituan_poi_id_str` | String(64) | index, 可空 | **⚠️ 每次分享重新加密、非稳定主键**(调研 §八)→ 单列存「可复跳的一次性票据」,**不进 `id_meituan`** |
|
||||
| `meituan_share_url` | String(256) | 可空 | `dpurl.cn` 短链 |
|
||||
| `meituan_resolved_url` | Text | 可空 | 302 落地 menu URL(含 poi_id_str) |
|
||||
| `meituan_deeplink` | Text | 可空 | 拼好的 `imeituan://` 店内搜索 deeplink |
|
||||
| **京东原料**(3.cn 短链 → 重定向反查 → openapp.jdmobile://) | | | |
|
||||
| `jd_vender_id` | String(64) | index, 可空 | venderId 单列存(deeplink 模板 venderId+storeId 都要;venderId 是商家维度、可跨门店,与门店 storeId 分开记) |
|
||||
| `jd_share_url` | String(256) | 可空 | `3.cn` 短链 |
|
||||
| `jd_resolved_url` | Text | 可空 | 反查出的目标 `openapp.jdmobile://` URL |
|
||||
| `jd_deeplink` | Text | 可空 | 拼好的 pages/search 店内搜索 deeplink |
|
||||
| `jd_deeplink_invalid_at` | DateTime(tz) | 可空 | **京东 deeplink 失效标记**(#56,对标淘宝)。撞「当前门店超出配送范围」页时被置;**NULL=有效**。lookup 过滤非 NULL 的京东候选 |
|
||||
| `attrs` | JSON(PG JSONB) | 可空 | 灵活字段兜底,免得加字段就迁移 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | lookup 同名候选无 geo 时按它取最新 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- **无硬外键**。
|
||||
- `trace_id` **软指** pricebot work_logs(溯源/排查),并作 upsert 幂等合并键。
|
||||
- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。
|
||||
- lookup 的匹配靠**店名字符串精确相等**(`_SOURCE_NAME_COLUMN` 把源平台映到对应 name 列)+ 可选 geo 取最近,**非 id 级 join**。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`。
|
||||
- **UNIQUE(`trace_id`) = `uq_store_mapping_trace`**:一次比价(trace)只一行 → pricebot 重试 / 客户端 replay 重复上报幂等去重;后到的平台腿填空合并进同一行。
|
||||
- index:`id_taobao`、`id_meituan`、`id_jd`、`meituan_poi_id_str`、`jd_vender_id`、`geohash`、`source_platform`、`source_device_id`、`source_user_id`、`created_at`(lookup 反查 / 失效标记按 id 列查需要)。
|
||||
|
||||
## 注意
|
||||
- **deeplink 失效→标记→回退闭环**(#53 淘宝 / #56 京东,本表最关键机制):缓存的 deeplink 会过期(淘宝撞「页面出错了」、京东撞「当前门店超出配送范围」)。pricebot 撞到错误降级页 → 调 `/internal/store-mapping/invalidate` 按 shopId/storeId 把**所有**相关行打上 `*_deeplink_invalid_at` → 后续 `lookup_nearest` **整条排除**失效候选(当没缓存,pricebot 走正常搜店)→ 重搜成功会写**新行**自然覆盖。按 shopId 标记所有行(不是单行)是因为同一坏 id 可能散在多次比价的多行里,全标掉才能让 lookup 不再返回它。
|
||||
- **美团没有失效标记**:`meituan_poi_id_str` 是「每次分享重新加密」的一次性票据、本就不稳定不复用为稳定 id,故无 invalidate 路径(`invalidate` 端点对 meituan no-op)。三平台 share→id 反查机制不对称:淘宝 `m.tb.cn`→shopId(稳定)、京东 `3.cn`→venderId+storeId(稳定)、美团 `dpurl.cn`→poi_id_str(**非稳定**)。
|
||||
- **资产层「先存后用」+ append-only**:lookup 是已接通的读用途(省现场反查),但表本身是原始记录、不做清洗归一。「一行多平台 id 共存」**不代表已核实同一实体**——下游做精确匹配前别当真值用。
|
||||
- **数据质量依赖 LLM 匹配**:跨平台连接来自 agent 店铺匹配,**可能连错店**;排查某行可疑映射时回 `trace_id` 指的 pricebot trace 看当时的匹配上下文。
|
||||
- **`attrs` 是 JSONB 兜底,别塞大树**(同 `price_observation`);`_JSON = JSON().with_variant(JSONB(), "postgresql")`,PG JSONB / SQLite dev 通用 JSON。
|
||||
- **填空合并不覆盖**:共享列(geo/source/device)先到先得,后到的腿只填自己那几列的空位;想「纠正」已写错的列**当前没有路径**(append-only,靠新 trace 写新行)。
|
||||
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(默认关闭态),本地 dev / 未启用时该表不会有新数据。
|
||||
Reference in New Issue
Block a user