"""钱包 / 我的资产 endpoint。 路由前缀 `/api/v1/wallet`: GET /account 金币 + 现金余额(我的资产卡) GET /coin-transactions 金币流水(游标分页) """ from __future__ import annotations import logging from fastapi import APIRouter, Depends, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession from app.core.config import settings from app.core.ratelimit import rate_limit from app.core.rewards import ( COIN_PER_CENT, COIN_PER_YUAN, MIN_EXCHANGE_COIN, WITHDRAW_MAX_CENTS, WITHDRAW_MIN_CENTS, ) from app.repositories import wallet as crud_wallet from app.models.user import User from app.schemas.welfare import ( BindWechatRequest, BindWechatResultOut, CashTransactionOut, CashTransactionPage, CoinAccountOut, CoinTransactionOut, CoinTransactionPage, ExchangeInfoOut, ExchangeRequest, ExchangeResultOut, TransferAuthResultOut, TransferAuthStatusOut, UnbindWechatRequest, 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, req: UnbindWechatRequest | None = None ) -> UnbindWechatResultOut: # 有「待审核(reviewing)」提现单时,首次解绑拦下要求确认:这类单还没打款,确认解绑(force) # 时会被立即退回现金余额(refund_reviewing_withdraws_on_unbind),后台不再挂单,用户需先知情。 # (pending 单转账已在途、解绑影响不到,不拦;见 has_reviewing_withdraw。) force = req.force if req is not None else False if not force and crud_wallet.has_reviewing_withdraw(db, user.id): logger.info("unbind wechat needs_confirm(有待审核提现) user_id=%d", user.id) return UnbindWechatResultOut( bound=bool(user.wechat_openid), needs_confirm=True, message="你有提现正在审核中,解绑微信后这笔会自动退回到现金余额。确定解绑吗?", ) # 先把待审核提现立即退回现金(无则 no-op),再清 openid —— 让"解绑即退"名副其实 refunded = crud_wallet.refund_reviewing_withdraws_on_unbind(db, user.id) crud_wallet.unbind_wechat_openid(db, user.id) logger.info("unbind wechat ok user_id=%d force=%s refunded_withdraws=%d", user.id, force, refunded) 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) # 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时 auth = crud_wallet.sync_transfer_auth(db, 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, transfer_auth_enabled=bool(auth and auth.state == "active"), ) @router.post( "/withdraw", response_model=WithdrawResultOut, summary="发起提现(扣款建单,待人工审核;审核通过后才打款)", dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;用户级未完成单限制在仓库层 ) 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, source=req.source, user_name=req.user_name, out_bill_no=req.out_bill_no, # 0.01 元调试提现:放行低于最低额的小额。双闸——客户端仅 debug 包在「0.01 元提现」开关开时 # 连同 skip_review 一起下发;服务端仅非 prod 才认。生产恒 False,最低额校验照常。 allow_sub_min=(req.skip_review and not settings.is_prod), ) except crud_wallet.InvalidWithdrawAmountError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="提现金额不符合要求", ) from e except crud_wallet.WechatNotBoundError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e except crud_wallet.WithdrawTooFrequentError as e: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="已有提现申请正在审核或打款中,请处理完成后再申请", ) from e except crud_wallet.InsufficientCashError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e # 调试直发(非生产):skip_review=true 时跳过人工审核,立即发起微信转账(等价 admin approve)。 # 双闸保护——客户端仅 debug 包下发此 flag,服务端仅非 prod 才认;任一道闸拦住即恢复正常审核, # 生产绝不会被客户端 flag 绕过审核。用于本地联调"提现→微信转账/免确认到账"全链路。 if req.skip_review and not settings.is_prod and order.status == "reviewing": logger.warning( "withdraw skip_review(非prod调试直发,跳过人工审核立即打款) user_id=%d bill=%s", user.id, order.out_bill_no, ) order = crud_wallet.execute_withdraw_transfer(db, order) acc = crud_wallet.get_or_create_account(db, user.id) logger.info( "withdraw submitted user_id=%d cents=%d bill=%s status=%s", user.id, req.amount_cents, order.out_bill_no, order.status, ) # 此刻 status=reviewing,尚未打款 → 无 package_info;App 据 status 提示"已提交,等待审核"。 # 审核通过后客户端轮询 /withdraw/status 拿到 package_info(若需确认)再拉起微信确认页。 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, fail_reason=order.fail_reason, # 审核通过后 status=pending 且可能带 package_info(WAIT_USER_CONFIRM):供 App 拉起微信确认页 package_info=order.package_info, mch_id=settings.WXPAY_MCH_ID if order.package_info else None, app_id=settings.WECHAT_APP_ID if order.package_info else None, ) @router.get("/withdraw-orders", response_model=WithdrawOrderPage, summary="提现单列表(游标分页)") def withdraw_orders( user: CurrentUser, db: DbSession, source: str | None = Query(None, description="按账户来源过滤:coin_cash / invite_cash;不传=全部"), 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, source=source, limit=limit, cursor=cursor) return WithdrawOrderPage( items=[WithdrawOrderOut.model_validate(it) for it in items], next_cursor=next_cursor, ) # ===== 免确认收款授权(用户授权免确认模式)===== # 开启一次后,后续提现走免确认转账直接到账,不再跳微信确认。绑定 openid 是前提。 @router.post( "/transfer-auth", response_model=TransferAuthResultOut, summary="开启免确认到账(申请授权,返回拉起微信授权页的 package)", dependencies=[Depends(rate_limit(10, 60, "transfer-auth"))], ) def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOut: if not settings.wxpay_auth_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat transfer auth not configured", ) try: info = crud_wallet.apply_transfer_auth(db, user.id) except crud_wallet.WechatNotBoundError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e except crud_wallet.WithdrawTransferError as e: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e logger.info("open transfer-auth user_id=%d already_active=%s", user.id, info["already_active"]) return TransferAuthResultOut(**info) @router.get( "/transfer-auth/status", response_model=TransferAuthStatusOut, summary="查免确认授权状态(从微信授权页返回后轮询)", ) def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut: auth = crud_wallet.sync_transfer_auth(db, user.id) state = auth.state if auth else "none" return TransferAuthStatusOut(state=state, enabled=(state == "active")) @router.post( "/transfer-auth/close", response_model=TransferAuthStatusOut, summary="关闭免确认到账(解除授权)", ) def close_transfer_auth_endpoint(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut: crud_wallet.close_transfer_auth(db, user.id) logger.info("close transfer-auth user_id=%d", user.id) return TransferAuthStatusOut(state="closed", enabled=False)