Compare commits

...

32 Commits

Author SHA1 Message Date
chenshuobo b3d3fda744 feat: 新增 PG 初始化脚本 scripts/init_postgres.py
新机器一键拉起数据库, 替代手动跑 psql 建用户建库的 6 行命令.

脚本特性:
- 跨平台 Python (Windows/macOS/Linux 通吃, 不依赖 psql CLI 在 PATH 里)
- 直接用 psycopg 连 PG, 不 shell out
- 幂等: 用户已存在则重置密码, 库已存在则跳过, 重复跑不出错
- 业务密码自动 secrets.token_urlsafe(32) 强随机, 不让同事自己想
- 自动从 .env.example 复制出 .env 并写入 DATABASE_URL
- .env 里已有 DATABASE_URL 会询问是否覆盖 (不无脑覆盖)
- 跑完自动 alembic upgrade head, 一条命令到可用状态
- 全交互项支持环境变量提前指定 (PG_SUPER_PASS / APP_DB_PASS 等), CI 友好

同事新机器初始化流程从 6 步简化为 3 步:
  pip install -e .
  python scripts/init_postgres.py    # 唯一手动输入项: postgres 超级用户密码
  ./run.sh

CREATE/ALTER USER 注意点: PG 不支持参数化密码绑定, 用 sql.Literal 内联,
psycopg 自带的转义会处理引号和注入.

docs/数据库迁移.md 拆出 PG / SQLite 两套初始化流程, PG 走脚本,
SQLite 保留旧步骤 (本地临时回退用).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:21:04 +08:00
chenshuobo b76e5bd515 feat: SQLite → PostgreSQL 迁移代码改动
按 docs/数据库迁移.md 第 3 节扫雷清单落地代码层改动:

- pyproject.toml: 新增 psycopg[binary]>=3.1 依赖 (psycopg3, SQLAlchemy 2.0 时代默认, 不要再装 psycopg2)

- app/db/session.py: 非 SQLite 自动加连接池参数
  - pool_size=10, max_overflow=20, pool_recycle=3600
  - SQLite 单文件不池化, _is_sqlite 判断分支保留以便本地 dev 临时回退

- app/models/savings.py: dishes 列从 JSON 改为 JSONB
  - PG 上能用 GIN 索引和 jsonb 操作符
  - SQLite 上 JSON 实际是 TEXT, 切到 PG 后用 JSON 类型存的是 json 不是 jsonb

- alembic/versions/ef96beb47b1e_*.py: 新增迁移把 savings_record.dishes 从 json 转 jsonb
  - 用 op.get_bind().dialect.name 判断 PG 才执行 ALTER COLUMN
  - SQLite 上是 no-op (SQLite 没有 jsonb 类型)
  - 旧的 savings_shop_dishes.py 迁移保持 sa.JSON() 不动 (按文档"不改老迁移"原则)

本地切 PG 验证: 10 张表全部建出, dishes 字段为 jsonb 类型,
登录/me/美团 feed 三个真接口全 200, user 数据正确写入 PG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:20:22 +08:00
marco 4d444b7c43 Merge pull request 'feat(user): 用户资料与反馈接口(昵称/头像/注销 + 帮助反馈)' (#4) from feat/user-profile-feedback-api into main
Reviewed-on: #4
2026-05-29 01:04:10 +08:00
陈世睿 49379fd045 feat(user): 用户资料与反馈接口(昵称/头像/注销 + 帮助反馈)
- /api/v1/user: PATCH 改昵称、POST 上传头像、DELETE 注销账号(软删除+匿名化)
- /api/v1/feedback: 提交反馈(内容/联系方式/截图入库)
- 新增 media 模块(图片落盘+魔数校验)+ /media 静态服务
- feedback 表 + Alembic 迁移(部署需 alembic upgrade head)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:27:36 +08:00
marco abd8eabf9b feat(meituan): 未配置 MT_CPS_APP_KEY 时接口降级返空,不再 502
之前未配美团 CPS 凭证时,/coupons 和 /referral-link 调用会抛 MeituanCpsError
被翻成 502,前端首页一进就拉 /feed → 看到错误(虽然 /feed 因 _fetch_topic
吞异常已经返空)。新开发机没填美团 key 时体验差。

本次让美团 3 个接口都在入口处早返空响应:
- Settings 新增 mt_cps_configured property(同 wxpay_configured 套路)
- /coupons / /feed / /referral-link 未配置时分别返空 list / 空 link
- 客户端首页 feed 看到空列表,其他业务(登录/领券/广告/提现)完全不受影响

副作用:/feed 返空现在同时表示"配置缺失"和"美团真无券"两种状态,排查时
看 settings.mt_cps_configured 区分。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:54:51 +08:00
marco 8d9bd3c72f Merge pull request 'test(coupon-proxy): 修正已过时的 401 鉴权断言' (#3) from feature/coupon-claim-integration into main
Reviewed-on: #3
2026-05-28 10:53:58 +08:00
marco ba946a9d9a fix(docs): 移除 3ff95db merge commit 残留的冲突标记
3ff95db 那次 merge 时, IDE/editor 已经修改文件但 my Edit 被 file-freshness
guard 拒绝,后续 git add/commit/push 把仍带 <<<<<<< / =======/>>>>>>> 的
coupon-step.md 推上去了 —— 已部署文档破损。

按原合并方案 fix-forward:鉴权行用精确版「Bearer access_token(客户端契约;Server
MVP 阶段不强校验)」,⚠️ 段保留「前端已接通」详述 + 用「客户端契约 vs Server
实现」措辞写鉴权状态 + device_id 用途 + P1 链接。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:49:01 +08:00
marco 3ff95dba76 Merge origin/feature/coupon-claim-integration: 合并 coupon-step.md 鉴权措辞两边
冲突:本地 36f6465(MVP 不鉴权 + 前端已接通详述 + P1 指向)与远程 3d1bb78
(鉴权措辞精确化:Android 已带 Bearer 客户端契约 / Server MVP 不强校验)。

解法:综合两边——
- 鉴权行用远程精确版「Bearer access_token(客户端契约;Server MVP 阶段不强校验)」
- 说明段保留本地「前端已接通」路径详述(远程版没有)+ 用远程的「客户端契约 vs
  Server 实现」措辞重写 ⚠️ 段 + 本地的 device_id 用途说明 + P1 链接。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:48:05 +08:00
no_gen_mu 3d1bb78afe docs(api/coupon-step): 鉴权说法精确化 — Android 已带 Bearer / Server MVP 不强校验
P1 接通真机验证时 logcat 实证 Android 客户端通过 AuthInterceptor 自动带
Authorization: Bearer <access_token> 头(见 shaguabijia-app-android 仓
data/net/ApiClient.kt:36)。原 docs 只写「鉴权:Bearer access_token」描述
契约目标,但没说明 server 端 MVP 不强校验的实现现状,容易让对接方误以为
缺 token 就 401。

L3 + L18 修订后清晰描述「客户端契约 vs Server 端 MVP 实现」两端联合状态,
不动 server 代码(coupon.py 仍 MVP 不接 Depends, P1 后启用)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:44:19 +08:00
marco 36f646597d docs: 同步领券去鉴权 + 前端已接通(收尾 stash 的预 WIP + merge 冲突解决)
应用本次会话开始时 stash 的两份文档 WIP(主题一致:领券去鉴权后的文档同步):

- docs/api/coupon-step.md: 鉴权 Bearer → 无(MVP);入参说明加 device_id;说明段去
  "鉴权壳"措辞,加"前端已接通"链路 + MVP 不鉴权数据债说明(指向 P1)。
- docs/后端技术实现.md: 行 18 表格 / section 6 段 / 已知问题表三处 stash pop
  已干净 apply;一句话总结(行 20)"鉴权 + 透传壳"→"透传壳"对齐;api/v1/ 文件树
  冲突 — 采纳同事 8 行 router 清单(wallet/signin/tasks/savings/ad 新加),给
  coupon.py 行末尾加"MVP 不鉴权",并补 compare.py 一行(本分支刚加的外卖比价
  透传,同事 commit 那时还没有,补上保持文档跟代码一致)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:43:53 +08:00
marco 16b78478c0 Merge remote-tracking branch 'origin/main' into feature/coupon-claim-integration
# Conflicts:
#	docs/api/README.md
2026-05-28 10:33:35 +08:00
marco aa34ad7d0b feat(compare): 外卖比价 2 端点透传到 pricebot-backend (food MVP)
仿 coupon_step 在 app/api/v1/compare.py 加纯 body 透传 2 端点(MVP 不鉴权,只读
device_id/trace_id/step 打日志,不做 schema 校验)。client /api/v1/{intent/recognize,
price/step} → backend 去掉 /v1 转发到 PRICEBOT_BASE_URL,接通外卖比价
client→server→pricebot-backend 链路。

- compare.py: _passthrough helper + intent/recognize + price/step
- config.py: PRICEBOT_COMPARE_TIMEOUT_SEC=60(意图识别大上下文 LLM、price/step
  每帧 LLM,比领券 30s 长;对齐客户端 agent ApiClient 的 60s 读超时)
- main.py: 注册 compare_router
- tests/test_compare_proxy.py: 2 端点参数化测试(透传/5xx→502/不可达→502/坏 JSON→400)
- docs/api/{compare-intent-recognize,compare-price-step}.md + 更新 README 索引
- 待办与技术债.md P2 外卖 2 端点标 (2026-05-27);电商 2 个待接(compare.py
  加两行即可)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:29:20 +08:00
marco 24faaf9d47 test(coupon-proxy): 修正已过时的 401 鉴权断言
coupon/step MVP 阶段已去鉴权(见「待办与技术债.md」已解决),但
test_coupon_step_requires_auth 仍断言"无 token → 401",baseline 实际走到上游
拿 502,这条用例长期红灯。改为 mock httpx 验证不带 token 也能正常透传,与新加的
compare 端点 no-auth 测试一致。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:29:20 +08:00
marco 6e99287c77 Merge pull request 'feat: 福利钱包+签到+省钱战绩+激励广告发奖+微信提现 后端' (#2) from feature/welfare-withdraw-ad into main
Reviewed-on: #2
2026-05-27 23:04:22 +08:00
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
118 changed files with 7284 additions and 312 deletions
+36
View File
@@ -34,6 +34,42 @@ SMS_MOCK=true
SMS_CODE_TTL_SEC=300
SMS_SEND_INTERVAL_SEC=60
# ===== 美团联盟 CPS =====
# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥
MT_CPS_APP_KEY=
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')
@@ -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 ###
@@ -0,0 +1,36 @@
"""convert savings_record.dishes from json to jsonb
PG only. SQLite 上 JSON/JSONB 都是 TEXT, 此迁移是 no-op。
Revision ID: ef96beb47b1e
Revises: c8d9e0f1a2b3
Create Date: 2026-05-29 10:57:21.471774
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = 'ef96beb47b1e'
down_revision: Union[str, Sequence[str], None] = 'c8d9e0f1a2b3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute(
"ALTER TABLE savings_record "
"ALTER COLUMN dishes TYPE JSONB USING dishes::JSONB"
)
def downgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute(
"ALTER TABLE savings_record "
"ALTER COLUMN dishes TYPE JSON USING dishes::JSON"
)
+44
View File
@@ -0,0 +1,44 @@
"""feedback table (用户帮助与反馈)
Revision ID: d1e2f3a4b5c6
Revises: c8d9e0f1a2b3
Create Date: 2026-05-28 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd1e2f3a4b5c6'
down_revision: Union[str, Sequence[str], None] = 'c8d9e0f1a2b3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'feedback',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('contact', sa.String(length=128), nullable=False),
sa.Column('images', sa.JSON(), nullable=True),
sa.Column('status', 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('feedback', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_feedback_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_feedback_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('feedback', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_feedback_created_at'))
batch_op.drop_index(batch_op.f('ix_feedback_user_id'))
op.drop_table('feedback')
+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")
+88
View File
@@ -0,0 +1,88 @@
"""外卖比价业务透传端点。
把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权**
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
device_id↔user_id 绑定后才能做用户级画像)。
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
价格,一次性。
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
和 /ecom/step 两行即可。
pricebot 协议文档:
pricebot-backend/docs/main/02_api_protocol.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.compare")
router = APIRouter(prefix="/api/v1", tags=["compare"])
async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
"""把请求体原样转发给 pricebot-backend 的 {upstream_path}
跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
比领券的 30s 长)。
"""
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('/')}{upstream_path}"
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
logger.info(
"compare %s device_id=%s trace_id=%s step=%s",
upstream_path,
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()
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)")
async def intent_recognize(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/intent/recognize")
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
async def price_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/price/step")
+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()
+63
View File
@@ -0,0 +1,63 @@
"""帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张)
截图复用 [app.core.media] 落盘到 /media/feedback/。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import feedback as feedback_repo
from app.schemas.feedback import FeedbackOut
logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4
_CONTENT_MAX = 2000
_CONTACT_MAX = 128
@router.post("", response_model=FeedbackOut, summary="提交反馈")
async def submit_feedback(
user: CurrentUser,
db: DbSession,
content: str = Form(...),
contact: str = Form(...),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
contact = contact.strip()
if not content:
raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
files = [f for f in (images or []) if f is not None and f.filename]
if len(files) > _MAX_IMAGES:
raise HTTPException(status_code=400, detail=f"最多上传 {_MAX_IMAGES} 张图片")
urls: list[str] = []
for f in files:
data = await f.read()
try:
urls.append(media.save_feedback_image(user.id, data))
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls,
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
+132
View File
@@ -0,0 +1,132 @@
"""美团 CPS 券列表 + 换链。
不需要登录,客户端传经纬度即可。
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, HTTPException
from app.core.config import settings
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.schemas.meituan import (
CouponCard,
CouponListRequest,
CouponListResponse,
FeedRequest,
FeedResponse,
ReferralLinkRequest,
ReferralLinkResponse,
)
logger = logging.getLogger("shagua.meituan")
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
def list_coupons(req: CouponListRequest) -> CouponListResponse:
if not settings.mt_cps_configured:
return CouponListResponse(items=[], has_next=False, search_id=None)
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
try:
raw = query_coupon(
longitude=req.longitude,
latitude=req.latitude,
platform=req.platform,
biz_line=req.biz_line,
list_topic_id=req.list_topic_id,
search_text=req.keyword,
search_id=req.search_id,
sort_field=req.sort_field,
page_no=req.page,
page_size=req.page_size,
)
except MeituanCpsError as e:
logger.error("query_coupon failed: %s", e)
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])]
return CouponListResponse(
items=items,
has_next=raw.get("hasNext", False),
search_id=raw.get("searchId"),
)
_TOPIC_ROUNDS = [
(3, 3), # 爆款筛选
(2, 2), # 今日必推
(1, 5), # 精选 + 限时筛选
]
def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
items: list[CouponCard] = []
seen: set[str] = set()
i = j = 0
while i < len(waimai) or j < len(daodian):
for _ in range(2):
if i < len(waimai):
card = CouponCard.from_raw(waimai[i]); i += 1
if card.product_view_sign not in seen:
seen.add(card.product_view_sign); items.append(card)
if j < len(daodian):
card = CouponCard.from_raw(daodian[j]); j += 1
if card.product_view_sign not in seen:
seen.add(card.product_view_sign); items.append(card)
return items
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
def feed(req: FeedRequest) -> FeedResponse:
if not settings.mt_cps_configured:
return FeedResponse(items=[], has_next=False, page=req.page)
page_idx = req.page - 1
lon, lat = req.longitude, req.latitude
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
try:
return (query_coupon(
longitude=lon, latitude=lat,
platform=platform, biz_line=biz_line,
list_topic_id=topic, page_size=20,
).get("data") or [])
except MeituanCpsError:
return []
if page_idx >= len(_TOPIC_ROUNDS):
return FeedResponse(items=[], has_next=False, page=req.page)
wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx]
with ThreadPoolExecutor(max_workers=2) as pool:
f_wm = pool.submit(_fetch_topic, 1, None, wm_topic)
f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic)
waimai, daodian = f_wm.result(), f_dd.result()
items = _interleave(waimai, daodian)
has_next = page_idx + 1 < len(_TOPIC_ROUNDS)
return FeedResponse(items=items, has_next=has_next, page=req.page)
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
if not settings.mt_cps_configured:
return ReferralLinkResponse(link="", link_map={})
try:
raw = get_referral_link(
product_view_sign=req.product_view_sign,
platform=req.platform,
biz_line=req.biz_line,
sid=req.sid,
link_type_list=req.link_type_list,
)
except MeituanCpsError as e:
logger.error("get_referral_link failed: %s", e)
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
link_map = raw.get("referralLinkMap") or {}
link = raw.get("data") or link_map.get("1") or link_map.get("3") or next(iter(link_map.values()), "")
return ReferralLinkResponse(link=link, link_map=link_map)
+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)
+59
View File
@@ -0,0 +1,59 @@
"""用户资料 endpoint。
路由前缀 `/api/v1/user`,全部需 Bearer 鉴权(用户级数据,不同于 MVP 透传接口):
PATCH /profile 修改昵称
POST /avatar 上传头像(multipart,字段名 file)
DELETE / 注销账号(软删除 + 匿名化)
昵称/头像后端持久化到 user 表,登录后经 /auth/me 与登录响应回传客户端——这才是
"改完重登仍生效"的唯一数据源(此前客户端只存本地、退登即丢)。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import user as user_repo
from app.schemas.auth import UserOut
from app.schemas.user import OkResponse, ProfileUpdateRequest
logger = logging.getLogger("shagua.user")
router = APIRouter(prefix="/api/v1/user", tags=["user"])
@router.patch("/profile", response_model=UserOut, summary="修改昵称")
def update_profile(req: ProfileUpdateRequest, user: CurrentUser, db: DbSession) -> UserOut:
updated = user_repo.update_nickname(db, user, nickname=req.nickname)
logger.info("update nickname user_id=%d", user.id)
return UserOut.model_validate(updated)
@router.post("/avatar", response_model=UserOut, summary="上传头像")
async def upload_avatar(
user: CurrentUser,
db: DbSession,
file: UploadFile = File(..., description="头像图片(jpeg/png/webp,≤5MB)"),
) -> UserOut:
data = await file.read()
try:
url = media.save_avatar(user.id, data)
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
old_url = user.avatar_url
updated = user_repo.set_avatar_url(db, user, avatar_url=url)
media.delete_avatar(old_url) # 落库成功后再删旧文件,避免删了新的没存上
logger.info("update avatar user_id=%d url=%s", user.id, url)
return UserOut.model_validate(updated)
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
media.delete_avatar(user.avatar_url)
user_repo.soft_delete_account(db, user)
logger.info("delete account user_id=%d", user.id)
return OkResponse()
+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,
)
+75 -1
View File
@@ -6,15 +6,18 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file=str(_PROJECT_ROOT / ".env"),
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
@@ -25,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"
@@ -46,6 +52,74 @@ class Settings(BaseSettings):
SMS_CODE_TTL_SEC: int = 300
SMS_SEND_INTERVAL_SEC: int = 60
# ===== 美团联盟 CPS =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = ""
MT_CPS_APP_SECRET: str = ""
MT_CPS_HOST: str = "https://media.meituan.com"
MT_CPS_TIMEOUT_SEC: int = 15
MT_CPS_DEFAULT_SID: str = "sgbjia"
@property
def mt_cps_configured(self) -> bool:
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
# ===== 微信支付(商家转账到零钱 / 提现)=====
# 真实凭证放 .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
# 比价(intent/recognize + price/step)透传超时:意图识别是大上下文 LLM、
# price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。
PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60
# ===== 媒体文件(用户头像上传)=====
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
# 生产可改由 nginx 直接 serve MEDIA_ROOT,绕过应用进程。
MEDIA_ROOT: str = "./data/media"
MEDIA_URL_PREFIX: str = "/media"
AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB
# ===== CORS =====
CORS_ALLOW_ORIGINS: str = ""
+76
View File
@@ -0,0 +1,76 @@
"""用户上传文件(头像 / 反馈截图)的本地落盘 + 对外 URL 构建。
落盘到 `settings.MEDIA_ROOT/<子目录>/`,对外以 `settings.MEDIA_URL_PREFIX` 暴露
(main.py 用 StaticFiles 挂载)。返回**相对路径** URL(如 `/media/avatars/xxx.jpg`),
由客户端按自己的 BASE_URL 解析为绝对地址——服务端不知道客户端怎么访问到自己(dev 下
可能是 10.0.2.2 / LAN IP),所以不返回绝对 URL。
安全:不信任客户端给的 content-type / 文件名,改用魔数嗅探判定真实图片类型;
文件名服务端随机生成,杜绝路径穿越与覆盖。
"""
from __future__ import annotations
import secrets
from pathlib import Path
from app.core.config import settings
class MediaError(Exception):
"""上传文件不合法(类型/大小)。调用方转 400。"""
def _media_dir(subdir: str) -> Path:
d = Path(settings.MEDIA_ROOT) / subdir
d.mkdir(parents=True, exist_ok=True)
return d
def _sniff_ext(data: bytes) -> str | None:
"""按魔数判定图片类型,返回扩展名;非支持类型返回 None。"""
if data[:3] == b"\xff\xd8\xff":
return ".jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
return None
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
"""通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。"""
if not data:
raise MediaError("空文件")
if len(data) > settings.AVATAR_MAX_BYTES:
raise MediaError("图片过大(上限 5MB)")
ext = _sniff_ext(data)
if ext is None:
raise MediaError("仅支持 JPEG / PNG / WebP 图片")
fname = f"u{user_id}_{secrets.token_hex(8)}{ext}"
(_media_dir(subdir) / fname).write_bytes(data)
return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}"
def save_avatar(user_id: int, data: bytes) -> str:
"""保存头像,返回相对 URL(`/media/avatars/<file>`)。"""
return _save_image("avatars", user_id, data)
def save_feedback_image(user_id: int, data: bytes) -> str:
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)。"""
return _save_image("feedback", user_id, data)
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
if not url or not url.startswith(prefix):
return
fname = url[len(prefix):]
if not fname or "/" in fname or "\\" in fname or ".." in fname:
return # 防路径穿越
try:
(_media_dir("avatars") / fname).unlink(missing_ok=True)
except OSError:
pass
+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)
+14 -8
View File
@@ -27,19 +27,25 @@ def _ensure_sqlite_dir(url: str) -> None:
_ensure_sqlite_dir(settings.DATABASE_URL)
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
# SQLite 跨线程访问要 check_same_thread=False;PG/MySQL 不需要这个参数
_connect_args: dict = {}
if settings.DATABASE_URL.startswith("sqlite"):
if _is_sqlite:
_connect_args["check_same_thread"] = False
engine = create_engine(
settings.DATABASE_URL,
connect_args=_connect_args,
_engine_kwargs: dict = {
"connect_args": _connect_args,
# echo 在 dev 下打 SQL,生产关掉
echo=settings.APP_DEBUG and not settings.is_prod,
future=True,
pool_pre_ping=True,
)
"echo": settings.APP_DEBUG and not settings.is_prod,
"future": True,
"pool_pre_ping": True,
}
# SQLite 用单文件不需要池;PG/MySQL 必须显式池化 + recycle 防 idle 断连
if not _is_sqlite:
_engine_kwargs.update(pool_size=10, max_overflow=20, pool_recycle=3600)
engine = create_engine(settings.DATABASE_URL, **_engine_kwargs)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
+140
View File
@@ -0,0 +1,140 @@
"""美团联盟 CPS 开放接口客户端。
签名方式:类阿里云网关 S-Ca 头,但 stringToSign **只有 4 段**——
METHOD\n + Content-MD5\n + Headers + Url
没有 Accept / Content-Type / Date 行(加了就签名失败)。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.meituan")
class MeituanCpsError(Exception):
"""美团 CPS 接口调用失败。"""
def _content_md5(body: bytes) -> str:
return base64.b64encode(hashlib.md5(body).digest()).decode()
def _sign(secret: str, method: str, content_md5: str, path: str,
signed_headers: dict[str, str]) -> str:
block = "".join(
f"{k}:{signed_headers[k]}\n"
for k in sorted(signed_headers)
)
sts = f"{method}\n{content_md5}\n{block}{path}"
return base64.b64encode(
hmac.new(secret.encode(), sts.encode(), hashlib.sha256).digest()
).decode()
def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
if not settings.MT_CPS_APP_KEY or not settings.MT_CPS_APP_SECRET:
raise MeituanCpsError("MT_CPS_APP_KEY / MT_CPS_APP_SECRET not configured")
body = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
content_md5 = _content_md5(body)
ts = str(int(time.time() * 1000))
signed_headers = {"S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts}
signature = _sign(settings.MT_CPS_APP_SECRET, "POST", content_md5, path, signed_headers)
headers = {
"Content-Type": "application/json",
"Content-MD5": content_md5,
"S-Ca-App": settings.MT_CPS_APP_KEY,
"S-Ca-Timestamp": ts,
"S-Ca-Signature-Headers": "S-Ca-Timestamp,S-Ca-App",
"S-Ca-Signature": signature,
}
url = f"{settings.MT_CPS_HOST}{path}"
try:
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
except httpx.HTTPError as e:
logger.exception("[MT] http error calling %s", url)
raise MeituanCpsError(f"meituan http error: {e}") from e
if resp.status_code != 200:
logger.error("[MT] HTTP %s body=%s", resp.status_code, resp.text[:500])
raise MeituanCpsError(f"meituan http {resp.status_code}")
data = resp.json()
if data.get("code") != 0:
logger.error("[MT] api error code=%s message=%s", data.get("code"), data.get("message"))
raise MeituanCpsError(f"code={data.get('code')} {data.get('message')}")
return data
# ────────────────────── 业务方法 ──────────────────────
def query_coupon(
*,
longitude: float,
latitude: float,
platform: int = 1,
biz_line: int | None = None,
list_topic_id: int | None = 3,
search_text: str | None = None,
search_id: str | None = None,
sort_field: int | None = None,
page_no: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
body: dict[str, Any] = {
"platform": platform,
"longitude": int(longitude * 1_000_000),
"latitude": int(latitude * 1_000_000),
"pageNo": page_no,
"pageSize": page_size,
}
if biz_line is not None:
body["bizLine"] = biz_line
if search_text:
body["searchText"] = search_text
if sort_field is None:
sort_field = 6
elif list_topic_id is not None:
body["listTopiId"] = list_topic_id
if sort_field is not None:
body["sortField"] = sort_field
if search_id:
body["searchId"] = search_id
return _call("/cps_open/common/api/v1/query_coupon", body)
def get_referral_link(
*,
product_view_sign: str,
platform: int = 1,
biz_line: int | None = None,
sid: str | None = None,
link_type_list: list[int] | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"productViewSign": product_view_sign}
if platform == 2:
body["platform"] = 2
if biz_line is not None:
body["bizLine"] = biz_line
body["sid"] = sid or settings.MT_CPS_DEFAULT_SID
body["linkTypeList"] = link_type_list or [1, 3]
return _call("/cps_open/common/api/v1/get_referral_link", body)
+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}
+32
View File
@@ -8,10 +8,23 @@ import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.v1.ad import router as ad_router
from app.api.v1.auth import router as auth_router
from app.api.v1.compare import router as compare_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.meituan import router as meituan_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.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.core.config import settings
from app.core.logging import setup_logging
@@ -57,3 +70,22 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(coupon_router)
app.include_router(compare_router)
app.include_router(meituan_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)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
_media_root.mkdir(parents=True, exist_ok=True)
app.mount(
settings.MEDIA_URL_PREFIX,
StaticFiles(directory=str(_media_root)),
name="media",
)
+11
View File
@@ -1,2 +1,13 @@
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
from app.models.ad_reward import AdRewardRecord # noqa: F401
from app.models.feedback import Feedback # 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.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}>"
+35
View File
@@ -0,0 +1,35 @@
"""用户反馈表(帮助与反馈)。
每条 = 用户一次提交。content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的
截图 URL 列表(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Feedback(Base):
__tablename__ = "feedback"
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
)
content: Mapped[str] = mapped_column(Text, nullable=False)
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# new(待处理) / handled(已处理)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
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"<Feedback id={self.id} user_id={self.user_id} status={self.status}>"
+42
View File
@@ -0,0 +1,42 @@
"""省钱记录表。
是 profile「累计帮你省了」和「省钱战绩」的唯一数据源:每完成一次比价/下单,
省了多少记一行。本期比价还没接后端,数据由 demo seeder 幂等灌入(source='demo'),
聚合接口照常做真实 SUM/分组;等比价真接入后,改成上报写入、停掉 seeder 即可。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
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)
# 菜品名列表(JSONB),PG 上可建 GIN 索引,前 2 道直接展示,其余收进「还有 N 道菜」展开。
dishes: Mapped[list[str]] = mapped_column(JSONB, 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
+27
View File
@@ -0,0 +1,27 @@
"""feedback 表写入。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.feedback import Feedback
def create_feedback(
db: Session,
*,
user_id: int,
content: str,
contact: str,
images: list[str] | None,
) -> Feedback:
fb = Feedback(
user_id=user_id,
content=content,
contact=contact,
images=images or None,
status="new",
)
db.add(fb)
db.commit()
db.refresh(fb)
return fb
+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
@@ -46,3 +46,31 @@ def upsert_user_for_login(
db.commit()
db.refresh(user)
return user
def update_nickname(db: Session, user: User, *, nickname: str) -> User:
user.nickname = nickname
db.commit()
db.refresh(user)
return user
def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
user.avatar_url = avatar_url
db.commit()
db.refresh(user)
return user
def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:软删除 + 匿名化。
phone 改成 `deleted_<id>` 释放唯一约束,允许同号码重新注册成全新账号;
清空 PII(昵称/头像),保留行用于审计status=deleted 后将无法再登录该行
(登录接口校验 status==active)
"""
user.status = "deleted"
user.phone = f"deleted_{user.id}"
user.nickname = None
user.avatar_url = None
db.commit()
+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="看完一个激励视频发的金币")
+14
View File
@@ -0,0 +1,14 @@
"""反馈相关响应 schema。请求是 multipart 表单(content/contact/images),在路由里直接校验。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class FeedbackOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
status: str
created_at: datetime
+157
View File
@@ -0,0 +1,157 @@
"""美团 CPS 券列表 / 换链相关 schemas。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
class CouponCard(BaseModel):
product_view_sign: str
platform: int = Field(..., description="1=外卖/到家, 2=到店")
biz_line: int | None = Field(None, description="到店子类: 1到餐 2到综 3酒店 4门票")
name: str
head_image_url: str
brand_name: str | None = None
brand_logo_url: str | None = None
sell_price: str
original_price: str
discount_amount: str = Field(..., description="优惠金额 = 原价 - 现价")
commission_rate: str = Field(..., description="佣金比例, 如 1.4%")
commission_amount: str | None = Field(None, description="预估佣金元")
sale_volume: str | None = None
poi_name: str | None = Field(None, description="最近门店名")
distance_text: str | None = Field(None, description="格式化距离, 如 600m / 1.5km")
distance_meters: float | None = Field(None, description="原始距离(米)")
available_poi_num: int | None = Field(None, description="可用门店数")
coupon_num: int | None = Field(None, description="券张数(外卖兑换券)")
valid_days: int | None = Field(None, description="券有效天数")
price_label: str | None = Field(None, description="如 15天低价")
rank_label: str | None = Field(None, description="如 2小时北京外卖销量榜第1名")
rating_label: str | None = Field(None, description="如 4.6分")
@classmethod
def from_raw(cls, item: dict[str, Any]) -> CouponCard:
cpd = item.get("couponPackDetail") or {}
brand = item.get("brandInfo") or {}
comm = item.get("commissionInfo") or {}
poi = item.get("deliverablePoiInfo") or {}
avail = item.get("availablePoiInfo") or {}
label = cpd.get("productLabel") or {}
price_lbl = label.get("pricePowerLabel") or {}
valid_info = item.get("couponValidTimeInfo") or {}
platform = cpd.get("platform") or 1
biz_line = cpd.get("bizLine")
head_url = cpd.get("headUrl") or ""
if "@" in head_url:
head_url = head_url.split("@")[0]
sell = cpd.get("sellPrice") or "0"
orig = cpd.get("originalPrice") or "0"
try:
discount = f"{float(orig) - float(sell):.2f}"
except (ValueError, TypeError):
discount = "0"
cp = comm.get("commissionPercent") or "0"
try:
rate = f"{float(cp) / 100:.1f}%"
except (ValueError, TypeError):
rate = "0%"
raw_dist = poi.get("deliveryDistance")
dist_meters: float | None = None
dist_text: str | None = None
if raw_dist not in (None, "null", ""):
try:
d = float(raw_dist)
dist_meters = d * 1000 if platform == 1 else d
if dist_meters < 1000:
dist_text = f"{int(dist_meters)}m"
else:
dist_text = f"{dist_meters / 1000:.1f}km"
except (ValueError, TypeError):
pass
return cls(
product_view_sign=cpd.get("productViewSign") or cpd.get("skuViewId") or "",
platform=platform,
biz_line=biz_line,
name=cpd.get("name") or "",
head_image_url=head_url,
brand_name=brand.get("brandName"),
brand_logo_url=brand.get("brandLogoUrl"),
sell_price=sell,
original_price=orig,
discount_amount=discount,
commission_rate=rate,
commission_amount=comm.get("commission"),
sale_volume=cpd.get("saleVolume"),
poi_name=poi.get("poiName"),
distance_text=dist_text,
distance_meters=dist_meters,
available_poi_num=avail.get("availablePoiNum"),
coupon_num=cpd.get("couponNum"),
valid_days=valid_info.get("couponValidDay"),
price_label=price_lbl.get("historyPriceLabel") or price_lbl.get("beatMTLabel"),
rank_label=label.get("productRankLabel"),
rating_label=label.get("dianPingRankLabel"),
)
# ───────────────── 券列表 请求 / 响应 ─────────────────
class CouponListRequest(BaseModel):
longitude: float = Field(..., description="经度, 如 116.404")
latitude: float = Field(..., description="纬度, 如 39.928")
platform: int = Field(1, description="1=外卖/到家, 2=到店")
biz_line: int | None = Field(None, description="到店子类: 1到餐 2到综 3酒店 4门票; 外卖不填")
list_topic_id: int | None = Field(3, description="榜单: 1精选 2今日必推 3爆款筛选 5限时筛选")
keyword: str | None = Field(None, description="搜索关键词(填了会忽略 list_topic_id)")
sort_field: int | None = Field(None, description="1价格 2销量 6离我最近; 搜索默认6")
search_id: str | None = Field(None, description="翻页token, 首页不填")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=20)
class CouponListResponse(BaseModel):
items: list[CouponCard]
has_next: bool = False
search_id: str | None = None
class FeedRequest(BaseModel):
longitude: float = Field(..., description="经度")
latitude: float = Field(..., description="纬度")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=20)
class FeedResponse(BaseModel):
items: list[CouponCard]
has_next: bool = False
page: int = 1
# ───────────────── 换链 请求 / 响应 ─────────────────
class ReferralLinkRequest(BaseModel):
product_view_sign: str = Field(..., min_length=1)
platform: int = Field(1, description="1=外卖/到家, 2=到店")
biz_line: int | None = None
sid: str | None = Field(None, description="渠道追踪标识, 不填用默认")
link_type_list: list[int] | None = Field(None, description="1=H5长链 2=H5短链 3=deeplink; 不填默认[1,3]")
class ReferralLinkResponse(BaseModel):
link: str = Field(..., description="默认推广链接")
link_map: dict[str, str] = Field(default_factory=dict, description="各类型链接 {linkType: url}")
+20
View File
@@ -0,0 +1,20 @@
"""用户资料(昵称/头像)相关请求 schemas。响应复用 [auth.UserOut]。"""
from __future__ import annotations
from pydantic import BaseModel, Field, field_validator
class ProfileUpdateRequest(BaseModel):
nickname: str = Field(..., min_length=1, max_length=16, description="昵称,1-16 字")
@field_validator("nickname")
@classmethod
def _strip_non_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("昵称不能为空")
return v
class OkResponse(BaseModel):
ok: bool = True
+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` 总览表里补一行链接。这样索引始终是唯一的"传送门",细节各自独立、互不干扰。
+144
View File
@@ -0,0 +1,144 @@
# 傻瓜比价 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` | 无 | [详情](./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`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) |
| **钱包 / 我的资产**(前缀 `/api/v1/wallet` |||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) |
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) |
| **签到**(前缀 `/api/v1/signin` |||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
| **任务**(前缀 `/api/v1/tasks` |||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
| **省钱**(前缀 `/api/v1/savings` |||
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
| **看广告发奖**(前缀 `/api/v1/ad` |||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
> 福利相关业务接口(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`)下不真发短信,仅记录冷却时间。
+25
View File
@@ -0,0 +1,25 @@
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id``trace_id``step` 用于日志。
客户端实际传源平台购物车页的无障碍树采集结果(pricebot 协议里的 `screens`:`cart_page_1` / `cart_page_2`)。
## 出参
pricebot-backend 的响应**原样返回**JSON object)。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`
## 错误码
- `400` body 不是合法 JSON
- `502` pricebot 上游不可达(网络错误)或返回 5xx
## 说明
把请求体原样转发到 `PRICEBOT_BASE_URL``/api/intent/recognize`(去掉 `/v1`,async httpx)。**一次比价只调一次**。真正的识别逻辑(剪枝 + 索引 + LLM)在 **pricebot-backend(另一个 repo,GoalEngine**,本接口只是"透传壳"。
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
- `PRICEBOT_COMPARE_TIMEOUT_SEC`(默认 60s,意图识别是大上下文 LLM,比领券的 30s 长)
+23
View File
@@ -0,0 +1,23 @@
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id``trace_id``step` 用于日志。
客户端逐帧上报 `screen_state` + 上一步 `action_result`;`step=0` 还带 `query` + `calibration`(来自 Phase 1)。
## 出参
pricebot-backend 的响应**原样返回**JSON object)。含 `action`tap / set_text / launch / wait / done…)、`continue``status`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
## 错误码
- `400` body 不是合法 JSON
- `502` pricebot 上游不可达(网络错误)或返回 5xx
## 说明
把请求体原样转发到 `PRICEBOT_BASE_URL``/api/price/step`(去掉 `/v1`,async httpx)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**,本接口只是"透传壳"。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`)。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`;生产部署应与 pricebot-backend 同内网——比价一单 30~80 步、逐帧多一跳,走公网延迟会累积)
- `PRICEBOT_COMPARE_TIMEOUT_SEC`(默认 60s,price/step 每帧都是 LLM
+24
View File
@@ -0,0 +1,24 @@
# POST /api/v1/coupon/step — 一键领券任务步进(透传到 pricebot)
> 所属:Coupon 组(前缀 `/api/v1/coupon` | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](./README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id``trace_id``step` 用于日志。
## 出参
pricebot-backend 的响应**原样返回**(JSON object)。
## 错误码
- `400` body 不是合法 JSON
- `502` pricebot 上游不可达(网络错误)或返回 5xx
## 说明
把请求体原样转发到 `PRICEBOT_BASE_URL``/api/coupon/step`async httpx)。真正的领券逻辑在 **pricebot-backend(另一个 repo,GoalEngine + 事件驱动)**,本接口只是"透传壳"。
是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。
**相关配置**:
- `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`

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