feat: 福利钱包+签到+省钱战绩+激励广告发奖+微信提现 后端

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-05-27 17:38:08 +08:00
parent 236907dcd2
commit 0722d7b0d5
43 changed files with 4009 additions and 0 deletions
+22
View File
@@ -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
@@ -0,0 +1,96 @@
"""welfare tables: coin account, coin txn, signin, user task
Revision ID: 357520ea3015
Revises: 9c559acc164a
Create Date: 2026-05-24 16:16:20.677711
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '357520ea3015'
down_revision: Union[str, Sequence[str], None] = '9c559acc164a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('coin_account',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coin_balance', sa.Integer(), nullable=False),
sa.Column('cash_balance_cents', sa.Integer(), nullable=False),
sa.Column('total_coin_earned', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id')
)
op.create_table('coin_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('balance_after', sa.Integer(), nullable=False),
sa.Column('biz_type', sa.String(length=32), nullable=False),
sa.Column('ref_id', sa.String(length=64), nullable=True),
sa.Column('remark', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('coin_transaction', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_coin_transaction_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_coin_transaction_user_id'), ['user_id'], unique=False)
op.create_table('signin_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('signin_date', sa.Date(), nullable=False),
sa.Column('cycle_day', sa.Integer(), nullable=False),
sa.Column('streak', sa.Integer(), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_user_date')
)
with op.batch_alter_table('signin_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_signin_record_user_id'), ['user_id'], unique=False)
op.create_table('user_task',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('task_key', sa.String(length=48), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'task_key', name='uq_task_user_key')
)
with op.batch_alter_table('user_task', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_task_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_task', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_task_user_id'))
op.drop_table('user_task')
with op.batch_alter_table('signin_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_signin_record_user_id'))
op.drop_table('signin_record')
with op.batch_alter_table('coin_transaction', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coin_transaction_user_id'))
batch_op.drop_index(batch_op.f('ix_coin_transaction_created_at'))
op.drop_table('coin_transaction')
op.drop_table('coin_account')
# ### end Alembic commands ###
@@ -0,0 +1,49 @@
"""cash_transaction table
Revision ID: 3d9cb19df76f
Revises: 357520ea3015
Create Date: 2026-05-24 16:21:22.950992
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3d9cb19df76f'
down_revision: Union[str, Sequence[str], None] = '357520ea3015'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('cash_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('balance_after_cents', sa.Integer(), nullable=False),
sa.Column('biz_type', sa.String(length=32), nullable=False),
sa.Column('ref_id', sa.String(length=64), nullable=True),
sa.Column('remark', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('cash_transaction', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_cash_transaction_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_cash_transaction_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('cash_transaction', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_cash_transaction_user_id'))
batch_op.drop_index(batch_op.f('ix_cash_transaction_created_at'))
op.drop_table('cash_transaction')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""unique index on user.wechat_openid (一个微信只绑一个账号)
Revision ID: a7c8d9e0f1b2
Revises: f5b2d3c4e6a7
Create Date: 2026-05-25 19:10:00.000000
注意:升级前需保证 wechat_openid 无重复值(NULL 不算重复)。
本仓库 data/app.db 升级前已手动清掉重复 openid(保留最近绑定的账号)。
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a7c8d9e0f1b2'
down_revision: Union[str, Sequence[str], None] = 'f5b2d3c4e6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# nullable 列上的唯一索引:SQLite / Postgres 都允许多个 NULL,只约束非空值唯一
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_user_wechat_openid'), ['wechat_openid'], unique=True
)
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_wechat_openid'))
@@ -0,0 +1,33 @@
"""savings_record.shop_name + dishes(订单明细卡:店铺名 + 菜品列表)
Revision ID: b1c2d3e4f5a6
Revises: a7c8d9e0f1b2
Create Date: 2026-05-26 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b1c2d3e4f5a6'
down_revision: Union[str, Sequence[str], None] = 'a7c8d9e0f1b2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('shop_name', sa.String(length=128), nullable=True))
# dishes 存 JSON(SQLite 落 TEXT);旧行给 '[]' 默认,满足 NOT NULL
batch_op.add_column(
sa.Column('dishes', sa.JSON(), nullable=False, server_default=sa.text("'[]'"))
)
def downgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_column('dishes')
batch_op.drop_column('shop_name')
@@ -0,0 +1,49 @@
"""savings_record table
Revision ID: bf2a67c0e2ab
Revises: 3d9cb19df76f
Create Date: 2026-05-24 16:24:34.622394
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bf2a67c0e2ab'
down_revision: Union[str, Sequence[str], None] = '3d9cb19df76f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('savings_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('order_amount_cents', sa.Integer(), nullable=False),
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=32), nullable=True),
sa.Column('title', sa.String(length=128), nullable=True),
sa.Column('source', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_savings_record_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_savings_record_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_savings_record_user_id'))
batch_op.drop_index(batch_op.f('ix_savings_record_created_at'))
op.drop_table('savings_record')
# ### end Alembic commands ###
@@ -0,0 +1,50 @@
"""ad_reward_record table (看激励视频发奖记录)
Revision ID: c8d9e0f1a2b3
Revises: b1c2d3e4f5a6
Create Date: 2026-05-26 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c8d9e0f1a2b3'
down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'ad_reward_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('trans_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coin', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('reward_date', sa.String(length=10), nullable=False),
sa.Column('reward_name', sa.String(length=64), nullable=True),
sa.Column('raw', sa.String(length=1024), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('ad_reward_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_reward_record_trans_id'), ['trans_id'], unique=True)
batch_op.create_index(batch_op.f('ix_ad_reward_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_reward_record_reward_date'), ['reward_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_reward_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_reward_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_reward_record_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_reward_date'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_user_id'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_trans_id'))
op.drop_table('ad_reward_record')
@@ -0,0 +1,56 @@
"""withdraw_order table + user.wechat_openid
Revision ID: e4a1c2b3d4f5
Revises: bf2a67c0e2ab
Create Date: 2026-05-25 16:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e4a1c2b3d4f5'
down_revision: Union[str, Sequence[str], None] = 'bf2a67c0e2ab'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'withdraw_order',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('out_bill_no', sa.String(length=64), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('wechat_state', sa.String(length=32), nullable=True),
sa.Column('transfer_bill_no', sa.String(length=64), nullable=True),
sa.Column('package_info', sa.String(length=512), nullable=True),
sa.Column('fail_reason', sa.String(length=256), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_withdraw_order_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_withdraw_order_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_withdraw_order_out_bill_no'), ['out_bill_no'], unique=True)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('wechat_openid', sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('wechat_openid')
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_withdraw_order_out_bill_no'))
batch_op.drop_index(batch_op.f('ix_withdraw_order_user_id'))
batch_op.drop_index(batch_op.f('ix_withdraw_order_created_at'))
op.drop_table('withdraw_order')
@@ -0,0 +1,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')
+122
View File
@@ -0,0 +1,122 @@
"""看激励视频发奖 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 pangle
from app.core.config import settings
from app.core.ratelimit import rate_limit
from app.crud 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"])
@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:
"""穿山甲服务器在激励视频播完后回调,带 user_id / trans_id / sign 等 query 参数。
流程:开关/密钥就绪 → 验签 → 解析 user_id/trans_id → 幂等发金币。
验签失败 403(穿山甲会重试,留给真请求);user_id 非法/不存在则受理但不发(is_valid 仍 True,
避免穿山甲无意义重试)。
"""
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():
# 验签过了但参数缺/坏:受理不发奖,不让穿山甲重试
logger.warning("pangle callback bad params: %s", params)
return PangleCallbackOut(is_valid=False)
user_id = int(raw_user_id)
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, 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_valid=False)
logger.info(
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin
)
return PangleCallbackOut(is_valid=True)
@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,
)
+59
View File
@@ -0,0 +1,59 @@
"""省钱 endpoint(profile 的「累计帮你省了」「省钱战绩」「省钱明细」)。
路由前缀 `/api/v1/savings`:
GET /summary 累计省下 + 订单数 + 平均每单省
GET /battle 本周已省 + 超过百分比 + 连续省钱天数
GET /records 省钱明细(游标分页)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query
from app.api.deps import CurrentUser, DbSession
from app.crud import savings as crud_savings
from app.schemas.welfare import (
SavingsBattleOut,
SavingsRecordOut,
SavingsRecordPage,
SavingsSummaryOut,
)
logger = logging.getLogger("shagua.savings")
router = APIRouter(prefix="/api/v1/savings", tags=["savings"])
@router.get("/summary", response_model=SavingsSummaryOut, summary="累计帮你省了")
def summary(user: CurrentUser, db: DbSession) -> SavingsSummaryOut:
s = crud_savings.get_summary(db, user.id)
return SavingsSummaryOut(
total_saved_cents=s.total_saved_cents,
order_count=s.order_count,
avg_saved_cents=s.avg_saved_cents,
)
@router.get("/battle", response_model=SavingsBattleOut, summary="省钱战绩")
def battle(user: CurrentUser, db: DbSession) -> SavingsBattleOut:
b = crud_savings.get_battle(db, user.id)
return SavingsBattleOut(
week_saved_cents=b.week_saved_cents,
beat_percent=b.beat_percent,
streak_days=b.streak_days,
)
@router.get("/records", response_model=SavingsRecordPage, summary="省钱明细(游标分页)")
def records(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> SavingsRecordPage:
items, next_cursor = crud_savings.list_records(db, user.id, limit=limit, cursor=cursor)
return SavingsRecordPage(
items=[SavingsRecordOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
+44
View File
@@ -0,0 +1,44 @@
"""签到 endpoint。
路由前缀 `/api/v1/signin`:
GET /status 今日签到状态 + 7 天档位
POST / 执行今日签到
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.crud import signin as crud_signin
from app.schemas.welfare import SigninResultOut, SigninStatusOut
logger = logging.getLogger("shagua.signin")
router = APIRouter(prefix="/api/v1/signin", tags=["signin"])
@router.get("/status", response_model=SigninStatusOut, summary="今日签到状态")
def signin_status(user: CurrentUser, db: DbSession) -> SigninStatusOut:
st = crud_signin.get_status(db, user.id)
return SigninStatusOut.model_validate(st, from_attributes=True)
@router.post("", response_model=SigninResultOut, summary="执行今日签到")
def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
try:
record, balance = crud_signin.do_signin(db, user.id)
except crud_signin.AlreadySignedError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already signed today") from e
logger.info(
"signin ok user_id=%d day=%d streak=%d coin=%d",
user.id, record.cycle_day, record.streak, record.coin_awarded,
)
return SigninResultOut(
coin_awarded=record.coin_awarded,
cycle_day=record.cycle_day,
streak=record.streak,
coin_balance=balance,
)
+47
View File
@@ -0,0 +1,47 @@
"""一次性任务 endpoint。
路由前缀 `/api/v1/tasks`:
GET / 任务及领取状态(如"打开消息提醒")
POST /{task_key}/claim 领取任务奖励
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.crud 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)
+240
View File
@@ -0,0 +1,240 @@
"""钱包 / 我的资产 endpoint。
路由前缀 `/api/v1/wallet`:
GET /account 金币 + 现金余额(我的资产卡)
GET /coin-transactions 金币流水(游标分页)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.ratelimit import rate_limit
from app.core.rewards import (
COIN_PER_CENT,
COIN_PER_YUAN,
MIN_EXCHANGE_COIN,
WITHDRAW_MAX_CENTS,
WITHDRAW_MIN_CENTS,
)
from app.crud 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,
)
+41
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
APP_NAME: str = "shaguabijia-app-server"
APP_DEBUG: bool = True
# 接口限流开关(测试里关掉,避免内存计数跨用例累加误伤)
RATE_LIMIT_ENABLED: bool = True
# ===== 数据库 =====
DATABASE_URL: str = "sqlite:///./data/app.db"
@@ -55,6 +58,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
+42
View File
@@ -0,0 +1,42 @@
"""穿山甲激励视频"服务端发奖回调"的验签。
发奖闭环:激励视频播完 → 穿山甲服务器 GET 回调本服务(带 user_id / trans_id / sign 等)
→ 本服务**验签**确认回调确实来自穿山甲(而非伪造请求) → 幂等发金币。
客户端不参与发奖,被破解也刷不到钱。
⚠️ 关于验签方案:穿山甲后台的"奖励校验"实际签名算法以控制台为准(不同接入方式可能是
HMAC 共享密钥,也可能是穿山甲 RSA 公钥验签)。这里用 **HMAC-SHA256(排序参数, 密钥)** 作为
默认实现并配套自验签测试。真实接入时**只需改本文件的 `verify_callback_sign` / `build_sign`
这一处**——发奖、幂等、每日限额逻辑(crud/ad_reward.py)与验签方案完全无关,不受影响。
"""
from __future__ import annotations
import hashlib
import hmac
# 验签时排除的参数(sign 自身不参与签名;空值参数也不计入)
_SIGN_EXCLUDE = {"sign"}
def _canonical(params: dict[str, str]) -> str:
"""把参与签名的参数按 key 升序拼成 `k=v&k=v`。空值剔除,保证两端构造一致。"""
items = sorted(
(k, v) for k, v in params.items() if k not in _SIGN_EXCLUDE and v not in (None, "")
)
return "&".join(f"{k}={v}" for k, v in items)
def build_sign(params: dict[str, str], secret: str) -> str:
"""对参数计算 HMAC-SHA256 十六进制签名(测试 / 自验用)。"""
msg = _canonical(params).encode("utf-8")
return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
def verify_callback_sign(params: dict[str, str], secret: str) -> bool:
"""校验回调签名。`params` 是回调全部 query 参数(含 sign)。密钥为空一律判失败。"""
if not secret:
return False
got = params.get("sign") or ""
expect = build_sign(params, secret)
# 定长比较防时序侧信道
return hmac.compare_digest(got, expect)
+59
View File
@@ -0,0 +1,59 @@
"""轻量内存限流(固定窗口计数)。
定位:防脚本刷接口的**安全网**,不是精确配额。局限——进程内存、单 worker 有效、重启清零、
多 worker/多机不共享。上线放量后应换 Redis 后端 + 按用户维度。当前单 worker uvicorn 够用。
用法:把 `rate_limit(limit, window_sec, scope)` 作为路由依赖挂上,默认按客户端 IP 计数。
"""
from __future__ import annotations
import threading
import time
from fastapi import HTTPException, Request, status
from app.core.config import settings
# key -> (window_start_ts, count)
_buckets: dict[str, tuple[float, int]] = {}
_lock = threading.Lock()
def _hit(key: str, limit: int, window_sec: float) -> bool:
"""记一次访问。返回 True=放行,False=超限。"""
now = time.monotonic()
with _lock:
start, count = _buckets.get(key, (now, 0))
if now - start >= window_sec: # 窗口过期,重置
start, count = now, 0
count += 1
_buckets[key] = (start, count)
# 顺手清理过期 key,防内存无限涨(低频访问足够)
if len(_buckets) > 10000:
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
_buckets.pop(k, None)
return count <= limit
def _client_ip(request: Request) -> str:
# nginx 反代后真实 IP 在 X-Forwarded-For 首段;直连用 request.client
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def rate_limit(limit: int, window_sec: float, scope: str):
"""生成一个 FastAPI 依赖:同一 IP 在 window_sec 内对该 scope 超过 limit 次 → 429。"""
def _dep(request: Request) -> None:
if not settings.RATE_LIMIT_ENABLED:
return
key = f"{scope}:{_client_ip(request)}"
if not _hit(key, limit, window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="操作过于频繁,请稍后再试",
)
return _dep
+66
View File
@@ -0,0 +1,66 @@
"""福利 / 激励相关的业务常量与换算规则。
集中放在这里,业务代码引用,避免数值散落各处。这些是**产品规则**(签到奖励、
任务奖励、金币汇率)而非部署配置,所以不走环境变量;要调整直接改这里。
"""
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 元)。
AD_REWARD_COIN: int = 100
# 每用户每日发奖次数上限,防刷 + 控成本。
# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
DAILY_AD_REWARD_LIMIT: int = 10
+219
View File
@@ -0,0 +1,219 @@
"""微信支付 V3 —— 商家转账到零钱(提现)。
移植自 elderhelper 的 wxpay.py,改成从 [app.core.config.settings] 读配置、密钥懒加载
(文件缺失不在 import 时炸进程,只在真正调用转账时报错,与极光私钥同款策略)。
只实现提现需要的两个接口:
- create_transfer:发起转账(商家转账到零钱)
- query_transfer :按商户单号查转账单状态
签名用商户 API 私钥(RSA-SHA256/PKCS1v15),敏感字段(实名)用微信支付平台公钥 OAEP 加密。
"""
from __future__ import annotations
import base64
import json
import time
import uuid
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from app.core.config import settings
_API_HOST = "https://api.mch.weixin.qq.com"
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
# 懒加载缓存
_private_key: RSAPrivateKey | None = None
_public_key: RSAPublicKey | None = None
class WxPayNotConfiguredError(Exception):
"""微信支付凭证 / 证书缺失,无法调用。"""
def _load_private_key() -> RSAPrivateKey:
global _private_key
if _private_key is None:
try:
with open(settings.WXPAY_MCH_PRIVATE_KEY_PATH, "rb") as f:
_private_key = serialization.load_pem_private_key(f.read(), password=None)
except FileNotFoundError as e:
raise WxPayNotConfiguredError(
f"商户私钥不存在: {settings.WXPAY_MCH_PRIVATE_KEY_PATH}"
) from e
return _private_key
def _load_public_key() -> RSAPublicKey:
global _public_key
if _public_key is None:
try:
with open(settings.WXPAY_PUBLIC_KEY_PATH, "rb") as f:
_public_key = serialization.load_pem_public_key(f.read())
except FileNotFoundError as e:
raise WxPayNotConfiguredError(
f"微信支付平台公钥不存在: {settings.WXPAY_PUBLIC_KEY_PATH}"
) from e
return _public_key
def _sign(message: str) -> str:
"""RSA-SHA256 签名,base64 输出。"""
signature = _load_private_key().sign(
message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()
)
return base64.b64encode(signature).decode("utf-8")
def _build_authorization(method: str, url_path: str, body: str) -> str:
"""构建 V3 Authorization 头(WECHATPAY2-SHA256-RSA2048)。"""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
sign_str = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
signature = _sign(sign_str)
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{settings.WXPAY_MCH_ID}",'
f'nonce_str="{nonce}",'
f'timestamp="{timestamp}",'
f'serial_no="{settings.WXPAY_MCH_SERIAL_NO}",'
f'signature="{signature}"'
)
def encrypt_sensitive(plain_text: str) -> str:
"""用微信支付平台公钥 OAEP(SHA1) 加密敏感信息(如实名)。"""
ciphertext = _load_public_key().encrypt(
plain_text.encode("utf-8"),
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
)
return base64.b64encode(ciphertext).decode("utf-8")
def create_transfer(
openid: str, amount_fen: int, out_bill_no: str, user_name: str | None = None
) -> dict:
"""发起商家转账到零钱。返回 {status_code, data}。
user_name(实名)在金额达微信阈值时必须传(加密)。这里沿用 elderhelper 阈值,
实际阈值以微信侧要求为准。
"""
body = {
"appid": settings.WECHAT_APP_ID,
"out_bill_no": out_bill_no,
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
"openid": openid,
"transfer_amount": amount_fen,
"transfer_remark": "金币提现",
"transfer_scene_report_infos": [
{"info_type": "活动名称", "info_content": "比价返现"},
{"info_type": "奖励说明", "info_content": "现金提现到微信零钱"},
],
}
if user_name and amount_fen >= 30:
body["user_name"] = encrypt_sensitive(user_name)
body_str = json.dumps(body, ensure_ascii=False)
headers = {
"Authorization": _build_authorization("POST", _TRANSFER_PATH, body_str),
"Accept": "application/json",
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{_TRANSFER_PATH}",
content=body_str,
headers=headers,
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
return {"status_code": resp.status_code, "data": resp.json()}
def query_transfer(out_bill_no: str) -> dict:
"""按商户单号查转账单。返回 {status_code, data}。"""
path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}"
headers = {
"Authorization": _build_authorization("GET", path, ""),
"Accept": "application/json",
}
with httpx.Client() as client:
resp = client.get(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
return {"status_code": resp.status_code, "data": resp.json()}
def cancel_transfer(out_bill_no: str) -> dict:
"""撤销转账单(仅 WAIT_USER_CONFIRM/ACCEPTED 可撤)。用户没在确认页确认就离开时调用。
成功返回 {status_code:200, data:{state:'CANCELLING'|'CANCELLED', ...}}。"""
path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}/cancel"
headers = {
"Authorization": _build_authorization("POST", path, ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
return {"status_code": resp.status_code, "data": resp.json()}
def code_to_openid(code: str) -> str:
"""用微信授权 code 换 openid(开放平台移动应用 sns/oauth2)。失败抛 ValueError。"""
resp = httpx.get(
"https://api.weixin.qq.com/sns/oauth2/access_token",
params={
"appid": settings.WECHAT_APP_ID,
"secret": settings.WECHAT_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
data = resp.json()
if "openid" not in data:
raise ValueError(f"微信授权失败: {data.get('errmsg', data)}")
return data["openid"]
def code_to_userinfo(code: str) -> dict:
"""code 换 access_token+openid,再拉 sns/userinfo 取昵称头像。
返回 {openid, nickname, avatar_url, raw}。失败抛 ValueError。
注意:微信隐私新政下,部分 app 的 sns/userinfo 可能返回脱敏值(昵称"微信用户"/灰头像);
nickname/avatar_url 可能为空,调用方需兜底。"""
r1 = httpx.get(
"https://api.weixin.qq.com/sns/oauth2/access_token",
params={
"appid": settings.WECHAT_APP_ID,
"secret": settings.WECHAT_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
d1 = r1.json()
if "openid" not in d1 or "access_token" not in d1:
raise ValueError(f"微信授权失败: {d1.get('errmsg', d1)}")
openid = d1["openid"]
nickname = None
avatar_url = None
raw: dict = {}
# userinfo 拉取失败不应让绑定失败(openid 已拿到),吞掉异常只是没昵称头像
try:
r2 = httpx.get(
"https://api.weixin.qq.com/sns/userinfo",
params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
raw = r2.json()
nickname = raw.get("nickname") or None
avatar_url = raw.get("headimgurl") or None
except Exception: # noqa: BLE001
pass
return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw}
+105
View File
@@ -0,0 +1,105 @@
"""看激励视频发奖 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.crud 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,
*,
reward_name: str | None = None,
raw: str | None = None,
) -> AdRewardRecord:
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
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, AD_REWARD_COIN,
biz_type="ad_reward", ref_id=trans_id, remark="看广告奖励",
)
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=AD_REWARD_COIN, status="granted",
reward_date=today, reward_name=reward_name, raw=raw,
)
return _commit_record(db, rec, trans_id)
def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardRecord:
"""提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。"""
db.add(rec)
try:
db.commit()
except IntegrityError:
db.rollback()
existing = _find_by_trans(db, trans_id)
if existing is not None:
return existing
raise
db.refresh(rec)
return rec
def today_status(db: Session, user_id: int) -> tuple[int, int, int]:
"""客户端查"今日看广告发奖"进度:返回 (今日已发次数, 每日上限, 单次金币)。"""
used = _granted_today(db, user_id, cn_today().isoformat())
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN
+186
View File
@@ -0,0 +1,186 @@
"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。
设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自
demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。
聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑——
created_at 带时区,统一转成北京时间的 date 再分组。
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ, cn_today
from app.models.savings import SavingsRecord
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
_DEMO_STREAK_DAYS = 9
_DEMO_EXTRA_ORDERS = 14
# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。
# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中;
# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。
_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [
("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]),
("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]),
("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]),
("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]),
("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]),
("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]),
("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]),
("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]),
("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]),
("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]),
]
@dataclass
class SavingsSummary:
total_saved_cents: int
order_count: int
avg_saved_cents: int # 平均每单省(分)
@dataclass
class SavingsBattle:
week_saved_cents: int # 本周(周一起)已省
beat_percent: int # 超过百分之多少用户
streak_days: int # 连续省钱天数
def _local_date(dt: datetime):
"""把(可能带时区的)created_at 转成北京时间的 date。"""
if dt.tzinfo is None:
return dt.date()
return dt.astimezone(CN_TZ).date()
def _all_records(db: Session, user_id: int) -> list[SavingsRecord]:
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
return list(db.execute(stmt).scalars().all())
def ensure_seeded(db: Session, user_id: int) -> None:
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
exists = db.execute(
select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1)
).first()
if exists is not None:
return
rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致
today = cn_today()
records: list[SavingsRecord] = []
def _mk(day) -> SavingsRecord:
hour = rng.randint(8, 21)
minute = rng.randint(0, 59)
ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ)
platform, shop, dishes = rng.choice(_DEMO_ORDERS)
# 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡
saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元
return SavingsRecord(
user_id=user_id,
order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价)
saved_amount_cents=saved,
platform=platform,
title=shop,
shop_name=shop,
dishes=dishes,
source="demo",
created_at=ts,
)
# 最近 N 天每天 1 单 → 连续省钱 N 天
for d in range(_DEMO_STREAK_DAYS):
records.append(_mk(today - timedelta(days=d)))
# 历史散单(第 10~40 天)
for _ in range(_DEMO_EXTRA_ORDERS):
records.append(_mk(today - timedelta(days=rng.randint(10, 40))))
db.add_all(records)
db.commit()
def get_summary(db: Session, user_id: int) -> SavingsSummary:
ensure_seeded(db, user_id)
records = _all_records(db, user_id)
total = sum(r.saved_amount_cents for r in records)
count = len(records)
avg = total // count if count else 0
return SavingsSummary(total_saved_cents=total, order_count=count, avg_saved_cents=avg)
def _streak_days(dates: set) -> int:
"""从最近活跃日往前数连续有省钱记录的天数。"""
if not dates:
return 0
cur = max(dates)
streak = 0
while cur in dates:
streak += 1
cur = cur - timedelta(days=1)
return streak
def get_battle(db: Session, user_id: int) -> SavingsBattle:
ensure_seeded(db, user_id)
records = _all_records(db, user_id)
today = cn_today()
week_start = today - timedelta(days=today.weekday()) # 本周一
week_saved = sum(
r.saved_amount_cents for r in records if _local_date(r.created_at) >= week_start
)
dates = {_local_date(r.created_at) for r in records}
streak = _streak_days(dates)
beat_percent = _compute_beat_percent(db, user_id)
return SavingsBattle(
week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak
)
def _compute_beat_percent(db: Session, user_id: int) -> int:
"""超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。
= 严格少于我的其他用户数 / 其他用户总数 * 100。
全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。
"""
rows = db.execute(
select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents))
.group_by(SavingsRecord.user_id)
).all()
totals = {uid: (total or 0) for uid, total in rows}
others = {uid: t for uid, t in totals.items() if uid != user_id}
if not others:
return 0
my_total = totals.get(user_id, 0)
beaten = sum(1 for t in others.values() if t < my_total)
return round(100 * beaten / len(others))
def list_records(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[SavingsRecord], int | None]:
"""省钱明细分页(按 id 倒序,游标式)。"""
ensure_seeded(db, user_id)
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
if cursor is not None:
stmt = stmt.where(SavingsRecord.id < cursor)
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
+126
View File
@@ -0,0 +1,126 @@
"""签到 CRUD:状态查询 + 执行签到。
7 天循环规则:
- 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续)
- 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置)
今天的 cycle_day 决定发多少金币(见 app.core.rewards.SIGNIN_REWARDS)。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import SIGNIN_CYCLE_LEN, SIGNIN_REWARDS, cn_today, signin_reward
from app.crud import wallet as crud_wallet
from app.models.signin import SigninRecord
class AlreadySignedError(Exception):
"""今天已经签过了。"""
@dataclass
class SigninStep:
day: int # 1..7
coin: int
status: str # claimed / today / locked
@dataclass
class SigninStatus:
today_signed: bool
consecutive_days: int # 当前已确认的连续签到天数
today_cycle_day: int # 今天落在循环的第几档(1..7)
today_coin: int # 今天这一档的金币
can_claim: bool # 现在能否签到(= not today_signed)
steps: list[SigninStep]
def _latest_record(db: Session, user_id: int) -> SigninRecord | None:
stmt = (
select(SigninRecord)
.where(SigninRecord.user_id == user_id)
.order_by(SigninRecord.signin_date.desc())
.limit(1)
)
return db.execute(stmt).scalar_one_or_none()
def _next_cycle_day(last: SigninRecord | None, today) -> int:
"""若现在签到,今天会落在第几档(1..7)。"""
if last is not None and last.signin_date == today - timedelta(days=1):
return last.cycle_day % SIGNIN_CYCLE_LEN + 1
return 1
def get_status(db: Session, user_id: int) -> SigninStatus:
today = cn_today()
last = _latest_record(db, user_id)
today_signed = last is not None and last.signin_date == today
if today_signed:
today_cycle_day = last.cycle_day
consecutive_days = last.streak
else:
today_cycle_day = _next_cycle_day(last, today)
# 未签时的"已连续"= 还在连续窗口内的既有 streak,否则 0
if last is not None and last.signin_date == today - timedelta(days=1):
consecutive_days = last.streak
else:
consecutive_days = 0
steps: list[SigninStep] = []
for day in range(1, SIGNIN_CYCLE_LEN + 1):
if today_signed:
status = "claimed" if day <= today_cycle_day else "locked"
else:
if day < today_cycle_day:
status = "claimed"
elif day == today_cycle_day:
status = "today"
else:
status = "locked"
steps.append(SigninStep(day=day, coin=SIGNIN_REWARDS[day - 1], status=status))
return SigninStatus(
today_signed=today_signed,
consecutive_days=consecutive_days,
today_cycle_day=today_cycle_day,
today_coin=signin_reward(today_cycle_day),
can_claim=not today_signed,
steps=steps,
)
def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
"""执行签到。返回 (签到记录, 签到后金币余额)。今天已签则抛 AlreadySignedError。"""
today = cn_today()
last = _latest_record(db, user_id)
if last is not None and last.signin_date == today:
raise AlreadySignedError
if last is not None and last.signin_date == today - timedelta(days=1):
cycle_day = last.cycle_day % SIGNIN_CYCLE_LEN + 1
streak = last.streak + 1
else:
cycle_day = 1
streak = 1
coin = signin_reward(cycle_day)
record = SigninRecord(
user_id=user_id,
signin_date=today,
cycle_day=cycle_day,
streak=streak,
coin_awarded=coin,
)
db.add(record)
acc, _ = crud_wallet.grant_coins(
db, user_id, coin, biz_type="signin", ref_id=today.isoformat()
)
db.commit()
db.refresh(record)
return record, acc.coin_balance
+71
View File
@@ -0,0 +1,71 @@
"""一次性任务 CRUD:列表状态 + 领奖。
领奖原子化:写 user_task(唯一约束防重复)+ 发金币,同一事务 commit。
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import TASK_REWARDS
from app.crud import wallet as crud_wallet
from app.models.task import UserTask
class UnknownTaskError(Exception):
"""task_key 不在已知任务表里。"""
class AlreadyClaimedError(Exception):
"""该任务已经领过奖了。"""
@dataclass
class TaskState:
task_key: str
coin: int
claimed: bool
def list_tasks(db: Session, user_id: int) -> list[TaskState]:
"""返回所有已知一次性任务及其领取状态。"""
stmt = select(UserTask.task_key).where(UserTask.user_id == user_id)
claimed_keys = set(db.execute(stmt).scalars().all())
return [
TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)
for key, coin in TASK_REWARDS.items()
]
def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
"""领取一次性任务奖励。返回 (发放金币, 领奖后余额)。
未知任务抛 UnknownTaskError;重复领取抛 AlreadyClaimedError。
"""
if task_key not in TASK_REWARDS:
raise UnknownTaskError
existing = db.execute(
select(UserTask).where(
UserTask.user_id == user_id, UserTask.task_key == task_key
)
).scalar_one_or_none()
if existing is not None:
raise AlreadyClaimedError
coin = TASK_REWARDS[task_key]
db.add(
UserTask(
user_id=user_id,
task_key=task_key,
status="completed",
coin_awarded=coin,
)
)
acc, _ = crud_wallet.grant_coins(
db, user_id, coin, biz_type=f"task_{task_key}", ref_id=task_key
)
db.commit()
return coin, acc.coin_balance
+506
View File
@@ -0,0 +1,506 @@
"""钱包 CRUD:账户读取 + 金币入账(共享给签到 / 任务)+ 流水查询。
`grant_coins` 是所有"发金币"的唯一入口:它更新余额快照并写一笔流水,但**不 commit**,
由调用方在同一事务里 commit(签到要同时写 signin_record,任务要同时写 user_task,
保证"记录""加金币"原子化,不会只发钱不留痕或反之)。
"""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app.core import wxpay
from app.core.rewards import (
COIN_PER_CENT,
MIN_EXCHANGE_COIN,
WITHDRAW_MAX_CENTS,
WITHDRAW_MIN_CENTS,
coins_to_cents,
)
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
+10
View File
@@ -11,9 +11,14 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.ad import router as ad_router
from app.api.v1.auth import router as auth_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.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)
+10
View File
@@ -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,
)
+42
View File
@@ -0,0 +1,42 @@
"""看激励视频发奖记录(穿山甲 S2S 回调)。
每条 = 穿山甲一次发奖回调。`trans_id`(穿山甲交易号)唯一,做幂等键:穿山甲会重试回调,
同号只发一次金币。`reward_date`(北京时间日期串)给"每日上限"计数用——按日期串等值查,
不在 SQL 里做跨时区 date 比较(SQLite 上不可靠)。审计 / 对账时整张表可逐笔回溯。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AdRewardRecord(Base):
__tablename__ = "ad_reward_record"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 穿山甲交易号,幂等键(同号回调不重复发奖)
trans_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 实发金币(capped 时为 0)
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# granted(已发) / capped(当日超限未发)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
# 穿山甲上报的奖励名(参考,不作发奖依据)
reward_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 回调原始参数,审计排查用
raw: Mapped[str | None] = mapped_column(String(1024), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} {self.status} coin={self.coin}>"
+41
View File
@@ -0,0 +1,41 @@
"""省钱记录表。
是 profile「累计帮你省了」和「省钱战绩」的唯一数据源:每完成一次比价/下单,
省了多少记一行。本期比价还没接后端,数据由 demo seeder 幂等灌入(source='demo'),
聚合接口照常做真实 SUM/分组;等比价真接入后,改成上报写入、停掉 seeder 即可。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class SavingsRecord(Base):
__tablename__ = "savings_record"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 订单金额 / 省下金额,单位:分
order_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
saved_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。
dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
# 来源:demo(演示) / compare(真实比价上报)
source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<SavingsRecord id={self.id} user_id={self.user_id} saved={self.saved_amount_cents}>"
+41
View File
@@ -0,0 +1,41 @@
"""签到记录表。
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class SigninRecord(Base):
__tablename__ = "signin_record"
__table_args__ = (
UniqueConstraint("user_id", "signin_date", name="uq_signin_user_date"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 签到日期(北京时间的 date)
signin_date: Mapped[date] = mapped_column(Date, nullable=False)
cycle_day: Mapped[int] = mapped_column(Integer, nullable=False)
streak: Mapped[int] = mapped_column(Integer, nullable=False)
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<SigninRecord user_id={self.user_id} date={self.signin_date} "
f"day={self.cycle_day} streak={self.streak}>"
)
+36
View File
@@ -0,0 +1,36 @@
"""一次性任务完成记录表。
"打开消息提醒"这类只能领一次的任务,完成后写一行,(user_id, task_key) 唯一,
防止重复领奖。可循环领取的任务(签到)不走这张表,各有专表。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class UserTask(Base):
__tablename__ = "user_task"
__table_args__ = (
UniqueConstraint("user_id", "task_key", name="uq_task_user_key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 任务标识,见 app.core.rewards.TASK_REWARDS
task_key: Mapped[str] = mapped_column(String(48), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="completed")
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
completed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<UserTask user_id={self.user_id} key={self.task_key}>"
+9
View File
@@ -33,6 +33,15 @@ class User(Base):
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 微信开放平台 openid(微信登录绑定后存,提现转账到零钱用)。同一 appid 下唯一;
# unique 索引保证一个微信只能绑一个账号(nullable,允许多个 NULL=未绑定)。
wechat_openid: Mapped[str | None] = mapped_column(
String(64), unique=True, index=True, nullable=True
)
# 微信昵称/头像(绑定时从 sns/userinfo 拉,展示在提现绑定卡)。与上面通用 nickname/avatar_url 分开,不互相覆盖。
wechat_nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
wechat_avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 账号状态:active / disabled / deleted
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
+127
View File
@@ -0,0 +1,127 @@
"""钱包相关表:金币账户 + 金币流水。
设计要点:
- 余额快照(coin_account)用于读取,流水账本(coin_transaction)用于对账,
每次余额变动都写一笔流水并记 balance_after,出问题能逐笔回溯。
- 金额一律存整数:金币是个数,现金存""(cash_balance_cents),不用浮点。
- cash_balance_cents 本步先留列、恒为 0,金币兑现金(第 2 步)再启用。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class CoinAccount(Base):
__tablename__ = "coin_account"
# 一个用户一行,user_id 既是主键也是外键
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), primary_key=True
)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return f"<CoinAccount user_id={self.user_id} coin={self.coin_balance}>"
class CoinTransaction(Base):
__tablename__ = "coin_transaction"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 正数=入账(赚),负数=出账(花/兑换)
amount: Mapped[int] = mapped_column(Integer, nullable=False)
# 本笔变动后的金币余额,对账用
balance_after: Mapped[int] = mapped_column(Integer, nullable=False)
# 业务类型:signin / task_<key> / exchange_out / ...
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 关联业务 id(签到日期、任务 key 等),可空
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<CoinTransaction id={self.id} user_id={self.user_id} amount={self.amount}>"
class WithdrawOrder(Base):
"""提现单(现金 → 微信零钱)。状态机:pending → success / failed。
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金
并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM /
SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态。
"""
__tablename__ = "withdraw_order"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 归一化状态:pending / success / failed
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
# 微信侧原始状态
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 微信转账单号
transfer_bill_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 待用户确认时返回给 App 拉起确认页的 package_info
package_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<WithdrawOrder id={self.id} user_id={self.user_id} cents={self.amount_cents} {self.status}>"
class CashTransaction(Base):
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
__tablename__ = "cash_transaction"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 正数=入账(兑入),负数=出账(提现)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 业务类型:exchange_in / withdraw
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
+35
View File
@@ -0,0 +1,35 @@
"""看激励视频发奖相关 schemas。
约定同其余模块:字段 snake_case、金额/次数存整数。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class PangleCallbackOut(BaseModel):
"""回给穿山甲的回调响应。is_valid=True 表示已受理(发奖或当日已达上限均算受理,
穿山甲不必重试);验签失败由 API 层直接返 403,不走这里。"""
is_valid: bool = True
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="看完一个激励视频发的金币")
+214
View File
@@ -0,0 +1,214 @@
"""福利模块(钱包 / 签到 / 任务)请求 / 响应 schemas。
约定同 auth:字段 snake_case、时间 ISO 8601(UTC)、金额存整数。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ===== 钱包 / 我的资产 =====
class CoinAccountOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
coin_balance: int = Field(..., description="当前金币余额")
cash_balance_cents: int = Field(..., description="当前现金余额(分)")
total_coin_earned: int = Field(..., description="累计赚取金币")
class CoinTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
amount: int = Field(..., description="正=入账,负=出账")
balance_after: int
biz_type: str
ref_id: str | None = None
remark: str | None = None
created_at: datetime
class CoinTransactionPage(BaseModel):
items: list[CoinTransactionOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
class CashTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
amount_cents: int = Field(..., description="正=兑入,负=提现")
balance_after_cents: int
biz_type: str
ref_id: str | None = None
remark: str | None = None
created_at: datetime
class CashTransactionPage(BaseModel):
items: list[CashTransactionOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
# ===== 金币兑现金 =====
class ExchangeInfoOut(BaseModel):
coin_per_yuan: int = Field(..., description="多少金币兑 1 元")
min_coin: int = Field(..., description="单次兑换最少金币")
step_coin: int = Field(..., description="兑换金币需为该值的整数倍(整分)")
class ExchangeRequest(BaseModel):
coin_amount: int = Field(..., gt=0, description="要兑换的金币数")
class ExchangeResultOut(BaseModel):
coin_amount: int = Field(..., description="本次扣除的金币")
cash_added_cents: int = Field(..., description="本次兑入的现金(分)")
coin_balance: int = Field(..., description="兑换后金币余额")
cash_balance_cents: int = Field(..., description="兑换后现金余额(分)")
# ===== 提现(现金 → 微信零钱) =====
class WithdrawInfoOut(BaseModel):
min_cents: int = Field(..., description="单次最低提现(分)")
max_cents: int = Field(..., description="单次最高提现(分)")
wechat_bound: bool = Field(..., description="当前用户是否已绑定微信")
wechat_nickname: str | None = Field(None, description="微信昵称(可能为空/脱敏)")
wechat_avatar_url: str | None = Field(None, description="微信头像 URL(可能为空)")
class BindWechatRequest(BaseModel):
code: str = Field(..., description="微信授权返回的 code")
class BindWechatResultOut(BaseModel):
bound: bool = True
wechat_nickname: str | None = None
wechat_avatar_url: str | None = None
class UnbindWechatResultOut(BaseModel):
bound: bool = False
class WithdrawRequest(BaseModel):
amount_cents: int = Field(..., gt=0, description="提现金额(分)")
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
)
class WithdrawResultOut(BaseModel):
out_bill_no: str = Field(..., description="商户提现单号(查单用)")
status: str = Field(..., description="pending / success / failed")
wechat_state: str | None = Field(None, description="微信侧原始状态")
amount_cents: int
cash_balance_cents: int = Field(..., description="提现后现金余额(分)")
# 供 App 拉起微信确认页(WXOpenBusinessView requestMerchantTransfer)
package_info: str | None = None
mch_id: str | None = None
app_id: str | None = None
class WithdrawStatusOut(BaseModel):
out_bill_no: str
status: str = Field(..., description="pending / success / failed")
wechat_state: str | None = None
amount_cents: int
class WithdrawOrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
out_bill_no: str
amount_cents: int
status: str
wechat_state: str | None = None
fail_reason: str | None = None
created_at: datetime
class WithdrawOrderPage(BaseModel):
items: list[WithdrawOrderOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
# ===== 签到 =====
class SigninStepOut(BaseModel):
day: int = Field(..., description="循环内第几天 1..7")
coin: int
status: str = Field(..., description="claimed / today / locked")
class SigninStatusOut(BaseModel):
today_signed: bool
consecutive_days: int
today_cycle_day: int
today_coin: int
can_claim: bool
steps: list[SigninStepOut]
class SigninResultOut(BaseModel):
coin_awarded: int
cycle_day: int
streak: int
coin_balance: int = Field(..., description="签到后金币余额")
# ===== 任务 =====
class TaskOut(BaseModel):
task_key: str
coin: int
claimed: bool
class TaskListOut(BaseModel):
items: list[TaskOut]
class TaskClaimResultOut(BaseModel):
task_key: str
coin_awarded: int
coin_balance: int = Field(..., description="领奖后金币余额")
# ===== 省钱(累计帮你省了 / 省钱战绩 / 明细)=====
class SavingsSummaryOut(BaseModel):
total_saved_cents: int = Field(..., description="累计省下(分)")
order_count: int = Field(..., description="累计省钱订单数")
avg_saved_cents: int = Field(..., description="平均每单省(分)")
class SavingsBattleOut(BaseModel):
week_saved_cents: int = Field(..., description="本周已省(分)")
beat_percent: int = Field(..., description="超过百分之多少用户")
streak_days: int = Field(..., description="连续省钱天数")
class SavingsRecordOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
order_amount_cents: int
saved_amount_cents: int
platform: str | None = None
title: str | None = None
shop_name: str | None = None
dishes: list[str] = []
created_at: datetime
class SavingsRecordPage(BaseModel):
items: list[SavingsRecordOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
+70
View File
@@ -0,0 +1,70 @@
# 「看广告赚金币」上线 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)—— 阻塞于穿山甲后台
- [ ] **穿山甲后台配 S2S 回调 URL**`https://app-api.shaguabijia.com/api/v1/ad/pangle-callback`
- [ ] **拿到真实验签方案 / 公钥**(后台奖励校验密钥;大概率 RSA)
- [ ] **换验签**:`app/core/pangle.py``verify_callback_sign` 从占位 HMAC-SHA256 改成穿山甲真实方案
- [ ] 对齐回调字段名(`user_id` / `trans_id` / 奖励数量等);客户端已透传 `setUserID` + `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 并行。
+38
View File
@@ -0,0 +1,38 @@
"""提现对账(#4 孤儿单兜底)。
扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化:
- 微信 SUCCESS → 置 success
- 微信失败/取消/关闭/无此单 → 退回现金 + 置 failed
- WAIT_USER_CONFIRM(久未确认)→ 撤销 + 退款
防止"扣了款但转账没发起/没确认"的单永久锁住用户余额。
用法(建议 cron / systemd timer 每 5~10 分钟跑一次):
python -m scripts.reconcile_withdraws # 默认处理 >15 分钟的 pending
python -m scripts.reconcile_withdraws 30 # 处理 >30 分钟的 pending
"""
from __future__ import annotations
import sys
# Windows 控制台按 UTF-8 输出中文/¥
try:
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
except Exception: # noqa: BLE001
pass
from app.crud import wallet as crud_wallet
from app.db.session import SessionLocal
def main() -> None:
minutes = int(sys.argv[1]) if len(sys.argv) > 1 else 15
db = SessionLocal()
try:
result = crud_wallet.reconcile_pending_withdraws(db, older_than_minutes=minutes)
print(f"对账完成: 检查 {result['checked']} 笔 pending(>{minutes}分钟), 归结 {result['resolved']}")
finally:
db.close()
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
"""清空指定用户的签到记录,方便反复测试"未签到→自动弹窗→签到领金币"
默认**只删 signin_record**(签到历史),不动金币/现金——这样能保留你已注入/兑换的余额,
只把"今天是否已签到 / 连续天数"重置掉。删后当天即变成"未签到",再进福利页会重新自动弹签到弹窗。
⚠️ 注意:只删签到记录的话,之前签到已发的金币仍留在余额和 coin_transaction 里(biz_type='signin')。
如果想把签到发的金币也一并退掉(余额减回、删 signin 流水),加 --with-coins。
用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑):
python scripts/reset_signin.py # 只清 user_id=2 的签到记录
python scripts/reset_signin.py 5 # 清 user_id=5
python scripts/reset_signin.py --all # 清所有用户的签到记录
python scripts/reset_signin.py --with-coins # 连签到发的金币一起退回(余额减、删 signin 流水)
python scripts/reset_signin.py --dry-run # 只看现状,不改
DB 默认取 <项目根>/data/app.db,可用 --db 指定。
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
# 本脚本在 scripts/ 下,项目根是上一级
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
"""返回签到记录数 + 签到金币流水数/总额 + 账户金币余额,用于打印 before/after。"""
signin_rows = cur.execute(
f"select count(*) from signin_record where {where}", params
).fetchone()[0]
signin_coin_rows, signin_coin_sum = cur.execute(
f"select count(*), coalesce(sum(amount), 0) from coin_transaction "
f"where biz_type='signin' and {where}",
params,
).fetchone()
accounts = cur.execute(
f"select user_id, coin_balance, total_coin_earned from coin_account where {where}",
params,
).fetchall()
return {
"signin_rows": signin_rows,
"signin_coin_rows": signin_coin_rows,
"signin_coin_sum": signin_coin_sum,
"accounts": accounts,
}
def print_snapshot(label: str, snap: dict) -> None:
print(f"--- {label} ---")
print(f" signin_record: {snap['signin_rows']}")
print(f" coin_transaction(signin): {snap['signin_coin_rows']} 条, 共 {snap['signin_coin_sum']} 金币")
if snap["accounts"]:
for uid, coin, earned in snap["accounts"]:
print(f" coin_account user={uid}: coin={coin} earned={earned}")
else:
print(" coin_account: (无)")
def main() -> None:
parser = argparse.ArgumentParser(description="清空用户签到记录")
parser.add_argument("user_id", nargs="?", type=int, default=2,
help="要清的 user_id(默认 2)")
parser.add_argument("--all", action="store_true", help="清所有用户")
parser.add_argument("--with-coins", action="store_true",
help="同时退回签到发的金币(余额减、删 signin 流水)")
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
args = parser.parse_args()
if args.all:
where, params, scope = "1=1", (), "ALL users"
else:
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
db_path = Path(args.db)
if not db_path.exists():
raise SystemExit(f"DB 不存在: {db_path}")
conn = sqlite3.connect(str(db_path))
cur = conn.cursor()
print(f"DB: {db_path} scope: {scope} with_coins: {args.with_coins}")
print_snapshot("before", snapshot(cur, where, params))
if args.dry_run:
print("(dry-run,未修改)")
conn.close()
return
if args.with_coins:
# 先按用户把签到金币总额退回(余额、累计收益都减),再删 signin 流水
rows = cur.execute(
f"select user_id, coalesce(sum(amount), 0) from coin_transaction "
f"where biz_type='signin' and {where} group by user_id",
params,
).fetchall()
for uid, total in rows:
cur.execute(
"update coin_account set coin_balance = coin_balance - ?, "
"total_coin_earned = max(0, total_coin_earned - ?) where user_id = ?",
(total, total, uid),
)
cur.execute(f"delete from coin_transaction where biz_type='signin' and {where}", params)
cur.execute(f"delete from signin_record where {where}", params)
conn.commit()
print_snapshot("after", snapshot(cur, where, params))
conn.close()
print("签到记录已清空。提醒:App 内存里的状态不会自动同步,重启 App 或进收益明细页刷新;"
"重进福利页(当天未签到)会重新自动弹签到弹窗。")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
"""一键回滚某个用户的福利数据(金币/现金/签到/一次性任务/看广告发奖),方便反复测试。
清掉:coin_transaction / cash_transaction / signin_record / user_task / ad_reward_record,
并把 coin_account 的金币余额累计收益现金余额全部归零
**不动** savings_record(那是 profile 省钱卡的 demo 演示数据,与领奖测试无关)
用法( shaguabijia-app-server 目录下,用本项目的 python ):
python scripts/reset_welfare.py # 默认重置 user_id=2(手机登录用户)
python scripts/reset_welfare.py 5 # 重置 user_id=5
python scripts/reset_welfare.py --all # 重置所有用户
python scripts/reset_welfare.py --dry-run # 只看现状,不改
DB 默认取 <项目根>/data/app.db,可用 --db 指定
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
# 本脚本在 scripts/ 下,项目根是上一级
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
# 受影响的表:每个用户的钱包/行为数据
WALLET_TABLES = ("coin_transaction", "cash_transaction", "signin_record", "user_task", "ad_reward_record")
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
"""返回当前各表行数 + 账户余额,用于打印 before/after。"""
counts = {
t: cur.execute(f"select count(*) from {t} where {where}", params).fetchone()[0]
for t in WALLET_TABLES
}
accounts = cur.execute(
f"select user_id, coin_balance, cash_balance_cents, total_coin_earned "
f"from coin_account where {where}",
params,
).fetchall()
return {"counts": counts, "accounts": accounts}
def print_snapshot(label: str, snap: dict) -> None:
print(f"--- {label} ---")
for t, n in snap["counts"].items():
print(f" {t}: {n}")
if snap["accounts"]:
for uid, coin, cash, earned in snap["accounts"]:
print(f" coin_account user={uid}: coin={coin} cash_cents={cash} earned={earned}")
else:
print(" coin_account: (无)")
def main() -> None:
parser = argparse.ArgumentParser(description="回滚用户福利数据")
parser.add_argument("user_id", nargs="?", type=int, default=2,
help="要重置的 user_id(默认 2)")
parser.add_argument("--all", action="store_true", help="重置所有用户")
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
args = parser.parse_args()
if args.all:
where, params, scope = "1=1", (), "ALL users"
else:
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
db_path = Path(args.db)
if not db_path.exists():
raise SystemExit(f"DB 不存在: {db_path}")
conn = sqlite3.connect(str(db_path))
cur = conn.cursor()
print(f"DB: {db_path} scope: {scope}")
print_snapshot("before", snapshot(cur, where, params))
if args.dry_run:
print("(dry-run,未修改)")
conn.close()
return
for t in WALLET_TABLES:
cur.execute(f"delete from {t} where {where}", params)
cur.execute(
f"update coin_account set coin_balance=0, total_coin_earned=0, "
f"cash_balance_cents=0 where {where}",
params,
)
conn.commit()
print_snapshot("after", snapshot(cur, where, params))
conn.close()
print("回滚完成。提醒:App 内存里的共享余额态不会自动同步,重启 App 或进收益明细页刷新。")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
"""模拟穿山甲激励视频 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.core import pangle # noqa: E402
from app.core.config import settings # noqa: E402
from app.core.rewards import AD_REWARD_COIN # noqa: E402
from app.crud 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": "金币",
"reward_amount": "1",
}
params["sign"] = pangle.build_sign(params, 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()
+10
View File
@@ -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
+137
View File
@@ -0,0 +1,137 @@
"""看激励视频发奖测试(穿山甲 S2S 回调)。
回调无 JWT,靠验签测试用配置里的 mock 密钥自签自验覆盖:发奖到账幂等
验签失败每日上限未知用户客户端进度查询回调未配置
"""
from __future__ import annotations
from sqlalchemy import select
from app.core import pangle
from app.core.config import settings
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT
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]:
"""构造带合法签名的回调参数(模拟穿山甲服务器)。"""
params = {"user_id": str(user_id), "trans_id": trans_id, **extra}
params["sign"] = pangle.build_sign(params, 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-status 计数 +1。"""
phone = "13800003001"
token = _login(client, phone)
uid = _user_id(phone)
r = _callback(client, _signed(uid, "trans_a1", reward_name="金币", reward_amount="1"))
assert r.status_code == 200, r.text
assert r.json() == {"is_valid": True}
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_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_valid": True}
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_valid false),不崩。"""
params = _signed(999999, "trans_ghost")
r = _callback(client, params)
assert r.status_code == 200, r.text
assert r.json() == {"is_valid": False}
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
+257
View File
@@ -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
+271
View File
@@ -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.core.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.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_ok", "nickname": None, "avatar_url": None, "raw": {}})
monkeypatch.setattr(
"app.core.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.core.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.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_wait", "nickname": None, "avatar_url": None, "raw": {}})
monkeypatch.setattr(
"app.core.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.core.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.core.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.core.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.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_fail", "nickname": None, "avatar_url": None, "raw": {}})
monkeypatch.setattr(
"app.core.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.core.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.core.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.core.wxpay.create_transfer", _create)
# 幂等查单时(pending)会查一次,返回仍在途
monkeypatch.setattr(
"app.core.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.core.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.core.wxpay.create_transfer", _boom)
monkeypatch.setattr(
"app.core.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.core.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