939eabd7ab
提现人工审核(提现不再即时打款): - models/wallet.py: WithdrawOrder 状态机改为 reviewing →(审核通过)→ pending → success/failed;reviewing →(驳回)→ rejected(已退款), 默认态 pending → reviewing(扣现金建单但不打款);新增 user_name 列(实名, 微信达额转账要求) - admin/routers/withdraw.py + admin/schemas/wallet.py: 运营后台审核接口(通过触发微信转账 / 驳回退款) - api/v1/wallet.py + repositories/wallet.py: 发起提现进 reviewing 态, 驳回退回现金并写 withdraw_refund - schemas/welfare.py: 提现出参增 reviewing/rejected 状态 + fail_reason 看广告每日时长限流(防刷主闸 + 次数兜底): - 新增 models/ad_watch_log.py: 按 (user_id, watch_date) 聚合当日观看秒数 - 新增 repositories/ad_watch.py: watched_seconds_today / add_watch_seconds(夹 [0, MAX_SINGLE_WATCH_SECONDS]) - api/v1/ad.py: 新增 POST /ad/watch-report(JWT 取 user, 落时长返当日累计); /ad/reward-status 增 watched_seconds_today / limit / remaining - schemas/ad.py: WatchReportIn/Out - core/rewards.py: 新增 DAILY_AD_WATCH_SECONDS_LIMIT=50 分钟(主闸)+ MAX_SINGLE_WATCH_SECONDS=120; DAILY_AD_REWARD_LIMIT 20→200 改作次数兜底(防前端少报时长绕过时长闸, 又不误伤看短广告的正常用户) - repositories/ad_reward.py + models/__init__.py: 接入新表与时长闸 alembic: withdraw_review_and_ad_watch 迁移(withdraw_order.user_name 列 + ad_watch_log 表) tests: 补 test_ad_reward / test_withdraw Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
249 lines
9.6 KiB
Python
249 lines
9.6 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
|
|
|
|
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,
|
|
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,
|
|
)
|