19f5987436
Reviewed-on: #82 Co-authored-by: xiebing <xiebing@wonderable.ai> Co-committed-by: xiebing <xiebing@wonderable.ai>
335 lines
14 KiB
Python
335 lines
14 KiB
Python
"""钱包 / 我的资产 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,
|
|
)
|
|
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)
|