a4d214964a
- 数据访问层统一: app/crud/{ad_reward,savings,signin,task,wallet}.py 移入
app/repositories/(与 user.py 同目录),删除 crud/;更新 10 处 import
(api/v1/* + scripts/* + repositories 内部交叉引用 app.crud→app.repositories)
- alembic/versions 9 个迁移文件去掉 hex 前缀,改成可读文件名(如
welfare_tables_coin_account_coin_txn.py);**仅重命名文件**,文件内
revision/down_revision 不动 → 迁移链与已迁移库的 alembic_version 不受影响
(alembic heads/history 验证链完好,单一 head c8d9e0f1a2b3)
- 测试: 37 passed(1 个 coupon 代理失败为连不到 pricebot 上游的环境问题,与本次无关)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
241 lines
9.0 KiB
Python
241 lines
9.0 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,
|
|
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,
|
|
)
|