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:
@@ -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')
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
|
||||
APP_NAME: str = "shaguabijia-app-server"
|
||||
APP_DEBUG: bool = True
|
||||
|
||||
# 接口限流开关(测试里关掉,避免内存计数跨用例累加误伤)
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
|
||||
# ===== 数据库 =====
|
||||
DATABASE_URL: str = "sqlite:///./data/app.db"
|
||||
|
||||
@@ -55,6 +58,44 @@ class Settings(BaseSettings):
|
||||
MT_CPS_HOST: str = "https://media.meituan.com"
|
||||
MT_CPS_TIMEOUT_SEC: int = 15
|
||||
MT_CPS_DEFAULT_SID: str = "sgbjia"
|
||||
# ===== 微信支付(商家转账到零钱 / 提现)=====
|
||||
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
|
||||
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
|
||||
WECHAT_APP_ID: str = "" # 开放平台移动应用 appid(wx 开头)
|
||||
WECHAT_APP_SECRET: str = "" # 开放平台 AppSecret,code 换 openid 用
|
||||
WXPAY_MCH_ID: str = "" # 微信支付商户号
|
||||
WXPAY_MCH_SERIAL_NO: str = "" # 商户 API 证书序列号
|
||||
WXPAY_MCH_PRIVATE_KEY_PATH: str = "./secrets/apiclient_key.pem" # 商户 API 私钥
|
||||
WXPAY_PUBLIC_KEY_ID: str = "" # 微信支付平台公钥 ID(PUB_KEY_ID_...)
|
||||
WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥
|
||||
WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销)
|
||||
WXPAY_REQUEST_TIMEOUT_SEC: int = 10
|
||||
|
||||
@property
|
||||
def wxpay_configured(self) -> bool:
|
||||
"""凭证是否齐全(缺则提现接口拒绝调用,而非启动时炸)。"""
|
||||
return bool(
|
||||
self.WECHAT_APP_ID
|
||||
and self.WXPAY_MCH_ID
|
||||
and self.WXPAY_MCH_SERIAL_NO
|
||||
and self.WXPAY_PUBLIC_KEY_ID
|
||||
)
|
||||
|
||||
# ===== 穿山甲激励视频(服务端发奖回调)=====
|
||||
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
|
||||
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
|
||||
PANGLE_CALLBACK_ENABLED: bool = False
|
||||
PANGLE_REWARD_SECRET: str = ""
|
||||
|
||||
# ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、
|
||||
# 穿山甲 S2S 回调打不到本地时,直接触发一次发奖,验证"看广告→金币到账"全链路。
|
||||
# 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。
|
||||
AD_REWARD_TEST_GRANT_ENABLED: bool = False
|
||||
|
||||
@property
|
||||
def pangle_callback_configured(self) -> bool:
|
||||
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
|
||||
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
|
||||
|
||||
# ===== Pricebot 上游 (领券业务透传目标) =====
|
||||
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""轻量内存限流(固定窗口计数)。
|
||||
|
||||
定位:防脚本刷接口的**安全网**,不是精确配额。局限——进程内存、单 worker 有效、重启清零、
|
||||
多 worker/多机不共享。上线放量后应换 Redis 后端 + 按用户维度。当前单 worker uvicorn 够用。
|
||||
|
||||
用法:把 `rate_limit(limit, window_sec, scope)` 作为路由依赖挂上,默认按客户端 IP 计数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# key -> (window_start_ts, count)
|
||||
_buckets: dict[str, tuple[float, int]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _hit(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""记一次访问。返回 True=放行,False=超限。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count = _buckets.get(key, (now, 0))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
count += 1
|
||||
_buckets[key] = (start, count)
|
||||
# 顺手清理过期 key,防内存无限涨(低频访问足够)
|
||||
if len(_buckets) > 10000:
|
||||
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
|
||||
_buckets.pop(k, None)
|
||||
return count <= limit
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
# nginx 反代后真实 IP 在 X-Forwarded-For 首段;直连用 request.client
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def rate_limit(limit: int, window_sec: float, scope: str):
|
||||
"""生成一个 FastAPI 依赖:同一 IP 在 window_sec 内对该 scope 超过 limit 次 → 429。"""
|
||||
|
||||
def _dep(request: Request) -> None:
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
key = f"{scope}:{_client_ip(request)}"
|
||||
if not _hit(key, limit, window_sec):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
return _dep
|
||||
@@ -0,0 +1,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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -0,0 +1,186 @@
|
||||
"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。
|
||||
|
||||
设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自
|
||||
demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。
|
||||
|
||||
聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑——
|
||||
created_at 带时区,统一转成北京时间的 date 再分组。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ, cn_today
|
||||
from app.models.savings import SavingsRecord
|
||||
|
||||
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
|
||||
_DEMO_STREAK_DAYS = 9
|
||||
_DEMO_EXTRA_ORDERS = 14
|
||||
|
||||
# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。
|
||||
# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中;
|
||||
# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。
|
||||
_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [
|
||||
("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]),
|
||||
("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]),
|
||||
("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]),
|
||||
("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]),
|
||||
("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]),
|
||||
("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]),
|
||||
("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]),
|
||||
("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]),
|
||||
("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]),
|
||||
("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SavingsSummary:
|
||||
total_saved_cents: int
|
||||
order_count: int
|
||||
avg_saved_cents: int # 平均每单省(分)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SavingsBattle:
|
||||
week_saved_cents: int # 本周(周一起)已省
|
||||
beat_percent: int # 超过百分之多少用户
|
||||
streak_days: int # 连续省钱天数
|
||||
|
||||
|
||||
def _local_date(dt: datetime):
|
||||
"""把(可能带时区的)created_at 转成北京时间的 date。"""
|
||||
if dt.tzinfo is None:
|
||||
return dt.date()
|
||||
return dt.astimezone(CN_TZ).date()
|
||||
|
||||
|
||||
def _all_records(db: Session, user_id: int) -> list[SavingsRecord]:
|
||||
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def ensure_seeded(db: Session, user_id: int) -> None:
|
||||
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
|
||||
exists = db.execute(
|
||||
select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1)
|
||||
).first()
|
||||
if exists is not None:
|
||||
return
|
||||
|
||||
rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致
|
||||
today = cn_today()
|
||||
records: list[SavingsRecord] = []
|
||||
|
||||
def _mk(day) -> SavingsRecord:
|
||||
hour = rng.randint(8, 21)
|
||||
minute = rng.randint(0, 59)
|
||||
ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ)
|
||||
platform, shop, dishes = rng.choice(_DEMO_ORDERS)
|
||||
# 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡
|
||||
saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元
|
||||
return SavingsRecord(
|
||||
user_id=user_id,
|
||||
order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价)
|
||||
saved_amount_cents=saved,
|
||||
platform=platform,
|
||||
title=shop,
|
||||
shop_name=shop,
|
||||
dishes=dishes,
|
||||
source="demo",
|
||||
created_at=ts,
|
||||
)
|
||||
|
||||
# 最近 N 天每天 1 单 → 连续省钱 N 天
|
||||
for d in range(_DEMO_STREAK_DAYS):
|
||||
records.append(_mk(today - timedelta(days=d)))
|
||||
# 历史散单(第 10~40 天)
|
||||
for _ in range(_DEMO_EXTRA_ORDERS):
|
||||
records.append(_mk(today - timedelta(days=rng.randint(10, 40))))
|
||||
|
||||
db.add_all(records)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_summary(db: Session, user_id: int) -> SavingsSummary:
|
||||
ensure_seeded(db, user_id)
|
||||
records = _all_records(db, user_id)
|
||||
total = sum(r.saved_amount_cents for r in records)
|
||||
count = len(records)
|
||||
avg = total // count if count else 0
|
||||
return SavingsSummary(total_saved_cents=total, order_count=count, avg_saved_cents=avg)
|
||||
|
||||
|
||||
def _streak_days(dates: set) -> int:
|
||||
"""从最近活跃日往前数连续有省钱记录的天数。"""
|
||||
if not dates:
|
||||
return 0
|
||||
cur = max(dates)
|
||||
streak = 0
|
||||
while cur in dates:
|
||||
streak += 1
|
||||
cur = cur - timedelta(days=1)
|
||||
return streak
|
||||
|
||||
|
||||
def get_battle(db: Session, user_id: int) -> SavingsBattle:
|
||||
ensure_seeded(db, user_id)
|
||||
records = _all_records(db, user_id)
|
||||
|
||||
today = cn_today()
|
||||
week_start = today - timedelta(days=today.weekday()) # 本周一
|
||||
week_saved = sum(
|
||||
r.saved_amount_cents for r in records if _local_date(r.created_at) >= week_start
|
||||
)
|
||||
|
||||
dates = {_local_date(r.created_at) for r in records}
|
||||
streak = _streak_days(dates)
|
||||
|
||||
beat_percent = _compute_beat_percent(db, user_id)
|
||||
|
||||
return SavingsBattle(
|
||||
week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak
|
||||
)
|
||||
|
||||
|
||||
def _compute_beat_percent(db: Session, user_id: int) -> int:
|
||||
"""超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。
|
||||
|
||||
= 严格少于我的其他用户数 / 其他用户总数 * 100。
|
||||
全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。
|
||||
"""
|
||||
rows = db.execute(
|
||||
select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents))
|
||||
.group_by(SavingsRecord.user_id)
|
||||
).all()
|
||||
totals = {uid: (total or 0) for uid, total in rows}
|
||||
others = {uid: t for uid, t in totals.items() if uid != user_id}
|
||||
if not others:
|
||||
return 0
|
||||
my_total = totals.get(user_id, 0)
|
||||
beaten = sum(1 for t in others.values() if t < my_total)
|
||||
return round(100 * beaten / len(others))
|
||||
|
||||
|
||||
def list_records(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[SavingsRecord], int | None]:
|
||||
"""省钱明细分页(按 id 倒序,游标式)。"""
|
||||
ensure_seeded(db, user_id)
|
||||
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(SavingsRecord.id < cursor)
|
||||
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
|
||||
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
return items, next_cursor
|
||||
@@ -0,0 +1,126 @@
|
||||
"""签到 CRUD:状态查询 + 执行签到。
|
||||
|
||||
7 天循环规则:
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续)
|
||||
- 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置)
|
||||
今天的 cycle_day 决定发多少金币(见 app.core.rewards.SIGNIN_REWARDS)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import SIGNIN_CYCLE_LEN, SIGNIN_REWARDS, cn_today, signin_reward
|
||||
from app.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
|
||||
@@ -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
|
||||
@@ -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
@@ -11,9 +11,14 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.ad import router as ad_router
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.v1.meituan import router as meituan_router
|
||||
from app.api.v1.savings import router as savings_router
|
||||
from app.api.v1.signin import router as signin_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
@@ -61,3 +66,8 @@ def health() -> dict[str, str]:
|
||||
app.include_router(auth_router)
|
||||
app.include_router(coupon_router)
|
||||
app.include_router(meituan_router)
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(signin_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(savings_router)
|
||||
app.include_router(ad_router)
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.savings import SavingsRecord # noqa: F401
|
||||
from app.models.signin import SigninRecord # noqa: F401
|
||||
from app.models.task import UserTask # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.wallet import ( # noqa: F401
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""看激励视频发奖记录(穿山甲 S2S 回调)。
|
||||
|
||||
每条 = 穿山甲一次发奖回调。`trans_id`(穿山甲交易号)唯一,做幂等键:穿山甲会重试回调,
|
||||
同号只发一次金币。`reward_date`(北京时间日期串)给"每日上限"计数用——按日期串等值查,
|
||||
不在 SQL 里做跨时区 date 比较(SQLite 上不可靠)。审计 / 对账时整张表可逐笔回溯。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AdRewardRecord(Base):
|
||||
__tablename__ = "ad_reward_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 穿山甲交易号,幂等键(同号回调不重复发奖)
|
||||
trans_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 实发金币(capped 时为 0)
|
||||
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# granted(已发) / capped(当日超限未发)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
|
||||
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
# 穿山甲上报的奖励名(参考,不作发奖依据)
|
||||
reward_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 回调原始参数,审计排查用
|
||||
raw: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} {self.status} coin={self.coin}>"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""省钱记录表。
|
||||
|
||||
是 profile「累计帮你省了」和「省钱战绩」的唯一数据源:每完成一次比价/下单,
|
||||
省了多少记一行。本期比价还没接后端,数据由 demo seeder 幂等灌入(source='demo'),
|
||||
聚合接口照常做真实 SUM/分组;等比价真接入后,改成上报写入、停掉 seeder 即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SavingsRecord(Base):
|
||||
__tablename__ = "savings_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 订单金额 / 省下金额,单位:分
|
||||
order_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
saved_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」)
|
||||
shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。
|
||||
dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
# 来源:demo(演示) / compare(真实比价上报)
|
||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<SavingsRecord id={self.id} user_id={self.user_id} saved={self.saved_amount_cents}>"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""签到记录表。
|
||||
|
||||
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
|
||||
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。
|
||||
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SigninRecord(Base):
|
||||
__tablename__ = "signin_record"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "signin_date", name="uq_signin_user_date"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 签到日期(北京时间的 date)
|
||||
signin_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
cycle_day: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
streak: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<SigninRecord user_id={self.user_id} date={self.signin_date} "
|
||||
f"day={self.cycle_day} streak={self.streak}>"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""一次性任务完成记录表。
|
||||
|
||||
像"打开消息提醒"这类只能领一次的任务,完成后写一行,(user_id, task_key) 唯一,
|
||||
防止重复领奖。可循环领取的任务(签到)不走这张表,各有专表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserTask(Base):
|
||||
__tablename__ = "user_task"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "task_key", name="uq_task_user_key"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 任务标识,见 app.core.rewards.TASK_REWARDS
|
||||
task_key: Mapped[str] = mapped_column(String(48), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="completed")
|
||||
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
completed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<UserTask user_id={self.user_id} key={self.task_key}>"
|
||||
@@ -33,6 +33,15 @@ class User(Base):
|
||||
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# 微信开放平台 openid(微信登录绑定后存,提现转账到零钱用)。同一 appid 下唯一;
|
||||
# unique 索引保证一个微信只能绑一个账号(nullable,允许多个 NULL=未绑定)。
|
||||
wechat_openid: Mapped[str | None] = mapped_column(
|
||||
String(64), unique=True, index=True, nullable=True
|
||||
)
|
||||
# 微信昵称/头像(绑定时从 sns/userinfo 拉,展示在提现绑定卡)。与上面通用 nickname/avatar_url 分开,不互相覆盖。
|
||||
wechat_nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
wechat_avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# 账号状态:active / disabled / deleted
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""钱包相关表:金币账户 + 金币流水。
|
||||
|
||||
设计要点:
|
||||
- 余额快照(coin_account)用于读取,流水账本(coin_transaction)用于对账,
|
||||
每次余额变动都写一笔流水并记 balance_after,出问题能逐笔回溯。
|
||||
- 金额一律存整数:金币是个数,现金存"分"(cash_balance_cents),不用浮点。
|
||||
- cash_balance_cents 本步先留列、恒为 0,金币兑现金(第 2 步)再启用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class CoinAccount(Base):
|
||||
__tablename__ = "coin_account"
|
||||
|
||||
# 一个用户一行,user_id 既是主键也是外键
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), primary_key=True
|
||||
)
|
||||
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
|
||||
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CoinAccount user_id={self.user_id} coin={self.coin_balance}>"
|
||||
|
||||
|
||||
class CoinTransaction(Base):
|
||||
__tablename__ = "coin_transaction"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 正数=入账(赚),负数=出账(花/兑换)
|
||||
amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 本笔变动后的金币余额,对账用
|
||||
balance_after: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 业务类型:signin / task_<key> / exchange_out / ...
|
||||
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 关联业务 id(签到日期、任务 key 等),可空
|
||||
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CoinTransaction id={self.id} user_id={self.user_id} amount={self.amount}>"
|
||||
|
||||
|
||||
class WithdrawOrder(Base):
|
||||
"""提现单(现金 → 微信零钱)。状态机:pending → success / failed。
|
||||
|
||||
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金
|
||||
并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM /
|
||||
SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态。
|
||||
"""
|
||||
|
||||
__tablename__ = "withdraw_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
|
||||
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 归一化状态:pending / success / failed
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
# 微信侧原始状态
|
||||
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 微信转账单号
|
||||
transfer_bill_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 待用户确认时返回给 App 拉起确认页的 package_info
|
||||
package_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<WithdrawOrder id={self.id} user_id={self.user_id} cents={self.amount_cents} {self.status}>"
|
||||
|
||||
|
||||
class CashTransaction(Base):
|
||||
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
|
||||
|
||||
__tablename__ = "cash_transaction"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 正数=入账(兑入),负数=出账(提现)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 业务类型:exchange_in / withdraw
|
||||
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
|
||||
@@ -0,0 +1,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="看完一个激励视频发的金币")
|
||||
@@ -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);为空表示到底")
|
||||
@@ -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 并行。
|
||||
@@ -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()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""清空指定用户的签到记录,方便反复测试"未签到→自动弹窗→签到领金币"。
|
||||
|
||||
默认**只删 signin_record**(签到历史),不动金币/现金——这样能保留你已注入/兑换的余额,
|
||||
只把"今天是否已签到 / 连续天数"重置掉。删后当天即变成"未签到",再进福利页会重新自动弹签到弹窗。
|
||||
|
||||
⚠️ 注意:只删签到记录的话,之前签到已发的金币仍留在余额和 coin_transaction 里(biz_type='signin')。
|
||||
如果想把签到发的金币也一并退掉(余额减回、删 signin 流水),加 --with-coins。
|
||||
|
||||
用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑):
|
||||
python scripts/reset_signin.py # 只清 user_id=2 的签到记录
|
||||
python scripts/reset_signin.py 5 # 清 user_id=5
|
||||
python scripts/reset_signin.py --all # 清所有用户的签到记录
|
||||
python scripts/reset_signin.py --with-coins # 连签到发的金币一起退回(余额减、删 signin 流水)
|
||||
python scripts/reset_signin.py --dry-run # 只看现状,不改
|
||||
|
||||
DB 默认取 <项目根>/data/app.db,可用 --db 指定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
# 本脚本在 scripts/ 下,项目根是上一级
|
||||
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
|
||||
|
||||
|
||||
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
|
||||
"""返回签到记录数 + 签到金币流水数/总额 + 账户金币余额,用于打印 before/after。"""
|
||||
signin_rows = cur.execute(
|
||||
f"select count(*) from signin_record where {where}", params
|
||||
).fetchone()[0]
|
||||
signin_coin_rows, signin_coin_sum = cur.execute(
|
||||
f"select count(*), coalesce(sum(amount), 0) from coin_transaction "
|
||||
f"where biz_type='signin' and {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
accounts = cur.execute(
|
||||
f"select user_id, coin_balance, total_coin_earned from coin_account where {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
return {
|
||||
"signin_rows": signin_rows,
|
||||
"signin_coin_rows": signin_coin_rows,
|
||||
"signin_coin_sum": signin_coin_sum,
|
||||
"accounts": accounts,
|
||||
}
|
||||
|
||||
|
||||
def print_snapshot(label: str, snap: dict) -> None:
|
||||
print(f"--- {label} ---")
|
||||
print(f" signin_record: {snap['signin_rows']}")
|
||||
print(f" coin_transaction(signin): {snap['signin_coin_rows']} 条, 共 {snap['signin_coin_sum']} 金币")
|
||||
if snap["accounts"]:
|
||||
for uid, coin, earned in snap["accounts"]:
|
||||
print(f" coin_account user={uid}: coin={coin} earned={earned}")
|
||||
else:
|
||||
print(" coin_account: (无)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="清空用户签到记录")
|
||||
parser.add_argument("user_id", nargs="?", type=int, default=2,
|
||||
help="要清的 user_id(默认 2)")
|
||||
parser.add_argument("--all", action="store_true", help="清所有用户")
|
||||
parser.add_argument("--with-coins", action="store_true",
|
||||
help="同时退回签到发的金币(余额减、删 signin 流水)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
|
||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
where, params, scope = "1=1", (), "ALL users"
|
||||
else:
|
||||
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
raise SystemExit(f"DB 不存在: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cur = conn.cursor()
|
||||
print(f"DB: {db_path} scope: {scope} with_coins: {args.with_coins}")
|
||||
print_snapshot("before", snapshot(cur, where, params))
|
||||
|
||||
if args.dry_run:
|
||||
print("(dry-run,未修改)")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if args.with_coins:
|
||||
# 先按用户把签到金币总额退回(余额、累计收益都减),再删 signin 流水
|
||||
rows = cur.execute(
|
||||
f"select user_id, coalesce(sum(amount), 0) from coin_transaction "
|
||||
f"where biz_type='signin' and {where} group by user_id",
|
||||
params,
|
||||
).fetchall()
|
||||
for uid, total in rows:
|
||||
cur.execute(
|
||||
"update coin_account set coin_balance = coin_balance - ?, "
|
||||
"total_coin_earned = max(0, total_coin_earned - ?) where user_id = ?",
|
||||
(total, total, uid),
|
||||
)
|
||||
cur.execute(f"delete from coin_transaction where biz_type='signin' and {where}", params)
|
||||
|
||||
cur.execute(f"delete from signin_record where {where}", params)
|
||||
conn.commit()
|
||||
|
||||
print_snapshot("after", snapshot(cur, where, params))
|
||||
conn.close()
|
||||
print("签到记录已清空。提醒:App 内存里的状态不会自动同步,重启 App 或进收益明细页刷新;"
|
||||
"重进福利页(当天未签到)会重新自动弹签到弹窗。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""一键回滚某个用户的福利数据(金币/现金/签到/一次性任务/看广告发奖),方便反复测试。
|
||||
|
||||
清掉:coin_transaction / cash_transaction / signin_record / user_task / ad_reward_record,
|
||||
并把 coin_account 的金币余额、累计收益、现金余额全部归零。
|
||||
**不动** savings_record(那是 profile 省钱卡的 demo 演示数据,与领奖测试无关)。
|
||||
|
||||
用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑):
|
||||
python scripts/reset_welfare.py # 默认重置 user_id=2(手机登录用户)
|
||||
python scripts/reset_welfare.py 5 # 重置 user_id=5
|
||||
python scripts/reset_welfare.py --all # 重置所有用户
|
||||
python scripts/reset_welfare.py --dry-run # 只看现状,不改
|
||||
|
||||
DB 默认取 <项目根>/data/app.db,可用 --db 指定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
# 本脚本在 scripts/ 下,项目根是上一级
|
||||
DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db"
|
||||
|
||||
# 受影响的表:每个用户的钱包/行为数据
|
||||
WALLET_TABLES = ("coin_transaction", "cash_transaction", "signin_record", "user_task", "ad_reward_record")
|
||||
|
||||
|
||||
def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict:
|
||||
"""返回当前各表行数 + 账户余额,用于打印 before/after。"""
|
||||
counts = {
|
||||
t: cur.execute(f"select count(*) from {t} where {where}", params).fetchone()[0]
|
||||
for t in WALLET_TABLES
|
||||
}
|
||||
accounts = cur.execute(
|
||||
f"select user_id, coin_balance, cash_balance_cents, total_coin_earned "
|
||||
f"from coin_account where {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
return {"counts": counts, "accounts": accounts}
|
||||
|
||||
|
||||
def print_snapshot(label: str, snap: dict) -> None:
|
||||
print(f"--- {label} ---")
|
||||
for t, n in snap["counts"].items():
|
||||
print(f" {t}: {n}")
|
||||
if snap["accounts"]:
|
||||
for uid, coin, cash, earned in snap["accounts"]:
|
||||
print(f" coin_account user={uid}: coin={coin} cash_cents={cash} earned={earned}")
|
||||
else:
|
||||
print(" coin_account: (无)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="回滚用户福利数据")
|
||||
parser.add_argument("user_id", nargs="?", type=int, default=2,
|
||||
help="要重置的 user_id(默认 2)")
|
||||
parser.add_argument("--all", action="store_true", help="重置所有用户")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改")
|
||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
where, params, scope = "1=1", (), "ALL users"
|
||||
else:
|
||||
where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}"
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
raise SystemExit(f"DB 不存在: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cur = conn.cursor()
|
||||
print(f"DB: {db_path} scope: {scope}")
|
||||
print_snapshot("before", snapshot(cur, where, params))
|
||||
|
||||
if args.dry_run:
|
||||
print("(dry-run,未修改)")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
for t in WALLET_TABLES:
|
||||
cur.execute(f"delete from {t} where {where}", params)
|
||||
cur.execute(
|
||||
f"update coin_account set coin_balance=0, total_coin_earned=0, "
|
||||
f"cash_balance_cents=0 where {where}",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
print_snapshot("after", snapshot(cur, where, params))
|
||||
conn.close()
|
||||
print("回滚完成。提醒:App 内存里的共享余额态不会自动同步,重启 App 或进收益明细页刷新。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -23,6 +23,16 @@ os.environ.setdefault("JG_MASTER_SECRET", "test-secret")
|
||||
os.environ.setdefault("SMS_MOCK", "true")
|
||||
os.environ.setdefault("APP_ENV", "dev")
|
||||
os.environ.setdefault("APP_DEBUG", "false") # 测试不打 SQL 日志
|
||||
# 微信支付:填 dummy 让 wxpay_configured 为真(测试里 wxpay 的网络调用全 monkeypatch,不会真发)
|
||||
os.environ.setdefault("WECHAT_APP_ID", "wxtest0000000000")
|
||||
os.environ.setdefault("WECHAT_APP_SECRET", "test-secret")
|
||||
os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
|
||||
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
|
||||
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
|
||||
os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨用例累加,测试关掉
|
||||
# 穿山甲发奖回调:测试里开启 + 给个 mock 验签密钥,test 内自签自验闭环
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,257 @@
|
||||
"""福利模块测试:钱包 / 签到 / 一次性任务。
|
||||
|
||||
用 sms mock 登录拿 token,再跑各接口闭环。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.rewards import (
|
||||
COIN_PER_CENT,
|
||||
COIN_PER_YUAN,
|
||||
MIN_EXCHANGE_COIN,
|
||||
SIGNIN_REWARDS,
|
||||
TASK_ENABLE_NOTIFICATION,
|
||||
TASK_REWARDS,
|
||||
)
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
"""sms mock 登录,返回 access_token。"""
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_account_auto_created_empty(client) -> None:
|
||||
"""首次访问账户:自动建空账户,余额全 0。"""
|
||||
token = _login(client, "13800001001")
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body == {"coin_balance": 0, "cash_balance_cents": 0, "total_coin_earned": 0}
|
||||
|
||||
|
||||
def test_signin_flow(client) -> None:
|
||||
"""签到状态 → 签到 → 余额到账 → 重复签到 409 → 流水有记录。"""
|
||||
token = _login(client, "13800001002")
|
||||
|
||||
# 1. 签到前状态:未签、可签、今天是第 1 档
|
||||
r = client.get("/api/v1/signin/status", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
st = r.json()
|
||||
assert st["today_signed"] is False
|
||||
assert st["can_claim"] is True
|
||||
assert st["today_cycle_day"] == 1
|
||||
assert st["today_coin"] == SIGNIN_REWARDS[0]
|
||||
assert len(st["steps"]) == len(SIGNIN_REWARDS)
|
||||
assert st["steps"][0]["status"] == "today"
|
||||
|
||||
# 2. 执行签到,发第 1 档金币
|
||||
r = client.post("/api/v1/signin", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["cycle_day"] == 1
|
||||
assert res["streak"] == 1
|
||||
assert res["coin_awarded"] == SIGNIN_REWARDS[0]
|
||||
assert res["coin_balance"] == SIGNIN_REWARDS[0]
|
||||
|
||||
# 3. 账户余额到账
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["coin_balance"] == SIGNIN_REWARDS[0]
|
||||
|
||||
# 4. 状态变为已签
|
||||
r = client.get("/api/v1/signin/status", headers=_auth(token))
|
||||
st = r.json()
|
||||
assert st["today_signed"] is True
|
||||
assert st["can_claim"] is False
|
||||
assert st["consecutive_days"] == 1
|
||||
assert st["steps"][0]["status"] == "claimed"
|
||||
|
||||
# 5. 重复签到 → 409
|
||||
r = client.post("/api/v1/signin", headers=_auth(token))
|
||||
assert r.status_code == 409
|
||||
|
||||
# 6. 金币流水有一笔 signin 入账
|
||||
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
page = r.json()
|
||||
assert page["next_cursor"] is None
|
||||
assert len(page["items"]) == 1
|
||||
txn = page["items"][0]
|
||||
assert txn["amount"] == SIGNIN_REWARDS[0]
|
||||
assert txn["biz_type"] == "signin"
|
||||
assert txn["balance_after"] == SIGNIN_REWARDS[0]
|
||||
|
||||
|
||||
def test_task_claim_flow(client) -> None:
|
||||
"""任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。"""
|
||||
token = _login(client, "13800001003")
|
||||
coin = TASK_REWARDS[TASK_ENABLE_NOTIFICATION]
|
||||
|
||||
# 1. 任务列表:未领取
|
||||
r = client.get("/api/v1/tasks", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
items = {it["task_key"]: it for it in r.json()["items"]}
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == coin
|
||||
|
||||
# 2. 领奖
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["coin_awarded"] == coin
|
||||
assert r.json()["coin_balance"] == coin
|
||||
|
||||
# 3. 重复领 → 409
|
||||
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
assert r.status_code == 409
|
||||
|
||||
# 4. 列表变为已领取
|
||||
r = client.get("/api/v1/tasks", headers=_auth(token))
|
||||
items = {it["task_key"]: it for it in r.json()["items"]}
|
||||
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True
|
||||
|
||||
# 5. 未知任务 → 404
|
||||
r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_exchange_info(client) -> None:
|
||||
token = _login(client, "13800001004")
|
||||
r = client.get("/api/v1/wallet/exchange-info", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["coin_per_yuan"] == COIN_PER_YUAN
|
||||
assert body["min_coin"] == MIN_EXCHANGE_COIN
|
||||
assert body["step_coin"] == COIN_PER_CENT
|
||||
|
||||
|
||||
def test_exchange_flow(client) -> None:
|
||||
"""先用任务领够金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。"""
|
||||
token = _login(client, "13800001005")
|
||||
# 领"打开消息提醒"得 10000 金币(= 1 元额度)
|
||||
client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
|
||||
|
||||
# 兑换 10000 金币 → 100 分
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": COIN_PER_YUAN},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["coin_amount"] == COIN_PER_YUAN
|
||||
assert res["cash_added_cents"] == 100
|
||||
assert res["coin_balance"] == 0
|
||||
assert res["cash_balance_cents"] == 100
|
||||
|
||||
# 账户余额同步
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["coin_balance"] == 0
|
||||
assert r.json()["cash_balance_cents"] == 100
|
||||
|
||||
# 金币流水多了一笔 exchange_out(负)
|
||||
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
|
||||
types = [t["biz_type"] for t in r.json()["items"]]
|
||||
assert "exchange_out" in types
|
||||
out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out")
|
||||
assert out_txn["amount"] == -COIN_PER_YUAN
|
||||
|
||||
# 现金流水有一笔 exchange_in(正)
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
page = r.json()
|
||||
assert len(page["items"]) == 1
|
||||
assert page["items"][0]["amount_cents"] == 100
|
||||
assert page["items"][0]["biz_type"] == "exchange_in"
|
||||
|
||||
|
||||
def test_exchange_insufficient_and_invalid(client) -> None:
|
||||
token = _login(client, "13800001006")
|
||||
|
||||
# 余额 0 → 兑换 1 元 → 409 余额不足
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": COIN_PER_YUAN},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
# 低于最小额 → 400
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": MIN_EXCHANGE_COIN - COIN_PER_CENT},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# 非整分倍数(带零头)→ 400
|
||||
r = client.post(
|
||||
"/api/v1/wallet/exchange",
|
||||
json={"coin_amount": COIN_PER_YUAN + 1},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_savings_summary_and_battle(client) -> None:
|
||||
"""首次访问触发 demo seeder,聚合接口返回真实算出的数字。"""
|
||||
token = _login(client, "13800001007")
|
||||
|
||||
r = client.get("/api/v1/savings/summary", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
s = r.json()
|
||||
assert s["order_count"] > 0
|
||||
assert s["total_saved_cents"] > 0
|
||||
# 平均每单 = 总额 // 单数
|
||||
assert s["avg_saved_cents"] == s["total_saved_cents"] // s["order_count"]
|
||||
|
||||
r = client.get("/api/v1/savings/battle", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
b = r.json()
|
||||
assert b["streak_days"] >= 1
|
||||
assert b["week_saved_cents"] >= 0
|
||||
assert 0 <= b["beat_percent"] <= 100
|
||||
|
||||
|
||||
def test_savings_seeder_idempotent(client) -> None:
|
||||
"""重复访问不应重复灌数据:两次 summary 完全一致。"""
|
||||
token = _login(client, "13800001008")
|
||||
first = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
|
||||
second = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_savings_records_pagination(client) -> None:
|
||||
token = _login(client, "13800001009")
|
||||
# 第一页
|
||||
r = client.get("/api/v1/savings/records?limit=5", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
page1 = r.json()
|
||||
assert len(page1["items"]) == 5
|
||||
assert page1["next_cursor"] is not None
|
||||
# id 倒序
|
||||
ids = [it["id"] for it in page1["items"]]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
# 第二页接着游标走,不与第一页重叠
|
||||
r = client.get(
|
||||
f"/api/v1/savings/records?limit=5&cursor={page1['next_cursor']}",
|
||||
headers=_auth(token),
|
||||
)
|
||||
page2 = r.json()
|
||||
assert max(it["id"] for it in page2["items"]) < min(ids)
|
||||
|
||||
|
||||
def test_endpoints_require_auth(client) -> None:
|
||||
"""不带 token 的福利接口统一 401。"""
|
||||
assert client.get("/api/v1/wallet/account").status_code == 401
|
||||
assert client.get("/api/v1/signin/status").status_code == 401
|
||||
assert client.post("/api/v1/signin").status_code == 401
|
||||
assert client.get("/api/v1/tasks").status_code == 401
|
||||
assert client.get("/api/v1/wallet/cash-transactions").status_code == 401
|
||||
assert client.post("/api/v1/wallet/exchange", json={"coin_amount": 10000}).status_code == 401
|
||||
assert client.get("/api/v1/savings/summary").status_code == 401
|
||||
assert client.get("/api/v1/savings/battle").status_code == 401
|
||||
assert client.get("/api/v1/savings/records").status_code == 401
|
||||
@@ -0,0 +1,271 @@
|
||||
"""提现(现金 → 微信零钱)测试。
|
||||
|
||||
wxpay 的网络调用(code 换 openid / 发起转账 / 查单)全部 monkeypatch,不真发微信。
|
||||
现金余额用 SessionLocal 直接 seed(没有"加现金"接口,正常靠金币兑换)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, WithdrawOrder
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _seed_cash(client, token: str, phone: str, cents: int) -> None:
|
||||
"""先访问 /account 触发建账户,再用 DB 直接灌现金余额。"""
|
||||
client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
||||
acc = db.get(CoinAccount, user.id)
|
||||
acc.cash_balance_cents = cents
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵称", avatar="https://x/y.png"):
|
||||
monkeypatch.setattr(
|
||||
"app.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
|
||||
Reference in New Issue
Block a user