"""钱包 / 我的资产 endpoint。 路由前缀 `/api/v1/wallet`: GET /account 金币 + 现金余额(我的资产卡) GET /coin-transactions 金币流水(游标分页) """ from __future__ import annotations import logging from fastapi import APIRouter, Depends, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession from app.core.config import settings from app.core.ratelimit import rate_limit from app.core.rewards import ( COIN_PER_CENT, COIN_PER_YUAN, MIN_EXCHANGE_COIN, WITHDRAW_MAX_CENTS, WITHDRAW_MIN_CENTS, ) from app.repositories import wallet as crud_wallet from app.models.user import User from app.schemas.welfare import ( BindWechatRequest, BindWechatResultOut, CashTransactionOut, CashTransactionPage, CoinAccountOut, CoinTransactionOut, CoinTransactionPage, ExchangeInfoOut, ExchangeRequest, ExchangeResultOut, UnbindWechatResultOut, WithdrawInfoOut, WithdrawOrderOut, WithdrawOrderPage, WithdrawRequest, WithdrawResultOut, WithdrawStatusOut, ) logger = logging.getLogger("shagua.wallet") router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"]) @router.get("/account", response_model=CoinAccountOut, summary="金币+现金余额") def get_account(user: CurrentUser, db: DbSession) -> CoinAccountOut: acc = crud_wallet.get_or_create_account(db, user.id) return CoinAccountOut.model_validate(acc) @router.get( "/coin-transactions", response_model=CoinTransactionPage, summary="金币流水(游标分页)", ) def coin_transactions( user: CurrentUser, db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), ) -> CoinTransactionPage: items, next_cursor = crud_wallet.list_coin_transactions( db, user.id, limit=limit, cursor=cursor ) return CoinTransactionPage( items=[CoinTransactionOut.model_validate(it) for it in items], next_cursor=next_cursor, ) @router.get("/cash-transactions", response_model=CashTransactionPage, summary="现金流水(游标分页)") def cash_transactions( user: CurrentUser, db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), ) -> CashTransactionPage: items, next_cursor = crud_wallet.list_cash_transactions( db, user.id, limit=limit, cursor=cursor ) return CashTransactionPage( items=[CashTransactionOut.model_validate(it) for it in items], next_cursor=next_cursor, ) @router.get("/exchange-info", response_model=ExchangeInfoOut, summary="金币兑现金汇率/规则") def exchange_info() -> ExchangeInfoOut: return ExchangeInfoOut( coin_per_yuan=COIN_PER_YUAN, min_coin=MIN_EXCHANGE_COIN, step_coin=COIN_PER_CENT, ) @router.post("/exchange", response_model=ExchangeResultOut, summary="金币兑现金") def exchange(req: ExchangeRequest, user: CurrentUser, db: DbSession) -> ExchangeResultOut: try: acc, cents = crud_wallet.exchange_coins_to_cash(db, user.id, req.coin_amount) except crud_wallet.InvalidExchangeAmountError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"coin_amount must be >= {MIN_EXCHANGE_COIN} and a multiple of {COIN_PER_CENT}", ) from e except crud_wallet.InsufficientCoinError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient coin balance") from e logger.info("exchange ok user_id=%d coin=%d cents=%d", user.id, req.coin_amount, cents) return ExchangeResultOut( coin_amount=req.coin_amount, cash_added_cents=cents, coin_balance=acc.coin_balance, cash_balance_cents=acc.cash_balance_cents, ) # ===== 提现(现金 → 微信零钱) ===== @router.post( "/bind-wechat", response_model=BindWechatResultOut, summary="微信授权 code 换 openid 并绑定", dependencies=[Depends(rate_limit(10, 60, "bind-wechat"))], # #6 同 IP 每分钟≤10 次 ) def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> BindWechatResultOut: if not settings.wxpay_configured: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured") try: info = crud_wallet.bind_wechat_openid(db, user.id, req.code) except crud_wallet.WechatAlreadyBoundError as e: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="该微信已绑定其他账号" ) from e except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e logger.info( "bind wechat ok user_id=%d openid=%s*** nickname=%r avatar=%s", user.id, info["openid"][:6], info["nickname"], bool(info["avatar_url"]), ) return BindWechatResultOut( bound=True, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"] ) @router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)") def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut: crud_wallet.unbind_wechat_openid(db, user.id) logger.info("unbind wechat ok user_id=%d", user.id) return UnbindWechatResultOut(bound=False) @router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态") def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut: u = db.get(User, user.id) return WithdrawInfoOut( min_cents=WITHDRAW_MIN_CENTS, max_cents=WITHDRAW_MAX_CENTS, wechat_bound=bool(u and u.wechat_openid), wechat_nickname=u.wechat_nickname if u else None, wechat_avatar_url=u.wechat_avatar_url if u else None, ) @router.post( "/withdraw", response_model=WithdrawResultOut, summary="发起提现到微信零钱", dependencies=[Depends(rate_limit(20, 60, "withdraw"))], # #6 同 IP 每分钟≤20 次 ) def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut: if not settings.wxpay_configured: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured") try: order = crud_wallet.create_withdraw( db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no ) except crud_wallet.InvalidWithdrawAmountError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"amount_cents must be within [{WITHDRAW_MIN_CENTS}, {WITHDRAW_MAX_CENTS}]", ) from e except crud_wallet.WechatNotBoundError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e except crud_wallet.InsufficientCashError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e except crud_wallet.WithdrawTransferError as e: # 转账调用失败,余额已退回 raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"wechat transfer failed: {e}") from e acc = crud_wallet.get_or_create_account(db, user.id) logger.info("withdraw user_id=%d cents=%d bill=%s state=%s", user.id, req.amount_cents, order.out_bill_no, order.wechat_state) return WithdrawResultOut( out_bill_no=order.out_bill_no, status=order.status, wechat_state=order.wechat_state, amount_cents=order.amount_cents, cash_balance_cents=acc.cash_balance_cents, package_info=order.package_info, mch_id=settings.WXPAY_MCH_ID, app_id=settings.WECHAT_APP_ID, ) @router.get("/withdraw/status", response_model=WithdrawStatusOut, summary="查提现单状态(轮询)") def withdraw_status( user: CurrentUser, db: DbSession, out_bill_no: str = Query(..., description="商户提现单号") ) -> WithdrawStatusOut: try: # 用户从确认页返回后查单:仍 WAIT_USER_CONFIRM 视为放弃 → 撤销+退款 order = crud_wallet.refresh_withdraw_status( db, user.id, out_bill_no, cancel_if_unconfirmed=True ) except crud_wallet.WithdrawOrderNotFound as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="withdraw order not found") from e return WithdrawStatusOut( out_bill_no=order.out_bill_no, status=order.status, wechat_state=order.wechat_state, amount_cents=order.amount_cents, ) @router.get("/withdraw-orders", response_model=WithdrawOrderPage, summary="提现单列表(游标分页)") def withdraw_orders( user: CurrentUser, db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), ) -> WithdrawOrderPage: items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor) return WithdrawOrderPage( items=[WithdrawOrderOut.model_validate(it) for it in items], next_cursor=next_cursor, )