27f76918b2
本 PR 汇合三块运营后台改动(原 #50 仅含其中「加固」一块,已并入本 PR 并关闭)。 ## 1. review 加固 (436b2a3) - 超管防自锁:降级/禁用最后一个 active super_admin 前校验,杜绝零超管死局 - 时间筛选统一 tz-aware(列为 timestamptz),比较绝对时刻、不依赖 DB 会话时区 - 上报审核 / 调余额(set·扣减)加行锁,防并发/连点重复发钱 - ad_audit 复算排序补 id 次级键;health-check 限 finance;调账/拒绝 reason 去空白校验 ## 2. 反馈改版 (5a18dbb) - contact 可选、截图≤6;admin 反馈列表筛选/排序;admin·wallet 接口调整 + docs ## 3. 列表页码分页 (1a7a624) - CursorPage 加 total;新增 offset_paginate(count 与分页同源) - 上报/审计日志从 id 游标改 offset 分页(支持跳页) - 用户 / 提现 / 上报 / 审计日志 四页接入页码分页 测试:admin 套件 47 passed。前端配套改动见 shaguabijia-admin-web。 --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #51 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
448 lines
18 KiB
Python
448 lines
18 KiB
Python
"""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, total = 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,
|
|
total=total,
|
|
)
|
|
|
|
|
|
@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="提现配置健康检查",
|
|
dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super
|
|
)
|
|
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)
|