diff --git a/.env.example b/.env.example index 034d5da..82c5bd1 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,25 @@ PRICEBOT_REQUEST_TIMEOUT_SEC=30 # ===== CORS ===== # 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类 CORS_ALLOW_ORIGINS= + +# ===== 微信支付(商家转账到零钱 / 提现)===== +# appid/secret 来自微信开放平台移动应用;mch/序列号/公钥ID 来自微信支付商户平台。 +# 证书 .pem 放 secrets/(已 gitignore)。 +WECHAT_APP_ID=wxxxxxxxxxxxxxxxxx +WECHAT_APP_SECRET=your_app_secret +WXPAY_MCH_ID=your_mch_id +WXPAY_MCH_SERIAL_NO=your_cert_serial_no +WXPAY_MCH_PRIVATE_KEY_PATH=./secrets/apiclient_key.pem +WXPAY_PUBLIC_KEY_ID=PUB_KEY_ID_xxxxxxxx +WXPAY_PUBLIC_KEY_PATH=./secrets/pub_key.pem +WXPAY_TRANSFER_SCENE_ID=1000 + +# ===== 穿山甲激励视频(服务端发奖回调)===== +# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。 +# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里; +# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +PANGLE_CALLBACK_ENABLED=false +PANGLE_REWARD_SECRET= +# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖, +# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。 +AD_REWARD_TEST_GRANT_ENABLED=false diff --git a/alembic/versions/357520ea3015_welfare_tables_coin_account_coin_txn_.py b/alembic/versions/357520ea3015_welfare_tables_coin_account_coin_txn_.py new file mode 100644 index 0000000..a2e064b --- /dev/null +++ b/alembic/versions/357520ea3015_welfare_tables_coin_account_coin_txn_.py @@ -0,0 +1,96 @@ +"""welfare tables: coin account, coin txn, signin, user task + +Revision ID: 357520ea3015 +Revises: 9c559acc164a +Create Date: 2026-05-24 16:16:20.677711 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '357520ea3015' +down_revision: Union[str, Sequence[str], None] = '9c559acc164a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('coin_account', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('coin_balance', sa.Integer(), nullable=False), + sa.Column('cash_balance_cents', sa.Integer(), nullable=False), + sa.Column('total_coin_earned', sa.Integer(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_table('coin_transaction', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Integer(), nullable=False), + sa.Column('balance_after', sa.Integer(), nullable=False), + sa.Column('biz_type', sa.String(length=32), nullable=False), + sa.Column('ref_id', sa.String(length=64), nullable=True), + sa.Column('remark', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('coin_transaction', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_coin_transaction_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_coin_transaction_user_id'), ['user_id'], unique=False) + + op.create_table('signin_record', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('signin_date', sa.Date(), nullable=False), + sa.Column('cycle_day', sa.Integer(), nullable=False), + sa.Column('streak', sa.Integer(), nullable=False), + sa.Column('coin_awarded', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_user_date') + ) + with op.batch_alter_table('signin_record', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_signin_record_user_id'), ['user_id'], unique=False) + + op.create_table('user_task', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('task_key', sa.String(length=48), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('coin_awarded', sa.Integer(), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'task_key', name='uq_task_user_key') + ) + with op.batch_alter_table('user_task', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_task_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_task', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_task_user_id')) + + op.drop_table('user_task') + with op.batch_alter_table('signin_record', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_signin_record_user_id')) + + op.drop_table('signin_record') + with op.batch_alter_table('coin_transaction', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_coin_transaction_user_id')) + batch_op.drop_index(batch_op.f('ix_coin_transaction_created_at')) + + op.drop_table('coin_transaction') + op.drop_table('coin_account') + # ### end Alembic commands ### diff --git a/alembic/versions/3d9cb19df76f_cash_transaction_table.py b/alembic/versions/3d9cb19df76f_cash_transaction_table.py new file mode 100644 index 0000000..1291b03 --- /dev/null +++ b/alembic/versions/3d9cb19df76f_cash_transaction_table.py @@ -0,0 +1,49 @@ +"""cash_transaction table + +Revision ID: 3d9cb19df76f +Revises: 357520ea3015 +Create Date: 2026-05-24 16:21:22.950992 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3d9cb19df76f' +down_revision: Union[str, Sequence[str], None] = '357520ea3015' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('cash_transaction', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('amount_cents', sa.Integer(), nullable=False), + sa.Column('balance_after_cents', sa.Integer(), nullable=False), + sa.Column('biz_type', sa.String(length=32), nullable=False), + sa.Column('ref_id', sa.String(length=64), nullable=True), + sa.Column('remark', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('cash_transaction', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_cash_transaction_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_cash_transaction_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('cash_transaction', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_cash_transaction_user_id')) + batch_op.drop_index(batch_op.f('ix_cash_transaction_created_at')) + + op.drop_table('cash_transaction') + # ### end Alembic commands ### diff --git a/alembic/versions/a7c8d9e0f1b2_unique_wechat_openid.py b/alembic/versions/a7c8d9e0f1b2_unique_wechat_openid.py new file mode 100644 index 0000000..235b1e0 --- /dev/null +++ b/alembic/versions/a7c8d9e0f1b2_unique_wechat_openid.py @@ -0,0 +1,32 @@ +"""unique index on user.wechat_openid (一个微信只绑一个账号) + +Revision ID: a7c8d9e0f1b2 +Revises: f5b2d3c4e6a7 +Create Date: 2026-05-25 19:10:00.000000 + +注意:升级前需保证 wechat_openid 无重复值(NULL 不算重复)。 +本仓库 data/app.db 升级前已手动清掉重复 openid(保留最近绑定的账号)。 +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'a7c8d9e0f1b2' +down_revision: Union[str, Sequence[str], None] = 'f5b2d3c4e6a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # nullable 列上的唯一索引:SQLite / Postgres 都允许多个 NULL,只约束非空值唯一 + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_user_wechat_openid'), ['wechat_openid'], unique=True + ) + + +def downgrade() -> None: + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_wechat_openid')) diff --git a/alembic/versions/b1c2d3e4f5a6_savings_shop_dishes.py b/alembic/versions/b1c2d3e4f5a6_savings_shop_dishes.py new file mode 100644 index 0000000..27afcdd --- /dev/null +++ b/alembic/versions/b1c2d3e4f5a6_savings_shop_dishes.py @@ -0,0 +1,33 @@ +"""savings_record.shop_name + dishes(订单明细卡:店铺名 + 菜品列表) + +Revision ID: b1c2d3e4f5a6 +Revises: a7c8d9e0f1b2 +Create Date: 2026-05-26 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b1c2d3e4f5a6' +down_revision: Union[str, Sequence[str], None] = 'a7c8d9e0f1b2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('savings_record', schema=None) as batch_op: + batch_op.add_column(sa.Column('shop_name', sa.String(length=128), nullable=True)) + # dishes 存 JSON(SQLite 落 TEXT);旧行给 '[]' 默认,满足 NOT NULL + batch_op.add_column( + sa.Column('dishes', sa.JSON(), nullable=False, server_default=sa.text("'[]'")) + ) + + +def downgrade() -> None: + with op.batch_alter_table('savings_record', schema=None) as batch_op: + batch_op.drop_column('dishes') + batch_op.drop_column('shop_name') diff --git a/alembic/versions/bf2a67c0e2ab_savings_record_table.py b/alembic/versions/bf2a67c0e2ab_savings_record_table.py new file mode 100644 index 0000000..28fa90a --- /dev/null +++ b/alembic/versions/bf2a67c0e2ab_savings_record_table.py @@ -0,0 +1,49 @@ +"""savings_record table + +Revision ID: bf2a67c0e2ab +Revises: 3d9cb19df76f +Create Date: 2026-05-24 16:24:34.622394 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bf2a67c0e2ab' +down_revision: Union[str, Sequence[str], None] = '3d9cb19df76f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('savings_record', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('order_amount_cents', sa.Integer(), nullable=False), + sa.Column('saved_amount_cents', sa.Integer(), nullable=False), + sa.Column('platform', sa.String(length=32), nullable=True), + sa.Column('title', sa.String(length=128), nullable=True), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('savings_record', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_savings_record_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_savings_record_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('savings_record', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_savings_record_user_id')) + batch_op.drop_index(batch_op.f('ix_savings_record_created_at')) + + op.drop_table('savings_record') + # ### end Alembic commands ### diff --git a/alembic/versions/c8d9e0f1a2b3_ad_reward_record.py b/alembic/versions/c8d9e0f1a2b3_ad_reward_record.py new file mode 100644 index 0000000..0161a4c --- /dev/null +++ b/alembic/versions/c8d9e0f1a2b3_ad_reward_record.py @@ -0,0 +1,50 @@ +"""ad_reward_record table (看激励视频发奖记录) + +Revision ID: c8d9e0f1a2b3 +Revises: b1c2d3e4f5a6 +Create Date: 2026-05-26 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c8d9e0f1a2b3' +down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'ad_reward_record', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('trans_id', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('coin', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('reward_date', sa.String(length=10), nullable=False), + sa.Column('reward_name', sa.String(length=64), nullable=True), + sa.Column('raw', sa.String(length=1024), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('ad_reward_record', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_ad_reward_record_trans_id'), ['trans_id'], unique=True) + batch_op.create_index(batch_op.f('ix_ad_reward_record_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_ad_reward_record_reward_date'), ['reward_date'], unique=False) + batch_op.create_index(batch_op.f('ix_ad_reward_record_created_at'), ['created_at'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('ad_reward_record', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_ad_reward_record_created_at')) + batch_op.drop_index(batch_op.f('ix_ad_reward_record_reward_date')) + batch_op.drop_index(batch_op.f('ix_ad_reward_record_user_id')) + batch_op.drop_index(batch_op.f('ix_ad_reward_record_trans_id')) + + op.drop_table('ad_reward_record') diff --git a/alembic/versions/e4a1c2b3d4f5_withdraw_order_and_user_openid.py b/alembic/versions/e4a1c2b3d4f5_withdraw_order_and_user_openid.py new file mode 100644 index 0000000..9b210b6 --- /dev/null +++ b/alembic/versions/e4a1c2b3d4f5_withdraw_order_and_user_openid.py @@ -0,0 +1,56 @@ +"""withdraw_order table + user.wechat_openid + +Revision ID: e4a1c2b3d4f5 +Revises: bf2a67c0e2ab +Create Date: 2026-05-25 16:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e4a1c2b3d4f5' +down_revision: Union[str, Sequence[str], None] = 'bf2a67c0e2ab' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'withdraw_order', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('out_bill_no', sa.String(length=64), nullable=False), + sa.Column('amount_cents', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('wechat_state', sa.String(length=32), nullable=True), + sa.Column('transfer_bill_no', sa.String(length=64), nullable=True), + sa.Column('package_info', sa.String(length=512), nullable=True), + sa.Column('fail_reason', sa.String(length=256), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('withdraw_order', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_withdraw_order_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_withdraw_order_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_withdraw_order_out_bill_no'), ['out_bill_no'], unique=True) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('wechat_openid', sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('wechat_openid') + + with op.batch_alter_table('withdraw_order', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_withdraw_order_out_bill_no')) + batch_op.drop_index(batch_op.f('ix_withdraw_order_user_id')) + batch_op.drop_index(batch_op.f('ix_withdraw_order_created_at')) + + op.drop_table('withdraw_order') diff --git a/alembic/versions/f5b2d3c4e6a7_user_wechat_nickname_avatar.py b/alembic/versions/f5b2d3c4e6a7_user_wechat_nickname_avatar.py new file mode 100644 index 0000000..d36afd2 --- /dev/null +++ b/alembic/versions/f5b2d3c4e6a7_user_wechat_nickname_avatar.py @@ -0,0 +1,30 @@ +"""user.wechat_nickname + wechat_avatar_url + +Revision ID: f5b2d3c4e6a7 +Revises: e4a1c2b3d4f5 +Create Date: 2026-05-25 18:40:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f5b2d3c4e6a7' +down_revision: Union[str, Sequence[str], None] = 'e4a1c2b3d4f5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('wechat_nickname', sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column('wechat_avatar_url', sa.String(length=512), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('wechat_avatar_url') + batch_op.drop_column('wechat_nickname') diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py new file mode 100644 index 0000000..ba38632 --- /dev/null +++ b/app/api/v1/ad.py @@ -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, + ) diff --git a/app/api/v1/savings.py b/app/api/v1/savings.py new file mode 100644 index 0000000..c0db8f0 --- /dev/null +++ b/app/api/v1/savings.py @@ -0,0 +1,59 @@ +"""省钱 endpoint(profile 的「累计帮你省了」「省钱战绩」「省钱明细」)。 + +路由前缀 `/api/v1/savings`: + GET /summary 累计省下 + 订单数 + 平均每单省 + GET /battle 本周已省 + 超过百分比 + 连续省钱天数 + GET /records 省钱明细(游标分页) +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Query + +from app.api.deps import CurrentUser, DbSession +from app.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, + ) diff --git a/app/api/v1/signin.py b/app/api/v1/signin.py new file mode 100644 index 0000000..1880d93 --- /dev/null +++ b/app/api/v1/signin.py @@ -0,0 +1,44 @@ +"""签到 endpoint。 + +路由前缀 `/api/v1/signin`: + GET /status 今日签到状态 + 7 天档位 + POST / 执行今日签到 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import CurrentUser, DbSession +from app.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, + ) diff --git a/app/api/v1/tasks.py b/app/api/v1/tasks.py new file mode 100644 index 0000000..0faa995 --- /dev/null +++ b/app/api/v1/tasks.py @@ -0,0 +1,47 @@ +"""一次性任务 endpoint。 + +路由前缀 `/api/v1/tasks`: + GET / 任务及领取状态(如"打开消息提醒") + POST /{task_key}/claim 领取任务奖励 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import CurrentUser, DbSession +from app.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) diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py new file mode 100644 index 0000000..2ac0373 --- /dev/null +++ b/app/api/v1/wallet.py @@ -0,0 +1,240 @@ +"""钱包 / 我的资产 endpoint。 + +路由前缀 `/api/v1/wallet`: + GET /account 金币 + 现金余额(我的资产卡) + GET /coin-transactions 金币流水(游标分页) +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.api.deps import CurrentUser, DbSession +from app.core.config import settings +from app.core.ratelimit import rate_limit +from app.core.rewards import ( + COIN_PER_CENT, + COIN_PER_YUAN, + MIN_EXCHANGE_COIN, + WITHDRAW_MAX_CENTS, + WITHDRAW_MIN_CENTS, +) +from app.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, + ) diff --git a/app/core/config.py b/app/core/config.py index 7709b6c..8e6b396 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -28,6 +28,9 @@ class Settings(BaseSettings): APP_NAME: str = "shaguabijia-app-server" APP_DEBUG: bool = True + # 接口限流开关(测试里关掉,避免内存计数跨用例累加误伤) + RATE_LIMIT_ENABLED: bool = True + # ===== 数据库 ===== DATABASE_URL: str = "sqlite:///./data/app.db" @@ -55,6 +58,44 @@ class Settings(BaseSettings): MT_CPS_HOST: str = "https://media.meituan.com" MT_CPS_TIMEOUT_SEC: int = 15 MT_CPS_DEFAULT_SID: str = "sgbjia" + # ===== 微信支付(商家转账到零钱 / 提现)===== + # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 + # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 + WECHAT_APP_ID: str = "" # 开放平台移动应用 appid(wx 开头) + WECHAT_APP_SECRET: str = "" # 开放平台 AppSecret,code 换 openid 用 + WXPAY_MCH_ID: str = "" # 微信支付商户号 + WXPAY_MCH_SERIAL_NO: str = "" # 商户 API 证书序列号 + WXPAY_MCH_PRIVATE_KEY_PATH: str = "./secrets/apiclient_key.pem" # 商户 API 私钥 + WXPAY_PUBLIC_KEY_ID: str = "" # 微信支付平台公钥 ID(PUB_KEY_ID_...) + WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥 + WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销) + WXPAY_REQUEST_TIMEOUT_SEC: int = 10 + + @property + def wxpay_configured(self) -> bool: + """凭证是否齐全(缺则提现接口拒绝调用,而非启动时炸)。""" + return bool( + self.WECHAT_APP_ID + and self.WXPAY_MCH_ID + and self.WXPAY_MCH_SERIAL_NO + and self.WXPAY_PUBLIC_KEY_ID + ) + + # ===== 穿山甲激励视频(服务端发奖回调)===== + # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 + # PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。 + PANGLE_CALLBACK_ENABLED: bool = False + PANGLE_REWARD_SECRET: str = "" + + # ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、 + # 穿山甲 S2S 回调打不到本地时,直接触发一次发奖,验证"看广告→金币到账"全链路。 + # 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。 + AD_REWARD_TEST_GRANT_ENABLED: bool = False + + @property + def pangle_callback_configured(self) -> bool: + """回调开关打开且验签密钥已配,才接受发奖回调。""" + return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET) # ===== Pricebot 上游 (领券业务透传目标) ===== # pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step diff --git a/app/core/pangle.py b/app/core/pangle.py new file mode 100644 index 0000000..10dcc90 --- /dev/null +++ b/app/core/pangle.py @@ -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) diff --git a/app/core/ratelimit.py b/app/core/ratelimit.py new file mode 100644 index 0000000..ce64f06 --- /dev/null +++ b/app/core/ratelimit.py @@ -0,0 +1,59 @@ +"""轻量内存限流(固定窗口计数)。 + +定位:防脚本刷接口的**安全网**,不是精确配额。局限——进程内存、单 worker 有效、重启清零、 +多 worker/多机不共享。上线放量后应换 Redis 后端 + 按用户维度。当前单 worker uvicorn 够用。 + +用法:把 `rate_limit(limit, window_sec, scope)` 作为路由依赖挂上,默认按客户端 IP 计数。 +""" +from __future__ import annotations + +import threading +import time + +from fastapi import HTTPException, Request, status + +from app.core.config import settings + +# key -> (window_start_ts, count) +_buckets: dict[str, tuple[float, int]] = {} +_lock = threading.Lock() + + +def _hit(key: str, limit: int, window_sec: float) -> bool: + """记一次访问。返回 True=放行,False=超限。""" + now = time.monotonic() + with _lock: + start, count = _buckets.get(key, (now, 0)) + if now - start >= window_sec: # 窗口过期,重置 + start, count = now, 0 + count += 1 + _buckets[key] = (start, count) + # 顺手清理过期 key,防内存无限涨(低频访问足够) + if len(_buckets) > 10000: + for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]: + _buckets.pop(k, None) + return count <= limit + + +def _client_ip(request: Request) -> str: + # nginx 反代后真实 IP 在 X-Forwarded-For 首段;直连用 request.client + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def rate_limit(limit: int, window_sec: float, scope: str): + """生成一个 FastAPI 依赖:同一 IP 在 window_sec 内对该 scope 超过 limit 次 → 429。""" + + def _dep(request: Request) -> None: + if not settings.RATE_LIMIT_ENABLED: + return + key = f"{scope}:{_client_ip(request)}" + if not _hit(key, limit, window_sec): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="操作过于频繁,请稍后再试", + ) + + return _dep diff --git a/app/core/rewards.py b/app/core/rewards.py new file mode 100644 index 0000000..1651383 --- /dev/null +++ b/app/core/rewards.py @@ -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 diff --git a/app/core/wxpay.py b/app/core/wxpay.py new file mode 100644 index 0000000..8c66f24 --- /dev/null +++ b/app/core/wxpay.py @@ -0,0 +1,219 @@ +"""微信支付 V3 —— 商家转账到零钱(提现)。 + +移植自 elderhelper 的 wxpay.py,改成从 [app.core.config.settings] 读配置、密钥懒加载 +(文件缺失不在 import 时炸进程,只在真正调用转账时报错,与极光私钥同款策略)。 + +只实现提现需要的两个接口: + - create_transfer:发起转账(商家转账到零钱) + - query_transfer :按商户单号查转账单状态 +签名用商户 API 私钥(RSA-SHA256/PKCS1v15),敏感字段(实名)用微信支付平台公钥 OAEP 加密。 +""" +from __future__ import annotations + +import base64 +import json +import time +import uuid + +import httpx +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey + +from app.core.config import settings + +_API_HOST = "https://api.mch.weixin.qq.com" +_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills" + +# 懒加载缓存 +_private_key: RSAPrivateKey | None = None +_public_key: RSAPublicKey | None = None + + +class WxPayNotConfiguredError(Exception): + """微信支付凭证 / 证书缺失,无法调用。""" + + +def _load_private_key() -> RSAPrivateKey: + global _private_key + if _private_key is None: + try: + with open(settings.WXPAY_MCH_PRIVATE_KEY_PATH, "rb") as f: + _private_key = serialization.load_pem_private_key(f.read(), password=None) + except FileNotFoundError as e: + raise WxPayNotConfiguredError( + f"商户私钥不存在: {settings.WXPAY_MCH_PRIVATE_KEY_PATH}" + ) from e + return _private_key + + +def _load_public_key() -> RSAPublicKey: + global _public_key + if _public_key is None: + try: + with open(settings.WXPAY_PUBLIC_KEY_PATH, "rb") as f: + _public_key = serialization.load_pem_public_key(f.read()) + except FileNotFoundError as e: + raise WxPayNotConfiguredError( + f"微信支付平台公钥不存在: {settings.WXPAY_PUBLIC_KEY_PATH}" + ) from e + return _public_key + + +def _sign(message: str) -> str: + """RSA-SHA256 签名,base64 输出。""" + signature = _load_private_key().sign( + message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256() + ) + return base64.b64encode(signature).decode("utf-8") + + +def _build_authorization(method: str, url_path: str, body: str) -> str: + """构建 V3 Authorization 头(WECHATPAY2-SHA256-RSA2048)。""" + timestamp = str(int(time.time())) + nonce = uuid.uuid4().hex + sign_str = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n" + signature = _sign(sign_str) + return ( + f'WECHATPAY2-SHA256-RSA2048 mchid="{settings.WXPAY_MCH_ID}",' + f'nonce_str="{nonce}",' + f'timestamp="{timestamp}",' + f'serial_no="{settings.WXPAY_MCH_SERIAL_NO}",' + f'signature="{signature}"' + ) + + +def encrypt_sensitive(plain_text: str) -> str: + """用微信支付平台公钥 OAEP(SHA1) 加密敏感信息(如实名)。""" + ciphertext = _load_public_key().encrypt( + plain_text.encode("utf-8"), + padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None), + ) + return base64.b64encode(ciphertext).decode("utf-8") + + +def create_transfer( + openid: str, amount_fen: int, out_bill_no: str, user_name: str | None = None +) -> dict: + """发起商家转账到零钱。返回 {status_code, data}。 + + user_name(实名)在金额达微信阈值时必须传(加密)。这里沿用 elderhelper 阈值, + 实际阈值以微信侧要求为准。 + """ + body = { + "appid": settings.WECHAT_APP_ID, + "out_bill_no": out_bill_no, + "transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID, + "openid": openid, + "transfer_amount": amount_fen, + "transfer_remark": "金币提现", + "transfer_scene_report_infos": [ + {"info_type": "活动名称", "info_content": "比价返现"}, + {"info_type": "奖励说明", "info_content": "现金提现到微信零钱"}, + ], + } + if user_name and amount_fen >= 30: + body["user_name"] = encrypt_sensitive(user_name) + + body_str = json.dumps(body, ensure_ascii=False) + headers = { + "Authorization": _build_authorization("POST", _TRANSFER_PATH, body_str), + "Accept": "application/json", + "Content-Type": "application/json", + "Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID, + } + with httpx.Client() as client: + resp = client.post( + f"{_API_HOST}{_TRANSFER_PATH}", + content=body_str, + headers=headers, + timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC, + ) + return {"status_code": resp.status_code, "data": resp.json()} + + +def query_transfer(out_bill_no: str) -> dict: + """按商户单号查转账单。返回 {status_code, data}。""" + path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}" + headers = { + "Authorization": _build_authorization("GET", path, ""), + "Accept": "application/json", + } + with httpx.Client() as client: + resp = client.get( + f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC + ) + return {"status_code": resp.status_code, "data": resp.json()} + + +def cancel_transfer(out_bill_no: str) -> dict: + """撤销转账单(仅 WAIT_USER_CONFIRM/ACCEPTED 可撤)。用户没在确认页确认就离开时调用。 + 成功返回 {status_code:200, data:{state:'CANCELLING'|'CANCELLED', ...}}。""" + path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}/cancel" + headers = { + "Authorization": _build_authorization("POST", path, ""), + "Accept": "application/json", + "Content-Type": "application/json", + } + with httpx.Client() as client: + resp = client.post( + f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC + ) + return {"status_code": resp.status_code, "data": resp.json()} + + +def code_to_openid(code: str) -> str: + """用微信授权 code 换 openid(开放平台移动应用 sns/oauth2)。失败抛 ValueError。""" + resp = httpx.get( + "https://api.weixin.qq.com/sns/oauth2/access_token", + params={ + "appid": settings.WECHAT_APP_ID, + "secret": settings.WECHAT_APP_SECRET, + "code": code, + "grant_type": "authorization_code", + }, + timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC, + ) + data = resp.json() + if "openid" not in data: + raise ValueError(f"微信授权失败: {data.get('errmsg', data)}") + return data["openid"] + + +def code_to_userinfo(code: str) -> dict: + """code 换 access_token+openid,再拉 sns/userinfo 取昵称头像。 + 返回 {openid, nickname, avatar_url, raw}。失败抛 ValueError。 + 注意:微信隐私新政下,部分 app 的 sns/userinfo 可能返回脱敏值(昵称"微信用户"/灰头像); + nickname/avatar_url 可能为空,调用方需兜底。""" + r1 = httpx.get( + "https://api.weixin.qq.com/sns/oauth2/access_token", + params={ + "appid": settings.WECHAT_APP_ID, + "secret": settings.WECHAT_APP_SECRET, + "code": code, + "grant_type": "authorization_code", + }, + timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC, + ) + d1 = r1.json() + if "openid" not in d1 or "access_token" not in d1: + raise ValueError(f"微信授权失败: {d1.get('errmsg', d1)}") + openid = d1["openid"] + + nickname = None + avatar_url = None + raw: dict = {} + # userinfo 拉取失败不应让绑定失败(openid 已拿到),吞掉异常只是没昵称头像 + try: + r2 = httpx.get( + "https://api.weixin.qq.com/sns/userinfo", + params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"}, + timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC, + ) + raw = r2.json() + nickname = raw.get("nickname") or None + avatar_url = raw.get("headimgurl") or None + except Exception: # noqa: BLE001 + pass + + return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw} diff --git a/app/crud/ad_reward.py b/app/crud/ad_reward.py new file mode 100644 index 0000000..4f31f8e --- /dev/null +++ b/app/crud/ad_reward.py @@ -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 diff --git a/app/crud/savings.py b/app/crud/savings.py new file mode 100644 index 0000000..ecc6870 --- /dev/null +++ b/app/crud/savings.py @@ -0,0 +1,186 @@ +"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。 + +设计:聚合逻辑都是"生产级"真实计算(SUM、按日分组、连续天数),只是数据本期来自 +demo seeder。等比价真接入后停掉 seeder、改成上报写入,这些聚合无需改。 + +聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑—— +created_at 带时区,统一转成北京时间的 date 再分组。 +""" +from __future__ import annotations + +import random +from dataclasses import dataclass +from datetime import datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.rewards import CN_TZ, cn_today +from app.models.savings import SavingsRecord + +# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单 +_DEMO_STREAK_DAYS = 9 +_DEMO_EXTRA_ORDERS = 14 + +# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。 +# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中; +# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。 +_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [ + ("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]), + ("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]), + ("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]), + ("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]), + ("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]), + ("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]), + ("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]), + ("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]), + ("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]), + ("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]), +] + + +@dataclass +class SavingsSummary: + total_saved_cents: int + order_count: int + avg_saved_cents: int # 平均每单省(分) + + +@dataclass +class SavingsBattle: + week_saved_cents: int # 本周(周一起)已省 + beat_percent: int # 超过百分之多少用户 + streak_days: int # 连续省钱天数 + + +def _local_date(dt: datetime): + """把(可能带时区的)created_at 转成北京时间的 date。""" + if dt.tzinfo is None: + return dt.date() + return dt.astimezone(CN_TZ).date() + + +def _all_records(db: Session, user_id: int) -> list[SavingsRecord]: + stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) + return list(db.execute(stmt).scalars().all()) + + +def ensure_seeded(db: Session, user_id: int) -> None: + """该用户没有任何省钱记录时,幂等灌一批 demo 数据。""" + exists = db.execute( + select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1) + ).first() + if exists is not None: + return + + rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致 + today = cn_today() + records: list[SavingsRecord] = [] + + def _mk(day) -> SavingsRecord: + hour = rng.randint(8, 21) + minute = rng.randint(0, 59) + ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ) + platform, shop, dishes = rng.choice(_DEMO_ORDERS) + # 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡 + saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元 + return SavingsRecord( + user_id=user_id, + order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价) + saved_amount_cents=saved, + platform=platform, + title=shop, + shop_name=shop, + dishes=dishes, + source="demo", + created_at=ts, + ) + + # 最近 N 天每天 1 单 → 连续省钱 N 天 + for d in range(_DEMO_STREAK_DAYS): + records.append(_mk(today - timedelta(days=d))) + # 历史散单(第 10~40 天) + for _ in range(_DEMO_EXTRA_ORDERS): + records.append(_mk(today - timedelta(days=rng.randint(10, 40)))) + + db.add_all(records) + db.commit() + + +def get_summary(db: Session, user_id: int) -> SavingsSummary: + ensure_seeded(db, user_id) + records = _all_records(db, user_id) + total = sum(r.saved_amount_cents for r in records) + count = len(records) + avg = total // count if count else 0 + return SavingsSummary(total_saved_cents=total, order_count=count, avg_saved_cents=avg) + + +def _streak_days(dates: set) -> int: + """从最近活跃日往前数连续有省钱记录的天数。""" + if not dates: + return 0 + cur = max(dates) + streak = 0 + while cur in dates: + streak += 1 + cur = cur - timedelta(days=1) + return streak + + +def get_battle(db: Session, user_id: int) -> SavingsBattle: + ensure_seeded(db, user_id) + records = _all_records(db, user_id) + + today = cn_today() + week_start = today - timedelta(days=today.weekday()) # 本周一 + week_saved = sum( + r.saved_amount_cents for r in records if _local_date(r.created_at) >= week_start + ) + + dates = {_local_date(r.created_at) for r in records} + streak = _streak_days(dates) + + beat_percent = _compute_beat_percent(db, user_id) + + return SavingsBattle( + week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak + ) + + +def _compute_beat_percent(db: Session, user_id: int) -> int: + """超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。 + + = 严格少于我的其他用户数 / 其他用户总数 * 100。 + 全部省得比我少 → 100;只有自己一个用户 → 0(无可比)。 + """ + rows = db.execute( + select(SavingsRecord.user_id, func.sum(SavingsRecord.saved_amount_cents)) + .group_by(SavingsRecord.user_id) + ).all() + totals = {uid: (total or 0) for uid, total in rows} + others = {uid: t for uid, t in totals.items() if uid != user_id} + if not others: + return 0 + my_total = totals.get(user_id, 0) + beaten = sum(1 for t in others.values() if t < my_total) + return round(100 * beaten / len(others)) + + +def list_records( + db: Session, + user_id: int, + *, + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[SavingsRecord], int | None]: + """省钱明细分页(按 id 倒序,游标式)。""" + ensure_seeded(db, user_id) + stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id) + if cursor is not None: + stmt = stmt.where(SavingsRecord.id < cursor) + stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit) + + items = list(db.execute(stmt).scalars().all()) + next_cursor = items[-1].id if len(items) == limit else None + return items, next_cursor diff --git a/app/crud/signin.py b/app/crud/signin.py new file mode 100644 index 0000000..2084816 --- /dev/null +++ b/app/crud/signin.py @@ -0,0 +1,126 @@ +"""签到 CRUD:状态查询 + 执行签到。 + +7 天循环规则: + - 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续) + - 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置) +今天的 cycle_day 决定发多少金币(见 app.core.rewards.SIGNIN_REWARDS)。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.rewards import SIGNIN_CYCLE_LEN, SIGNIN_REWARDS, cn_today, signin_reward +from app.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 diff --git a/app/crud/task.py b/app/crud/task.py new file mode 100644 index 0000000..cf40172 --- /dev/null +++ b/app/crud/task.py @@ -0,0 +1,71 @@ +"""一次性任务 CRUD:列表状态 + 领奖。 + +领奖原子化:写 user_task(唯一约束防重复)+ 发金币,同一事务 commit。 +""" +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.rewards import TASK_REWARDS +from app.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 diff --git a/app/crud/wallet.py b/app/crud/wallet.py new file mode 100644 index 0000000..25796a6 --- /dev/null +++ b/app/crud/wallet.py @@ -0,0 +1,506 @@ +"""钱包 CRUD:账户读取 + 金币入账(共享给签到 / 任务)+ 流水查询。 + +`grant_coins` 是所有"发金币"的唯一入口:它更新余额快照并写一笔流水,但**不 commit**, +由调用方在同一事务里 commit(签到要同时写 signin_record,任务要同时写 user_task, +保证"记录"和"加金币"原子化,不会只发钱不留痕或反之)。 +""" +from __future__ import annotations + +import re +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from app.core 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 diff --git a/app/main.py b/app/main.py index 159fc08..cfa71fc 100644 --- a/app/main.py +++ b/app/main.py @@ -11,9 +11,14 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.api.v1.ad import router as ad_router from app.api.v1.auth import router as auth_router from app.api.v1.coupon import router as coupon_router from app.api.v1.meituan import router as meituan_router +from app.api.v1.savings import router as savings_router +from app.api.v1.signin import router as signin_router +from app.api.v1.tasks import router as tasks_router +from app.api.v1.wallet import router as wallet_router from app.core.config import settings from app.core.logging import setup_logging @@ -61,3 +66,8 @@ def health() -> dict[str, str]: app.include_router(auth_router) app.include_router(coupon_router) app.include_router(meituan_router) +app.include_router(wallet_router) +app.include_router(signin_router) +app.include_router(tasks_router) +app.include_router(savings_router) +app.include_router(ad_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index d288759..d51de66 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,2 +1,12 @@ """所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" +from app.models.ad_reward import AdRewardRecord # noqa: F401 +from app.models.savings import SavingsRecord # noqa: F401 +from app.models.signin import SigninRecord # noqa: F401 +from app.models.task import UserTask # noqa: F401 from app.models.user import User # noqa: F401 +from app.models.wallet import ( # noqa: F401 + CashTransaction, + CoinAccount, + CoinTransaction, + WithdrawOrder, +) diff --git a/app/models/ad_reward.py b/app/models/ad_reward.py new file mode 100644 index 0000000..18b583b --- /dev/null +++ b/app/models/ad_reward.py @@ -0,0 +1,42 @@ +"""看激励视频发奖记录(穿山甲 S2S 回调)。 + +每条 = 穿山甲一次发奖回调。`trans_id`(穿山甲交易号)唯一,做幂等键:穿山甲会重试回调, +同号只发一次金币。`reward_date`(北京时间日期串)给"每日上限"计数用——按日期串等值查, +不在 SQL 里做跨时区 date 比较(SQLite 上不可靠)。审计 / 对账时整张表可逐笔回溯。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AdRewardRecord(Base): + __tablename__ = "ad_reward_record" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 穿山甲交易号,幂等键(同号回调不重复发奖) + trans_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 实发金币(capped 时为 0) + coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # granted(已发) / capped(当日超限未发) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted") + # 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数 + reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) + # 穿山甲上报的奖励名(参考,不作发奖依据) + reward_name: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 回调原始参数,审计排查用 + raw: Mapped[str | None] = mapped_column(String(1024), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/savings.py b/app/models/savings.py new file mode 100644 index 0000000..29fabf5 --- /dev/null +++ b/app/models/savings.py @@ -0,0 +1,41 @@ +"""省钱记录表。 + +是 profile「累计帮你省了」和「省钱战绩」的唯一数据源:每完成一次比价/下单, +省了多少记一行。本期比价还没接后端,数据由 demo seeder 幂等灌入(source='demo'), +聚合接口照常做真实 SUM/分组;等比价真接入后,改成上报写入、停掉 seeder 即可。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SavingsRecord(Base): + __tablename__ = "savings_record" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 订单金额 / 省下金额,单位:分 + order_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) + saved_amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) + platform: Mapped[str | None] = mapped_column(String(32), nullable=True) + title: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」) + shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。 + dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + # 来源:demo(演示) / compare(真实比价上报) + source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/signin.py b/app/models/signin.py new file mode 100644 index 0000000..f23724a --- /dev/null +++ b/app/models/signin.py @@ -0,0 +1,41 @@ +"""签到记录表。 + +每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 + - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。 + - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 +""" +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SigninRecord(Base): + __tablename__ = "signin_record" + __table_args__ = ( + UniqueConstraint("user_id", "signin_date", name="uq_signin_user_date"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 签到日期(北京时间的 date) + signin_date: Mapped[date] = mapped_column(Date, nullable=False) + cycle_day: Mapped[int] = mapped_column(Integer, nullable=False) + streak: Mapped[int] = mapped_column(Integer, nullable=False) + coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/models/task.py b/app/models/task.py new file mode 100644 index 0000000..dd5c734 --- /dev/null +++ b/app/models/task.py @@ -0,0 +1,36 @@ +"""一次性任务完成记录表。 + +像"打开消息提醒"这类只能领一次的任务,完成后写一行,(user_id, task_key) 唯一, +防止重复领奖。可循环领取的任务(签到)不走这张表,各有专表。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class UserTask(Base): + __tablename__ = "user_task" + __table_args__ = ( + UniqueConstraint("user_id", "task_key", name="uq_task_user_key"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 任务标识,见 app.core.rewards.TASK_REWARDS + task_key: Mapped[str] = mapped_column(String(48), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="completed") + coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + completed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/user.py b/app/models/user.py index f652db1..bf4be86 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -33,6 +33,15 @@ class User(Base): nickname: Mapped[str | None] = mapped_column(String(64), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 微信开放平台 openid(微信登录绑定后存,提现转账到零钱用)。同一 appid 下唯一; + # unique 索引保证一个微信只能绑一个账号(nullable,允许多个 NULL=未绑定)。 + wechat_openid: Mapped[str | None] = mapped_column( + String(64), unique=True, index=True, nullable=True + ) + # 微信昵称/头像(绑定时从 sns/userinfo 拉,展示在提现绑定卡)。与上面通用 nickname/avatar_url 分开,不互相覆盖。 + wechat_nickname: Mapped[str | None] = mapped_column(String(64), nullable=True) + wechat_avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 账号状态:active / disabled / deleted status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") diff --git a/app/models/wallet.py b/app/models/wallet.py new file mode 100644 index 0000000..4ab4177 --- /dev/null +++ b/app/models/wallet.py @@ -0,0 +1,127 @@ +"""钱包相关表:金币账户 + 金币流水。 + +设计要点: + - 余额快照(coin_account)用于读取,流水账本(coin_transaction)用于对账, + 每次余额变动都写一笔流水并记 balance_after,出问题能逐笔回溯。 + - 金额一律存整数:金币是个数,现金存"分"(cash_balance_cents),不用浮点。 + - cash_balance_cents 本步先留列、恒为 0,金币兑现金(第 2 步)再启用。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CoinAccount(Base): + __tablename__ = "coin_account" + + # 一个用户一行,user_id 既是主键也是外键 + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), primary_key=True + ) + coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # 累计赚取的金币(只增不减),用于"历史总收益"类展示 + total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class CoinTransaction(Base): + __tablename__ = "coin_transaction" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 正数=入账(赚),负数=出账(花/兑换) + amount: Mapped[int] = mapped_column(Integer, nullable=False) + # 本笔变动后的金币余额,对账用 + balance_after: Mapped[int] = mapped_column(Integer, nullable=False) + # 业务类型:signin / task_ / exchange_out / ... + biz_type: Mapped[str] = mapped_column(String(32), nullable=False) + # 关联业务 id(签到日期、任务 key 等),可空 + ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + remark: Mapped[str | None] = mapped_column(String(128), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class WithdrawOrder(Base): + """提现单(现金 → 微信零钱)。状态机:pending → success / failed。 + + 扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金 + 并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM / + SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态。 + """ + + __tablename__ = "withdraw_order" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 商户单号(我们生成,微信查单的 out_bill_no),唯一 + out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) + # 归一化状态:pending / success / failed + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") + # 微信侧原始状态 + wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 微信转账单号 + transfer_bill_no: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 待用户确认时返回给 App 拉起确认页的 package_info + package_info: Mapped[str | None] = mapped_column(String(512), nullable=True) + fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class CashTransaction(Base): + """现金流水(单位:分)。金币兑现金、提现都记在这里。""" + + __tablename__ = "cash_transaction" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 正数=入账(兑入),负数=出账(提现) + amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) + balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False) + # 业务类型:exchange_in / withdraw + biz_type: Mapped[str] = mapped_column(String(32), nullable=False) + ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + remark: Mapped[str | None] = mapped_column(String(128), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/schemas/ad.py b/app/schemas/ad.py new file mode 100644 index 0000000..ecf8315 --- /dev/null +++ b/app/schemas/ad.py @@ -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="看完一个激励视频发的金币") diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py new file mode 100644 index 0000000..b49cc92 --- /dev/null +++ b/app/schemas/welfare.py @@ -0,0 +1,214 @@ +"""福利模块(钱包 / 签到 / 任务)请求 / 响应 schemas。 + +约定同 auth:字段 snake_case、时间 ISO 8601(UTC)、金额存整数。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +# ===== 钱包 / 我的资产 ===== + +class CoinAccountOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + coin_balance: int = Field(..., description="当前金币余额") + cash_balance_cents: int = Field(..., description="当前现金余额(分)") + total_coin_earned: int = Field(..., description="累计赚取金币") + + +class CoinTransactionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + amount: int = Field(..., description="正=入账,负=出账") + balance_after: int + biz_type: str + ref_id: str | None = None + remark: str | None = None + created_at: datetime + + +class CoinTransactionPage(BaseModel): + items: list[CoinTransactionOut] + next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底") + + +class CashTransactionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + amount_cents: int = Field(..., description="正=兑入,负=提现") + balance_after_cents: int + biz_type: str + ref_id: str | None = None + remark: str | None = None + created_at: datetime + + +class CashTransactionPage(BaseModel): + items: list[CashTransactionOut] + next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底") + + +# ===== 金币兑现金 ===== + +class ExchangeInfoOut(BaseModel): + coin_per_yuan: int = Field(..., description="多少金币兑 1 元") + min_coin: int = Field(..., description="单次兑换最少金币") + step_coin: int = Field(..., description="兑换金币需为该值的整数倍(整分)") + + +class ExchangeRequest(BaseModel): + coin_amount: int = Field(..., gt=0, description="要兑换的金币数") + + +class ExchangeResultOut(BaseModel): + coin_amount: int = Field(..., description="本次扣除的金币") + cash_added_cents: int = Field(..., description="本次兑入的现金(分)") + coin_balance: int = Field(..., description="兑换后金币余额") + cash_balance_cents: int = Field(..., description="兑换后现金余额(分)") + + +# ===== 提现(现金 → 微信零钱) ===== + +class WithdrawInfoOut(BaseModel): + min_cents: int = Field(..., description="单次最低提现(分)") + max_cents: int = Field(..., description="单次最高提现(分)") + wechat_bound: bool = Field(..., description="当前用户是否已绑定微信") + wechat_nickname: str | None = Field(None, description="微信昵称(可能为空/脱敏)") + wechat_avatar_url: str | None = Field(None, description="微信头像 URL(可能为空)") + + +class BindWechatRequest(BaseModel): + code: str = Field(..., description="微信授权返回的 code") + + +class BindWechatResultOut(BaseModel): + bound: bool = True + wechat_nickname: str | None = None + wechat_avatar_url: str | None = None + + +class UnbindWechatResultOut(BaseModel): + bound: bool = False + + +class WithdrawRequest(BaseModel): + amount_cents: int = Field(..., gt=0, description="提现金额(分)") + user_name: str | None = Field(None, description="实名(达额时微信要求,可空)") + out_bill_no: str | None = Field( + None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成" + ) + + +class WithdrawResultOut(BaseModel): + out_bill_no: str = Field(..., description="商户提现单号(查单用)") + status: str = Field(..., description="pending / success / failed") + wechat_state: str | None = Field(None, description="微信侧原始状态") + amount_cents: int + cash_balance_cents: int = Field(..., description="提现后现金余额(分)") + # 供 App 拉起微信确认页(WXOpenBusinessView requestMerchantTransfer) + package_info: str | None = None + mch_id: str | None = None + app_id: str | None = None + + +class WithdrawStatusOut(BaseModel): + out_bill_no: str + status: str = Field(..., description="pending / success / failed") + wechat_state: str | None = None + amount_cents: int + + +class WithdrawOrderOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + out_bill_no: str + amount_cents: int + status: str + wechat_state: str | None = None + fail_reason: str | None = None + created_at: datetime + + +class WithdrawOrderPage(BaseModel): + items: list[WithdrawOrderOut] + next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底") + + +# ===== 签到 ===== + +class SigninStepOut(BaseModel): + day: int = Field(..., description="循环内第几天 1..7") + coin: int + status: str = Field(..., description="claimed / today / locked") + + +class SigninStatusOut(BaseModel): + today_signed: bool + consecutive_days: int + today_cycle_day: int + today_coin: int + can_claim: bool + steps: list[SigninStepOut] + + +class SigninResultOut(BaseModel): + coin_awarded: int + cycle_day: int + streak: int + coin_balance: int = Field(..., description="签到后金币余额") + + +# ===== 任务 ===== + +class TaskOut(BaseModel): + task_key: str + coin: int + claimed: bool + + +class TaskListOut(BaseModel): + items: list[TaskOut] + + +class TaskClaimResultOut(BaseModel): + task_key: str + coin_awarded: int + coin_balance: int = Field(..., description="领奖后金币余额") + + +# ===== 省钱(累计帮你省了 / 省钱战绩 / 明细)===== + +class SavingsSummaryOut(BaseModel): + total_saved_cents: int = Field(..., description="累计省下(分)") + order_count: int = Field(..., description="累计省钱订单数") + avg_saved_cents: int = Field(..., description="平均每单省(分)") + + +class SavingsBattleOut(BaseModel): + week_saved_cents: int = Field(..., description="本周已省(分)") + beat_percent: int = Field(..., description="超过百分之多少用户") + streak_days: int = Field(..., description="连续省钱天数") + + +class SavingsRecordOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + order_amount_cents: int + saved_amount_cents: int + platform: str | None = None + title: str | None = None + shop_name: str | None = None + dishes: list[str] = [] + created_at: datetime + + +class SavingsRecordPage(BaseModel): + items: list[SavingsRecordOut] + next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底") diff --git a/docs/ad_reward_golive_checklist.md b/docs/ad_reward_golive_checklist.md new file mode 100644 index 0000000..c1f5fa0 --- /dev/null +++ b/docs/ad_reward_golive_checklist.md @@ -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 并行。 diff --git a/scripts/reconcile_withdraws.py b/scripts/reconcile_withdraws.py new file mode 100644 index 0000000..90c9c21 --- /dev/null +++ b/scripts/reconcile_withdraws.py @@ -0,0 +1,38 @@ +"""提现对账(#4 孤儿单兜底)。 + +扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化: + - 微信 SUCCESS → 置 success + - 微信失败/取消/关闭/无此单 → 退回现金 + 置 failed + - WAIT_USER_CONFIRM(久未确认)→ 撤销 + 退款 +防止"扣了款但转账没发起/没确认"的单永久锁住用户余额。 + +用法(建议 cron / systemd timer 每 5~10 分钟跑一次): + python -m scripts.reconcile_withdraws # 默认处理 >15 分钟的 pending + python -m scripts.reconcile_withdraws 30 # 处理 >30 分钟的 pending +""" +from __future__ import annotations + +import sys + +# Windows 控制台按 UTF-8 输出中文/¥ +try: + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] +except Exception: # noqa: BLE001 + pass + +from app.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() diff --git a/scripts/reset_signin.py b/scripts/reset_signin.py new file mode 100644 index 0000000..1dafab9 --- /dev/null +++ b/scripts/reset_signin.py @@ -0,0 +1,121 @@ +"""清空指定用户的签到记录,方便反复测试"未签到→自动弹窗→签到领金币"。 + +默认**只删 signin_record**(签到历史),不动金币/现金——这样能保留你已注入/兑换的余额, +只把"今天是否已签到 / 连续天数"重置掉。删后当天即变成"未签到",再进福利页会重新自动弹签到弹窗。 + +⚠️ 注意:只删签到记录的话,之前签到已发的金币仍留在余额和 coin_transaction 里(biz_type='signin')。 +如果想把签到发的金币也一并退掉(余额减回、删 signin 流水),加 --with-coins。 + +用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑): + python scripts/reset_signin.py # 只清 user_id=2 的签到记录 + python scripts/reset_signin.py 5 # 清 user_id=5 + python scripts/reset_signin.py --all # 清所有用户的签到记录 + python scripts/reset_signin.py --with-coins # 连签到发的金币一起退回(余额减、删 signin 流水) + python scripts/reset_signin.py --dry-run # 只看现状,不改 + +DB 默认取 <项目根>/data/app.db,可用 --db 指定。 +""" +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + +# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +# 本脚本在 scripts/ 下,项目根是上一级 +DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db" + + +def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict: + """返回签到记录数 + 签到金币流水数/总额 + 账户金币余额,用于打印 before/after。""" + signin_rows = cur.execute( + f"select count(*) from signin_record where {where}", params + ).fetchone()[0] + signin_coin_rows, signin_coin_sum = cur.execute( + f"select count(*), coalesce(sum(amount), 0) from coin_transaction " + f"where biz_type='signin' and {where}", + params, + ).fetchone() + accounts = cur.execute( + f"select user_id, coin_balance, total_coin_earned from coin_account where {where}", + params, + ).fetchall() + return { + "signin_rows": signin_rows, + "signin_coin_rows": signin_coin_rows, + "signin_coin_sum": signin_coin_sum, + "accounts": accounts, + } + + +def print_snapshot(label: str, snap: dict) -> None: + print(f"--- {label} ---") + print(f" signin_record: {snap['signin_rows']}") + print(f" coin_transaction(signin): {snap['signin_coin_rows']} 条, 共 {snap['signin_coin_sum']} 金币") + if snap["accounts"]: + for uid, coin, earned in snap["accounts"]: + print(f" coin_account user={uid}: coin={coin} earned={earned}") + else: + print(" coin_account: (无)") + + +def main() -> None: + parser = argparse.ArgumentParser(description="清空用户签到记录") + parser.add_argument("user_id", nargs="?", type=int, default=2, + help="要清的 user_id(默认 2)") + parser.add_argument("--all", action="store_true", help="清所有用户") + parser.add_argument("--with-coins", action="store_true", + help="同时退回签到发的金币(余额减、删 signin 流水)") + parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改") + parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径") + args = parser.parse_args() + + if args.all: + where, params, scope = "1=1", (), "ALL users" + else: + where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}" + + db_path = Path(args.db) + if not db_path.exists(): + raise SystemExit(f"DB 不存在: {db_path}") + + conn = sqlite3.connect(str(db_path)) + cur = conn.cursor() + print(f"DB: {db_path} scope: {scope} with_coins: {args.with_coins}") + print_snapshot("before", snapshot(cur, where, params)) + + if args.dry_run: + print("(dry-run,未修改)") + conn.close() + return + + if args.with_coins: + # 先按用户把签到金币总额退回(余额、累计收益都减),再删 signin 流水 + rows = cur.execute( + f"select user_id, coalesce(sum(amount), 0) from coin_transaction " + f"where biz_type='signin' and {where} group by user_id", + params, + ).fetchall() + for uid, total in rows: + cur.execute( + "update coin_account set coin_balance = coin_balance - ?, " + "total_coin_earned = max(0, total_coin_earned - ?) where user_id = ?", + (total, total, uid), + ) + cur.execute(f"delete from coin_transaction where biz_type='signin' and {where}", params) + + cur.execute(f"delete from signin_record where {where}", params) + conn.commit() + + print_snapshot("after", snapshot(cur, where, params)) + conn.close() + print("签到记录已清空。提醒:App 内存里的状态不会自动同步,重启 App 或进收益明细页刷新;" + "重进福利页(当天未签到)会重新自动弹签到弹窗。") + + +if __name__ == "__main__": + main() diff --git a/scripts/reset_welfare.py b/scripts/reset_welfare.py new file mode 100644 index 0000000..12c3d53 --- /dev/null +++ b/scripts/reset_welfare.py @@ -0,0 +1,101 @@ +"""一键回滚某个用户的福利数据(金币/现金/签到/一次性任务/看广告发奖),方便反复测试。 + +清掉:coin_transaction / cash_transaction / signin_record / user_task / ad_reward_record, +并把 coin_account 的金币余额、累计收益、现金余额全部归零。 +**不动** savings_record(那是 profile 省钱卡的 demo 演示数据,与领奖测试无关)。 + +用法(在 shaguabijia-app-server 目录下,用本项目的 python 跑): + python scripts/reset_welfare.py # 默认重置 user_id=2(手机登录用户) + python scripts/reset_welfare.py 5 # 重置 user_id=5 + python scripts/reset_welfare.py --all # 重置所有用户 + python scripts/reset_welfare.py --dry-run # 只看现状,不改 + +DB 默认取 <项目根>/data/app.db,可用 --db 指定。 +""" +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + +# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +# 本脚本在 scripts/ 下,项目根是上一级 +DEFAULT_DB = Path(__file__).resolve().parent.parent / "data" / "app.db" + +# 受影响的表:每个用户的钱包/行为数据 +WALLET_TABLES = ("coin_transaction", "cash_transaction", "signin_record", "user_task", "ad_reward_record") + + +def snapshot(cur: sqlite3.Cursor, where: str, params: tuple) -> dict: + """返回当前各表行数 + 账户余额,用于打印 before/after。""" + counts = { + t: cur.execute(f"select count(*) from {t} where {where}", params).fetchone()[0] + for t in WALLET_TABLES + } + accounts = cur.execute( + f"select user_id, coin_balance, cash_balance_cents, total_coin_earned " + f"from coin_account where {where}", + params, + ).fetchall() + return {"counts": counts, "accounts": accounts} + + +def print_snapshot(label: str, snap: dict) -> None: + print(f"--- {label} ---") + for t, n in snap["counts"].items(): + print(f" {t}: {n}") + if snap["accounts"]: + for uid, coin, cash, earned in snap["accounts"]: + print(f" coin_account user={uid}: coin={coin} cash_cents={cash} earned={earned}") + else: + print(" coin_account: (无)") + + +def main() -> None: + parser = argparse.ArgumentParser(description="回滚用户福利数据") + parser.add_argument("user_id", nargs="?", type=int, default=2, + help="要重置的 user_id(默认 2)") + parser.add_argument("--all", action="store_true", help="重置所有用户") + parser.add_argument("--dry-run", action="store_true", help="只看现状,不修改") + parser.add_argument("--db", default=str(DEFAULT_DB), help="SQLite 路径") + args = parser.parse_args() + + if args.all: + where, params, scope = "1=1", (), "ALL users" + else: + where, params, scope = "user_id=?", (args.user_id,), f"user_id={args.user_id}" + + db_path = Path(args.db) + if not db_path.exists(): + raise SystemExit(f"DB 不存在: {db_path}") + + conn = sqlite3.connect(str(db_path)) + cur = conn.cursor() + print(f"DB: {db_path} scope: {scope}") + print_snapshot("before", snapshot(cur, where, params)) + + if args.dry_run: + print("(dry-run,未修改)") + conn.close() + return + + for t in WALLET_TABLES: + cur.execute(f"delete from {t} where {where}", params) + cur.execute( + f"update coin_account set coin_balance=0, total_coin_earned=0, " + f"cash_balance_cents=0 where {where}", + params, + ) + conn.commit() + + print_snapshot("after", snapshot(cur, where, params)) + conn.close() + print("回滚完成。提醒:App 内存里的共享余额态不会自动同步,重启 App 或进收益明细页刷新。") + + +if __name__ == "__main__": + main() diff --git a/scripts/sim_pangle_callback.py b/scripts/sim_pangle_callback.py new file mode 100644 index 0000000..b6ec1c7 --- /dev/null +++ b/scripts/sim_pangle_callback.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 562f6b6..5990374 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,16 @@ os.environ.setdefault("JG_MASTER_SECRET", "test-secret") os.environ.setdefault("SMS_MOCK", "true") os.environ.setdefault("APP_ENV", "dev") os.environ.setdefault("APP_DEBUG", "false") # 测试不打 SQL 日志 +# 微信支付:填 dummy 让 wxpay_configured 为真(测试里 wxpay 的网络调用全 monkeypatch,不会真发) +os.environ.setdefault("WECHAT_APP_ID", "wxtest0000000000") +os.environ.setdefault("WECHAT_APP_SECRET", "test-secret") +os.environ.setdefault("WXPAY_MCH_ID", "test-mch") +os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial") +os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id") +os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨用例累加,测试关掉 +# 穿山甲发奖回调:测试里开启 + 给个 mock 验签密钥,test 内自签自验闭环 +os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true") +os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest") import pytest from fastapi.testclient import TestClient diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py new file mode 100644 index 0000000..9ee3f20 --- /dev/null +++ b/tests/test_ad_reward.py @@ -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 diff --git a/tests/test_welfare.py b/tests/test_welfare.py new file mode 100644 index 0000000..7bf69fe --- /dev/null +++ b/tests/test_welfare.py @@ -0,0 +1,257 @@ +"""福利模块测试:钱包 / 签到 / 一次性任务。 + +用 sms mock 登录拿 token,再跑各接口闭环。 +""" +from __future__ import annotations + +from app.core.rewards import ( + COIN_PER_CENT, + COIN_PER_YUAN, + MIN_EXCHANGE_COIN, + SIGNIN_REWARDS, + TASK_ENABLE_NOTIFICATION, + TASK_REWARDS, +) + + +def _login(client, phone: str) -> str: + """sms mock 登录,返回 access_token。""" + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def test_account_auto_created_empty(client) -> None: + """首次访问账户:自动建空账户,余额全 0。""" + token = _login(client, "13800001001") + r = client.get("/api/v1/wallet/account", headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body == {"coin_balance": 0, "cash_balance_cents": 0, "total_coin_earned": 0} + + +def test_signin_flow(client) -> None: + """签到状态 → 签到 → 余额到账 → 重复签到 409 → 流水有记录。""" + token = _login(client, "13800001002") + + # 1. 签到前状态:未签、可签、今天是第 1 档 + r = client.get("/api/v1/signin/status", headers=_auth(token)) + assert r.status_code == 200, r.text + st = r.json() + assert st["today_signed"] is False + assert st["can_claim"] is True + assert st["today_cycle_day"] == 1 + assert st["today_coin"] == SIGNIN_REWARDS[0] + assert len(st["steps"]) == len(SIGNIN_REWARDS) + assert st["steps"][0]["status"] == "today" + + # 2. 执行签到,发第 1 档金币 + r = client.post("/api/v1/signin", headers=_auth(token)) + assert r.status_code == 200, r.text + res = r.json() + assert res["cycle_day"] == 1 + assert res["streak"] == 1 + assert res["coin_awarded"] == SIGNIN_REWARDS[0] + assert res["coin_balance"] == SIGNIN_REWARDS[0] + + # 3. 账户余额到账 + r = client.get("/api/v1/wallet/account", headers=_auth(token)) + assert r.json()["coin_balance"] == SIGNIN_REWARDS[0] + + # 4. 状态变为已签 + r = client.get("/api/v1/signin/status", headers=_auth(token)) + st = r.json() + assert st["today_signed"] is True + assert st["can_claim"] is False + assert st["consecutive_days"] == 1 + assert st["steps"][0]["status"] == "claimed" + + # 5. 重复签到 → 409 + r = client.post("/api/v1/signin", headers=_auth(token)) + assert r.status_code == 409 + + # 6. 金币流水有一笔 signin 入账 + r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) + assert r.status_code == 200, r.text + page = r.json() + assert page["next_cursor"] is None + assert len(page["items"]) == 1 + txn = page["items"][0] + assert txn["amount"] == SIGNIN_REWARDS[0] + assert txn["biz_type"] == "signin" + assert txn["balance_after"] == SIGNIN_REWARDS[0] + + +def test_task_claim_flow(client) -> None: + """任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。""" + token = _login(client, "13800001003") + coin = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] + + # 1. 任务列表:未领取 + r = client.get("/api/v1/tasks", headers=_auth(token)) + assert r.status_code == 200, r.text + items = {it["task_key"]: it for it in r.json()["items"]} + assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False + assert items[TASK_ENABLE_NOTIFICATION]["coin"] == coin + + # 2. 领奖 + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["coin_awarded"] == coin + assert r.json()["coin_balance"] == coin + + # 3. 重复领 → 409 + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 409 + + # 4. 列表变为已领取 + r = client.get("/api/v1/tasks", headers=_auth(token)) + items = {it["task_key"]: it for it in r.json()["items"]} + assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True + + # 5. 未知任务 → 404 + r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token)) + assert r.status_code == 404 + + +def test_exchange_info(client) -> None: + token = _login(client, "13800001004") + r = client.get("/api/v1/wallet/exchange-info", headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["coin_per_yuan"] == COIN_PER_YUAN + assert body["min_coin"] == MIN_EXCHANGE_COIN + assert body["step_coin"] == COIN_PER_CENT + + +def test_exchange_flow(client) -> None: + """先用任务领够金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。""" + token = _login(client, "13800001005") + # 领"打开消息提醒"得 10000 金币(= 1 元额度) + client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + + # 兑换 10000 金币 → 100 分 + r = client.post( + "/api/v1/wallet/exchange", + json={"coin_amount": COIN_PER_YUAN}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + res = r.json() + assert res["coin_amount"] == COIN_PER_YUAN + assert res["cash_added_cents"] == 100 + assert res["coin_balance"] == 0 + assert res["cash_balance_cents"] == 100 + + # 账户余额同步 + r = client.get("/api/v1/wallet/account", headers=_auth(token)) + assert r.json()["coin_balance"] == 0 + assert r.json()["cash_balance_cents"] == 100 + + # 金币流水多了一笔 exchange_out(负) + r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) + types = [t["biz_type"] for t in r.json()["items"]] + assert "exchange_out" in types + out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out") + assert out_txn["amount"] == -COIN_PER_YUAN + + # 现金流水有一笔 exchange_in(正) + r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token)) + page = r.json() + assert len(page["items"]) == 1 + assert page["items"][0]["amount_cents"] == 100 + assert page["items"][0]["biz_type"] == "exchange_in" + + +def test_exchange_insufficient_and_invalid(client) -> None: + token = _login(client, "13800001006") + + # 余额 0 → 兑换 1 元 → 409 余额不足 + r = client.post( + "/api/v1/wallet/exchange", + json={"coin_amount": COIN_PER_YUAN}, + headers=_auth(token), + ) + assert r.status_code == 409 + + # 低于最小额 → 400 + r = client.post( + "/api/v1/wallet/exchange", + json={"coin_amount": MIN_EXCHANGE_COIN - COIN_PER_CENT}, + headers=_auth(token), + ) + assert r.status_code == 400 + + # 非整分倍数(带零头)→ 400 + r = client.post( + "/api/v1/wallet/exchange", + json={"coin_amount": COIN_PER_YUAN + 1}, + headers=_auth(token), + ) + assert r.status_code == 400 + + +def test_savings_summary_and_battle(client) -> None: + """首次访问触发 demo seeder,聚合接口返回真实算出的数字。""" + token = _login(client, "13800001007") + + r = client.get("/api/v1/savings/summary", headers=_auth(token)) + assert r.status_code == 200, r.text + s = r.json() + assert s["order_count"] > 0 + assert s["total_saved_cents"] > 0 + # 平均每单 = 总额 // 单数 + assert s["avg_saved_cents"] == s["total_saved_cents"] // s["order_count"] + + r = client.get("/api/v1/savings/battle", headers=_auth(token)) + assert r.status_code == 200, r.text + b = r.json() + assert b["streak_days"] >= 1 + assert b["week_saved_cents"] >= 0 + assert 0 <= b["beat_percent"] <= 100 + + +def test_savings_seeder_idempotent(client) -> None: + """重复访问不应重复灌数据:两次 summary 完全一致。""" + token = _login(client, "13800001008") + first = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + second = client.get("/api/v1/savings/summary", headers=_auth(token)).json() + assert first == second + + +def test_savings_records_pagination(client) -> None: + token = _login(client, "13800001009") + # 第一页 + r = client.get("/api/v1/savings/records?limit=5", headers=_auth(token)) + assert r.status_code == 200, r.text + page1 = r.json() + assert len(page1["items"]) == 5 + assert page1["next_cursor"] is not None + # id 倒序 + ids = [it["id"] for it in page1["items"]] + assert ids == sorted(ids, reverse=True) + # 第二页接着游标走,不与第一页重叠 + r = client.get( + f"/api/v1/savings/records?limit=5&cursor={page1['next_cursor']}", + headers=_auth(token), + ) + page2 = r.json() + assert max(it["id"] for it in page2["items"]) < min(ids) + + +def test_endpoints_require_auth(client) -> None: + """不带 token 的福利接口统一 401。""" + assert client.get("/api/v1/wallet/account").status_code == 401 + assert client.get("/api/v1/signin/status").status_code == 401 + assert client.post("/api/v1/signin").status_code == 401 + assert client.get("/api/v1/tasks").status_code == 401 + assert client.get("/api/v1/wallet/cash-transactions").status_code == 401 + assert client.post("/api/v1/wallet/exchange", json={"coin_amount": 10000}).status_code == 401 + assert client.get("/api/v1/savings/summary").status_code == 401 + assert client.get("/api/v1/savings/battle").status_code == 401 + assert client.get("/api/v1/savings/records").status_code == 401 diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py new file mode 100644 index 0000000..c77ca1a --- /dev/null +++ b/tests/test_withdraw.py @@ -0,0 +1,271 @@ +"""提现(现金 → 微信零钱)测试。 + +wxpay 的网络调用(code 换 openid / 发起转账 / 查单)全部 monkeypatch,不真发微信。 +现金余额用 SessionLocal 直接 seed(没有"加现金"接口,正常靠金币兑换)。 +""" +from __future__ import annotations + +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.user import User +from app.models.wallet import CoinAccount, WithdrawOrder + + +def _login(client, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _seed_cash(client, token: str, phone: str, cents: int) -> None: + """先访问 /account 触发建账户,再用 DB 直接灌现金余额。""" + client.get("/api/v1/wallet/account", headers=_auth(token)) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + acc = db.get(CoinAccount, user.id) + acc.cash_balance_cents = cents + db.commit() + finally: + db.close() + + +def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵称", avatar="https://x/y.png"): + monkeypatch.setattr( + "app.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