feat: 福利钱包+签到+省钱战绩+激励广告发奖+微信提现 后端 #2
@@ -51,3 +51,25 @@ 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
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
傻瓜比价正式 App 后端 — FastAPI + SQLAlchemy + JWT。
|
||||
|
||||
> 占坑期已有的试验后台 `shaguabijia-server` 是无状态 mock,本项目是正式版,
|
||||
> 引入账号体系、JWT 登录态、Alembic 迁移、SQLite 起步可平滑切 PostgreSQL。
|
||||
覆盖:**账号体系**(极光一键登录 / 短信登录 + JWT)、**福利钱包**(金币 / 现金 / 签到 / 任务 / 省钱战绩)、**微信提现**(商家转账到零钱)、**看广告发奖**(穿山甲 GroMore S2S 回调)、**美团 CPS**(领券透传 / 比价推荐)。
|
||||
|
||||
详细技术说明见 [docs/后端技术实现.md](docs/后端技术实现.md)(登录链路、极光 RSA、部署 checklist)。
|
||||
> 占坑期的试验后台 `shaguabijia-server` 是无状态 mock;本项目是正式版,引入账号体系、JWT 登录态、Alembic 迁移,SQLite 起步可平滑切 PostgreSQL。
|
||||
|
||||
**文档导航**(都在 [docs/](docs/README.md)):
|
||||
- [docs/api/](docs/api/README.md) — **接口契约**(索引 + 一接口一文件)
|
||||
- [docs/integrations/](docs/integrations/README.md) — **集成层**(SDK 签名 / 加解密 / 协议细节)
|
||||
- [docs/后端技术实现.md](docs/后端技术实现.md) — **架构与链路**(分层、登录、CPS、数据模型)
|
||||
- [docs/数据库迁移.md](docs/数据库迁移.md) — **数据库迁移**(Alembic 怎么建表 / 升级)
|
||||
|
||||
---
|
||||
|
||||
@@ -16,57 +21,75 @@
|
||||
| 语言 | Python 3.11+ | |
|
||||
| Web | FastAPI 0.115+ | |
|
||||
| ASGI | uvicorn[standard] | |
|
||||
| ORM | SQLAlchemy 2.0 | Mapped/DeclarativeBase 新风格 |
|
||||
| ORM | SQLAlchemy 2.0 | Mapped / DeclarativeBase 新风格 |
|
||||
| 迁移 | Alembic | render_as_batch 兼容 SQLite |
|
||||
| DB | SQLite | 后续切 PostgreSQL 只改 `DATABASE_URL` |
|
||||
| DB | SQLite 起步 | 切 PostgreSQL 只改 `DATABASE_URL` |
|
||||
| Auth | JWT (PyJWT) | access(2h) + refresh(30d) |
|
||||
| 极光一键登录 | httpx + cryptography | RSA 解密,逻辑参考试验后台 |
|
||||
| HTTP 客户端 | httpx | 调极光 / 微信 / 美团 / 穿山甲 |
|
||||
| 加解密 | cryptography | 极光 RSA 解密、微信 V3 签名 / 敏感字段加密 |
|
||||
| 微信支付 | 商家转账到零钱 V3 | 提现;懒加载商户证书 |
|
||||
| 配置 | pydantic-settings | `.env` 加载 |
|
||||
| 测试 | pytest + TestClient | |
|
||||
|
||||
---
|
||||
|
||||
## 架构分层
|
||||
|
||||
一个请求自上而下:**api(薄,收发) → integrations(外部 SDK) / repositories(数据) → models / db**。
|
||||
|
||||
- `api/v1/` 只做"解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码映射",不放重逻辑。
|
||||
- **SDK 集成的重逻辑(签名 / 验签 / 加解密 / 外部 HTTP)一律在 `integrations/`**,换方案 / 换供应商只动这一层。
|
||||
- 数据访问统一在 `repositories/`(早期叫 `crud/`,已并入)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
shaguabijia-app-server/
|
||||
├── app/
|
||||
│ ├── main.py FastAPI 入口 + /health + CORS
|
||||
│ ├── core/
|
||||
│ ├── main.py FastAPI 入口:注册全部 router + /health + CORS + lifespan
|
||||
│ ├── api/
|
||||
│ │ ├── deps.py 共享依赖:get_current_user(Bearer 校验)、get_db
|
||||
│ │ └── v1/ 接口层(薄)
|
||||
│ │ ├── auth.py 认证 6 端点(极光 / 短信 send+login / refresh / me / logout)
|
||||
│ │ ├── wallet.py 钱包 / 提现 11 端点(余额 / 流水 / 兑换 / 绑微信 / 提现 / 查单)
|
||||
│ │ ├── signin.py 签到 2 端点
|
||||
│ │ ├── tasks.py 一次性任务 2 端点
|
||||
│ │ ├── savings.py 省钱 3 端点(汇总 / 战绩 / 明细)
|
||||
│ │ ├── ad.py 看广告发奖 3 端点(穿山甲回调 / 进度 / 联调发奖)
|
||||
│ │ ├── coupon.py 领券透传 /coupon/step(转发 pricebot)
|
||||
│ │ └── meituan.py 美团 CPS 3 端点(券列表 / feed / 换链)
|
||||
│ ├── integrations/ 外部服务 / SDK 客户端(重逻辑)
|
||||
│ │ ├── jiguang.py 极光 REST 验 token + RSA 解密(多 padding 试错)
|
||||
│ │ ├── sms.py 短信验证码(mock,进程内冷却表)
|
||||
│ │ ├── meituan.py 美团 CPS 网关签名 + query_coupon / get_referral_link
|
||||
│ │ ├── pangle.py 穿山甲激励视频发奖回调验签(SHA256)
|
||||
│ │ └── wxpay.py 微信支付 V3 商家转账(提现)+ code 换 openid
|
||||
│ ├── core/ 基础设施(无外部业务集成)
|
||||
│ │ ├── config.py Settings (pydantic-settings)
|
||||
│ │ ├── logging.py 统一日志配置
|
||||
│ │ ├── security.py JWT 签发 / 校验 / token 对
|
||||
│ │ ├── jiguang.py 极光 REST 调用 + RSA 解密
|
||||
│ │ └── sms.py 短信验证码 (mock 实现)
|
||||
│ ├── db/
|
||||
│ │ ├── base.py DeclarativeBase
|
||||
│ │ └── session.py engine + SessionLocal + get_db
|
||||
│ ├── models/
|
||||
│ │ └── user.py User 表
|
||||
│ ├── schemas/
|
||||
│ │ └── auth.py 登录/Token 相关 Pydantic
|
||||
│ ├── crud/
|
||||
│ │ └── user.py upsert_user_for_login 等
|
||||
│ └── api/
|
||||
│ ├── deps.py get_current_user (Bearer 校验)
|
||||
│ └── v1/
|
||||
│ └── auth.py /api/v1/auth/* 路由
|
||||
│ │ ├── ratelimit.py 同 IP 滑动窗口限流依赖
|
||||
│ │ ├── rewards.py 发奖 / 兑换 / 提现额度等业务常量与换算
|
||||
│ │ └── logging.py 统一日志配置
|
||||
│ ├── repositories/ 数据访问 + 事务(早期叫 crud,已并入)
|
||||
│ │ ├── user.py upsert_user_for_login 等
|
||||
│ │ ├── wallet.py 账户 / 流水 / 兑换 / 提现单(调 integrations/wxpay)
|
||||
│ │ ├── signin.py 签到记录 / 连续天数 / 档位
|
||||
│ │ ├── task.py 一次性任务领取
|
||||
│ │ ├── savings.py 省钱汇总 / 战绩 / 明细
|
||||
│ │ └── ad_reward.py 看广告发奖(按 trans_id 幂等 + 每日上限)
|
||||
│ ├── models/ ORM 表结构(user / wallet / signin / task / savings / ad_reward)
|
||||
│ ├── schemas/ Pydantic 收发契约(auth / meituan / welfare / ad)
|
||||
│ └── db/ base.py(DeclarativeBase) + session.py(engine / get_db)
|
||||
│
|
||||
├── alembic/ 迁移脚本
|
||||
│ ├── env.py
|
||||
│ ├── script.py.mako
|
||||
│ └── versions/ (空,第一次跑 alembic revision 生成)
|
||||
├── alembic/ 数据库迁移(versions/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
|
||||
├── alembic.ini
|
||||
│
|
||||
├── deploy/
|
||||
│ ├── shaguabijia-app-server.service systemd unit
|
||||
│ └── nginx/
|
||||
│ └── app-api.shaguabijia.com.conf
|
||||
│
|
||||
├── secrets/ 不入 git。放 jverify_rsa_private.pem
|
||||
├── data/ 不入 git。SQLite 文件默认在这里
|
||||
├── tests/ 冒烟 + auth 流程
|
||||
│
|
||||
├── deploy/ systemd(.service) + nginx(.conf)
|
||||
├── secrets/ 不入 git:极光 RSA 私钥 / 微信支付证书
|
||||
├── data/ 不入 git:SQLite 文件默认在这里
|
||||
├── scripts/ 运维脚本(migrate 迁移 / 对账 / 重置签到·福利 / 模拟穿山甲回调)
|
||||
├── tests/ pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy)
|
||||
├── docs/ 文档库(api/ + integrations/ + 技术实现 / 迁移 / 待办)
|
||||
├── run.sh 本地启动(自动先跑迁移再起服务)
|
||||
├── pyproject.toml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
@@ -76,98 +99,63 @@ shaguabijia-app-server/
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 鉴权 |
|
||||
完整契约见 **[docs/api/README.md](docs/api/README.md)**(总览表 + 一接口一文件)。按组速览:
|
||||
|
||||
| 组 | 前缀 | 端点数 | 鉴权 |
|
||||
|---|---|---|---|
|
||||
| GET | `/health` | 健康检查 | 无 |
|
||||
| POST | `/api/v1/auth/jverify-login` | 极光一键登录(`loginToken` → 注册即登录 → 签发 token 对) | 无 |
|
||||
| POST | `/api/v1/auth/sms/send` | 发短信验证码(mock 阶段任意 6 位通过) | 无 |
|
||||
| POST | `/api/v1/auth/sms/login` | 手机号+验证码登录 | 无 |
|
||||
| POST | `/api/v1/auth/refresh` | 用 `refresh_token` 换新的 token 对 | 无 |
|
||||
| GET | `/api/v1/auth/me` | 获取当前登录用户 | `Bearer access_token` |
|
||||
| POST | `/api/v1/auth/logout` | 登出(占位,客户端清 token) | `Bearer access_token` |
|
||||
| 健康检查 | `/health` | 1 | 无 |
|
||||
| 认证 Auth | `/api/v1/auth` | 6 | 登录类无 / `me`·`logout` Bearer |
|
||||
| 钱包·提现 Wallet | `/api/v1/wallet` | 11 | Bearer(`exchange-info` 无) |
|
||||
| 签到 Signin | `/api/v1/signin` | 2 | Bearer |
|
||||
| 任务 Tasks | `/api/v1/tasks` | 2 | Bearer |
|
||||
| 省钱 Savings | `/api/v1/savings` | 3 | Bearer |
|
||||
| 看广告发奖 Ad | `/api/v1/ad` | 3 | 回调验签 / 其余 Bearer |
|
||||
| 领券 Coupon | `/api/v1/coupon` | 1 | Bearer |
|
||||
| 美团 CPS Meituan | `/api/v1/meituan` | 3 | 无 |
|
||||
|
||||
登录类接口的响应统一格式:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJxxx...",
|
||||
"refresh_token": "eyJxxx...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 7200,
|
||||
"refresh_expires_in": 2592000,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"phone": "13800138000",
|
||||
"nickname": null,
|
||||
"avatar_url": null,
|
||||
"register_channel": "jverify",
|
||||
"status": "active",
|
||||
"created_at": "2026-05-23T04:30:00Z",
|
||||
"last_login_at": "2026-05-23T04:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
文档启动后看 `http://localhost:8770/docs` (Swagger UI)。
|
||||
登录类接口响应统一是 `TokenWithUser`(access/refresh token 对 + user);金额字段一律以**分**为单位(`*_cents`)。`APP_ENV` 非 prod 时开 `http://localhost:8770/docs`(Swagger UI)。
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 1. 装依赖
|
||||
|
||||
```bash
|
||||
cd shaguabijia-app-server
|
||||
python3.11 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### 2. 准备配置
|
||||
|
||||
### 2. 配置
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env,生产部署务必改 JWT_SECRET_KEY
|
||||
# 至少改 JWT_SECRET_KEY;测美团填 MT_CPS_*;测提现填 WXPAY_*/WECHAT_*;测看广告填 PANGLE_*
|
||||
```
|
||||
测极光一键登录把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`(没放也不影响其他接口,只是 `jverify-login` 返 502)。
|
||||
|
||||
如果要测极光一键登录,把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`。
|
||||
没放也不影响其他接口(`sms`/`/health` 等),只是 `jverify-login` 会 502。
|
||||
|
||||
### 3. 建表(首次)
|
||||
|
||||
### 3. 建表 / 升级数据库
|
||||
```bash
|
||||
# 生成首版 migration(已有 User model,会自动生成 create_table)
|
||||
alembic revision --autogenerate -m "init user table"
|
||||
# 应用 migration
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
后续改 model 后:
|
||||
```bash
|
||||
alembic revision --autogenerate -m "add nickname index"
|
||||
alembic upgrade head
|
||||
mkdir -p data # SQLite 库目录
|
||||
alembic upgrade head # 按迁移链建到最新(幂等)
|
||||
```
|
||||
> 详见 [docs/数据库迁移.md](docs/数据库迁移.md)。只想迁移不启服务:`bash scripts/migrate.sh`。
|
||||
|
||||
### 4. 跑起来
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload --port 8770
|
||||
./run.sh # 监听 0.0.0.0:8770,自动先跑迁移;改代码自动重启
|
||||
# 或手动: uvicorn app.main:app --reload --port 8770
|
||||
```
|
||||
|
||||
冒烟:
|
||||
```bash
|
||||
curl http://localhost:8770/health
|
||||
# {"status":"ok"}
|
||||
|
||||
# mock 走通登录闭环
|
||||
curl -X POST http://localhost:8770/api/v1/auth/sms/send -H 'Content-Type: application/json' -d '{"phone":"13800138000"}'
|
||||
|
||||
curl http://localhost:8770/health # {"status":"ok"}
|
||||
# mock 登录闭环
|
||||
curl -X POST http://localhost:8770/api/v1/auth/sms/send -H 'Content-Type: application/json' -d '{"phone":"13800138000"}'
|
||||
curl -X POST http://localhost:8770/api/v1/auth/sms/login -H 'Content-Type: application/json' -d '{"phone":"13800138000","code":"123456"}'
|
||||
# 拿到 access_token 后:
|
||||
curl http://localhost:8770/api/v1/auth/me -H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
### 5. 测试
|
||||
|
||||
```bash
|
||||
pytest -q
|
||||
```
|
||||
@@ -176,17 +164,13 @@ pytest -q
|
||||
|
||||
## 部署(生产)
|
||||
|
||||
参考 `deploy/`,部署到 `app-api.shaguabijia.com`:
|
||||
|
||||
部署到 `app-api.shaguabijia.com`(参考 `deploy/`):
|
||||
```bash
|
||||
# 一次性
|
||||
ssh server "apt-get install -y python3.11-venv python3-pip"
|
||||
|
||||
# 后续更新
|
||||
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/jverify_rsa_private.pem' \
|
||||
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/*' \
|
||||
./ server:/opt/shaguabijia-app-server/
|
||||
|
||||
# 私钥单独同步(只首次 / 私钥更换时)
|
||||
# 证书 / 私钥单独同步(首次 / 更换时):极光私钥 + 微信支付商户私钥·平台公钥
|
||||
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
|
||||
|
||||
ssh server "
|
||||
@@ -196,8 +180,7 @@ ssh server "
|
||||
systemctl restart shaguabijia-app-server
|
||||
"
|
||||
```
|
||||
|
||||
详见 `deploy/shaguabijia-app-server.service` 和 `deploy/nginx/app-api.shaguabijia.com.conf`。
|
||||
详见 `deploy/`。**看广告发奖上线**前对照 [docs/看广告赚金币上线清单.md](docs/看广告赚金币上线清单.md)(GroMore 回调配置、清理调试脚手架、端到端验收)。
|
||||
|
||||
---
|
||||
|
||||
@@ -206,11 +189,9 @@ ssh server "
|
||||
| 维度 | 试验后台 (shaguabijia-server) | 本项目 (shaguabijia-app-server) |
|
||||
|---|---|---|
|
||||
| 数据库 | stdlib sqlite3,无 ORM | SQLAlchemy 2.0 + Alembic |
|
||||
| 业务 | mock parse(无账号体系) | 完整账号体系 + JWT |
|
||||
| 登录态 | 无,客户端发 phone 当 ID | JWT access+refresh |
|
||||
| 业务 | mock parse(无账号体系) | 账号 + 钱包 / 提现 / 签到 / 看广告 / CPS |
|
||||
| 登录态 | 无,客户端发 phone 当 ID | JWT access + refresh |
|
||||
| 配置 | 硬编码 | pydantic-settings + `.env` |
|
||||
| 短信登录 | 无 | 有(mock 实现,留扩展点) |
|
||||
| 域名 | api.shaguabijia.com (试验) | app-api.shaguabijia.com (正式) |
|
||||
| 域名 | api.shaguabijia.com(试验) | app-api.shaguabijia.com(正式) |
|
||||
|
||||
极光一键登录的具体实现(REST 调用、RSA padding 试错顺序)直接参考自试验后台,
|
||||
逻辑等价。
|
||||
极光一键登录的实现(REST 调用、RSA padding 试错顺序)参考自试验后台,逻辑等价。
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""ad_reward_record table (看激励视频发奖记录)
|
||||
|
||||
Revision ID: c8d9e0f1a2b3
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-05-26 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c8d9e0f1a2b3'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'ad_reward_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('trans_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('coin', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('reward_date', sa.String(length=10), nullable=False),
|
||||
sa.Column('reward_name', sa.String(length=64), nullable=True),
|
||||
sa.Column('raw', sa.String(length=1024), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('ad_reward_record', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_ad_reward_record_trans_id'), ['trans_id'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_ad_reward_record_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_reward_record_reward_date'), ['reward_date'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_reward_record_created_at'), ['created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('ad_reward_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_ad_reward_record_created_at'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_reward_record_reward_date'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_reward_record_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_reward_record_trans_id'))
|
||||
|
||||
op.drop_table('ad_reward_record')
|
||||
@@ -0,0 +1,49 @@
|
||||
"""cash_transaction table
|
||||
|
||||
Revision ID: 3d9cb19df76f
|
||||
Revises: 357520ea3015
|
||||
Create Date: 2026-05-24 16:21:22.950992
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3d9cb19df76f'
|
||||
down_revision: Union[str, Sequence[str], None] = '357520ea3015'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('cash_transaction',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('balance_after_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('biz_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('ref_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('remark', sa.String(length=128), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('cash_transaction', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_cash_transaction_created_at'), ['created_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_cash_transaction_user_id'), ['user_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('cash_transaction', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_cash_transaction_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_cash_transaction_created_at'))
|
||||
|
||||
op.drop_table('cash_transaction')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,49 @@
|
||||
"""savings_record table
|
||||
|
||||
Revision ID: bf2a67c0e2ab
|
||||
Revises: 3d9cb19df76f
|
||||
Create Date: 2026-05-24 16:24:34.622394
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'bf2a67c0e2ab'
|
||||
down_revision: Union[str, Sequence[str], None] = '3d9cb19df76f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('savings_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('platform', sa.String(length=32), nullable=True),
|
||||
sa.Column('title', sa.String(length=128), nullable=True),
|
||||
sa.Column('source', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('savings_record', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_savings_record_created_at'), ['created_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_savings_record_user_id'), ['user_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('savings_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_savings_record_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_savings_record_created_at'))
|
||||
|
||||
op.drop_table('savings_record')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,33 @@
|
||||
"""savings_record.shop_name + dishes(订单明细卡:店铺名 + 菜品列表)
|
||||
|
||||
Revision ID: b1c2d3e4f5a6
|
||||
Revises: a7c8d9e0f1b2
|
||||
Create Date: 2026-05-26 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b1c2d3e4f5a6'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a7c8d9e0f1b2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('savings_record', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('shop_name', sa.String(length=128), nullable=True))
|
||||
# dishes 存 JSON(SQLite 落 TEXT);旧行给 '[]' 默认,满足 NOT NULL
|
||||
batch_op.add_column(
|
||||
sa.Column('dishes', sa.JSON(), nullable=False, server_default=sa.text("'[]'"))
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('savings_record', schema=None) as batch_op:
|
||||
batch_op.drop_column('dishes')
|
||||
batch_op.drop_column('shop_name')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""unique index on user.wechat_openid (一个微信只绑一个账号)
|
||||
|
||||
Revision ID: a7c8d9e0f1b2
|
||||
Revises: f5b2d3c4e6a7
|
||||
Create Date: 2026-05-25 19:10:00.000000
|
||||
|
||||
注意:升级前需保证 wechat_openid 无重复值(NULL 不算重复)。
|
||||
本仓库 data/app.db 升级前已手动清掉重复 openid(保留最近绑定的账号)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a7c8d9e0f1b2'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f5b2d3c4e6a7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# nullable 列上的唯一索引:SQLite / Postgres 都允许多个 NULL,只约束非空值唯一
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_user_wechat_openid'), ['wechat_openid'], unique=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_user_wechat_openid'))
|
||||
@@ -0,0 +1,30 @@
|
||||
"""user.wechat_nickname + wechat_avatar_url
|
||||
|
||||
Revision ID: f5b2d3c4e6a7
|
||||
Revises: e4a1c2b3d4f5
|
||||
Create Date: 2026-05-25 18:40:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f5b2d3c4e6a7'
|
||||
down_revision: Union[str, Sequence[str], None] = 'e4a1c2b3d4f5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('wechat_nickname', sa.String(length=64), nullable=True))
|
||||
batch_op.add_column(sa.Column('wechat_avatar_url', sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_column('wechat_avatar_url')
|
||||
batch_op.drop_column('wechat_nickname')
|
||||
@@ -0,0 +1,96 @@
|
||||
"""welfare tables: coin account, coin txn, signin, user task
|
||||
|
||||
Revision ID: 357520ea3015
|
||||
Revises: 9c559acc164a
|
||||
Create Date: 2026-05-24 16:16:20.677711
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '357520ea3015'
|
||||
down_revision: Union[str, Sequence[str], None] = '9c559acc164a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('coin_account',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('coin_balance', sa.Integer(), nullable=False),
|
||||
sa.Column('cash_balance_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('total_coin_earned', sa.Integer(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id')
|
||||
)
|
||||
op.create_table('coin_transaction',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('amount', sa.Integer(), nullable=False),
|
||||
sa.Column('balance_after', sa.Integer(), nullable=False),
|
||||
sa.Column('biz_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('ref_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('remark', sa.String(length=128), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('coin_transaction', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_coin_transaction_created_at'), ['created_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_coin_transaction_user_id'), ['user_id'], unique=False)
|
||||
|
||||
op.create_table('signin_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('signin_date', sa.Date(), nullable=False),
|
||||
sa.Column('cycle_day', sa.Integer(), nullable=False),
|
||||
sa.Column('streak', sa.Integer(), nullable=False),
|
||||
sa.Column('coin_awarded', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_user_date')
|
||||
)
|
||||
with op.batch_alter_table('signin_record', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_signin_record_user_id'), ['user_id'], unique=False)
|
||||
|
||||
op.create_table('user_task',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('task_key', sa.String(length=48), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('coin_awarded', sa.Integer(), nullable=False),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'task_key', name='uq_task_user_key')
|
||||
)
|
||||
with op.batch_alter_table('user_task', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_user_task_user_id'), ['user_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user_task', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_user_task_user_id'))
|
||||
|
||||
op.drop_table('user_task')
|
||||
with op.batch_alter_table('signin_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_signin_record_user_id'))
|
||||
|
||||
op.drop_table('signin_record')
|
||||
with op.batch_alter_table('coin_transaction', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_coin_transaction_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_coin_transaction_created_at'))
|
||||
|
||||
op.drop_table('coin_transaction')
|
||||
op.drop_table('coin_account')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,56 @@
|
||||
"""withdraw_order table + user.wechat_openid
|
||||
|
||||
Revision ID: e4a1c2b3d4f5
|
||||
Revises: bf2a67c0e2ab
|
||||
Create Date: 2026-05-25 16:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e4a1c2b3d4f5'
|
||||
down_revision: Union[str, Sequence[str], None] = 'bf2a67c0e2ab'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'withdraw_order',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('out_bill_no', sa.String(length=64), nullable=False),
|
||||
sa.Column('amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('wechat_state', sa.String(length=32), nullable=True),
|
||||
sa.Column('transfer_bill_no', sa.String(length=64), nullable=True),
|
||||
sa.Column('package_info', sa.String(length=512), nullable=True),
|
||||
sa.Column('fail_reason', sa.String(length=256), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_withdraw_order_created_at'), ['created_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_withdraw_order_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_withdraw_order_out_bill_no'), ['out_bill_no'], unique=True)
|
||||
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('wechat_openid', sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_column('wechat_openid')
|
||||
|
||||
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_withdraw_order_out_bill_no'))
|
||||
batch_op.drop_index(batch_op.f('ix_withdraw_order_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_withdraw_order_created_at'))
|
||||
|
||||
op.drop_table('withdraw_order')
|
||||
@@ -0,0 +1,131 @@
|
||||
"""看激励视频发奖 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/ad`:
|
||||
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
|
||||
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
|
||||
|
||||
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
|
||||
看完后刷新余额,不参与发奖,被破解也刷不到钱。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.integrations import pangle
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.schemas.ad import AdRewardStatusOut, PangleCallbackOut, TestGrantOut
|
||||
|
||||
logger = logging.getLogger("shagua.ad")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ad", tags=["ad"])
|
||||
|
||||
# GroMore 回调 is_verify=false 时透传给客户端 SDK 的错误码(自定义,仅用于排查/客户端提示)
|
||||
REASON_OK = 0
|
||||
REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
|
||||
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pangle-callback",
|
||||
response_model=PangleCallbackOut,
|
||||
summary="穿山甲激励视频发奖回调(S2S,验签)",
|
||||
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
|
||||
)
|
||||
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / reward_amount / sign 等 query 参数。
|
||||
|
||||
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/reward_amount → 幂等发金币。
|
||||
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
|
||||
granted / capped → is_verify=true + reason=0。
|
||||
"""
|
||||
if not settings.pangle_callback_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
|
||||
params = dict(request.query_params)
|
||||
|
||||
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
|
||||
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
|
||||
|
||||
trans_id = params.get("trans_id") or ""
|
||||
raw_user_id = params.get("user_id") or ""
|
||||
if not trans_id or not raw_user_id.isdigit():
|
||||
# 验签过了但参数缺/坏:不发奖(is_verify=false),带错误码
|
||||
logger.warning("pangle callback bad params: %s", params)
|
||||
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
|
||||
|
||||
user_id = int(raw_user_id)
|
||||
coin = rewards.resolve_ad_reward_coin(params.get("reward_amount"))
|
||||
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user_id, trans_id, coin=coin,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
)
|
||||
except crud_ad.UnknownUserError:
|
||||
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
|
||||
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
|
||||
|
||||
logger.info(
|
||||
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin
|
||||
)
|
||||
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
|
||||
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
||||
|
||||
|
||||
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
||||
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
used, limit, coin_per = crud_ad.today_status(db, user.id)
|
||||
return AdRewardStatusOut(
|
||||
used_today=used,
|
||||
daily_limit=limit,
|
||||
remaining=max(0, limit - used),
|
||||
coin_per_ad=coin_per,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/test-grant",
|
||||
response_model=TestGrantOut,
|
||||
summary="[仅本地联调]模拟穿山甲回调发奖",
|
||||
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
|
||||
)
|
||||
def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
|
||||
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
|
||||
|
||||
必须 settings.AD_REWARD_TEST_GRANT_ENABLED=true 才开放(默认 False),否则 404 当不存在。
|
||||
它让已登录客户端能自助发奖 = 绕过反作弊,**生产必须关闭**。
|
||||
"""
|
||||
if not settings.AD_REWARD_TEST_GRANT_ENABLED:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
|
||||
|
||||
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
|
||||
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user.id, trans_id, reward_name="测试发奖", raw="client debug test-grant"
|
||||
)
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
|
||||
used, limit, coin_per = crud_ad.today_status(db, user.id)
|
||||
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
|
||||
return TestGrantOut(
|
||||
granted=(rec.status == "granted"),
|
||||
status=rec.status,
|
||||
coin=rec.coin,
|
||||
used_today=used,
|
||||
daily_limit=limit,
|
||||
remaining=max(0, limit - used),
|
||||
coin_per_ad=coin_per,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""省钱 endpoint(profile 的「累计帮你省了」「省钱战绩」「省钱明细」)。
|
||||
|
||||
路由前缀 `/api/v1/savings`:
|
||||
GET /summary 累计省下 + 订单数 + 平均每单省
|
||||
GET /battle 本周已省 + 超过百分比 + 连续省钱天数
|
||||
GET /records 省钱明细(游标分页)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import savings as crud_savings
|
||||
from app.schemas.welfare import (
|
||||
SavingsBattleOut,
|
||||
SavingsRecordOut,
|
||||
SavingsRecordPage,
|
||||
SavingsSummaryOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.savings")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/savings", tags=["savings"])
|
||||
|
||||
|
||||
@router.get("/summary", response_model=SavingsSummaryOut, summary="累计帮你省了")
|
||||
def summary(user: CurrentUser, db: DbSession) -> SavingsSummaryOut:
|
||||
s = crud_savings.get_summary(db, user.id)
|
||||
return SavingsSummaryOut(
|
||||
total_saved_cents=s.total_saved_cents,
|
||||
order_count=s.order_count,
|
||||
avg_saved_cents=s.avg_saved_cents,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/battle", response_model=SavingsBattleOut, summary="省钱战绩")
|
||||
def battle(user: CurrentUser, db: DbSession) -> SavingsBattleOut:
|
||||
b = crud_savings.get_battle(db, user.id)
|
||||
return SavingsBattleOut(
|
||||
week_saved_cents=b.week_saved_cents,
|
||||
beat_percent=b.beat_percent,
|
||||
streak_days=b.streak_days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records", response_model=SavingsRecordPage, summary="省钱明细(游标分页)")
|
||||
def records(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
cursor: int | None = Query(None, description="上一页末条 id"),
|
||||
) -> SavingsRecordPage:
|
||||
items, next_cursor = crud_savings.list_records(db, user.id, limit=limit, cursor=cursor)
|
||||
return SavingsRecordPage(
|
||||
items=[SavingsRecordOut.model_validate(it) for it in items],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""签到 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/signin`:
|
||||
GET /status 今日签到状态 + 7 天档位
|
||||
POST / 执行今日签到
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.schemas.welfare import SigninResultOut, SigninStatusOut
|
||||
|
||||
logger = logging.getLogger("shagua.signin")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/signin", tags=["signin"])
|
||||
|
||||
|
||||
@router.get("/status", response_model=SigninStatusOut, summary="今日签到状态")
|
||||
def signin_status(user: CurrentUser, db: DbSession) -> SigninStatusOut:
|
||||
st = crud_signin.get_status(db, user.id)
|
||||
return SigninStatusOut.model_validate(st, from_attributes=True)
|
||||
|
||||
|
||||
@router.post("", response_model=SigninResultOut, summary="执行今日签到")
|
||||
def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
|
||||
try:
|
||||
record, balance = crud_signin.do_signin(db, user.id)
|
||||
except crud_signin.AlreadySignedError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already signed today") from e
|
||||
|
||||
logger.info(
|
||||
"signin ok user_id=%d day=%d streak=%d coin=%d",
|
||||
user.id, record.cycle_day, record.streak, record.coin_awarded,
|
||||
)
|
||||
return SigninResultOut(
|
||||
coin_awarded=record.coin_awarded,
|
||||
cycle_day=record.cycle_day,
|
||||
streak=record.streak,
|
||||
coin_balance=balance,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""一次性任务 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/tasks`:
|
||||
GET / 任务及领取状态(如"打开消息提醒")
|
||||
POST /{task_key}/claim 领取任务奖励
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import task as crud_task
|
||||
from app.schemas.welfare import TaskClaimResultOut, TaskListOut, TaskOut
|
||||
|
||||
logger = logging.getLogger("shagua.tasks")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("", response_model=TaskListOut, summary="任务及领取状态")
|
||||
def list_tasks(user: CurrentUser, db: DbSession) -> TaskListOut:
|
||||
states = crud_task.list_tasks(db, user.id)
|
||||
return TaskListOut(
|
||||
items=[
|
||||
TaskOut(task_key=s.task_key, coin=s.coin, claimed=s.claimed)
|
||||
for s in states
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{task_key}/claim",
|
||||
response_model=TaskClaimResultOut,
|
||||
summary="领取任务奖励",
|
||||
)
|
||||
def claim_task(task_key: str, user: CurrentUser, db: DbSession) -> TaskClaimResultOut:
|
||||
try:
|
||||
coin, balance = crud_task.claim_task(db, user.id, task_key)
|
||||
except crud_task.UnknownTaskError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown task") from e
|
||||
except crud_task.AlreadyClaimedError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="task already claimed") from e
|
||||
|
||||
logger.info("task claimed user_id=%d key=%s coin=%d", user.id, task_key, coin)
|
||||
return TaskClaimResultOut(task_key=task_key, coin_awarded=coin, coin_balance=balance)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""钱包 / 我的资产 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/wallet`:
|
||||
GET /account 金币 + 现金余额(我的资产卡)
|
||||
GET /coin-transactions 金币流水(游标分页)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.config import settings
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.core.rewards import (
|
||||
COIN_PER_CENT,
|
||||
COIN_PER_YUAN,
|
||||
MIN_EXCHANGE_COIN,
|
||||
WITHDRAW_MAX_CENTS,
|
||||
WITHDRAW_MIN_CENTS,
|
||||
)
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.models.user import User
|
||||
from app.schemas.welfare import (
|
||||
BindWechatRequest,
|
||||
BindWechatResultOut,
|
||||
CashTransactionOut,
|
||||
CashTransactionPage,
|
||||
CoinAccountOut,
|
||||
CoinTransactionOut,
|
||||
CoinTransactionPage,
|
||||
ExchangeInfoOut,
|
||||
ExchangeRequest,
|
||||
ExchangeResultOut,
|
||||
UnbindWechatResultOut,
|
||||
WithdrawInfoOut,
|
||||
WithdrawOrderOut,
|
||||
WithdrawOrderPage,
|
||||
WithdrawRequest,
|
||||
WithdrawResultOut,
|
||||
WithdrawStatusOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.wallet")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"])
|
||||
|
||||
|
||||
@router.get("/account", response_model=CoinAccountOut, summary="金币+现金余额")
|
||||
def get_account(user: CurrentUser, db: DbSession) -> CoinAccountOut:
|
||||
acc = crud_wallet.get_or_create_account(db, user.id)
|
||||
return CoinAccountOut.model_validate(acc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/coin-transactions",
|
||||
response_model=CoinTransactionPage,
|
||||
summary="金币流水(游标分页)",
|
||||
)
|
||||
def coin_transactions(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
cursor: int | None = Query(None, description="上一页末条 id"),
|
||||
) -> CoinTransactionPage:
|
||||
items, next_cursor = crud_wallet.list_coin_transactions(
|
||||
db, user.id, limit=limit, cursor=cursor
|
||||
)
|
||||
return CoinTransactionPage(
|
||||
items=[CoinTransactionOut.model_validate(it) for it in items],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cash-transactions", response_model=CashTransactionPage, summary="现金流水(游标分页)")
|
||||
def cash_transactions(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
cursor: int | None = Query(None, description="上一页末条 id"),
|
||||
) -> CashTransactionPage:
|
||||
items, next_cursor = crud_wallet.list_cash_transactions(
|
||||
db, user.id, limit=limit, cursor=cursor
|
||||
)
|
||||
return CashTransactionPage(
|
||||
items=[CashTransactionOut.model_validate(it) for it in items],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/exchange-info", response_model=ExchangeInfoOut, summary="金币兑现金汇率/规则")
|
||||
def exchange_info() -> ExchangeInfoOut:
|
||||
return ExchangeInfoOut(
|
||||
coin_per_yuan=COIN_PER_YUAN,
|
||||
min_coin=MIN_EXCHANGE_COIN,
|
||||
step_coin=COIN_PER_CENT,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/exchange", response_model=ExchangeResultOut, summary="金币兑现金")
|
||||
def exchange(req: ExchangeRequest, user: CurrentUser, db: DbSession) -> ExchangeResultOut:
|
||||
try:
|
||||
acc, cents = crud_wallet.exchange_coins_to_cash(db, user.id, req.coin_amount)
|
||||
except crud_wallet.InvalidExchangeAmountError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"coin_amount must be >= {MIN_EXCHANGE_COIN} and a multiple of {COIN_PER_CENT}",
|
||||
) from e
|
||||
except crud_wallet.InsufficientCoinError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient coin balance") from e
|
||||
|
||||
logger.info("exchange ok user_id=%d coin=%d cents=%d", user.id, req.coin_amount, cents)
|
||||
return ExchangeResultOut(
|
||||
coin_amount=req.coin_amount,
|
||||
cash_added_cents=cents,
|
||||
coin_balance=acc.coin_balance,
|
||||
cash_balance_cents=acc.cash_balance_cents,
|
||||
)
|
||||
|
||||
|
||||
# ===== 提现(现金 → 微信零钱) =====
|
||||
|
||||
|
||||
@router.post(
|
||||
"/bind-wechat",
|
||||
response_model=BindWechatResultOut,
|
||||
summary="微信授权 code 换 openid 并绑定",
|
||||
dependencies=[Depends(rate_limit(10, 60, "bind-wechat"))], # #6 同 IP 每分钟≤10 次
|
||||
)
|
||||
def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> BindWechatResultOut:
|
||||
if not settings.wxpay_configured:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
|
||||
try:
|
||||
info = crud_wallet.bind_wechat_openid(db, user.id, req.code)
|
||||
except crud_wallet.WechatAlreadyBoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="该微信已绑定其他账号"
|
||||
) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
logger.info(
|
||||
"bind wechat ok user_id=%d openid=%s*** nickname=%r avatar=%s",
|
||||
user.id, info["openid"][:6], info["nickname"], bool(info["avatar_url"]),
|
||||
)
|
||||
return BindWechatResultOut(
|
||||
bound=True, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)")
|
||||
def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
|
||||
crud_wallet.unbind_wechat_openid(db, user.id)
|
||||
logger.info("unbind wechat ok user_id=%d", user.id)
|
||||
return UnbindWechatResultOut(bound=False)
|
||||
|
||||
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态")
|
||||
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
u = db.get(User, user.id)
|
||||
return WithdrawInfoOut(
|
||||
min_cents=WITHDRAW_MIN_CENTS,
|
||||
max_cents=WITHDRAW_MAX_CENTS,
|
||||
wechat_bound=bool(u and u.wechat_openid),
|
||||
wechat_nickname=u.wechat_nickname if u else None,
|
||||
wechat_avatar_url=u.wechat_avatar_url if u else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/withdraw",
|
||||
response_model=WithdrawResultOut,
|
||||
summary="发起提现到微信零钱",
|
||||
dependencies=[Depends(rate_limit(20, 60, "withdraw"))], # #6 同 IP 每分钟≤20 次
|
||||
)
|
||||
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
|
||||
if not settings.wxpay_configured:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
|
||||
try:
|
||||
order = crud_wallet.create_withdraw(
|
||||
db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no
|
||||
)
|
||||
except crud_wallet.InvalidWithdrawAmountError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"amount_cents must be within [{WITHDRAW_MIN_CENTS}, {WITHDRAW_MAX_CENTS}]",
|
||||
) from e
|
||||
except crud_wallet.WechatNotBoundError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
|
||||
except crud_wallet.InsufficientCashError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e
|
||||
except crud_wallet.WithdrawTransferError as e:
|
||||
# 转账调用失败,余额已退回
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"wechat transfer failed: {e}") from e
|
||||
|
||||
acc = crud_wallet.get_or_create_account(db, user.id)
|
||||
logger.info("withdraw user_id=%d cents=%d bill=%s state=%s", user.id, req.amount_cents, order.out_bill_no, order.wechat_state)
|
||||
return WithdrawResultOut(
|
||||
out_bill_no=order.out_bill_no,
|
||||
status=order.status,
|
||||
wechat_state=order.wechat_state,
|
||||
amount_cents=order.amount_cents,
|
||||
cash_balance_cents=acc.cash_balance_cents,
|
||||
package_info=order.package_info,
|
||||
mch_id=settings.WXPAY_MCH_ID,
|
||||
app_id=settings.WECHAT_APP_ID,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/withdraw/status", response_model=WithdrawStatusOut, summary="查提现单状态(轮询)")
|
||||
def withdraw_status(
|
||||
user: CurrentUser, db: DbSession, out_bill_no: str = Query(..., description="商户提现单号")
|
||||
) -> WithdrawStatusOut:
|
||||
try:
|
||||
# 用户从确认页返回后查单:仍 WAIT_USER_CONFIRM 视为放弃 → 撤销+退款
|
||||
order = crud_wallet.refresh_withdraw_status(
|
||||
db, user.id, out_bill_no, cancel_if_unconfirmed=True
|
||||
)
|
||||
except crud_wallet.WithdrawOrderNotFound as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="withdraw order not found") from e
|
||||
return WithdrawStatusOut(
|
||||
out_bill_no=order.out_bill_no,
|
||||
status=order.status,
|
||||
wechat_state=order.wechat_state,
|
||||
amount_cents=order.amount_cents,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/withdraw-orders", response_model=WithdrawOrderPage, summary="提现单列表(游标分页)")
|
||||
def withdraw_orders(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
cursor: int | None = Query(None, description="上一页末条 id"),
|
||||
) -> WithdrawOrderPage:
|
||||
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor)
|
||||
return WithdrawOrderPage(
|
||||
items=[WithdrawOrderOut.model_validate(it) for it in items],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
|
||||
APP_NAME: str = "shaguabijia-app-server"
|
||||
APP_DEBUG: bool = True
|
||||
|
||||
# 接口限流开关(测试里关掉,避免内存计数跨用例累加误伤)
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
|
||||
# ===== 数据库 =====
|
||||
DATABASE_URL: str = "sqlite:///./data/app.db"
|
||||
|
||||
@@ -55,6 +58,44 @@ class Settings(BaseSettings):
|
||||
MT_CPS_HOST: str = "https://media.meituan.com"
|
||||
MT_CPS_TIMEOUT_SEC: int = 15
|
||||
MT_CPS_DEFAULT_SID: str = "sgbjia"
|
||||
# ===== 微信支付(商家转账到零钱 / 提现)=====
|
||||
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
|
||||
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
|
||||
WECHAT_APP_ID: str = "" # 开放平台移动应用 appid(wx 开头)
|
||||
WECHAT_APP_SECRET: str = "" # 开放平台 AppSecret,code 换 openid 用
|
||||
WXPAY_MCH_ID: str = "" # 微信支付商户号
|
||||
WXPAY_MCH_SERIAL_NO: str = "" # 商户 API 证书序列号
|
||||
WXPAY_MCH_PRIVATE_KEY_PATH: str = "./secrets/apiclient_key.pem" # 商户 API 私钥
|
||||
WXPAY_PUBLIC_KEY_ID: str = "" # 微信支付平台公钥 ID(PUB_KEY_ID_...)
|
||||
WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥
|
||||
WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销)
|
||||
WXPAY_REQUEST_TIMEOUT_SEC: int = 10
|
||||
|
||||
@property
|
||||
def wxpay_configured(self) -> bool:
|
||||
"""凭证是否齐全(缺则提现接口拒绝调用,而非启动时炸)。"""
|
||||
return bool(
|
||||
self.WECHAT_APP_ID
|
||||
and self.WXPAY_MCH_ID
|
||||
and self.WXPAY_MCH_SERIAL_NO
|
||||
and self.WXPAY_PUBLIC_KEY_ID
|
||||
)
|
||||
|
||||
# ===== 穿山甲激励视频(服务端发奖回调)=====
|
||||
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
|
||||
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
|
||||
PANGLE_CALLBACK_ENABLED: bool = False
|
||||
PANGLE_REWARD_SECRET: str = ""
|
||||
|
||||
# ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、
|
||||
# 穿山甲 S2S 回调打不到本地时,直接触发一次发奖,验证"看广告→金币到账"全链路。
|
||||
# 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。
|
||||
AD_REWARD_TEST_GRANT_ENABLED: bool = False
|
||||
|
||||
@property
|
||||
def pangle_callback_configured(self) -> bool:
|
||||
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
|
||||
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
|
||||
|
||||
# ===== Pricebot 上游 (领券业务透传目标) =====
|
||||
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""轻量内存限流(固定窗口计数)。
|
||||
|
||||
定位:防脚本刷接口的**安全网**,不是精确配额。局限——进程内存、单 worker 有效、重启清零、
|
||||
多 worker/多机不共享。上线放量后应换 Redis 后端 + 按用户维度。当前单 worker uvicorn 够用。
|
||||
|
||||
用法:把 `rate_limit(limit, window_sec, scope)` 作为路由依赖挂上,默认按客户端 IP 计数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# key -> (window_start_ts, count)
|
||||
_buckets: dict[str, tuple[float, int]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _hit(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""记一次访问。返回 True=放行,False=超限。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count = _buckets.get(key, (now, 0))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
count += 1
|
||||
_buckets[key] = (start, count)
|
||||
# 顺手清理过期 key,防内存无限涨(低频访问足够)
|
||||
if len(_buckets) > 10000:
|
||||
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
|
||||
_buckets.pop(k, None)
|
||||
return count <= limit
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
# nginx 反代后真实 IP 在 X-Forwarded-For 首段;直连用 request.client
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def rate_limit(limit: int, window_sec: float, scope: str):
|
||||
"""生成一个 FastAPI 依赖:同一 IP 在 window_sec 内对该 scope 超过 limit 次 → 429。"""
|
||||
|
||||
def _dep(request: Request) -> None:
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
key = f"{scope}:{_client_ip(request)}"
|
||||
if not _hit(key, limit, window_sec):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
return _dep
|
||||
@@ -0,0 +1,87 @@
|
||||
"""福利 / 激励相关的业务常量与换算规则。
|
||||
|
||||
集中放在这里,业务代码引用,避免数值散落各处。这些是**产品规则**(签到奖励、
|
||||
任务奖励、金币汇率)而非部署配置,所以不走环境变量;要调整直接改这里。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
|
||||
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
|
||||
CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def cn_today() -> date:
|
||||
"""当前北京时间的日期,签到去重 / 连续判断都以此为准。"""
|
||||
return datetime.now(CN_TZ).date()
|
||||
|
||||
|
||||
# ===== 签到:7 天循环,断签重置回第 1 天 =====
|
||||
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引。
|
||||
SIGNIN_REWARDS: tuple[int, ...] = (10, 20, 30, 50, 80, 120, 200)
|
||||
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
|
||||
|
||||
|
||||
def signin_reward(cycle_day: int) -> int:
|
||||
"""cycle_day 取值 1..SIGNIN_CYCLE_LEN。"""
|
||||
return SIGNIN_REWARDS[cycle_day - 1]
|
||||
|
||||
|
||||
# ===== 金币 → 现金 汇率 =====
|
||||
# 10000 金币 = 1 元 = 100 分。
|
||||
COIN_PER_YUAN: int = 10000
|
||||
CENTS_PER_YUAN: int = 100
|
||||
# 1 分对应多少金币 = 100。兑换金币数必须是它的整数倍,否则会出现不足 1 分的零头。
|
||||
COIN_PER_CENT: int = COIN_PER_YUAN // CENTS_PER_YUAN
|
||||
# 单次兑换最少 1 元(避免大量 1 分级碎兑)
|
||||
MIN_EXCHANGE_COIN: int = COIN_PER_YUAN
|
||||
|
||||
|
||||
def coins_to_cents(coin_amount: int) -> int:
|
||||
"""金币换算成现金(分)。调用前应已校验 coin_amount 是 COIN_PER_CENT 的整数倍。"""
|
||||
return coin_amount // COIN_PER_CENT
|
||||
|
||||
|
||||
# ===== 提现(现金 → 微信零钱)=====
|
||||
# 单次提现额度(分)。下限 0.1 元是微信商家转账的最低额,低于它(如 0.01 测试档)真转账会被拒。
|
||||
WITHDRAW_MIN_CENTS: int = 10
|
||||
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
|
||||
|
||||
# ===== 一次性任务(领一次,user_task 去重)=====
|
||||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||||
|
||||
# task_key -> 奖励金币
|
||||
TASK_REWARDS: dict[str, int] = {
|
||||
TASK_ENABLE_NOTIFICATION: 10000,
|
||||
}
|
||||
|
||||
|
||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||
# 看完一个激励视频发的金币(≈¥0.01,汇率 10000 金币=1 元)。
|
||||
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"。
|
||||
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
|
||||
# 代码位"奖励数量"配成与本值一致(如 100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
|
||||
AD_REWARD_COIN: int = 100
|
||||
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
||||
MAX_AD_REWARD_COIN: int = 1000
|
||||
# 每用户每日发奖次数上限,防刷 + 控成本。
|
||||
# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
|
||||
DAILY_AD_REWARD_LIMIT: int = 10
|
||||
|
||||
|
||||
def resolve_ad_reward_coin(reward_amount: str | int | None) -> int:
|
||||
"""把穿山甲回调的 reward_amount(后台代码位配的"奖励数量")解析成本次发放金币。
|
||||
|
||||
缺失 / 非数字 / ≤0 → 回退 AD_REWARD_COIN;超过 MAX_AD_REWARD_COIN → 夹紧。
|
||||
注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
|
||||
(要 SecurityKey),故能过验签的 reward_amount 即视为可信,这里只防后台配错。
|
||||
"""
|
||||
try:
|
||||
coin = int(reward_amount) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return AD_REWARD_COIN
|
||||
if coin <= 0:
|
||||
return AD_REWARD_COIN
|
||||
return min(coin, MAX_AD_REWARD_COIN)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""穿山甲激励视频"服务端发奖回调"的验签。
|
||||
|
||||
发奖闭环:激励视频播完 → 穿山甲服务器 GET 回调本服务(带 user_id / trans_id / sign 等)
|
||||
→ 本服务**验签**确认回调确实来自穿山甲(而非伪造请求) → 幂等发金币。
|
||||
客户端不参与发奖,被破解也刷不到钱。
|
||||
|
||||
验签方案(穿山甲 GroMore「广告位 → 服务端激励回调」官方规范,见 supportcenter/26240。
|
||||
我们客户端是 useMediation(true) 融合,回调走 GroMore 广告位层级,**不是**联盟代码位层级):
|
||||
**sign = SHA256("{m-key}:{trans_id}")** 的十六进制串。注意:
|
||||
- 是**普通 SHA256**,不是 HMAC,也不是 RSA;
|
||||
- **只对 `m-key:trans_id` 这一个字符串**签名,user_id / reward_amount 等其余参数**不参与**签名
|
||||
(它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 就连合法 sign
|
||||
都造不出,自然也无法注入假 user_id/reward_amount)。
|
||||
m-key(安全密钥)在穿山甲后台「GroMore 聚合管理 → 搜广告位ID → 编辑」处获取,配到
|
||||
`settings.PANGLE_REWARD_SECRET`。(联盟代码位层级用的是另一套 Security Key + isValid 响应,我们不走那条。)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
|
||||
def build_sign(trans_id: str, secret: str) -> str:
|
||||
"""按穿山甲规范计算 sign = SHA256("{SecurityKey}:{trans_id}") 的十六进制串。
|
||||
|
||||
secret 即后台的 Security Key。自验签测试 / 模拟回调脚本也用这一个函数,保证两端一致。
|
||||
"""
|
||||
msg = f"{secret}:{trans_id}".encode("utf-8")
|
||||
return hashlib.sha256(msg).hexdigest()
|
||||
|
||||
|
||||
def verify_callback_sign(params: dict[str, str], secret: str) -> bool:
|
||||
"""校验回调签名。`params` 是回调全部 query 参数(含 sign / trans_id)。
|
||||
|
||||
密钥为空或缺 trans_id 一律判失败。比较用定长比较防时序侧信道。
|
||||
"""
|
||||
if not secret:
|
||||
return False
|
||||
trans_id = params.get("trans_id") or ""
|
||||
if not trans_id:
|
||||
return False
|
||||
got = params.get("sign") or ""
|
||||
expect = build_sign(trans_id, secret)
|
||||
return hmac.compare_digest(got, expect)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""微信支付 V3 —— 商家转账到零钱(提现)。
|
||||
|
||||
移植自 elderhelper 的 wxpay.py,改成从 [app.core.config.settings] 读配置、密钥懒加载
|
||||
(文件缺失不在 import 时炸进程,只在真正调用转账时报错,与极光私钥同款策略)。
|
||||
|
||||
只实现提现需要的两个接口:
|
||||
- create_transfer:发起转账(商家转账到零钱)
|
||||
- query_transfer :按商户单号查转账单状态
|
||||
签名用商户 API 私钥(RSA-SHA256/PKCS1v15),敏感字段(实名)用微信支付平台公钥 OAEP 加密。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_API_HOST = "https://api.mch.weixin.qq.com"
|
||||
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
|
||||
|
||||
# 懒加载缓存
|
||||
_private_key: RSAPrivateKey | None = None
|
||||
_public_key: RSAPublicKey | None = None
|
||||
|
||||
|
||||
class WxPayNotConfiguredError(Exception):
|
||||
"""微信支付凭证 / 证书缺失,无法调用。"""
|
||||
|
||||
|
||||
def _load_private_key() -> RSAPrivateKey:
|
||||
global _private_key
|
||||
if _private_key is None:
|
||||
try:
|
||||
with open(settings.WXPAY_MCH_PRIVATE_KEY_PATH, "rb") as f:
|
||||
_private_key = serialization.load_pem_private_key(f.read(), password=None)
|
||||
except FileNotFoundError as e:
|
||||
raise WxPayNotConfiguredError(
|
||||
f"商户私钥不存在: {settings.WXPAY_MCH_PRIVATE_KEY_PATH}"
|
||||
) from e
|
||||
return _private_key
|
||||
|
||||
|
||||
def _load_public_key() -> RSAPublicKey:
|
||||
global _public_key
|
||||
if _public_key is None:
|
||||
try:
|
||||
with open(settings.WXPAY_PUBLIC_KEY_PATH, "rb") as f:
|
||||
_public_key = serialization.load_pem_public_key(f.read())
|
||||
except FileNotFoundError as e:
|
||||
raise WxPayNotConfiguredError(
|
||||
f"微信支付平台公钥不存在: {settings.WXPAY_PUBLIC_KEY_PATH}"
|
||||
) from e
|
||||
return _public_key
|
||||
|
||||
|
||||
def _sign(message: str) -> str:
|
||||
"""RSA-SHA256 签名,base64 输出。"""
|
||||
signature = _load_private_key().sign(
|
||||
message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()
|
||||
)
|
||||
return base64.b64encode(signature).decode("utf-8")
|
||||
|
||||
|
||||
def _build_authorization(method: str, url_path: str, body: str) -> str:
|
||||
"""构建 V3 Authorization 头(WECHATPAY2-SHA256-RSA2048)。"""
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
sign_str = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
|
||||
signature = _sign(sign_str)
|
||||
return (
|
||||
f'WECHATPAY2-SHA256-RSA2048 mchid="{settings.WXPAY_MCH_ID}",'
|
||||
f'nonce_str="{nonce}",'
|
||||
f'timestamp="{timestamp}",'
|
||||
f'serial_no="{settings.WXPAY_MCH_SERIAL_NO}",'
|
||||
f'signature="{signature}"'
|
||||
)
|
||||
|
||||
|
||||
def encrypt_sensitive(plain_text: str) -> str:
|
||||
"""用微信支付平台公钥 OAEP(SHA1) 加密敏感信息(如实名)。"""
|
||||
ciphertext = _load_public_key().encrypt(
|
||||
plain_text.encode("utf-8"),
|
||||
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
|
||||
)
|
||||
return base64.b64encode(ciphertext).decode("utf-8")
|
||||
|
||||
|
||||
def create_transfer(
|
||||
openid: str, amount_fen: int, out_bill_no: str, user_name: str | None = None
|
||||
) -> dict:
|
||||
"""发起商家转账到零钱。返回 {status_code, data}。
|
||||
|
||||
user_name(实名)在金额达微信阈值时必须传(加密)。这里沿用 elderhelper 阈值,
|
||||
实际阈值以微信侧要求为准。
|
||||
"""
|
||||
body = {
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"out_bill_no": out_bill_no,
|
||||
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
|
||||
"openid": openid,
|
||||
"transfer_amount": amount_fen,
|
||||
"transfer_remark": "金币提现",
|
||||
"transfer_scene_report_infos": [
|
||||
{"info_type": "活动名称", "info_content": "比价返现"},
|
||||
{"info_type": "奖励说明", "info_content": "现金提现到微信零钱"},
|
||||
],
|
||||
}
|
||||
if user_name and amount_fen >= 30:
|
||||
body["user_name"] = encrypt_sensitive(user_name)
|
||||
|
||||
body_str = json.dumps(body, ensure_ascii=False)
|
||||
headers = {
|
||||
"Authorization": _build_authorization("POST", _TRANSFER_PATH, body_str),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{_TRANSFER_PATH}",
|
||||
content=body_str,
|
||||
headers=headers,
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
return {"status_code": resp.status_code, "data": resp.json()}
|
||||
|
||||
|
||||
def query_transfer(out_bill_no: str) -> dict:
|
||||
"""按商户单号查转账单。返回 {status_code, data}。"""
|
||||
path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}"
|
||||
headers = {
|
||||
"Authorization": _build_authorization("GET", path, ""),
|
||||
"Accept": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.get(
|
||||
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
||||
)
|
||||
return {"status_code": resp.status_code, "data": resp.json()}
|
||||
|
||||
|
||||
def cancel_transfer(out_bill_no: str) -> dict:
|
||||
"""撤销转账单(仅 WAIT_USER_CONFIRM/ACCEPTED 可撤)。用户没在确认页确认就离开时调用。
|
||||
成功返回 {status_code:200, data:{state:'CANCELLING'|'CANCELLED', ...}}。"""
|
||||
path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}/cancel"
|
||||
headers = {
|
||||
"Authorization": _build_authorization("POST", path, ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
||||
)
|
||||
return {"status_code": resp.status_code, "data": resp.json()}
|
||||
|
||||
|
||||
def code_to_openid(code: str) -> str:
|
||||
"""用微信授权 code 换 openid(开放平台移动应用 sns/oauth2)。失败抛 ValueError。"""
|
||||
resp = httpx.get(
|
||||
"https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||
params={
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"secret": settings.WECHAT_APP_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
data = resp.json()
|
||||
if "openid" not in data:
|
||||
raise ValueError(f"微信授权失败: {data.get('errmsg', data)}")
|
||||
return data["openid"]
|
||||
|
||||
|
||||
def code_to_userinfo(code: str) -> dict:
|
||||
"""code 换 access_token+openid,再拉 sns/userinfo 取昵称头像。
|
||||
返回 {openid, nickname, avatar_url, raw}。失败抛 ValueError。
|
||||
注意:微信隐私新政下,部分 app 的 sns/userinfo 可能返回脱敏值(昵称"微信用户"/灰头像);
|
||||
nickname/avatar_url 可能为空,调用方需兜底。"""
|
||||
r1 = httpx.get(
|
||||
"https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||
params={
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"secret": settings.WECHAT_APP_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
d1 = r1.json()
|
||||
if "openid" not in d1 or "access_token" not in d1:
|
||||
raise ValueError(f"微信授权失败: {d1.get('errmsg', d1)}")
|
||||
openid = d1["openid"]
|
||||
|
||||
nickname = None
|
||||
avatar_url = None
|
||||
raw: dict = {}
|
||||
# userinfo 拉取失败不应让绑定失败(openid 已拿到),吞掉异常只是没昵称头像
|
||||
try:
|
||||
r2 = httpx.get(
|
||||
"https://api.weixin.qq.com/sns/userinfo",
|
||||
params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
raw = r2.json()
|
||||
nickname = raw.get("nickname") or None
|
||||
avatar_url = raw.get("headimgurl") or None
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw}
|
||||
+10
@@ -11,9 +11,14 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.ad import router as ad_router
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.v1.meituan import router as meituan_router
|
||||
from app.api.v1.savings import router as savings_router
|
||||
from app.api.v1.signin import router as signin_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
@@ -61,3 +66,8 @@ def health() -> dict[str, str]:
|
||||
app.include_router(auth_router)
|
||||
app.include_router(coupon_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)
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.savings import SavingsRecord # noqa: F401
|
||||
from app.models.signin import SigninRecord # noqa: F401
|
||||
from app.models.task import UserTask # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.wallet import ( # noqa: F401
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""看激励视频发奖记录(穿山甲 S2S 回调)。
|
||||
|
||||
每条 = 穿山甲一次发奖回调。`trans_id`(穿山甲交易号)唯一,做幂等键:穿山甲会重试回调,
|
||||
同号只发一次金币。`reward_date`(北京时间日期串)给"每日上限"计数用——按日期串等值查,
|
||||
不在 SQL 里做跨时区 date 比较(SQLite 上不可靠)。审计 / 对账时整张表可逐笔回溯。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AdRewardRecord(Base):
|
||||
__tablename__ = "ad_reward_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 穿山甲交易号,幂等键(同号回调不重复发奖)
|
||||
trans_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 实发金币(capped 时为 0)
|
||||
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# granted(已发) / capped(当日超限未发)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
|
||||
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
# 穿山甲上报的奖励名(参考,不作发奖依据)
|
||||
reward_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 回调原始参数,审计排查用
|
||||
raw: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} {self.status} coin={self.coin}>"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""省钱记录表。
|
||||
|
||||
是 profile「累计帮你省了」和「省钱战绩」的唯一数据源:每完成一次比价/下单,
|
||||
省了多少记一行。本期比价还没接后端,数据由 demo seeder 幂等灌入(source='demo'),
|
||||
聚合接口照常做真实 SUM/分组;等比价真接入后,改成上报写入、停掉 seeder 即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SavingsRecord(Base):
|
||||
__tablename__ = "savings_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 订单金额 / 省下金额,单位:分
|
||||
order_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
saved_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
|
||||
shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。
|
||||
dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
# 来源:demo(演示) / compare(真实比价上报)
|
||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<SavingsRecord id={self.id} user_id={self.user_id} saved={self.saved_amount_cents}>"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""签到记录表。
|
||||
|
||||
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
|
||||
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。
|
||||
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SigninRecord(Base):
|
||||
__tablename__ = "signin_record"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "signin_date", name="uq_signin_user_date"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 签到日期(北京时间的 date)
|
||||
signin_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
cycle_day: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
streak: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<SigninRecord user_id={self.user_id} date={self.signin_date} "
|
||||
f"day={self.cycle_day} streak={self.streak}>"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""一次性任务完成记录表。
|
||||
|
||||
像"打开消息提醒"这类只能领一次的任务,完成后写一行,(user_id, task_key) 唯一,
|
||||
防止重复领奖。可循环领取的任务(签到)不走这张表,各有专表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserTask(Base):
|
||||
__tablename__ = "user_task"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "task_key", name="uq_task_user_key"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 任务标识,见 app.core.rewards.TASK_REWARDS
|
||||
task_key: Mapped[str] = mapped_column(String(48), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="completed")
|
||||
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
completed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<UserTask user_id={self.user_id} key={self.task_key}>"
|
||||
@@ -33,6 +33,15 @@ class User(Base):
|
||||
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# 微信开放平台 openid(微信登录绑定后存,提现转账到零钱用)。同一 appid 下唯一;
|
||||
# unique 索引保证一个微信只能绑一个账号(nullable,允许多个 NULL=未绑定)。
|
||||
wechat_openid: Mapped[str | None] = mapped_column(
|
||||
String(64), unique=True, index=True, nullable=True
|
||||
)
|
||||
# 微信昵称/头像(绑定时从 sns/userinfo 拉,展示在提现绑定卡)。与上面通用 nickname/avatar_url 分开,不互相覆盖。
|
||||
wechat_nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
wechat_avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# 账号状态:active / disabled / deleted
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""钱包相关表:金币账户 + 金币流水。
|
||||
|
||||
设计要点:
|
||||
- 余额快照(coin_account)用于读取,流水账本(coin_transaction)用于对账,
|
||||
每次余额变动都写一笔流水并记 balance_after,出问题能逐笔回溯。
|
||||
- 金额一律存整数:金币是个数,现金存"分"(cash_balance_cents),不用浮点。
|
||||
- cash_balance_cents 本步先留列、恒为 0,金币兑现金(第 2 步)再启用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class CoinAccount(Base):
|
||||
__tablename__ = "coin_account"
|
||||
|
||||
# 一个用户一行,user_id 既是主键也是外键
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), primary_key=True
|
||||
)
|
||||
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
|
||||
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CoinAccount user_id={self.user_id} coin={self.coin_balance}>"
|
||||
|
||||
|
||||
class CoinTransaction(Base):
|
||||
__tablename__ = "coin_transaction"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 正数=入账(赚),负数=出账(花/兑换)
|
||||
amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 本笔变动后的金币余额,对账用
|
||||
balance_after: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 业务类型:signin / task_<key> / exchange_out / ...
|
||||
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 关联业务 id(签到日期、任务 key 等),可空
|
||||
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CoinTransaction id={self.id} user_id={self.user_id} amount={self.amount}>"
|
||||
|
||||
|
||||
class WithdrawOrder(Base):
|
||||
"""提现单(现金 → 微信零钱)。状态机:pending → success / failed。
|
||||
|
||||
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金
|
||||
并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM /
|
||||
SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态。
|
||||
"""
|
||||
|
||||
__tablename__ = "withdraw_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
|
||||
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 归一化状态:pending / success / failed
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
# 微信侧原始状态
|
||||
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 微信转账单号
|
||||
transfer_bill_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 待用户确认时返回给 App 拉起确认页的 package_info
|
||||
package_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<WithdrawOrder id={self.id} user_id={self.user_id} cents={self.amount_cents} {self.status}>"
|
||||
|
||||
|
||||
class CashTransaction(Base):
|
||||
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
|
||||
|
||||
__tablename__ = "cash_transaction"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 正数=入账(兑入),负数=出账(提现)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 业务类型:exchange_in / withdraw
|
||||
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""看激励视频发奖 CRUD。
|
||||
|
||||
发奖三道闸(仿提现的资金安全思路):
|
||||
1. 验签不过 → API 层直接拒,不进这里。
|
||||
2. trans_id 唯一 → 同一交易号二次回调不重复发(穿山甲会重试)。
|
||||
3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。
|
||||
|
||||
发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务,
|
||||
保证"记一笔 + 加金币"原子化。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, cn_today
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UnknownUserError(Exception):
|
||||
"""回调里的 user_id 不存在(可能是伪造)。"""
|
||||
|
||||
|
||||
def _find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
|
||||
return db.execute(
|
||||
select(AdRewardRecord).where(AdRewardRecord.trans_id == trans_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
return db.execute(
|
||||
select(func.count())
|
||||
.select_from(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_date == reward_date,
|
||||
AdRewardRecord.status == "granted",
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def grant_ad_reward(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
trans_id: str,
|
||||
*,
|
||||
coin: int = AD_REWARD_COIN,
|
||||
reward_name: str | None = None,
|
||||
raw: str | None = None,
|
||||
) -> AdRewardRecord:
|
||||
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
|
||||
|
||||
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见
|
||||
rewards.resolve_ad_reward_coin);默认 AD_REWARD_COIN 供 test-grant / 缺省场景用。
|
||||
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。
|
||||
"""
|
||||
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
|
||||
existing = _find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
if db.get(User, user_id) is None:
|
||||
raise UnknownUserError
|
||||
|
||||
today = cn_today().isoformat()
|
||||
|
||||
# #3 每日上限:超了记一条 capped(不发金币),让审计能看到"今天到顶了"
|
||||
if _granted_today(db, user_id, today) >= DAILY_AD_REWARD_LIMIT:
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
# 发金币 + 记一笔,同事务
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="ad_reward", ref_id=trans_id, remark="看广告奖励",
|
||||
)
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
|
||||
def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardRecord:
|
||||
"""提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。"""
|
||||
db.add(rec)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
existing = _find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def today_status(db: Session, user_id: int) -> tuple[int, int, int]:
|
||||
"""客户端查"今日看广告发奖"进度:返回 (今日已发次数, 每日上限, 单次金币)。"""
|
||||
used = _granted_today(db, user_id, cn_today().isoformat())
|
||||
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN
|
||||
@@ -0,0 +1,186 @@
|
||||
"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。
|
||||
|
||||
设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自
|
||||
demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。
|
||||
|
||||
聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑——
|
||||
created_at 带时区,统一转成北京时间的 date 再分组。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ, cn_today
|
||||
from app.models.savings import SavingsRecord
|
||||
|
||||
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
|
||||
_DEMO_STREAK_DAYS = 9
|
||||
_DEMO_EXTRA_ORDERS = 14
|
||||
|
||||
# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。
|
||||
# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中;
|
||||
# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。
|
||||
_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [
|
||||
("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]),
|
||||
("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]),
|
||||
("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]),
|
||||
("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]),
|
||||
("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]),
|
||||
("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]),
|
||||
("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]),
|
||||
("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]),
|
||||
("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]),
|
||||
("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SavingsSummary:
|
||||
total_saved_cents: int
|
||||
order_count: int
|
||||
avg_saved_cents: int # 平均每单省(分)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SavingsBattle:
|
||||
week_saved_cents: int # 本周(周一起)已省
|
||||
beat_percent: int # 超过百分之多少用户
|
||||
streak_days: int # 连续省钱天数
|
||||
|
||||
|
||||
def _local_date(dt: datetime):
|
||||
"""把(可能带时区的)created_at 转成北京时间的 date。"""
|
||||
if dt.tzinfo is None:
|
||||
return dt.date()
|
||||
return dt.astimezone(CN_TZ).date()
|
||||
|
||||
|
||||
def _all_records(db: Session, user_id: int) -> list[SavingsRecord]:
|
||||
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def ensure_seeded(db: Session, user_id: int) -> None:
|
||||
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
|
||||
exists = db.execute(
|
||||
select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1)
|
||||
).first()
|
||||
if exists is not None:
|
||||
return
|
||||
|
||||
rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致
|
||||
today = cn_today()
|
||||
records: list[SavingsRecord] = []
|
||||
|
||||
def _mk(day) -> SavingsRecord:
|
||||
hour = rng.randint(8, 21)
|
||||
minute = rng.randint(0, 59)
|
||||
ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ)
|
||||
platform, shop, dishes = rng.choice(_DEMO_ORDERS)
|
||||
# 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡
|
||||
saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元
|
||||
return SavingsRecord(
|
||||
user_id=user_id,
|
||||
order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价)
|
||||
saved_amount_cents=saved,
|
||||
platform=platform,
|
||||
title=shop,
|
||||
shop_name=shop,
|
||||
dishes=dishes,
|
||||
source="demo",
|
||||
created_at=ts,
|
||||
)
|
||||
|
||||
# 最近 N 天每天 1 单 → 连续省钱 N 天
|
||||
for d in range(_DEMO_STREAK_DAYS):
|
||||
records.append(_mk(today - timedelta(days=d)))
|
||||
# 历史散单(第 10~40 天)
|
||||
for _ in range(_DEMO_EXTRA_ORDERS):
|
||||
records.append(_mk(today - timedelta(days=rng.randint(10, 40))))
|
||||
|
||||
db.add_all(records)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_summary(db: Session, user_id: int) -> SavingsSummary:
|
||||
ensure_seeded(db, user_id)
|
||||
records = _all_records(db, user_id)
|
||||
total = sum(r.saved_amount_cents for r in records)
|
||||
count = len(records)
|
||||
avg = total // count if count else 0
|
||||
return SavingsSummary(total_saved_cents=total, order_count=count, avg_saved_cents=avg)
|
||||
|
||||
|
||||
def _streak_days(dates: set) -> int:
|
||||
"""从最近活跃日往前数连续有省钱记录的天数。"""
|
||||
if not dates:
|
||||
return 0
|
||||
cur = max(dates)
|
||||
streak = 0
|
||||
while cur in dates:
|
||||
streak += 1
|
||||
cur = cur - timedelta(days=1)
|
||||
return streak
|
||||
|
||||
|
||||
def get_battle(db: Session, user_id: int) -> SavingsBattle:
|
||||
ensure_seeded(db, user_id)
|
||||
records = _all_records(db, user_id)
|
||||
|
||||
today = cn_today()
|
||||
week_start = today - timedelta(days=today.weekday()) # 本周一
|
||||
week_saved = sum(
|
||||
r.saved_amount_cents for r in records if _local_date(r.created_at) >= week_start
|
||||
)
|
||||
|
||||
dates = {_local_date(r.created_at) for r in records}
|
||||
streak = _streak_days(dates)
|
||||
|
||||
beat_percent = _compute_beat_percent(db, user_id)
|
||||
|
||||
return SavingsBattle(
|
||||
week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak
|
||||
)
|
||||
|
||||
|
||||
def _compute_beat_percent(db: Session, user_id: int) -> int:
|
||||
"""超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。
|
||||
|
||||
= 严格少于我的其他用户数 / 其他用户总数 * 100。
|
||||
全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。
|
||||
"""
|
||||
rows = db.execute(
|
||||
select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents))
|
||||
.group_by(SavingsRecord.user_id)
|
||||
).all()
|
||||
totals = {uid: (total or 0) for uid, total in rows}
|
||||
others = {uid: t for uid, t in totals.items() if uid != user_id}
|
||||
if not others:
|
||||
return 0
|
||||
my_total = totals.get(user_id, 0)
|
||||
beaten = sum(1 for t in others.values() if t < my_total)
|
||||
return round(100 * beaten / len(others))
|
||||
|
||||
|
||||
def list_records(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[SavingsRecord], int | None]:
|
||||
"""省钱明细分页(按 id 倒序,游标式)。"""
|
||||
ensure_seeded(db, user_id)
|
||||
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(SavingsRecord.id < cursor)
|
||||
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
|
||||
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
return items, next_cursor
|
||||
@@ -0,0 +1,126 @@
|
||||
"""签到 CRUD:状态查询 + 执行签到。
|
||||
|
||||
7 天循环规则:
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续)
|
||||
- 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置)
|
||||
今天的 cycle_day 决定发多少金币(见 app.core.rewards.SIGNIN_REWARDS)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import SIGNIN_CYCLE_LEN, SIGNIN_REWARDS, cn_today, signin_reward
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.models.signin import SigninRecord
|
||||
|
||||
|
||||
class AlreadySignedError(Exception):
|
||||
"""今天已经签过了。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SigninStep:
|
||||
day: int # 1..7
|
||||
coin: int
|
||||
status: str # claimed / today / locked
|
||||
|
||||
|
||||
@dataclass
|
||||
class SigninStatus:
|
||||
today_signed: bool
|
||||
consecutive_days: int # 当前已确认的连续签到天数
|
||||
today_cycle_day: int # 今天落在循环的第几档(1..7)
|
||||
today_coin: int # 今天这一档的金币
|
||||
can_claim: bool # 现在能否签到(= not today_signed)
|
||||
steps: list[SigninStep]
|
||||
|
||||
|
||||
def _latest_record(db: Session, user_id: int) -> SigninRecord | None:
|
||||
stmt = (
|
||||
select(SigninRecord)
|
||||
.where(SigninRecord.user_id == user_id)
|
||||
.order_by(SigninRecord.signin_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def _next_cycle_day(last: SigninRecord | None, today) -> int:
|
||||
"""若现在签到,今天会落在第几档(1..7)。"""
|
||||
if last is not None and last.signin_date == today - timedelta(days=1):
|
||||
return last.cycle_day % SIGNIN_CYCLE_LEN + 1
|
||||
return 1
|
||||
|
||||
|
||||
def get_status(db: Session, user_id: int) -> SigninStatus:
|
||||
today = cn_today()
|
||||
last = _latest_record(db, user_id)
|
||||
today_signed = last is not None and last.signin_date == today
|
||||
|
||||
if today_signed:
|
||||
today_cycle_day = last.cycle_day
|
||||
consecutive_days = last.streak
|
||||
else:
|
||||
today_cycle_day = _next_cycle_day(last, today)
|
||||
# 未签时的"已连续"= 还在连续窗口内的既有 streak,否则 0
|
||||
if last is not None and last.signin_date == today - timedelta(days=1):
|
||||
consecutive_days = last.streak
|
||||
else:
|
||||
consecutive_days = 0
|
||||
|
||||
steps: list[SigninStep] = []
|
||||
for day in range(1, SIGNIN_CYCLE_LEN + 1):
|
||||
if today_signed:
|
||||
status = "claimed" if day <= today_cycle_day else "locked"
|
||||
else:
|
||||
if day < today_cycle_day:
|
||||
status = "claimed"
|
||||
elif day == today_cycle_day:
|
||||
status = "today"
|
||||
else:
|
||||
status = "locked"
|
||||
steps.append(SigninStep(day=day, coin=SIGNIN_REWARDS[day - 1], status=status))
|
||||
|
||||
return SigninStatus(
|
||||
today_signed=today_signed,
|
||||
consecutive_days=consecutive_days,
|
||||
today_cycle_day=today_cycle_day,
|
||||
today_coin=signin_reward(today_cycle_day),
|
||||
can_claim=not today_signed,
|
||||
steps=steps,
|
||||
)
|
||||
|
||||
|
||||
def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
|
||||
"""执行签到。返回 (签到记录, 签到后金币余额)。今天已签则抛 AlreadySignedError。"""
|
||||
today = cn_today()
|
||||
last = _latest_record(db, user_id)
|
||||
if last is not None and last.signin_date == today:
|
||||
raise AlreadySignedError
|
||||
|
||||
if last is not None and last.signin_date == today - timedelta(days=1):
|
||||
cycle_day = last.cycle_day % SIGNIN_CYCLE_LEN + 1
|
||||
streak = last.streak + 1
|
||||
else:
|
||||
cycle_day = 1
|
||||
streak = 1
|
||||
|
||||
coin = signin_reward(cycle_day)
|
||||
record = SigninRecord(
|
||||
user_id=user_id,
|
||||
signin_date=today,
|
||||
cycle_day=cycle_day,
|
||||
streak=streak,
|
||||
coin_awarded=coin,
|
||||
)
|
||||
db.add(record)
|
||||
acc, _ = crud_wallet.grant_coins(
|
||||
db, user_id, coin, biz_type="signin", ref_id=today.isoformat()
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record, acc.coin_balance
|
||||
@@ -0,0 +1,71 @@
|
||||
"""一次性任务 CRUD:列表状态 + 领奖。
|
||||
|
||||
领奖原子化:写 user_task(唯一约束防重复)+ 发金币,同一事务 commit。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import TASK_REWARDS
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.models.task import UserTask
|
||||
|
||||
|
||||
class UnknownTaskError(Exception):
|
||||
"""task_key 不在已知任务表里。"""
|
||||
|
||||
|
||||
class AlreadyClaimedError(Exception):
|
||||
"""该任务已经领过奖了。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskState:
|
||||
task_key: str
|
||||
coin: int
|
||||
claimed: bool
|
||||
|
||||
|
||||
def list_tasks(db: Session, user_id: int) -> list[TaskState]:
|
||||
"""返回所有已知一次性任务及其领取状态。"""
|
||||
stmt = select(UserTask.task_key).where(UserTask.user_id == user_id)
|
||||
claimed_keys = set(db.execute(stmt).scalars().all())
|
||||
return [
|
||||
TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)
|
||||
for key, coin in TASK_REWARDS.items()
|
||||
]
|
||||
|
||||
|
||||
def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
|
||||
"""领取一次性任务奖励。返回 (发放金币, 领奖后余额)。
|
||||
|
||||
未知任务抛 UnknownTaskError;重复领取抛 AlreadyClaimedError。
|
||||
"""
|
||||
if task_key not in TASK_REWARDS:
|
||||
raise UnknownTaskError
|
||||
|
||||
existing = db.execute(
|
||||
select(UserTask).where(
|
||||
UserTask.user_id == user_id, UserTask.task_key == task_key
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise AlreadyClaimedError
|
||||
|
||||
coin = TASK_REWARDS[task_key]
|
||||
db.add(
|
||||
UserTask(
|
||||
user_id=user_id,
|
||||
task_key=task_key,
|
||||
status="completed",
|
||||
coin_awarded=coin,
|
||||
)
|
||||
)
|
||||
acc, _ = crud_wallet.grant_coins(
|
||||
db, user_id, coin, biz_type=f"task_{task_key}", ref_id=task_key
|
||||
)
|
||||
db.commit()
|
||||
return coin, acc.coin_balance
|
||||
@@ -0,0 +1,506 @@
|
||||
"""钱包 CRUD:账户读取 + 金币入账(共享给签到 / 任务)+ 流水查询。
|
||||
|
||||
`grant_coins` 是所有"发金币"的唯一入口:它更新余额快照并写一笔流水,但**不 commit**,
|
||||
由调用方在同一事务里 commit(签到要同时写 signin_record,任务要同时写 user_task,
|
||||
保证"记录"和"加金币"原子化,不会只发钱不留痕或反之)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import (
|
||||
COIN_PER_CENT,
|
||||
MIN_EXCHANGE_COIN,
|
||||
WITHDRAW_MAX_CENTS,
|
||||
WITHDRAW_MIN_CENTS,
|
||||
coins_to_cents,
|
||||
)
|
||||
from app.integrations import wxpay
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
|
||||
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
|
||||
_WX_STATE_SUCCESS = "SUCCESS"
|
||||
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
||||
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
||||
|
||||
|
||||
class InvalidExchangeAmountError(Exception):
|
||||
"""兑换金币数非法(<最小额 或 不是整分倍数)。"""
|
||||
|
||||
|
||||
class InsufficientCoinError(Exception):
|
||||
"""金币余额不足。"""
|
||||
|
||||
|
||||
class InvalidWithdrawAmountError(Exception):
|
||||
"""提现金额超出允许范围。"""
|
||||
|
||||
|
||||
class WechatNotBoundError(Exception):
|
||||
"""用户未绑定微信(无 openid),无法提现。"""
|
||||
|
||||
|
||||
class WechatAlreadyBoundError(Exception):
|
||||
"""该微信(openid)已绑定到其他账号。"""
|
||||
|
||||
|
||||
class InsufficientCashError(Exception):
|
||||
"""现金余额不足。"""
|
||||
|
||||
|
||||
class WithdrawTransferError(Exception):
|
||||
"""调用微信转账失败(已退回余额)。"""
|
||||
|
||||
|
||||
class WithdrawOrderNotFound(Exception):
|
||||
"""提现单不存在。"""
|
||||
|
||||
|
||||
def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount:
|
||||
"""取用户金币账户,不存在则建一个空账户。"""
|
||||
acc = db.get(CoinAccount, user_id)
|
||||
if acc is None:
|
||||
acc = CoinAccount(
|
||||
user_id=user_id,
|
||||
coin_balance=0,
|
||||
cash_balance_cents=0,
|
||||
total_coin_earned=0,
|
||||
)
|
||||
db.add(acc)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(acc)
|
||||
else:
|
||||
db.flush()
|
||||
return acc
|
||||
|
||||
|
||||
def grant_coins(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
*,
|
||||
biz_type: str,
|
||||
ref_id: str | None = None,
|
||||
remark: str | None = None,
|
||||
) -> tuple[CoinAccount, CoinTransaction]:
|
||||
"""金币变动入口(正数入账 / 负数出账)。更新余额 + 写流水,不 commit。
|
||||
|
||||
返回 (account, transaction)。调用方负责 commit。
|
||||
"""
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
acc.coin_balance += amount
|
||||
if amount > 0:
|
||||
acc.total_coin_earned += amount
|
||||
|
||||
txn = CoinTransaction(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
balance_after=acc.coin_balance,
|
||||
biz_type=biz_type,
|
||||
ref_id=ref_id,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(txn)
|
||||
db.flush()
|
||||
return acc, txn
|
||||
|
||||
|
||||
def list_coin_transactions(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[CoinTransaction], int | None]:
|
||||
"""金币流水分页(按 id 倒序,游标式)。
|
||||
|
||||
cursor 为上一页最后一条的 id;返回 (本页列表, next_cursor)。
|
||||
next_cursor 为 None 表示没有下一页。
|
||||
"""
|
||||
stmt = select(CoinTransaction).where(CoinTransaction.user_id == user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(CoinTransaction.id < cursor)
|
||||
stmt = stmt.order_by(CoinTransaction.id.desc()).limit(limit)
|
||||
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
return items, next_cursor
|
||||
|
||||
|
||||
def exchange_coins_to_cash(
|
||||
db: Session, user_id: int, coin_amount: int
|
||||
) -> tuple[CoinAccount, int]:
|
||||
"""金币兑现金。返回 (account, 本次兑入的分)。
|
||||
|
||||
校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError),
|
||||
余额充足(InsufficientCoinError)。扣金币 + 加现金,两条流水同事务 commit。
|
||||
"""
|
||||
if coin_amount < MIN_EXCHANGE_COIN or coin_amount % COIN_PER_CENT != 0:
|
||||
raise InvalidExchangeAmountError
|
||||
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
if acc.coin_balance < coin_amount:
|
||||
raise InsufficientCoinError
|
||||
|
||||
cents = coins_to_cents(coin_amount)
|
||||
remark = f"{coin_amount}金币兑{cents}分"
|
||||
|
||||
# 1. 扣金币(写 coin_transaction)
|
||||
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=remark)
|
||||
# 2. 加现金(写 cash_transaction)
|
||||
acc.cash_balance_cents += cents
|
||||
db.add(
|
||||
CashTransaction(
|
||||
user_id=user_id,
|
||||
amount_cents=cents,
|
||||
balance_after_cents=acc.cash_balance_cents,
|
||||
biz_type="exchange_in",
|
||||
remark=remark,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(acc)
|
||||
return acc, cents
|
||||
|
||||
|
||||
def list_cash_transactions(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[CashTransaction], int | None]:
|
||||
"""现金流水分页(按 id 倒序,游标式)。"""
|
||||
stmt = select(CashTransaction).where(CashTransaction.user_id == user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(CashTransaction.id < cursor)
|
||||
stmt = stmt.order_by(CashTransaction.id.desc()).limit(limit)
|
||||
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
return items, next_cursor
|
||||
|
||||
|
||||
# ===== 提现(现金 → 微信零钱) =====
|
||||
|
||||
|
||||
def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
|
||||
"""用微信授权 code 换 openid + 昵称头像并绑定到用户。失败抛 ValueError。
|
||||
返回 {openid, nickname, avatar_url}(昵称头像可能为 None,见 wxpay.code_to_userinfo)。"""
|
||||
info = wxpay.code_to_userinfo(code) # 失败抛 ValueError
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise WithdrawOrderNotFound # 理论上当前用户必存在
|
||||
|
||||
# #5 一个微信只能绑一个账号:若该 openid 已属于别的用户,拒绝(防多账号薅羊毛/重复提现)
|
||||
other = db.execute(
|
||||
select(User.id).where(User.wechat_openid == info["openid"], User.id != user_id)
|
||||
).scalar_one_or_none()
|
||||
if other is not None:
|
||||
raise WechatAlreadyBoundError
|
||||
|
||||
user.wechat_openid = info["openid"]
|
||||
user.wechat_nickname = info["nickname"]
|
||||
user.wechat_avatar_url = info["avatar_url"]
|
||||
db.commit()
|
||||
return info
|
||||
|
||||
|
||||
def unbind_wechat_openid(db: Session, user_id: int) -> None:
|
||||
"""解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。"""
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise WithdrawOrderNotFound
|
||||
if user.wechat_openid is not None:
|
||||
user.wechat_openid = None
|
||||
user.wechat_nickname = None
|
||||
user.wechat_avatar_url = None
|
||||
db.commit()
|
||||
|
||||
|
||||
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
|
||||
|
||||
|
||||
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int) -> bool:
|
||||
"""原子扣减现金:仅当余额足够时扣,返回是否成功。
|
||||
|
||||
用带条件的 UPDATE(`WHERE cash_balance_cents >= amount`)避免"读-判断-写"竞态——
|
||||
并发/重试时不会两次都通过余额检查导致超额扣款(SQLite 串行写、Postgres 行级,均安全)。
|
||||
"""
|
||||
res = db.execute(
|
||||
update(CoinAccount)
|
||||
.where(
|
||||
CoinAccount.user_id == user_id,
|
||||
CoinAccount.cash_balance_cents >= amount_cents,
|
||||
)
|
||||
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
|
||||
)
|
||||
return res.rowcount == 1
|
||||
|
||||
|
||||
def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
|
||||
"""原子增加现金(退款用),返回加后余额。"""
|
||||
db.execute(
|
||||
update(CoinAccount)
|
||||
.where(CoinAccount.user_id == user_id)
|
||||
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents)
|
||||
)
|
||||
db.flush()
|
||||
bal = db.execute(
|
||||
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
|
||||
).scalar_one()
|
||||
return bal
|
||||
|
||||
|
||||
def _refund_withdraw(db: Session, order: WithdrawOrder, reason: str) -> None:
|
||||
"""转账失败/取消:退回现金 + 写 withdraw_refund 流水 + 单子置 failed。幂等:已 failed 不重复退。"""
|
||||
if order.status == "failed":
|
||||
return # 防重复退款(并发/对账与查单同时触发)
|
||||
bal = _add_cash(db, order.user_id, order.amount_cents)
|
||||
db.add(
|
||||
CashTransaction(
|
||||
user_id=order.user_id,
|
||||
amount_cents=order.amount_cents,
|
||||
balance_after_cents=bal,
|
||||
biz_type="withdraw_refund",
|
||||
ref_id=order.out_bill_no,
|
||||
remark="提现未成功,金额已退回", # 用户可见;技术原因记在 order.fail_reason
|
||||
)
|
||||
)
|
||||
order.status = "failed"
|
||||
order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
|
||||
|
||||
def _wx_not_found(result: dict) -> bool:
|
||||
"""微信查单/转账响应是否表示'此单不存在'(说明转账从未创建,退款安全)。"""
|
||||
if result.get("status_code") == 404:
|
||||
return True
|
||||
data = result.get("data")
|
||||
code = data.get("code", "") if isinstance(data, dict) else ""
|
||||
return "NOT_FOUND" in str(code)
|
||||
|
||||
|
||||
def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> None:
|
||||
"""转账调用结果不明(超时/异常/非200)时,**先查单再决定**,绝不盲目退款(防退款后又到账)。
|
||||
- 微信查到 SUCCESS → 钱已出,置 success,不退款
|
||||
- 微信查到 FAIL/取消/关闭 → 退款
|
||||
- 微信查到在途/待确认 → 保持 pending(带上 package_info/state),交给查单/对账后续处理
|
||||
- 微信明确无此单 → 从未创建,退款安全
|
||||
- 查询本身也失败 → 保持 pending,留给对账任务(reconcile)兜底,绝不退款
|
||||
"""
|
||||
try:
|
||||
q = wxpay.query_transfer(order.out_bill_no)
|
||||
except Exception: # noqa: BLE001 — 查询都失败,保持 pending 等对账
|
||||
order.fail_reason = (reason + " | 查单异常,保持pending")[:256]
|
||||
db.commit()
|
||||
return
|
||||
|
||||
if q["status_code"] != 200:
|
||||
if _wx_not_found(q):
|
||||
_refund_withdraw(db, order, reason=reason + " (微信无此单,已退回)")
|
||||
else:
|
||||
order.fail_reason = (reason + " | 查单非200,保持pending")[:256]
|
||||
db.commit()
|
||||
return
|
||||
|
||||
state = q["data"].get("state", "")
|
||||
order.wechat_state = state
|
||||
if state == _WX_STATE_SUCCESS:
|
||||
order.status = "success"
|
||||
order.transfer_bill_no = q["data"].get("transfer_bill_no")
|
||||
db.commit()
|
||||
elif state in _WX_STATE_FAILED:
|
||||
_refund_withdraw(db, order, reason=reason)
|
||||
else:
|
||||
order.package_info = q["data"].get("package_info") or order.package_info
|
||||
db.commit()
|
||||
|
||||
|
||||
def create_withdraw(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
amount_cents: int,
|
||||
*,
|
||||
user_name: str | None = None,
|
||||
out_bill_no: str | None = None,
|
||||
) -> WithdrawOrder:
|
||||
"""发起提现(原子扣款 + 幂等 + 模糊失败查单)。
|
||||
|
||||
#1 用原子条件 UPDATE 扣款,杜绝并发超额。
|
||||
#2 out_bill_no 由客户端提供作幂等键:同号重试不会重复转账,直接返回该单当前状态。
|
||||
#3 微信调用结果不明时先查单再决定,绝不盲目退款。
|
||||
返回提现单(可能含 package_info 供 App 拉起确认页)。
|
||||
"""
|
||||
if amount_cents < WITHDRAW_MIN_CENTS or amount_cents > WITHDRAW_MAX_CENTS:
|
||||
raise InvalidWithdrawAmountError
|
||||
|
||||
user = db.get(User, user_id)
|
||||
openid = user.wechat_openid if user else None
|
||||
if not openid:
|
||||
raise WechatNotBoundError
|
||||
|
||||
# #2 幂等:客户端传了合法 out_bill_no 且该单已存在 → 返回现状(pending 的顺手查一下),不重复发起
|
||||
if out_bill_no and _OUT_BILL_NO_RE.match(out_bill_no):
|
||||
existing = db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
if existing.status == "pending":
|
||||
return refresh_withdraw_status(db, user_id, out_bill_no)
|
||||
return existing
|
||||
else:
|
||||
out_bill_no = uuid.uuid4().hex
|
||||
|
||||
# 账户须存在(原子扣款的 UPDATE 不会建账户)
|
||||
get_or_create_account(db, user_id, commit=True)
|
||||
|
||||
# #1 原子扣款:余额不足时影响行数为 0
|
||||
if not _try_deduct_cash(db, user_id, amount_cents):
|
||||
db.rollback()
|
||||
raise InsufficientCashError
|
||||
|
||||
bal = db.execute(
|
||||
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
|
||||
).scalar_one()
|
||||
db.add(
|
||||
CashTransaction(
|
||||
user_id=user_id,
|
||||
amount_cents=-amount_cents,
|
||||
balance_after_cents=bal,
|
||||
biz_type="withdraw",
|
||||
ref_id=out_bill_no,
|
||||
remark="提现到微信零钱",
|
||||
)
|
||||
)
|
||||
order = WithdrawOrder(
|
||||
user_id=user_id, out_bill_no=out_bill_no, amount_cents=amount_cents, status="pending"
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
|
||||
# 2) 调微信转账;结果不明先查单再决定(#3)
|
||||
try:
|
||||
result = wxpay.create_transfer(openid, amount_cents, out_bill_no, user_name=user_name)
|
||||
except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认
|
||||
_settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}")
|
||||
db.refresh(order)
|
||||
if order.status == "failed":
|
||||
raise WithdrawTransferError(str(e)) from e
|
||||
return order
|
||||
|
||||
if result["status_code"] != 200:
|
||||
msg = str(result["data"].get("message") or result["data"])
|
||||
_settle_after_ambiguous(db, order, reason=msg)
|
||||
db.refresh(order)
|
||||
if order.status == "failed":
|
||||
raise WithdrawTransferError(msg)
|
||||
return order
|
||||
|
||||
data = result["data"]
|
||||
order.wechat_state = data.get("state")
|
||||
order.transfer_bill_no = data.get("transfer_bill_no")
|
||||
order.package_info = data.get("package_info")
|
||||
if data.get("state") == _WX_STATE_SUCCESS:
|
||||
order.status = "success"
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
def refresh_withdraw_status(
|
||||
db: Session, user_id: int, out_bill_no: str, *, cancel_if_unconfirmed: bool = False
|
||||
) -> WithdrawOrder:
|
||||
"""查微信单状态并归一化:SUCCESS→success;FAIL/CANCELLED/CLOSED→退款+failed;其余保持 pending。
|
||||
|
||||
cancel_if_unconfirmed:查到 WAIT_USER_CONFIRM 时,是否当作"用户放弃"撤销并退款。
|
||||
- True:用户从确认页返回后查单、或对账老单时传 → 撤销+退款。
|
||||
- False(默认):幂等重试只想读状态时传 → 保留 pending,不能误撤用户还想确认的单。
|
||||
"""
|
||||
order = db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if order is None:
|
||||
raise WithdrawOrderNotFound
|
||||
if order.status != "pending":
|
||||
return order # 已终态,不再查
|
||||
|
||||
result = wxpay.query_transfer(out_bill_no)
|
||||
if result["status_code"] != 200:
|
||||
if _wx_not_found(result):
|
||||
# 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
|
||||
_refund_withdraw(db, order, reason="微信无此单,已退回")
|
||||
db.refresh(order)
|
||||
return order # 其他查询失败不改状态,保持 pending 等下次
|
||||
|
||||
state = result["data"].get("state", "")
|
||||
order.wechat_state = state
|
||||
if state == _WX_STATE_SUCCESS:
|
||||
order.status = "success"
|
||||
db.commit()
|
||||
elif state in _WX_STATE_FAILED:
|
||||
_refund_withdraw(db, order, reason=f"微信转账状态 {state}")
|
||||
elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
|
||||
# 用户从确认页回来了却仍未确认 → 视为放弃:撤销微信单(防事后确认导致重复打款)后退款。
|
||||
# 撤单失败(可能已被确认进 ACCEPTED 的竞态)则保持 pending,等下次查询。
|
||||
cancel = wxpay.cancel_transfer(out_bill_no)
|
||||
if cancel["status_code"] in (200, 202):
|
||||
_refund_withdraw(db, order, reason="用户未确认,已撤销提现")
|
||||
else:
|
||||
db.commit()
|
||||
else:
|
||||
db.commit() # ACCEPTED / PROCESSING 等真在途,仍 pending
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) -> dict:
|
||||
"""对账:扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化(成功/退款/撤销未确认)。
|
||||
防止"扣了款但转账没发起/没确认"的孤儿单永久锁住用户余额。建议用 cron/systemd timer 定时跑。
|
||||
返回 {checked, resolved} 计数。"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
|
||||
orders = list(
|
||||
db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
WithdrawOrder.status == "pending", WithdrawOrder.created_at < cutoff
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
resolved = 0
|
||||
for o in orders:
|
||||
try:
|
||||
# 老单视同"用户已放弃确认",查到 WAIT_USER_CONFIRM 也撤销退款
|
||||
refreshed = refresh_withdraw_status(
|
||||
db, o.user_id, o.out_bill_no, cancel_if_unconfirmed=True
|
||||
)
|
||||
if refreshed.status != "pending":
|
||||
resolved += 1
|
||||
except Exception: # noqa: BLE001 — 单笔失败不影响其余,下轮再试
|
||||
db.rollback()
|
||||
continue
|
||||
return {"checked": len(orders), "resolved": resolved}
|
||||
|
||||
|
||||
def list_withdraw_orders(
|
||||
db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None
|
||||
) -> tuple[list[WithdrawOrder], int | None]:
|
||||
"""提现单分页(按 id 倒序,游标式)。"""
|
||||
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(WithdrawOrder.id < cursor)
|
||||
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
return items, next_cursor
|
||||
@@ -0,0 +1,41 @@
|
||||
"""看激励视频发奖相关 schemas。
|
||||
|
||||
约定同其余模块:字段 snake_case、金额/次数存整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PangleCallbackOut(BaseModel):
|
||||
"""回给穿山甲 GroMore 的回调响应。
|
||||
|
||||
GroMore 服务端激励回调规范(supportcenter/26240)要求响应体为
|
||||
`{"is_verify": bool, "reason": int}`:
|
||||
- `is_verify=true` + `reason=0`:校验通过、发放奖励(发奖成功 或 当日已达上限均算"已处理")。
|
||||
- `is_verify=false`:不发放,`reason` 带错误码透传客户端 SDK(见 ad.py 的 REASON_*)。
|
||||
验签失败由 API 层直接返 403,不走这里。"""
|
||||
|
||||
is_verify: bool = True
|
||||
reason: int = 0
|
||||
|
||||
|
||||
class AdRewardStatusOut(BaseModel):
|
||||
"""客户端查今日看广告发奖进度(福利页"看视频赚金币"用)。"""
|
||||
|
||||
used_today: int = Field(..., description="今日已成功发奖次数")
|
||||
daily_limit: int = Field(..., description="每日发奖次数上限")
|
||||
remaining: int = Field(..., description="今日剩余可领次数")
|
||||
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
||||
|
||||
|
||||
class TestGrantOut(BaseModel):
|
||||
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
|
||||
|
||||
granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)")
|
||||
status: str = Field(..., description="granted / capped")
|
||||
coin: int = Field(..., description="本次发放金币(capped 时为 0)")
|
||||
used_today: int = Field(..., description="今日已成功发奖次数")
|
||||
daily_limit: int = Field(..., description="每日发奖次数上限")
|
||||
remaining: int = Field(..., description="今日剩余可领次数")
|
||||
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
||||
@@ -0,0 +1,214 @@
|
||||
"""福利模块(钱包 / 签到 / 任务)请求 / 响应 schemas。
|
||||
|
||||
约定同 auth:字段 snake_case、时间 ISO 8601(UTC)、金额存整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ===== 钱包 / 我的资产 =====
|
||||
|
||||
class CoinAccountOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
coin_balance: int = Field(..., description="当前金币余额")
|
||||
cash_balance_cents: int = Field(..., description="当前现金余额(分)")
|
||||
total_coin_earned: int = Field(..., description="累计赚取金币")
|
||||
|
||||
|
||||
class CoinTransactionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
amount: int = Field(..., description="正=入账,负=出账")
|
||||
balance_after: int
|
||||
biz_type: str
|
||||
ref_id: str | None = None
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CoinTransactionPage(BaseModel):
|
||||
items: list[CoinTransactionOut]
|
||||
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
|
||||
|
||||
|
||||
class CashTransactionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
amount_cents: int = Field(..., description="正=兑入,负=提现")
|
||||
balance_after_cents: int
|
||||
biz_type: str
|
||||
ref_id: str | None = None
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CashTransactionPage(BaseModel):
|
||||
items: list[CashTransactionOut]
|
||||
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
|
||||
|
||||
|
||||
# ===== 金币兑现金 =====
|
||||
|
||||
class ExchangeInfoOut(BaseModel):
|
||||
coin_per_yuan: int = Field(..., description="多少金币兑 1 元")
|
||||
min_coin: int = Field(..., description="单次兑换最少金币")
|
||||
step_coin: int = Field(..., description="兑换金币需为该值的整数倍(整分)")
|
||||
|
||||
|
||||
class ExchangeRequest(BaseModel):
|
||||
coin_amount: int = Field(..., gt=0, description="要兑换的金币数")
|
||||
|
||||
|
||||
class ExchangeResultOut(BaseModel):
|
||||
coin_amount: int = Field(..., description="本次扣除的金币")
|
||||
cash_added_cents: int = Field(..., description="本次兑入的现金(分)")
|
||||
coin_balance: int = Field(..., description="兑换后金币余额")
|
||||
cash_balance_cents: int = Field(..., description="兑换后现金余额(分)")
|
||||
|
||||
|
||||
# ===== 提现(现金 → 微信零钱) =====
|
||||
|
||||
class WithdrawInfoOut(BaseModel):
|
||||
min_cents: int = Field(..., description="单次最低提现(分)")
|
||||
max_cents: int = Field(..., description="单次最高提现(分)")
|
||||
wechat_bound: bool = Field(..., description="当前用户是否已绑定微信")
|
||||
wechat_nickname: str | None = Field(None, description="微信昵称(可能为空/脱敏)")
|
||||
wechat_avatar_url: str | None = Field(None, description="微信头像 URL(可能为空)")
|
||||
|
||||
|
||||
class BindWechatRequest(BaseModel):
|
||||
code: str = Field(..., description="微信授权返回的 code")
|
||||
|
||||
|
||||
class BindWechatResultOut(BaseModel):
|
||||
bound: bool = True
|
||||
wechat_nickname: str | None = None
|
||||
wechat_avatar_url: str | None = None
|
||||
|
||||
|
||||
class UnbindWechatResultOut(BaseModel):
|
||||
bound: bool = False
|
||||
|
||||
|
||||
class WithdrawRequest(BaseModel):
|
||||
amount_cents: int = Field(..., gt=0, description="提现金额(分)")
|
||||
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
|
||||
out_bill_no: str | None = Field(
|
||||
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
|
||||
)
|
||||
|
||||
|
||||
class WithdrawResultOut(BaseModel):
|
||||
out_bill_no: str = Field(..., description="商户提现单号(查单用)")
|
||||
status: str = Field(..., description="pending / success / failed")
|
||||
wechat_state: str | None = Field(None, description="微信侧原始状态")
|
||||
amount_cents: int
|
||||
cash_balance_cents: int = Field(..., description="提现后现金余额(分)")
|
||||
# 供 App 拉起微信确认页(WXOpenBusinessView requestMerchantTransfer)
|
||||
package_info: str | None = None
|
||||
mch_id: str | None = None
|
||||
app_id: str | None = None
|
||||
|
||||
|
||||
class WithdrawStatusOut(BaseModel):
|
||||
out_bill_no: str
|
||||
status: str = Field(..., description="pending / success / failed")
|
||||
wechat_state: str | None = None
|
||||
amount_cents: int
|
||||
|
||||
|
||||
class WithdrawOrderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
out_bill_no: str
|
||||
amount_cents: int
|
||||
status: str
|
||||
wechat_state: str | None = None
|
||||
fail_reason: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class WithdrawOrderPage(BaseModel):
|
||||
items: list[WithdrawOrderOut]
|
||||
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
|
||||
|
||||
|
||||
# ===== 签到 =====
|
||||
|
||||
class SigninStepOut(BaseModel):
|
||||
day: int = Field(..., description="循环内第几天 1..7")
|
||||
coin: int
|
||||
status: str = Field(..., description="claimed / today / locked")
|
||||
|
||||
|
||||
class SigninStatusOut(BaseModel):
|
||||
today_signed: bool
|
||||
consecutive_days: int
|
||||
today_cycle_day: int
|
||||
today_coin: int
|
||||
can_claim: bool
|
||||
steps: list[SigninStepOut]
|
||||
|
||||
|
||||
class SigninResultOut(BaseModel):
|
||||
coin_awarded: int
|
||||
cycle_day: int
|
||||
streak: int
|
||||
coin_balance: int = Field(..., description="签到后金币余额")
|
||||
|
||||
|
||||
# ===== 任务 =====
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
task_key: str
|
||||
coin: int
|
||||
claimed: bool
|
||||
|
||||
|
||||
class TaskListOut(BaseModel):
|
||||
items: list[TaskOut]
|
||||
|
||||
|
||||
class TaskClaimResultOut(BaseModel):
|
||||
task_key: str
|
||||
coin_awarded: int
|
||||
coin_balance: int = Field(..., description="领奖后金币余额")
|
||||
|
||||
|
||||
# ===== 省钱(累计帮你省了 / 省钱战绩 / 明细)=====
|
||||
|
||||
class SavingsSummaryOut(BaseModel):
|
||||
total_saved_cents: int = Field(..., description="累计省下(分)")
|
||||
order_count: int = Field(..., description="累计省钱订单数")
|
||||
avg_saved_cents: int = Field(..., description="平均每单省(分)")
|
||||
|
||||
|
||||
class SavingsBattleOut(BaseModel):
|
||||
week_saved_cents: int = Field(..., description="本周已省(分)")
|
||||
beat_percent: int = Field(..., description="超过百分之多少用户")
|
||||
streak_days: int = Field(..., description="连续省钱天数")
|
||||
|
||||
|
||||
class SavingsRecordOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
order_amount_cents: int
|
||||
saved_amount_cents: int
|
||||
platform: str | None = None
|
||||
title: str | None = None
|
||||
shop_name: str | None = None
|
||||
dishes: list[str] = []
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SavingsRecordPage(BaseModel):
|
||||
items: list[SavingsRecordOut]
|
||||
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
|
||||
@@ -6,7 +6,10 @@
|
||||
|---|---|
|
||||
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 |
|
||||
| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 |
|
||||
| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
|
||||
| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 |
|
||||
| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 |
|
||||
| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 |
|
||||
|
||||
## api/ 目录是怎么组织的(传送门式)
|
||||
|
||||
|
||||
+36
-1
@@ -4,6 +4,7 @@
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-05-27
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -22,9 +23,36 @@
|
||||
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
|
||||
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
|
||||
| **钱包 / 我的资产**(前缀 `/api/v1/wallet`) |||
|
||||
| 12 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
|
||||
| 13 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
|
||||
| 14 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
|
||||
| 15 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
|
||||
| 16 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
|
||||
| 17 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
|
||||
| 18 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
|
||||
| 19 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
|
||||
| 20 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
|
||||
| 21 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) |
|
||||
| 22 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) |
|
||||
| **签到**(前缀 `/api/v1/signin`) |||
|
||||
| 23 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
|
||||
| 24 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
|
||||
| **任务**(前缀 `/api/v1/tasks`) |||
|
||||
| 25 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
|
||||
| 26 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
|
||||
| **省钱**(前缀 `/api/v1/savings`) |||
|
||||
| 27 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
|
||||
| 28 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
|
||||
| 29 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
|
||||
| **看广告发奖**(前缀 `/api/v1/ad`) |||
|
||||
| 30 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
|
||||
| 31 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
|
||||
| 32 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
> `coupon/step` 是后端**唯一需要 JWT 鉴权的业务接口**。
|
||||
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
|
||||
> 金额字段一律以**分**为单位(`*_cents`)。
|
||||
|
||||
---
|
||||
|
||||
@@ -40,6 +68,13 @@
|
||||
- `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` 倒序(最新在前)。
|
||||
|
||||
---
|
||||
|
||||
## 复用数据结构
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# GET /api/v1/ad/pangle-callback — 穿山甲 GroMore 激励视频发奖回调(S2S)
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> ⚠️ 我们客户端用 `useMediation(true)`(GroMore 融合),回调走 **GroMore 广告位层级**(规范见 supportcenter/26240),**不是**联盟代码位层级(5416)。两者密钥、响应格式都不同,别混。后台配置入口:**GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」**(广告位层级配了就别再在代码位层级重复配,会冲突)。
|
||||
>
|
||||
> 集成实现:见 [integrations/pangle](../integrations/pangle.md)(验签算法、m-key 来源、设计动机)。
|
||||
|
||||
## 入参(query,由 GroMore 拼装)
|
||||
GroMore 以 GET 回调,关键参数:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `user_id` | string | 客户端 `setUserID` 传入的用户标识(须为数字 = 本系统 user.id) |
|
||||
| `trans_id` | string | 交易号(**幂等键** + **唯一参与签名的字段**) |
|
||||
| `reward_name` | string | 奖励名(广告位配置,入库备注) |
|
||||
| `reward_amount` | int | 奖励数量(广告位配置)→ **本次发放金币**。缺/坏/≤0 回退 `AD_REWARD_COIN`,超 `MAX_AD_REWARD_COIN` 夹紧 |
|
||||
| `extra` | string | 客户端透传的 customData(可空,入库备注) |
|
||||
| `mediation_rit` | string | 代码位 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`。
|
||||
@@ -0,0 +1,19 @@
|
||||
# GET /api/v1/ad/reward-status — 今日看广告发奖进度
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`AdRewardStatusOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `used_today` | int | 今日已成功发奖次数 |
|
||||
| `daily_limit` | int | 每日发奖次数上限 |
|
||||
| `remaining` | int | 今日剩余可领次数 |
|
||||
| `coin_per_ad` | int | 看完一个激励视频发的金币 |
|
||||
|
||||
## 说明
|
||||
福利页「看视频赚金币」用:展示「今日还能看 N 次」「看一次得 M 金币」。真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。
|
||||
@@ -0,0 +1,27 @@
|
||||
# POST /api/v1/ad/test-grant — [仅本地联调]模拟穿山甲回调发奖
|
||||
|
||||
> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`TestGrantOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `granted` | bool | 本次是否真发了金币(达每日上限则 false) |
|
||||
| `status` | string | `granted` / `capped`(达上限) |
|
||||
| `coin` | int | 本次发放金币(capped 时 0) |
|
||||
| `used_today` | int | 今日已成功发奖次数 |
|
||||
| `daily_limit` | int | 每日发奖次数上限 |
|
||||
| `remaining` | int | 今日剩余可领次数 |
|
||||
| `coin_per_ad` | int | 看完一个激励视频发的金币 |
|
||||
|
||||
## 错误码
|
||||
- `404` 开关未开(伪装不存在) / 用户不存在
|
||||
|
||||
## 说明
|
||||
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限)。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
|
||||
@@ -1,6 +1,8 @@
|
||||
# POST /api/v1/auth/jverify-login — 极光一键登录
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/jiguang](../integrations/jiguang.md)(极光核验链路、RSA 解密策略、私钥配对踩坑)。
|
||||
|
||||
## 入参
|
||||
|
||||
@@ -17,4 +19,4 @@
|
||||
- `403` 账号被禁用
|
||||
|
||||
## 说明
|
||||
后端拿 `login_token` 调极光 `loginTokenVerify` 验真 → 取回 RSA 加密的手机号 → 用私钥解密 → 注册即登录(手机号不存在则建号)→ 签发 JWT。
|
||||
后端验真 `login_token` 取回并解密手机号 → 注册即登录(手机号不存在则建号)→ 签发 JWT。极光核验 + RSA 解密的实现细节见 [integrations/jiguang](../integrations/jiguang.md)。
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# POST /api/v1/auth/sms/login — 手机号 + 验证码登录
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。
|
||||
|
||||
## 入参
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# POST /api/v1/auth/sms/send — 发送短信验证码
|
||||
|
||||
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/sms](../integrations/sms.md)(mock 模式、频控、接真供应商 TODO)。
|
||||
|
||||
## 入参
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# POST /api/v1/meituan/coupons — 券列表 / 搜索
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
|
||||
|
||||
## 入参
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# POST /api/v1/meituan/feed — 首页混合推荐流
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
|
||||
|
||||
## 入参
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# POST /api/v1/meituan/referral-link — 换取推广链接
|
||||
|
||||
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
|
||||
|
||||
## 入参
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# GET /api/v1/savings/battle — 省钱战绩
|
||||
|
||||
> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`SavingsBattleOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `week_saved_cents` | int | 本周已省(分) |
|
||||
| `beat_percent` | int | 超过百分之多少用户 |
|
||||
| `streak_days` | int | 连续省钱天数 |
|
||||
|
||||
## 说明
|
||||
「我的」页「省钱战绩」卡数据源。接口已通但无真实业务写入,实际多为 0。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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`)数据源。
|
||||
@@ -0,0 +1,18 @@
|
||||
# GET /api/v1/savings/summary — 累计帮你省了
|
||||
|
||||
> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`SavingsSummaryOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `total_saved_cents` | int | 累计省下(分) |
|
||||
| `order_count` | int | 累计省钱订单数 |
|
||||
| `avg_saved_cents` | int | 平均每单省(分) |
|
||||
|
||||
## 说明
|
||||
「我的」页「累计帮你省了」卡数据源。当前比价上报未接入,实际多为 0。
|
||||
@@ -0,0 +1,22 @@
|
||||
# POST /api/v1/signin — 执行今日签到
|
||||
|
||||
> 所属:Signin 组(前缀 `/api/v1/signin`,本接口 POST 到前缀本身) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`SigninResultOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin_awarded` | int | 本次签到发放金币 |
|
||||
| `cycle_day` | int | 本次签到落在循环第几天 |
|
||||
| `streak` | int | 签到后的连续天数 |
|
||||
| `coin_balance` | int | 签到后金币余额 |
|
||||
|
||||
## 错误码
|
||||
- `409` 今日已签(`already signed today`)
|
||||
|
||||
## 说明
|
||||
发金币已计入 [wallet-account](./wallet-account.md) 的余额(客户端就地刷新即可,不必另叠)。
|
||||
@@ -0,0 +1,29 @@
|
||||
# GET /api/v1/signin/status — 今日签到状态 + 7 天档位
|
||||
|
||||
> 所属:Signin 组(前缀 `/api/v1/signin`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`SigninStatusOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `today_signed` | bool | 今日是否已签 |
|
||||
| `consecutive_days` | int | 当前连续签到天数 |
|
||||
| `today_cycle_day` | int | 今日处于循环内第几天(1..7) |
|
||||
| `today_coin` | int | 今日签到可得金币 |
|
||||
| `can_claim` | bool | 今日是否可领(= 未签) |
|
||||
| `steps` | SigninStepOut[] | 7 天档位 |
|
||||
|
||||
**SigninStepOut**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `day` | int | 循环内第几天 1..7 |
|
||||
| `coin` | int | 该档金币 |
|
||||
| `status` | string | `claimed`(已领) / `today`(今日待领) / `locked`(未到) |
|
||||
|
||||
## 说明
|
||||
福利页签到行 + 签到弹窗 7 档 timeline 数据源。
|
||||
@@ -0,0 +1,25 @@
|
||||
# POST /api/v1/tasks/{task_key}/claim — 领取任务奖励
|
||||
|
||||
> 所属:Tasks 组(前缀 `/api/v1/tasks`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参(path)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `task_key` | string | ✅ | 任务标识(见 [tasks-list](./tasks-list.md) 的 `task_key`) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`TaskClaimResultOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `task_key` | string | 任务标识 |
|
||||
| `coin_awarded` | int | 本次发放金币 |
|
||||
| `coin_balance` | int | 领奖后金币余额 |
|
||||
|
||||
## 错误码
|
||||
- `404` 未知任务(`unknown task`)
|
||||
- `409` 任务已领取(`task already claimed`)
|
||||
|
||||
## 说明
|
||||
发奖端 gate 由后端把控(如「打开消息提醒」须客户端确认权限已开后才调)。金币已计入余额。
|
||||
@@ -0,0 +1,20 @@
|
||||
# GET /api/v1/tasks — 任务及领取状态
|
||||
|
||||
> 所属:Tasks 组(前缀 `/api/v1/tasks`,本接口 GET 前缀本身) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`TaskListOut` = `{ items: TaskOut[] }`
|
||||
|
||||
**TaskOut**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `task_key` | string | 任务标识,如 `enable_notification`(打开消息提醒) |
|
||||
| `coin` | int | 完成可得金币 |
|
||||
| `claimed` | bool | 是否已领取 |
|
||||
|
||||
## 说明
|
||||
福利页一次性任务行(如「打开消息提醒」)的状态源。领取走 [tasks-claim](./tasks-claim.md)。
|
||||
@@ -0,0 +1,18 @@
|
||||
# GET /api/v1/wallet/account — 金币 + 现金余额(我的资产)
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`CoinAccountOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin_balance` | int | 当前金币余额 |
|
||||
| `cash_balance_cents` | int | 当前现金余额(分) |
|
||||
| `total_coin_earned` | int | 累计赚取金币 |
|
||||
|
||||
## 说明
|
||||
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。
|
||||
@@ -0,0 +1,28 @@
|
||||
# POST /api/v1/wallet/bind-wechat — 微信授权 code 换 openid 并绑定
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | 限流:同 IP ≤10 次/分 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(code 换 openid / userinfo)。
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `code` | string | ✅ | 微信授权返回的 code(客户端 OAuth 拿到) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`BindWechatResultOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `bound` | bool | 固定 `true` |
|
||||
| `wechat_nickname` | string \| null | 微信昵称 |
|
||||
| `wechat_avatar_url` | string \| null | 微信头像 URL |
|
||||
|
||||
## 错误码
|
||||
- `400` code 无效 / 换 openid 失败
|
||||
- `409` 该微信已绑定其他账号
|
||||
- `503` 微信支付未配置(`wxpay_configured=false`)
|
||||
|
||||
## 说明
|
||||
后端用 `code` 调微信 `jscode2session`(或 OAuth)换 `openid` 并写入 user。提现前置:必须先绑定。`openid` 全局唯一(DB 唯一约束),一个微信只能绑一个账号。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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`)数据源。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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`)数据源。
|
||||
@@ -0,0 +1,18 @@
|
||||
# GET /api/v1/wallet/exchange-info — 金币兑现金 汇率/规则
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:**无**(返回静态配置,不读用户) | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
无。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`ExchangeInfoOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin_per_yuan` | int | 多少金币兑 1 元 |
|
||||
| `min_coin` | int | 单次兑换最少金币 |
|
||||
| `step_coin` | int | 兑换金币需为该值的整数倍(对齐整分) |
|
||||
|
||||
## 说明
|
||||
给兑换页展示汇率与规则用。值来自 `app/core/rewards.py`(`COIN_PER_YUAN` / `MIN_EXCHANGE_COIN` / `COIN_PER_CENT`)。
|
||||
@@ -0,0 +1,26 @@
|
||||
# POST /api/v1/wallet/exchange — 金币兑现金
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `coin_amount` | int | ✅(>0) | 要兑换的金币数,须 ≥ `min_coin` 且为 `step_coin` 整数倍 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`ExchangeResultOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin_amount` | int | 本次扣除的金币 |
|
||||
| `cash_added_cents` | int | 本次兑入的现金(分) |
|
||||
| `coin_balance` | int | 兑换后金币余额 |
|
||||
| `cash_balance_cents` | int | 兑换后现金余额(分) |
|
||||
|
||||
## 错误码
|
||||
- `400` `coin_amount` 不满足 ≥ `min_coin` 且为 `step_coin` 整数倍
|
||||
- `409` 金币余额不足
|
||||
|
||||
## 说明
|
||||
兑换规则见 [wallet-exchange-info](./wallet-exchange-info.md)。金币与现金的扣加在同一事务内完成。
|
||||
@@ -0,0 +1,18 @@
|
||||
# POST /api/v1/wallet/unbind-wechat — 解绑微信(清空 openid)
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)。
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`UnbindWechatResultOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `bound` | bool | 固定 `false` |
|
||||
|
||||
## 说明
|
||||
清空当前用户的 `wechat_openid` / `wechat_nickname` / `wechat_avatar_url`。幂等:未绑定时调用也返回 `bound=false`。解绑后该微信可被其他账号绑定。
|
||||
@@ -0,0 +1,22 @@
|
||||
# GET /api/v1/wallet/withdraw-info — 提现额度 / 绑定状态
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(提现链路)。
|
||||
|
||||
## 入参
|
||||
无(用户由 token 确定)。
|
||||
|
||||
## 出参
|
||||
响应 `200`:`WithdrawInfoOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `min_cents` | int | 单次最低提现(分) |
|
||||
| `max_cents` | int | 单次最高提现(分) |
|
||||
| `wechat_bound` | bool | 是否已绑定微信 |
|
||||
| `wechat_nickname` | string \| null | 微信昵称(可能脱敏/空) |
|
||||
| `wechat_avatar_url` | string \| null | 微信头像 URL(可能空) |
|
||||
|
||||
## 说明
|
||||
提现页进入时拉一次:决定展示额度区间、是否需先去绑定微信。额度来自 `rewards.py`(`WITHDRAW_MIN_CENTS` / `WITHDRAW_MAX_CENTS`)。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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 | 发起时间 |
|
||||
|
||||
## 说明
|
||||
提现记录页数据源。
|
||||
@@ -0,0 +1,27 @@
|
||||
# GET /api/v1/wallet/withdraw/status — 查提现单状态(轮询)
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(查单 / 撤销转账)。
|
||||
|
||||
## 入参(query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `out_bill_no` | string | ✅ | 商户提现单号([withdraw](./wallet-withdraw.md) 返回的) |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`WithdrawStatusOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `out_bill_no` | string | 商户提现单号 |
|
||||
| `status` | string | `pending` / `success` / `failed` |
|
||||
| `wechat_state` | string \| null | 微信侧原始状态 |
|
||||
| `amount_cents` | int | 提现额(分) |
|
||||
|
||||
## 错误码
|
||||
- `404` 提现单不存在(或不属于当前用户)
|
||||
|
||||
## 说明
|
||||
App 从微信确认页返回后调此接口刷新单状态。**副作用**:若查时仍是 `WAIT_USER_CONFIRM`(用户在确认页放弃),后端会**撤销该单并退回余额**(`cancel_if_unconfirmed=True`)。
|
||||
@@ -0,0 +1,36 @@
|
||||
# POST /api/v1/wallet/withdraw — 发起提现到微信零钱
|
||||
|
||||
> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | 限流:同 IP ≤20 次/分 | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
|
||||
|
||||
## 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `amount_cents` | int | ✅(>0) | 提现金额(分),须落在 `[min_cents, max_cents]` |
|
||||
| `user_name` | string | ❌ | 实名(达额时微信商家转账要求,可空) |
|
||||
| `out_bill_no` | string | ❌ | **客户端幂等键(商户单号)**:同号重试不重复转账;不传则服务端生成 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`WithdrawResultOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `out_bill_no` | string | 商户提现单号(查单用) |
|
||||
| `status` | string | `pending` / `success` / `failed` |
|
||||
| `wechat_state` | string \| null | 微信侧原始状态 |
|
||||
| `amount_cents` | int | 本次提现额(分) |
|
||||
| `cash_balance_cents` | int | 提现后现金余额(分) |
|
||||
| `package_info` | string \| null | 拉起微信确认页用(`WXOpenBusinessView` requestMerchantTransfer) |
|
||||
| `mch_id` | string \| null | 商户号 |
|
||||
| `app_id` | string \| null | 微信 AppID |
|
||||
|
||||
## 错误码
|
||||
- `400` 金额越界 / 未绑定微信
|
||||
- `409` 现金余额不足
|
||||
- `502` 微信转账调用失败(余额已退回)
|
||||
- `503` 微信支付未配置
|
||||
|
||||
## 说明
|
||||
走微信「商家转账到零钱」V3。先扣余额建单再调微信,转账失败则退回余额。`out_bill_no` 幂等防止网络重试重复打款。返回的 `package_info`/`mch_id`/`app_id` 供 App 拉起微信确认页;确认结果由 [wallet-withdraw-status](./wallet-withdraw-status.md) 轮询。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 集成层(integrations)文档 — 索引
|
||||
|
||||
> 本目录记录 `app/integrations/` 下各**第三方 SDK / 外部服务**的集成内部实现:签名、加解密、协议细节、配置项、踩坑。
|
||||
> 与 [docs/api/](../api/README.md) 的区别:`docs/api/` 描述**对外 HTTP 接口契约**(入参/出参/错误码);本目录描述**接口背后调外部服务的重逻辑**。
|
||||
|
||||
## 架构约定
|
||||
- **`app/api/v1/` 接口层只放很轻的东西**:解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码映射。
|
||||
- **集成 SDK 的重逻辑(签名/验签/加解密/外部 HTTP 调用)一律放 `app/integrations/`**。换签名方案、换供应商只动这一层,api 层不受影响。
|
||||
- 集成模块统一从 `app.core.config.settings` 读配置;密钥/证书**懒加载**(文件缺失不在 import 时炸进程,只在真正调用时抛各自的 `XxxError`)。
|
||||
|
||||
## 集成清单
|
||||
|
||||
| 模块 | 文件 | 用途 | 关联 API 文档 |
|
||||
|---|---|---|---|
|
||||
| 极光 jiguang | `app/integrations/jiguang.py` | 一键登录服务端核验 + RSA 解密手机号 | [auth-jverify-login](../api/auth-jverify-login.md) |
|
||||
| 短信 sms | `app/integrations/sms.py` | 验证码发送 / 校验(**当前 mock**) | [auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) |
|
||||
| 美团 meituan | `app/integrations/meituan.py` | CPS 券查询 / 换推广链 | [meituan-coupons](../api/meituan-coupons.md) · [meituan-feed](../api/meituan-feed.md) · [meituan-referral-link](../api/meituan-referral-link.md) |
|
||||
| 穿山甲 pangle | `app/integrations/pangle.py` | 激励视频发奖回调**验签** | [ad-pangle-callback](../api/ad-pangle-callback.md) |
|
||||
| 微信支付 wxpay | `app/integrations/wxpay.py` | 商家转账到零钱(提现)+ code 换 openid(绑定) | [wallet-withdraw](../api/wallet-withdraw.md) 等 |
|
||||
|
||||
## 详情
|
||||
- [极光一键登录 jiguang](./jiguang.md)
|
||||
- [短信验证码 sms](./sms.md)
|
||||
- [美团 CPS meituan](./meituan.md)
|
||||
- [穿山甲发奖验签 pangle](./pangle.md)
|
||||
- [微信支付 / 提现 wxpay](./wxpay.md)
|
||||
@@ -0,0 +1,36 @@
|
||||
# 极光一键登录 — 服务端核验 + RSA 解密手机号(jiguang)
|
||||
|
||||
> 文件:`app/integrations/jiguang.py` | 关联接口:[auth-jverify-login](../api/auth-jverify-login.md) | [← 集成索引](./README.md)
|
||||
|
||||
## 作用
|
||||
极光一键登录的服务端环节:拿客户端的 `loginToken` 调极光 REST 验真,取回 RSA 加密的手机号并解密成明文。
|
||||
|
||||
## 链路
|
||||
```
|
||||
Android SDK loginAuth → loginToken
|
||||
→ 本服务 POST 极光 /v1/web/loginTokenVerify (Basic Auth = AppKey:MasterSecret)
|
||||
→ 极光返回 RSA 公钥加密的手机号 (base64)
|
||||
→ 用配套 RSA 私钥解密
|
||||
→ 返回明文手机号
|
||||
```
|
||||
|
||||
## 函数 / 异常
|
||||
- `verify_and_get_phone(login_token)` —— 对外入口,验证 + 解密一把梭。
|
||||
- 失败统一抛 `JiguangError`,api 层 catch 后翻成 4xx/5xx(验证失败/解密失败 → `502`)。
|
||||
- RSA 私钥**懒加载并缓存**(`_private_key_cache`):文件缺失抛 `JiguangError`(让单请求失败 + 日志,而不是 import 时炸进程)。
|
||||
|
||||
## RSA 解密策略(关键)
|
||||
极光文档没明说 padding。实测 PKCS1v15 套在 OAEP 密文上会"假成功"返回垃圾(`UnicodeDecodeError 0x8e`)。因此按 **OAEP-SHA1 → OAEP-SHA256 → PKCS1v15** 顺序尝试,谁能解出 **11 位纯数字手机号**就用谁;base64 先按 4 字节对齐补 `=`(各运营商可能返回不带 `=` 的 base64)。
|
||||
|
||||
## 配置
|
||||
| 配置项 | 说明 |
|
||||
|---|---|
|
||||
| `JG_APP_KEY` / `JG_MASTER_SECRET` | 极光 AppKey / MasterSecret(拼 Basic Auth) |
|
||||
| `JG_PRIVATE_KEY_PATH` | RSA 私钥 .pem 路径 |
|
||||
| `JG_VERIFY_ENDPOINT` | 极光验证接口 URL(`/v1/web/loginTokenVerify`) |
|
||||
| `JG_REQUEST_TIMEOUT_SEC` | 请求超时 |
|
||||
|
||||
## 踩坑
|
||||
- **私钥必须与极光控制台为该 appkey 登记的公钥配对**,否则三种 padding 全失败、报 `all RSA paddings failed; last error: Decryption failed`。私钥由相关开发同学生成提供。
|
||||
- **换私钥后必须重启后端**——`_private_key_cache` 会缓存旧 key。
|
||||
- 极光验证响应 `code != 8000` 视为失败;缺 `phone` 字段也失败。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 美团联盟 CPS 开放接口(meituan)
|
||||
|
||||
> 文件:`app/integrations/meituan.py` | 关联接口:[meituan-coupons](../api/meituan-coupons.md) · [meituan-feed](../api/meituan-feed.md) · [meituan-referral-link](../api/meituan-referral-link.md) | [← 集成索引](./README.md)
|
||||
|
||||
## 作用
|
||||
调美团联盟 CPS 开放接口:查券(列表/搜索)+ 换推广链。
|
||||
|
||||
## 函数 / 异常
|
||||
| 函数 | 说明 |
|
||||
|---|---|
|
||||
| `query_coupon(...)` | 券查询/搜索 → `/cps_open/common/api/v1/query_coupon` |
|
||||
| `get_referral_link(...)` | 换推广链 → `/cps_open/common/api/v1/get_referral_link` |
|
||||
| 异常 `MeituanCpsError` | HTTP 错误 / 业务 `code != 0`,api 层翻成 `502` |
|
||||
|
||||
## 签名机制(关键)
|
||||
类阿里云网关 **S-Ca** 头,但 `stringToSign` **只有 4 段**:
|
||||
```
|
||||
METHOD\n + Content-MD5\n + Headers(按 key 升序 "k:v\n") + Url
|
||||
```
|
||||
**没有 Accept / Content-Type / Date 行**(加了就签名失败)。签名 = HMAC-SHA256(stringToSign, AppSecret) 的 base64。参与签名的头:`S-Ca-Timestamp,S-Ca-App`(在 `S-Ca-Signature-Headers` 里声明)。
|
||||
|
||||
## 入参换算 / 坑
|
||||
- 经纬度 **× 1_000_000 取整**传给美团。
|
||||
- 榜单字段是 **`listTopiId`**(美团接口的拼写,少个 `c`,别"纠正"成 `listTopicId`)。
|
||||
- 有 `searchText` 时走搜索、`sort_field` 默认 6(离我最近);否则按 `listTopiId` 出榜单。
|
||||
- 换链 `sid` 不传则用 `MT_CPS_DEFAULT_SID`;`linkTypeList` 默认 `[1,3]`。
|
||||
|
||||
## 配置
|
||||
| 配置项 | 说明 |
|
||||
|---|---|
|
||||
| `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` | CPS 网关 AppKey / AppSecret |
|
||||
| `MT_CPS_HOST` | 网关 host |
|
||||
| `MT_CPS_DEFAULT_SID` | 默认推广位 sid(渠道) |
|
||||
| `MT_CPS_TIMEOUT_SEC` | 请求超时 |
|
||||
|
||||
## 备注
|
||||
美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 穿山甲激励视频发奖回调 — 验签(pangle)
|
||||
|
||||
> 文件:`app/integrations/pangle.py` | 关联接口:[ad-pangle-callback](../api/ad-pangle-callback.md) | [← 集成索引](./README.md)
|
||||
|
||||
## 作用
|
||||
穿山甲激励视频播完后,穿山甲**服务器**会 GET 回调我们的 `/api/v1/ad/pangle-callback`(带 `user_id` / `trans_id` / `sign` 等 query)。本模块负责**验签**,确认回调真来自穿山甲而非伪造请求;验签通过后由数据层(`repositories/ad_reward`)幂等发金币。客户端不参与发奖,被破解也刷不到钱。
|
||||
|
||||
## 验签方案(关键)
|
||||
**`sign = SHA256("{m-key}:{trans_id}")` 的十六进制串。**
|
||||
|
||||
- 是**普通 SHA256**,不是 HMAC,也不是 RSA。
|
||||
- **只对 `m-key:trans_id` 这一个字符串**签名,`user_id` / `reward_amount` 等其余参数**不参与**签名。它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 连合法 sign 都造不出,自然无法注入假 `user_id`。
|
||||
- 这是穿山甲 **GroMore「广告位 → 服务端激励回调」官方规范**(supportcenter/26240)。我们客户端是 `useMediation(true)` 融合,回调走 **GroMore 广告位层级**,不是联盟代码位层级。
|
||||
|
||||
## 函数
|
||||
| 函数 | 说明 |
|
||||
|---|---|
|
||||
| `build_sign(trans_id, secret) -> str` | 计算 `SHA256("{secret}:{trans_id}")` hex。自验签测试 / 模拟回调脚本共用,保证两端一致 |
|
||||
| `verify_callback_sign(params, secret) -> bool` | 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 |
|
||||
|
||||
## 配置
|
||||
| 配置项 | 说明 |
|
||||
|---|---|
|
||||
| `PANGLE_REWARD_SECRET` | m-key(安全密钥)。后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取 |
|
||||
|
||||
## 踩坑
|
||||
- **别用联盟代码位那套**:联盟代码位层级用的是另一套 Security Key + `isValid` 响应体,与 GroMore 广告位层级不通用。我们走 GroMore,别接错。
|
||||
- 验签失败时 api 层返 `403`(留给真请求重试);验签过但参数缺/坏或 user 不存在,则受理不发奖、不让穿山甲重试。
|
||||
- 本地无公网、回调打不到时,用 [ad-test-grant](../api/ad-test-grant.md) 模拟(仅联调,开关控制)。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 短信验证码(sms)
|
||||
|
||||
> 文件:`app/integrations/sms.py` | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md)
|
||||
|
||||
## 作用
|
||||
手机号 + 验证码登录的验证码发送 / 校验。**当前是 mock 模式**(占坑),未接真实短信供应商。
|
||||
|
||||
## 函数 / 异常
|
||||
| 函数 | 行为 |
|
||||
|---|---|
|
||||
| `send_code(phone) -> int` | mock 下不真发、只 log;返回距下次可发的秒数。进程内存表 `_last_sent` 记 `phone → 发送时间`,`SMS_SEND_INTERVAL_SEC` 内再发抛 `SmsError`(发送过频)。真供应商未接时抛 `SmsError("sms provider not configured")` |
|
||||
| `verify_code(phone, code) -> bool` | mock 下**任意 6 位数字**均通过(配合前台 demo);真模式未实现返回 `False` |
|
||||
| 异常 `SmsError` | 发送过频 / 验证码不对,api 层 catch 后翻成 4xx(过频 `429`) |
|
||||
|
||||
## 配置
|
||||
| 配置项 | 说明 |
|
||||
|---|---|
|
||||
| `SMS_MOCK` | 是否 mock(当前 `true`) |
|
||||
| `SMS_SEND_INTERVAL_SEC` | 同号两次发送最小间隔(防过频) |
|
||||
|
||||
## 后续(接真实供应商时)
|
||||
- `send_code()` 改调供应商 API(阿里云 / 腾讯云),把生成的 6 位码存 cache(redis / sqlite)。
|
||||
- `verify_code()` 比对 cache 里的码,且**验过即作废**。
|
||||
- 进程内存方案占坑期够用;切真实供应商时一并切 redis(多进程/多机时内存表不共享,频控会失效)。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 微信支付 V3 — 商家转账到零钱(提现)+ code 换 openid(wxpay)
|
||||
|
||||
> 文件:`app/integrations/wxpay.py` | 关联接口:[wallet-withdraw](../api/wallet-withdraw.md) · [wallet-withdraw-status](../api/wallet-withdraw-status.md) · [wallet-bind-wechat](../api/wallet-bind-wechat.md) · [wallet-withdraw-info](../api/wallet-withdraw-info.md) | [← 集成索引](./README.md)
|
||||
|
||||
## 作用
|
||||
两件事:①**提现**——微信支付 V3「商家转账到零钱」;②**绑定**——微信授权 code 换 openid / 昵称头像。移植自 elderhelper,密钥**懒加载**(文件缺失不在 import 时炸,只在真正调用时抛 `WxPayNotConfiguredError`,与极光私钥同款策略)。
|
||||
|
||||
## 函数
|
||||
| 函数 | 说明 |
|
||||
|---|---|
|
||||
| `create_transfer(openid, amount_fen, out_bill_no, user_name=None)` | 发起商家转账到零钱。返回 `{status_code, data}` |
|
||||
| `query_transfer(out_bill_no)` | 按商户单号查转账单状态 |
|
||||
| `cancel_transfer(out_bill_no)` | 撤销转账单(仅 `WAIT_USER_CONFIRM`/`ACCEPTED` 可撤)。用户没在确认页确认就离开时调 |
|
||||
| `encrypt_sensitive(plain)` | 用微信支付平台公钥 OAEP(SHA1) 加密敏感信息(实名) |
|
||||
| `code_to_openid(code)` | code 换 openid(`sns/oauth2`),失败抛 `ValueError` |
|
||||
| `code_to_userinfo(code)` | code 换 openid + 拉昵称头像,返回 `{openid, nickname, avatar_url, raw}` |
|
||||
|
||||
## 签名机制
|
||||
- 请求签名:**商户 API 私钥** RSA-SHA256/PKCS1v15,构造 V3 `Authorization` 头(`WECHATPAY2-SHA256-RSA2048`,含 mchid/nonce/timestamp/serial_no/signature)。
|
||||
- 敏感字段(实名):微信支付**平台公钥** OAEP(SHA1) 加密,请求头带 `Wechatpay-Serial`。
|
||||
- 实名传递阈值:`amount_fen >= 30`(沿用 elderhelper,实际以微信侧要求为准)才加密带 `user_name`。
|
||||
- 转账接口路径:`/v3/fund-app/mch-transfer/transfer-bills`,host `https://api.mch.weixin.qq.com`。
|
||||
|
||||
## 配置
|
||||
| 配置项 | 说明 |
|
||||
|---|---|
|
||||
| `WECHAT_APP_ID` / `WECHAT_APP_SECRET` | 微信开放平台移动应用 appid / secret(换 openid 用) |
|
||||
| `WXPAY_MCH_ID` / `WXPAY_MCH_SERIAL_NO` | 商户号 / 商户证书序列号 |
|
||||
| `WXPAY_MCH_PRIVATE_KEY_PATH` | 商户 API 私钥 .pem 路径 |
|
||||
| `WXPAY_PUBLIC_KEY_PATH` / `WXPAY_PUBLIC_KEY_ID` | 微信支付平台公钥 .pem 路径 / 公钥 ID |
|
||||
| `WXPAY_TRANSFER_SCENE_ID` | 转账场景 ID |
|
||||
| `WXPAY_REQUEST_TIMEOUT_SEC` | 请求超时 |
|
||||
|
||||
## 踩坑
|
||||
- **userinfo 可能脱敏**:微信隐私新政下 `sns/userinfo` 可能返回昵称「微信用户」/灰头像,`nickname`/`avatar_url` 可能为空,调用方需兜底(绑定不因此失败,openid 已拿到)。
|
||||
- `cancel_transfer` 是提现「轮询查单时若仍 `WAIT_USER_CONFIRM`」的退款依据(见 [wallet-withdraw-status](../api/wallet-withdraw-status.md) 的副作用)。
|
||||
- 凭证/证书未配置时所有转账类调用抛 `WxPayNotConfiguredError`,api 层翻成 `503`。
|
||||
+43
-18
@@ -41,44 +41,69 @@
|
||||
|
||||
## 3. 分层架构与目录结构
|
||||
|
||||
标准 FastAPI 分层,一个请求自上而下穿过:**api(薄,收发) → integrations(外部) / repositories(数据) → models / db**。
|
||||
标准 FastAPI 分层,一个请求自上而下穿过:**api(薄,收发) → integrations(外部 SDK) / repositories(数据) → models / db**。重逻辑(SDK 签名/加解密/外部 HTTP)落 `integrations`,集成层细节见 [docs/integrations/](./integrations/README.md)。
|
||||
|
||||
```
|
||||
app/
|
||||
├── main.py # FastAPI 入口:注册 3 个 router、CORS、/health、lifespan
|
||||
├── main.py # FastAPI 入口:注册全部 router、CORS、/health、lifespan
|
||||
├── api/
|
||||
│ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session)
|
||||
│ └── v1/
|
||||
│ ├── auth.py # 登录 6 个端点
|
||||
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,唯一需鉴权的业务接口)
|
||||
│ └── meituan.py # 美团 3 个端点 + feed 拼接逻辑(_interleave / _TOPIC_ROUNDS)
|
||||
│ └── v1/ # 接口层(薄):解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码
|
||||
│ ├── auth.py # 登录 6 端点(极光一键登录 / 短信 send+login / refresh / me / logout)
|
||||
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot)
|
||||
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS)
|
||||
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
|
||||
│ ├── signin.py # 签到 2 端点(状态 / 执行签到)
|
||||
│ ├── tasks.py # 一次性任务 2 端点(列表 / 领取)
|
||||
│ ├── savings.py # 省钱 3 端点(汇总 / 战绩 / 明细)
|
||||
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度 / 联调发奖)
|
||||
├── schemas/ # Pydantic:API 收发的数据契约(与客户端对齐字段看这里)
|
||||
│ ├── auth.py
|
||||
│ └── meituan.py
|
||||
├── integrations/ # 外部服务客户端(2026-05 从 core 拆出)
|
||||
│ ├── meituan.py
|
||||
│ ├── welfare.py # 钱包/签到/任务/省钱 收发模型
|
||||
│ └── ad.py # 看广告发奖收发模型
|
||||
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
|
||||
│ ├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
|
||||
│ ├── meituan.py # 美团 CPS 网关签名 + query_coupon / get_referral_link
|
||||
│ └── sms.py # 短信验证码(mock,进程内存冷却表)
|
||||
├── core/ # 基础设施(仅保留)
|
||||
│ ├── sms.py # 短信验证码(mock,进程内存冷却表)
|
||||
│ ├── pangle.py # 穿山甲激励视频发奖回调验签(SHA256,2026-05 从 core 移入)
|
||||
│ └── wxpay.py # 微信支付 V3 商家转账(提现)+ code 换 openid(2026-05 从 core 移入)
|
||||
├── core/ # 基础设施(无外部业务集成)
|
||||
│ ├── config.py # pydantic-settings
|
||||
│ ├── security.py # JWT 签发/校验
|
||||
│ ├── ratelimit.py # 同 IP 滑动窗口限流依赖
|
||||
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算
|
||||
│ └── logging.py
|
||||
├── repositories/ # 数据访问(2026-05 由 crud 改名)
|
||||
│ └── user.py # get_user_by_id / get_user_by_phone / upsert_user_for_login
|
||||
├── models/user.py # ORM:user 表结构
|
||||
├── repositories/ # 数据访问 + 事务(早期叫 crud,2026-05 统一并入此目录)
|
||||
│ ├── user.py # get_user_by_id / get_user_by_phone / upsert_user_for_login
|
||||
│ ├── wallet.py # 账户/流水/兑换/提现单(调 integrations/wxpay)
|
||||
│ ├── signin.py # 签到记录 / 连续天数 / 档位
|
||||
│ ├── task.py # 一次性任务领取
|
||||
│ ├── savings.py # 省钱汇总 / 战绩 / 明细
|
||||
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限)
|
||||
├── models/ # ORM 表结构
|
||||
│ ├── user.py # user(含微信 openid/nickname/avatar)
|
||||
│ ├── wallet.py # 金币账户 / 金币流水 / 现金流水 / 提现单
|
||||
│ ├── signin.py # 签到记录
|
||||
│ ├── task.py # 任务领取记录
|
||||
│ ├── savings.py # 省钱明细 / 店铺菜品
|
||||
│ └── ad_reward.py # 看广告发奖记录
|
||||
└── db/
|
||||
├── base.py # DeclarativeBase
|
||||
└── session.py # engine + get_db
|
||||
|
||||
alembic/ # 数据库迁移(versions/ 目前仅 init_user_table)
|
||||
alembic/ # 数据库迁移(versions/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
|
||||
deploy/ # systemd(.service) + nginx(.conf)
|
||||
secrets/ # 极光 RSA 私钥(不入 git,仅 .gitkeep 占位)
|
||||
tests/ # pytest(conftest + test_auth + test_health)
|
||||
run.sh # 本地启动脚本
|
||||
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git,仅 .gitkeep 占位)
|
||||
scripts/ # 运维脚本(migrate 迁移 / 对账 / 重置签到 / 重置福利 / 模拟穿山甲回调)
|
||||
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy)
|
||||
run.sh # 本地启动脚本(自动先跑迁移再起服务)
|
||||
docs/api/ # API 接口文档(索引 README + 一接口一文件)
|
||||
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
|
||||
docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
|
||||
```
|
||||
|
||||
> **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部集成、`repositories` 装数据访问,与 `core`(基础设施)分离。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
|
||||
> **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# 数据库迁移指南(Alembic)
|
||||
|
||||
> 本项目用 **Alembic** 管理数据库表结构。所有"建表/改表"都写成 `alembic/versions/` 下的迁移脚本,
|
||||
> 数据库的真实结构 = 把这些迁移按顺序跑一遍的结果。**不要手动改库结构**,一律走迁移。
|
||||
|
||||
---
|
||||
|
||||
## 一句话
|
||||
**clone 项目 / 拉到新迁移后,跑一条命令即可:**
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
它会把数据库结构升到最新(幂等,已是最新则什么都不做)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 首次初始化(clone 后)
|
||||
```bash
|
||||
# (1) 装依赖(在你的虚拟环境里)
|
||||
pip install -e .
|
||||
|
||||
# (2) 配 .env(从模板复制,至少填 JWT_SECRET_KEY)
|
||||
cp .env.example .env
|
||||
|
||||
# (3) SQLite 默认库需要 data/ 目录存在
|
||||
mkdir -p data
|
||||
|
||||
# (4) 跑迁移建表 —— 关键
|
||||
alembic upgrade head
|
||||
|
||||
# (5) 启动(run.sh 会自动重跑 3+4,所以平时直接 ./run.sh 也行)
|
||||
./run.sh
|
||||
```
|
||||
> 也可以直接 `bash scripts/migrate.sh` 只做迁移、不启服务(部署/CI 用)。
|
||||
|
||||
## 2. 数据库地址从哪来
|
||||
- 由 `settings.DATABASE_URL` 决定(`app/core/config.py`),可在 `.env` 用 `DATABASE_URL=` 覆盖。
|
||||
- 默认 **SQLite**:`sqlite:///./data/app.db`(所以要先 `mkdir -p data`)。
|
||||
- 切 **PostgreSQL**:`.env` 里设 `DATABASE_URL=postgresql+psycopg://user:pwd@host:5432/dbname`,再 `alembic upgrade head` 即可(迁移脚本用了 `render_as_batch` 兼容 SQLite,Postgres 也能跑)。
|
||||
- `alembic.ini` 的 `sqlalchemy.url` 故意留空,由 `alembic/env.py` 从 `settings` 注入,**别在 alembic.ini 里填库地址**。
|
||||
|
||||
## 3. 常用命令
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `alembic upgrade head` | 升到最新(最常用) |
|
||||
| `alembic current` | 看当前库在哪个 revision |
|
||||
| `alembic history` | 看迁移链(谁接谁) |
|
||||
| `alembic heads` | 看有几个 head(正常只 1 个;出现多个 = 分叉了要 merge) |
|
||||
| `alembic downgrade -1` | 回滚一步(**生产慎用**,会丢数据) |
|
||||
| `alembic downgrade <revision>` | 回滚到指定版本 |
|
||||
|
||||
## 4. 改了 ORM model、要新增一次结构变更
|
||||
```bash
|
||||
# 1) 自动对比 models 与库,生成迁移草稿
|
||||
alembic revision --autogenerate -m "add xxx index"
|
||||
# 2) **务必打开生成的文件 review**(autogenerate 不完美,索引/默认值/数据迁移常需手改)
|
||||
# 3) 应用
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关于迁移文件名(重要)
|
||||
- 文件名是 `<描述>.py`(如 `welfare_tables_coin_account_coin_txn.py`)。**早期带的 hex 前缀已去掉,只是文件名变干净**。
|
||||
- **真正的版本 id 是文件内的 `revision` 变量**(如 `revision = '357520ea3015'`),迁移链靠文件内的 `down_revision` 串起来,**与文件名无关**。数据库 `alembic_version` 表里存的也是这个内部 id。
|
||||
- 因此:**可以随意重命名迁移文件**(Alembic 靠扫描文件、读 `revision` 识别),但 **绝不要改文件内的 `revision` / `down_revision`**——改了会和已迁移过的库(含生产)对不上,报"找不到 revision"。
|
||||
- ⚠️ `alembic revision --autogenerate` **默认仍生成带 hex 前缀的文件名**。想保持本目录"无前缀"风格,二选一:
|
||||
- 生成后**手动把文件名前缀去掉**(内部 id 别动);或
|
||||
- 在 `alembic.ini` 配 `file_template = %%(slug)s`(注意 ini 里 `%` 要写成 `%%`)让以后直接生成无前缀文件名(缺点:同名 slug 会冲突,描述写具体点)。
|
||||
|
||||
## 6. 当前迁移链(9 条)
|
||||
```
|
||||
init_user_table ← 起点(down_revision=None)
|
||||
→ welfare_tables_coin_account_coin_txn 金币账户/金币流水/签到/任务表
|
||||
→ cash_transaction_table 现金流水表
|
||||
→ savings_record_table 省钱明细表
|
||||
→ withdraw_order_and_user_openid 提现单表 + user.wechat_openid
|
||||
→ user_wechat_nickname_avatar user 微信昵称/头像字段
|
||||
→ unique_wechat_openid user.wechat_openid 唯一约束
|
||||
→ savings_shop_dishes 省钱明细 shop_name + dishes
|
||||
→ ad_reward_record 看广告发奖记录表 (head,最新)
|
||||
```
|
||||
> 顺序以 `alembic history` 输出为准(按 `down_revision` 链,不是文件名字母序)。
|
||||
|
||||
## 7. 踩坑
|
||||
- **SQLite 找不到目录**:报 `unable to open database file` → 先 `mkdir -p data`。
|
||||
- **多个 head**:`alembic heads` 出现 2 个 → 有人并行各加了一条迁移分叉,需 `alembic merge -m "merge" <head1> <head2>` 合并。
|
||||
- **生产库**:不要随意 `downgrade`;新迁移先在本地/测试库验证过再上。生产 `DATABASE_URL` 由部署侧配置,不在本仓库 `.env`。
|
||||
@@ -0,0 +1,80 @@
|
||||
# 「看广告赚金币」上线 Checklist
|
||||
|
||||
> 跨两个仓库:`shaguabijia-app-server`(后端发奖)+ `shaguabijia-app-android`(客户端看广告)。
|
||||
> 截至 2026-05-26:**本地 + debug 包已端到端验证通过**(出广告 → 发奖 → 金币到账 → 明细显示「看广告奖励」),
|
||||
> 但**尚未在生产上线**。本文件记录上线前必须做的事,尤其是一堆"仅本地/debug 用、上线前必须清理"的脚手架。
|
||||
|
||||
## 设计要点(别改坏)
|
||||
- **发奖只走服务端**:激励视频播完,穿山甲服务器 S2S 回调后端 `/api/v1/ad/pangle-callback` 发金币。
|
||||
客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。
|
||||
- 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。
|
||||
- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)。
|
||||
|
||||
---
|
||||
|
||||
## A. ⚠️ 上线前必须清理的"测试/调试专用"脚手架
|
||||
|
||||
这些是为了"没部署公网也能本地验证"加的,**带到生产 = 体验差 / 绕过反作弊 / 引入废弃工具**,务必清理。
|
||||
|
||||
### A1. 后端:本地模拟发奖接口
|
||||
- [ ] `.env` 删除 / 设为 false:`AD_REWARD_TEST_GRANT_ENABLED=false`(默认就是 false;它一开,已登录客户端就能自助发奖)
|
||||
- [ ] (可选)确认生产 `.env` 没这行;`/api/v1/ad/test-grant` 在 false 时返回 404
|
||||
- 涉及:`app/api/v1/ad.py` 的 `test_grant`、`app/core/config.py` 的 `AD_REWARD_TEST_GRANT_ENABLED`
|
||||
|
||||
### A2. 客户端:test-grant 调用
|
||||
- [ ] `WelfareViewModel.onAdRewardEarned()` 里 `if (BuildConfig.DEBUG) { repo.adTestGrant() }` —— release 包(`BuildConfig.DEBUG=false`)本就跳过,**确认 release 构建不含该调用即可**(结构上已隔离,无需改码;打 release 包验一次)
|
||||
- 涉及:`WelfareViewModel.kt`、`WelfareRepository.adTestGrant()`、`WelfareApi.adTestGrant`、`TestGrantDto`(留着无害,只要 release 不调用 + 后端 404)
|
||||
|
||||
### A3. 客户端:穿山甲测试工具(广告预览)
|
||||
- [ ] `app/build.gradle.kts`:移除 `debugImplementation(files("libs/tools-release.aar"))`
|
||||
- [ ] 删除 `app/libs/tools-release.aar`
|
||||
- [ ] `gradle.properties`:移除 `android.enableJetifier=true`(仅为兼容 tools-release.aar 的旧 support 库而开,Jetifier 已废弃)
|
||||
- [ ] 删除 `app/src/debug/.../ad/TestToolLauncher.kt` + `app/src/release/.../ad/TestToolLauncher.kt`(空桩)
|
||||
- [ ] `WelfareScreen.kt`:移除「【调试】唤起穿山甲测试工具」入口(`if (BuildConfig.DEBUG){...}` 那段)+ `TestToolLauncher` import
|
||||
- 注:这些都 debug-only,不影响 release 功能;清掉是为了不留废弃依赖
|
||||
|
||||
---
|
||||
|
||||
## B. 生产真正发奖链路(S2S)—— 阻塞于穿山甲后台
|
||||
|
||||
> 已确认走 **GroMore 广告位层级**回调(客户端 useMediation(true);规范 supportcenter/26240),
|
||||
> **不是**联盟代码位层级(5416)。验签算法 / 响应格式 / reward_amount 解析**代码已按 GroMore 规范实现**
|
||||
> (2026-05-27),剩下的是后台配置 + 填密钥。
|
||||
|
||||
- [ ] **GroMore 后台配回调**:GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」→
|
||||
回调 URL 填 `https://app-api.shaguabijia.com/api/v1/ad/pangle-callback`(用域名,别用 IP)。
|
||||
⚠️ 广告位层级配了就**别再在代码位层级重复配**(会导致发奖出问题)。
|
||||
- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取,填到生产 `.env` 的 `PANGLE_REWARD_SECRET`。
|
||||
(注:这是 GroMore 广告位的 m-key,**不是**联盟代码位那个 Security Key。)
|
||||
- [x] ~~换验签~~:`app/integrations/pangle.py` 已实现 `sign = SHA256("{m-key}:{trans_id}")`(GroMore 真实算法)。
|
||||
- [x] ~~响应格式~~:已返回 GroMore 要求的 `{"is_verify": bool, "reason": int}`。
|
||||
- [x] ~~reward_amount~~:回调按 `reward_amount` 发金币(`rewards.resolve_ad_reward_coin`,带回退/夹紧);
|
||||
**后台广告位"奖励数量"须配成与 `AD_REWARD_COIN`(=100)一致**,保证"广告内展示/进度预告/到账"三者一致。
|
||||
- [x] ~~透传 user_id~~:客户端已 `setUserID(userId)` + `setMediaExtra("uid:...")`。
|
||||
- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + `PANGLE_REWARD_SECRET=<m-key>`(配齐才不返 503)
|
||||
|
||||
## C. 部署 + 包名
|
||||
|
||||
- [ ] **后端部署到公网**(由服务器管理员;`/opt/shaguabijia-app-server`,uvicorn 127.0.0.1:8770,nginx 反代)
|
||||
- [ ] **跑迁移**:`alembic upgrade head`(建 `ad_reward_record` 表,迁移 `c8d9e0f1a2b3`)
|
||||
- [ ] **包名定稿**:当前 `com.jishisongfu.shaguabijia`。穿山甲(APP_ID 5830519)、极光、微信都绑"包名 + 签名",定了再上,别再换
|
||||
- 微信提现链路当前因复用 elderhelper 的 appid + 包名切换已 dead,要恢复需申请傻瓜比价自己的微信 appid(另见客户端 build.gradle 注释)
|
||||
- [ ] (可选,提升真实填充)集成 **MSA OAID SDK**:申请证书(绑包名、审核几天)。App 侧当前 `getDevOaid=null`,有 OAID 后投放匹配 + 填充会明显改善
|
||||
|
||||
## D. 提现对账(顺带,与本功能独立但同属上线必做)
|
||||
|
||||
- [ ] 生产给 `scripts/reconcile_withdraws.py` 挂 **cron / systemd timer**(每 5~10 分钟跑一次)
|
||||
- 否则"扣款后微信无回调"的孤儿提现单没人兜底退款(客户端已加 ON_RESUME 无条件查单,但 cron 是最后防线)
|
||||
|
||||
---
|
||||
|
||||
## E. 端到端验收(生产链路通后)
|
||||
|
||||
- [ ] 真机看完一条**真实**激励视频 → 穿山甲 S2S 回调 → 后端发奖 → 客户端余额真到账
|
||||
- [ ] **幂等**:同一 `trans_id` 不重复发
|
||||
- [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」
|
||||
- [ ] 中途退出广告 → 客户端提示「未看完视频,本次没有奖励哦」,且不发奖
|
||||
- [ ] 收益明细显示「看广告奖励」(biz_type=`ad_reward`)
|
||||
|
||||
## 关键阻塞链
|
||||
B(拿验签方案/配回调)→ E(端到端验收);A(清理)可随时做;C(部署)与 B 并行。
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# 只做数据库迁移(不启服务)。部署 / CI / "只想把表建好"时用。
|
||||
# 用法: bash scripts/migrate.sh
|
||||
# 启动服务用 ./run.sh(它会自动先跑迁移)。详见 docs/数据库迁移.md
|
||||
set -e
|
||||
cd "$(dirname "$0")/.." # 切到项目根(alembic.ini 所在)
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "❌ 缺 .env:先 cp .env.example .env 并填值(至少 JWT_SECRET_KEY)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p data # SQLite 库文件所在目录(用 Postgres 时无害)
|
||||
echo "→ alembic upgrade head"
|
||||
alembic upgrade head # 幂等:已是最新则 no-op
|
||||
echo "✅ 数据库已升到最新 (alembic current 可查看当前版本)"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""提现对账(#4 孤儿单兜底)。
|
||||
|
||||
扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化:
|
||||
- 微信 SUCCESS → 置 success
|
||||
- 微信失败/取消/关闭/无此单 → 退回现金 + 置 failed
|
||||
- WAIT_USER_CONFIRM(久未确认)→ 撤销 + 退款
|
||||
防止"扣了款但转账没发起/没确认"的单永久锁住用户余额。
|
||||
|
||||
用法(建议 cron / systemd timer 每 5~10 分钟跑一次):
|
||||
python -m scripts.reconcile_withdraws # 默认处理 >15 分钟的 pending
|
||||
python -m scripts.reconcile_withdraws 30 # 处理 >30 分钟的 pending
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
# Windows 控制台按 UTF-8 输出中文/¥
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
|
||||
def main() -> None:
|
||||
minutes = int(sys.argv[1]) if len(sys.argv) > 1 else 15
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = crud_wallet.reconcile_pending_withdraws(db, older_than_minutes=minutes)
|
||||
print(f"对账完成: 检查 {result['checked']} 笔 pending(>{minutes}分钟), 归结 {result['resolved']} 笔")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""清空指定用户的签到记录,方便反复测试"未签到→自动弹窗→签到领金币"。
|
||||
|
||||
默认**只删 signin_record**(签到历史),不动金币/现金——这样能保留你已注入/兑换的余额,
|
||||
只把"今天是否已签到 / 连续天数"重置掉。删后当天即变成"未签到",再进福利页会重新自动弹签到弹窗。
|
||||
|
||||
⚠️ 注意:只删签到记录的话,之前签到已发的金币仍留在余额和 coin_transaction 里(biz_type='signin')。
|
||||
如果想把签到发的金币也一并退掉(余额减回、删 signin 流水),加 --with-coins。
|
||||
|
||||
用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑):
|
||||
python scripts/reset_signin.py # 只清 user_id=2 的签到记录
|
||||
python scripts/reset_signin.py 5 # 清 user_id=5
|
||||
python scripts/reset_signin.py --all # 清所有用户的签到记录
|
||||
python scripts/reset_signin.py --with-coins # 连签到发的金币一起退回(余额减、删 signin 流水)
|
||||
python scripts/reset_signin.py --dry-run # 只看现状,不改
|
||||
|
||||
DB 默认取 <项目根>/data/app.db,可用 --db 指定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
# 本脚本在 scripts/ 下,项目根是上一级
|
||||
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
|
||||
|
||||
|
||||
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
|
||||
"""返回签到记录数 + 签到金币流水数/总额 + 账户金币余额,用于打印 before/after。"""
|
||||
signin_rows = cur.execute(
|
||||
f"select count(*) from signin_record where {where}", params
|
||||
).fetchone()[0]
|
||||
signin_coin_rows, signin_coin_sum = cur.execute(
|
||||
f"select count(*), coalesce(sum(amount), 0) from coin_transaction "
|
||||
f"where biz_type='signin' and {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
accounts = cur.execute(
|
||||
f"select user_id, coin_balance, total_coin_earned from coin_account where {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
return {
|
||||
"signin_rows": signin_rows,
|
||||
"signin_coin_rows": signin_coin_rows,
|
||||
"signin_coin_sum": signin_coin_sum,
|
||||
"accounts": accounts,
|
||||
}
|
||||
|
||||
|
||||
def print_snapshot(label: str, snap: dict) -> None:
|
||||
print(f"--- {label} ---")
|
||||
print(f" signin_record: {snap['signin_rows']}")
|
||||
print(f" coin_transaction(signin): {snap['signin_coin_rows']} 条, 共 {snap['signin_coin_sum']} 金币")
|
||||
if snap["accounts"]:
|
||||
for uid, coin, earned in snap["accounts"]:
|
||||
print(f" coin_account user={uid}: coin={coin} earned={earned}")
|
||||
else:
|
||||
print(" coin_account: (无)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="清空用户签到记录")
|
||||
parser.add_argument("user_id", nargs="?", type=int, default=2,
|
||||
help="要清的 user_id(默认 2)")
|
||||
parser.add_argument("--all", action="store_true", help="清所有用户")
|
||||
parser.add_argument("--with-coins", action="store_true",
|
||||
help="同时退回签到发的金币(余额减、删 signin 流水)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
|
||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
where, params, scope = "1=1", (), "ALL users"
|
||||
else:
|
||||
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
raise SystemExit(f"DB 不存在: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cur = conn.cursor()
|
||||
print(f"DB: {db_path} scope: {scope} with_coins: {args.with_coins}")
|
||||
print_snapshot("before", snapshot(cur, where, params))
|
||||
|
||||
if args.dry_run:
|
||||
print("(dry-run,未修改)")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if args.with_coins:
|
||||
# 先按用户把签到金币总额退回(余额、累计收益都减),再删 signin 流水
|
||||
rows = cur.execute(
|
||||
f"select user_id, coalesce(sum(amount), 0) from coin_transaction "
|
||||
f"where biz_type='signin' and {where} group by user_id",
|
||||
params,
|
||||
).fetchall()
|
||||
for uid, total in rows:
|
||||
cur.execute(
|
||||
"update coin_account set coin_balance = coin_balance - ?, "
|
||||
"total_coin_earned = max(0, total_coin_earned - ?) where user_id = ?",
|
||||
(total, total, uid),
|
||||
)
|
||||
cur.execute(f"delete from coin_transaction where biz_type='signin' and {where}", params)
|
||||
|
||||
cur.execute(f"delete from signin_record where {where}", params)
|
||||
conn.commit()
|
||||
|
||||
print_snapshot("after", snapshot(cur, where, params))
|
||||
conn.close()
|
||||
print("签到记录已清空。提醒:App 内存里的状态不会自动同步,重启 App 或进收益明细页刷新;"
|
||||
"重进福利页(当天未签到)会重新自动弹签到弹窗。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""一键回滚某个用户的福利数据(金币/现金/签到/一次性任务/看广告发奖),方便反复测试。
|
||||
|
||||
清掉:coin_transaction / cash_transaction / signin_record / user_task / ad_reward_record,
|
||||
并把 coin_account 的金币余额、累计收益、现金余额全部归零。
|
||||
**不动** savings_record(那是 profile 省钱卡的 demo 演示数据,与领奖测试无关)。
|
||||
|
||||
用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑):
|
||||
python scripts/reset_welfare.py # 默认重置 user_id=2(手机登录用户)
|
||||
python scripts/reset_welfare.py 5 # 重置 user_id=5
|
||||
python scripts/reset_welfare.py --all # 重置所有用户
|
||||
python scripts/reset_welfare.py --dry-run # 只看现状,不改
|
||||
|
||||
DB 默认取 <项目根>/data/app.db,可用 --db 指定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
# 本脚本在 scripts/ 下,项目根是上一级
|
||||
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
|
||||
|
||||
# 受影响的表:每个用户的钱包/行为数据
|
||||
WALLET_TABLES = ("coin_transaction", "cash_transaction", "signin_record", "user_task", "ad_reward_record")
|
||||
|
||||
|
||||
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
|
||||
"""返回当前各表行数 + 账户余额,用于打印 before/after。"""
|
||||
counts = {
|
||||
t: cur.execute(f"select count(*) from {t} where {where}", params).fetchone()[0]
|
||||
for t in WALLET_TABLES
|
||||
}
|
||||
accounts = cur.execute(
|
||||
f"select user_id, coin_balance, cash_balance_cents, total_coin_earned "
|
||||
f"from coin_account where {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
return {"counts": counts, "accounts": accounts}
|
||||
|
||||
|
||||
def print_snapshot(label: str, snap: dict) -> None:
|
||||
print(f"--- {label} ---")
|
||||
for t, n in snap["counts"].items():
|
||||
print(f" {t}: {n}")
|
||||
if snap["accounts"]:
|
||||
for uid, coin, cash, earned in snap["accounts"]:
|
||||
print(f" coin_account user={uid}: coin={coin} cash_cents={cash} earned={earned}")
|
||||
else:
|
||||
print(" coin_account: (无)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="回滚用户福利数据")
|
||||
parser.add_argument("user_id", nargs="?", type=int, default=2,
|
||||
help="要重置的 user_id(默认 2)")
|
||||
parser.add_argument("--all", action="store_true", help="重置所有用户")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
|
||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
where, params, scope = "1=1", (), "ALL users"
|
||||
else:
|
||||
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
raise SystemExit(f"DB 不存在: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cur = conn.cursor()
|
||||
print(f"DB: {db_path} scope: {scope}")
|
||||
print_snapshot("before", snapshot(cur, where, params))
|
||||
|
||||
if args.dry_run:
|
||||
print("(dry-run,未修改)")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
for t in WALLET_TABLES:
|
||||
cur.execute(f"delete from {t} where {where}", params)
|
||||
cur.execute(
|
||||
f"update coin_account set coin_balance=0, total_coin_earned=0, "
|
||||
f"cash_balance_cents=0 where {where}",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
print_snapshot("after", snapshot(cur, where, params))
|
||||
conn.close()
|
||||
print("回滚完成。提醒:App 内存里的共享余额态不会自动同步,重启 App 或进收益明细页刷新。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""模拟穿山甲激励视频 S2S 发奖回调,本地验证「验签 → 幂等发币 → 余额到账」闭环。
|
||||
|
||||
真穿山甲回调需要后端公网可达 + 穿山甲后台配置"奖励回调 URL",本地内网拿不到。本脚本用
|
||||
FastAPI TestClient 在进程内打**真实** endpoint(`/api/v1/ad/pangle-callback`)、写**真实** DB
|
||||
(./data/app.db),绕开公网依赖,证明后端这半条链路 OK——发完进 App 收益明细/重进福利页即可看到余额。
|
||||
|
||||
用法(在 shaguabijia-app-server 目录下,用本项目 python 跑):
|
||||
python scripts/sim_pangle_callback.py # 给最近注册用户发一次(随机 trans_id)
|
||||
python scripts/sim_pangle_callback.py 2 # 指定 user_id=2
|
||||
python scripts/sim_pangle_callback.py 2 --trans t1 # 指定交易号;同号再跑应 Δ0(验幂等)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
# 必须在 import app.* 之前开 PANGLE 开关(settings 在 import 时构造并缓存);
|
||||
# 这里 setdefault 不覆盖 .env / 真实环境已有的值。
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "dev-local-pangle-secret")
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from app.integrations import pangle # noqa: E402
|
||||
from app.core.config import settings # noqa: E402
|
||||
from app.core.rewards import AD_REWARD_COIN # noqa: E402
|
||||
from app.repositories import wallet as crud_wallet # noqa: E402
|
||||
from app.db.session import SessionLocal # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models.user import User # noqa: E402
|
||||
|
||||
|
||||
def _latest_user_id() -> int | None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.execute(select(User.id).order_by(User.id.desc())).scalars().first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _coin_balance(user_id: int) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return crud_wallet.get_or_create_account(db, user_id).coin_balance
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="模拟穿山甲发奖回调")
|
||||
p.add_argument("user_id", nargs="?", type=int, default=None, help="目标 user_id(默认最近注册)")
|
||||
p.add_argument("--trans", default=None, help="交易号(默认随机);同号重跑验幂等")
|
||||
args = p.parse_args()
|
||||
|
||||
if not settings.pangle_callback_configured:
|
||||
raise SystemExit("PANGLE 未配置(PANGLE_CALLBACK_ENABLED / PANGLE_REWARD_SECRET)")
|
||||
|
||||
uid = args.user_id or _latest_user_id()
|
||||
if uid is None:
|
||||
raise SystemExit("DB 里没有用户,先在 App 登录一次再跑")
|
||||
|
||||
trans_id = args.trans or f"sim_{uuid.uuid4().hex[:12]}"
|
||||
before = _coin_balance(uid)
|
||||
|
||||
params = {
|
||||
"user_id": str(uid),
|
||||
"trans_id": trans_id,
|
||||
"reward_name": "金币",
|
||||
# 镜像生产:后台代码位"奖励数量"应配成 AD_REWARD_COIN,回调据此发金币
|
||||
"reward_amount": str(AD_REWARD_COIN),
|
||||
}
|
||||
params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
|
||||
|
||||
resp = TestClient(app).get("/api/v1/ad/pangle-callback", params=params)
|
||||
after = _coin_balance(uid)
|
||||
|
||||
print(f"user_id={uid} trans_id={trans_id}")
|
||||
print(f"HTTP {resp.status_code} {resp.json()}")
|
||||
print(f"金币: {before} -> {after} (Δ{after - before}, 单次应发 {AD_REWARD_COIN})")
|
||||
print("同 trans_id 再跑应 Δ0(幂等);进 App 收益明细/重进福利页刷新即可看到余额。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -23,6 +23,16 @@ os.environ.setdefault("JG_MASTER_SECRET", "test-secret")
|
||||
os.environ.setdefault("SMS_MOCK", "true")
|
||||
os.environ.setdefault("APP_ENV", "dev")
|
||||
os.environ.setdefault("APP_DEBUG", "false") # 测试不打 SQL 日志
|
||||
# 微信支付:填 dummy 让 wxpay_configured 为真(测试里 wxpay 的网络调用全 monkeypatch,不会真发)
|
||||
os.environ.setdefault("WECHAT_APP_ID", "wxtest0000000000")
|
||||
os.environ.setdefault("WECHAT_APP_SECRET", "test-secret")
|
||||
os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
|
||||
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
|
||||
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
|
||||
os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨用例累加,测试关掉
|
||||
# 穿山甲发奖回调:测试里开启 + 给个 mock 验签密钥,test 内自签自验闭环
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""看激励视频发奖测试(穿山甲 S2S 回调)。
|
||||
|
||||
回调无 JWT,靠验签——测试用配置里的 mock 密钥自签自验。覆盖:发奖到账、幂等、
|
||||
验签失败、每日上限、未知用户、客户端进度查询、回调未配置。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.integrations import pangle
|
||||
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, MAX_AD_REWARD_COIN
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _user_id(phone: str) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.execute(select(User.id).where(User.phone == phone)).scalar_one()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _signed(user_id: int, trans_id: str, **extra: str) -> dict[str, str]:
|
||||
"""构造带合法签名的回调参数(模拟穿山甲服务器)。sign 只对 trans_id 签(穿山甲规范)。"""
|
||||
params = {"user_id": str(user_id), "trans_id": trans_id, **extra}
|
||||
params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
|
||||
return params
|
||||
|
||||
|
||||
def _callback(client, params: dict[str, str]):
|
||||
return client.get("/api/v1/ad/pangle-callback", params=params)
|
||||
|
||||
|
||||
def _coin_balance(client, token: str) -> int:
|
||||
return client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]
|
||||
|
||||
|
||||
def test_callback_grants_coins(client) -> None:
|
||||
"""验签通过的回调(无 reward_amount → 回退 AD_REWARD_COIN)→ 发金币到账 + 计数 +1。"""
|
||||
phone = "13800003001"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
r = _callback(client, _signed(uid, "trans_a1", reward_name="金币"))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
|
||||
assert _coin_balance(client, token) == AD_REWARD_COIN
|
||||
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == 1
|
||||
assert st["daily_limit"] == DAILY_AD_REWARD_LIMIT
|
||||
assert st["remaining"] == DAILY_AD_REWARD_LIMIT - 1
|
||||
assert st["coin_per_ad"] == AD_REWARD_COIN
|
||||
|
||||
|
||||
def test_callback_uses_reward_amount(client) -> None:
|
||||
"""带合法 reward_amount → 按它发金币(而非常量);异常值回退/夹紧。"""
|
||||
phone = "13800003011"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
# 合法值:发 250
|
||||
assert _callback(client, _signed(uid, "ra_ok", reward_amount="250")).status_code == 200
|
||||
assert _coin_balance(client, token) == 250
|
||||
|
||||
# ≤0 / 非数字 → 回退 AD_REWARD_COIN(累加 100)
|
||||
assert _callback(client, _signed(uid, "ra_zero", reward_amount="0")).status_code == 200
|
||||
assert _coin_balance(client, token) == 250 + AD_REWARD_COIN
|
||||
|
||||
# 超上限 → 夹紧到 MAX_AD_REWARD_COIN
|
||||
assert _callback(client, _signed(uid, "ra_big", reward_amount="999999")).status_code == 200
|
||||
assert _coin_balance(client, token) == 250 + AD_REWARD_COIN + MAX_AD_REWARD_COIN
|
||||
|
||||
|
||||
def test_callback_idempotent(client) -> None:
|
||||
"""同一 trans_id 二次回调 → 只发一次金币(穿山甲重试不重复发)。"""
|
||||
phone = "13800003002"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
p = _signed(uid, "trans_dup")
|
||||
assert _callback(client, p).status_code == 200
|
||||
assert _callback(client, p).status_code == 200 # 重试
|
||||
|
||||
assert _coin_balance(client, token) == AD_REWARD_COIN # 没翻倍
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == 1
|
||||
|
||||
|
||||
def test_callback_bad_sign(client) -> None:
|
||||
"""签名错误 → 403,不发金币。"""
|
||||
phone = "13800003003"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
params = {"user_id": str(uid), "trans_id": "trans_bad", "sign": "deadbeef"}
|
||||
r = _callback(client, params)
|
||||
assert r.status_code == 403, r.text
|
||||
assert _coin_balance(client, token) == 0
|
||||
|
||||
|
||||
def test_daily_cap(client) -> None:
|
||||
"""达到每日上限后继续回调 → 受理但不发(capped),余额封顶。"""
|
||||
phone = "13800003004"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
for i in range(DAILY_AD_REWARD_LIMIT):
|
||||
r = _callback(client, _signed(uid, f"trans_cap_{i}"))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# 第 limit+1 次:capped,仍 is_valid(不让穿山甲重试),但不加币
|
||||
r = _callback(client, _signed(uid, "trans_cap_over"))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
|
||||
assert _coin_balance(client, token) == DAILY_AD_REWARD_LIMIT * AD_REWARD_COIN
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == DAILY_AD_REWARD_LIMIT
|
||||
assert st["remaining"] == 0
|
||||
|
||||
|
||||
def test_callback_unknown_user(client) -> None:
|
||||
"""验签过但 user_id 不存在 → 不发奖(is_verify false + reason),不崩。"""
|
||||
params = _signed(999999, "trans_ghost")
|
||||
r = _callback(client, params)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"is_verify": False, "reason": 2}
|
||||
|
||||
|
||||
def test_reward_status_requires_auth(client) -> None:
|
||||
"""客户端进度接口需登录。"""
|
||||
r = client.get("/api/v1/ad/reward-status")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_callback_disabled_returns_503(client, monkeypatch) -> None:
|
||||
"""未配置回调(开关关)时 → 503。"""
|
||||
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)
|
||||
# 503 发生在验签/发奖之前,不需要真实用户
|
||||
r = _callback(client, _signed(1, "trans_disabled"))
|
||||
assert r.status_code == 503, r.text
|
||||
@@ -0,0 +1,257 @@
|
||||
"""福利模块测试:钱包 / 签到 / 一次性任务。
|
||||
|
||||
用 sms mock 登录拿 token,再跑各接口闭环。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.rewards import (
|
||||
COIN_PER_CENT,
|
||||
COIN_PER_YUAN,
|
||||
MIN_EXCHANGE_COIN,
|
||||
SIGNIN_REWARDS,
|
||||
TASK_ENABLE_NOTIFICATION,
|
||||
TASK_REWARDS,
|
||||
)
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
"""sms mock 登录,返回 access_token。"""
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_account_auto_created_empty(client) -> None:
|
||||
"""首次访问账户:自动建空账户,余额全 0。"""
|
||||
token = _login(client, "13800001001")
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body == {"coin_balance": 0, "cash_balance_cents": 0, "total_coin_earned": 0}
|
||||
|
||||
|
||||
def test_signin_flow(client) -> None:
|
||||
"""签到状态 → 签到 → 余额到账 → 重复签到 409 → 流水有记录。"""
|
||||
token = _login(client, "13800001002")
|
||||
|
||||
# 1. 签到前状态:未签、可签、今天是第 1 档
|
||||
r = client.get("/api/v1/signin/status", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
st = r.json()
|
||||
assert st["today_signed"] is False
|
||||
assert st["can_claim"] is True
|
||||
assert st["today_cycle_day"] == 1
|
||||
assert st["today_coin"] == SIGNIN_REWARDS[0]
|
||||
assert len(st["steps"]) == len(SIGNIN_REWARDS)
|
||||
assert st["steps"][0]["status"] == "today"
|
||||
|
||||
# 2. 执行签到,发第 1 档金币
|
||||
r = client.post("/api/v1/signin", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["cycle_day"] == 1
|
||||
assert res["streak"] == 1
|
||||
assert res["coin_awarded"] == SIGNIN_REWARDS[0]
|
||||
assert res["coin_balance"] == SIGNIN_REWARDS[0]
|
||||
|
||||
# 3. 账户余额到账
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["coin_balance"] == SIGNIN_REWARDS[0]
|
||||
|
||||
# 4. 状态变为已签
|
||||
r = client.get("/api/v1/signin/status", headers=_auth(token))
|
||||
st = r.json()
|
||||
assert st["today_signed"] is True
|
||||
assert st["can_claim"] is False
|
||||
assert st["consecutive_days"] == 1
|
||||
assert st["steps"][0]["status"] == "claimed"
|
||||
|
||||
# 5. 重复签到 → 409
|
||||
r = client.post("/api/v1/signin", headers=_auth(token))
|
||||
assert r.status_code == 409
|
||||
|
||||
# 6. 金币流水有一笔 signin 入账
|
||||
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
page = r.json()
|
||||
assert page["next_cursor"] is None
|
||||
assert len(page["items"]) == 1
|
||||
txn = page["items"][0]
|
||||
assert txn["amount"] == SIGNIN_REWARDS[0]
|
||||
assert txn["biz_type"] == "signin"
|
||||
assert txn["balance_after"] == SIGNIN_REWARDS[0]
|
||||
|
||||
|
||||
def test_task_claim_flow(client) -> None:
|
||||
"""任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。"""
|
||||
token = _login(client, "13800001003")
|
||||
coin = TASK_REWARDS[TASK_ENABLE_NOTIFICATION]
|
||||
|
||||
# 1. 任务列表:未领取
|
||||
r = client.get("/api/v1/tasks", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
items = {it["task_key"]: it for it in r.json()["items"]}
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == coin
|
||||
|
||||
# 2. 领奖
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["coin_awarded"] == coin
|
||||
assert r.json()["coin_balance"] == coin
|
||||
|
||||
# 3. 重复领 → 409
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 409
|
||||
|
||||
# 4. 列表变为已领取
|
||||
r = client.get("/api/v1/tasks", headers=_auth(token))
|
||||
items = {it["task_key"]: it for it in r.json()["items"]}
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True
|
||||
|
||||
# 5. 未知任务 → 404
|
||||
r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_exchange_info(client) -> None:
|
||||
token = _login(client, "13800001004")
|
||||
r = client.get("/api/v1/wallet/exchange-info", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["coin_per_yuan"] == COIN_PER_YUAN
|
||||
assert body["min_coin"] == MIN_EXCHANGE_COIN
|
||||
assert body["step_coin"] == COIN_PER_CENT
|
||||
|
||||
|
||||
def test_exchange_flow(client) -> None:
|
||||
"""先用任务领够金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。"""
|
||||
token = _login(client, "13800001005")
|
||||
# 领"打开消息提醒"得 10000 金币(= 1 元额度)
|
||||
client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
|
||||
# 兑换 10000 金币 → 100 分
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": COIN_PER_YUAN},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["coin_amount"] == COIN_PER_YUAN
|
||||
assert res["cash_added_cents"] == 100
|
||||
assert res["coin_balance"] == 0
|
||||
assert res["cash_balance_cents"] == 100
|
||||
|
||||
# 账户余额同步
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["coin_balance"] == 0
|
||||
assert r.json()["cash_balance_cents"] == 100
|
||||
|
||||
# 金币流水多了一笔 exchange_out(负)
|
||||
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
|
||||
types = [t["biz_type"] for t in r.json()["items"]]
|
||||
assert "exchange_out" in types
|
||||
out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out")
|
||||
assert out_txn["amount"] == -COIN_PER_YUAN
|
||||
|
||||
# 现金流水有一笔 exchange_in(正)
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
page = r.json()
|
||||
assert len(page["items"]) == 1
|
||||
assert page["items"][0]["amount_cents"] == 100
|
||||
assert page["items"][0]["biz_type"] == "exchange_in"
|
||||
|
||||
|
||||
def test_exchange_insufficient_and_invalid(client) -> None:
|
||||
token = _login(client, "13800001006")
|
||||
|
||||
# 余额 0 → 兑换 1 元 → 409 余额不足
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": COIN_PER_YUAN},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
# 低于最小额 → 400
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": MIN_EXCHANGE_COIN - COIN_PER_CENT},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# 非整分倍数(带零头)→ 400
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": COIN_PER_YUAN + 1},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_savings_summary_and_battle(client) -> None:
|
||||
"""首次访问触发 demo seeder,聚合接口返回真实算出的数字。"""
|
||||
token = _login(client, "13800001007")
|
||||
|
||||
r = client.get("/api/v1/savings/summary", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
s = r.json()
|
||||
assert s["order_count"] > 0
|
||||
assert s["total_saved_cents"] > 0
|
||||
# 平均每单 = 总额 // 单数
|
||||
assert s["avg_saved_cents"] == s["total_saved_cents"] // s["order_count"]
|
||||
|
||||
r = client.get("/api/v1/savings/battle", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
b = r.json()
|
||||
assert b["streak_days"] >= 1
|
||||
assert b["week_saved_cents"] >= 0
|
||||
assert 0 <= b["beat_percent"] <= 100
|
||||
|
||||
|
||||
def test_savings_seeder_idempotent(client) -> None:
|
||||
"""重复访问不应重复灌数据:两次 summary 完全一致。"""
|
||||
token = _login(client, "13800001008")
|
||||
first = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
|
||||
second = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_savings_records_pagination(client) -> None:
|
||||
token = _login(client, "13800001009")
|
||||
# 第一页
|
||||
r = client.get("/api/v1/savings/records?limit=5", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
page1 = r.json()
|
||||
assert len(page1["items"]) == 5
|
||||
assert page1["next_cursor"] is not None
|
||||
# id 倒序
|
||||
ids = [it["id"] for it in page1["items"]]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
# 第二页接着游标走,不与第一页重叠
|
||||
r = client.get(
|
||||
f"/api/v1/savings/records?limit=5&cursor={page1['next_cursor']}",
|
||||
headers=_auth(token),
|
||||
)
|
||||
page2 = r.json()
|
||||
assert max(it["id"] for it in page2["items"]) < min(ids)
|
||||
|
||||
|
||||
def test_endpoints_require_auth(client) -> None:
|
||||
"""不带 token 的福利接口统一 401。"""
|
||||
assert client.get("/api/v1/wallet/account").status_code == 401
|
||||
assert client.get("/api/v1/signin/status").status_code == 401
|
||||
assert client.post("/api/v1/signin").status_code == 401
|
||||
assert client.get("/api/v1/tasks").status_code == 401
|
||||
assert client.get("/api/v1/wallet/cash-transactions").status_code == 401
|
||||
assert client.post("/api/v1/wallet/exchange", json={"coin_amount": 10000}).status_code == 401
|
||||
assert client.get("/api/v1/savings/summary").status_code == 401
|
||||
assert client.get("/api/v1/savings/battle").status_code == 401
|
||||
assert client.get("/api/v1/savings/records").status_code == 401
|
||||
@@ -0,0 +1,271 @@
|
||||
"""提现(现金 → 微信零钱)测试。
|
||||
|
||||
wxpay 的网络调用(code 换 openid / 发起转账 / 查单)全部 monkeypatch,不真发微信。
|
||||
现金余额用 SessionLocal 直接 seed(没有"加现金"接口,正常靠金币兑换)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, WithdrawOrder
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _seed_cash(client, token: str, phone: str, cents: int) -> None:
|
||||
"""先访问 /account 触发建账户,再用 DB 直接灌现金余额。"""
|
||||
client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
||||
acc = db.get(CoinAccount, user.id)
|
||||
acc.cash_balance_cents = cents
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵称", avatar="https://x/y.png"):
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": openid, "nickname": nickname, "avatar_url": avatar, "raw": {}},
|
||||
)
|
||||
|
||||
|
||||
def test_bind_wechat(client, monkeypatch) -> None:
|
||||
_patch_userinfo(monkeypatch)
|
||||
token = _login(client, "13800002001")
|
||||
|
||||
r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["wechat_bound"] is False
|
||||
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "auth_code_x"}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["bound"] is True
|
||||
assert body["wechat_nickname"] == "测试昵称"
|
||||
assert body["wechat_avatar_url"] == "https://x/y.png"
|
||||
|
||||
r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
|
||||
j = r.json()
|
||||
assert j["wechat_bound"] is True
|
||||
assert j["wechat_nickname"] == "测试昵称"
|
||||
|
||||
|
||||
def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_ok", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
lambda openid, amount_fen, out_bill_no, user_name=None: {
|
||||
"status_code": 200,
|
||||
"data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg123", "transfer_bill_no": "tb_1"},
|
||||
},
|
||||
)
|
||||
token = _login(client, "13800002002")
|
||||
_seed_cash(client, token, "13800002002", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
# 发起提现 50 分
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "pending"
|
||||
assert body["package_info"] == "pkg123"
|
||||
assert body["mch_id"] and body["app_id"]
|
||||
assert body["cash_balance_cents"] == 50 # 已扣
|
||||
bill = body["out_bill_no"]
|
||||
|
||||
# 现金流水有一条 -50 的 withdraw
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
txns = r.json()["items"]
|
||||
assert any(t["biz_type"] == "withdraw" and t["amount_cents"] == -50 for t in txns)
|
||||
|
||||
# 查单 → 微信返回 SUCCESS → 归一化 success
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}},
|
||||
)
|
||||
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "success"
|
||||
|
||||
# 提现单列表有 1 条 success
|
||||
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1 and items[0]["status"] == "success"
|
||||
|
||||
|
||||
def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) -> None:
|
||||
"""用户进了确认页但没确认就回来:查单仍 WAIT_USER_CONFIRM → 撤销微信单 + 退款 + 单置 failed。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_wait", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
lambda openid, amount_fen, out_bill_no, user_name=None: {
|
||||
"status_code": 200,
|
||||
"data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb_w"},
|
||||
},
|
||||
)
|
||||
token = _login(client, "13800002006")
|
||||
_seed_cash(client, token, "13800002006", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
bill = r.json()["out_bill_no"]
|
||||
assert r.json()["cash_balance_cents"] == 50 # 已扣
|
||||
|
||||
# 回到 app 查单:微信仍是 WAIT_USER_CONFIRM(没确认) + 撤单成功
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "WAIT_USER_CONFIRM"}},
|
||||
)
|
||||
cancel_called = {}
|
||||
def _fake_cancel(out_bill_no):
|
||||
cancel_called["bill"] = out_bill_no
|
||||
return {"status_code": 200, "data": {"state": "CANCELLING"}}
|
||||
monkeypatch.setattr("app.integrations.wxpay.cancel_transfer", _fake_cancel)
|
||||
|
||||
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "failed"
|
||||
assert cancel_called["bill"] == bill # 确实调了撤单
|
||||
|
||||
# 余额已退回 100
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["cash_balance_cents"] == 100
|
||||
# 有退款流水
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in r.json()["items"])
|
||||
|
||||
|
||||
def test_withdraw_insufficient_cash(client, monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_x", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
token = _login(client, "13800002003")
|
||||
_seed_cash(client, token, "13800002003", 20)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_withdraw_not_bound(client) -> None:
|
||||
token = _login(client, "13800002004")
|
||||
_seed_cash(client, token, "13800002004", 100)
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_fail", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
lambda openid, amount_fen, out_bill_no, user_name=None: {
|
||||
"status_code": 400,
|
||||
"data": {"code": "PARAM_ERROR", "message": "amount invalid"},
|
||||
},
|
||||
)
|
||||
# #3:非200 也要先查单确认。这里 PARAM_ERROR=没创建,查单返回 NOT_FOUND → 退款安全
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 404, "data": {"code": "NOT_FOUND"}},
|
||||
)
|
||||
token = _login(client, "13800002005")
|
||||
_seed_cash(client, token, "13800002005", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 502, r.text
|
||||
|
||||
# 余额已退回
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["cash_balance_cents"] == 100
|
||||
# 有退款流水
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
txns = r.json()["items"]
|
||||
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in txns)
|
||||
# 提现单 failed
|
||||
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
|
||||
assert r.json()["items"][0]["status"] == "failed"
|
||||
|
||||
|
||||
def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None:
|
||||
"""#2 同 out_bill_no 重试:只转一次,第二次返回同一单,余额只扣一次。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_idem", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
calls = {"n": 0}
|
||||
def _create(openid, amount_fen, out_bill_no, user_name=None):
|
||||
calls["n"] += 1
|
||||
return {"status_code": 200, "data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb"}}
|
||||
monkeypatch.setattr("app.integrations.wxpay.create_transfer", _create)
|
||||
# 幂等查单时(pending)会查一次,返回仍在途
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "ACCEPTED"}},
|
||||
)
|
||||
token = _login(client, "13800002007")
|
||||
_seed_cash(client, token, "13800002007", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
bill = "idemkey1234567890"
|
||||
r1 = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50, "out_bill_no": bill}, headers=_auth(token))
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50, "out_bill_no": bill}, headers=_auth(token))
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
assert calls["n"] == 1 # 只真发起一次转账
|
||||
assert r1.json()["out_bill_no"] == bill
|
||||
assert r2.json()["out_bill_no"] == bill
|
||||
# 余额只扣一次(100-50=50)
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["cash_balance_cents"] == 50
|
||||
# 只有一条 withdraw 流水
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
assert sum(1 for t in r.json()["items"] if t["biz_type"] == "withdraw") == 1
|
||||
|
||||
|
||||
def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch) -> None:
|
||||
"""#3 转账调用超时(异常),但查单确认已 SUCCESS → 不退款,单置 success。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_amb", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
def _boom(openid, amount_fen, out_bill_no, user_name=None):
|
||||
raise TimeoutError("read timeout")
|
||||
monkeypatch.setattr("app.integrations.wxpay.create_transfer", _boom)
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS", "transfer_bill_no": "tb_ok"}},
|
||||
)
|
||||
token = _login(client, "13800002008")
|
||||
_seed_cash(client, token, "13800002008", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "success"
|
||||
# 没退款:余额仍是扣后的 50,且无 withdraw_refund
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["cash_balance_cents"] == 50
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
assert not any(t["biz_type"] == "withdraw_refund" for t in r.json()["items"])
|
||||
|
||||
|
||||
def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None:
|
||||
"""#5 一个微信只能绑一个账号:openid 已属于别的用户 → 409。"""
|
||||
shared_openid = "openid_shared_xyz"
|
||||
# 先让 A 绑定
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": shared_openid, "nickname": "A", "avatar_url": None, "raw": {}})
|
||||
token_a = _login(client, "13800002009")
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a))
|
||||
assert r.status_code == 200, r.text
|
||||
# B 想绑同一个 openid → 409
|
||||
token_b = _login(client, "13800002010")
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
|
||||
assert r.status_code == 409, r.text
|
||||
Reference in New Issue
Block a user