Compare commits

..

18 Commits

Author SHA1 Message Date
OuYingJun1024 9a2599bc3a docs: README 翻新到当前结构 + 上线清单改中文名 + 文档库导航补全
- README.md 翻新: 从 v0.1.0 登录模块版更新到当前(钱包/提现/签到/省钱/看广告/CPS);
  目录结构按现状重写(integrations 含 pangle/wxpay、repositories 含全部数据访问、
  core 含 ratelimit/rewards);API 改为按组速览 + 链 docs/api;建表步骤改为
  alembic upgrade head(原步骤是过时的 autogenerate init);部署补微信支付证书同步
- docs/ad_reward_golive_checklist.md → docs/看广告赚金币上线清单.md(改中文名)
- docs/README.md 文档库导航补 integrations/ + 数据库迁移.md + 看广告上线清单

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:10:44 +08:00
OuYingJun1024 c61997b876 docs: 新增数据库迁移指南 + 迁移脚本,同步目录结构文档
- 新增 docs/数据库迁移.md: clone 后如何建表(alembic upgrade head)、日常升级、
  新增迁移、迁移文件名 vs revision id 的关系与去前缀注意事项、当前 9 条迁移链
- 新增 scripts/migrate.sh: 只做迁移不启服务(部署/CI 用),run.sh 已自动包含
- 后端技术实现.md 目录树: crud 并入 repositories、alembic 文件名去 hex 说明、
  补 docs/数据库迁移.md;接口层"调 repositories/integration"措辞同步
- README.md / docs/integrations 残留 crud 提法同步为 repositories

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:04:25 +08:00
OuYingJun1024 a4d214964a refactor: crud 并入 repositories + alembic 迁移文件去 hex 前缀
- 数据访问层统一: app/crud/{ad_reward,savings,signin,task,wallet}.py 移入
  app/repositories/(与 user.py 同目录),删除 crud/;更新 10 处 import
  (api/v1/* + scripts/* + repositories 内部交叉引用 app.crud→app.repositories)
- alembic/versions 9 个迁移文件去掉 hex 前缀,改成可读文件名(如
  welfare_tables_coin_account_coin_txn.py);**仅重命名文件**,文件内
  revision/down_revision 不动 → 迁移链与已迁移库的 alembic_version 不受影响
  (alembic heads/history 验证链完好,单一 head c8d9e0f1a2b3)
- 测试: 37 passed(1 个 coupon 代理失败为连不到 pricebot 上游的环境问题,与本次无关)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:04:03 +08:00
OuYingJun1024 3d1e8969e6 docs: 新建 docs/integrations 集成层文档 + api 文档变薄
- 新增 docs/integrations/(README + jiguang/sms/meituan/pangle/wxpay 各一篇):
  记录各 SDK 集成的签名/验签/加解密/协议细节与踩坑(对齐 app/integrations 分层)
- docs/api 接口文档变薄: 受影响接口头部加「集成实现」链接,把验签/RSA/CPS 签名
  等重逻辑描述移到集成文档;ad-pangle-callback 验签段精简为指针
- docs/api/README 补架构说明(api 薄 / 重逻辑在 integrations)
- 后端技术实现.md 目录树补全(福利/钱包/签到/省钱/发奖端点 + crud/integrations 现状),
  pangle/wxpay 标注移入 integrations
- ad_reward_golive_checklist 路径同步 core→integrations

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:45:12 +08:00
OuYingJun1024 6f2731ee2d refactor(integrations): 穿山甲验签改 GroMore SHA256 + pangle/wxpay 从 core 移入 integrations
- 验签: pangle.build_sign/verify_callback_sign 改为 GroMore 官方
  sign=SHA256("{m-key}:{trans_id}")(普通 SHA256,只签 trans_id);
  回调响应改成 GroMore 要求的 {is_verify, reason}
- 发奖: 按回调 reward_amount 发金币(rewards 带回退/夹紧),ad_reward 幂等 + 每日上限
- 分层: app/core/{pangle,wxpay}.py → app/integrations/(对齐 jiguang/meituan/sms);
  更新 ad.py / crud/wallet.py / scripts/sim_pangle_callback.py / 测试的 import
  (含 test_withdraw 的 monkeypatch 路径 app.core.wxpay→app.integrations.wxpay)
- 测试: test_ad_reward + test_withdraw 共 17 项通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:44:49 +08:00
OuYingJun1024 18ab7eab92 docs(api): 补全福利/钱包/提现/签到/任务/省钱/看广告 21 个接口文档
按 docs/api 一接口一文件约定新增:
- 钱包(11): account / coin-transactions / cash-transactions / exchange-info / exchange / bind-wechat / unbind-wechat / withdraw-info / withdraw / withdraw/status / withdraw-orders
- 签到(2): status / 执行签到
- 任务(2): list / {task_key}/claim
- 省钱(3): summary / battle / records
- 看广告发奖(3): pangle-callback(S2S验签) / reward-status / test-grant(仅联调)
- README 索引补 12–32 行 + 新增「游标分页约定」+ 修正鉴权说明 + 注明金额以分为单位

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 17:50:01 +08:00
OuYingJun1024 0722d7b0d5 feat: 福利钱包+签到+省钱战绩+激励广告发奖+微信提现 后端
- 福利钱包: 金币/现金余额、流水、兑换 (api/v1/wallet.py, crud/wallet.py, models/wallet.py, schemas/welfare.py)
- 每日签到: 连续天数 + 档位奖励 (api/v1/signin.py, crud/signin.py, models/signin.py)
- 任务系统: "打开消息提醒"等任务领奖 (api/v1/tasks.py, crud/task.py, models/task.py)
- 省钱战绩: 省钱汇总/战绩/店铺菜品 (api/v1/savings.py, crud/savings.py, models/savings.py)
- 激励广告发奖: 穿山甲服务端回调 + 发奖规则 + 限流 (api/v1/ad.py, core/pangle.py, core/rewards.py, core/ratelimit.py, crud/ad_reward.py, schemas/ad.py, docs/ad_reward_golive_checklist.md)
- 微信提现: 商家转账到零钱 V3 (core/wxpay.py); user 表加微信 openid/nickname/avatar
- DB 迁移: 8 个 alembic (welfare 表/cash_transaction/openid 唯一约束/savings 店铺菜品/savings_record/ad_reward/withdraw_order+openid/user 微信字段)
- 运维脚本: reconcile_withdraws(对账) + reset_signin/reset_welfare + sim_pangle_callback(模拟穿山甲回调)
- 测试: test_welfare / test_withdraw / test_ad_reward
- 配置: .env.example + config.py 新增福利/广告/微信支付项; main.py 挂 5 个新路由

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 17:38:08 +08:00
marco 236907dcd2 Merge pull request 'feat(coupon): add /api/v1/coupon/step proxy to pricebot' (#1) from feature/coupon-claim-integration into main
Reviewed-on: #1
2026-05-27 16:51:47 +08:00
marco 007f8a0eb2 feat: coupon/step 透传去鉴权(MVP) + 技术债账本补充领券落地遗留
- /api/v1/coupon/step 去掉 CurrentUser 鉴权:MVP 领券不鉴权,device_id 透传给
  pricebot 区分设备;user_id 绑定 + 用户级画像见账本 P1,待补
- docs/待办与技术债.md 补「阶段1领券落地遗留」:FloatingButton 死代码、ComparingScreen
  弃用、ApiClient unused 常量、权限引导最简版等待清理项

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:38:10 +08:00
marco a188c43bf7 技术债文档 2026-05-27 12:26:58 +08:00
marco a07361007f docs: 新增 docs/README.md 文档库导航(说明 api/ 传送门结构)
- docs/README.md: 说明文档库结构(后端技术实现.md + api/), 重点讲 api/ 的
  "索引 + 一接口一文件"传送门组织方式, 及新增接口时的维护约定

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:13:58 +08:00
marco a2263b48a0 docs: API 文档拆分为 docs/api/(索引+一接口一文件), 补全遗漏的 coupon/step
- 单文件 docs/API.md 拆成 docs/api/: README.md(传送门索引: 11 接口总览表 +
  通用约定 + 复用结构) + 11 个一接口一文件, 便于接口增多后维护
- 补全此前 API.md 与技术文档均遗漏的核心接口 coupon-step.md
  (一键领券透传 pricebot-backend, 唯一需 JWT 鉴权的业务接口)
- 更新 后端技术实现.md: 业务补"领券透传"块、目录补 coupon.py、新增 coupon/step 节、
  配置补 PRICEBOT_*、接口索引改指向 docs/api/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:07:54 +08:00
marco 391f3df1bb Merge branch 'main' into feature/coupon-claim-integration
把 main 的"美团 CPS 模块 + 后端分层重构(integrations/repositories)"合入领券分支。
解决 3 处冲突(均为两边各自新增, 保留双方):
- app/main.py: 同时注册 coupon_router 与 meituan_router
- app/core/config.py: 同时保留 PRICEBOT_* 与 MT_CPS_* 配置
- .env.example: 同上
coupon.py 本身无冲突(仅依赖 deps.CurrentUser 与 core.config, 重构未触及)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:26:11 +08:00
marco c532fe966f docs: 新增 API 接口文档 + 更新后端技术方案
- 新增 docs/API.md: 全部 10 个接口的完整协议(入参/出参/错误码/鉴权)、
  复用结构(TokenPair/TokenWithUser/UserOut/CouponCard)、鉴权与刷新机制
- 更新 docs/后端技术实现.md: 目录结构同步重构后的 integrations/repositories 分层;
  补美团 CPS 模块(S-Ca 签名/feed 2外卖1到店拼接/无鉴权与sid风险);
  更新极光 RSA 密钥现状(旧密钥不配对、已重新生成);
  补已知问题与上线 checklist(JWT secret/SMS_MOCK/feed 静默吞异常)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:15:23 +08:00
marco 8303a4fac2 refactor: 后端分层调整(集成层独立、crud 改名) + 本地启动脚本
- core 拆分: 极光/美团/短信三个外部集成从 core/ 迁出到新建的 integrations/,
  core/ 仅保留 config/security/logging 等基础设施
- crud/ 改名 repositories/; auth.py 中模块别名 crud_user -> user_repo
- 同步更新 app/api/v1/{auth,meituan}.py 的 import 路径
- 新增 run.sh: 本地开发启动脚本(校验 .env、alembic upgrade 建表、uvicorn 监听 0.0.0.0:8770)
- .gitignore 忽略 *.log, 避免 run.sh 运行日志入库

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:04:51 +08:00
lowmaster-chen 9cf24ccecd fix: 放宽 Python 版本要求至 >=3.10
代码已使用 from __future__ import annotations, 兼容 3.10 运行时

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:29:39 +08:00
lowmaster-chen cc9d9f03f7 feat: 接入美团联盟 CPS 优惠券推荐模块
- 新增 app/core/meituan.py: 美团联盟 API 客户端 (S-Ca 签名, query_coupon, get_referral_link)
- 新增 app/api/v1/meituan.py: /api/v1/meituan/coupons 和 /feed 路由, 支持按经纬度推荐
- 新增 app/schemas/meituan.py: 请求/响应 Pydantic Schema
- app/main.py: 注册 meituan_router
- app/core/config.py: 新增 MT_CPS_* 配置项, .env 路径改为绝对路径避免 CWD 问题
- .env.example: 补充美团联盟 CPS 配置模板

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:29:31 +08:00
no_gen_mu 8fe58f4a42 feat(coupon): add /api/v1/coupon/step proxy to pricebot
- new POST /api/v1/coupon/step, JWT-authenticated, async httpx forwards body to pricebot
- new PRICEBOT_BASE_URL / PRICEBOT_REQUEST_TIMEOUT_SEC settings (default localhost:8000)
- error handling: pricebot unreachable / 5xx -> 502 with friendly message
- tests: auth, passthrough, 5xx, unreachable, invalid json (5 cases, all pass)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:46:14 +08:00
106 changed files with 5907 additions and 473 deletions
+29
View File
@@ -41,6 +41,35 @@ MT_CPS_APP_SECRET=
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
MT_CPS_DEFAULT_SID=sgbjia
# ===== Pricebot 上游 (领券业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step。
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
PRICEBOT_BASE_URL=http://localhost:8000
# 领券单帧最多 wait 6s,加网络往返,30s 兜底
PRICEBOT_REQUEST_TIMEOUT_SEC=30
# ===== CORS =====
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
CORS_ALLOW_ORIGINS=
# ===== 微信支付(商家转账到零钱 / 提现)=====
# appid/secret 来自微信开放平台移动应用;mch/序列号/公钥ID 来自微信支付商户平台。
# 证书 .pem 放 secrets/(已 gitignore)。
WECHAT_APP_ID=wxxxxxxxxxxxxxxxxx
WECHAT_APP_SECRET=your_app_secret
WXPAY_MCH_ID=your_mch_id
WXPAY_MCH_SERIAL_NO=your_cert_serial_no
WXPAY_MCH_PRIVATE_KEY_PATH=./secrets/apiclient_key.pem
WXPAY_PUBLIC_KEY_ID=PUB_KEY_ID_xxxxxxxx
WXPAY_PUBLIC_KEY_PATH=./secrets/pub_key.pem
WXPAY_TRANSFER_SCENE_ID=1000
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里;
# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
PANGLE_CALLBACK_ENABLED=false
PANGLE_REWARD_SECRET=
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
AD_REWARD_TEST_GRANT_ENABLED=false
+3
View File
@@ -34,3 +34,6 @@ secrets/*
.idea/
.vscode/
*.swp
# 运行日志(run.sh 输出, 不入库)
*.log
+95 -114
View File
@@ -2,10 +2,15 @@
傻瓜比价正式 App 后端 — FastAPI + SQLAlchemy + JWT。
> 占坑期已有的试验后台 `shaguabijia-server` 是无状态 mock,本项目是正式版,
> 引入账号体系、JWT 登录态、Alembic 迁移、SQLite 起步可平滑切 PostgreSQL。
覆盖:**账号体系**(极光一键登录 / 短信登录 + JWT)、**福利钱包**(金币 / 现金 / 签到 / 任务 / 省钱战绩)、**微信提现**(商家转账到零钱)、**看广告发奖**(穿山甲 GroMore S2S 回调)、**美团 CPS**(领券透传 / 比价推荐)。
详细技术说明见 [docs/后端技术实现.md](docs/后端技术实现.md)(登录链路、极光 RSA、部署 checklist)
> 占坑期的试验后台 `shaguabijia-server` 是无状态 mock;本项目是正式版,引入账号体系、JWT 登录态、Alembic 迁移,SQLite 起步可平滑切 PostgreSQL
**文档导航**(都在 [docs/](docs/README.md)):
- [docs/api/](docs/api/README.md) — **接口契约**(索引 + 一接口一文件)
- [docs/integrations/](docs/integrations/README.md) — **集成层**(SDK 签名 / 加解密 / 协议细节)
- [docs/后端技术实现.md](docs/后端技术实现.md) — **架构与链路**(分层、登录、CPS、数据模型)
- [docs/数据库迁移.md](docs/数据库迁移.md) — **数据库迁移**(Alembic 怎么建表 / 升级)
---
@@ -16,57 +21,75 @@
| 语言 | Python 3.11+ | |
| Web | FastAPI 0.115+ | |
| ASGI | uvicorn[standard] | |
| ORM | SQLAlchemy 2.0 | Mapped/DeclarativeBase 新风格 |
| ORM | SQLAlchemy 2.0 | Mapped / DeclarativeBase 新风格 |
| 迁移 | Alembic | render_as_batch 兼容 SQLite |
| DB | SQLite | 后续切 PostgreSQL 只改 `DATABASE_URL` |
| DB | SQLite 起步 | 切 PostgreSQL 只改 `DATABASE_URL` |
| Auth | JWT (PyJWT) | access(2h) + refresh(30d) |
| 极光一键登录 | httpx + cryptography | RSA 解密,逻辑参考试验后台 |
| HTTP 客户端 | httpx | 调极光 / 微信 / 美团 / 穿山甲 |
| 加解密 | cryptography | 极光 RSA 解密、微信 V3 签名 / 敏感字段加密 |
| 微信支付 | 商家转账到零钱 V3 | 提现;懒加载商户证书 |
| 配置 | pydantic-settings | `.env` 加载 |
| 测试 | pytest + TestClient | |
---
## 架构分层
一个请求自上而下:**api(薄,收发) → integrations(外部 SDK) / repositories(数据) → models / db**。
- `api/v1/` 只做"解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码映射",不放重逻辑。
- **SDK 集成的重逻辑(签名 / 验签 / 加解密 / 外部 HTTP)一律在 `integrations/`**,换方案 / 换供应商只动这一层。
- 数据访问统一在 `repositories/`(早期叫 `crud/`,已并入)。
## 目录结构
```
shaguabijia-app-server/
├── app/
│ ├── main.py FastAPI 入口 + /health + CORS
│ ├── core/
│ ├── main.py FastAPI 入口:注册全部 router + /health + CORS + lifespan
│ ├── api/
│ │ ├── deps.py 共享依赖:get_current_user(Bearer 校验)、get_db
│ │ └── v1/ 接口层(薄)
│ │ ├── auth.py 认证 6 端点(极光 / 短信 send+login / refresh / me / logout)
│ │ ├── wallet.py 钱包 / 提现 11 端点(余额 / 流水 / 兑换 / 绑微信 / 提现 / 查单)
│ │ ├── signin.py 签到 2 端点
│ │ ├── tasks.py 一次性任务 2 端点
│ │ ├── savings.py 省钱 3 端点(汇总 / 战绩 / 明细)
│ │ ├── ad.py 看广告发奖 3 端点(穿山甲回调 / 进度 / 联调发奖)
│ │ ├── coupon.py 领券透传 /coupon/step(转发 pricebot)
│ │ └── meituan.py 美团 CPS 3 端点(券列表 / feed / 换链)
│ ├── integrations/ 外部服务 / SDK 客户端(重逻辑)
│ │ ├── jiguang.py 极光 REST 验 token + RSA 解密(多 padding 试错)
│ │ ├── sms.py 短信验证码(mock,进程内冷却表)
│ │ ├── meituan.py 美团 CPS 网关签名 + query_coupon / get_referral_link
│ │ ├── pangle.py 穿山甲激励视频发奖回调验签(SHA256)
│ │ └── wxpay.py 微信支付 V3 商家转账(提现)+ code 换 openid
│ ├── core/ 基础设施(无外部业务集成)
│ │ ├── config.py Settings (pydantic-settings)
│ │ ├── logging.py 统一日志配置
│ │ ├── security.py JWT 签发 / 校验 / token 对
│ │ ├── jiguang.py 极光 REST 调用 + RSA 解密
│ │ ── sms.py 短信验证码 (mock 实现)
├── db/
│ ├── base.py DeclarativeBase
│ │ ── session.py engine + SessionLocal + get_db
│ ├── models/
│ │ ── user.py User 表
│ ├── schemas/
│ │ ── auth.py 登录/Token 相关 Pydantic
├── crud/
│ └── user.py upsert_user_for_login 等
── api/
├── deps.py get_current_user (Bearer 校验)
│ └── v1/
│ └── auth.py /api/v1/auth/* 路由
│ │ ├── ratelimit.py 同 IP 滑动窗口限流依赖
│ │ ── rewards.py 发奖 / 兑换 / 提现额度等业务常量与换算
│ └── logging.py 统一日志配置
│ ├── repositories/ 数据访问 + 事务(早期叫 crud,已并入)
│ │ ── user.py upsert_user_for_login 等
│ ├── wallet.py 账户 / 流水 / 兑换 / 提现单(调 integrations/wxpay)
│ │ ── signin.py 签到记录 / 连续天数 / 档位
│ ├── task.py 一次性任务领取
│ │ ── savings.py 省钱汇总 / 战绩 / 明细
│ └── ad_reward.py 看广告发奖(按 trans_id 幂等 + 每日上限)
├── models/ ORM 表结构(user / wallet / signin / task / savings / ad_reward)
── schemas/ Pydantic 收发契约(auth / meituan / welfare / ad)
└── db/ base.py(DeclarativeBase) + session.py(engine / get_db)
├── alembic/ 迁移脚本
│ ├── env.py
│ ├── script.py.mako
│ └── versions/ (空,第一次跑 alembic revision 生成)
├── alembic/ 数据库迁移(versions/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
├── alembic.ini
├── deploy/
│ ├── shaguabijia-app-server.service systemd unit
│ └── nginx/
│ └── app-api.shaguabijia.com.conf
├── secrets/ 不入 git。放 jverify_rsa_private.pem
├── data/ 不入 git。SQLite 文件默认在这里
├── tests/ 冒烟 + auth 流程
├── deploy/ systemd(.service) + nginx(.conf)
├── secrets/ 不入 git:极光 RSA 私钥 / 微信支付证书
├── data/ 不入 git:SQLite 文件默认在这里
├── scripts/ 运维脚本(migrate 迁移 / 对账 / 重置签到·福利 / 模拟穿山甲回调)
├── tests/ pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy)
├── docs/ 文档库(api/ + integrations/ + 技术实现 / 迁移 / 待办)
├── run.sh 本地启动(自动先跑迁移再起服务)
├── pyproject.toml
├── .env.example
└── README.md
@@ -76,98 +99,63 @@ shaguabijia-app-server/
## API 接口
| 方法 | 路径 | 说明 | 鉴权 |
完整契约见 **[docs/api/README.md](docs/api/README.md)**(总览表 + 一接口一文件)。按组速览:
| 组 | 前缀 | 端点数 | 鉴权 |
|---|---|---|---|
| GET | `/health` | 健康检查 | 无 |
| POST | `/api/v1/auth/jverify-login` | 极光一键登录(`loginToken` → 注册即登录 → 签发 token 对) | 无 |
| POST | `/api/v1/auth/sms/send` | 发短信验证码(mock 阶段任意 6 位通过) | 无 |
| POST | `/api/v1/auth/sms/login` | 手机号+验证码登录 | 无 |
| POST | `/api/v1/auth/refresh` | 用 `refresh_token` 换新的 token 对 | 无 |
| GET | `/api/v1/auth/me` | 获取当前登录用户 | `Bearer access_token` |
| POST | `/api/v1/auth/logout` | 登出(占位,客户端清 token) | `Bearer access_token` |
| 健康检查 | `/health` | 1 | 无 |
| 认证 Auth | `/api/v1/auth` | 6 | 登录类无 / `me`·`logout` Bearer |
| 钱包·提现 Wallet | `/api/v1/wallet` | 11 | Bearer(`exchange-info`) |
| 签到 Signin | `/api/v1/signin` | 2 | Bearer |
| 任务 Tasks | `/api/v1/tasks` | 2 | Bearer |
| 省钱 Savings | `/api/v1/savings` | 3 | Bearer |
| 看广告发奖 Ad | `/api/v1/ad` | 3 | 回调验签 / 其余 Bearer |
| 领券 Coupon | `/api/v1/coupon` | 1 | Bearer |
| 美团 CPS Meituan | `/api/v1/meituan` | 3 | 无 |
登录类接口响应统一格式:
```json
{
"access_token": "eyJxxx...",
"refresh_token": "eyJxxx...",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_expires_in": 2592000,
"user": {
"id": 1,
"phone": "13800138000",
"nickname": null,
"avatar_url": null,
"register_channel": "jverify",
"status": "active",
"created_at": "2026-05-23T04:30:00Z",
"last_login_at": "2026-05-23T04:30:00Z"
}
}
```
文档启动后看 `http://localhost:8770/docs` (Swagger UI)。
登录类接口响应统一`TokenWithUser`(access/refresh token 对 + user);金额字段一律以**分**为单位(`*_cents`)。`APP_ENV` 非 prod 时开 `http://localhost:8770/docs`(Swagger UI)。
---
## 本地开发
### 1. 装依赖
```bash
cd shaguabijia-app-server
python3.11 -m venv .venv
source .venv/bin/activate
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
```
### 2. 准备配置
### 2. 配置
```bash
cp .env.example .env
# 编辑 .env,生产部署务必改 JWT_SECRET_KEY
# 至少改 JWT_SECRET_KEY;测美团填 MT_CPS_*;测提现填 WXPAY_*/WECHAT_*;测看广告填 PANGLE_*
```
测极光一键登录把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`(没放也不影响其他接口,只是 `jverify-login` 返 502)。
如果要测极光一键登录,把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`
没放也不影响其他接口(`sms`/`/health` 等),只是 `jverify-login` 会 502。
### 3. 建表(首次)
### 3. 建表 / 升级数据库
```bash
# 生成首版 migration(已有 User model,会自动生成 create_table)
alembic revision --autogenerate -m "init user table"
# 应用 migration
alembic upgrade head
```
后续改 model 后:
```bash
alembic revision --autogenerate -m "add nickname index"
alembic upgrade head
mkdir -p data # SQLite 库目录
alembic upgrade head # 按迁移链建到最新(幂等)
```
> 详见 [docs/数据库迁移.md](docs/数据库迁移.md)。只想迁移不启服务:`bash scripts/migrate.sh`。
### 4. 跑起来
```bash
uvicorn app.main:app --reload --port 8770
./run.sh # 监听 0.0.0.0:8770,自动先跑迁移;改代码自动重启
# 或手动: uvicorn app.main:app --reload --port 8770
```
冒烟:
```bash
curl http://localhost:8770/health
# {"status":"ok"}
# mock 走通登录闭环
curl -X POST http://localhost:8770/api/v1/auth/sms/send -H 'Content-Type: application/json' -d '{"phone":"13800138000"}'
curl http://localhost:8770/health # {"status":"ok"}
# mock 登录闭环
curl -X POST http://localhost:8770/api/v1/auth/sms/send -H 'Content-Type: application/json' -d '{"phone":"13800138000"}'
curl -X POST http://localhost:8770/api/v1/auth/sms/login -H 'Content-Type: application/json' -d '{"phone":"13800138000","code":"123456"}'
# 拿到 access_token 后:
curl http://localhost:8770/api/v1/auth/me -H "Authorization: Bearer <access_token>"
```
### 5. 测试
```bash
pytest -q
```
@@ -176,17 +164,13 @@ pytest -q
## 部署(生产)
参考 `deploy/`,部署到 `app-api.shaguabijia.com`:
部署到 `app-api.shaguabijia.com`(参考 `deploy/`):
```bash
# 一次性
ssh server "apt-get install -y python3.11-venv python3-pip"
# 后续更新
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/jverify_rsa_private.pem' \
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/*' \
./ server:/opt/shaguabijia-app-server/
# 私钥单独同步(首次 / 私钥更换时)
# 证书 / 私钥单独同步(首次 / 更换时):极光私钥 + 微信支付商户私钥·平台公钥
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
ssh server "
@@ -196,8 +180,7 @@ ssh server "
systemctl restart shaguabijia-app-server
"
```
详见 `deploy/shaguabijia-app-server.service``deploy/nginx/app-api.shaguabijia.com.conf`
详见 `deploy/`。**看广告发奖上线**前对照 [docs/看广告赚金币上线清单.md](docs/看广告赚金币上线清单.md)(GroMore 回调配置、清理调试脚手架、端到端验收)。
---
@@ -206,11 +189,9 @@ ssh server "
| 维度 | 试验后台 (shaguabijia-server) | 本项目 (shaguabijia-app-server) |
|---|---|---|
| 数据库 | stdlib sqlite3,无 ORM | SQLAlchemy 2.0 + Alembic |
| 业务 | mock parse(无账号体系) | 完整账号体系 + JWT |
| 登录态 | 无,客户端发 phone 当 ID | JWT access+refresh |
| 业务 | mock parse(无账号体系) | 账号 + 钱包 / 提现 / 签到 / 看广告 / CPS |
| 登录态 | 无,客户端发 phone 当 ID | JWT access + refresh |
| 配置 | 硬编码 | pydantic-settings + `.env` |
| 短信登录 | 无 | 有(mock 实现,留扩展点) |
| 域名 | api.shaguabijia.com (试验) | app-api.shaguabijia.com (正式) |
| 域名 | api.shaguabijia.com(试验) | app-api.shaguabijia.com(正式) |
极光一键登录的具体实现(REST 调用、RSA padding 试错顺序)直接参考自试验后台,
逻辑等价。
极光一键登录的实现(REST 调用、RSA padding 试错顺序)参考自试验后台,逻辑等价。
+50
View File
@@ -0,0 +1,50 @@
"""ad_reward_record table (看激励视频发奖记录)
Revision ID: c8d9e0f1a2b3
Revises: b1c2d3e4f5a6
Create Date: 2026-05-26 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c8d9e0f1a2b3'
down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'ad_reward_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('trans_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coin', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('reward_date', sa.String(length=10), nullable=False),
sa.Column('reward_name', sa.String(length=64), nullable=True),
sa.Column('raw', sa.String(length=1024), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('ad_reward_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_reward_record_trans_id'), ['trans_id'], unique=True)
batch_op.create_index(batch_op.f('ix_ad_reward_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_reward_record_reward_date'), ['reward_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_reward_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_reward_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_reward_record_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_reward_date'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_user_id'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_trans_id'))
op.drop_table('ad_reward_record')
@@ -1,49 +0,0 @@
"""add order_record table
Revision ID: c64a944a6139
Revises: 9c559acc164a
Create Date: 2026-05-26 21:09:40.047619
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c64a944a6139'
down_revision: Union[str, Sequence[str], None] = '9c559acc164a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('order_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=30), nullable=False),
sa.Column('store_name', sa.String(length=128), nullable=False),
sa.Column('dishes', sa.Text(), nullable=False),
sa.Column('order_date', sa.String(length=10), nullable=False),
sa.Column('price', sa.String(length=20), nullable=False),
sa.Column('original_price', sa.String(length=20), nullable=True),
sa.Column('savings', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('order_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_order_record_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('order_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_order_record_user_id'))
op.drop_table('order_record')
# ### end Alembic commands ###
@@ -0,0 +1,49 @@
"""cash_transaction table
Revision ID: 3d9cb19df76f
Revises: 357520ea3015
Create Date: 2026-05-24 16:21:22.950992
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3d9cb19df76f'
down_revision: Union[str, Sequence[str], None] = '357520ea3015'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('cash_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('balance_after_cents', sa.Integer(), nullable=False),
sa.Column('biz_type', sa.String(length=32), nullable=False),
sa.Column('ref_id', sa.String(length=64), nullable=True),
sa.Column('remark', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('cash_transaction', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_cash_transaction_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_cash_transaction_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('cash_transaction', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_cash_transaction_user_id'))
batch_op.drop_index(batch_op.f('ix_cash_transaction_created_at'))
op.drop_table('cash_transaction')
# ### end Alembic commands ###
+49
View File
@@ -0,0 +1,49 @@
"""savings_record table
Revision ID: bf2a67c0e2ab
Revises: 3d9cb19df76f
Create Date: 2026-05-24 16:24:34.622394
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bf2a67c0e2ab'
down_revision: Union[str, Sequence[str], None] = '3d9cb19df76f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('savings_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('order_amount_cents', sa.Integer(), nullable=False),
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=32), nullable=True),
sa.Column('title', sa.String(length=128), nullable=True),
sa.Column('source', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_savings_record_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_savings_record_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_savings_record_user_id'))
batch_op.drop_index(batch_op.f('ix_savings_record_created_at'))
op.drop_table('savings_record')
# ### end Alembic commands ###
+33
View File
@@ -0,0 +1,33 @@
"""savings_record.shop_name + dishes(订单明细卡:店铺名 + 菜品列表)
Revision ID: b1c2d3e4f5a6
Revises: a7c8d9e0f1b2
Create Date: 2026-05-26 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b1c2d3e4f5a6'
down_revision: Union[str, Sequence[str], None] = 'a7c8d9e0f1b2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('shop_name', sa.String(length=128), nullable=True))
# dishes 存 JSON(SQLite 落 TEXT);旧行给 '[]' 默认,满足 NOT NULL
batch_op.add_column(
sa.Column('dishes', sa.JSON(), nullable=False, server_default=sa.text("'[]'"))
)
def downgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_column('dishes')
batch_op.drop_column('shop_name')
+32
View File
@@ -0,0 +1,32 @@
"""unique index on user.wechat_openid (一个微信只绑一个账号)
Revision ID: a7c8d9e0f1b2
Revises: f5b2d3c4e6a7
Create Date: 2026-05-25 19:10:00.000000
注意:升级前需保证 wechat_openid 无重复值(NULL 不算重复)。
本仓库 data/app.db 升级前已手动清掉重复 openid(保留最近绑定的账号)。
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a7c8d9e0f1b2'
down_revision: Union[str, Sequence[str], None] = 'f5b2d3c4e6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# nullable 列上的唯一索引:SQLite / Postgres 都允许多个 NULL,只约束非空值唯一
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_user_wechat_openid'), ['wechat_openid'], unique=True
)
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_wechat_openid'))
@@ -0,0 +1,30 @@
"""user.wechat_nickname + wechat_avatar_url
Revision ID: f5b2d3c4e6a7
Revises: e4a1c2b3d4f5
Create Date: 2026-05-25 18:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f5b2d3c4e6a7'
down_revision: Union[str, Sequence[str], None] = 'e4a1c2b3d4f5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('wechat_nickname', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('wechat_avatar_url', sa.String(length=512), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('wechat_avatar_url')
batch_op.drop_column('wechat_nickname')
@@ -0,0 +1,96 @@
"""welfare tables: coin account, coin txn, signin, user task
Revision ID: 357520ea3015
Revises: 9c559acc164a
Create Date: 2026-05-24 16:16:20.677711
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '357520ea3015'
down_revision: Union[str, Sequence[str], None] = '9c559acc164a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('coin_account',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coin_balance', sa.Integer(), nullable=False),
sa.Column('cash_balance_cents', sa.Integer(), nullable=False),
sa.Column('total_coin_earned', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id')
)
op.create_table('coin_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('balance_after', sa.Integer(), nullable=False),
sa.Column('biz_type', sa.String(length=32), nullable=False),
sa.Column('ref_id', sa.String(length=64), nullable=True),
sa.Column('remark', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('coin_transaction', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_coin_transaction_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_coin_transaction_user_id'), ['user_id'], unique=False)
op.create_table('signin_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('signin_date', sa.Date(), nullable=False),
sa.Column('cycle_day', sa.Integer(), nullable=False),
sa.Column('streak', sa.Integer(), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_user_date')
)
with op.batch_alter_table('signin_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_signin_record_user_id'), ['user_id'], unique=False)
op.create_table('user_task',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('task_key', sa.String(length=48), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'task_key', name='uq_task_user_key')
)
with op.batch_alter_table('user_task', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_task_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_task', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_task_user_id'))
op.drop_table('user_task')
with op.batch_alter_table('signin_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_signin_record_user_id'))
op.drop_table('signin_record')
with op.batch_alter_table('coin_transaction', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coin_transaction_user_id'))
batch_op.drop_index(batch_op.f('ix_coin_transaction_created_at'))
op.drop_table('coin_transaction')
op.drop_table('coin_account')
# ### end Alembic commands ###
@@ -0,0 +1,56 @@
"""withdraw_order table + user.wechat_openid
Revision ID: e4a1c2b3d4f5
Revises: bf2a67c0e2ab
Create Date: 2026-05-25 16:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e4a1c2b3d4f5'
down_revision: Union[str, Sequence[str], None] = 'bf2a67c0e2ab'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'withdraw_order',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('out_bill_no', sa.String(length=64), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('wechat_state', sa.String(length=32), nullable=True),
sa.Column('transfer_bill_no', sa.String(length=64), nullable=True),
sa.Column('package_info', sa.String(length=512), nullable=True),
sa.Column('fail_reason', sa.String(length=256), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_withdraw_order_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_withdraw_order_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_withdraw_order_out_bill_no'), ['out_bill_no'], unique=True)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('wechat_openid', sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('wechat_openid')
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_withdraw_order_out_bill_no'))
batch_op.drop_index(batch_op.f('ix_withdraw_order_user_id'))
batch_op.drop_index(batch_op.f('ix_withdraw_order_created_at'))
op.drop_table('withdraw_order')
+131
View File
@@ -0,0 +1,131 @@
"""看激励视频发奖 endpoint。
路由前缀 `/api/v1/ad`:
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
看完后刷新余额,不参与发奖,被破解也刷不到钱。
"""
from __future__ import annotations
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit
from app.repositories import ad_reward as crud_ad
from app.schemas.ad import AdRewardStatusOut, PangleCallbackOut, TestGrantOut
logger = logging.getLogger("shagua.ad")
router = APIRouter(prefix="/api/v1/ad", tags=["ad"])
# GroMore 回调 is_verify=false 时透传给客户端 SDK 的错误码(自定义,仅用于排查/客户端提示)
REASON_OK = 0
REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
@router.get(
"/pangle-callback",
response_model=PangleCallbackOut,
summary="穿山甲激励视频发奖回调(S2S,验签)",
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
)
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / reward_amount / sign 等 query 参数。
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/reward_amount → 幂等发金币。
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
granted / capped → is_verify=true + reason=0。
"""
if not settings.pangle_callback_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
params = dict(request.query_params)
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
trans_id = params.get("trans_id") or ""
raw_user_id = params.get("user_id") or ""
if not trans_id or not raw_user_id.isdigit():
# 验签过了但参数缺/坏:不发奖(is_verify=false),带错误码
logger.warning("pangle callback bad params: %s", params)
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
user_id = int(raw_user_id)
coin = rewards.resolve_ad_reward_coin(params.get("reward_amount"))
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
try:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, coin=coin,
reward_name=params.get("reward_name"), raw=raw[:1024],
)
except crud_ad.UnknownUserError:
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
logger.info(
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin
)
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
used, limit, coin_per = crud_ad.today_status(db, user.id)
return AdRewardStatusOut(
used_today=used,
daily_limit=limit,
remaining=max(0, limit - used),
coin_per_ad=coin_per,
)
@router.post(
"/test-grant",
response_model=TestGrantOut,
summary="[仅本地联调]模拟穿山甲回调发奖",
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
)
def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
必须 settings.AD_REWARD_TEST_GRANT_ENABLED=true 才开放(默认 False),否则 404 当不存在。
它让已登录客户端能自助发奖 = 绕过反作弊,**生产必须关闭**。
"""
if not settings.AD_REWARD_TEST_GRANT_ENABLED:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, reward_name="测试发奖", raw="client debug test-grant"
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
used, limit, coin_per = crud_ad.today_status(db, user.id)
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
return TestGrantOut(
granted=(rec.status == "granted"),
status=rec.status,
coin=rec.coin,
used_today=used,
daily_limit=limit,
remaining=max(0, limit - used),
coin_per_ad=coin_per,
)
+6 -6
View File
@@ -15,10 +15,10 @@ import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.core.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.core.security import TokenError, decode_token, issue_token_pair
from app.core.sms import SmsError, send_code, verify_code
from app.crud import user as crud_user
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
from app.repositories import user as user_repo
from app.schemas.auth import (
JverifyLoginRequest,
LogoutResponse,
@@ -61,7 +61,7 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
user = crud_user.upsert_user_for_login(db, phone=phone, register_channel="jverify")
user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
@@ -88,7 +88,7 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
user = crud_user.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
@@ -106,7 +106,7 @@ def refresh(req: RefreshRequest, db: DbSession) -> TokenPair:
raise HTTPException(status_code=401, detail=str(e)) from e
user_id = int(payload["sub"])
user = crud_user.get_user_by_id(db, user_id)
user = user_repo.get_user_by_id(db, user_id)
if user is None or user.status != "active":
raise HTTPException(status_code=401, detail="user not found or disabled")
+72
View File
@@ -0,0 +1,72 @@
"""领券业务透传端点。
把客户端的 POST /api/v1/coupon/step 请求原样转发给 pricebot-backend
的 /api/coupon/step。MVP 阶段**不鉴权**(见 docs/待办与技术债.md P1:
device_id 透传给 pricebot 区分设备,待补 JWT + device_id↔user_id 绑定后
才能做用户级画像)。
pricebot 协议文档:
pricebot-backend/docs/projects/领券-客户端对接协议.md
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
logger = logging.getLogger("shagua.coupon")
router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"])
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step(
request: Request,
) -> dict[str, Any]:
"""把请求体原样转发给 pricebot-backend 的 /api/coupon/step。
- 鉴权: MVP 阶段不鉴权(待补 JWT,见 docs/待办与技术债.md P1)
- 透传: 不做 schema 校验,pricebot 自己校验
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
"""
try:
body = await request.json()
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}/api/coupon/step"
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
logger.info(
"coupon_step device_id=%s trace_id=%s step=%s",
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=body)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream unreachable: {e}",
) from e
if resp.status_code >= 500:
logger.error(
"[pricebot] 5xx status=%d body=%s",
resp.status_code,
resp.text[:500],
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream returned {resp.status_code}",
)
return resp.json()
+1 -1
View File
@@ -9,7 +9,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, HTTPException
from app.core.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.schemas.meituan import (
CouponCard,
CouponListRequest,
+59
View File
@@ -0,0 +1,59 @@
"""省钱 endpoint(profile 的「累计帮你省了」「省钱战绩」「省钱明细」)。
路由前缀 `/api/v1/savings`:
GET /summary 累计省下 + 订单数 + 平均每单省
GET /battle 本周已省 + 超过百分比 + 连续省钱天数
GET /records 省钱明细(游标分页)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query
from app.api.deps import CurrentUser, DbSession
from app.repositories import savings as crud_savings
from app.schemas.welfare import (
SavingsBattleOut,
SavingsRecordOut,
SavingsRecordPage,
SavingsSummaryOut,
)
logger = logging.getLogger("shagua.savings")
router = APIRouter(prefix="/api/v1/savings", tags=["savings"])
@router.get("/summary", response_model=SavingsSummaryOut, summary="累计帮你省了")
def summary(user: CurrentUser, db: DbSession) -> SavingsSummaryOut:
s = crud_savings.get_summary(db, user.id)
return SavingsSummaryOut(
total_saved_cents=s.total_saved_cents,
order_count=s.order_count,
avg_saved_cents=s.avg_saved_cents,
)
@router.get("/battle", response_model=SavingsBattleOut, summary="省钱战绩")
def battle(user: CurrentUser, db: DbSession) -> SavingsBattleOut:
b = crud_savings.get_battle(db, user.id)
return SavingsBattleOut(
week_saved_cents=b.week_saved_cents,
beat_percent=b.beat_percent,
streak_days=b.streak_days,
)
@router.get("/records", response_model=SavingsRecordPage, summary="省钱明细(游标分页)")
def records(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> SavingsRecordPage:
items, next_cursor = crud_savings.list_records(db, user.id, limit=limit, cursor=cursor)
return SavingsRecordPage(
items=[SavingsRecordOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
+44
View File
@@ -0,0 +1,44 @@
"""签到 endpoint。
路由前缀 `/api/v1/signin`:
GET /status 今日签到状态 + 7 天档位
POST / 执行今日签到
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import signin as crud_signin
from app.schemas.welfare import SigninResultOut, SigninStatusOut
logger = logging.getLogger("shagua.signin")
router = APIRouter(prefix="/api/v1/signin", tags=["signin"])
@router.get("/status", response_model=SigninStatusOut, summary="今日签到状态")
def signin_status(user: CurrentUser, db: DbSession) -> SigninStatusOut:
st = crud_signin.get_status(db, user.id)
return SigninStatusOut.model_validate(st, from_attributes=True)
@router.post("", response_model=SigninResultOut, summary="执行今日签到")
def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
try:
record, balance = crud_signin.do_signin(db, user.id)
except crud_signin.AlreadySignedError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already signed today") from e
logger.info(
"signin ok user_id=%d day=%d streak=%d coin=%d",
user.id, record.cycle_day, record.streak, record.coin_awarded,
)
return SigninResultOut(
coin_awarded=record.coin_awarded,
cycle_day=record.cycle_day,
streak=record.streak,
coin_balance=balance,
)
+47
View File
@@ -0,0 +1,47 @@
"""一次性任务 endpoint。
路由前缀 `/api/v1/tasks`:
GET / 任务及领取状态(如"打开消息提醒")
POST /{task_key}/claim 领取任务奖励
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import task as crud_task
from app.schemas.welfare import TaskClaimResultOut, TaskListOut, TaskOut
logger = logging.getLogger("shagua.tasks")
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
@router.get("", response_model=TaskListOut, summary="任务及领取状态")
def list_tasks(user: CurrentUser, db: DbSession) -> TaskListOut:
states = crud_task.list_tasks(db, user.id)
return TaskListOut(
items=[
TaskOut(task_key=s.task_key, coin=s.coin, claimed=s.claimed)
for s in states
]
)
@router.post(
"/{task_key}/claim",
response_model=TaskClaimResultOut,
summary="领取任务奖励",
)
def claim_task(task_key: str, user: CurrentUser, db: DbSession) -> TaskClaimResultOut:
try:
coin, balance = crud_task.claim_task(db, user.id, task_key)
except crud_task.UnknownTaskError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown task") from e
except crud_task.AlreadyClaimedError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="task already claimed") from e
logger.info("task claimed user_id=%d key=%s coin=%d", user.id, task_key, coin)
return TaskClaimResultOut(task_key=task_key, coin_awarded=coin, coin_balance=balance)
-38
View File
@@ -1,38 +0,0 @@
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Query
from app.api.deps import CurrentUser, DbSession
from app.crud import order_record as crud_order
from app.schemas.order_record import OrderListResponse, OrderRecordOut
logger = logging.getLogger("shagua.api.user")
router = APIRouter(prefix="/api/v1/user", tags=["user"])
@router.get("/records", response_model=OrderListResponse, summary="分页查询当前用户的订单记录")
def list_records(
user: CurrentUser,
db: DbSession,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
) -> OrderListResponse:
rows, has_next = crud_order.get_records_page(db, user_id=user.id, page=page, size=size)
records = [
OrderRecordOut(
id=r.id,
platform=r.platform,
store_name=r.store_name,
dishes=json.loads(r.dishes) if r.dishes else [],
order_date=r.order_date,
price=r.price,
original_price=r.original_price,
savings=r.savings,
)
for r in rows
]
return OrderListResponse(records=records, has_next=has_next, page=page)
+240
View File
@@ -0,0 +1,240 @@
"""钱包 / 我的资产 endpoint。
路由前缀 `/api/v1/wallet`:
GET /account 金币 + 现金余额(我的资产卡)
GET /coin-transactions 金币流水(游标分页)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.ratelimit import rate_limit
from app.core.rewards import (
COIN_PER_CENT,
COIN_PER_YUAN,
MIN_EXCHANGE_COIN,
WITHDRAW_MAX_CENTS,
WITHDRAW_MIN_CENTS,
)
from app.repositories import wallet as crud_wallet
from app.models.user import User
from app.schemas.welfare import (
BindWechatRequest,
BindWechatResultOut,
CashTransactionOut,
CashTransactionPage,
CoinAccountOut,
CoinTransactionOut,
CoinTransactionPage,
ExchangeInfoOut,
ExchangeRequest,
ExchangeResultOut,
UnbindWechatResultOut,
WithdrawInfoOut,
WithdrawOrderOut,
WithdrawOrderPage,
WithdrawRequest,
WithdrawResultOut,
WithdrawStatusOut,
)
logger = logging.getLogger("shagua.wallet")
router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"])
@router.get("/account", response_model=CoinAccountOut, summary="金币+现金余额")
def get_account(user: CurrentUser, db: DbSession) -> CoinAccountOut:
acc = crud_wallet.get_or_create_account(db, user.id)
return CoinAccountOut.model_validate(acc)
@router.get(
"/coin-transactions",
response_model=CoinTransactionPage,
summary="金币流水(游标分页)",
)
def coin_transactions(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> CoinTransactionPage:
items, next_cursor = crud_wallet.list_coin_transactions(
db, user.id, limit=limit, cursor=cursor
)
return CoinTransactionPage(
items=[CoinTransactionOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
@router.get("/cash-transactions", response_model=CashTransactionPage, summary="现金流水(游标分页)")
def cash_transactions(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> CashTransactionPage:
items, next_cursor = crud_wallet.list_cash_transactions(
db, user.id, limit=limit, cursor=cursor
)
return CashTransactionPage(
items=[CashTransactionOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
@router.get("/exchange-info", response_model=ExchangeInfoOut, summary="金币兑现金汇率/规则")
def exchange_info() -> ExchangeInfoOut:
return ExchangeInfoOut(
coin_per_yuan=COIN_PER_YUAN,
min_coin=MIN_EXCHANGE_COIN,
step_coin=COIN_PER_CENT,
)
@router.post("/exchange", response_model=ExchangeResultOut, summary="金币兑现金")
def exchange(req: ExchangeRequest, user: CurrentUser, db: DbSession) -> ExchangeResultOut:
try:
acc, cents = crud_wallet.exchange_coins_to_cash(db, user.id, req.coin_amount)
except crud_wallet.InvalidExchangeAmountError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"coin_amount must be >= {MIN_EXCHANGE_COIN} and a multiple of {COIN_PER_CENT}",
) from e
except crud_wallet.InsufficientCoinError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient coin balance") from e
logger.info("exchange ok user_id=%d coin=%d cents=%d", user.id, req.coin_amount, cents)
return ExchangeResultOut(
coin_amount=req.coin_amount,
cash_added_cents=cents,
coin_balance=acc.coin_balance,
cash_balance_cents=acc.cash_balance_cents,
)
# ===== 提现(现金 → 微信零钱) =====
@router.post(
"/bind-wechat",
response_model=BindWechatResultOut,
summary="微信授权 code 换 openid 并绑定",
dependencies=[Depends(rate_limit(10, 60, "bind-wechat"))], # #6 同 IP 每分钟≤10 次
)
def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> BindWechatResultOut:
if not settings.wxpay_configured:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try:
info = crud_wallet.bind_wechat_openid(db, user.id, req.code)
except crud_wallet.WechatAlreadyBoundError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="该微信已绑定其他账号"
) from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info(
"bind wechat ok user_id=%d openid=%s*** nickname=%r avatar=%s",
user.id, info["openid"][:6], info["nickname"], bool(info["avatar_url"]),
)
return BindWechatResultOut(
bound=True, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"]
)
@router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)")
def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
crud_wallet.unbind_wechat_openid(db, user.id)
logger.info("unbind wechat ok user_id=%d", user.id)
return UnbindWechatResultOut(bound=False)
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态")
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
u = db.get(User, user.id)
return WithdrawInfoOut(
min_cents=WITHDRAW_MIN_CENTS,
max_cents=WITHDRAW_MAX_CENTS,
wechat_bound=bool(u and u.wechat_openid),
wechat_nickname=u.wechat_nickname if u else None,
wechat_avatar_url=u.wechat_avatar_url if u else None,
)
@router.post(
"/withdraw",
response_model=WithdrawResultOut,
summary="发起提现到微信零钱",
dependencies=[Depends(rate_limit(20, 60, "withdraw"))], # #6 同 IP 每分钟≤20 次
)
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
if not settings.wxpay_configured:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try:
order = crud_wallet.create_withdraw(
db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no
)
except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"amount_cents must be within [{WITHDRAW_MIN_CENTS}, {WITHDRAW_MAX_CENTS}]",
) from e
except crud_wallet.WechatNotBoundError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
except crud_wallet.InsufficientCashError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e
except crud_wallet.WithdrawTransferError as e:
# 转账调用失败,余额已退回
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"wechat transfer failed: {e}") from e
acc = crud_wallet.get_or_create_account(db, user.id)
logger.info("withdraw user_id=%d cents=%d bill=%s state=%s", user.id, req.amount_cents, order.out_bill_no, order.wechat_state)
return WithdrawResultOut(
out_bill_no=order.out_bill_no,
status=order.status,
wechat_state=order.wechat_state,
amount_cents=order.amount_cents,
cash_balance_cents=acc.cash_balance_cents,
package_info=order.package_info,
mch_id=settings.WXPAY_MCH_ID,
app_id=settings.WECHAT_APP_ID,
)
@router.get("/withdraw/status", response_model=WithdrawStatusOut, summary="查提现单状态(轮询)")
def withdraw_status(
user: CurrentUser, db: DbSession, out_bill_no: str = Query(..., description="商户提现单号")
) -> WithdrawStatusOut:
try:
# 用户从确认页返回后查单:仍 WAIT_USER_CONFIRM 视为放弃 → 撤销+退款
order = crud_wallet.refresh_withdraw_status(
db, user.id, out_bill_no, cancel_if_unconfirmed=True
)
except crud_wallet.WithdrawOrderNotFound as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="withdraw order not found") from e
return WithdrawStatusOut(
out_bill_no=order.out_bill_no,
status=order.status,
wechat_state=order.wechat_state,
amount_cents=order.amount_cents,
)
@router.get("/withdraw-orders", response_model=WithdrawOrderPage, summary="提现单列表(游标分页)")
def withdraw_orders(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> WithdrawOrderPage:
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor)
return WithdrawOrderPage(
items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
+47
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
APP_NAME: str = "shaguabijia-app-server"
APP_DEBUG: bool = True
# 接口限流开关(测试里关掉,避免内存计数跨用例累加误伤)
RATE_LIMIT_ENABLED: bool = True
# ===== 数据库 =====
DATABASE_URL: str = "sqlite:///./data/app.db"
@@ -55,6 +58,50 @@ class Settings(BaseSettings):
MT_CPS_HOST: str = "https://media.meituan.com"
MT_CPS_TIMEOUT_SEC: int = 15
MT_CPS_DEFAULT_SID: str = "sgbjia"
# ===== 微信支付(商家转账到零钱 / 提现)=====
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
WECHAT_APP_ID: str = "" # 开放平台移动应用 appid(wx 开头)
WECHAT_APP_SECRET: str = "" # 开放平台 AppSecret,code 换 openid 用
WXPAY_MCH_ID: str = "" # 微信支付商户号
WXPAY_MCH_SERIAL_NO: str = "" # 商户 API 证书序列号
WXPAY_MCH_PRIVATE_KEY_PATH: str = "./secrets/apiclient_key.pem" # 商户 API 私钥
WXPAY_PUBLIC_KEY_ID: str = "" # 微信支付平台公钥 ID(PUB_KEY_ID_...)
WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥
WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销)
WXPAY_REQUEST_TIMEOUT_SEC: int = 10
@property
def wxpay_configured(self) -> bool:
"""凭证是否齐全(缺则提现接口拒绝调用,而非启动时炸)。"""
return bool(
self.WECHAT_APP_ID
and self.WXPAY_MCH_ID
and self.WXPAY_MCH_SERIAL_NO
and self.WXPAY_PUBLIC_KEY_ID
)
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
PANGLE_CALLBACK_ENABLED: bool = False
PANGLE_REWARD_SECRET: str = ""
# ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、
# 穿山甲 S2S 回调打不到本地时,直接触发一次发奖,验证"看广告→金币到账"全链路。
# 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。
AD_REWARD_TEST_GRANT_ENABLED: bool = False
@property
def pangle_callback_configured(self) -> bool:
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
# ===== Pricebot 上游 (领券业务透传目标) =====
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
PRICEBOT_BASE_URL: str = "http://localhost:8000"
# 领券一帧最多 wait 6s,加网络往返,timeout 给 30s 比较稳
PRICEBOT_REQUEST_TIMEOUT_SEC: int = 30
# ===== CORS =====
CORS_ALLOW_ORIGINS: str = ""
+59
View File
@@ -0,0 +1,59 @@
"""轻量内存限流(固定窗口计数)。
定位:防脚本刷接口的**安全网**,不是精确配额。局限——进程内存、单 worker 有效、重启清零、
多 worker/多机不共享。上线放量后应换 Redis 后端 + 按用户维度。当前单 worker uvicorn 够用。
用法:把 `rate_limit(limit, window_sec, scope)` 作为路由依赖挂上,默认按客户端 IP 计数。
"""
from __future__ import annotations
import threading
import time
from fastapi import HTTPException, Request, status
from app.core.config import settings
# key -> (window_start_ts, count)
_buckets: dict[str, tuple[float, int]] = {}
_lock = threading.Lock()
def _hit(key: str, limit: int, window_sec: float) -> bool:
"""记一次访问。返回 True=放行,False=超限。"""
now = time.monotonic()
with _lock:
start, count = _buckets.get(key, (now, 0))
if now - start >= window_sec: # 窗口过期,重置
start, count = now, 0
count += 1
_buckets[key] = (start, count)
# 顺手清理过期 key,防内存无限涨(低频访问足够)
if len(_buckets) > 10000:
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
_buckets.pop(k, None)
return count <= limit
def _client_ip(request: Request) -> str:
# nginx 反代后真实 IP 在 X-Forwarded-For 首段;直连用 request.client
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def rate_limit(limit: int, window_sec: float, scope: str):
"""生成一个 FastAPI 依赖:同一 IP 在 window_sec 内对该 scope 超过 limit 次 → 429。"""
def _dep(request: Request) -> None:
if not settings.RATE_LIMIT_ENABLED:
return
key = f"{scope}:{_client_ip(request)}"
if not _hit(key, limit, window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="操作过于频繁,请稍后再试",
)
return _dep
+87
View File
@@ -0,0 +1,87 @@
"""福利 / 激励相关的业务常量与换算规则。
集中放在这里,业务代码引用,避免数值散落各处。这些是**产品规则**(签到奖励、
任务奖励、金币汇率)而非部署配置,所以不走环境变量;要调整直接改这里。
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
CN_TZ = timezone(timedelta(hours=8))
def cn_today() -> date:
"""当前北京时间的日期,签到去重 / 连续判断都以此为准。"""
return datetime.now(CN_TZ).date()
# ===== 签到:7 天循环,断签重置回第 1 天 =====
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引。
SIGNIN_REWARDS: tuple[int, ...] = (10, 20, 30, 50, 80, 120, 200)
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
def signin_reward(cycle_day: int) -> int:
"""cycle_day 取值 1..SIGNIN_CYCLE_LEN。"""
return SIGNIN_REWARDS[cycle_day - 1]
# ===== 金币 → 现金 汇率 =====
# 10000 金币 = 1 元 = 100 分。
COIN_PER_YUAN: int = 10000
CENTS_PER_YUAN: int = 100
# 1 分对应多少金币 = 100。兑换金币数必须是它的整数倍,否则会出现不足 1 分的零头。
COIN_PER_CENT: int = COIN_PER_YUAN // CENTS_PER_YUAN
# 单次兑换最少 1 元(避免大量 1 分级碎兑)
MIN_EXCHANGE_COIN: int = COIN_PER_YUAN
def coins_to_cents(coin_amount: int) -> int:
"""金币换算成现金(分)。调用前应已校验 coin_amount 是 COIN_PER_CENT 的整数倍。"""
return coin_amount // COIN_PER_CENT
# ===== 提现(现金 → 微信零钱)=====
# 单次提现额度(分)。下限 0.1 元是微信商家转账的最低额,低于它(如 0.01 测试档)真转账会被拒。
WITHDRAW_MIN_CENTS: int = 10
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
# ===== 一次性任务(领一次,user_task 去重)=====
TASK_ENABLE_NOTIFICATION = "enable_notification"
# task_key -> 奖励金币
TASK_REWARDS: dict[str, int] = {
TASK_ENABLE_NOTIFICATION: 10000,
}
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
# 看完一个激励视频发的金币(≈¥0.01,汇率 10000 金币=1 元)。
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"。
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
# 代码位"奖励数量"配成与本值一致(如 100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
AD_REWARD_COIN: int = 100
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限,防刷 + 控成本。
# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
DAILY_AD_REWARD_LIMIT: int = 10
def resolve_ad_reward_coin(reward_amount: str | int | None) -> int:
"""把穿山甲回调的 reward_amount(后台代码位配的"奖励数量")解析成本次发放金币。
缺失 / 非数字 / ≤0 → 回退 AD_REWARD_COIN;超过 MAX_AD_REWARD_COIN → 夹紧。
注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
(要 SecurityKey),故能过验签的 reward_amount 即视为可信,这里只防后台配错。
"""
try:
coin = int(reward_amount) # type: ignore[arg-type]
except (TypeError, ValueError):
return AD_REWARD_COIN
if coin <= 0:
return AD_REWARD_COIN
return min(coin, MAX_AD_REWARD_COIN)
-26
View File
@@ -1,26 +0,0 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.order_record import OrderRecord
def get_records_page(
db: Session,
*,
user_id: int,
page: int = 1,
size: int = 20,
) -> tuple[list[OrderRecord], bool]:
offset = (page - 1) * size
stmt = (
select(OrderRecord)
.where(OrderRecord.user_id == user_id)
.order_by(OrderRecord.order_date.desc(), OrderRecord.id.desc())
.offset(offset)
.limit(size + 1)
)
rows = list(db.execute(stmt).scalars().all())
has_next = len(rows) > size
return rows[:size], has_next
+44
View File
@@ -0,0 +1,44 @@
"""穿山甲激励视频"服务端发奖回调"的验签。
发奖闭环:激励视频播完 → 穿山甲服务器 GET 回调本服务(带 user_id / trans_id / sign 等)
→ 本服务**验签**确认回调确实来自穿山甲(而非伪造请求) → 幂等发金币。
客户端不参与发奖,被破解也刷不到钱。
验签方案(穿山甲 GroMore「广告位 → 服务端激励回调」官方规范,见 supportcenter/26240。
我们客户端是 useMediation(true) 融合,回调走 GroMore 广告位层级,**不是**联盟代码位层级):
**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/reward_amount)。
m-key(安全密钥)在穿山甲后台「GroMore 聚合管理 → 搜广告位ID → 编辑」处获取,配到
`settings.PANGLE_REWARD_SECRET`。(联盟代码位层级用的是另一套 Security Key + isValid 响应,我们不走那条。)
"""
from __future__ import annotations
import hashlib
import hmac
def build_sign(trans_id: str, secret: str) -> str:
"""按穿山甲规范计算 sign = SHA256("{SecurityKey}:{trans_id}") 的十六进制串。
secret 即后台的 Security Key。自验签测试 / 模拟回调脚本也用这一个函数,保证两端一致。
"""
msg = f"{secret}:{trans_id}".encode("utf-8")
return hashlib.sha256(msg).hexdigest()
def verify_callback_sign(params: dict[str, str], secret: str) -> bool:
"""校验回调签名。`params` 是回调全部 query 参数(含 sign / trans_id)。
密钥为空或缺 trans_id 一律判失败。比较用定长比较防时序侧信道。
"""
if not secret:
return False
trans_id = params.get("trans_id") or ""
if not trans_id:
return False
got = params.get("sign") or ""
expect = build_sign(trans_id, secret)
return hmac.compare_digest(got, expect)
+219
View File
@@ -0,0 +1,219 @@
"""微信支付 V3 —— 商家转账到零钱(提现)。
移植自 elderhelper 的 wxpay.py,改成从 [app.core.config.settings] 读配置、密钥懒加载
(文件缺失不在 import 时炸进程,只在真正调用转账时报错,与极光私钥同款策略)。
只实现提现需要的两个接口:
- create_transfer:发起转账(商家转账到零钱)
- query_transfer :按商户单号查转账单状态
签名用商户 API 私钥(RSA-SHA256/PKCS1v15),敏感字段(实名)用微信支付平台公钥 OAEP 加密。
"""
from __future__ import annotations
import base64
import json
import time
import uuid
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from app.core.config import settings
_API_HOST = "https://api.mch.weixin.qq.com"
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
# 懒加载缓存
_private_key: RSAPrivateKey | None = None
_public_key: RSAPublicKey | None = None
class WxPayNotConfiguredError(Exception):
"""微信支付凭证 / 证书缺失,无法调用。"""
def _load_private_key() -> RSAPrivateKey:
global _private_key
if _private_key is None:
try:
with open(settings.WXPAY_MCH_PRIVATE_KEY_PATH, "rb") as f:
_private_key = serialization.load_pem_private_key(f.read(), password=None)
except FileNotFoundError as e:
raise WxPayNotConfiguredError(
f"商户私钥不存在: {settings.WXPAY_MCH_PRIVATE_KEY_PATH}"
) from e
return _private_key
def _load_public_key() -> RSAPublicKey:
global _public_key
if _public_key is None:
try:
with open(settings.WXPAY_PUBLIC_KEY_PATH, "rb") as f:
_public_key = serialization.load_pem_public_key(f.read())
except FileNotFoundError as e:
raise WxPayNotConfiguredError(
f"微信支付平台公钥不存在: {settings.WXPAY_PUBLIC_KEY_PATH}"
) from e
return _public_key
def _sign(message: str) -> str:
"""RSA-SHA256 签名,base64 输出。"""
signature = _load_private_key().sign(
message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()
)
return base64.b64encode(signature).decode("utf-8")
def _build_authorization(method: str, url_path: str, body: str) -> str:
"""构建 V3 Authorization 头(WECHATPAY2-SHA256-RSA2048)。"""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
sign_str = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
signature = _sign(sign_str)
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{settings.WXPAY_MCH_ID}",'
f'nonce_str="{nonce}",'
f'timestamp="{timestamp}",'
f'serial_no="{settings.WXPAY_MCH_SERIAL_NO}",'
f'signature="{signature}"'
)
def encrypt_sensitive(plain_text: str) -> str:
"""用微信支付平台公钥 OAEP(SHA1) 加密敏感信息(如实名)。"""
ciphertext = _load_public_key().encrypt(
plain_text.encode("utf-8"),
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
)
return base64.b64encode(ciphertext).decode("utf-8")
def create_transfer(
openid: str, amount_fen: int, out_bill_no: str, user_name: str | None = None
) -> dict:
"""发起商家转账到零钱。返回 {status_code, data}。
user_name(实名)在金额达微信阈值时必须传(加密)。这里沿用 elderhelper 阈值,
实际阈值以微信侧要求为准。
"""
body = {
"appid": settings.WECHAT_APP_ID,
"out_bill_no": out_bill_no,
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
"openid": openid,
"transfer_amount": amount_fen,
"transfer_remark": "金币提现",
"transfer_scene_report_infos": [
{"info_type": "活动名称", "info_content": "比价返现"},
{"info_type": "奖励说明", "info_content": "现金提现到微信零钱"},
],
}
if user_name and amount_fen >= 30:
body["user_name"] = encrypt_sensitive(user_name)
body_str = json.dumps(body, ensure_ascii=False)
headers = {
"Authorization": _build_authorization("POST", _TRANSFER_PATH, body_str),
"Accept": "application/json",
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{_TRANSFER_PATH}",
content=body_str,
headers=headers,
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
return {"status_code": resp.status_code, "data": resp.json()}
def query_transfer(out_bill_no: str) -> dict:
"""按商户单号查转账单。返回 {status_code, data}。"""
path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}"
headers = {
"Authorization": _build_authorization("GET", path, ""),
"Accept": "application/json",
}
with httpx.Client() as client:
resp = client.get(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
return {"status_code": resp.status_code, "data": resp.json()}
def cancel_transfer(out_bill_no: str) -> dict:
"""撤销转账单(仅 WAIT_USER_CONFIRM/ACCEPTED 可撤)。用户没在确认页确认就离开时调用。
成功返回 {status_code:200, data:{state:'CANCELLING'|'CANCELLED', ...}}。"""
path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}/cancel"
headers = {
"Authorization": _build_authorization("POST", path, ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
return {"status_code": resp.status_code, "data": resp.json()}
def code_to_openid(code: str) -> str:
"""用微信授权 code 换 openid(开放平台移动应用 sns/oauth2)。失败抛 ValueError。"""
resp = httpx.get(
"https://api.weixin.qq.com/sns/oauth2/access_token",
params={
"appid": settings.WECHAT_APP_ID,
"secret": settings.WECHAT_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
data = resp.json()
if "openid" not in data:
raise ValueError(f"微信授权失败: {data.get('errmsg', data)}")
return data["openid"]
def code_to_userinfo(code: str) -> dict:
"""code 换 access_token+openid,再拉 sns/userinfo 取昵称头像。
返回 {openid, nickname, avatar_url, raw}。失败抛 ValueError。
注意:微信隐私新政下,部分 app 的 sns/userinfo 可能返回脱敏值(昵称"微信用户"/灰头像);
nickname/avatar_url 可能为空,调用方需兜底。"""
r1 = httpx.get(
"https://api.weixin.qq.com/sns/oauth2/access_token",
params={
"appid": settings.WECHAT_APP_ID,
"secret": settings.WECHAT_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
d1 = r1.json()
if "openid" not in d1 or "access_token" not in d1:
raise ValueError(f"微信授权失败: {d1.get('errmsg', d1)}")
openid = d1["openid"]
nickname = None
avatar_url = None
raw: dict = {}
# userinfo 拉取失败不应让绑定失败(openid 已拿到),吞掉异常只是没昵称头像
try:
r2 = httpx.get(
"https://api.weixin.qq.com/sns/userinfo",
params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
raw = r2.json()
nickname = raw.get("nickname") or None
avatar_url = raw.get("headimgurl") or None
except Exception: # noqa: BLE001
pass
return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw}
+12 -2
View File
@@ -11,9 +11,14 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.ad import router as ad_router
from app.api.v1.auth import router as auth_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.user import router as user_router
from app.api.v1.savings import router as savings_router
from app.api.v1.signin import router as signin_router
from app.api.v1.tasks import router as tasks_router
from app.api.v1.wallet import router as wallet_router
from app.core.config import settings
from app.core.logging import setup_logging
@@ -59,5 +64,10 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(coupon_router)
app.include_router(meituan_router)
app.include_router(user_router)
app.include_router(wallet_router)
app.include_router(signin_router)
app.include_router(tasks_router)
app.include_router(savings_router)
app.include_router(ad_router)
+10 -1
View File
@@ -1,3 +1,12 @@
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
from app.models.ad_reward import AdRewardRecord # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninRecord # noqa: F401
from app.models.task import UserTask # noqa: F401
from app.models.user import User # noqa: F401
from app.models.order_record import OrderRecord # noqa: F401
from app.models.wallet import ( # noqa: F401
CashTransaction,
CoinAccount,
CoinTransaction,
WithdrawOrder,
)
+42
View File
@@ -0,0 +1,42 @@
"""看激励视频发奖记录(穿山甲 S2S 回调)。
每条 = 穿山甲一次发奖回调。`trans_id`(穿山甲交易号)唯一,做幂等键:穿山甲会重试回调,
同号只发一次金币。`reward_date`(北京时间日期串)给"每日上限"计数用——按日期串等值查,
不在 SQL 里做跨时区 date 比较(SQLite 上不可靠)。审计 / 对账时整张表可逐笔回溯。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AdRewardRecord(Base):
__tablename__ = "ad_reward_record"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 穿山甲交易号,幂等键(同号回调不重复发奖)
trans_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 实发金币(capped 时为 0)
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# granted(已发) / capped(当日超限未发)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
# 穿山甲上报的奖励名(参考,不作发奖依据)
reward_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 回调原始参数,审计排查用
raw: Mapped[str | None] = mapped_column(String(1024), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} {self.status} coin={self.coin}>"
-32
View File
@@ -1,32 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class OrderRecord(Base):
__tablename__ = "order_record"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False, index=True)
platform: Mapped[str] = mapped_column(String(30), nullable=False)
store_name: Mapped[str] = mapped_column(String(128), nullable=False)
dishes: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
order_date: Mapped[str] = mapped_column(String(10), nullable=False)
price: Mapped[str] = mapped_column(String(20), nullable=False)
original_price: Mapped[str | None] = mapped_column(String(20), nullable=True)
savings: Mapped[str | None] = mapped_column(String(20), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+41
View File
@@ -0,0 +1,41 @@
"""省钱记录表。
是 profile「累计帮你省了」和「省钱战绩」的唯一数据源:每完成一次比价/下单,
省了多少记一行。本期比价还没接后端,数据由 demo seeder 幂等灌入(source='demo'),
聚合接口照常做真实 SUM/分组;等比价真接入后,改成上报写入、停掉 seeder 即可。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class SavingsRecord(Base):
__tablename__ = "savings_record"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 订单金额 / 省下金额,单位:分
order_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
saved_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。
dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
# 来源:demo(演示) / compare(真实比价上报)
source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<SavingsRecord id={self.id} user_id={self.user_id} saved={self.saved_amount_cents}>"
+41
View File
@@ -0,0 +1,41 @@
"""签到记录表。
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class SigninRecord(Base):
__tablename__ = "signin_record"
__table_args__ = (
UniqueConstraint("user_id", "signin_date", name="uq_signin_user_date"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 签到日期(北京时间的 date)
signin_date: Mapped[date] = mapped_column(Date, nullable=False)
cycle_day: Mapped[int] = mapped_column(Integer, nullable=False)
streak: Mapped[int] = mapped_column(Integer, nullable=False)
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<SigninRecord user_id={self.user_id} date={self.signin_date} "
f"day={self.cycle_day} streak={self.streak}>"
)
+36
View File
@@ -0,0 +1,36 @@
"""一次性任务完成记录表。
"打开消息提醒"这类只能领一次的任务,完成后写一行,(user_id, task_key) 唯一,
防止重复领奖。可循环领取的任务(签到)不走这张表,各有专表。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class UserTask(Base):
__tablename__ = "user_task"
__table_args__ = (
UniqueConstraint("user_id", "task_key", name="uq_task_user_key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 任务标识,见 app.core.rewards.TASK_REWARDS
task_key: Mapped[str] = mapped_column(String(48), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="completed")
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
completed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<UserTask user_id={self.user_id} key={self.task_key}>"
+9
View File
@@ -33,6 +33,15 @@ class User(Base):
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 微信开放平台 openid(微信登录绑定后存,提现转账到零钱用)。同一 appid 下唯一;
# unique 索引保证一个微信只能绑一个账号(nullable,允许多个 NULL=未绑定)。
wechat_openid: Mapped[str | None] = mapped_column(
String(64), unique=True, index=True, nullable=True
)
# 微信昵称/头像(绑定时从 sns/userinfo 拉,展示在提现绑定卡)。与上面通用 nickname/avatar_url 分开,不互相覆盖。
wechat_nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
wechat_avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 账号状态:active / disabled / deleted
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
+127
View File
@@ -0,0 +1,127 @@
"""钱包相关表:金币账户 + 金币流水。
设计要点:
- 余额快照(coin_account)用于读取,流水账本(coin_transaction)用于对账,
每次余额变动都写一笔流水并记 balance_after,出问题能逐笔回溯。
- 金额一律存整数:金币是个数,现金存""(cash_balance_cents),不用浮点。
- cash_balance_cents 本步先留列、恒为 0,金币兑现金(第 2 步)再启用。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class CoinAccount(Base):
__tablename__ = "coin_account"
# 一个用户一行,user_id 既是主键也是外键
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), primary_key=True
)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return f"<CoinAccount user_id={self.user_id} coin={self.coin_balance}>"
class CoinTransaction(Base):
__tablename__ = "coin_transaction"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 正数=入账(赚),负数=出账(花/兑换)
amount: Mapped[int] = mapped_column(Integer, nullable=False)
# 本笔变动后的金币余额,对账用
balance_after: Mapped[int] = mapped_column(Integer, nullable=False)
# 业务类型:signin / task_<key> / exchange_out / ...
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 关联业务 id(签到日期、任务 key 等),可空
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<CoinTransaction id={self.id} user_id={self.user_id} amount={self.amount}>"
class WithdrawOrder(Base):
"""提现单(现金 → 微信零钱)。状态机:pending → success / failed。
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金
并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM /
SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态。
"""
__tablename__ = "withdraw_order"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 归一化状态:pending / success / failed
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
# 微信侧原始状态
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 微信转账单号
transfer_bill_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 待用户确认时返回给 App 拉起确认页的 package_info
package_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<WithdrawOrder id={self.id} user_id={self.user_id} cents={self.amount_cents} {self.status}>"
class CashTransaction(Base):
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
__tablename__ = "cash_transaction"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 正数=入账(兑入),负数=出账(提现)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 业务类型:exchange_in / withdraw
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
View File
+108
View File
@@ -0,0 +1,108 @@
"""看激励视频发奖 CRUD。
发奖三道闸(仿提现的资金安全思路):
1. 验签不过 → API 层直接拒,不进这里。
2. trans_id 唯一 → 同一交易号二次回调不重复发(穿山甲会重试)。
3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。
发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务,
保证"记一笔 + 加金币"原子化。
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, cn_today
from app.repositories import wallet as crud_wallet
from app.models.ad_reward import AdRewardRecord
from app.models.user import User
class UnknownUserError(Exception):
"""回调里的 user_id 不存在(可能是伪造)。"""
def _find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
return db.execute(
select(AdRewardRecord).where(AdRewardRecord.trans_id == trans_id)
).scalar_one_or_none()
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute(
select(func.count())
.select_from(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted",
)
).scalar_one()
def grant_ad_reward(
db: Session,
user_id: int,
trans_id: str,
*,
coin: int = AD_REWARD_COIN,
reward_name: str | None = None,
raw: str | None = None,
) -> AdRewardRecord:
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见
rewards.resolve_ad_reward_coin);默认 AD_REWARD_COIN 供 test-grant / 缺省场景用。
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。
"""
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
existing = _find_by_trans(db, trans_id)
if existing is not None:
return existing
if db.get(User, user_id) is None:
raise UnknownUserError
today = cn_today().isoformat()
# #3 每日上限:超了记一条 capped(不发金币),让审计能看到"今天到顶了"
if _granted_today(db, user_id, today) >= DAILY_AD_REWARD_LIMIT:
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
reward_date=today, reward_name=reward_name, raw=raw,
)
return _commit_record(db, rec, trans_id)
# 发金币 + 记一笔,同事务
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="ad_reward", ref_id=trans_id, remark="看广告奖励",
)
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
reward_date=today, reward_name=reward_name, raw=raw,
)
return _commit_record(db, rec, trans_id)
def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardRecord:
"""提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。"""
db.add(rec)
try:
db.commit()
except IntegrityError:
db.rollback()
existing = _find_by_trans(db, trans_id)
if existing is not None:
return existing
raise
db.refresh(rec)
return rec
def today_status(db: Session, user_id: int) -> tuple[int, int, int]:
"""客户端查"今日看广告发奖"进度:返回 (今日已发次数, 每日上限, 单次金币)。"""
used = _granted_today(db, user_id, cn_today().isoformat())
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN
+186
View File
@@ -0,0 +1,186 @@
"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。
设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自
demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。
聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑——
created_at 带时区,统一转成北京时间的 date 再分组。
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ, cn_today
from app.models.savings import SavingsRecord
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
_DEMO_STREAK_DAYS = 9
_DEMO_EXTRA_ORDERS = 14
# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。
# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中;
# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。
_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [
("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]),
("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]),
("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]),
("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]),
("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]),
("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]),
("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]),
("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]),
("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]),
("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]),
]
@dataclass
class SavingsSummary:
total_saved_cents: int
order_count: int
avg_saved_cents: int # 平均每单省(分)
@dataclass
class SavingsBattle:
week_saved_cents: int # 本周(周一起)已省
beat_percent: int # 超过百分之多少用户
streak_days: int # 连续省钱天数
def _local_date(dt: datetime):
"""把(可能带时区的)created_at 转成北京时间的 date。"""
if dt.tzinfo is None:
return dt.date()
return dt.astimezone(CN_TZ).date()
def _all_records(db: Session, user_id: int) -> list[SavingsRecord]:
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
return list(db.execute(stmt).scalars().all())
def ensure_seeded(db: Session, user_id: int) -> None:
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
exists = db.execute(
select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1)
).first()
if exists is not None:
return
rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致
today = cn_today()
records: list[SavingsRecord] = []
def _mk(day) -> SavingsRecord:
hour = rng.randint(8, 21)
minute = rng.randint(0, 59)
ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ)
platform, shop, dishes = rng.choice(_DEMO_ORDERS)
# 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡
saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元
return SavingsRecord(
user_id=user_id,
order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价)
saved_amount_cents=saved,
platform=platform,
title=shop,
shop_name=shop,
dishes=dishes,
source="demo",
created_at=ts,
)
# 最近 N 天每天 1 单 → 连续省钱 N 天
for d in range(_DEMO_STREAK_DAYS):
records.append(_mk(today - timedelta(days=d)))
# 历史散单(第 10~40 天)
for _ in range(_DEMO_EXTRA_ORDERS):
records.append(_mk(today - timedelta(days=rng.randint(10, 40))))
db.add_all(records)
db.commit()
def get_summary(db: Session, user_id: int) -> SavingsSummary:
ensure_seeded(db, user_id)
records = _all_records(db, user_id)
total = sum(r.saved_amount_cents for r in records)
count = len(records)
avg = total // count if count else 0
return SavingsSummary(total_saved_cents=total, order_count=count, avg_saved_cents=avg)
def _streak_days(dates: set) -> int:
"""从最近活跃日往前数连续有省钱记录的天数。"""
if not dates:
return 0
cur = max(dates)
streak = 0
while cur in dates:
streak += 1
cur = cur - timedelta(days=1)
return streak
def get_battle(db: Session, user_id: int) -> SavingsBattle:
ensure_seeded(db, user_id)
records = _all_records(db, user_id)
today = cn_today()
week_start = today - timedelta(days=today.weekday()) # 本周一
week_saved = sum(
r.saved_amount_cents for r in records if _local_date(r.created_at) >= week_start
)
dates = {_local_date(r.created_at) for r in records}
streak = _streak_days(dates)
beat_percent = _compute_beat_percent(db, user_id)
return SavingsBattle(
week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak
)
def _compute_beat_percent(db: Session, user_id: int) -> int:
"""超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。
= 严格少于我的其他用户数 / 其他用户总数 * 100。
全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。
"""
rows = db.execute(
select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents))
.group_by(SavingsRecord.user_id)
).all()
totals = {uid: (total or 0) for uid, total in rows}
others = {uid: t for uid, t in totals.items() if uid != user_id}
if not others:
return 0
my_total = totals.get(user_id, 0)
beaten = sum(1 for t in others.values() if t < my_total)
return round(100 * beaten / len(others))
def list_records(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[SavingsRecord], int | None]:
"""省钱明细分页(按 id 倒序,游标式)。"""
ensure_seeded(db, user_id)
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
if cursor is not None:
stmt = stmt.where(SavingsRecord.id < cursor)
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
+126
View File
@@ -0,0 +1,126 @@
"""签到 CRUD:状态查询 + 执行签到。
7 天循环规则:
- 昨天签过 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续)
- 否则(首签 / 断签) cycle_day = 1,streak = 1(重置)
今天的 cycle_day 决定发多少金币( app.core.rewards.SIGNIN_REWARDS)
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import SIGNIN_CYCLE_LEN, SIGNIN_REWARDS, cn_today, signin_reward
from app.repositories import wallet as crud_wallet
from app.models.signin import SigninRecord
class AlreadySignedError(Exception):
"""今天已经签过了。"""
@dataclass
class SigninStep:
day: int # 1..7
coin: int
status: str # claimed / today / locked
@dataclass
class SigninStatus:
today_signed: bool
consecutive_days: int # 当前已确认的连续签到天数
today_cycle_day: int # 今天落在循环的第几档(1..7)
today_coin: int # 今天这一档的金币
can_claim: bool # 现在能否签到(= not today_signed)
steps: list[SigninStep]
def _latest_record(db: Session, user_id: int) -> SigninRecord | None:
stmt = (
select(SigninRecord)
.where(SigninRecord.user_id == user_id)
.order_by(SigninRecord.signin_date.desc())
.limit(1)
)
return db.execute(stmt).scalar_one_or_none()
def _next_cycle_day(last: SigninRecord | None, today) -> int:
"""若现在签到,今天会落在第几档(1..7)。"""
if last is not None and last.signin_date == today - timedelta(days=1):
return last.cycle_day % SIGNIN_CYCLE_LEN + 1
return 1
def get_status(db: Session, user_id: int) -> SigninStatus:
today = cn_today()
last = _latest_record(db, user_id)
today_signed = last is not None and last.signin_date == today
if today_signed:
today_cycle_day = last.cycle_day
consecutive_days = last.streak
else:
today_cycle_day = _next_cycle_day(last, today)
# 未签时的"已连续"= 还在连续窗口内的既有 streak,否则 0
if last is not None and last.signin_date == today - timedelta(days=1):
consecutive_days = last.streak
else:
consecutive_days = 0
steps: list[SigninStep] = []
for day in range(1, SIGNIN_CYCLE_LEN + 1):
if today_signed:
status = "claimed" if day <= today_cycle_day else "locked"
else:
if day < today_cycle_day:
status = "claimed"
elif day == today_cycle_day:
status = "today"
else:
status = "locked"
steps.append(SigninStep(day=day, coin=SIGNIN_REWARDS[day - 1], status=status))
return SigninStatus(
today_signed=today_signed,
consecutive_days=consecutive_days,
today_cycle_day=today_cycle_day,
today_coin=signin_reward(today_cycle_day),
can_claim=not today_signed,
steps=steps,
)
def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
"""执行签到。返回 (签到记录, 签到后金币余额)。今天已签则抛 AlreadySignedError。"""
today = cn_today()
last = _latest_record(db, user_id)
if last is not None and last.signin_date == today:
raise AlreadySignedError
if last is not None and last.signin_date == today - timedelta(days=1):
cycle_day = last.cycle_day % SIGNIN_CYCLE_LEN + 1
streak = last.streak + 1
else:
cycle_day = 1
streak = 1
coin = signin_reward(cycle_day)
record = SigninRecord(
user_id=user_id,
signin_date=today,
cycle_day=cycle_day,
streak=streak,
coin_awarded=coin,
)
db.add(record)
acc, _ = crud_wallet.grant_coins(
db, user_id, coin, biz_type="signin", ref_id=today.isoformat()
)
db.commit()
db.refresh(record)
return record, acc.coin_balance
+71
View File
@@ -0,0 +1,71 @@
"""一次性任务 CRUD:列表状态 + 领奖。
领奖原子化: user_task(唯一约束防重复)+ 发金币,同一事务 commit
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import TASK_REWARDS
from app.repositories import wallet as crud_wallet
from app.models.task import UserTask
class UnknownTaskError(Exception):
"""task_key 不在已知任务表里。"""
class AlreadyClaimedError(Exception):
"""该任务已经领过奖了。"""
@dataclass
class TaskState:
task_key: str
coin: int
claimed: bool
def list_tasks(db: Session, user_id: int) -> list[TaskState]:
"""返回所有已知一次性任务及其领取状态。"""
stmt = select(UserTask.task_key).where(UserTask.user_id == user_id)
claimed_keys = set(db.execute(stmt).scalars().all())
return [
TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)
for key, coin in TASK_REWARDS.items()
]
def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
"""领取一次性任务奖励。返回 (发放金币, 领奖后余额)。
未知任务抛 UnknownTaskError;重复领取抛 AlreadyClaimedError
"""
if task_key not in TASK_REWARDS:
raise UnknownTaskError
existing = db.execute(
select(UserTask).where(
UserTask.user_id == user_id, UserTask.task_key == task_key
)
).scalar_one_or_none()
if existing is not None:
raise AlreadyClaimedError
coin = TASK_REWARDS[task_key]
db.add(
UserTask(
user_id=user_id,
task_key=task_key,
status="completed",
coin_awarded=coin,
)
)
acc, _ = crud_wallet.grant_coins(
db, user_id, coin, biz_type=f"task_{task_key}", ref_id=task_key
)
db.commit()
return coin, acc.coin_balance
+506
View File
@@ -0,0 +1,506 @@
"""钱包 CRUD:账户读取 + 金币入账(共享给签到 / 任务)+ 流水查询。
`grant_coins` 是所有"发金币"的唯一入口:它更新余额快照并写一笔流水,** commit**,
由调用方在同一事务里 commit(签到要同时写 signin_record,任务要同时写 user_task,
保证"记录""加金币"原子化,不会只发钱不留痕或反之)
"""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app.core.rewards import (
COIN_PER_CENT,
MIN_EXCHANGE_COIN,
WITHDRAW_MAX_CENTS,
WITHDRAW_MIN_CENTS,
coins_to_cents,
)
from app.integrations import wxpay
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
_WX_STATE_SUCCESS = "SUCCESS"
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
class InvalidExchangeAmountError(Exception):
"""兑换金币数非法(<最小额 或 不是整分倍数)。"""
class InsufficientCoinError(Exception):
"""金币余额不足。"""
class InvalidWithdrawAmountError(Exception):
"""提现金额超出允许范围。"""
class WechatNotBoundError(Exception):
"""用户未绑定微信(无 openid),无法提现。"""
class WechatAlreadyBoundError(Exception):
"""该微信(openid)已绑定到其他账号。"""
class InsufficientCashError(Exception):
"""现金余额不足。"""
class WithdrawTransferError(Exception):
"""调用微信转账失败(已退回余额)。"""
class WithdrawOrderNotFound(Exception):
"""提现单不存在。"""
def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount:
"""取用户金币账户,不存在则建一个空账户。"""
acc = db.get(CoinAccount, user_id)
if acc is None:
acc = CoinAccount(
user_id=user_id,
coin_balance=0,
cash_balance_cents=0,
total_coin_earned=0,
)
db.add(acc)
if commit:
db.commit()
db.refresh(acc)
else:
db.flush()
return acc
def grant_coins(
db: Session,
user_id: int,
amount: int,
*,
biz_type: str,
ref_id: str | None = None,
remark: str | None = None,
) -> tuple[CoinAccount, CoinTransaction]:
"""金币变动入口(正数入账 / 负数出账)。更新余额 + 写流水,不 commit。
返回 (account, transaction)调用方负责 commit
"""
acc = get_or_create_account(db, user_id, commit=False)
acc.coin_balance += amount
if amount > 0:
acc.total_coin_earned += amount
txn = CoinTransaction(
user_id=user_id,
amount=amount,
balance_after=acc.coin_balance,
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
)
db.add(txn)
db.flush()
return acc, txn
def list_coin_transactions(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[CoinTransaction], int | None]:
"""金币流水分页(按 id 倒序,游标式)。
cursor 为上一页最后一条的 id;返回 (本页列表, next_cursor)
next_cursor None 表示没有下一页
"""
stmt = select(CoinTransaction).where(CoinTransaction.user_id == user_id)
if cursor is not None:
stmt = stmt.where(CoinTransaction.id < cursor)
stmt = stmt.order_by(CoinTransaction.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
def exchange_coins_to_cash(
db: Session, user_id: int, coin_amount: int
) -> tuple[CoinAccount, int]:
"""金币兑现金。返回 (account, 本次兑入的分)。
校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError),
余额充足(InsufficientCoinError)扣金币 + 加现金,两条流水同事务 commit
"""
if coin_amount < MIN_EXCHANGE_COIN or coin_amount % COIN_PER_CENT != 0:
raise InvalidExchangeAmountError
acc = get_or_create_account(db, user_id, commit=False)
if acc.coin_balance < coin_amount:
raise InsufficientCoinError
cents = coins_to_cents(coin_amount)
remark = f"{coin_amount}金币兑{cents}"
# 1. 扣金币(写 coin_transaction)
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=remark)
# 2. 加现金(写 cash_transaction)
acc.cash_balance_cents += cents
db.add(
CashTransaction(
user_id=user_id,
amount_cents=cents,
balance_after_cents=acc.cash_balance_cents,
biz_type="exchange_in",
remark=remark,
)
)
db.commit()
db.refresh(acc)
return acc, cents
def list_cash_transactions(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[CashTransaction], int | None]:
"""现金流水分页(按 id 倒序,游标式)。"""
stmt = select(CashTransaction).where(CashTransaction.user_id == user_id)
if cursor is not None:
stmt = stmt.where(CashTransaction.id < cursor)
stmt = stmt.order_by(CashTransaction.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
# ===== 提现(现金 → 微信零钱) =====
def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
"""用微信授权 code 换 openid + 昵称头像并绑定到用户。失败抛 ValueError。
返回 {openid, nickname, avatar_url}(昵称头像可能为 None, wxpay.code_to_userinfo)"""
info = wxpay.code_to_userinfo(code) # 失败抛 ValueError
user = db.get(User, user_id)
if user is None:
raise WithdrawOrderNotFound # 理论上当前用户必存在
# #5 一个微信只能绑一个账号:若该 openid 已属于别的用户,拒绝(防多账号薅羊毛/重复提现)
other = db.execute(
select(User.id).where(User.wechat_openid == info["openid"], User.id != user_id)
).scalar_one_or_none()
if other is not None:
raise WechatAlreadyBoundError
user.wechat_openid = info["openid"]
user.wechat_nickname = info["nickname"]
user.wechat_avatar_url = info["avatar_url"]
db.commit()
return info
def unbind_wechat_openid(db: Session, user_id: int) -> None:
"""解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。"""
user = db.get(User, user_id)
if user is None:
raise WithdrawOrderNotFound
if user.wechat_openid is not None:
user.wechat_openid = None
user.wechat_nickname = None
user.wechat_avatar_url = None
db.commit()
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int) -> bool:
"""原子扣减现金:仅当余额足够时扣,返回是否成功。
用带条件的 UPDATE(`WHERE cash_balance_cents >= amount`)避免"读-判断-写"竞态
并发/重试时不会两次都通过余额检查导致超额扣款(SQLite 串行写Postgres 行级,均安全)
"""
res = db.execute(
update(CoinAccount)
.where(
CoinAccount.user_id == user_id,
CoinAccount.cash_balance_cents >= amount_cents,
)
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
)
return res.rowcount == 1
def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
"""原子增加现金(退款用),返回加后余额。"""
db.execute(
update(CoinAccount)
.where(CoinAccount.user_id == user_id)
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents)
)
db.flush()
bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
).scalar_one()
return bal
def _refund_withdraw(db: Session, order: WithdrawOrder, reason: str) -> None:
"""转账失败/取消:退回现金 + 写 withdraw_refund 流水 + 单子置 failed。幂等:已 failed 不重复退。"""
if order.status == "failed":
return # 防重复退款(并发/对账与查单同时触发)
bal = _add_cash(db, order.user_id, order.amount_cents)
db.add(
CashTransaction(
user_id=order.user_id,
amount_cents=order.amount_cents,
balance_after_cents=bal,
biz_type="withdraw_refund",
ref_id=order.out_bill_no,
remark="提现未成功,金额已退回", # 用户可见;技术原因记在 order.fail_reason
)
)
order.status = "failed"
order.fail_reason = reason[:256]
db.commit()
def _wx_not_found(result: dict) -> bool:
"""微信查单/转账响应是否表示'此单不存在'(说明转账从未创建,退款安全)。"""
if result.get("status_code") == 404:
return True
data = result.get("data")
code = data.get("code", "") if isinstance(data, dict) else ""
return "NOT_FOUND" in str(code)
def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> None:
"""转账调用结果不明(超时/异常/非200)时,**先查单再决定**,绝不盲目退款(防退款后又到账)。
- 微信查到 SUCCESS 钱已出, success,不退款
- 微信查到 FAIL/取消/关闭 退款
- 微信查到在途/待确认 保持 pending(带上 package_info/state),交给查单/对账后续处理
- 微信明确无此单 从未创建,退款安全
- 查询本身也失败 保持 pending,留给对账任务(reconcile)兜底,绝不退款
"""
try:
q = wxpay.query_transfer(order.out_bill_no)
except Exception: # noqa: BLE001 — 查询都失败,保持 pending 等对账
order.fail_reason = (reason + " | 查单异常,保持pending")[:256]
db.commit()
return
if q["status_code"] != 200:
if _wx_not_found(q):
_refund_withdraw(db, order, reason=reason + " (微信无此单,已退回)")
else:
order.fail_reason = (reason + " | 查单非200,保持pending")[:256]
db.commit()
return
state = q["data"].get("state", "")
order.wechat_state = state
if state == _WX_STATE_SUCCESS:
order.status = "success"
order.transfer_bill_no = q["data"].get("transfer_bill_no")
db.commit()
elif state in _WX_STATE_FAILED:
_refund_withdraw(db, order, reason=reason)
else:
order.package_info = q["data"].get("package_info") or order.package_info
db.commit()
def create_withdraw(
db: Session,
user_id: int,
amount_cents: int,
*,
user_name: str | None = None,
out_bill_no: str | None = None,
) -> WithdrawOrder:
"""发起提现(原子扣款 + 幂等 + 模糊失败查单)。
#1 用原子条件 UPDATE 扣款,杜绝并发超额。
#2 out_bill_no 由客户端提供作幂等键:同号重试不会重复转账,直接返回该单当前状态。
#3 微信调用结果不明时先查单再决定,绝不盲目退款。
返回提现单(可能含 package_info App 拉起确认页)
"""
if amount_cents < WITHDRAW_MIN_CENTS or amount_cents > WITHDRAW_MAX_CENTS:
raise InvalidWithdrawAmountError
user = db.get(User, user_id)
openid = user.wechat_openid if user else None
if not openid:
raise WechatNotBoundError
# #2 幂等:客户端传了合法 out_bill_no 且该单已存在 → 返回现状(pending 的顺手查一下),不重复发起
if out_bill_no and _OUT_BILL_NO_RE.match(out_bill_no):
existing = db.execute(
select(WithdrawOrder).where(
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
)
).scalar_one_or_none()
if existing is not None:
if existing.status == "pending":
return refresh_withdraw_status(db, user_id, out_bill_no)
return existing
else:
out_bill_no = uuid.uuid4().hex
# 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True)
# #1 原子扣款:余额不足时影响行数为 0
if not _try_deduct_cash(db, user_id, amount_cents):
db.rollback()
raise InsufficientCashError
bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
).scalar_one()
db.add(
CashTransaction(
user_id=user_id,
amount_cents=-amount_cents,
balance_after_cents=bal,
biz_type="withdraw",
ref_id=out_bill_no,
remark="提现到微信零钱",
)
)
order = WithdrawOrder(
user_id=user_id, out_bill_no=out_bill_no, amount_cents=amount_cents, status="pending"
)
db.add(order)
db.commit()
db.refresh(order)
# 2) 调微信转账;结果不明先查单再决定(#3)
try:
result = wxpay.create_transfer(openid, amount_cents, out_bill_no, user_name=user_name)
except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认
_settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}")
db.refresh(order)
if order.status == "failed":
raise WithdrawTransferError(str(e)) from e
return order
if result["status_code"] != 200:
msg = str(result["data"].get("message") or result["data"])
_settle_after_ambiguous(db, order, reason=msg)
db.refresh(order)
if order.status == "failed":
raise WithdrawTransferError(msg)
return order
data = result["data"]
order.wechat_state = data.get("state")
order.transfer_bill_no = data.get("transfer_bill_no")
order.package_info = data.get("package_info")
if data.get("state") == _WX_STATE_SUCCESS:
order.status = "success"
db.commit()
db.refresh(order)
return order
def refresh_withdraw_status(
db: Session, user_id: int, out_bill_no: str, *, cancel_if_unconfirmed: bool = False
) -> WithdrawOrder:
"""查微信单状态并归一化:SUCCESS→success;FAIL/CANCELLED/CLOSED→退款+failed;其余保持 pending。
cancel_if_unconfirmed:查到 WAIT_USER_CONFIRM ,是否当作"用户放弃"撤销并退款
- True:用户从确认页返回后查单或对账老单时传 撤销+退款
- False(默认):幂等重试只想读状态时传 保留 pending,不能误撤用户还想确认的单
"""
order = db.execute(
select(WithdrawOrder).where(
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
)
).scalar_one_or_none()
if order is None:
raise WithdrawOrderNotFound
if order.status != "pending":
return order # 已终态,不再查
result = wxpay.query_transfer(out_bill_no)
if result["status_code"] != 200:
if _wx_not_found(result):
# 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
_refund_withdraw(db, order, reason="微信无此单,已退回")
db.refresh(order)
return order # 其他查询失败不改状态,保持 pending 等下次
state = result["data"].get("state", "")
order.wechat_state = state
if state == _WX_STATE_SUCCESS:
order.status = "success"
db.commit()
elif state in _WX_STATE_FAILED:
_refund_withdraw(db, order, reason=f"微信转账状态 {state}")
elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
# 用户从确认页回来了却仍未确认 → 视为放弃:撤销微信单(防事后确认导致重复打款)后退款。
# 撤单失败(可能已被确认进 ACCEPTED 的竞态)则保持 pending,等下次查询。
cancel = wxpay.cancel_transfer(out_bill_no)
if cancel["status_code"] in (200, 202):
_refund_withdraw(db, order, reason="用户未确认,已撤销提现")
else:
db.commit()
else:
db.commit() # ACCEPTED / PROCESSING 等真在途,仍 pending
db.refresh(order)
return order
def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) -> dict:
"""对账:扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化(成功/退款/撤销未确认)。
防止"扣了款但转账没发起/没确认"的孤儿单永久锁住用户余额建议用 cron/systemd timer 定时跑
返回 {checked, resolved} 计数"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
orders = list(
db.execute(
select(WithdrawOrder).where(
WithdrawOrder.status == "pending", WithdrawOrder.created_at < cutoff
)
).scalars().all()
)
resolved = 0
for o in orders:
try:
# 老单视同"用户已放弃确认",查到 WAIT_USER_CONFIRM 也撤销退款
refreshed = refresh_withdraw_status(
db, o.user_id, o.out_bill_no, cancel_if_unconfirmed=True
)
if refreshed.status != "pending":
resolved += 1
except Exception: # noqa: BLE001 — 单笔失败不影响其余,下轮再试
db.rollback()
continue
return {"checked": len(orders), "resolved": resolved}
def list_withdraw_orders(
db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None
) -> tuple[list[WithdrawOrder], int | None]:
"""提现单分页(按 id 倒序,游标式)。"""
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id)
if cursor is not None:
stmt = stmt.where(WithdrawOrder.id < cursor)
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
+41
View File
@@ -0,0 +1,41 @@
"""看激励视频发奖相关 schemas。
约定同其余模块:字段 snake_case金额/次数存整数
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class PangleCallbackOut(BaseModel):
"""回给穿山甲 GroMore 的回调响应。
GroMore 服务端激励回调规范(supportcenter/26240)要求响应体为
`{"is_verify": bool, "reason": int}`:
- `is_verify=true` + `reason=0`:校验通过发放奖励(发奖成功 当日已达上限均算"已处理")
- `is_verify=false`:不发放,`reason` 带错误码透传客户端 SDK( ad.py REASON_*)
验签失败由 API 层直接返 403,不走这里"""
is_verify: bool = True
reason: int = 0
class AdRewardStatusOut(BaseModel):
"""客户端查今日看广告发奖进度(福利页"看视频赚金币"用)。"""
used_today: int = Field(..., description="今日已成功发奖次数")
daily_limit: int = Field(..., description="每日发奖次数上限")
remaining: int = Field(..., description="今日剩余可领次数")
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
class TestGrantOut(BaseModel):
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)")
status: str = Field(..., description="granted / capped")
coin: int = Field(..., description="本次发放金币(capped 时为 0)")
used_today: int = Field(..., description="今日已成功发奖次数")
daily_limit: int = Field(..., description="每日发奖次数上限")
remaining: int = Field(..., description="今日剩余可领次数")
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
-22
View File
@@ -1,22 +0,0 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class OrderRecordOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
platform: str
store_name: str
dishes: list[str]
order_date: str
price: str
original_price: str | None = None
savings: str | None = None
class OrderListResponse(BaseModel):
records: list[OrderRecordOut]
has_next: bool = False
page: int = 1
+214
View File
@@ -0,0 +1,214 @@
"""福利模块(钱包 / 签到 / 任务)请求 / 响应 schemas。
约定同 auth:字段 snake_case时间 ISO 8601(UTC)金额存整数
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ===== 钱包 / 我的资产 =====
class CoinAccountOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
coin_balance: int = Field(..., description="当前金币余额")
cash_balance_cents: int = Field(..., description="当前现金余额(分)")
total_coin_earned: int = Field(..., description="累计赚取金币")
class CoinTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
amount: int = Field(..., description="正=入账,负=出账")
balance_after: int
biz_type: str
ref_id: str | None = None
remark: str | None = None
created_at: datetime
class CoinTransactionPage(BaseModel):
items: list[CoinTransactionOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
class CashTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
amount_cents: int = Field(..., description="正=兑入,负=提现")
balance_after_cents: int
biz_type: str
ref_id: str | None = None
remark: str | None = None
created_at: datetime
class CashTransactionPage(BaseModel):
items: list[CashTransactionOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
# ===== 金币兑现金 =====
class ExchangeInfoOut(BaseModel):
coin_per_yuan: int = Field(..., description="多少金币兑 1 元")
min_coin: int = Field(..., description="单次兑换最少金币")
step_coin: int = Field(..., description="兑换金币需为该值的整数倍(整分)")
class ExchangeRequest(BaseModel):
coin_amount: int = Field(..., gt=0, description="要兑换的金币数")
class ExchangeResultOut(BaseModel):
coin_amount: int = Field(..., description="本次扣除的金币")
cash_added_cents: int = Field(..., description="本次兑入的现金(分)")
coin_balance: int = Field(..., description="兑换后金币余额")
cash_balance_cents: int = Field(..., description="兑换后现金余额(分)")
# ===== 提现(现金 → 微信零钱) =====
class WithdrawInfoOut(BaseModel):
min_cents: int = Field(..., description="单次最低提现(分)")
max_cents: int = Field(..., description="单次最高提现(分)")
wechat_bound: bool = Field(..., description="当前用户是否已绑定微信")
wechat_nickname: str | None = Field(None, description="微信昵称(可能为空/脱敏)")
wechat_avatar_url: str | None = Field(None, description="微信头像 URL(可能为空)")
class BindWechatRequest(BaseModel):
code: str = Field(..., description="微信授权返回的 code")
class BindWechatResultOut(BaseModel):
bound: bool = True
wechat_nickname: str | None = None
wechat_avatar_url: str | None = None
class UnbindWechatResultOut(BaseModel):
bound: bool = False
class WithdrawRequest(BaseModel):
amount_cents: int = Field(..., gt=0, description="提现金额(分)")
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
)
class WithdrawResultOut(BaseModel):
out_bill_no: str = Field(..., description="商户提现单号(查单用)")
status: str = Field(..., description="pending / success / failed")
wechat_state: str | None = Field(None, description="微信侧原始状态")
amount_cents: int
cash_balance_cents: int = Field(..., description="提现后现金余额(分)")
# 供 App 拉起微信确认页(WXOpenBusinessView requestMerchantTransfer)
package_info: str | None = None
mch_id: str | None = None
app_id: str | None = None
class WithdrawStatusOut(BaseModel):
out_bill_no: str
status: str = Field(..., description="pending / success / failed")
wechat_state: str | None = None
amount_cents: int
class WithdrawOrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
out_bill_no: str
amount_cents: int
status: str
wechat_state: str | None = None
fail_reason: str | None = None
created_at: datetime
class WithdrawOrderPage(BaseModel):
items: list[WithdrawOrderOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
# ===== 签到 =====
class SigninStepOut(BaseModel):
day: int = Field(..., description="循环内第几天 1..7")
coin: int
status: str = Field(..., description="claimed / today / locked")
class SigninStatusOut(BaseModel):
today_signed: bool
consecutive_days: int
today_cycle_day: int
today_coin: int
can_claim: bool
steps: list[SigninStepOut]
class SigninResultOut(BaseModel):
coin_awarded: int
cycle_day: int
streak: int
coin_balance: int = Field(..., description="签到后金币余额")
# ===== 任务 =====
class TaskOut(BaseModel):
task_key: str
coin: int
claimed: bool
class TaskListOut(BaseModel):
items: list[TaskOut]
class TaskClaimResultOut(BaseModel):
task_key: str
coin_awarded: int
coin_balance: int = Field(..., description="领奖后金币余额")
# ===== 省钱(累计帮你省了 / 省钱战绩 / 明细)=====
class SavingsSummaryOut(BaseModel):
total_saved_cents: int = Field(..., description="累计省下(分)")
order_count: int = Field(..., description="累计省钱订单数")
avg_saved_cents: int = Field(..., description="平均每单省(分)")
class SavingsBattleOut(BaseModel):
week_saved_cents: int = Field(..., description="本周已省(分)")
beat_percent: int = Field(..., description="超过百分之多少用户")
streak_days: int = Field(..., description="连续省钱天数")
class SavingsRecordOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
order_amount_cents: int
saved_amount_cents: int
platform: str | None = None
title: str | None = None
shop_name: str | None = None
dishes: list[str] = []
created_at: datetime
class SavingsRecordPage(BaseModel):
items: list[SavingsRecordOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
+25
View File
@@ -0,0 +1,25 @@
# 后端文档库(docs/)
`shaguabijia-app-server` 的文档都在这里。各文档作用:
| 文档 / 目录 | 作用 |
|---|---|
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 |
| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 |
| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 |
| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 |
| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 |
## api/ 目录是怎么组织的(传送门式)
接口多了之后,单个大文件会臃肿、难维护,所以拆成 **一个索引 + 一个接口一个文件**:
- **[api/README.md](./api/README.md) — 入口/传送门**
一张总览表列出**全部接口**(方法、路径、鉴权),每行链接到该接口的独立文档;另含**通用约定**(错误码、时间格式等)和**复用数据结构**(`TokenPair` / `UserOut` / `CouponCard` 等)。它本身不展开每个接口的细节,只负责"指路"。
- **api/&lt;模块&gt;-&lt;接口&gt;.md — 单接口文档**
每个接口一个文件,只写自己的入参 / 出参 / 错误码 / 说明。命名按 `<模块>-<接口>`,如 `auth-jverify-login.md``coupon-step.md``meituan-feed.md`
**查某个接口的协议**:先打开 [api/README.md](./api/README.md) 的总览表 → 找到接口 → 点链接进对应文档。
> **新增接口时**:在 `api/` 下加一个 `<模块>-<接口>.md`,并到 `api/README.md` 总览表里补一行链接。这样索引始终是唯一的"传送门",细节各自独立、互不干扰。
+140
View File
@@ -0,0 +1,140 @@
# 傻瓜比价 App 后端 — API 接口文档(索引)
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-05-27
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
## 接口总览
| # | 方法 + 路径 | 鉴权 | 详情 |
|---|---|---|---|
| 1 | `GET /health` | 无 | [详情](./health.md) |
| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth-jverify-login.md) |
| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth-sms-send.md) |
| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth-sms-login.md) |
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) |
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) |
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) |
| 8 | `POST /api/v1/coupon/step` | Bearer | [详情](./coupon-step.md) |
| 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/wallet` |||
| 12 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 13 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
| 14 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
| 15 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
| 16 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
| 17 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
| 18 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
| 19 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
| 20 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
| 21 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) |
| 22 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) |
| **签到**(前缀 `/api/v1/signin` |||
| 23 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
| 24 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
| **任务**(前缀 `/api/v1/tasks` |||
| 25 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
| 26 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
| **省钱**(前缀 `/api/v1/savings` |||
| 27 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
| 28 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
| 29 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
| **看广告发奖**(前缀 `/api/v1/ad` |||
| 30 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
| 31 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
| 32 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
> 金额字段一律以**分**为单位(`*_cents`)。
---
## 通用约定
- **时间格式**:ISO 8601 UTC(如 `2026-05-27T12:34:56Z`
- **错误响应**:FastAPI 标准结构 `{"detail": "<错误信息>"}`,配合语义化 HTTP 状态码:
- `400` 业务参数错(如验证码不对)
- `401` 未认证 / token 无效或过期 / 用户被禁(响应头带 `WWW-Authenticate: Bearer`
- `403` 账号被禁用
- `422` 请求体字段校验失败(FastAPI 自动校验,如手机号格式)
- `429` 触发限流(短信发送过频)
- `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` 倒序(最新在前)。
---
## 复用数据结构
### TokenPair
| 字段 | 类型 | 说明 |
|---|---|---|
| `access_token` | string | 访问令牌,之后每个鉴权请求带 `Authorization: Bearer <它>` |
| `refresh_token` | string | 刷新令牌 |
| `token_type` | string | 固定 `"Bearer"` |
| `expires_in` | int | access 剩余秒数(默认 7200 = 2h |
| `refresh_expires_in` | int | refresh 剩余秒数(默认 2592000 = 30d |
### TokenWithUser
继承 `TokenPair` 全部字段,额外多一个 `user``UserOut`)。登录类接口成功时返回此结构。
### UserOut
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 用户主键 |
| `phone` | string | 手机号 |
| `nickname` | string \| null | 昵称(当前无接口可改,恒为 null) |
| `avatar_url` | string \| null | 头像(同上) |
| `register_channel` | string | 注册渠道:`jverify` / `sms` |
| `status` | string | `active` / `disabled` / `deleted` |
| `created_at` | datetime | 注册时间 |
| `last_login_at` | datetime | 最近登录时间 |
### CouponCard(券卡片,`coupons``feed``items` 元素)
| 字段 | 类型 | 说明 |
|---|---|---|
| `product_view_sign` | string | **换链主键**(传给 `referral-link` |
| `platform` | int | `1`=外卖/到家, `2`=到店 |
| `biz_line` | int \| null | 到店子类:1到餐 2到综 3酒店 4门票 |
| `name` | string | 商品名 |
| `head_image_url` | string | 头图 |
| `brand_name` | string \| null | 品牌名 |
| `brand_logo_url` | string \| null | 品牌 logo |
| `sell_price` | string | 现价 |
| `original_price` | string | 原价 |
| `discount_amount` | string | 优惠额 = 原价 现价 |
| `commission_rate` | string | 佣金比例,如 `"1.4%"` |
| `commission_amount` | string \| null | 预估佣金(元) |
| `sale_volume` | string \| null | 销量 |
| `poi_name` | string \| null | 最近门店名 |
| `distance_text` | string \| null | 格式化距离,如 `"600m"` / `"1.5km"` |
| `distance_meters` | float \| null | 原始距离(米) |
| `available_poi_num` | int \| null | 可用门店数 |
| `coupon_num` | int \| null | 券张数 |
| `valid_days` | int \| null | 券有效天数 |
| `price_label` | string \| null | 如 `"15天低价"` |
| `rank_label` | string \| null | 如 `"2小时北京外卖销量榜第1名"` |
| `rating_label` | string \| null | 如 `"4.6分"` |
---
## 附:鉴权与刷新机制
- **签发**:登录成功后签发 access(HS256,2h) + refresh(30d),payload 含 `sub`(user_id)、`typ`(access/refresh)、`iat``exp`
- **携带**:客户端对需鉴权接口自动加 `Authorization: Bearer <access>`(登录类接口跳过)。
- **校验**`/me``/logout`:验签 → 验过期 → 验 `typ=access` → 查库确认用户存在且 `status=active`,任一不过 → 401。
- **续期**:access 过期触发 401 → 客户端用 refresh 调 `/refresh` 换新 token 对并重放原请求;refresh 也失效 → 清本地、跳登录。
- **无状态**:token 不落库,服务端无法主动吊销(logout 为占位),靠短 access 有效期限制泄露风险。JWT 密钥 `JWT_SECRET_KEY` 必须为高熵随机串(生产严禁用默认值)。
+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`,幂等 + 每日上限)。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
+22
View File
@@ -0,0 +1,22 @@
# POST /api/v1/auth/jverify-login — 极光一键登录
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/jiguang](../integrations/jiguang.md)(极光核验链路、RSA 解密策略、私钥配对踩坑)。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `login_token` | string | ✅(非空) | 客户端极光 `loginAuth` 拿到的 loginToken |
| `operator` | string | ❌(默认 `""` | 运营商标识 CM/CU/CT,仅用于后端日志 |
## 出参
响应 `200`:`TokenWithUser``user.register_channel``"jverify"`)。结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
- `502` 极光验证或 RSA 解密失败
- `403` 账号被禁用
## 说明
后端验真 `login_token` 取回并解密手机号 → 注册即登录(手机号不存在则建号)→ 签发 JWT。极光核验 + RSA 解密的实现细节见 [integrations/jiguang](../integrations/jiguang.md)。
+15
View File
@@ -0,0 +1,15 @@
# POST /api/v1/auth/logout — 登出
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
## 入参
## 出参
响应 `200`:`{ "ok": true }`
## 错误码
## 说明
服务端**无状态、不吊销 token**(占位实现),真正失效靠客户端删除本地 token。后续若加 jti 黑名单可在此写入。
+15
View File
@@ -0,0 +1,15 @@
# GET /api/v1/auth/me — 获取当前登录用户
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
## 入参
无(身份取自 Header token
## 出参
响应 `200`:`UserOut`。结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
- `401` 未带 token / token 无效或过期 / 用户被禁用
## 说明
+18
View File
@@ -0,0 +1,18 @@
# POST /api/v1/auth/refresh — 刷新 token
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token | [← 返回 API 索引](./README.md)
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `refresh_token` | string | ✅ | 有效的 refresh token |
## 出参
响应 `200`:`TokenPair`**注意:不含 `user` 字段**)。结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
- `401` refresh token 无效/过期/类型不对,或用户不存在/被禁用
## 说明
服务端校验 refresh token(验签 + 验过期 + 验类型为 refresh)并查库确认用户仍 active,然后签发**全新的** access+refresh 对。客户端在请求收到 401 时由 OkHttp `Authenticator` 自动调用。
+22
View File
@@ -0,0 +1,22 @@
# POST /api/v1/auth/sms/login — 手机号 + 验证码登录
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `phone` | string | ✅ | 手机号 `^1\d{10}$` |
| `code` | string | ✅ | 验证码,长度 48 |
## 出参
响应 `200`:`TokenWithUser``user.register_channel``"sms"`)。结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
- `400` 验证码错误
- `403` 账号被禁用
## 说明
mock 模式下任意 6 位数字均通过校验。注册即登录。
+26
View File
@@ -0,0 +1,26 @@
# POST /api/v1/auth/sms/send — 发送短信验证码
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/sms](../integrations/sms.md)(mock 模式、频控、接真供应商 TODO)。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `phone` | string | ✅ | 手机号,正则 `^1\d{10}$` |
## 出参
响应 `200`:
| 字段 | 类型 | 说明 |
|---|---|---|
| `sent` | bool | 是否已发送 |
| `mock` | bool | 是否 mock 模式(true 时不真发,任意 6 位通过) |
| `cooldown_sec` | int | 多少秒后才能再次发送 |
## 错误码
- `429` 发送过频(默认 60s 冷却内重复发)
## 说明
mock 模式(`SMS_MOCK=true`)下不真发短信,仅记录冷却时间。
+22
View File
@@ -0,0 +1,22 @@
# POST /api/v1/coupon/step — 一键领券任务步进(透传到 pricebot)
> 所属:Coupon 组(前缀 `/api/v1/coupon` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `trace_id``step` 用于日志。
## 出参
pricebot-backend 的响应**原样返回**(JSON object)。
## 错误码
- `400` body 不是合法 JSON
- `502` pricebot 上游不可达(网络错误)或返回 5xx
## 说明
本服务只加一层 JWT 鉴权,把请求体原样转发到 `PRICEBOT_BASE_URL``/api/coupon/step`async httpx)。真正的领券逻辑在 **pricebot-backend(另一个 repo**,本接口是"鉴权壳 + 透传"。是产品"一键领券"的接入点,前端目前尚未对接。
这是后端**唯一需要 JWT 鉴权的业务接口**。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
- `PRICEBOT_REQUEST_TIMEOUT_SEC`(默认 30s,因领券单帧最多 wait 6s
+15
View File
@@ -0,0 +1,15 @@
# GET /health — 健康检查
> 所属:Meta | 鉴权:无 | [← 返回 API 索引](./README.md)
## 入参
## 出参
响应 `200`:`{ "status": "ok" }`
## 错误码
## 说明
+29
View File
@@ -0,0 +1,29 @@
# POST /api/v1/meituan/coupons — 券列表 / 搜索
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
## 入参
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `longitude` | float | ✅ | — | 经度 |
| `latitude` | float | ✅ | — | 纬度 |
| `platform` | int | ❌ | 1 | 1=外卖/到家, 2=到店 |
| `biz_line` | int | ❌ | null | 到店子类:1到餐 2到综 3酒店 4门票 |
| `list_topic_id` | int | ❌ | 3 | 榜单:1精选 2今日必推 3爆款筛选 5限时筛选 |
| `keyword` | string | ❌ | null | 搜索词(**填了则忽略 `list_topic_id`,走搜索** |
| `sort_field` | int | ❌ | null | 1价格 2销量 6离我最近(搜索时默认 6) |
| `search_id` | string | ❌ | null | 翻页 token,首页不填 |
| `page` | int | ❌ | 1 | ≥1 |
| `page_size` | int | ❌ | 20 | 120 |
## 出参
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null }``CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
- `502` 美团接口失败
## 说明
当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整。
+25
View File
@@ -0,0 +1,25 @@
# POST /api/v1/meituan/feed — 首页混合推荐流
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
## 入参
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `longitude` | float | ✅ | — | 经度 |
| `latitude` | float | ✅ | — | 纬度 |
| `page` | int | ❌ | 1 | ≥1 |
| `page_size` | int | ❌ | 20 | 120 |
## 出参
响应 `200`:`{ items: CouponCard[], has_next: bool, page: int }``CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
## 错误码
无(见"说明"中的可观测性盲区)
## 说明
后端在 `coupons` 之上封装的"伪推荐"——每页并发拉一次外卖、一次到店(到餐)榜单,按 **2 外卖 + 1 到店** 交叉去重。榜单组合写死 3 页(第1页爆款、第2页今日必推、第3页外卖精选+到店限时),**第 3 页起 `has_next=false`、第 4 页返空**。
⚠️ **可观测性盲区**:`feed` 内部对美团调用异常是静默吞掉返回空列表(不报 502、不打日志)。若 `items` 为空但无错误,优先用 `coupons` 接口逼出真实错误(它会以 502 暴露,如"MT_CPS_APP_KEY not configured")。
+31
View File
@@ -0,0 +1,31 @@
# POST /api/v1/meituan/referral-link — 换取推广链接
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
## 入参
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `product_view_sign` | string | ✅ | — | 券的换链主键(来自 `CouponCard` |
| `platform` | int | ❌ | 1 | 1=外卖/到家, 2=到店 |
| `biz_line` | int | ❌ | null | 到店子类 |
| `sid` | string | ❌ | 服务端默认 `sgbjia` | 渠道追踪标识 |
| `link_type_list` | int[] | ❌ | `[1, 3]` | 1=H5长链 2=H5短链 3=deeplink |
## 出参
响应 `200`:
| 字段 | 类型 | 说明 |
|---|---|---|
| `link` | string | 默认推广链接(按 data > H5(1) > deeplink(3) > 任意 兜底) |
| `link_map` | object | 各类型链接 `{ "linkType": "url" }`,如 `{"1": "<H5>", "3": "<deeplink>"}` |
## 错误码
- `502` 美团接口失败
## 说明
客户端实际优先用 `link_map["3"]`deeplink)拉起美团 App,失败降级 `link_map["1"]`H5)。
⚠️ **安全**:`sid` 允许客户端传值覆盖服务端默认渠道,理论上他人可借本接口刷自己渠道的分佣;建议服务端锁定 `sid`、忽略客户端传值。
+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`
+196 -182
View File
@@ -1,69 +1,109 @@
# 傻瓜比价 App Server — 后端技术实现
# 傻瓜比价 App Server — 后端技术方案
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
> 仓库:`shaguabijia-app-server`
> 当前版本:v0.1.0(登录模块)
> 最后更新:2026-05-23
> 接口协议详见 [`docs/api/`](./api/)(索引 + 一接口一文件)
> 最后更新:2026-05-27
---
## 1. 本次实施范围
## 1. 这个后端是干什么的
本阶段目标是搭正式 App 后端的**账号与登录**能力,供 Android 正式版(`shaguabijia-app-android`)对接。
正式 App(`shaguabijia-app-android`,包名 `com.jishisongfu.shaguabijia`)提供三块能力:
已实现:
| 能力 | 说明 |
|---|---|
| **账号与登录** | 极光一键登录 + 短信验证码登录(mock)→ 签发 JWT |
| **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换取推广链接(分佣) |
| **领券透传** | `/coupon/step` 加 JWT 鉴权后转发到 pricebot-backend(一键领券核心,前端待接入) |
| 能力 | 状态 | 说明 |
|---|---|---|
| 极光一键登录 | ✅ 已联调 | `loginToken` → 极光 REST 验真 → RSA 解密手机号 → 注册/登录 |
| 短信验证码登录 | ✅ mock 可用 | 开发阶段任意 6 位通过,便于无短信供应商时联调 |
| JWT 双 token | ✅ | access(2h) + refresh(30d) |
| 用户表 + ORM | ✅ | SQLAlchemy 2.0 + Alembic |
| `/me` 鉴权 | ✅ | Bearer access_token |
| 单元测试 | ✅ | health + sms 登录闭环 + refresh + 限流 |
未做(后续迭代):
- 真实短信供应商(阿里云/腾讯云)
- logout 服务端 token 黑名单(jti)
- 昵称/头像修改、注销账号
- PostgreSQL 生产切库(当前 SQLite 起步)
**本服务自身没有"比价"实现**——名字叫比价,实质是:登录态 + 美团券聚合分佣 + 给真正的领券/比价核心(pricebot-backend,另一个 repo)做"鉴权 + 透传壳"。无爬虫、无 LLM、无积分/提现/钱包,数据模型仅一张 `user` 表(美团数据与领券请求均实时透传不落库)。
---
## 2. 与试验后台的关系
| 维度 | 试验后台 `shaguabijia-server` | 正式后台 `shaguabijia-app-server` |
|---|---|---|
| 域名 | `api.shaguabijia.com` | `app-api.shaguabijia.com` |
| 数据库 | stdlib sqlite3,手写 SQL | SQLAlchemy 2.0 + Alembic |
| 账号体系 | 仅占坑(phone 当 ID) | User 表 + JWT |
| 极光验 token | ✅ 有 | ✅ 移植并增强 |
| 短信登录 | ❌ | ✅ mock |
| 配置 | 硬编码 | pydantic-settings + `.env` |
**极光一键登录的核心逻辑**(REST 调用、RSA padding 试错顺序)直接参考试验后台 `app/api/auth.py`,
等价实现在 `app/core/jiguang.py`
试验后台 RSA 私钥部署在服务器 `/opt/shaguabijia-server/secrets/jverify_rsa_private.pem`(不入 git)。
正式后台因原私钥不可用,在极光控制台**重新生成 1024-bit RSA 密钥对**并上传公钥。
---
## 3. 技术栈
## 2. 技术栈
| 维度 | 选型 | 说明 |
|---|---|---|
| 语言 | Python 3.11+ | 本地开发用 3.13 |
| Web | FastAPI 0.115+ | OpenAPI 自动生成 |
| 语言 | Python 3.10 | 本地开发用 conda 环境 `price` |
| Web | FastAPI 0.115+ | OpenAPI 自动生成(非 prod 开 `/docs`) |
| ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` |
| ORM | SQLAlchemy 2.0 | Mapped 风格 |
| 迁移 | Alembic | `render_as_batch` 兼容 SQLite |
| DB | SQLite | 生产可`DATABASE_URL` 切 PostgreSQL |
| Auth | PyJWT | HS256,access + refresh |
| 极光 | httpx + cryptography | REST 验 token + RSA 解密 |
| ORM | SQLAlchemy 2.0(Mapped 风格) | |
| 迁移 | Alembic | |
| DB | SQLite 起步 | `DATABASE_URL` 切 PostgreSQL,无 Redis |
| Auth | PyJWT(HS256) | access + refresh |
| 极光 | httpx + cryptography | REST 验 token + RSA 解密手机号 |
| 美团 / pricebot | httpx(含 async) | 美团 S-Ca 网关签名;领券 async 透传 |
| 配置 | pydantic-settings | `.env` 加载 |
| 测试 | pytest + httpx TestClient | 临时文件 SQLite |
| 测试 | pytest + httpx TestClient | |
---
## 3. 分层架构与目录结构
标准 FastAPI 分层,一个请求自上而下穿过:**api(薄,收发) → integrations(外部 SDK) / repositories(数据) → models / db**。重逻辑(SDK 签名/加解密/外部 HTTP)落 `integrations`,集成层细节见 [docs/integrations/](./integrations/README.md)。
```
app/
├── main.py # FastAPI 入口:注册全部 router、CORS、/health、lifespan
├── api/
│ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session)
│ └── 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
│ ├── welfare.py # 钱包/签到/任务/省钱 收发模型
│ └── ad.py # 看广告发奖收发模型
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
│ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
│ ├── meituan.py # 美团 CPS 网关签名 + query_coupon / get_referral_link
│ ├── 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/ # 数据访问 + 事务(早期叫 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/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
deploy/ # systemd(.service) + nginx(.conf)
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` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
---
@@ -72,153 +112,140 @@
### 4.1 极光一键登录(主路径)
```
Android SDK loginAuth
→ 客户端拿到 loginToken
→ POST /api/v1/auth/jverify-login { login_token, operator }
后端 POST 极光 loginTokenVerify (Basic Auth = AppKey:MasterSecret)
极光返回 RSA 加密的手机号(base64)
→ 后端用私钥解密(PKCS1v15,实测电信 CT 走此 padding)
→ upsert User(phone 唯一) → 签 JWT → 返回 TokenWithUser
Android SDK loginAuth → 客户端拿到 loginToken
→ POST /api/v1/auth/jverify-login { login_token, operator }
后端 POST 极光 loginTokenVerify (Basic Auth = AppKey:MasterSecret)
极光返回 RSA 公钥加密的手机号(base64)
后端用配套私钥解密 → 明文手机号
→ upsert_user_for_login(phone 唯一) → 签 JWT → 返回 TokenWithUser
```
**关键配置**(与 Android 端必须一致):
**RSA 解密**(`integrations/jiguang.py``_decrypt_phone`):极光文档未明示 padding,代码按 **OAEP-SHA1 → OAEP-SHA256 → PKCS1v15** 依次试,解出 11 位纯数字手机号即采用(PKCS1v15 对错误密文会"假成功"返回非 UTF-8 垃圾,据此跳过)。
**关键配置**(须与 Android 端一致):
| 项 | 值 |
|---|---|
| 包名 | `com.jishisongfu.shaguabijia` |
| 极光 AppKey | `966b451a8d9cfe12d173ea9d` |
| RSA 公钥 | 极光控制台「加密公钥」(1024-bit,≤220 字符) |
| RSA 私钥 | `secrets/jverify_rsa_private.pem`(不入 git) |
| 极光 AppKey | `966b451a8d9cfe12d173ea9d`(`JG_APP_KEY`) |
| RSA 公钥 | 极光控制台「加密公钥」(**1024-bit**,极光限制 ≤220 字符) |
| RSA 私钥 | `secrets/jverify_rsa_private.pem`(PKCS#8,不入 git,`JG_PRIVATE_KEY_PATH` 指定,懒加载) |
**RSA 密钥说明**:
- 密钥对由我方生成(`openssl genrsa 1024`),公钥上传极光控制台
- 极光控制台限制公钥长度 ≤220 字符,因此用 **1024-bit** 而非 2048-bit
- 私钥路径由 `JG_PRIVATE_KEY_PATH` 指定,懒加载;文件不存在时 `jverify-login` 返回 502
> **密钥对配对关系是关键**:极光用控制台上传的公钥加密,后端必须用**配对的私钥**解密。私钥不配对时 `jverify-login` 报 502 `all RSA paddings failed`(密钥不对,非 padding 问题);私钥文件缺失则报 502 `private key not found`
> **2026-05-27 现状**:历史遗留的两套本地密钥均**不配对**当前 AppKey 公钥,已重新生成一对 1024-bit/PKCS#8 密钥,公钥需在极光控制台更新。⚠️ 该 AppKey 公钥为全局配置,若占坑版后端仍在用同一 AppKey,换公钥会使其旧私钥失效,需同步部署新私钥。
### 4.2 短信登录(mock 路径)
```
POST /api/v1/auth/sms/send { phone } → 60s 冷却,不真发短信
POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过(SMS_MOCK=true)
→ upsert User → 签 JWT → 返回 TokenWithUser
POST /api/v1/auth/sms/send { phone } → 60s 冷却,不真发(SMS_MOCK=true)
POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert → 签 JWT
```
### 4.3 Token 刷新
短信冷却表存进程内存(`--workers 1` 下够用,多 worker/重启即失效)。
```
POST /api/v1/auth/refresh { refresh_token }
→ 校验 refresh token → 签新 access+refresh 对
```
### 4.3 Token 与刷新
Android 端通过 OkHttp `Authenticator` 在 401 时自动调用
- access(2h)+ refresh(30d),HS256 自包含,payload 含 `sub`/`typ`/`iat`/`exp`,**无 session 表、无 jti 黑名单**
- `refresh` 校验 token + 查库确认用户 active 后,签发**全新一对**。
- 客户端 OkHttp `Authenticator` 在 401 时自动调 `/refresh` 续期重放。
- `logout` 为占位(服务端不吊销 token)。
---
## 5. 数据模型
## 5. 美团 CPS
### User 表
### 5.1 网关签名(`integrations/meituan.py``_sign` / `_call`)
类阿里云 S-Ca 网关签名,**stringToSign 只有 4 段**:`METHOD\n + Content-MD5\n + 排序后的 signed_headers + path`,**不含 Accept / Content-Type / Date 行**(加了签名失败)。头:`S-Ca-App`(=appkey)、`S-Ca-Timestamp``S-Ca-Signature``S-Ca-Signature-Headers`。配置项 `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` / `MT_CPS_DEFAULT_SID`(默认 `sgbjia`)。
> 美团请求字段 `listTopiId`(少个 c)是官方拼写,非笔误。经纬度 `*1_000_000` 取整。
### 5.2 三个接口
- `query_coupon`:底层取数,被 `coupons` 路由和 `feed` 路由共用。
- `coupons` 路由:对外的搜索/榜单接口,**客户端暂未接入**。
- `feed` 路由:首页推荐流,每页并发拉外卖(platform=1)+ 到店(platform=2,到餐)各一次,按 **2 外卖 + 1 到店** 交叉去重(`_interleave`);榜单组合写死 3 页(`_TOPIC_ROUNDS`:爆款 / 今日必推 / 外卖精选+到店限时),第 3 页 `has_next=false`、第 4 页返空。
- `referral-link`:换推广链接,客户端取 `link_map["3"]`(deeplink)优先跳美团 App。
### 5.3 风险点
- **三接口无鉴权**:任何人可调,消耗你的美团 appkey 能力/配额。
- **`sid` 可被客户端覆盖**:他人可借接口刷自己渠道分佣,建议服务端锁定。
- **`feed` 静默吞异常**:`_fetch_topic` 对美团错误 `except 返回 []` 且不打日志,导致美团失败时 `feed` 返回"200 + 空 items"无声失败。排查时改用 `coupons`(它以 502 暴露错误)。
---
## 6. 领券透传(coupon/step)
产品"一键领券"的核心接入点,但**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。本服务只做"鉴权 + 透传壳"。
```
客户端 → POST /api/v1/coupon/step (Bearer access_token + 任意 JSON body)
→ coupon.py:get_current_user 校验 JWT(唯一需鉴权的业务接口)
→ async httpx 把 body 原样 POST 到 PRICEBOT_BASE_URL/api/coupon/step
→ 把 pricebot 的响应原样返回
```
- **不做 schema 校验**:body 原样透传,pricebot 自己校验(后端仅读 `trace_id`/`step` 打日志)。
- **错误**:body 非合法 JSON → 400;pricebot 不可达或返回 5xx → 502。
- **配置**:`PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)、`PRICEBOT_REQUEST_TIMEOUT_SEC`(默认 30s,因领券单帧最多 wait 6s)。
- **现状**:接口已挂载可用,但**前端 ComparingScreen 仍是动画 demo、尚未对接此接口**;pricebot-backend 不在本目录。
---
## 7. 数据模型
仅一张 `user` 表(`models/user.py`):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 自增 |
| phone | VARCHAR UNIQUE | 手机号,登录主键 |
| register_channel | VARCHAR | `jverify` / `sms` |
| nickname | VARCHAR NULL | 预留 |
| avatar_url | VARCHAR NULL | 预留 |
| status | VARCHAR | `active` / `disabled` |
| created_at | DATETIME | 注册时间 |
| last_login_at | DATETIME | 最近登录 |
| phone | VARCHAR(20) UNIQUE | 登录主键,唯一索引 `ix_user_phone` |
| register_channel | VARCHAR(20) | `jverify` / `sms` |
| nickname | VARCHAR(64) NULL | 预留,**无接口可写,恒为 null** |
| avatar_url | VARCHAR(512) NULL | 同上 |
| status | VARCHAR(20) | `active` / `disabled` / `deleted`,仅 active 可登录/鉴权 |
| created_at / last_login_at | DATETIME(tz) | |
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册。
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。Alembic 实际库里另有框架表 `alembic_version`
---
## 6. API 一览
## 8. 配置与部署
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/health` | 无 | 健康检查 |
| POST | `/api/v1/auth/jverify-login` | 无 | 极光一键登录 |
| POST | `/api/v1/auth/sms/send` | 无 | 发短信(mock) |
| POST | `/api/v1/auth/sms/login` | 无 | 短信登录 |
| POST | `/api/v1/auth/refresh` | 无 | 刷新 token |
| GET | `/api/v1/auth/me` | Bearer | 当前用户 |
| POST | `/api/v1/auth/logout` | Bearer | 登出占位 |
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`、JWT、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / `PRICEBOT_REQUEST_TIMEOUT_SEC`)**、CORS。
Swagger UI: `http://localhost:8770/docs`
---
## 7. 目录结构
```
app/
├── main.py # FastAPI 入口,lifespan,CORS
├── core/
│ ├── config.py # pydantic-settings
│ ├── security.py # JWT 签发/校验
│ ├── jiguang.py # 极光 REST + RSA 解密
│ └── sms.py # 短信 mock(内存验证码)
├── db/
│ ├── base.py
│ └── session.py # engine + get_db
├── models/user.py
├── schemas/auth.py
├── crud/user.py
└── api/
├── deps.py # get_current_user
└── v1/auth.py # 登录路由
alembic/ # 数据库迁移
deploy/ # systemd + nginx
secrets/ # RSA 私钥(不入 git)
tests/ # pytest
```
---
## 8. 本地联调(真机 + LAN)
后端监听所有网卡,供同一 WiFi 下的测试机访问:
**生产部署**:systemd `shaguabijia-app-server.service`(WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,uvicorn 监听 `127.0.0.1:8770`,`--workers 1`)+ nginx 443 反代 → 8770。**无 Docker**。
```bash
source .venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
```
Android debug 包 `BASE_URL` 指向开发机 LAN IP(配置在 `local.properties`,不入 git):
```
debug.api.host=192.168.x.x
debug.api.port=8770
```
真机浏览器访问 `http://192.168.x.x:8770/health` 应返回 `{"status":"ok"}`
---
## 9. 部署要点
```bash
# 代码同步(排除 secrets 和 data)
rsync -avz --exclude='.venv' --exclude='data' --exclude='secrets/*.pem' \
./ server:/opt/shaguabijia-app-server/
# 私钥单独 scp(仅首次或轮换时)
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
# 迁移 + 重启
rsync -avz --exclude='.venv' --exclude='__pycache__' --exclude='data' --exclude='secrets/*.pem' ./ server:/opt/shaguabijia-app-server/
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ # 私钥单独传,不入 git
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
```
生产 checklist:
**生产 checklist(均为上线必查)**:
- [ ] `JWT_SECRET_KEY` 换成随机长字符串
- [ ] `APP_ENV=prod`, `APP_DEBUG=false`
- [ ] RSA 私钥权限 `chmod 600`
- [ ] nginx SSL 证书有效
- [ ] `alembic upgrade head` 已执行
- [ ] `JWT_SECRET_KEY` 改为高熵随机串——默认值 `change-me` 可被伪造 token
- [ ] `SMS_MOCK=false` 并接真实短信供应商——mock 下任意 6 位码可登录任意手机号
- [ ] `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` 已填(否则美团接口 502)
- [ ] `PRICEBOT_BASE_URL` 指向真实 pricebot-backend(否则领券 502)
- [ ] RSA 私钥就位且与极光控制台公钥**配对**,权限 600
- [ ] `APP_ENV=prod``APP_DEBUG=false`、nginx SSL 有效、`alembic upgrade head` 已执行
---
## 9. 本地联调(真机)
```bash
conda activate price # 首次:pip install -e .
./run.sh # 校验 .env → alembic 建表 → uvicorn 监听 0.0.0.0:8770
```
真机两种连法(详见前端文档):
- **adb reverse**(推荐):`adb reverse tcp:8770 tcp:8770`,前端 `debug.api.host=127.0.0.1`,USB 直连不依赖 WiFi。
- **局域网 IP**:前端 `debug.api.host=<开发机 LAN IP>`,手机电脑同 WiFi。
前端 debug 包对明文 HTTP 整体放行(`src/debug` 的 network_security_config),换 IP 只改 `local.properties`、无需改 xml。
---
@@ -226,25 +253,12 @@ ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head &&
| 项 | 说明 |
|---|---|
| logout 无服务端失效 | 目前靠客户端清 token;后续加 jti 黑名单表 |
| SMS 为 mock | 上线前需接真实短信供应商并设 `SMS_MOCK=false` |
| SQLite 并发 | 生产流量上来后切 PostgreSQL,只改 `DATABASE_URL` |
| 极光 RSA 1024-bit | 极光控制台限制所致;仅加密 11 位手机号,可接受 |
| logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表 |
| 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) |
| feed 静默吞异常 | 建议给 `_fetch_topic` 加日志,避免无声失败 |
| 领券依赖 pricebot | `coupon/step` 仅透传,真正逻辑在 pricebot-backend;前端尚未对接 |
| SMS 为 mock | 上线接真实供应商 + `SMS_MOCK=false` |
| 短信冷却存内存 | 扩 worker 前需迁移到 Redis |
| SQLite | 流量上来切 PostgreSQL,只改 `DATABASE_URL` |
---
## 11. 联调验证记录(2026-05-23)
本地 LAN 联调已通过:
```
# 短信登录
POST /api/v1/auth/sms/login → 200, user_id=2, phone=177****82
# 极光一键登录
POST /api/v1/auth/jverify-login → 200, operator=CT, token_len=448
[JG] decrypted with padding=PKCS1v15
jverify_login ok user_id=3, phone=153****91
```
Android 端包名、签名、极光 AppKey 与控制台一致,SDK init 8000,loginAuth 6000。
> 完整接口协议(11 个)见 [`docs/api/`](./api/),以代码为准。
+56
View File
@@ -0,0 +1,56 @@
# 待办与技术债账本
> 用途:记"现在先简化、以后要补"的事,和暂不处理的技术债,免得忘。每条标优先级 + 触发背景。做掉的移到末尾「已解决」。
> 范围:跨栈账本——本仓 `shaguabijia-app-server`(转发层) + `shaguabijia-app-android`(无障碍引擎移植) + `pricebot-backend`(agent 上游,不改)。
> 大背景:把"后端当大脑、无障碍服务遥控前端操作其它 App"的比价/领券引擎,从 demo app `pricebot` 移植进正式版傻瓜比价。引擎移植采用「代码全搬、独立子包 `…shaguabijia.agent.*`、验证分步(先领券后比价)」。
> 最后更新:2026-05-27
---
## P1 · 鉴权与用户绑定(最该补)
- **现状**agent 系列接口 MVP 阶段**全部不鉴权**——领券 `coupon/step` + 比价 4 个(`intent/recognize``price/step``ecom/intent/recognize``ecom/step`)。落地时现有 `coupon-step` 路由的 `CurrentUser` 依赖要先去掉。
- **代价(为什么记这笔账)**:不验 JWT → 转发时 server 拿不到 `user_id` → agent 行为只能绑到 `device_id`(设备级),**采集不到"哪个用户领了/买了什么"的用户级画像**。而精准人群画像、私域分群运营是商业模式的核心资产,靠的就是这份用户级行为数据。`device_id` 仍照传(后端按设备串领券队列够用)。
- **待补**:① agent 接口加 JWT 鉴权;② 建立 `device_id ↔ user_id` 绑定(登录后上报一次即可);③ 领券/比价记录按 user 维度落库。
- **连带**:补鉴权后,客户端引擎 `ApiClient` 要接回 JWT(复用 app 现有 `AuthInterceptor` 思路:注入带鉴权头的 OkHttpClient)。
---
## P2 · 引擎移植 · 后端转发层(本仓)
- **加 4 个比价透传端点**`intent/recognize``price/step``ecom/intent/recognize``ecom/step`。仿 `coupon_step` **纯 body 透传、不加 Pydantic schema**(协议归 pricebot 校验,否则它一改字段本仓就得跟着改,白白耦合)。client `/api/v1/xxx` → 转发 backend `/api/xxx`(去掉 v1,同 `coupon_step`)。
- **转发超时要调大**`coupon_step``PRICEBOT_REQUEST_TIMEOUT_SEC=30`(领券单帧 wait 6s)。但意图识别是一次大上下文 LLM、`price/step` 每帧也是 LLM,可能 >30s;客户端 `ApiClient` 读超时是 60s。→ 转发超时对齐 60s,**待测 pricebot 各接口真实耗时**再定终值。
- **生产 server 与 backend 同内网**`PRICEBOT_BASE_URL` 部署时设内网地址。比价是 30~80 步循环,每步多一跳,走公网延迟会累积。
- **nginx body 上限**:比价 debug 包带 `screenshot`(webp base64,几百 KB)。若 debug 联调走线上域名经 nginx,注意 `client_max_body_size`(release 不带截图、debug 直连本地都无此问题,仅"debug 走域名"才撞)。
---
## P2 · 引擎移植 · 客户端(android 仓,在此备忘)
- **无障碍身份统一为傻瓜比价**:服务名/图标自动继承 application(`<service>` 不单独设 label/icon);需显式改的 3 处文案——`accessibility_service_description`、前台常驻通知文案("PriceBot 正在待命"等)、`GuideCopy` 里引导高亮的系统列表条目名("PriceBot"→"傻瓜比价")。
- **BuildConfig 包名**:代码里 `com.pricebot.app.BuildConfig` 的 import 必须改成本 app 包名,否则编译不过。
- **领券入口**:首页"一键领券"从 `ComparingScreen` 假动画 → 真 `startCouponClaim()`
- **比价悬浮球**`SUPPORTED_BIZ_PACKAGES`(购物 App 前台才显示悬浮球)逻辑照搬。
- **权限引导融入 Compose**:把 pricebot `MainActivity` 里申请无障碍/悬浮窗/电池优化的逻辑抽出,接到傻瓜比价的 Compose 页(权限引导 Activity 那几个可照搬,改包名+文案)。
- **类名可选改名**`PriceBotService` 等用户不可见,保留无妨;想整洁可改 `ShaguaAccessibilityService`(牵动引用,不急)。
---
## 技术债(暂不处理,知情即可)
- **agent 接口公网裸奔**:不鉴权 = 任何人可调,烧 pricebot 的 LLM 算力。与美团 3 接口现状同级,记账后随 P1 一起补。
- **既有债(已记录在别处)**:美团 `referral-link``sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](./后端技术实现.md) 与 [api/](./api/)。
---
## 阶段 1 领券落地后的遗留(待清理,不阻塞跑通)
- **FloatingButton 死代码**:Running 面板已换成 ComposeView 承载 `CouponProgressPanel`(=复用 AgentFloat)。旧原生面板字段(`line1~4View`/`elapsedView`/`progressBarView`/`historyContainerView` 等)+ `updateStatus(StepStatus)` 里保留的那段更新逻辑 + `appendActionLine`/`markCurrentActionDone`/`markPlatformDone`/`addCancelButton` 全部 no-op,待删。
- **ComparingScreen / CompResultScreen 弃用**:领券改走悬浮窗,不再走全屏假动画。`Routes.COMPARING` / `COMP_RESULT` 成死路由,连同两个 Screen 文件待删(`CouponPromptDialog` 已抽到 `ui/coupon/`,删 ComparingScreen 不影响它)。
- **ApiClient 常量 unused**:`getBackendUrl()` 改用 `BuildConfig.BASE_URL` 后,`DEFAULT_BACKEND_URL`/`KEY_BACKEND_URL` 常量 unused,待清。
- **权限引导是最简版**:缺权限时直接跳系统设置页(`ACTION_ACCESSIBILITY_SETTINGS` / overlay 设置)。搬来的仿腾讯引导(`AccessibilityGuideActivity`/`PermissionGuide*`)尚未接入,体验后续可升级。
- **领券大数字语义**:悬浮窗左侧"已领券"大数字目前接 `progress.current`(正在领第几张),非"已成功数"。中间帧后端不回传已领明细,要精确需后端在 status.progress 加 `completed[]`(协议文档第四节已留扩展点)。
## 已解决
(暂无)
+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 并行。
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# 本地开发启动脚本:监听 0.0.0.0(真机局域网 / adb reverse 都能连),改代码自动重启。
#
# 前置(首次):先激活装好依赖的环境,例如:
# conda activate price && pip install -e .
# 真机联调流程见前台 local.properties 的 debug.api.host。
set -e
cd "$(dirname "$0")"
if [ ! -f .env ]; then
echo "❌ 缺 .env:先 cp .env.example .env 并填值(至少 JWT_SECRET_KEY;测美团再填 MT_CPS_*"
exit 1
fi
mkdir -p data # sqlite 文件所在目录
alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
exec uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 只做数据库迁移(不启服务)。部署 / CI / "只想把表建好"时用。
# 用法: bash scripts/migrate.sh
# 启动服务用 ./run.sh(它会自动先跑迁移)。详见 docs/数据库迁移.md
set -e
cd "$(dirname "$0")/.." # 切到项目根(alembic.ini 所在)
if [ ! -f .env ]; then
echo "❌ 缺 .env:先 cp .env.example .env 并填值(至少 JWT_SECRET_KEY)"
exit 1
fi
mkdir -p data # SQLite 库文件所在目录(用 Postgres 时无害)
echo "→ alembic upgrade head"
alembic upgrade head # 幂等:已是最新则 no-op
echo "✅ 数据库已升到最新 (alembic current 可查看当前版本)"
+38
View File
@@ -0,0 +1,38 @@
"""提现对账(#4 孤儿单兜底)。
扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化:
- 微信 SUCCESS success
- 微信失败/取消/关闭/无此单 退回现金 + failed
- WAIT_USER_CONFIRM(久未确认) 撤销 + 退款
防止"扣了款但转账没发起/没确认"的单永久锁住用户余额
用法(建议 cron / systemd timer 5~10 分钟跑一次):
python -m scripts.reconcile_withdraws # 默认处理 >15 分钟的 pending
python -m scripts.reconcile_withdraws 30 # 处理 >30 分钟的 pending
"""
from __future__ import annotations
import sys
# Windows 控制台按 UTF-8 输出中文/¥
try:
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
except Exception: # noqa: BLE001
pass
from app.repositories import wallet as crud_wallet
from app.db.session import SessionLocal
def main() -> None:
minutes = int(sys.argv[1]) if len(sys.argv) > 1 else 15
db = SessionLocal()
try:
result = crud_wallet.reconcile_pending_withdraws(db, older_than_minutes=minutes)
print(f"对账完成: 检查 {result['checked']} 笔 pending(>{minutes}分钟), 归结 {result['resolved']}")
finally:
db.close()
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
"""清空指定用户的签到记录,方便反复测试"未签到→自动弹窗→签到领金币"
默认**只删 signin_record**(签到历史),不动金币/现金这样能保留你已注入/兑换的余额,
只把"今天是否已签到 / 连续天数"重置掉删后当天即变成"未签到",再进福利页会重新自动弹签到弹窗
注意:只删签到记录的话,之前签到已发的金币仍留在余额和 coin_transaction (biz_type='signin')
如果想把签到发的金币也一并退掉(余额减回 signin 流水), --with-coins
用法( shaguabijia-app-server 目录下,用本项目的 python ):
python scripts/reset_signin.py # 只清 user_id=2 的签到记录
python scripts/reset_signin.py 5 # 清 user_id=5
python scripts/reset_signin.py --all # 清所有用户的签到记录
python scripts/reset_signin.py --with-coins # 连签到发的金币一起退回(余额减、删 signin 流水)
python scripts/reset_signin.py --dry-run # 只看现状,不改
DB 默认取 <项目根>/data/app.db,可用 --db 指定
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
# 本脚本在 scripts/ 下,项目根是上一级
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
"""返回签到记录数 + 签到金币流水数/总额 + 账户金币余额,用于打印 before/after。"""
signin_rows = cur.execute(
f"select count(*) from signin_record where {where}", params
).fetchone()[0]
signin_coin_rows, signin_coin_sum = cur.execute(
f"select count(*), coalesce(sum(amount), 0) from coin_transaction "
f"where biz_type='signin' and {where}",
params,
).fetchone()
accounts = cur.execute(
f"select user_id, coin_balance, total_coin_earned from coin_account where {where}",
params,
).fetchall()
return {
"signin_rows": signin_rows,
"signin_coin_rows": signin_coin_rows,
"signin_coin_sum": signin_coin_sum,
"accounts": accounts,
}
def print_snapshot(label: str, snap: dict) -> None:
print(f"--- {label} ---")
print(f" signin_record: {snap['signin_rows']}")
print(f" coin_transaction(signin): {snap['signin_coin_rows']} 条, 共 {snap['signin_coin_sum']} 金币")
if snap["accounts"]:
for uid, coin, earned in snap["accounts"]:
print(f" coin_account user={uid}: coin={coin} earned={earned}")
else:
print(" coin_account: (无)")
def main() -> None:
parser = argparse.ArgumentParser(description="清空用户签到记录")
parser.add_argument("user_id", nargs="?", type=int, default=2,
help="要清的 user_id(默认 2)")
parser.add_argument("--all", action="store_true", help="清所有用户")
parser.add_argument("--with-coins", action="store_true",
help="同时退回签到发的金币(余额减、删 signin 流水)")
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
args = parser.parse_args()
if args.all:
where, params, scope = "1=1", (), "ALL users"
else:
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
db_path = Path(args.db)
if not db_path.exists():
raise SystemExit(f"DB 不存在: {db_path}")
conn = sqlite3.connect(str(db_path))
cur = conn.cursor()
print(f"DB: {db_path} scope: {scope} with_coins: {args.with_coins}")
print_snapshot("before", snapshot(cur, where, params))
if args.dry_run:
print("(dry-run,未修改)")
conn.close()
return
if args.with_coins:
# 先按用户把签到金币总额退回(余额、累计收益都减),再删 signin 流水
rows = cur.execute(
f"select user_id, coalesce(sum(amount), 0) from coin_transaction "
f"where biz_type='signin' and {where} group by user_id",
params,
).fetchall()
for uid, total in rows:
cur.execute(
"update coin_account set coin_balance = coin_balance - ?, "
"total_coin_earned = max(0, total_coin_earned - ?) where user_id = ?",
(total, total, uid),
)
cur.execute(f"delete from coin_transaction where biz_type='signin' and {where}", params)
cur.execute(f"delete from signin_record where {where}", params)
conn.commit()
print_snapshot("after", snapshot(cur, where, params))
conn.close()
print("签到记录已清空。提醒:App 内存里的状态不会自动同步,重启 App 或进收益明细页刷新;"
"重进福利页(当天未签到)会重新自动弹签到弹窗。")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
"""一键回滚某个用户的福利数据(金币/现金/签到/一次性任务/看广告发奖),方便反复测试。
清掉:coin_transaction / cash_transaction / signin_record / user_task / ad_reward_record,
并把 coin_account 的金币余额累计收益现金余额全部归零
**不动** savings_record(那是 profile 省钱卡的 demo 演示数据,与领奖测试无关)
用法( shaguabijia-app-server 目录下,用本项目的 python ):
python scripts/reset_welfare.py # 默认重置 user_id=2(手机登录用户)
python scripts/reset_welfare.py 5 # 重置 user_id=5
python scripts/reset_welfare.py --all # 重置所有用户
python scripts/reset_welfare.py --dry-run # 只看现状,不改
DB 默认取 <项目根>/data/app.db,可用 --db 指定
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
# 本脚本在 scripts/ 下,项目根是上一级
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
# 受影响的表:每个用户的钱包/行为数据
WALLET_TABLES = ("coin_transaction", "cash_transaction", "signin_record", "user_task", "ad_reward_record")
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
"""返回当前各表行数 + 账户余额,用于打印 before/after。"""
counts = {
t: cur.execute(f"select count(*) from {t} where {where}", params).fetchone()[0]
for t in WALLET_TABLES
}
accounts = cur.execute(
f"select user_id, coin_balance, cash_balance_cents, total_coin_earned "
f"from coin_account where {where}",
params,
).fetchall()
return {"counts": counts, "accounts": accounts}
def print_snapshot(label: str, snap: dict) -> None:
print(f"--- {label} ---")
for t, n in snap["counts"].items():
print(f" {t}: {n}")
if snap["accounts"]:
for uid, coin, cash, earned in snap["accounts"]:
print(f" coin_account user={uid}: coin={coin} cash_cents={cash} earned={earned}")
else:
print(" coin_account: (无)")
def main() -> None:
parser = argparse.ArgumentParser(description="回滚用户福利数据")
parser.add_argument("user_id", nargs="?", type=int, default=2,
help="要重置的 user_id(默认 2)")
parser.add_argument("--all", action="store_true", help="重置所有用户")
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
args = parser.parse_args()
if args.all:
where, params, scope = "1=1", (), "ALL users"
else:
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
db_path = Path(args.db)
if not db_path.exists():
raise SystemExit(f"DB 不存在: {db_path}")
conn = sqlite3.connect(str(db_path))
cur = conn.cursor()
print(f"DB: {db_path} scope: {scope}")
print_snapshot("before", snapshot(cur, where, params))
if args.dry_run:
print("(dry-run,未修改)")
conn.close()
return
for t in WALLET_TABLES:
cur.execute(f"delete from {t} where {where}", params)
cur.execute(
f"update coin_account set coin_balance=0, total_coin_earned=0, "
f"cash_balance_cents=0 where {where}",
params,
)
conn.commit()
print_snapshot("after", snapshot(cur, where, params))
conn.close()
print("回滚完成。提醒:App 内存里的共享余额态不会自动同步,重启 App 或进收益明细页刷新。")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More