feat(ad): eCPM 上报接口 + 冷却策略解耦纯函数 + savings JSONB 跨库修复 (#8)
- 新增 POST /api/v1/ad/ecpm-report:激励视频展示后客户端上报本次 eCPM,落 ad_ecpm_record 做内部收益统计(model/repository/schema/alembic 迁移 + 接口文档) - 看广告冷却策略抽到 app/core/ad_cooldown.py 纯函数;ad_reward.today_status 只取数据,换策略只改这一处 - savings.dishes 改 JSON().with_variant(JSONB,"postgresql"):SQLite 无 visit_JSONB 会让 create_all 编译崩,修复后测试套件恢复 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #8 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
This commit was merged in pull request #8.
This commit is contained in:
+9
-1
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-05-31
|
||||
> 最后更新:2026-05-31(+ 比价战绩里程碑 12d/12e)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -26,6 +26,13 @@
|
||||
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend) |||
|
||||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
|
||||
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) |
|
||||
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
|
||||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
|
||||
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
|
||||
| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) |
|
||||
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
|
||||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) |
|
||||
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) |
|
||||
| **钱包 / 我的资产**(前缀 `/api/v1/wallet`) |||
|
||||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
|
||||
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
|
||||
@@ -52,6 +59,7 @@
|
||||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
|
||||
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
|
||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
|
||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计)
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
请求体:`EcpmReportIn`
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
|
||||
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,**单位待确认(分/元)**,原样上报 |
|
||||
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
|
||||
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
|
||||
|
||||
`user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`EcpmReportOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ok` | bool | 落库即 `true` |
|
||||
|
||||
## 说明
|
||||
客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。
|
||||
|
||||
- **best-effort**:客户端 fire-and-forget,丢一两条不影响业务;穿山甲后台报表是结算权威兜底。
|
||||
- 落 `ad_ecpm_record` 表,`report_date` 用北京时间当天,供「按用户/按天聚合」对账。
|
||||
- **与发奖是两条独立流**:发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S,有 `trans_id`、无 ecpm),本接口客户端上报(有 ecpm、无 `trans_id`)。两者无公共键,**不逐条一一对应**,只做按用户/按天聚合口径。
|
||||
- eCPM 是**每千次展示预估**,单条展示预估收益 ≈ `ecpm / 1000`,且为客户端预估口径,非最终结算。
|
||||
- ⚠️ eCPM **单位(分/元)截至 2026-05-31 未最终确认**,故 `ecpm_raw` 原样存字符串;确认后再加一列解析好的数值。
|
||||
- 限流:同 IP 120 次/分钟。
|
||||
|
||||
## 相关
|
||||
- [ad-pangle-callback](./ad-pangle-callback.md) — 穿山甲 S2S 发奖回调
|
||||
- [ad-reward-status](./ad-reward-status.md) — 今日看广告发奖进度
|
||||
@@ -0,0 +1,33 @@
|
||||
# POST /api/v1/compare/milestones/{milestone}/claim — 领取比价战绩里程碑奖励
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
领取某一档(第 `milestone` 次)的金币奖励,真实入账金币钱包。领取记录写
|
||||
`comparison_milestone_claim`((user_id, milestone) 唯一)+ `grant_coins` 同一事务原子提交,
|
||||
**每档只能领一次**。进度口径见 [milestones 进度接口](./compare-milestones.md)。
|
||||
|
||||
## 入参
|
||||
- 路径参数 `milestone`(int):档位序号,1..6(= `RECORD_MILESTONES` 长度)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `milestone` | int | 本次领取的档位序号 |
|
||||
| `coin_awarded` | int | 本次发放金币 |
|
||||
| `coin_balance` | int | 领奖后金币余额 |
|
||||
|
||||
```json
|
||||
{ "milestone": 1, "coin_awarded": 120, "coin_balance": 29087 }
|
||||
```
|
||||
|
||||
## 错误
|
||||
- `401` 未鉴权
|
||||
- `404` 档位越界(`unknown milestone`,milestone < 1 或 > 档位总数)
|
||||
- `409` 该档还没解锁(`milestone locked`,成功比价次数 < milestone)
|
||||
- `409` 该档已领过(`milestone already claimed`)
|
||||
|
||||
## 说明
|
||||
- 领取金币后,客户端应刷新钱包余额([`GET /api/v1/wallet/account`](./wallet-account.md))与本档状态。
|
||||
- 幂等:重复领同一档返回 409 且**不会重复发金币**(唯一约束 + 领取前查重)。
|
||||
@@ -0,0 +1,51 @@
|
||||
# GET /api/v1/compare/milestones — 比价战绩里程碑进度
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
福利页「记录比价战绩」的数据源。返回各档(第 1~6 次)解锁/领取状态。
|
||||
|
||||
**解锁进度** = 当前用户 `comparison_record` 里 `status='success'` 的条数(只算成功比价,失败的不计入)。第 N 档在「成功比价次数 ≥ N」时解锁;每档领一次(领取见 [claim 接口](./compare-milestone-claim.md))。
|
||||
|
||||
档位金额是**产品规则**,定义在后端 `app/core/rewards.py` 的 `RECORD_MILESTONES`(当前 `120/180/300/500/800/1200`),客户端**不要写死**,以本接口返回为准。
|
||||
|
||||
## 入参
|
||||
无(用户身份取自 Bearer token)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `success_count` | int | 累计成功比价次数(解锁进度) |
|
||||
| `claimable_count` | int | 当前可领(state=active)的档数 |
|
||||
| `milestones` | Milestone[] | 各档状态,按 milestone 升序 |
|
||||
|
||||
**Milestone**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `milestone` | int | 档位序号(1-based),= 解锁所需的成功比价次数 |
|
||||
| `coin` | int | 该档奖励金币 |
|
||||
| `state` | string | `claimed`(已领) / `active`(已解锁可领) / `locked`(未解锁) |
|
||||
|
||||
```json
|
||||
{
|
||||
"success_count": 2,
|
||||
"claimable_count": 1,
|
||||
"milestones": [
|
||||
{"milestone": 1, "coin": 120, "state": "claimed"},
|
||||
{"milestone": 2, "coin": 180, "state": "active"},
|
||||
{"milestone": 3, "coin": 300, "state": "locked"},
|
||||
{"milestone": 4, "coin": 500, "state": "locked"},
|
||||
{"milestone": 5, "coin": 800, "state": "locked"},
|
||||
{"milestone": 6, "coin": 1200, "state": "locked"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 错误
|
||||
- `401` 未鉴权
|
||||
|
||||
## 说明
|
||||
- 同时可有多档处于 `active`(如累计 3 次却一档没领,则前 3 档都可领),逐档调 claim。
|
||||
- 进度只增不减:领取不消耗成功次数,只是把对应档从 active→claimed。
|
||||
@@ -0,0 +1,25 @@
|
||||
# GET /api/v1/compare/records/{record_id} — 比价记录详情
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
单条比价记录详情,在列表项基础上额外带 `raw_payload`(客户端上报的原始全量),供未来 UI 展示任意细节。
|
||||
|
||||
## 入参(path)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `record_id` | int | 记录 id |
|
||||
|
||||
## 出参
|
||||
响应 `200`:**ComparisonRecordDetailOut** = [ComparisonRecordOut](./compare-records.md#出参) 全部字段 + 下列:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `raw_payload` | object \| null | 客户端上报的原始 body 全量(calibration + done.params) |
|
||||
|
||||
## 错误
|
||||
- `401` 未鉴权
|
||||
- `404` 记录不存在,或不属于当前用户(限本人,避免越权读他人记录)
|
||||
|
||||
## 说明
|
||||
`404` 同时覆盖「id 不存在」和「id 属于他人」两种情况——不区分以免泄露他人记录是否存在。
|
||||
@@ -0,0 +1,57 @@
|
||||
# POST /api/v1/compare/record — 上报一次比价结果(幂等)
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
|
||||
|
||||
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
|
||||
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。
|
||||
|
||||
## 入参(JSON body)
|
||||
|
||||
字段刻意对齐 pricebot 协议([02_api_protocol.md](../../../pricebot-backend/docs/main/02_api_protocol.md) 的 `calibration` + `done.params`),客户端把 Phase1 的 calibration 和 done 帧 params **零翻译**映射上来即可。
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `trace_id` | string | ✅ | — | pricebot 侧 trace_id。**幂等键**:同用户同 trace_id 重复上报覆盖、返回同一 id |
|
||||
| `business_type` | string | ❌ | `food` | `food`(外卖,当前唯一接通) / `ecom`(电商) / `coupon`(领券) |
|
||||
| `device_id` | string \| null | ❌ | null | 设备号 |
|
||||
| `store_name` | string \| null | ❌ | null | 店铺名(外卖,来自 calibration.result) |
|
||||
| `source_platform_id` | string \| null | ❌ | null | 源平台代号,如 `taobao_flash` |
|
||||
| `source_platform_name` | string \| null | ❌ | null | 源平台中文名 |
|
||||
| `source_package` | string \| null | ❌ | null | 源平台 Android 包名 |
|
||||
| `source_price` | float \| null | ❌ | null | 源平台到手价(**元**) |
|
||||
| `items` | Item[] | ❌ | [] | 下单菜品,`{name, qty, specs?}` |
|
||||
| `comparison_results` | Result[] | ❌ | [] | 逐平台对比,见下表(price 单位**元**) |
|
||||
| `skipped_dish_count` | int \| null | ❌ | null | 目标平台未找到、跳过的菜品数 |
|
||||
| `skipped_dish_names` | string[] | ❌ | [] | 被跳过的菜名 |
|
||||
| `total_dish_count` | int \| null | ❌ | null | 原始菜品总数 |
|
||||
| `information` | string \| null | ❌ | null | done 帧文案(成功摘要 / 失败具体原因),持久化为列并在列表/详情返回,前端失败时当原因展示 |
|
||||
| `status` | string \| null | ❌ | null | `success` / `failed`。**不传则服务端派生** |
|
||||
|
||||
**Item**:`{ name: string, qty: int=1, specs: string[]\|null }`
|
||||
|
||||
**Result(comparison_results 元素)**:`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null) }`
|
||||
|
||||
## 服务端派生(客户端不用算)
|
||||
|
||||
从 `comparison_results` 派生并落结构化列:
|
||||
|
||||
- `best_*`:取最便宜的一家(按 `rank` 升序、再按 `price`),写 `best_platform_id/name/best_price_cents`
|
||||
- `source_price_cents`:优先用 `source_price`,缺失则取 `comparison_results` 里 `is_source=true` 行的价
|
||||
- `saved_amount_cents` = `源价 − 最优价`(可为 0 / 负:源平台本来就最便宜)
|
||||
- `is_source_best`:最便宜的一家是否为源平台(= 这次没省到)
|
||||
- `status`(未显式传时):存在「非源且有价」的结果 → `success`,否则 → `failed`
|
||||
- 金额一律 `round(元 × 100)` 存**分**
|
||||
|
||||
`comparison_results` / `items` / `skipped_dish_names` 原样存 JSON;完整上报 body 另存 `raw_payload`(详情接口可取回)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ "id": int }`(写入或已存在记录的 id)
|
||||
|
||||
## 错误
|
||||
- `401` 未鉴权
|
||||
- `422` 缺 `trace_id` 或字段类型不符
|
||||
|
||||
## 说明
|
||||
失败的比价(done 帧只有源/空 comparison_results)也会落一条 `status=failed`——「越详细越好」,是否在 UI 展示由前端过滤。
|
||||
@@ -0,0 +1,47 @@
|
||||
# GET /api/v1/compare/records — 比价记录列表(游标分页)
|
||||
|
||||
> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
「我的比价记录」列表页数据源。按 `id` 倒序(最新在前)。
|
||||
|
||||
## 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: ComparisonRecordOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定))
|
||||
|
||||
**ComparisonRecordOut**(列表项,不含 `raw_payload`,减小 payload)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 记录 id(也是游标) |
|
||||
| `business_type` | string | `food` / `ecom` / `coupon` |
|
||||
| `trace_id` | string | pricebot trace_id |
|
||||
| `source_platform_id` | string \| null | 源平台代号 |
|
||||
| `source_platform_name` | string \| null | 源平台中文名 |
|
||||
| `source_package` | string \| null | 源平台包名 |
|
||||
| `source_price_cents` | int \| null | 源平台到手价(分) |
|
||||
| `best_platform_id` | string \| null | 最优平台代号 |
|
||||
| `best_platform_name` | string \| null | 最优平台中文名 |
|
||||
| `best_price_cents` | int \| null | 最优价(分) |
|
||||
| `saved_amount_cents` | int \| null | 省下(分,可 0/负) |
|
||||
| `is_source_best` | bool \| null | 源平台是否最便宜(= 没省到) |
|
||||
| `store_name` | string \| null | 店铺名 |
|
||||
| `total_dish_count` | int \| null | 菜品总数 |
|
||||
| `skipped_dish_count` | int \| null | 跳过菜品数 |
|
||||
| `status` | string | `success` / `failed` |
|
||||
| `information` | string \| null | done 帧文案。成功:"在美团找到同店,到手价 ¥X…";失败:具体原因(如"美团、京东外卖均未找到该商品"),前端在 `status=failed` 时当原因展示 |
|
||||
| `items` | object[] | 下单菜品 `{name, qty, specs?}` |
|
||||
| `comparison_results` | object[] | 逐平台对比(price 单位元,已按 rank 升序) |
|
||||
| `skipped_dish_names` | string[] | 被跳过的菜名 |
|
||||
| `created_at` | datetime | 时间 |
|
||||
|
||||
## 错误
|
||||
- `401` 未鉴权
|
||||
|
||||
## 说明
|
||||
只返回当前登录用户自己的记录。需要单条全量(含 `raw_payload`)走 [详情接口](./compare-record-detail.md)。
|
||||
Reference in New Issue
Block a user