e1bd0e3ef7
改了什么:新增新版大盘日期窗口聚合、广告收益拆分、比价耗时字段、美团 CPS 拉单入库与大盘展示,并同步反馈审核和邀请奖励下线口径。 验证:python -m pytest tests/test_admin_read.py tests/test_admin_write.py tests/test_compare_record.py tests/test_invite.py tests/test_feedback.py -q。
505 lines
18 KiB
Python
505 lines
18 KiB
Python
"""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 同款修复)。
|
|
"""
|
|
# cursor = 上页最后一条 id 的 keyset 游标;0/None 一律视为首页(不加过滤)。
|
|
# 页码 hook usePagedList 首页发 cursor=0,若按 id<0 过滤会把首页整页查空(列表显示"暂无数据")。
|
|
# useCursorList 只会传 null 或正数 id,不受影响。
|
|
if cursor:
|
|
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 offset_paginate(
|
|
db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None
|
|
) -> tuple[list, int | None, int]:
|
|
"""offset 分页 + 总数。cursor 即 offset,用于 admin Table 页码分页。"""
|
|
total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one())
|
|
offset = max(cursor or 0, 0)
|
|
rows = list(
|
|
db.execute(stmt.order_by(*sort_clause).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, total
|
|
|
|
|
|
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
|
|
"""给比价记录瞬态挂 phone/nickname,供 admin schema from_attributes 读取。"""
|
|
uids = {r.user_id for r in records}
|
|
if not uids:
|
|
return
|
|
rows = db.execute(
|
|
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
|
).all()
|
|
umap = {uid: (phone, nick) for uid, phone, nick in rows}
|
|
for r in records:
|
|
phone, nick = umap.get(r.user_id, (None, None))
|
|
r.phone = phone
|
|
r.nickname = nick
|
|
|
|
|
|
def list_comparison_records(
|
|
db: Session,
|
|
*,
|
|
user_id: int | None = None,
|
|
phone: str | None = None,
|
|
status: str | None = None,
|
|
business_type: str | None = None,
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[ComparisonRecord], int | None, int]:
|
|
stmt = select(ComparisonRecord)
|
|
if user_id is not None:
|
|
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
|
if phone:
|
|
stmt = stmt.where(
|
|
ComparisonRecord.user_id.in_(
|
|
select(User.id).where(User.phone.like(f"{phone}%"))
|
|
)
|
|
)
|
|
if status:
|
|
stmt = stmt.where(ComparisonRecord.status == status)
|
|
if business_type:
|
|
stmt = stmt.where(ComparisonRecord.business_type == business_type)
|
|
|
|
items, next_cursor, total = offset_paginate(
|
|
db,
|
|
stmt,
|
|
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
|
limit=limit,
|
|
cursor=cursor,
|
|
)
|
|
_attach_user_info(db, items)
|
|
return items, next_cursor, total
|
|
|
|
|
|
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
|
|
rec = db.get(ComparisonRecord, record_id)
|
|
if rec is not None:
|
|
_attach_user_info(db, [rec])
|
|
return rec
|
|
|
|
|
|
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()),
|
|
}
|