"""admin 提现:列表(读)+ 单笔重试查单 + 批量对账(写,带审计)。 提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。 这些 wallet 函数内部各自 commit(涉及微信调用),审计在其后单独 commit:操作本身幂等, 审计记录"谁触发的 + 结果",事务边界比"改金币"宽松是有意的(不能塞进 wallet 的自有事务)。 """ from __future__ import annotations from datetime import datetime 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.admin import AdminAuditLogOut from app.admin.schemas.wallet import ( CashTxnOut, ReconcileResult, WithdrawBulkRejectRequest, WithdrawBulkRequest, WithdrawBulkResult, WithdrawBulkItemResult, WithdrawDetailOut, WithdrawLedgerCheckOut, WithdrawOrderOut, WithdrawRejectRequest, WithdrawSummaryOut, WithdrawUserSnapshot, WxpayHealthCheckOut, ) from app.core.config import settings 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, keyword: Annotated[str | None, Query(max_length=100)] = None, date_from: Annotated[datetime | None, Query()] = None, date_to: Annotated[datetime | None, Query()] = None, date_field: Annotated[str, Query(pattern="^(created_at|updated_at)$")] = "created_at", sort_by: Annotated[ str, Query(pattern="^(id|created_at|updated_at|amount_cents)$") ] = "created_at", sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", quick_filter: Annotated[ str | None, Query(pattern="^(abnormal|failed|overdue_reviewing|pending_overdue|today|high_risk)$"), ] = 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, keyword=keyword, date_from=date_from, date_to=date_to, date_field=date_field, sort_by=sort_by, sort_order=sort_order, quick_filter=quick_filter, limit=limit, cursor=cursor, ) return CursorPage( items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor, ) @router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计") def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut: return WithdrawSummaryOut(**queries.withdraw_summary(db)) @router.get("/health-check", response_model=WxpayHealthCheckOut, summary="提现配置健康检查") def withdraw_health_check() -> WxpayHealthCheckOut: private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001 public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001 issues: list[str] = [] private_loadable = False public_loadable = False try: wxpay._load_private_key() # noqa: SLF001 private_loadable = True except Exception as e: # noqa: BLE001 issues.append(f"商户私钥不可用:{e}") try: wxpay._load_public_key() # noqa: SLF001 public_loadable = True except Exception as e: # noqa: BLE001 issues.append(f"微信支付平台公钥不可用:{e}") if not settings.wxpay_configured: issues.append("微信支付基础配置不完整") if not settings.WXPAY_AUTH_NOTIFY_URL: issues.append("免确认授权回调地址未配置") if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: issues.append("自动对账未开启") return WxpayHealthCheckOut( ok=not issues, wxpay_configured=settings.wxpay_configured, wxpay_auth_configured=settings.wxpay_auth_configured, private_key_path=str(private_path), private_key_exists=private_path.exists(), private_key_loadable=private_loadable, public_key_path=str(public_path), public_key_exists=public_path.exists(), public_key_loadable=public_loadable, auth_notify_url_configured=bool(settings.WXPAY_AUTH_NOTIFY_URL), auto_reconcile_enabled=settings.WITHDRAW_AUTO_RECONCILE_ENABLED, auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC, auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES, issues=issues, ) @router.get("/ledger-check", response_model=WithdrawLedgerCheckOut, summary="提现资金账本校验") def withdraw_ledger_check(db: AdminDb) -> WithdrawLedgerCheckOut: return WithdrawLedgerCheckOut(**queries.withdraw_ledger_check(db)) @router.get("/{out_bill_no}", response_model=WithdrawDetailOut, summary="提现单详情") def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut: order = queries.get_withdraw_by_out_bill_no(db, out_bill_no) if order is None: raise HTTPException(status_code=404, detail="提现单不存在") overview = queries.get_user_overview(db, order.user_id) user_snapshot = None if overview is not None: user = overview["user"] user_snapshot = WithdrawUserSnapshot( id=user.id, phone=user.phone, nickname=user.nickname, status=user.status, wechat_nickname=user.wechat_nickname, wechat_avatar_url=user.wechat_avatar_url, created_at=user.created_at, last_login_at=user.last_login_at, cash_balance_cents=overview["cash_balance_cents"], withdraw_total=overview["withdraw_total"], withdraw_success_cents=overview["withdraw_success_cents"], ) recent_withdraws, _ = queries.list_all_withdraw_orders( db, user_id=order.user_id, limit=5, cursor=None, ) recent_cash_transactions, _ = queries.list_all_cash_transactions( db, user_id=order.user_id, limit=8, cursor=None, ) audit_logs = queries.list_withdraw_audit_logs(db, out_bill_no, limit=10) risk_flags, risk_score = queries.withdraw_risk_flags( order, overview["user"] if overview else None, recent_withdraws, overview["cash_balance_cents"] if overview else 0, ) return WithdrawDetailOut( order=WithdrawOrderOut.model_validate(order), user=user_snapshot, risk_flags=risk_flags, risk_score=risk_score, recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws], recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions], audit_logs=[AdminAuditLogOut.model_validate(log) for log in audit_logs], ) # 注意:/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) def _bulk_result(items: list[WithdrawBulkItemResult]) -> WithdrawBulkResult: success = sum(1 for item in items if item.ok) return WithdrawBulkResult( total=len(items), success=success, failed=len(items) - success, items=items, ) @router.post("/bulk/refresh", response_model=WithdrawBulkResult, summary="批量刷新查单") def bulk_refresh_withdraws( body: WithdrawBulkRequest, request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> WithdrawBulkResult: results: list[WithdrawBulkItemResult] = [] ip = get_client_ip(request) for out_bill_no in body.out_bill_nos: try: order = queries.get_withdraw_by_out_bill_no(db, out_bill_no) if order is None: raise wallet_repo.WithdrawOrderNotFound refreshed = wallet_repo.refresh_withdraw_status( db, order.user_id, out_bill_no, cancel_if_unconfirmed=True, ) write_audit( db, admin, action="withdraw.refresh", target_type="withdraw", target_id=out_bill_no, detail={ "status": refreshed.status, "wechat_state": refreshed.wechat_state, "fail_reason": refreshed.fail_reason, "bulk": True, }, ip=ip, commit=True, ) results.append( WithdrawBulkItemResult( out_bill_no=out_bill_no, ok=True, status=refreshed.status ) ) except wallet_repo.WithdrawOrderNotFound: db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在") ) except wxpay.WxPayNotConfiguredError: db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="微信支付未配置") ) except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批 db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}") ) return _bulk_result(results) @router.post("/bulk/approve", response_model=WithdrawBulkResult, summary="批量审核通过并打款") def bulk_approve_withdraws( body: WithdrawBulkRequest, request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> WithdrawBulkResult: results: list[WithdrawBulkItemResult] = [] ip = get_client_ip(request) for out_bill_no in body.out_bill_nos: try: order = wallet_repo.approve_withdraw(db, out_bill_no) 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, "bulk": True, }, ip=ip, commit=True, ) results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status) ) except wallet_repo.WithdrawOrderNotFound: db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在") ) except wallet_repo.WithdrawNotReviewable as e: db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=str(e)) ) except wxpay.WxPayNotConfiguredError: db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="微信支付未配置") ) except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批 db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}") ) return _bulk_result(results) @router.post("/bulk/reject", response_model=WithdrawBulkResult, summary="批量审核拒绝并退款") def bulk_reject_withdraws( body: WithdrawBulkRejectRequest, request: Request, admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> WithdrawBulkResult: results: list[WithdrawBulkItemResult] = [] ip = get_client_ip(request) for out_bill_no in body.out_bill_nos: try: order = wallet_repo.reject_withdraw(db, out_bill_no, body.reason) write_audit( db, admin, action="withdraw.reject", target_type="withdraw", target_id=out_bill_no, detail={ "reason": body.reason, "amount_cents": order.amount_cents, "bulk": True, }, ip=ip, commit=True, ) results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status) ) except wallet_repo.WithdrawOrderNotFound: db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在") ) except wallet_repo.WithdrawNotReviewable as e: db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=str(e)) ) except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批 db.rollback() results.append( WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}") ) return _bulk_result(results) @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, "fail_reason": refreshed.fail_reason, }, 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)