"""admin 跨用户查询(去掉现有 repo 的 user_id 强制过滤)+ 通用游标分页 helper + 用户概览。 现有 app/repositories/ 的 list_* 都强绑单个 user_id(C 端只看自己);admin 要看全量、按条件筛, 所以在这里另起一套。游标约定与现有一致:id 倒序,cursor=上页最后一条 id,返回 (items, next_cursor)。 """ from __future__ import annotations from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo from sqlalchemy import Select, asc, desc, func, or_, select from sqlalchemy.orm import Session from app.models.admin import AdminAuditLog from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder def cursor_paginate( db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None ) -> tuple[list, int | None]: """通用游标分页(id 倒序)。stmt 不要预先带 order_by/limit。 多取 1 条探测有没有下一页;next_cursor 取本页最后一条的 id(下一页查 id < 它), 绝不用第 limit+1 条的 id——那条既不在本页也不在下页,会每页边界丢一条(见 audit_log 同款修复)。 """ if cursor is not None: stmt = stmt.where(id_col < cursor) stmt = stmt.order_by(id_col.desc()).limit(limit + 1) rows = list(db.execute(stmt).scalars().all()) has_more = len(rows) > limit items = rows[:limit] next_cursor = items[-1].id if has_more else None return items, next_cursor def list_users( db: Session, *, phone: str | None = None, register_channel: str | None = None, status: str | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[User], int | None]: stmt = select(User) if phone: stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 if register_channel: stmt = stmt.where(User.register_channel == register_channel) if status: stmt = stmt.where(User.status == status) return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor) def list_all_coin_transactions( db: Session, *, user_id: int | None = None, biz_type: str | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[CoinTransaction], int | None]: stmt = select(CoinTransaction) if user_id is not None: stmt = stmt.where(CoinTransaction.user_id == user_id) if biz_type: stmt = stmt.where(CoinTransaction.biz_type == biz_type) return cursor_paginate(db, stmt, CoinTransaction.id, limit=limit, cursor=cursor) def list_all_cash_transactions( db: Session, *, user_id: int | None = None, biz_type: str | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[CashTransaction], int | None]: stmt = select(CashTransaction) if user_id is not None: stmt = stmt.where(CashTransaction.user_id == user_id) if biz_type: stmt = stmt.where(CashTransaction.biz_type == biz_type) return cursor_paginate(db, stmt, CashTransaction.id, limit=limit, cursor=cursor) def list_all_withdraw_orders( db: Session, *, user_id: int | None = None, status: str | None = None, keyword: str | None = None, date_from: datetime | None = None, date_to: datetime | None = None, date_field: str = "created_at", sort_by: str = "created_at", sort_order: str = "desc", quick_filter: str | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[WithdrawOrder], int | None]: stmt = select(WithdrawOrder) needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk" if needs_user_join: stmt = stmt.outerjoin(User, User.id == WithdrawOrder.user_id) if user_id is not None: stmt = stmt.where(WithdrawOrder.user_id == user_id) if status: stmt = stmt.where(WithdrawOrder.status == status) kw = (keyword or "").strip() if kw: pattern = f"%{kw}%" conditions = [ WithdrawOrder.out_bill_no.ilike(pattern), WithdrawOrder.transfer_bill_no.ilike(pattern), WithdrawOrder.user_name.ilike(pattern), WithdrawOrder.wechat_state.ilike(pattern), WithdrawOrder.fail_reason.ilike(pattern), User.phone.ilike(pattern), User.nickname.ilike(pattern), User.wechat_nickname.ilike(pattern), ] if kw.isdigit(): conditions.append(WithdrawOrder.user_id == int(kw)) stmt = stmt.where(or_(*conditions)) date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at if date_from is not None: stmt = stmt.where(date_col >= _as_utc_naive(date_from)) if date_to is not None: stmt = stmt.where(date_col <= _as_utc_naive(date_to)) now = datetime.now(timezone.utc).replace(tzinfo=None) today_start = ( datetime.now(ZoneInfo("Asia/Shanghai")) .replace(hour=0, minute=0, second=0, microsecond=0) .astimezone(timezone.utc) .replace(tzinfo=None) ) if quick_filter == "abnormal": stmt = stmt.where( or_( WithdrawOrder.status.in_(("failed", "rejected")), WithdrawOrder.fail_reason.is_not(None), WithdrawOrder.wechat_state.in_(("FAIL", "CANCELLED", "CLOSED")), ) ) elif quick_filter == "failed": stmt = stmt.where(WithdrawOrder.status == "failed") elif quick_filter == "overdue_reviewing": stmt = stmt.where( WithdrawOrder.status == "reviewing", WithdrawOrder.created_at <= now - timedelta(minutes=30), ) elif quick_filter == "pending_overdue": stmt = stmt.where( WithdrawOrder.status == "pending", WithdrawOrder.updated_at <= now - timedelta(minutes=15), ) elif quick_filter == "today": stmt = stmt.where(WithdrawOrder.created_at >= today_start) elif quick_filter == "high_risk": stmt = stmt.where( or_( WithdrawOrder.user_name.is_(None), WithdrawOrder.user_name == "", User.status != "active", User.created_at >= now - timedelta(hours=24), WithdrawOrder.status.in_(("failed", "rejected")), WithdrawOrder.fail_reason.is_not(None), ) ) sort_cols = { "id": WithdrawOrder.id, "created_at": WithdrawOrder.created_at, "updated_at": WithdrawOrder.updated_at, "amount_cents": WithdrawOrder.amount_cents, } sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at) order_fn = asc if sort_order == "asc" else desc id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id) stmt = stmt.order_by(order_fn(sort_col), id_order) offset = max(cursor or 0, 0) rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all()) has_more = len(rows) > limit items = rows[:limit] next_cursor = offset + limit if has_more else None return items, next_cursor def _as_utc_naive(value: datetime) -> datetime: """前端传 ISO 时间;DB 当前按 UTC naive 比较最稳(SQLite/本地开发一致)。""" if value.tzinfo is None: return value return value.astimezone(timezone.utc).replace(tzinfo=None) def list_feedbacks( db: Session, *, status: str | None = None, user_id: int | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[Feedback], int | None]: stmt = select(Feedback) if status: stmt = stmt.where(Feedback.status == status) if user_id is not None: stmt = stmt.where(Feedback.user_id == user_id) return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor) def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None: """按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。""" return db.execute( select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no) ).scalar_one_or_none() def withdraw_summary(db: Session) -> dict: """提现审核台顶部统计。金额单位:分。""" rows = db.execute( select( WithdrawOrder.status, func.count(WithdrawOrder.id), func.coalesce(func.sum(WithdrawOrder.amount_cents), 0), ).group_by(WithdrawOrder.status) ).all() by_status = { status: {"count": int(count), "amount_cents": int(amount_cents)} for status, count, amount_cents in rows } today_start = ( datetime.now(ZoneInfo("Asia/Shanghai")) .replace(hour=0, minute=0, second=0, microsecond=0) .astimezone(timezone.utc) ) def _today_count(status: str) -> int: return db.execute( select(func.count(WithdrawOrder.id)).where( WithdrawOrder.status == status, WithdrawOrder.updated_at >= today_start, ) ).scalar_one() today_success_amount = db.execute( select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where( WithdrawOrder.status == "success", WithdrawOrder.updated_at >= today_start, ) ).scalar_one() return { "reviewing_count": by_status.get("reviewing", {}).get("count", 0), "reviewing_amount_cents": by_status.get("reviewing", {}).get("amount_cents", 0), "pending_count": by_status.get("pending", {}).get("count", 0), "failed_count": by_status.get("failed", {}).get("count", 0), "today_success_count": _today_count("success"), "today_success_amount_cents": int(today_success_amount), "today_rejected_count": _today_count("rejected"), } def list_withdraw_audit_logs( db: Session, out_bill_no: str, *, limit: int = 20 ) -> list[AdminAuditLog]: return list( db.execute( select(AdminAuditLog) .where(AdminAuditLog.target_type == "withdraw", AdminAuditLog.target_id == out_bill_no) .order_by(AdminAuditLog.id.desc()) .limit(limit) ).scalars().all() ) def withdraw_risk_flags( order: WithdrawOrder, user: User | None, recent_withdraws: list[WithdrawOrder], cash_balance_cents: int, ) -> tuple[list[str], int]: flags: list[str] = [] if not order.user_name: flags.append("缺少提现实名") if user and user.status != "active": flags.append(f"账号状态:{user.status}") if user and user.created_at: created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at if datetime.now(timezone.utc) - created_at < timedelta(hours=24): flags.append("新注册用户") failed_or_rejected = sum(1 for item in recent_withdraws if item.status in {"failed", "rejected"}) if failed_or_rejected: flags.append(f"历史异常提现{failed_or_rejected}笔") recent_reviewing = sum(1 for item in recent_withdraws if item.status == "reviewing") if recent_reviewing >= 3: flags.append(f"待审核提现偏多:{recent_reviewing}笔") if cash_balance_cents < 0: flags.append("现金余额为负") score = min(100, len(flags) * 20 + failed_or_rejected * 10) return flags, score def withdraw_ledger_check(db: Session) -> dict: cash_balance_total = int( db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one() ) cash_txn_total = int( db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one() ) orders = list(db.execute(select(WithdrawOrder)).scalars().all()) cash_txns = list( db.execute( select(CashTransaction).where( CashTransaction.biz_type.in_(("withdraw", "withdraw_refund")) ) ).scalars().all() ) withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"} refund_counts: dict[str, int] = {} for txn in cash_txns: if txn.biz_type == "withdraw_refund" and txn.ref_id: refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1 missing_withdraw = 0 missing_refund = 0 refund_on_non_terminal = 0 for order in orders: if order.out_bill_no not in withdraw_refs: missing_withdraw += 1 has_refund = refund_counts.get(order.out_bill_no, 0) > 0 if order.status in {"failed", "rejected"} and not has_refund: missing_refund += 1 if has_refund and order.status not in {"failed", "rejected"}: refund_on_non_terminal += 1 duplicate_refund = sum(1 for count in refund_counts.values() if count > 1) diff = cash_balance_total - cash_txn_total ok = ( diff == 0 and missing_withdraw == 0 and missing_refund == 0 and duplicate_refund == 0 and refund_on_non_terminal == 0 ) return { "ok": ok, "cash_balance_total_cents": cash_balance_total, "cash_transaction_total_cents": cash_txn_total, "balance_diff_cents": diff, "missing_withdraw_txn_count": missing_withdraw, "missing_refund_txn_count": missing_refund, "duplicate_refund_txn_count": duplicate_refund, "refund_txn_on_non_terminal_count": refund_on_non_terminal, } def get_user_overview(db: Session, user_id: int) -> dict | None: """用户 360 概览:基础资料 + 钱包余额 + 各项 count。历史明细走各自分页接口(带 user_id 过滤)。""" user = db.get(User, user_id) if user is None: return None acc = db.get(CoinAccount, user_id) # 可能为 None(从未发生过金币动作) def _count(model, *conds) -> int: return db.execute(select(func.count(model.id)).where(*conds)).scalar_one() return { "user": user, "coin_balance": acc.coin_balance if acc else 0, "cash_balance_cents": acc.cash_balance_cents if acc else 0, "total_coin_earned": acc.total_coin_earned if acc else 0, "comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id), "comparison_success": _count( ComparisonRecord, ComparisonRecord.user_id == user_id, ComparisonRecord.status == "success", ), "withdraw_total": _count(WithdrawOrder, WithdrawOrder.user_id == user_id), "withdraw_success_cents": db.execute( select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where( WithdrawOrder.user_id == user_id, WithdrawOrder.status == "success" ) ).scalar_one(), "feedback_total": _count(Feedback, Feedback.user_id == user_id), } def list_price_reports( db: Session, *, status: str | None = None, user_id: int | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[PriceReport], int | None]: """上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。""" stmt = select(PriceReport) if status: stmt = stmt.where(PriceReport.status == status) if user_id is not None: stmt = stmt.where(PriceReport.user_id == user_id) return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor) def price_report_summary(db: Session) -> dict: """上报审核台各状态计数(待审核/已通过/已拒绝/合计)。""" rows = db.execute( select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status) ).all() by_status = {status: int(count) for status, count in rows} return { "pending": by_status.get("pending", 0), "approved": by_status.get("approved", 0), "rejected": by_status.get("rejected", 0), "total": sum(by_status.values()), }