Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1ce109f13 | |||
| bd126635e3 | |||
| 8fc291a30c |
@@ -41,35 +41,6 @@ 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
|
||||
|
||||
@@ -34,6 +34,3 @@ secrets/*
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# 运行日志(run.sh 输出, 不入库)
|
||||
*.log
|
||||
|
||||
@@ -2,15 +2,10 @@
|
||||
|
||||
傻瓜比价正式 App 后端 — FastAPI + SQLAlchemy + JWT。
|
||||
|
||||
覆盖:**账号体系**(极光一键登录 / 短信登录 + JWT)、**福利钱包**(金币 / 现金 / 签到 / 任务 / 省钱战绩)、**微信提现**(商家转账到零钱)、**看广告发奖**(穿山甲 GroMore S2S 回调)、**美团 CPS**(领券透传 / 比价推荐)。
|
||||
> 占坑期已有的试验后台 `shaguabijia-server` 是无状态 mock,本项目是正式版,
|
||||
> 引入账号体系、JWT 登录态、Alembic 迁移、SQLite 起步可平滑切 PostgreSQL。
|
||||
|
||||
> 占坑期的试验后台 `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 怎么建表 / 升级)
|
||||
详细技术说明见 [docs/后端技术实现.md](docs/后端技术实现.md)(登录链路、极光 RSA、部署 checklist)。
|
||||
|
||||
---
|
||||
|
||||
@@ -21,75 +16,57 @@
|
||||
| 语言 | 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) |
|
||||
| HTTP 客户端 | httpx | 调极光 / 微信 / 美团 / 穿山甲 |
|
||||
| 加解密 | cryptography | 极光 RSA 解密、微信 V3 签名 / 敏感字段加密 |
|
||||
| 微信支付 | 商家转账到零钱 V3 | 提现;懒加载商户证书 |
|
||||
| 极光一键登录 | httpx + cryptography | RSA 解密,逻辑参考试验后台 |
|
||||
| 配置 | 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 入口:注册全部 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/ 基础设施(无外部业务集成)
|
||||
│ ├── main.py FastAPI 入口 + /health + CORS
|
||||
│ ├── core/
|
||||
│ │ ├── config.py Settings (pydantic-settings)
|
||||
│ │ ├── logging.py 统一日志配置
|
||||
│ │ ├── security.py JWT 签发 / 校验 / token 对
|
||||
│ │ ├── 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)
|
||||
│ │ ├── 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/* 路由
|
||||
│
|
||||
├── alembic/ 数据库迁移(versions/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
|
||||
├── alembic/ 迁移脚本
|
||||
│ ├── env.py
|
||||
│ ├── script.py.mako
|
||||
│ └── versions/ (空,第一次跑 alembic revision 生成)
|
||||
├── alembic.ini
|
||||
├── 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 本地启动(自动先跑迁移再起服务)
|
||||
│
|
||||
├── deploy/
|
||||
│ ├── shaguabijia-app-server.service systemd unit
|
||||
│ └── nginx/
|
||||
│ └── app-api.shaguabijia.com.conf
|
||||
│
|
||||
├── secrets/ 不入 git。放 jverify_rsa_private.pem
|
||||
├── data/ 不入 git。SQLite 文件默认在这里
|
||||
├── tests/ 冒烟 + auth 流程
|
||||
│
|
||||
├── pyproject.toml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
@@ -99,63 +76,98 @@ shaguabijia-app-server/
|
||||
|
||||
## API 接口
|
||||
|
||||
完整契约见 **[docs/api/README.md](docs/api/README.md)**(总览表 + 一接口一文件)。按组速览:
|
||||
|
||||
| 组 | 前缀 | 端点数 | 鉴权 |
|
||||
| 方法 | 路径 | 说明 | 鉴权 |
|
||||
|---|---|---|---|
|
||||
| 健康检查 | `/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 | 无 |
|
||||
| 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` |
|
||||
|
||||
登录类接口响应统一是 `TokenWithUser`(access/refresh token 对 + user);金额字段一律以**分**为单位(`*_cents`)。`APP_ENV` 非 prod 时开 `http://localhost:8770/docs`(Swagger UI)。
|
||||
登录类接口的响应统一格式:
|
||||
```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)。
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 1. 装依赖
|
||||
|
||||
```bash
|
||||
cd shaguabijia-app-server
|
||||
python3.11 -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### 2. 配置
|
||||
### 2. 准备配置
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 至少改 JWT_SECRET_KEY;测美团填 MT_CPS_*;测提现填 WXPAY_*/WECHAT_*;测看广告填 PANGLE_*
|
||||
# 编辑 .env,生产部署务必改 JWT_SECRET_KEY
|
||||
```
|
||||
测极光一键登录把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`(没放也不影响其他接口,只是 `jverify-login` 返 502)。
|
||||
|
||||
### 3. 建表 / 升级数据库
|
||||
如果要测极光一键登录,把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`。
|
||||
没放也不影响其他接口(`sms`/`/health` 等),只是 `jverify-login` 会 502。
|
||||
|
||||
### 3. 建表(首次)
|
||||
|
||||
```bash
|
||||
mkdir -p data # SQLite 库目录
|
||||
alembic upgrade head # 按迁移链建到最新(幂等)
|
||||
# 生成首版 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
|
||||
```
|
||||
> 详见 [docs/数据库迁移.md](docs/数据库迁移.md)。只想迁移不启服务:`bash scripts/migrate.sh`。
|
||||
|
||||
### 4. 跑起来
|
||||
|
||||
```bash
|
||||
./run.sh # 监听 0.0.0.0:8770,自动先跑迁移;改代码自动重启
|
||||
# 或手动: uvicorn app.main:app --reload --port 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
|
||||
```
|
||||
@@ -164,13 +176,17 @@ pytest -q
|
||||
|
||||
## 部署(生产)
|
||||
|
||||
部署到 `app-api.shaguabijia.com`(参考 `deploy/`):
|
||||
参考 `deploy/`,部署到 `app-api.shaguabijia.com`:
|
||||
|
||||
```bash
|
||||
# 一次性
|
||||
ssh server "apt-get install -y python3.11-venv python3-pip"
|
||||
|
||||
# 后续更新
|
||||
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/*' \
|
||||
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/jverify_rsa_private.pem' \
|
||||
./ server:/opt/shaguabijia-app-server/
|
||||
|
||||
# 证书 / 私钥单独同步(首次 / 更换时):极光私钥 + 微信支付商户私钥·平台公钥
|
||||
# 私钥单独同步(只首次 / 私钥更换时)
|
||||
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
|
||||
|
||||
ssh server "
|
||||
@@ -180,7 +196,8 @@ ssh server "
|
||||
systemctl restart shaguabijia-app-server
|
||||
"
|
||||
```
|
||||
详见 `deploy/`。**看广告发奖上线**前对照 [docs/看广告赚金币上线清单.md](docs/看广告赚金币上线清单.md)(GroMore 回调配置、清理调试脚手架、端到端验收)。
|
||||
|
||||
详见 `deploy/shaguabijia-app-server.service` 和 `deploy/nginx/app-api.shaguabijia.com.conf`。
|
||||
|
||||
---
|
||||
|
||||
@@ -189,9 +206,11 @@ ssh server "
|
||||
| 维度 | 试验后台 (shaguabijia-server) | 本项目 (shaguabijia-app-server) |
|
||||
|---|---|---|
|
||||
| 数据库 | stdlib sqlite3,无 ORM | SQLAlchemy 2.0 + Alembic |
|
||||
| 业务 | mock parse(无账号体系) | 账号 + 钱包 / 提现 / 签到 / 看广告 / CPS |
|
||||
| 登录态 | 无,客户端发 phone 当 ID | JWT access + refresh |
|
||||
| 业务 | mock parse(无账号体系) | 完整账号体系 + JWT |
|
||||
| 登录态 | 无,客户端发 phone 当 ID | JWT access+refresh |
|
||||
| 配置 | 硬编码 | pydantic-settings + `.env` |
|
||||
| 域名 | api.shaguabijia.com(试验) | app-api.shaguabijia.com(正式) |
|
||||
| 短信登录 | 无 | 有(mock 实现,留扩展点) |
|
||||
| 域名 | api.shaguabijia.com (试验) | app-api.shaguabijia.com (正式) |
|
||||
|
||||
极光一键登录的实现(REST 调用、RSA padding 试错顺序)参考自试验后台,逻辑等价。
|
||||
极光一键登录的具体实现(REST 调用、RSA padding 试错顺序)直接参考自试验后台,
|
||||
逻辑等价。
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""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 @@
|
||||
"""add order_record table
|
||||
|
||||
Revision ID: c64a944a6139
|
||||
Revises: 9c559acc164a
|
||||
Create Date: 2026-05-26 21:09:40.047619
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c64a944a6139'
|
||||
down_revision: Union[str, Sequence[str], None] = '9c559acc164a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('order_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('platform', sa.String(length=30), nullable=False),
|
||||
sa.Column('store_name', sa.String(length=128), nullable=False),
|
||||
sa.Column('dishes', sa.Text(), nullable=False),
|
||||
sa.Column('order_date', sa.String(length=10), nullable=False),
|
||||
sa.Column('price', sa.String(length=20), nullable=False),
|
||||
sa.Column('original_price', sa.String(length=20), nullable=True),
|
||||
sa.Column('savings', sa.String(length=20), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('order_record', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_order_record_user_id'), ['user_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('order_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_order_record_user_id'))
|
||||
|
||||
op.drop_table('order_record')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,49 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,36 +0,0 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -1,44 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,49 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,32 +0,0 @@
|
||||
"""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'))
|
||||
@@ -1,30 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,96 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,56 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,131 +0,0 @@
|
||||
"""看激励视频发奖 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
@@ -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.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.core.sms import SmsError, send_code, verify_code
|
||||
from app.crud import user as crud_user
|
||||
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 = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify")
|
||||
user = crud_user.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 = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
||||
user = crud_user.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 = user_repo.get_user_by_id(db, user_id)
|
||||
user = crud_user.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")
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""外卖比价业务透传端点。
|
||||
|
||||
把客户端的 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")
|
||||
@@ -1,72 +0,0 @@
|
||||
"""领券业务透传端点。
|
||||
|
||||
把客户端的 POST /api/v1/coupon/step 请求原样转发给 pricebot-backend
|
||||
的 /api/coupon/step。MVP 阶段**不鉴权**(见 docs/待办与技术债.md P1:
|
||||
device_id 透传给 pricebot 区分设备,待补 JWT + device_id↔user_id 绑定后
|
||||
才能做用户级画像)。
|
||||
|
||||
pricebot 协议文档:
|
||||
pricebot-backend/docs/projects/领券-客户端对接协议.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.coupon")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"])
|
||||
|
||||
|
||||
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
|
||||
async def coupon_step(
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
"""把请求体原样转发给 pricebot-backend 的 /api/coupon/step。
|
||||
|
||||
- 鉴权: MVP 阶段不鉴权(待补 JWT,见 docs/待办与技术债.md P1)
|
||||
- 透传: 不做 schema 校验,pricebot 自己校验
|
||||
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
|
||||
|
||||
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}/api/coupon/step"
|
||||
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
|
||||
|
||||
logger.info(
|
||||
"coupon_step device_id=%s trace_id=%s step=%s",
|
||||
body.get("device_id"),
|
||||
body.get("trace_id"),
|
||||
body.get("step"),
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(url, json=body)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"pricebot upstream unreachable: {e}",
|
||||
) from e
|
||||
|
||||
if resp.status_code >= 500:
|
||||
logger.error(
|
||||
"[pricebot] 5xx status=%d body=%s",
|
||||
resp.status_code,
|
||||
resp.text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"pricebot upstream returned {resp.status_code}",
|
||||
)
|
||||
|
||||
return resp.json()
|
||||
@@ -1,63 +0,0 @@
|
||||
"""帮助与反馈 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)
|
||||
@@ -9,8 +9,7 @@ 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.core.meituan import MeituanCpsError, get_referral_link, query_coupon
|
||||
from app.schemas.meituan import (
|
||||
CouponCard,
|
||||
CouponListRequest,
|
||||
@@ -28,8 +27,6 @@ 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(
|
||||
@@ -81,8 +78,6 @@ def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
|
||||
|
||||
@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)
|
||||
@@ -113,8 +108,6 @@ def feed(req: FeedRequest) -> FeedResponse:
|
||||
|
||||
@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,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""省钱 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,
|
||||
)
|
||||
@@ -1,44 +0,0 @@
|
||||
"""签到 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,
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""一次性任务 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)
|
||||
+25
-46
@@ -1,59 +1,38 @@
|
||||
"""用户资料 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/user`,全部需 Bearer 鉴权(用户级数据,不同于 MVP 透传接口):
|
||||
PATCH /profile 修改昵称
|
||||
POST /avatar 上传头像(multipart,字段名 file)
|
||||
DELETE / 注销账号(软删除 + 匿名化)
|
||||
|
||||
昵称/头像后端持久化到 user 表,登录后经 /auth/me 与登录响应回传客户端——这才是
|
||||
"改完重登仍生效"的唯一数据源(此前客户端只存本地、退登即丢)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
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
|
||||
from app.crud import order_record as crud_order
|
||||
from app.schemas.order_record import OrderListResponse, OrderRecordOut
|
||||
|
||||
logger = logging.getLogger("shagua.user")
|
||||
logger = logging.getLogger("shagua.api.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(
|
||||
@router.get("/records", response_model=OrderListResponse, summary="分页查询当前用户的订单记录")
|
||||
def list_records(
|
||||
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()
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
) -> OrderListResponse:
|
||||
rows, has_next = crud_order.get_records_page(db, user_id=user.id, page=page, size=size)
|
||||
records = [
|
||||
OrderRecordOut(
|
||||
id=r.id,
|
||||
platform=r.platform,
|
||||
store_name=r.store_name,
|
||||
dishes=json.loads(r.dishes) if r.dishes else [],
|
||||
order_date=r.order_date,
|
||||
price=r.price,
|
||||
original_price=r.original_price,
|
||||
savings=r.savings,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return OrderListResponse(records=records, has_next=has_next, page=page)
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
"""钱包 / 我的资产 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,
|
||||
)
|
||||
@@ -28,9 +28,6 @@ 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"
|
||||
|
||||
@@ -53,73 +50,12 @@ class Settings(BaseSettings):
|
||||
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 = ""
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""用户上传文件(头像 / 反馈截图)的本地落盘 + 对外 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
|
||||
@@ -1,59 +0,0 @@
|
||||
"""轻量内存限流(固定窗口计数)。
|
||||
|
||||
定位:防脚本刷接口的**安全网**,不是精确配额。局限——进程内存、单 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
|
||||
@@ -1,87 +0,0 @@
|
||||
"""福利 / 激励相关的业务常量与换算规则。
|
||||
|
||||
集中放在这里,业务代码引用,避免数值散落各处。这些是**产品规则**(签到奖励、
|
||||
任务奖励、金币汇率)而非部署配置,所以不走环境变量;要调整直接改这里。
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.order_record import OrderRecord
|
||||
|
||||
|
||||
def get_records_page(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
) -> tuple[list[OrderRecord], bool]:
|
||||
offset = (page - 1) * size
|
||||
stmt = (
|
||||
select(OrderRecord)
|
||||
.where(OrderRecord.user_id == user_id)
|
||||
.order_by(OrderRecord.order_date.desc(), OrderRecord.id.desc())
|
||||
.offset(offset)
|
||||
.limit(size + 1)
|
||||
)
|
||||
rows = list(db.execute(stmt).scalars().all())
|
||||
has_next = len(rows) > size
|
||||
return rows[:size], has_next
|
||||
@@ -46,31 +46,3 @@ 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()
|
||||
+8
-14
@@ -27,25 +27,19 @@ 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 _is_sqlite:
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
_connect_args["check_same_thread"] = False
|
||||
|
||||
_engine_kwargs: dict = {
|
||||
"connect_args": _connect_args,
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args=_connect_args,
|
||||
# echo 在 dev 下打 SQL,生产关掉
|
||||
"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)
|
||||
echo=settings.APP_DEBUG and not settings.is_prod,
|
||||
future=True,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""穿山甲激励视频"服务端发奖回调"的验签。
|
||||
|
||||
发奖闭环:激励视频播完 → 穿山甲服务器 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)
|
||||
@@ -1,219 +0,0 @@
|
||||
"""微信支付 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}
|
||||
+1
-29
@@ -8,23 +8,12 @@ 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
|
||||
|
||||
@@ -70,22 +59,5 @@ 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",
|
||||
)
|
||||
app.include_router(user_router)
|
||||
|
||||
+1
-11
@@ -1,13 +1,3 @@
|
||||
"""所有 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,
|
||||
)
|
||||
from app.models.order_record import OrderRecord # noqa: F401
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""看激励视频发奖记录(穿山甲 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}>"
|
||||
@@ -1,35 +0,0 @@
|
||||
"""用户反馈表(帮助与反馈)。
|
||||
|
||||
每条 = 用户一次提交。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}>"
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class OrderRecord(Base):
|
||||
__tablename__ = "order_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
|
||||
platform: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
store_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
dishes: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
|
||||
order_date: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
price: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
original_price: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
savings: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""省钱记录表。
|
||||
|
||||
是 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}>"
|
||||
@@ -1,41 +0,0 @@
|
||||
"""签到记录表。
|
||||
|
||||
每次签到一行,(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}>"
|
||||
)
|
||||
@@ -1,36 +0,0 @@
|
||||
"""一次性任务完成记录表。
|
||||
|
||||
像"打开消息提醒"这类只能领一次的任务,完成后写一行,(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}>"
|
||||
@@ -33,15 +33,6 @@ 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")
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"""钱包相关表:金币账户 + 金币流水。
|
||||
|
||||
设计要点:
|
||||
- 余额快照(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}>"
|
||||
@@ -1,108 +0,0 @@
|
||||
"""看激励视频发奖 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
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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
|
||||
@@ -1,186 +0,0 @@
|
||||
"""省钱 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
|
||||
@@ -1,126 +0,0 @@
|
||||
"""签到 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
|
||||
@@ -1,71 +0,0 @@
|
||||
"""一次性任务 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
|
||||
@@ -1,506 +0,0 @@
|
||||
"""钱包 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
|
||||
@@ -1,41 +0,0 @@
|
||||
"""看激励视频发奖相关 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="看完一个激励视频发的金币")
|
||||
@@ -1,14 +0,0 @@
|
||||
"""反馈相关响应 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
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class OrderRecordOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
platform: str
|
||||
store_name: str
|
||||
dishes: list[str]
|
||||
order_date: str
|
||||
price: str
|
||||
original_price: str | None = None
|
||||
savings: str | None = None
|
||||
|
||||
|
||||
class OrderListResponse(BaseModel):
|
||||
records: list[OrderRecordOut]
|
||||
has_next: bool = False
|
||||
page: int = 1
|
||||
@@ -1,20 +0,0 @@
|
||||
"""用户资料(昵称/头像)相关请求 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
|
||||
@@ -1,214 +0,0 @@
|
||||
"""福利模块(钱包 / 签到 / 任务)请求 / 响应 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);为空表示到底")
|
||||
@@ -1,25 +0,0 @@
|
||||
# 后端文档库(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/<模块>-<接口>.md — 单接口文档**
|
||||
每个接口一个文件,只写自己的入参 / 出参 / 错误码 / 说明。命名按 `<模块>-<接口>`,如 `auth-jverify-login.md`、`coupon-step.md`、`meituan-feed.md`。
|
||||
|
||||
**查某个接口的协议**:先打开 [api/README.md](./api/README.md) 的总览表 → 找到接口 → 点链接进对应文档。
|
||||
|
||||
> **新增接口时**:在 `api/` 下加一个 `<模块>-<接口>.md`,并到 `api/README.md` 总览表里补一行链接。这样索引始终是唯一的"传送门",细节各自独立、互不干扰。
|
||||
@@ -1,144 +0,0 @@
|
||||
# 傻瓜比价 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`(每页条数,1–100,默认 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` 必须为高熵随机串(生产严禁用默认值)。
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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 | 代码位 ID(GroMore 带,目前仅入 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`。
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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)。
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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`,幂等 + 每日上限)。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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)。
|
||||
@@ -1,15 +0,0 @@
|
||||
# POST /api/v1/auth/logout — 登出
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ "ok": true }`
|
||||
|
||||
## 错误码
|
||||
无
|
||||
|
||||
## 说明
|
||||
服务端**无状态、不吊销 token**(占位实现),真正失效靠客户端删除本地 token。后续若加 jti 黑名单可在此写入。
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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 无效或过期 / 用户被禁用
|
||||
|
||||
## 说明
|
||||
无
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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` 自动调用。
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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 | ✅ | 验证码,长度 4–8 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`TokenWithUser`(`user.register_channel` 为 `"sms"`)。结构见 [API 索引](./README.md#复用数据结构)。
|
||||
|
||||
## 错误码
|
||||
- `400` 验证码错误
|
||||
- `403` 账号被禁用
|
||||
|
||||
## 说明
|
||||
mock 模式下任意 6 位数字均通过校验。注册即登录。
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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`)下不真发短信,仅记录冷却时间。
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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 长)
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,15 +0,0 @@
|
||||
# GET /health — 健康检查
|
||||
|
||||
> 所属:Meta | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ "status": "ok" }`
|
||||
|
||||
## 错误码
|
||||
无
|
||||
|
||||
## 说明
|
||||
无
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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 | 1–20 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。
|
||||
|
||||
## 错误码
|
||||
- `502` 美团接口失败
|
||||
|
||||
## 说明
|
||||
当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整。
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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 | 1–20 |
|
||||
|
||||
## 出参
|
||||
响应 `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")。
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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`、忽略客户端传值。
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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。
|
||||
@@ -1,29 +0,0 @@
|
||||
# GET /api/v1/savings/records — 省钱明细(游标分页)
|
||||
|
||||
> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `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`)数据源。
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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。
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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) 的余额(客户端就地刷新即可,不必另叠)。
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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 数据源。
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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 由后端把控(如「打开消息提醒」须客户端确认权限已开后才调)。金币已计入余额。
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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)。
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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 | 累计赚取金币 |
|
||||
|
||||
## 说明
|
||||
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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 唯一约束),一个微信只能绑一个账号。
|
||||
@@ -1,28 +0,0 @@
|
||||
# GET /api/v1/wallet/cash-transactions — 现金流水(游标分页)
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `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`)数据源。
|
||||
@@ -1,28 +0,0 @@
|
||||
# GET /api/v1/wallet/coin-transactions — 金币流水(游标分页)
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `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`)数据源。
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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`)。
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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)。金币与现金的扣加在同一事务内完成。
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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`。解绑后该微信可被其他账号绑定。
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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`)。
|
||||
@@ -1,28 +0,0 @@
|
||||
# GET /api/v1/wallet/withdraw-orders — 提现单列表(游标分页)
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `limit` | int | ❌ | 20 | 1–100 |
|
||||
| `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 | 发起时间 |
|
||||
|
||||
## 说明
|
||||
提现记录页数据源。
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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`)。
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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) 轮询。
|
||||
@@ -1,26 +0,0 @@
|
||||
# 集成层(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)
|
||||
@@ -1,36 +0,0 @@
|
||||
# 极光一键登录 — 服务端核验 + 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` 字段也失败。
|
||||
@@ -1,37 +0,0 @@
|
||||
# 美团联盟 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 文档「备注」。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user