diff --git a/.env.example b/.env.example index 034d5da..82c5bd1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 100823b..6a81473 100644 --- a/README.md +++ b/README.md @@ -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 " ``` ### 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 试错顺序)参考自试验后台,逻辑等价。 diff --git a/alembic/versions/ad_reward_record.py b/alembic/versions/ad_reward_record.py new file mode 100644 index 0000000..0161a4c --- /dev/null +++ b/alembic/versions/ad_reward_record.py @@ -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') diff --git a/alembic/versions/cash_transaction_table.py b/alembic/versions/cash_transaction_table.py new file mode 100644 index 0000000..1291b03 --- /dev/null +++ b/alembic/versions/cash_transaction_table.py @@ -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 ### diff --git a/alembic/versions/9c559acc164a_init_user_table.py b/alembic/versions/init_user_table.py similarity index 100% rename from alembic/versions/9c559acc164a_init_user_table.py rename to alembic/versions/init_user_table.py diff --git a/alembic/versions/savings_record_table.py b/alembic/versions/savings_record_table.py new file mode 100644 index 0000000..28fa90a --- /dev/null +++ b/alembic/versions/savings_record_table.py @@ -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 ### diff --git a/alembic/versions/savings_shop_dishes.py b/alembic/versions/savings_shop_dishes.py new file mode 100644 index 0000000..27afcdd --- /dev/null +++ b/alembic/versions/savings_shop_dishes.py @@ -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') diff --git a/alembic/versions/unique_wechat_openid.py b/alembic/versions/unique_wechat_openid.py new file mode 100644 index 0000000..235b1e0 --- /dev/null +++ b/alembic/versions/unique_wechat_openid.py @@ -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')) diff --git a/alembic/versions/user_wechat_nickname_avatar.py b/alembic/versions/user_wechat_nickname_avatar.py new file mode 100644 index 0000000..d36afd2 --- /dev/null +++ b/alembic/versions/user_wechat_nickname_avatar.py @@ -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') diff --git a/alembic/versions/welfare_tables_coin_account_coin_txn.py b/alembic/versions/welfare_tables_coin_account_coin_txn.py new file mode 100644 index 0000000..a2e064b --- /dev/null +++ b/alembic/versions/welfare_tables_coin_account_coin_txn.py @@ -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 ### diff --git a/alembic/versions/withdraw_order_and_user_openid.py b/alembic/versions/withdraw_order_and_user_openid.py new file mode 100644 index 0000000..9b210b6 --- /dev/null +++ b/alembic/versions/withdraw_order_and_user_openid.py @@ -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') diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py new file mode 100644 index 0000000..57fcd19 --- /dev/null +++ b/app/api/v1/ad.py @@ -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, + ) diff --git a/app/api/v1/savings.py b/app/api/v1/savings.py new file mode 100644 index 0000000..50e756d --- /dev/null +++ b/app/api/v1/savings.py @@ -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, + ) diff --git a/app/api/v1/signin.py b/app/api/v1/signin.py new file mode 100644 index 0000000..3bd440a --- /dev/null +++ b/app/api/v1/signin.py @@ -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, + ) diff --git a/app/api/v1/tasks.py b/app/api/v1/tasks.py new file mode 100644 index 0000000..beaaf5d --- /dev/null +++ b/app/api/v1/tasks.py @@ -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) diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py new file mode 100644 index 0000000..4b57894 --- /dev/null +++ b/app/api/v1/wallet.py @@ -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, + ) diff --git a/app/core/config.py b/app/core/config.py index 7709b6c..8e6b396 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/app/core/ratelimit.py b/app/core/ratelimit.py new file mode 100644 index 0000000..ce64f06 --- /dev/null +++ b/app/core/ratelimit.py @@ -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 diff --git a/app/core/rewards.py b/app/core/rewards.py new file mode 100644 index 0000000..ff98e65 --- /dev/null +++ b/app/core/rewards.py @@ -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) diff --git a/app/integrations/pangle.py b/app/integrations/pangle.py new file mode 100644 index 0000000..ee5edad --- /dev/null +++ b/app/integrations/pangle.py @@ -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) diff --git a/app/integrations/wxpay.py b/app/integrations/wxpay.py new file mode 100644 index 0000000..8c66f24 --- /dev/null +++ b/app/integrations/wxpay.py @@ -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} diff --git a/app/main.py b/app/main.py index 159fc08..cfa71fc 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index d288759..d51de66 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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, +) diff --git a/app/models/ad_reward.py b/app/models/ad_reward.py new file mode 100644 index 0000000..18b583b --- /dev/null +++ b/app/models/ad_reward.py @@ -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"" diff --git a/app/models/savings.py b/app/models/savings.py new file mode 100644 index 0000000..29fabf5 --- /dev/null +++ b/app/models/savings.py @@ -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"" diff --git a/app/models/signin.py b/app/models/signin.py new file mode 100644 index 0000000..f23724a --- /dev/null +++ b/app/models/signin.py @@ -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"" + ) diff --git a/app/models/task.py b/app/models/task.py new file mode 100644 index 0000000..dd5c734 --- /dev/null +++ b/app/models/task.py @@ -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"" diff --git a/app/models/user.py b/app/models/user.py index f652db1..bf4be86 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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") diff --git a/app/models/wallet.py b/app/models/wallet.py new file mode 100644 index 0000000..4ab4177 --- /dev/null +++ b/app/models/wallet.py @@ -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"" + + +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_ / 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"" + + +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"" + + +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"" diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py new file mode 100644 index 0000000..bb7d008 --- /dev/null +++ b/app/repositories/ad_reward.py @@ -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 diff --git a/app/repositories/savings.py b/app/repositories/savings.py new file mode 100644 index 0000000..ecc6870 --- /dev/null +++ b/app/repositories/savings.py @@ -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 diff --git a/app/repositories/signin.py b/app/repositories/signin.py new file mode 100644 index 0000000..024f5dc --- /dev/null +++ b/app/repositories/signin.py @@ -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 diff --git a/app/repositories/task.py b/app/repositories/task.py new file mode 100644 index 0000000..9012243 --- /dev/null +++ b/app/repositories/task.py @@ -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 diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py new file mode 100644 index 0000000..97299b9 --- /dev/null +++ b/app/repositories/wallet.py @@ -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 diff --git a/app/schemas/ad.py b/app/schemas/ad.py new file mode 100644 index 0000000..83d960b --- /dev/null +++ b/app/schemas/ad.py @@ -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="看完一个激励视频发的金币") diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py new file mode 100644 index 0000000..b49cc92 --- /dev/null +++ b/app/schemas/welfare.py @@ -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);为空表示到底") diff --git a/docs/README.md b/docs/README.md index 9f40fc7..0829806 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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/ 目录是怎么组织的(传送门式) diff --git a/docs/api/README.md b/docs/api/README.md index 89556a2..8c60288 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -4,6 +4,7 @@ > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` > 最后更新: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": }`。`next_cursor` 为**末条记录的 id**;为 `null` 表示已到底,无更多数据。 +- `items` 按 `id` 倒序(最新在前)。 + --- ## 复用数据结构 diff --git a/docs/api/ad-pangle-callback.md b/docs/api/ad-pangle-callback.md new file mode 100644 index 0000000..f6e5aae --- /dev/null +++ b/docs/api/ad-pangle-callback.md @@ -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`。 diff --git a/docs/api/ad-reward-status.md b/docs/api/ad-reward-status.md new file mode 100644 index 0000000..7cd7e2d --- /dev/null +++ b/docs/api/ad-reward-status.md @@ -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)。 diff --git a/docs/api/ad-test-grant.md b/docs/api/ad-test-grant.md new file mode 100644 index 0000000..8c45b53 --- /dev/null +++ b/docs/api/ad-test-grant.md @@ -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`,幂等 + 每日上限)。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 diff --git a/docs/api/auth-jverify-login.md b/docs/api/auth-jverify-login.md index 5c06120..182ea6e 100644 --- a/docs/api/auth-jverify-login.md +++ b/docs/api/auth-jverify-login.md @@ -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)。 diff --git a/docs/api/auth-sms-login.md b/docs/api/auth-sms-login.md index 56d7637..a858ef0 100644 --- a/docs/api/auth-sms-login.md +++ b/docs/api/auth-sms-login.md @@ -1,6 +1,8 @@ # POST /api/v1/auth/sms/login — 手机号 + 验证码登录 > 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md) +> +> 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。 ## 入参 diff --git a/docs/api/auth-sms-send.md b/docs/api/auth-sms-send.md index 6d00de8..c2cb4df 100644 --- a/docs/api/auth-sms-send.md +++ b/docs/api/auth-sms-send.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)。 ## 入参 diff --git a/docs/api/meituan-coupons.md b/docs/api/meituan-coupons.md index baf8df2..f33cf2f 100644 --- a/docs/api/meituan-coupons.md +++ b/docs/api/meituan-coupons.md @@ -1,6 +1,8 @@ # POST /api/v1/meituan/coupons — 券列表 / 搜索 > 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> +> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。 ## 入参 diff --git a/docs/api/meituan-feed.md b/docs/api/meituan-feed.md index 41959a1..14491f0 100644 --- a/docs/api/meituan-feed.md +++ b/docs/api/meituan-feed.md @@ -1,6 +1,8 @@ # POST /api/v1/meituan/feed — 首页混合推荐流 > 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> +> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。 ## 入参 diff --git a/docs/api/meituan-referral-link.md b/docs/api/meituan-referral-link.md index 5cce9a5..20f9ca0 100644 --- a/docs/api/meituan-referral-link.md +++ b/docs/api/meituan-referral-link.md @@ -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 签名、入参换算坑)。 ## 入参 diff --git a/docs/api/savings-battle.md b/docs/api/savings-battle.md new file mode 100644 index 0000000..7bd982e --- /dev/null +++ b/docs/api/savings-battle.md @@ -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。 diff --git a/docs/api/savings-records.md b/docs/api/savings-records.md new file mode 100644 index 0000000..31fcf6c --- /dev/null +++ b/docs/api/savings-records.md @@ -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`)数据源。 diff --git a/docs/api/savings-summary.md b/docs/api/savings-summary.md new file mode 100644 index 0000000..3082a28 --- /dev/null +++ b/docs/api/savings-summary.md @@ -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。 diff --git a/docs/api/signin-do.md b/docs/api/signin-do.md new file mode 100644 index 0000000..513ba60 --- /dev/null +++ b/docs/api/signin-do.md @@ -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) 的余额(客户端就地刷新即可,不必另叠)。 diff --git a/docs/api/signin-status.md b/docs/api/signin-status.md new file mode 100644 index 0000000..8e09a54 --- /dev/null +++ b/docs/api/signin-status.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 数据源。 diff --git a/docs/api/tasks-claim.md b/docs/api/tasks-claim.md new file mode 100644 index 0000000..64b8a21 --- /dev/null +++ b/docs/api/tasks-claim.md @@ -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 由后端把控(如「打开消息提醒」须客户端确认权限已开后才调)。金币已计入余额。 diff --git a/docs/api/tasks-list.md b/docs/api/tasks-list.md new file mode 100644 index 0000000..63cc73d --- /dev/null +++ b/docs/api/tasks-list.md @@ -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)。 diff --git a/docs/api/wallet-account.md b/docs/api/wallet-account.md new file mode 100644 index 0000000..c059f4c --- /dev/null +++ b/docs/api/wallet-account.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 | 累计赚取金币 | + +## 说明 +账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。 diff --git a/docs/api/wallet-bind-wechat.md b/docs/api/wallet-bind-wechat.md new file mode 100644 index 0000000..34d3022 --- /dev/null +++ b/docs/api/wallet-bind-wechat.md @@ -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 唯一约束),一个微信只能绑一个账号。 diff --git a/docs/api/wallet-cash-transactions.md b/docs/api/wallet-cash-transactions.md new file mode 100644 index 0000000..80463e9 --- /dev/null +++ b/docs/api/wallet-cash-transactions.md @@ -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`)数据源。 diff --git a/docs/api/wallet-coin-transactions.md b/docs/api/wallet-coin-transactions.md new file mode 100644 index 0000000..70e0b69 --- /dev/null +++ b/docs/api/wallet-coin-transactions.md @@ -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`)数据源。 diff --git a/docs/api/wallet-exchange-info.md b/docs/api/wallet-exchange-info.md new file mode 100644 index 0000000..23c4b04 --- /dev/null +++ b/docs/api/wallet-exchange-info.md @@ -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`)。 diff --git a/docs/api/wallet-exchange.md b/docs/api/wallet-exchange.md new file mode 100644 index 0000000..9eaefae --- /dev/null +++ b/docs/api/wallet-exchange.md @@ -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)。金币与现金的扣加在同一事务内完成。 diff --git a/docs/api/wallet-unbind-wechat.md b/docs/api/wallet-unbind-wechat.md new file mode 100644 index 0000000..daba1da --- /dev/null +++ b/docs/api/wallet-unbind-wechat.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`。解绑后该微信可被其他账号绑定。 diff --git a/docs/api/wallet-withdraw-info.md b/docs/api/wallet-withdraw-info.md new file mode 100644 index 0000000..7a6f1ce --- /dev/null +++ b/docs/api/wallet-withdraw-info.md @@ -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`)。 diff --git a/docs/api/wallet-withdraw-orders.md b/docs/api/wallet-withdraw-orders.md new file mode 100644 index 0000000..80b5105 --- /dev/null +++ b/docs/api/wallet-withdraw-orders.md @@ -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 | 发起时间 | + +## 说明 +提现记录页数据源。 diff --git a/docs/api/wallet-withdraw-status.md b/docs/api/wallet-withdraw-status.md new file mode 100644 index 0000000..31a851f --- /dev/null +++ b/docs/api/wallet-withdraw-status.md @@ -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`)。 diff --git a/docs/api/wallet-withdraw.md b/docs/api/wallet-withdraw.md new file mode 100644 index 0000000..ce740f0 --- /dev/null +++ b/docs/api/wallet-withdraw.md @@ -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) 轮询。 diff --git a/docs/integrations/README.md b/docs/integrations/README.md new file mode 100644 index 0000000..c3b99f5 --- /dev/null +++ b/docs/integrations/README.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) diff --git a/docs/integrations/jiguang.md b/docs/integrations/jiguang.md new file mode 100644 index 0000000..53d4345 --- /dev/null +++ b/docs/integrations/jiguang.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` 字段也失败。 diff --git a/docs/integrations/meituan.md b/docs/integrations/meituan.md new file mode 100644 index 0000000..e8e3e95 --- /dev/null +++ b/docs/integrations/meituan.md @@ -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 文档「备注」。 diff --git a/docs/integrations/pangle.md b/docs/integrations/pangle.md new file mode 100644 index 0000000..5cde323 --- /dev/null +++ b/docs/integrations/pangle.md @@ -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) 模拟(仅联调,开关控制)。 diff --git a/docs/integrations/sms.md b/docs/integrations/sms.md new file mode 100644 index 0000000..505e867 --- /dev/null +++ b/docs/integrations/sms.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(多进程/多机时内存表不共享,频控会失效)。 diff --git a/docs/integrations/wxpay.md b/docs/integrations/wxpay.md new file mode 100644 index 0000000..d0fd19b --- /dev/null +++ b/docs/integrations/wxpay.md @@ -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`。 diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index 84bf3cb..aef17ac 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -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`(券列表)混淆。 --- diff --git a/docs/数据库迁移.md b/docs/数据库迁移.md new file mode 100644 index 0000000..9dcd593 --- /dev/null +++ b/docs/数据库迁移.md @@ -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 ` | 回滚到指定版本 | + +## 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" ` 合并。 +- **生产库**:不要随意 `downgrade`;新迁移先在本地/测试库验证过再上。生产 `DATABASE_URL` 由部署侧配置,不在本仓库 `.env`。 diff --git a/docs/看广告赚金币上线清单.md b/docs/看广告赚金币上线清单.md new file mode 100644 index 0000000..12cb3bf --- /dev/null +++ b/docs/看广告赚金币上线清单.md @@ -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=`(配齐才不返 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 并行。 diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100644 index 0000000..f8d514d --- /dev/null +++ b/scripts/migrate.sh @@ -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 可查看当前版本)" diff --git a/scripts/reconcile_withdraws.py b/scripts/reconcile_withdraws.py new file mode 100644 index 0000000..9e1fb20 --- /dev/null +++ b/scripts/reconcile_withdraws.py @@ -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() diff --git a/scripts/reset_signin.py b/scripts/reset_signin.py new file mode 100644 index 0000000..1dafab9 --- /dev/null +++ b/scripts/reset_signin.py @@ -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() diff --git a/scripts/reset_welfare.py b/scripts/reset_welfare.py new file mode 100644 index 0000000..12c3d53 --- /dev/null +++ b/scripts/reset_welfare.py @@ -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() diff --git a/scripts/sim_pangle_callback.py b/scripts/sim_pangle_callback.py new file mode 100644 index 0000000..52e767d --- /dev/null +++ b/scripts/sim_pangle_callback.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 562f6b6..5990374 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py new file mode 100644 index 0000000..ff1d00c --- /dev/null +++ b/tests/test_ad_reward.py @@ -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 diff --git a/tests/test_welfare.py b/tests/test_welfare.py new file mode 100644 index 0000000..7bf69fe --- /dev/null +++ b/tests/test_welfare.py @@ -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 diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py new file mode 100644 index 0000000..5eb8f8c --- /dev/null +++ b/tests/test_withdraw.py @@ -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