"""admin 提现:列表(读)+ 单笔重试查单 + 批量对账(写,带审计)。 提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。 这些 wallet 函数内部各自 commit(涉及微信调用),审计在其后单独 commit:操作本身幂等, 审计记录"谁触发的 + 结果",事务边界比"改金币"宽松是有意的(不能塞进 wallet 的自有事务)。 """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.repositories import queries from app.admin.schemas.common import CursorPage from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut, WithdrawRejectRequest from app.integrations import wxpay from app.models.admin import AdminUser from app.repositories import wallet as wallet_repo router = APIRouter( prefix="/admin/api/withdraws", tags=["admin-withdraw"], dependencies=[Depends(get_current_admin)], ) @router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表") def list_withdraws( db: AdminDb, user_id: Annotated[int | None, Query()] = None, status: Annotated[str | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[WithdrawOrderOut]: items, next_cursor = queries.list_all_withdraw_orders( db, user_id=user_id, status=status, limit=limit, cursor=cursor, ) return CursorPage( items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor, ) # 注意:/reconcile 必须在 /{out_bill_no}/refresh 之前声明(静态路径优先于路径参数) @router.post("/reconcile", response_model=ReconcileResult, summary="批量对账(扫超时 pending 单)") def reconcile( request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, older_than_minutes: Annotated[int, Query(ge=0)] = 15, ) -> ReconcileResult: try: result = wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes) except wxpay.WxPayNotConfiguredError as e: raise HTTPException(status_code=503, detail="微信支付未配置") from e write_audit( db, admin, action="withdraw.reconcile", target_type="withdraw", target_id=None, detail=result, ip=get_client_ip(request), commit=True, ) return ReconcileResult(**result) @router.post("/{out_bill_no}/refresh", response_model=WithdrawOrderOut, summary="单笔重试查单") def refresh_withdraw( out_bill_no: str, request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> WithdrawOrderOut: order = queries.get_withdraw_by_out_bill_no(db, out_bill_no) if order is None: raise HTTPException(status_code=404, detail="提现单不存在") try: refreshed = wallet_repo.refresh_withdraw_status( db, order.user_id, out_bill_no, cancel_if_unconfirmed=True, ) except wxpay.WxPayNotConfiguredError as e: raise HTTPException(status_code=503, detail="微信支付未配置") from e write_audit( db, admin, action="withdraw.refresh", target_type="withdraw", target_id=out_bill_no, detail={"status": refreshed.status, "wechat_state": refreshed.wechat_state}, ip=get_client_ip(request), commit=True, ) return WithdrawOrderOut.model_validate(refreshed) @router.post("/{out_bill_no}/approve", response_model=WithdrawOrderOut, summary="审核通过(发起微信打款)") def approve_withdraw_order( out_bill_no: str, request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> WithdrawOrderOut: """审核通过 → 调微信转账。仅 reviewing 单可通过(非 reviewing 返 409,防重复打款)。 打款的钱逻辑(转账/查单/退款/幂等)复用 wallet_repo.approve_withdraw → execute_withdraw_transfer; 返回的 order 可能是 pending(在途/待用户确认)/success/failed(打款失败已退),admin 前端按 status 展示。 """ try: order = wallet_repo.approve_withdraw(db, out_bill_no) except wallet_repo.WithdrawOrderNotFound as e: raise HTTPException(status_code=404, detail="提现单不存在") from e except wallet_repo.WithdrawNotReviewable as e: raise HTTPException(status_code=409, detail=str(e)) from e except wxpay.WxPayNotConfiguredError as e: raise HTTPException(status_code=503, detail="微信支付未配置") from e write_audit( db, admin, action="withdraw.approve", target_type="withdraw", target_id=out_bill_no, detail={ "status": order.status, "wechat_state": order.wechat_state, "amount_cents": order.amount_cents, }, ip=get_client_ip(request), commit=True, ) return WithdrawOrderOut.model_validate(order) @router.post("/{out_bill_no}/reject", response_model=WithdrawOrderOut, summary="审核拒绝(退回现金)") def reject_withdraw_order( out_bill_no: str, body: WithdrawRejectRequest, request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> WithdrawOrderOut: """审核拒绝 → 退回用户现金 + 置 rejected,理由写入 fail_reason(用户可见)。仅 reviewing 单可拒。""" try: order = wallet_repo.reject_withdraw(db, out_bill_no, body.reason) except wallet_repo.WithdrawOrderNotFound as e: raise HTTPException(status_code=404, detail="提现单不存在") from e except wallet_repo.WithdrawNotReviewable as e: raise HTTPException(status_code=409, detail=str(e)) from e write_audit( db, admin, action="withdraw.reject", target_type="withdraw", target_id=out_bill_no, detail={"reason": body.reason, "amount_cents": order.amount_cents}, ip=get_client_ip(request), commit=True, ) return WithdrawOrderOut.model_validate(order)