Merge remote-tracking branch 'origin/main' into feature/coupon-claim-integration

# Conflicts:
#	docs/api/README.md
This commit is contained in:
2026-05-28 10:33:35 +08:00
83 changed files with 5089 additions and 134 deletions
+3
View File
@@ -6,7 +6,10 @@
|---|---|
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 |
| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 |
| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 |
| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 |
| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 |
## api/ 目录是怎么组织的(传送门式)
+38 -1
View File
@@ -4,6 +4,7 @@
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-05-27
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -22,11 +23,40 @@
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
| **比价透传**(前缀 `/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/wallet` |||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) |
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) |
| **签到**(前缀 `/api/v1/signin` |||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
| **任务**(前缀 `/api/v1/tasks` |||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
| **省钱**(前缀 `/api/v1/savings` |||
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
| **看广告发奖**(前缀 `/api/v1/ad` |||
| 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) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。当前**所有业务接口均无鉴权**,只有 `/me`、`/logout` 需 Bearer。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
> 金额字段一律以**分**为单位(`*_cents`)。
---
@@ -42,6 +72,13 @@
- `502` 上游调用失败(极光验证/解密失败、美团接口失败)
- **接口文档(交互式)**:`APP_ENV` 非 prod 时开放 `GET /docs`Swagger UI)、`GET /redoc`;生产环境关闭。
### 游标分页约定
钱包流水 / 提现单 / 省钱明细等列表接口统一用**游标分页**:
- 请求 query:`limit`(每页条数,1100,默认 20)、`cursor`(上一页返回的 `next_cursor`,首页不传)。
- 响应:`{ "items": [...], "next_cursor": <int|null> }``next_cursor` 为**末条记录的 id**;为 `null` 表示已到底,无更多数据。
- `items``id` 倒序(最新在前)。
---
## 复用数据结构
+40
View File
@@ -0,0 +1,40 @@
# GET /api/v1/ad/pangle-callback — 穿山甲 GroMore 激励视频发奖回调(S2S)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](./README.md)
>
> ⚠️ 我们客户端用 `useMediation(true)`(GroMore 融合),回调走 **GroMore 广告位层级**(规范见 supportcenter/26240),**不是**联盟代码位层级(5416)。两者密钥、响应格式都不同,别混。后台配置入口:**GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」**(广告位层级配了就别再在代码位层级重复配,会冲突)。
>
> 集成实现:见 [integrations/pangle](../integrations/pangle.md)(验签算法、m-key 来源、设计动机)。
## 入参(query,由 GroMore 拼装)
GroMore 以 GET 回调,关键参数:
| 字段 | 类型 | 说明 |
|---|---|---|
| `user_id` | string | 客户端 `setUserID` 传入的用户标识(须为数字 = 本系统 user.id) |
| `trans_id` | string | 交易号(**幂等键** + **唯一参与签名的字段** |
| `reward_name` | string | 奖励名(广告位配置,入库备注) |
| `reward_amount` | int | 奖励数量(广告位配置)→ **本次发放金币**。缺/坏/≤0 回退 `AD_REWARD_COIN`,超 `MAX_AD_REWARD_COIN` 夹紧 |
| `extra` | string | 客户端透传的 customData(可空,入库备注) |
| `mediation_rit` | string | 代码位 IDGroMore 带,目前仅入 raw 备查) |
| `prime_rit` | string | 广告位 ID(同上) |
| `adn_name` | string | 实际出广告的 ADN 名(同上,可用于收益分析) |
| `ecpm` | string | 本次广告 eCPM(同上,可用于收益分析) |
| `sign` | string | 签名,见下 |
**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。算法细节、m-key 来源、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。
## 出参
响应 `200`,**响应体必须是 `{"is_verify": bool, "reason": int}`**GroMore 规范)。
| 字段 | 类型 | 说明 |
|---|---|---|
| `is_verify` | bool | `true`=校验通过、发放奖励(发奖成功 或 当日达上限,均算已处理、不重试) |
| `reason` | int | `is_verify=false` 时的错误码,透传客户端 SDK:`1`=参数缺/坏,`2`=user 不存在;成功为 `0` |
## 错误码
- `403` 验签失败(`bad sign`,留给真请求重试)
- `503` 回调未配置(`pangle_callback_configured=false`
## 说明
**发奖唯一可信入口**:验签 → 取 `user_id` → 按 `reward_amount` 解析金币 → 幂等发金币(按 `trans_id` 去重 + 每日上限)。客户端不参与发奖,被破解也刷不到钱。验签过但参数缺/坏或 user 不存在 → 不发(`is_verify=false` + `reason`;granted / capped → `is_verify=true` + `reason=0`
+19
View File
@@ -0,0 +1,19 @@
# GET /api/v1/ad/reward-status — 今日看广告发奖进度
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`AdRewardStatusOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `used_today` | int | 今日已成功发奖次数 |
| `daily_limit` | int | 每日发奖次数上限 |
| `remaining` | int | 今日剩余可领次数 |
| `coin_per_ad` | int | 看完一个激励视频发的金币 |
## 说明
福利页「看视频赚金币」用:展示「今日还能看 N 次」「看一次得 M 金币」。真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。
+27
View File
@@ -0,0 +1,27 @@
# POST /api/v1/ad/test-grant — [仅本地联调]模拟穿山甲回调发奖
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](./README.md)
>
> ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`TestGrantOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `granted` | bool | 本次是否真发了金币(达每日上限则 false) |
| `status` | string | `granted` / `capped`(达上限) |
| `coin` | int | 本次发放金币(capped 时 0 |
| `used_today` | int | 今日已成功发奖次数 |
| `daily_limit` | int | 每日发奖次数上限 |
| `remaining` | int | 今日剩余可领次数 |
| `coin_per_ad` | int | 看完一个激励视频发的金币 |
## 错误码
- `404` 开关未开(伪装不存在) / 用户不存在
## 说明
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限)。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
+3 -1
View File
@@ -1,6 +1,8 @@
# POST /api/v1/auth/jverify-login — 极光一键登录
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/jiguang](../integrations/jiguang.md)(极光核验链路、RSA 解密策略、私钥配对踩坑)。
## 入参
@@ -17,4 +19,4 @@
- `403` 账号被禁用
## 说明
后端 `login_token` 调极光 `loginTokenVerify` 验真 → 取回 RSA 加密的手机号 → 用私钥解密 → 注册即登录(手机号不存在则建号)→ 签发 JWT
后端验真 `login_token` 取回并解密手机号 → 注册即登录(手机号不存在则建号)→ 签发 JWT。极光核验 + RSA 解密的实现细节见 [integrations/jiguang](../integrations/jiguang.md)
+2
View File
@@ -1,6 +1,8 @@
# POST /api/v1/auth/sms/login — 手机号 + 验证码登录
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。
## 入参
+2
View File
@@ -1,6 +1,8 @@
# POST /api/v1/auth/sms/send — 发送短信验证码
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/sms](../integrations/sms.md)mock 模式、频控、接真供应商 TODO)。
## 入参
+2
View File
@@ -1,6 +1,8 @@
# POST /api/v1/meituan/coupons — 券列表 / 搜索
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权** | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)CPS S-Ca 签名、入参换算坑)。
## 入参
+2
View File
@@ -1,6 +1,8 @@
# POST /api/v1/meituan/feed — 首页混合推荐流
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权** | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)CPS S-Ca 签名、入参换算坑)。
## 入参
+2
View File
@@ -1,6 +1,8 @@
# POST /api/v1/meituan/referral-link — 换取推广链接
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权** | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)CPS S-Ca 签名、入参换算坑)。
## 入参
+18
View File
@@ -0,0 +1,18 @@
# GET /api/v1/savings/battle — 省钱战绩
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`SavingsBattleOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `week_saved_cents` | int | 本周已省(分) |
| `beat_percent` | int | 超过百分之多少用户 |
| `streak_days` | int | 连续省钱天数 |
## 说明
「我的」页「省钱战绩」卡数据源。接口已通但无真实业务写入,实际多为 0。
+29
View File
@@ -0,0 +1,29 @@
# GET /api/v1/savings/records — 省钱明细(游标分页)
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
## 出参
响应 `200`:`{ items: SavingsRecordOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
**SavingsRecordOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 记录 id(也是游标) |
| `order_amount_cents` | int | 订单金额(分) |
| `saved_amount_cents` | int | 本单省下(分) |
| `platform` | string \| null | 平台(美团/饿了么等) |
| `title` | string \| null | 标题 |
| `shop_name` | string \| null | 店铺名 |
| `dishes` | string[] | 菜品列表 |
| `created_at` | datetime | 时间 |
## 说明
订单/省钱明细页(`RECORDS`)数据源。
+18
View File
@@ -0,0 +1,18 @@
# GET /api/v1/savings/summary — 累计帮你省了
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`SavingsSummaryOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `total_saved_cents` | int | 累计省下(分) |
| `order_count` | int | 累计省钱订单数 |
| `avg_saved_cents` | int | 平均每单省(分) |
## 说明
「我的」页「累计帮你省了」卡数据源。当前比价上报未接入,实际多为 0。
+22
View File
@@ -0,0 +1,22 @@
# POST /api/v1/signin — 执行今日签到
> 所属:Signin 组(前缀 `/api/v1/signin`,本接口 POST 到前缀本身) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`SigninResultOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `coin_awarded` | int | 本次签到发放金币 |
| `cycle_day` | int | 本次签到落在循环第几天 |
| `streak` | int | 签到后的连续天数 |
| `coin_balance` | int | 签到后金币余额 |
## 错误码
- `409` 今日已签(`already signed today`
## 说明
发金币已计入 [wallet-account](./wallet-account.md) 的余额(客户端就地刷新即可,不必另叠)。
+29
View File
@@ -0,0 +1,29 @@
# GET /api/v1/signin/status — 今日签到状态 + 7 天档位
> 所属:Signin 组(前缀 `/api/v1/signin` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`SigninStatusOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `today_signed` | bool | 今日是否已签 |
| `consecutive_days` | int | 当前连续签到天数 |
| `today_cycle_day` | int | 今日处于循环内第几天(1..7) |
| `today_coin` | int | 今日签到可得金币 |
| `can_claim` | bool | 今日是否可领(= 未签) |
| `steps` | SigninStepOut[] | 7 天档位 |
**SigninStepOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `day` | int | 循环内第几天 1..7 |
| `coin` | int | 该档金币 |
| `status` | string | `claimed`(已领) / `today`(今日待领) / `locked`(未到) |
## 说明
福利页签到行 + 签到弹窗 7 档 timeline 数据源。
+25
View File
@@ -0,0 +1,25 @@
# POST /api/v1/tasks/{task_key}/claim — 领取任务奖励
> 所属:Tasks 组(前缀 `/api/v1/tasks` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参(path
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `task_key` | string | ✅ | 任务标识(见 [tasks-list](./tasks-list.md) 的 `task_key` |
## 出参
响应 `200`:`TaskClaimResultOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `task_key` | string | 任务标识 |
| `coin_awarded` | int | 本次发放金币 |
| `coin_balance` | int | 领奖后金币余额 |
## 错误码
- `404` 未知任务(`unknown task`
- `409` 任务已领取(`task already claimed`
## 说明
发奖端 gate 由后端把控(如「打开消息提醒」须客户端确认权限已开后才调)。金币已计入余额。
+20
View File
@@ -0,0 +1,20 @@
# GET /api/v1/tasks — 任务及领取状态
> 所属:Tasks 组(前缀 `/api/v1/tasks`,本接口 GET 前缀本身) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`TaskListOut` = `{ items: TaskOut[] }`
**TaskOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `task_key` | string | 任务标识,如 `enable_notification`(打开消息提醒) |
| `coin` | int | 完成可得金币 |
| `claimed` | bool | 是否已领取 |
## 说明
福利页一次性任务行(如「打开消息提醒」)的状态源。领取走 [tasks-claim](./tasks-claim.md)。
+18
View File
@@ -0,0 +1,18 @@
# GET /api/v1/wallet/account — 金币 + 现金余额(我的资产)
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`CoinAccountOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `coin_balance` | int | 当前金币余额 |
| `cash_balance_cents` | int | 当前现金余额(分) |
| `total_coin_earned` | int | 累计赚取金币 |
## 说明
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。
+28
View File
@@ -0,0 +1,28 @@
# POST /api/v1/wallet/bind-wechat — 微信授权 code 换 openid 并绑定
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | 限流:同 IP ≤10 次/分 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)code 换 openid / userinfo)。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `code` | string | ✅ | 微信授权返回的 code(客户端 OAuth 拿到) |
## 出参
响应 `200`:`BindWechatResultOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `bound` | bool | 固定 `true` |
| `wechat_nickname` | string \| null | 微信昵称 |
| `wechat_avatar_url` | string \| null | 微信头像 URL |
## 错误码
- `400` code 无效 / 换 openid 失败
- `409` 该微信已绑定其他账号
- `503` 微信支付未配置(`wxpay_configured=false`
## 说明
后端用 `code` 调微信 `jscode2session`(或 OAuth)换 `openid` 并写入 user。提现前置:必须先绑定。`openid` 全局唯一(DB 唯一约束),一个微信只能绑一个账号。
+28
View File
@@ -0,0 +1,28 @@
# GET /api/v1/wallet/cash-transactions — 现金流水(游标分页)
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
## 出参
响应 `200`:`{ items: CashTransactionOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
**CashTransactionOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 流水 id(也是游标) |
| `amount_cents` | int | 现金变动(分),正=兑入 负=提现 |
| `balance_after_cents` | int | 变动后现金余额(分) |
| `biz_type` | string | 业务类型(兑换/提现等) |
| `ref_id` | string \| null | 关联业务 id(如提现单号) |
| `remark` | string \| null | 备注 |
| `created_at` | datetime | 时间 |
## 说明
现金历史页(`CASH_HISTORY`)数据源。
+28
View File
@@ -0,0 +1,28 @@
# GET /api/v1/wallet/coin-transactions — 金币流水(游标分页)
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
## 出参
响应 `200`:`{ items: CoinTransactionOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
**CoinTransactionOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 流水 id(也是游标) |
| `amount` | int | 金币变动,正=入账 负=出账 |
| `balance_after` | int | 变动后余额 |
| `biz_type` | string | 业务类型(签到/任务/兑换等) |
| `ref_id` | string \| null | 关联业务 id |
| `remark` | string \| null | 备注 |
| `created_at` | datetime | 时间 |
## 说明
金币历史页(`COIN_HISTORY`)数据源。
+18
View File
@@ -0,0 +1,18 @@
# GET /api/v1/wallet/exchange-info — 金币兑现金 汇率/规则
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:**无**(返回静态配置,不读用户) | [← 返回 API 索引](./README.md)
## 入参
无。
## 出参
响应 `200`:`ExchangeInfoOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `coin_per_yuan` | int | 多少金币兑 1 元 |
| `min_coin` | int | 单次兑换最少金币 |
| `step_coin` | int | 兑换金币需为该值的整数倍(对齐整分) |
## 说明
给兑换页展示汇率与规则用。值来自 `app/core/rewards.py``COIN_PER_YUAN` / `MIN_EXCHANGE_COIN` / `COIN_PER_CENT`)。
+26
View File
@@ -0,0 +1,26 @@
# POST /api/v1/wallet/exchange — 金币兑现金
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `coin_amount` | int | ✅(>0) | 要兑换的金币数,须 ≥ `min_coin` 且为 `step_coin` 整数倍 |
## 出参
响应 `200`:`ExchangeResultOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `coin_amount` | int | 本次扣除的金币 |
| `cash_added_cents` | int | 本次兑入的现金(分) |
| `coin_balance` | int | 兑换后金币余额 |
| `cash_balance_cents` | int | 兑换后现金余额(分) |
## 错误码
- `400` `coin_amount` 不满足 ≥ `min_coin` 且为 `step_coin` 整数倍
- `409` 金币余额不足
## 说明
兑换规则见 [wallet-exchange-info](./wallet-exchange-info.md)。金币与现金的扣加在同一事务内完成。
+18
View File
@@ -0,0 +1,18 @@
# POST /api/v1/wallet/unbind-wechat — 解绑微信(清空 openid)
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)。
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`UnbindWechatResultOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `bound` | bool | 固定 `false` |
## 说明
清空当前用户的 `wechat_openid` / `wechat_nickname` / `wechat_avatar_url`。幂等:未绑定时调用也返回 `bound=false`。解绑后该微信可被其他账号绑定。
+22
View File
@@ -0,0 +1,22 @@
# GET /api/v1/wallet/withdraw-info — 提现额度 / 绑定状态
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(提现链路)。
## 入参
无(用户由 token 确定)。
## 出参
响应 `200`:`WithdrawInfoOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `min_cents` | int | 单次最低提现(分) |
| `max_cents` | int | 单次最高提现(分) |
| `wechat_bound` | bool | 是否已绑定微信 |
| `wechat_nickname` | string \| null | 微信昵称(可能脱敏/空) |
| `wechat_avatar_url` | string \| null | 微信头像 URL(可能空) |
## 说明
提现页进入时拉一次:决定展示额度区间、是否需先去绑定微信。额度来自 `rewards.py``WITHDRAW_MIN_CENTS` / `WITHDRAW_MAX_CENTS`)。
+28
View File
@@ -0,0 +1,28 @@
# GET /api/v1/wallet/withdraw-orders — 提现单列表(游标分页)
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
## 出参
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
**WithdrawOrderOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 单 id(也是游标) |
| `out_bill_no` | string | 商户提现单号 |
| `amount_cents` | int | 提现额(分) |
| `status` | string | `pending` / `success` / `failed` |
| `wechat_state` | string \| null | 微信侧原始状态 |
| `fail_reason` | string \| null | 失败原因 |
| `created_at` | datetime | 发起时间 |
## 说明
提现记录页数据源。
+27
View File
@@ -0,0 +1,27 @@
# GET /api/v1/wallet/withdraw/status — 查提现单状态(轮询)
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(查单 / 撤销转账)。
## 入参(query
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `out_bill_no` | string | ✅ | 商户提现单号([withdraw](./wallet-withdraw.md) 返回的) |
## 出参
响应 `200`:`WithdrawStatusOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `out_bill_no` | string | 商户提现单号 |
| `status` | string | `pending` / `success` / `failed` |
| `wechat_state` | string \| null | 微信侧原始状态 |
| `amount_cents` | int | 提现额(分) |
## 错误码
- `404` 提现单不存在(或不属于当前用户)
## 说明
App 从微信确认页返回后调此接口刷新单状态。**副作用**:若查时仍是 `WAIT_USER_CONFIRM`(用户在确认页放弃),后端会**撤销该单并退回余额**(`cancel_if_unconfirmed=True`)。
+36
View File
@@ -0,0 +1,36 @@
# POST /api/v1/wallet/withdraw — 发起提现到微信零钱
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | 限流:同 IP ≤20 次/分 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `amount_cents` | int | ✅(>0) | 提现金额(分),须落在 `[min_cents, max_cents]` |
| `user_name` | string | ❌ | 实名(达额时微信商家转账要求,可空) |
| `out_bill_no` | string | ❌ | **客户端幂等键(商户单号)**:同号重试不重复转账;不传则服务端生成 |
## 出参
响应 `200`:`WithdrawResultOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `out_bill_no` | string | 商户提现单号(查单用) |
| `status` | string | `pending` / `success` / `failed` |
| `wechat_state` | string \| null | 微信侧原始状态 |
| `amount_cents` | int | 本次提现额(分) |
| `cash_balance_cents` | int | 提现后现金余额(分) |
| `package_info` | string \| null | 拉起微信确认页用(`WXOpenBusinessView` requestMerchantTransfer |
| `mch_id` | string \| null | 商户号 |
| `app_id` | string \| null | 微信 AppID |
## 错误码
- `400` 金额越界 / 未绑定微信
- `409` 现金余额不足
- `502` 微信转账调用失败(余额已退回)
- `503` 微信支付未配置
## 说明
走微信「商家转账到零钱」V3。先扣余额建单再调微信,转账失败则退回余额。`out_bill_no` 幂等防止网络重试重复打款。返回的 `package_info`/`mch_id`/`app_id` 供 App 拉起微信确认页;确认结果由 [wallet-withdraw-status](./wallet-withdraw-status.md) 轮询。
+26
View File
@@ -0,0 +1,26 @@
# 集成层(integrations)文档 — 索引
> 本目录记录 `app/integrations/` 下各**第三方 SDK / 外部服务**的集成内部实现:签名、加解密、协议细节、配置项、踩坑。
> 与 [docs/api/](../api/README.md) 的区别:`docs/api/` 描述**对外 HTTP 接口契约**(入参/出参/错误码);本目录描述**接口背后调外部服务的重逻辑**。
## 架构约定
- **`app/api/v1/` 接口层只放很轻的东西**:解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码映射。
- **集成 SDK 的重逻辑(签名/验签/加解密/外部 HTTP 调用)一律放 `app/integrations/`**。换签名方案、换供应商只动这一层,api 层不受影响。
- 集成模块统一从 `app.core.config.settings` 读配置;密钥/证书**懒加载**(文件缺失不在 import 时炸进程,只在真正调用时抛各自的 `XxxError`)。
## 集成清单
| 模块 | 文件 | 用途 | 关联 API 文档 |
|---|---|---|---|
| 极光 jiguang | `app/integrations/jiguang.py` | 一键登录服务端核验 + RSA 解密手机号 | [auth-jverify-login](../api/auth-jverify-login.md) |
| 短信 sms | `app/integrations/sms.py` | 验证码发送 / 校验(**当前 mock** | [auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) |
| 美团 meituan | `app/integrations/meituan.py` | CPS 券查询 / 换推广链 | [meituan-coupons](../api/meituan-coupons.md) · [meituan-feed](../api/meituan-feed.md) · [meituan-referral-link](../api/meituan-referral-link.md) |
| 穿山甲 pangle | `app/integrations/pangle.py` | 激励视频发奖回调**验签** | [ad-pangle-callback](../api/ad-pangle-callback.md) |
| 微信支付 wxpay | `app/integrations/wxpay.py` | 商家转账到零钱(提现)+ code 换 openid(绑定) | [wallet-withdraw](../api/wallet-withdraw.md) 等 |
## 详情
- [极光一键登录 jiguang](./jiguang.md)
- [短信验证码 sms](./sms.md)
- [美团 CPS meituan](./meituan.md)
- [穿山甲发奖验签 pangle](./pangle.md)
- [微信支付 / 提现 wxpay](./wxpay.md)
+36
View File
@@ -0,0 +1,36 @@
# 极光一键登录 — 服务端核验 + RSA 解密手机号(jiguang
> 文件:`app/integrations/jiguang.py` | 关联接口:[auth-jverify-login](../api/auth-jverify-login.md) | [← 集成索引](./README.md)
## 作用
极光一键登录的服务端环节:拿客户端的 `loginToken` 调极光 REST 验真,取回 RSA 加密的手机号并解密成明文。
## 链路
```
Android SDK loginAuth → loginToken
→ 本服务 POST 极光 /v1/web/loginTokenVerify (Basic Auth = AppKey:MasterSecret)
→ 极光返回 RSA 公钥加密的手机号 (base64)
→ 用配套 RSA 私钥解密
→ 返回明文手机号
```
## 函数 / 异常
- `verify_and_get_phone(login_token)` —— 对外入口,验证 + 解密一把梭。
- 失败统一抛 `JiguangError`api 层 catch 后翻成 4xx/5xx(验证失败/解密失败 → `502`)。
- RSA 私钥**懒加载并缓存**`_private_key_cache`):文件缺失抛 `JiguangError`(让单请求失败 + 日志,而不是 import 时炸进程)。
## RSA 解密策略(关键)
极光文档没明说 padding。实测 PKCS1v15 套在 OAEP 密文上会"假成功"返回垃圾(`UnicodeDecodeError 0x8e`)。因此按 **OAEP-SHA1 → OAEP-SHA256 → PKCS1v15** 顺序尝试,谁能解出 **11 位纯数字手机号**就用谁;base64 先按 4 字节对齐补 `=`(各运营商可能返回不带 `=` 的 base64)。
## 配置
| 配置项 | 说明 |
|---|---|
| `JG_APP_KEY` / `JG_MASTER_SECRET` | 极光 AppKey / MasterSecret(拼 Basic Auth |
| `JG_PRIVATE_KEY_PATH` | RSA 私钥 .pem 路径 |
| `JG_VERIFY_ENDPOINT` | 极光验证接口 URL`/v1/web/loginTokenVerify` |
| `JG_REQUEST_TIMEOUT_SEC` | 请求超时 |
## 踩坑
- **私钥必须与极光控制台为该 appkey 登记的公钥配对**,否则三种 padding 全失败、报 `all RSA paddings failed; last error: Decryption failed`。私钥由相关开发同学生成提供。
- **换私钥后必须重启后端**——`_private_key_cache` 会缓存旧 key。
- 极光验证响应 `code != 8000` 视为失败;缺 `phone` 字段也失败。
+37
View File
@@ -0,0 +1,37 @@
# 美团联盟 CPS 开放接口(meituan
> 文件:`app/integrations/meituan.py` | 关联接口:[meituan-coupons](../api/meituan-coupons.md) · [meituan-feed](../api/meituan-feed.md) · [meituan-referral-link](../api/meituan-referral-link.md) | [← 集成索引](./README.md)
## 作用
调美团联盟 CPS 开放接口:查券(列表/搜索)+ 换推广链。
## 函数 / 异常
| 函数 | 说明 |
|---|---|
| `query_coupon(...)` | 券查询/搜索 → `/cps_open/common/api/v1/query_coupon` |
| `get_referral_link(...)` | 换推广链 → `/cps_open/common/api/v1/get_referral_link` |
| 异常 `MeituanCpsError` | HTTP 错误 / 业务 `code != 0`api 层翻成 `502` |
## 签名机制(关键)
类阿里云网关 **S-Ca** 头,但 `stringToSign` **只有 4 段**
```
METHOD\n + Content-MD5\n + Headers(按 key 升序 "k:v\n") + Url
```
**没有 Accept / Content-Type / Date 行**(加了就签名失败)。签名 = HMAC-SHA256(stringToSign, AppSecret) 的 base64。参与签名的头:`S-Ca-Timestamp,S-Ca-App`(在 `S-Ca-Signature-Headers` 里声明)。
## 入参换算 / 坑
- 经纬度 **× 1_000_000 取整**传给美团。
- 榜单字段是 **`listTopiId`**(美团接口的拼写,少个 `c`,别"纠正"成 `listTopicId`)。
-`searchText` 时走搜索、`sort_field` 默认 6(离我最近);否则按 `listTopiId` 出榜单。
- 换链 `sid` 不传则用 `MT_CPS_DEFAULT_SID``linkTypeList` 默认 `[1,3]`
## 配置
| 配置项 | 说明 |
|---|---|
| `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` | CPS 网关 AppKey / AppSecret |
| `MT_CPS_HOST` | 网关 host |
| `MT_CPS_DEFAULT_SID` | 默认推广位 sid(渠道) |
| `MT_CPS_TIMEOUT_SEC` | 请求超时 |
## 备注
美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」。
+29
View File
@@ -0,0 +1,29 @@
# 穿山甲激励视频发奖回调 — 验签(pangle)
> 文件:`app/integrations/pangle.py` | 关联接口:[ad-pangle-callback](../api/ad-pangle-callback.md) | [← 集成索引](./README.md)
## 作用
穿山甲激励视频播完后,穿山甲**服务器**会 GET 回调我们的 `/api/v1/ad/pangle-callback`(带 `user_id` / `trans_id` / `sign` 等 query)。本模块负责**验签**,确认回调真来自穿山甲而非伪造请求;验签通过后由数据层(`repositories/ad_reward`)幂等发金币。客户端不参与发奖,被破解也刷不到钱。
## 验签方案(关键)
**`sign = SHA256("{m-key}:{trans_id}")` 的十六进制串。**
- 是**普通 SHA256**,不是 HMAC,也不是 RSA。
- **只对 `m-key:trans_id` 这一个字符串**签名,`user_id` / `reward_amount` 等其余参数**不参与**签名。它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 连合法 sign 都造不出,自然无法注入假 `user_id`
- 这是穿山甲 **GroMore「广告位 → 服务端激励回调」官方规范**supportcenter/26240)。我们客户端是 `useMediation(true)` 融合,回调走 **GroMore 广告位层级**,不是联盟代码位层级。
## 函数
| 函数 | 说明 |
|---|---|
| `build_sign(trans_id, secret) -> str` | 计算 `SHA256("{secret}:{trans_id}")` hex。自验签测试 / 模拟回调脚本共用,保证两端一致 |
| `verify_callback_sign(params, secret) -> bool` | 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 |
## 配置
| 配置项 | 说明 |
|---|---|
| `PANGLE_REWARD_SECRET` | m-key(安全密钥)。后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取 |
## 踩坑
- **别用联盟代码位那套**:联盟代码位层级用的是另一套 Security Key + `isValid` 响应体,与 GroMore 广告位层级不通用。我们走 GroMore,别接错。
- 验签失败时 api 层返 `403`(留给真请求重试);验签过但参数缺/坏或 user 不存在,则受理不发奖、不让穿山甲重试。
- 本地无公网、回调打不到时,用 [ad-test-grant](../api/ad-test-grant.md) 模拟(仅联调,开关控制)。
+24
View File
@@ -0,0 +1,24 @@
# 短信验证码(sms
> 文件:`app/integrations/sms.py` | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md)
## 作用
手机号 + 验证码登录的验证码发送 / 校验。**当前是 mock 模式**(占坑),未接真实短信供应商。
## 函数 / 异常
| 函数 | 行为 |
|---|---|
| `send_code(phone) -> int` | mock 下不真发、只 log;返回距下次可发的秒数。进程内存表 `_last_sent``phone → 发送时间``SMS_SEND_INTERVAL_SEC` 内再发抛 `SmsError`(发送过频)。真供应商未接时抛 `SmsError("sms provider not configured")` |
| `verify_code(phone, code) -> bool` | mock 下**任意 6 位数字**均通过(配合前台 demo);真模式未实现返回 `False` |
| 异常 `SmsError` | 发送过频 / 验证码不对,api 层 catch 后翻成 4xx(过频 `429` |
## 配置
| 配置项 | 说明 |
|---|---|
| `SMS_MOCK` | 是否 mock(当前 `true` |
| `SMS_SEND_INTERVAL_SEC` | 同号两次发送最小间隔(防过频) |
## 后续(接真实供应商时)
- `send_code()` 改调供应商 API(阿里云 / 腾讯云),把生成的 6 位码存 cacheredis / sqlite)。
- `verify_code()` 比对 cache 里的码,且**验过即作废**。
- 进程内存方案占坑期够用;切真实供应商时一并切 redis(多进程/多机时内存表不共享,频控会失效)。
+37
View File
@@ -0,0 +1,37 @@
# 微信支付 V3 — 商家转账到零钱(提现)+ code 换 openidwxpay
> 文件:`app/integrations/wxpay.py` | 关联接口:[wallet-withdraw](../api/wallet-withdraw.md) · [wallet-withdraw-status](../api/wallet-withdraw-status.md) · [wallet-bind-wechat](../api/wallet-bind-wechat.md) · [wallet-withdraw-info](../api/wallet-withdraw-info.md) | [← 集成索引](./README.md)
## 作用
两件事:①**提现**——微信支付 V3「商家转账到零钱」;②**绑定**——微信授权 code 换 openid / 昵称头像。移植自 elderhelper,密钥**懒加载**(文件缺失不在 import 时炸,只在真正调用时抛 `WxPayNotConfiguredError`,与极光私钥同款策略)。
## 函数
| 函数 | 说明 |
|---|---|
| `create_transfer(openid, amount_fen, out_bill_no, user_name=None)` | 发起商家转账到零钱。返回 `{status_code, data}` |
| `query_transfer(out_bill_no)` | 按商户单号查转账单状态 |
| `cancel_transfer(out_bill_no)` | 撤销转账单(仅 `WAIT_USER_CONFIRM`/`ACCEPTED` 可撤)。用户没在确认页确认就离开时调 |
| `encrypt_sensitive(plain)` | 用微信支付平台公钥 OAEP(SHA1) 加密敏感信息(实名) |
| `code_to_openid(code)` | code 换 openid`sns/oauth2`),失败抛 `ValueError` |
| `code_to_userinfo(code)` | code 换 openid + 拉昵称头像,返回 `{openid, nickname, avatar_url, raw}` |
## 签名机制
- 请求签名:**商户 API 私钥** RSA-SHA256/PKCS1v15,构造 V3 `Authorization` 头(`WECHATPAY2-SHA256-RSA2048`,含 mchid/nonce/timestamp/serial_no/signature)。
- 敏感字段(实名):微信支付**平台公钥** OAEP(SHA1) 加密,请求头带 `Wechatpay-Serial`
- 实名传递阈值:`amount_fen >= 30`(沿用 elderhelper,实际以微信侧要求为准)才加密带 `user_name`
- 转账接口路径:`/v3/fund-app/mch-transfer/transfer-bills`host `https://api.mch.weixin.qq.com`
## 配置
| 配置项 | 说明 |
|---|---|
| `WECHAT_APP_ID` / `WECHAT_APP_SECRET` | 微信开放平台移动应用 appid / secret(换 openid 用) |
| `WXPAY_MCH_ID` / `WXPAY_MCH_SERIAL_NO` | 商户号 / 商户证书序列号 |
| `WXPAY_MCH_PRIVATE_KEY_PATH` | 商户 API 私钥 .pem 路径 |
| `WXPAY_PUBLIC_KEY_PATH` / `WXPAY_PUBLIC_KEY_ID` | 微信支付平台公钥 .pem 路径 / 公钥 ID |
| `WXPAY_TRANSFER_SCENE_ID` | 转账场景 ID |
| `WXPAY_REQUEST_TIMEOUT_SEC` | 请求超时 |
## 踩坑
- **userinfo 可能脱敏**:微信隐私新政下 `sns/userinfo` 可能返回昵称「微信用户」/灰头像,`nickname`/`avatar_url` 可能为空,调用方需兜底(绑定不因此失败,openid 已拿到)。
- `cancel_transfer` 是提现「轮询查单时若仍 `WAIT_USER_CONFIRM`」的退款依据(见 [wallet-withdraw-status](../api/wallet-withdraw-status.md) 的副作用)。
- 凭证/证书未配置时所有转账类调用抛 `WxPayNotConfiguredError`api 层翻成 `503`
+43 -18
View File
@@ -41,44 +41,69 @@
## 3. 分层架构与目录结构
标准 FastAPI 分层,一个请求自上而下穿过:**api(薄,收发) → integrations(外部) / repositories(数据) → models / db**。
标准 FastAPI 分层,一个请求自上而下穿过:**api(薄,收发) → integrations(外部 SDK) / repositories(数据) → models / db**。重逻辑(SDK 签名/加解密/外部 HTTP)落 `integrations`,集成层细节见 [docs/integrations/](./integrations/README.md)。
```
app/
├── main.py # FastAPI 入口:注册 3 个 router、CORS、/health、lifespan
├── main.py # FastAPI 入口:注册全部 router、CORS、/health、lifespan
├── api/
│ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session)
│ └── v1/
│ ├── auth.py # 登录 6 端点
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,唯一需鉴权的业务接口)
── meituan.py # 美团 3 端点 + feed 拼接逻辑(_interleave / _TOPIC_ROUNDS)
│ └── v1/ # 接口层(薄):解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码
│ ├── auth.py # 登录 6 端点(极光一键登录 / 短信 send+login / refresh / me / logout)
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot)
── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS)
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
│ ├── signin.py # 签到 2 端点(状态 / 执行签到)
│ ├── tasks.py # 一次性任务 2 端点(列表 / 领取)
│ ├── savings.py # 省钱 3 端点(汇总 / 战绩 / 明细)
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度 / 联调发奖)
├── schemas/ # Pydantic:API 收发的数据契约(与客户端对齐字段看这里)
│ ├── auth.py
── meituan.py
├── integrations/ # 外部服务客户端(2026-05 从 core 拆出)
── meituan.py
│ ├── welfare.py # 钱包/签到/任务/省钱 收发模型
│ └── ad.py # 看广告发奖收发模型
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
│ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
│ ├── meituan.py # 美团 CPS 网关签名 + query_coupon / get_referral_link
── sms.py # 短信验证码(mock,进程内存冷却表)
├── core/ # 基础设施(仅保留)
── sms.py # 短信验证码(mock,进程内存冷却表)
│ ├── pangle.py # 穿山甲激励视频发奖回调验签(SHA256,2026-05 从 core 移入)
│ └── wxpay.py # 微信支付 V3 商家转账(提现)+ code 换 openid(2026-05 从 core 移入)
├── core/ # 基础设施(无外部业务集成)
│ ├── config.py # pydantic-settings
│ ├── security.py # JWT 签发/校验
│ ├── ratelimit.py # 同 IP 滑动窗口限流依赖
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算
│ └── logging.py
├── repositories/ # 数据访问(2026-05 由 crud 改名)
── user.py # get_user_by_id / get_user_by_phone / upsert_user_for_login
├── models/user.py # ORM:user 表结构
├── repositories/ # 数据访问 + 事务(早期叫 crud,2026-05 统一并入此目录)
── user.py # get_user_by_id / get_user_by_phone / upsert_user_for_login
│ ├── wallet.py # 账户/流水/兑换/提现单(调 integrations/wxpay)
│ ├── signin.py # 签到记录 / 连续天数 / 档位
│ ├── task.py # 一次性任务领取
│ ├── savings.py # 省钱汇总 / 战绩 / 明细
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限)
├── models/ # ORM 表结构
│ ├── user.py # user(含微信 openid/nickname/avatar)
│ ├── wallet.py # 金币账户 / 金币流水 / 现金流水 / 提现单
│ ├── signin.py # 签到记录
│ ├── task.py # 任务领取记录
│ ├── savings.py # 省钱明细 / 店铺菜品
│ └── ad_reward.py # 看广告发奖记录
└── db/
├── base.py # DeclarativeBase
└── session.py # engine + get_db
alembic/ # 数据库迁移(versions/ 目前仅 init_user_table)
alembic/ # 数据库迁移(versions/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
deploy/ # systemd(.service) + nginx(.conf)
secrets/ # 极光 RSA 私钥(不入 git,仅 .gitkeep 占位)
tests/ # pytest(conftest + test_auth + test_health)
run.sh # 本地启动脚本
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git,仅 .gitkeep 占位)
scripts/ # 运维脚本(migrate 迁移 / 对账 / 重置签到 / 重置福利 / 模拟穿山甲回调)
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy)
run.sh # 本地启动脚本(自动先跑迁移再起服务)
docs/api/ # API 接口文档(索引 README + 一接口一文件)
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
```
> **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部集成、`repositories` 装数据访问,与 `core`(基础设施)分离。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
> **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问`core`基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
---
+88
View File
@@ -0,0 +1,88 @@
# 数据库迁移指南(Alembic
> 本项目用 **Alembic** 管理数据库表结构。所有"建表/改表"都写成 `alembic/versions/` 下的迁移脚本,
> 数据库的真实结构 = 把这些迁移按顺序跑一遍的结果。**不要手动改库结构**,一律走迁移。
---
## 一句话
**clone 项目 / 拉到新迁移后,跑一条命令即可:**
```bash
alembic upgrade head
```
它会把数据库结构升到最新(幂等,已是最新则什么都不做)。
---
## 1. 首次初始化(clone 后)
```bash
# (1) 装依赖(在你的虚拟环境里)
pip install -e .
# (2) 配 .env(从模板复制,至少填 JWT_SECRET_KEY)
cp .env.example .env
# (3) SQLite 默认库需要 data/ 目录存在
mkdir -p data
# (4) 跑迁移建表 —— 关键
alembic upgrade head
# (5) 启动(run.sh 会自动重跑 3+4,所以平时直接 ./run.sh 也行)
./run.sh
```
> 也可以直接 `bash scripts/migrate.sh` 只做迁移、不启服务(部署/CI 用)。
## 2. 数据库地址从哪来
-`settings.DATABASE_URL` 决定(`app/core/config.py`),可在 `.env``DATABASE_URL=` 覆盖。
- 默认 **SQLite**`sqlite:///./data/app.db`(所以要先 `mkdir -p data`)。
-**PostgreSQL**`.env` 里设 `DATABASE_URL=postgresql+psycopg://user:pwd@host:5432/dbname`,再 `alembic upgrade head` 即可(迁移脚本用了 `render_as_batch` 兼容 SQLitePostgres 也能跑)。
- `alembic.ini``sqlalchemy.url` 故意留空,由 `alembic/env.py``settings` 注入,**别在 alembic.ini 里填库地址**。
## 3. 常用命令
| 命令 | 作用 |
|---|---|
| `alembic upgrade head` | 升到最新(最常用) |
| `alembic current` | 看当前库在哪个 revision |
| `alembic history` | 看迁移链(谁接谁) |
| `alembic heads` | 看有几个 head(正常只 1 个;出现多个 = 分叉了要 merge) |
| `alembic downgrade -1` | 回滚一步(**生产慎用**,会丢数据) |
| `alembic downgrade <revision>` | 回滚到指定版本 |
## 4. 改了 ORM model、要新增一次结构变更
```bash
# 1) 自动对比 models 与库,生成迁移草稿
alembic revision --autogenerate -m "add xxx index"
# 2) **务必打开生成的文件 review**(autogenerate 不完美,索引/默认值/数据迁移常需手改)
# 3) 应用
alembic upgrade head
```
---
## 5. 关于迁移文件名(重要)
- 文件名是 `<描述>.py`(如 `welfare_tables_coin_account_coin_txn.py`)。**早期带的 hex 前缀已去掉,只是文件名变干净**。
- **真正的版本 id 是文件内的 `revision` 变量**(如 `revision = '357520ea3015'`),迁移链靠文件内的 `down_revision` 串起来,**与文件名无关**。数据库 `alembic_version` 表里存的也是这个内部 id。
- 因此:**可以随意重命名迁移文件**(Alembic 靠扫描文件、读 `revision` 识别),但 **绝不要改文件内的 `revision` / `down_revision`**——改了会和已迁移过的库(含生产)对不上,报"找不到 revision"。
- ⚠️ `alembic revision --autogenerate` **默认仍生成带 hex 前缀的文件名**。想保持本目录"无前缀"风格,二选一:
- 生成后**手动把文件名前缀去掉**(内部 id 别动);或
-`alembic.ini``file_template = %%(slug)s`(注意 ini 里 `%` 要写成 `%%`)让以后直接生成无前缀文件名(缺点:同名 slug 会冲突,描述写具体点)。
## 6. 当前迁移链(9 条)
```
init_user_table ← 起点(down_revision=None)
→ welfare_tables_coin_account_coin_txn 金币账户/金币流水/签到/任务表
→ cash_transaction_table 现金流水表
→ savings_record_table 省钱明细表
→ withdraw_order_and_user_openid 提现单表 + user.wechat_openid
→ user_wechat_nickname_avatar user 微信昵称/头像字段
→ unique_wechat_openid user.wechat_openid 唯一约束
→ savings_shop_dishes 省钱明细 shop_name + dishes
→ ad_reward_record 看广告发奖记录表 (head,最新)
```
> 顺序以 `alembic history` 输出为准(按 `down_revision` 链,不是文件名字母序)。
## 7. 踩坑
- **SQLite 找不到目录**:报 `unable to open database file` → 先 `mkdir -p data`
- **多个 head**`alembic heads` 出现 2 个 → 有人并行各加了一条迁移分叉,需 `alembic merge -m "merge" <head1> <head2>` 合并。
- **生产库**:不要随意 `downgrade`;新迁移先在本地/测试库验证过再上。生产 `DATABASE_URL` 由部署侧配置,不在本仓库 `.env`
+80
View File
@@ -0,0 +1,80 @@
# 「看广告赚金币」上线 Checklist
> 跨两个仓库:`shaguabijia-app-server`(后端发奖)+ `shaguabijia-app-android`(客户端看广告)。
> 截至 2026-05-26:**本地 + debug 包已端到端验证通过**(出广告 → 发奖 → 金币到账 → 明细显示「看广告奖励」),
> 但**尚未在生产上线**。本文件记录上线前必须做的事,尤其是一堆"仅本地/debug 用、上线前必须清理"的脚手架。
## 设计要点(别改坏)
- **发奖只走服务端**:激励视频播完,穿山甲服务器 S2S 回调后端 `/api/v1/ad/pangle-callback` 发金币。
客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。
- 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。
- 关键常量:`app/core/rewards.py``AD_REWARD_COIN=100``DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)。
---
## A. ⚠️ 上线前必须清理的"测试/调试专用"脚手架
这些是为了"没部署公网也能本地验证"加的,**带到生产 = 体验差 / 绕过反作弊 / 引入废弃工具**,务必清理。
### A1. 后端:本地模拟发奖接口
- [ ] `.env` 删除 / 设为 false:`AD_REWARD_TEST_GRANT_ENABLED=false`(默认就是 false;它一开,已登录客户端就能自助发奖)
- [ ] (可选)确认生产 `.env` 没这行;`/api/v1/ad/test-grant` 在 false 时返回 404
- 涉及:`app/api/v1/ad.py``test_grant``app/core/config.py``AD_REWARD_TEST_GRANT_ENABLED`
### A2. 客户端:test-grant 调用
- [ ] `WelfareViewModel.onAdRewardEarned()``if (BuildConfig.DEBUG) { repo.adTestGrant() }` —— release 包(`BuildConfig.DEBUG=false`)本就跳过,**确认 release 构建不含该调用即可**(结构上已隔离,无需改码;打 release 包验一次)
- 涉及:`WelfareViewModel.kt``WelfareRepository.adTestGrant()``WelfareApi.adTestGrant``TestGrantDto`(留着无害,只要 release 不调用 + 后端 404)
### A3. 客户端:穿山甲测试工具(广告预览)
- [ ] `app/build.gradle.kts`:移除 `debugImplementation(files("libs/tools-release.aar"))`
- [ ] 删除 `app/libs/tools-release.aar`
- [ ] `gradle.properties`:移除 `android.enableJetifier=true`(仅为兼容 tools-release.aar 的旧 support 库而开,Jetifier 已废弃)
- [ ] 删除 `app/src/debug/.../ad/TestToolLauncher.kt` + `app/src/release/.../ad/TestToolLauncher.kt`(空桩)
- [ ] `WelfareScreen.kt`:移除「【调试】唤起穿山甲测试工具」入口(`if (BuildConfig.DEBUG){...}` 那段)+ `TestToolLauncher` import
- 注:这些都 debug-only,不影响 release 功能;清掉是为了不留废弃依赖
---
## B. 生产真正发奖链路(S2S)—— 阻塞于穿山甲后台
> 已确认走 **GroMore 广告位层级**回调(客户端 useMediation(true);规范 supportcenter/26240),
> **不是**联盟代码位层级(5416)。验签算法 / 响应格式 / reward_amount 解析**代码已按 GroMore 规范实现**
> (2026-05-27),剩下的是后台配置 + 填密钥。
- [ ] **GroMore 后台配回调**:GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」→
回调 URL 填 `https://app-api.shaguabijia.com/api/v1/ad/pangle-callback`(用域名,别用 IP)。
⚠️ 广告位层级配了就**别再在代码位层级重复配**(会导致发奖出问题)。
- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取,填到生产 `.env``PANGLE_REWARD_SECRET`
(注:这是 GroMore 广告位的 m-key,**不是**联盟代码位那个 Security Key。)
- [x] ~~换验签~~:`app/integrations/pangle.py` 已实现 `sign = SHA256("{m-key}:{trans_id}")`(GroMore 真实算法)。
- [x] ~~响应格式~~:已返回 GroMore 要求的 `{"is_verify": bool, "reason": int}`
- [x] ~~reward_amount~~:回调按 `reward_amount` 发金币(`rewards.resolve_ad_reward_coin`,带回退/夹紧);
**后台广告位"奖励数量"须配成与 `AD_REWARD_COIN`(=100)一致**,保证"广告内展示/进度预告/到账"三者一致。
- [x] ~~透传 user_id~~:客户端已 `setUserID(userId)` + `setMediaExtra("uid:...")`
- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + `PANGLE_REWARD_SECRET=<m-key>`(配齐才不返 503)
## C. 部署 + 包名
- [ ] **后端部署到公网**(由服务器管理员;`/opt/shaguabijia-app-server`,uvicorn 127.0.0.1:8770,nginx 反代)
- [ ] **跑迁移**:`alembic upgrade head`(建 `ad_reward_record` 表,迁移 `c8d9e0f1a2b3`)
- [ ] **包名定稿**:当前 `com.jishisongfu.shaguabijia`。穿山甲(APP_ID 5830519)、极光、微信都绑"包名 + 签名",定了再上,别再换
- 微信提现链路当前因复用 elderhelper 的 appid + 包名切换已 dead,要恢复需申请傻瓜比价自己的微信 appid(另见客户端 build.gradle 注释)
- [ ] (可选,提升真实填充)集成 **MSA OAID SDK**:申请证书(绑包名、审核几天)。App 侧当前 `getDevOaid=null`,有 OAID 后投放匹配 + 填充会明显改善
## D. 提现对账(顺带,与本功能独立但同属上线必做)
- [ ] 生产给 `scripts/reconcile_withdraws.py`**cron / systemd timer**(每 5~10 分钟跑一次)
- 否则"扣款后微信无回调"的孤儿提现单没人兜底退款(客户端已加 ON_RESUME 无条件查单,但 cron 是最后防线)
---
## E. 端到端验收(生产链路通后)
- [ ] 真机看完一条**真实**激励视频 → 穿山甲 S2S 回调 → 后端发奖 → 客户端余额真到账
- [ ] **幂等**:同一 `trans_id` 不重复发
- [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」
- [ ] 中途退出广告 → 客户端提示「未看完视频,本次没有奖励哦」,且不发奖
- [ ] 收益明细显示「看广告奖励」(biz_type=`ad_reward`)
## 关键阻塞链
B(拿验签方案/配回调)→ E(端到端验收);A(清理)可随时做;C(部署)与 B 并行。