32f300b5a2
- GrantCashRequest 加 account(coin_cash / invite_cash,默认 coin_cash 兼容旧调用) - 调现金接口按 account 分支:邀请账户走 grant_invite_cash,余额读取/扣负保护/审计 detail 都按对应账户走;两本账物理隔离不可串 - 用户 360 overview 返回 invite_cash_balance_cents(admin 调现金弹窗展示邀请余额) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #95 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
1075 lines
43 KiB
Python
1075 lines
43 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, case, desc, func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core import rewards
|
|
from app.core.config import settings
|
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
|
from app.models.ad_reward import AdRewardRecord
|
|
from app.models.admin import AdminAuditLog
|
|
from app.models.analytics_event import AnalyticsEvent
|
|
from app.models.comparison import ComparisonRecord
|
|
from app.models.device import DeviceLiveness
|
|
from app.models.feedback import Feedback
|
|
from app.models.onboarding import OnboardingCompletion
|
|
from app.models.price_report import PriceReport
|
|
from app.models.user import User
|
|
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
|
|
|
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
|
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
|
|
|
# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。
|
|
_FEED_SCENE_LABEL = {
|
|
"comparison": "比价信息流",
|
|
"coupon": "领券信息流",
|
|
"welfare": "福利信息流",
|
|
}
|
|
|
|
|
|
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 offset_paginate(
|
|
db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None
|
|
) -> tuple[list, int | None, int]:
|
|
"""offset 分页 + 总数。stmt 只含 where/join,不要预先带 order_by/offset/limit。
|
|
|
|
cursor 即 offset(页码分页:offset=(page-1)*pageSize)。返回 (items, next_cursor, total):
|
|
- total:符合筛选条件的总条数(供 antd pagination 渲染页码/共 N 条),count 在 P0 量级开销可忽略;
|
|
- next_cursor:下一页 offset(兼容「加载更多」),末页为 None。
|
|
多取 1 条探测下一页。sort_clause 为 order_by 表达式元组(末位应含 id 保证稳定排序)。"""
|
|
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 list_users(
|
|
db: Session,
|
|
*,
|
|
phone: str | None = None,
|
|
register_channel: str | None = None,
|
|
status: str | None = None,
|
|
nickname: str | None = None,
|
|
created_from: datetime | None = None,
|
|
created_to: datetime | None = None,
|
|
last_login_from: datetime | None = None,
|
|
last_login_to: datetime | None = None,
|
|
sort_by: str = "id",
|
|
sort_order: str = "desc",
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[User], int | None, int]:
|
|
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
|
|
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
|
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
|
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
|
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)
|
|
if nickname and nickname.strip():
|
|
stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%"))
|
|
if created_from is not None:
|
|
stmt = stmt.where(User.created_at >= _as_utc(created_from))
|
|
if created_to is not None:
|
|
stmt = stmt.where(User.created_at <= _as_utc(created_to))
|
|
if last_login_from is not None:
|
|
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
|
|
if last_login_to is not None:
|
|
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
|
|
|
|
sort_cols = {
|
|
"id": User.id,
|
|
"created_at": User.created_at,
|
|
"last_login_at": User.last_login_at,
|
|
}
|
|
sort_col = sort_cols.get(sort_by, User.id)
|
|
order_fn = asc if sort_order == "asc" else desc
|
|
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
|
|
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
|
|
|
|
|
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
|
|
"""给每条记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。
|
|
按 user_id 鸭子类型,比价记录/反馈/上报通用。"""
|
|
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]:
|
|
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
|
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
|
|
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:
|
|
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
|
|
rec = db.get(ComparisonRecord, record_id)
|
|
if rec is not None:
|
|
_attach_user_info(db, [rec])
|
|
return rec
|
|
|
|
|
|
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
|
|
"""按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 +
|
|
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
|
|
引导、无需管理)。当前全量返回(上限 limit;调试期设备少,量大再加分页/搜索)。"""
|
|
rows = db.execute(
|
|
select(
|
|
OnboardingCompletion.device_id,
|
|
func.count(OnboardingCompletion.id),
|
|
func.max(OnboardingCompletion.completed_at),
|
|
)
|
|
.group_by(OnboardingCompletion.device_id)
|
|
.order_by(func.max(OnboardingCompletion.completed_at).desc())
|
|
.limit(limit)
|
|
).all()
|
|
return [
|
|
{"device_id": did, "account_count": int(cnt), "last_completed_at": last}
|
|
for did, cnt, last in rows
|
|
]
|
|
|
|
|
|
def _heartbeat_seconds_ago(last: datetime | None) -> int | None:
|
|
"""距上次心跳的秒数(兼容 SQLite 取回的 naive datetime,按 UTC 处理)。None = 从没心跳。"""
|
|
if last is None:
|
|
return None
|
|
if last.tzinfo is None:
|
|
last = last.replace(tzinfo=timezone.utc)
|
|
return int((datetime.now(timezone.utc) - last).total_seconds())
|
|
|
|
|
|
def _device_model_from_id(device_id: str) -> str:
|
|
"""从 device_id(格式 device_<机型>_<hash>)解析机型;不规范则回退原 id。"""
|
|
parts = device_id.split("_")
|
|
if len(parts) >= 3 and parts[0] == "device":
|
|
return "_".join(parts[1:-1]).replace("_", " ")
|
|
return device_id
|
|
|
|
|
|
def _attach_device_derived(devices: list[DeviceLiveness]) -> None:
|
|
"""给每台设备瞬态挂 device_model / online / display_state / offline_seconds(供 schema 读)。
|
|
|
|
在线判定:开过无障碍(ever_protected)且距上次心跳 ≤ HEARTBEAT_TIMEOUT_MINUTES。
|
|
从没开过无障碍(ever_protected=False)= never(不算掉线,避免把装了没用的设备误报掉线)。
|
|
offline_seconds 仅 offline 时给值(=掉线时长),在线/从未启用为 None。"""
|
|
timeout_sec = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES)) * 60
|
|
for d in devices:
|
|
d.device_model = _device_model_from_id(d.device_id)
|
|
secs = _heartbeat_seconds_ago(d.last_heartbeat_at)
|
|
if not d.ever_protected:
|
|
d.online = False
|
|
d.display_state = "never"
|
|
d.offline_seconds = None
|
|
elif secs is not None and secs <= timeout_sec:
|
|
d.online = True
|
|
d.display_state = "online"
|
|
d.offline_seconds = None
|
|
else:
|
|
d.online = False
|
|
d.display_state = "offline"
|
|
d.offline_seconds = secs
|
|
|
|
|
|
def _attach_device_user_info(db: Session, devices: list[DeviceLiveness]) -> None:
|
|
"""给每台设备瞬态挂归属用户 phone/nickname(同 _attach_user_info,供 admin schema 读)。"""
|
|
uids = {d.user_id for d in devices}
|
|
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 d in devices:
|
|
phone, nick = umap.get(d.user_id, (None, None))
|
|
d.phone = phone
|
|
d.nickname = nick
|
|
|
|
|
|
def _liveness_cutoff() -> datetime:
|
|
"""掉线判定分界:此刻 - HEARTBEAT_TIMEOUT_MINUTES。心跳早于它 = 掉线(同 list_overdue 口径)。"""
|
|
timeout_min = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
|
|
return datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
|
|
|
|
|
|
def list_device_liveness(
|
|
db: Session,
|
|
*,
|
|
status: str | None = None,
|
|
device_id: str | None = None,
|
|
phone: str | None = None,
|
|
user_id: int | None = None,
|
|
sort_by: str = "status",
|
|
sort_order: str = "desc",
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[DeviceLiveness], int | None, int]:
|
|
"""设备存活列表(admin 全量)。按 在线情况(online/offline/never)/ 设备id(包含)/
|
|
归属用户(手机号前缀 或 user_id)筛,offset 分页。join user 取 phone/nickname,派生
|
|
device_model/online/display_state/offline_seconds 挂行上。
|
|
默认排序 status=掉线置顶(offline → online → never;掉线组内掉得最久在前)。"""
|
|
cutoff = _liveness_cutoff()
|
|
stmt = select(DeviceLiveness)
|
|
# 在线情况派生筛选(口径同 _attach_device_derived:ever_protected + 心跳是否过阈值)
|
|
if status == "online":
|
|
stmt = stmt.where(
|
|
DeviceLiveness.ever_protected.is_(True),
|
|
DeviceLiveness.last_heartbeat_at.is_not(None),
|
|
DeviceLiveness.last_heartbeat_at >= cutoff,
|
|
)
|
|
elif status == "offline":
|
|
stmt = stmt.where(
|
|
DeviceLiveness.ever_protected.is_(True),
|
|
DeviceLiveness.last_heartbeat_at.is_not(None),
|
|
DeviceLiveness.last_heartbeat_at < cutoff,
|
|
)
|
|
elif status == "never":
|
|
stmt = stmt.where(DeviceLiveness.ever_protected.is_(False))
|
|
if device_id and device_id.strip():
|
|
stmt = stmt.where(DeviceLiveness.device_id.like(f"%{device_id.strip()}%"))
|
|
if user_id is not None:
|
|
stmt = stmt.where(DeviceLiveness.user_id == user_id)
|
|
if phone:
|
|
stmt = stmt.where(
|
|
DeviceLiveness.user_id.in_(select(User.id).where(User.phone.like(f"{phone}%")))
|
|
)
|
|
|
|
if sort_by in ("last_heartbeat_at", "created_at"):
|
|
col = (
|
|
DeviceLiveness.last_heartbeat_at
|
|
if sort_by == "last_heartbeat_at"
|
|
else DeviceLiveness.created_at
|
|
)
|
|
order_fn = asc if sort_order == "asc" else desc
|
|
id_order = asc(DeviceLiveness.id) if sort_order == "asc" else desc(DeviceLiveness.id)
|
|
sort_clause: tuple = (order_fn(col), id_order)
|
|
else:
|
|
# 默认「掉线置顶」:offline(0) → online(1) → never(2);掉线组内按心跳最旧(掉得最久)在前
|
|
rank = case(
|
|
(DeviceLiveness.ever_protected.is_(False), 2),
|
|
(DeviceLiveness.last_heartbeat_at < cutoff, 0),
|
|
else_=1,
|
|
)
|
|
sort_clause = (asc(rank), asc(DeviceLiveness.last_heartbeat_at), desc(DeviceLiveness.id))
|
|
|
|
items, next_cursor, total = offset_paginate(db, stmt, sort_clause, limit=limit, cursor=cursor)
|
|
_attach_device_user_info(db, items)
|
|
_attach_device_derived(items)
|
|
return items, next_cursor, total
|
|
|
|
|
|
def device_liveness_stats(db: Session) -> dict:
|
|
"""顶部卡片:总设备数 + 在线 / 已掉线 / 未启用(按心跳阈值派生,口径同列表)。"""
|
|
cutoff = _liveness_cutoff()
|
|
|
|
def _count(*conds) -> int:
|
|
return int(db.execute(select(func.count(DeviceLiveness.id)).where(*conds)).scalar_one())
|
|
|
|
total = int(db.execute(select(func.count(DeviceLiveness.id))).scalar_one())
|
|
never = _count(DeviceLiveness.ever_protected.is_(False))
|
|
online = _count(
|
|
DeviceLiveness.ever_protected.is_(True),
|
|
DeviceLiveness.last_heartbeat_at.is_not(None),
|
|
DeviceLiveness.last_heartbeat_at >= cutoff,
|
|
)
|
|
offline = _count(
|
|
DeviceLiveness.ever_protected.is_(True),
|
|
DeviceLiveness.last_heartbeat_at.is_not(None),
|
|
DeviceLiveness.last_heartbeat_at < cutoff,
|
|
)
|
|
return {"total": total, "online": online, "offline": offline, "never": never}
|
|
|
|
|
|
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, int]:
|
|
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(date_from))
|
|
if date_to is not None:
|
|
stmt = stmt.where(date_col <= _as_utc(date_to))
|
|
|
|
# tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py)
|
|
now = datetime.now(timezone.utc)
|
|
today_start = (
|
|
datetime.now(ZoneInfo("Asia/Shanghai"))
|
|
.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
.astimezone(timezone.utc)
|
|
)
|
|
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)
|
|
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
|
|
|
|
|
def _as_utc(value: datetime) -> datetime:
|
|
"""前端传 ISO 时间 → 统一成 tz-aware UTC 再比较。
|
|
|
|
所有时间列均为 `DateTime(timezone=True)`(Postgres timestamptz);用 tz-aware 绑定参数
|
|
比较的是绝对时刻,与 DB 会话时区无关、恒正确。曾用 naive UTC,正确性依赖会话 TimeZone=UTC,
|
|
生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。
|
|
无时区入参按 UTC 解释。"""
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]:
|
|
"""批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。
|
|
|
|
两条聚合查询搞定(避免逐行 N+1)。累计口径与 get_user_overview 的 withdraw_success_cents
|
|
完全一致:SUM(amount_cents) WHERE status='success'。返回 {user_id: {phone, nickname,
|
|
cumulative_success_cents}};某 user_id 查不到用户则不在 dict 里(路由侧回退到默认值)。
|
|
"""
|
|
uniq = list(set(user_ids))
|
|
if not uniq:
|
|
return {}
|
|
success_rows = db.execute(
|
|
select(
|
|
WithdrawOrder.user_id,
|
|
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
|
)
|
|
.where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
|
|
.group_by(WithdrawOrder.user_id)
|
|
).all()
|
|
success_map = {uid: int(total) for uid, total in success_rows}
|
|
users = db.execute(
|
|
select(User.id, User.phone, User.nickname).where(User.id.in_(uniq))
|
|
).all()
|
|
return {
|
|
uid: {
|
|
"phone": phone,
|
|
"nickname": nickname,
|
|
"cumulative_success_cents": success_map.get(uid, 0),
|
|
}
|
|
for uid, phone, nickname in users
|
|
}
|
|
|
|
|
|
def list_feedbacks(
|
|
db: Session,
|
|
*,
|
|
status: str | None = None,
|
|
user_id: int | None = None,
|
|
content: str | None = None,
|
|
created_from: datetime | None = None,
|
|
created_to: datetime | None = None,
|
|
sort_by: str = "id",
|
|
sort_order: str = "desc",
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[Feedback], int | None, int]:
|
|
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
|
|
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
|
|
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
|
|
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
|
|
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)
|
|
if content and content.strip():
|
|
stmt = stmt.where(Feedback.content.ilike(f"%{content.strip()}%"))
|
|
if created_from is not None:
|
|
stmt = stmt.where(Feedback.created_at >= _as_utc(created_from))
|
|
if created_to is not None:
|
|
stmt = stmt.where(Feedback.created_at <= _as_utc(created_to))
|
|
|
|
sort_cols = {"id": Feedback.id, "created_at": Feedback.created_at}
|
|
sort_col = sort_cols.get(sort_by, Feedback.id)
|
|
order_fn = asc if sort_order == "asc" else desc
|
|
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
|
|
items, next_cursor, total = offset_paginate(
|
|
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
|
)
|
|
_attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈)
|
|
return items, next_cursor, total
|
|
|
|
|
|
def feedback_summary(db: Session) -> dict:
|
|
"""反馈审核台各状态计数(待审核/已采纳/未采纳/合计)。待审核含历史 new 态(与前端 isPending 一致)。"""
|
|
rows = db.execute(
|
|
select(Feedback.status, func.count(Feedback.id)).group_by(Feedback.status)
|
|
).all()
|
|
by_status = {status: int(count) for status, count in rows}
|
|
return {
|
|
"pending": by_status.get("pending", 0) + by_status.get("new", 0),
|
|
"adopted": by_status.get("adopted", 0),
|
|
"rejected": by_status.get("rejected", 0),
|
|
"total": sum(by_status.values()),
|
|
}
|
|
|
|
|
|
def list_analytics_events(
|
|
db: Session,
|
|
*,
|
|
event: str | None = None,
|
|
device_id: str | None = None,
|
|
user_id: int | None = None,
|
|
session_id: str | None = None,
|
|
created_from: datetime | None = None,
|
|
created_to: datetime | None = None,
|
|
sort_by: str = "id",
|
|
sort_order: str = "desc",
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[AnalyticsEvent], int | None, int]:
|
|
"""埋点日志列表(admin 全量)。按 事件名 / 设备ID前缀 / 用户ID / 会话ID / 接收时间范围 筛选,
|
|
按 id·接收时间排序。offset 分页(同 [list_feedbacks])。created_at 为 timestamptz,
|
|
日期入参统一转 tz-aware UTC 比较。"""
|
|
stmt = select(AnalyticsEvent)
|
|
if event:
|
|
stmt = stmt.where(AnalyticsEvent.event == event)
|
|
if device_id and device_id.strip():
|
|
stmt = stmt.where(AnalyticsEvent.device_id.like(f"{device_id.strip()}%"))
|
|
if user_id is not None:
|
|
stmt = stmt.where(AnalyticsEvent.user_id == user_id)
|
|
if session_id and session_id.strip():
|
|
stmt = stmt.where(AnalyticsEvent.session_id == session_id.strip())
|
|
if created_from is not None:
|
|
stmt = stmt.where(AnalyticsEvent.created_at >= _as_utc(created_from))
|
|
if created_to is not None:
|
|
stmt = stmt.where(AnalyticsEvent.created_at <= _as_utc(created_to))
|
|
|
|
sort_cols = {"id": AnalyticsEvent.id, "created_at": AnalyticsEvent.created_at}
|
|
sort_col = sort_cols.get(sort_by, AnalyticsEvent.id)
|
|
order_fn = asc if sort_order == "asc" else desc
|
|
id_order = asc(AnalyticsEvent.id) if sort_order == "asc" else desc(AnalyticsEvent.id)
|
|
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), 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("新注册用户")
|
|
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
|
|
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
|
|
failed_n = sum(1 for item in recent_withdraws if item.status == "failed")
|
|
if rejected_n:
|
|
flags.append(f"提现拒绝{rejected_n}笔")
|
|
if failed_n:
|
|
flags.append(f"提现失败{failed_n}笔")
|
|
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 + (rejected_n + failed_n) * 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,
|
|
"invite_cash_balance_cents": acc.invite_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 _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
|
|
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
|
|
conds = []
|
|
if date_from is not None:
|
|
conds.append(col >= _as_utc_naive(date_from))
|
|
if date_to is not None:
|
|
conds.append(col <= _as_utc_naive(date_to))
|
|
return conds
|
|
|
|
|
|
def _coins_to_cents(coins: int) -> int:
|
|
"""累计金币折算成可提现现金(分)。COIN_PER_YUAN=10000 → 100 金币 = 1 分。"""
|
|
return round(coins / (rewards.COIN_PER_YUAN / 100))
|
|
|
|
|
|
def user_reward_stats(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
date_from: datetime | None = None,
|
|
date_to: datetime | None = None,
|
|
) -> dict:
|
|
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
|
|
|
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
|
|
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
|
|
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
|
"""
|
|
wd_success = db.execute(
|
|
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
|
WithdrawOrder.user_id == user_id,
|
|
WithdrawOrder.status == "success",
|
|
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
|
)
|
|
).scalar_one()
|
|
wd_total = db.execute(
|
|
select(func.count(WithdrawOrder.id)).where(
|
|
WithdrawOrder.user_id == user_id,
|
|
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
|
)
|
|
).scalar_one()
|
|
|
|
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
|
|
cash_balance = acc.cash_balance_cents if acc else 0
|
|
|
|
rv = list(db.execute(
|
|
select(AdRewardRecord).where(
|
|
AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.reward_scene == "reward_video",
|
|
AdRewardRecord.status == "granted",
|
|
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
|
)
|
|
).scalars())
|
|
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
|
rv_coins = sum(r.coin for r in rv)
|
|
|
|
feed = list(db.execute(
|
|
select(AdFeedRewardRecord).where(
|
|
AdFeedRewardRecord.user_id == user_id,
|
|
AdFeedRewardRecord.status == "granted",
|
|
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
|
)
|
|
).scalars())
|
|
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
|
feed_coins = sum(f.coin for f in feed)
|
|
|
|
trad_coins = db.execute(
|
|
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
|
|
CoinTransaction.user_id == user_id,
|
|
CoinTransaction.amount > 0,
|
|
CoinTransaction.biz_type.not_in(_NON_TASK_BIZ_TYPES),
|
|
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
|
)
|
|
).scalar_one()
|
|
|
|
return {
|
|
"withdraw_success_cents": int(wd_success),
|
|
"cash_balance_cents": int(cash_balance),
|
|
"withdraw_total": int(wd_total),
|
|
"traditional_task_cash_cents": _coins_to_cents(int(trad_coins)),
|
|
"reward_video_count": len(rv),
|
|
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
|
|
"reward_video_cash_cents": _coins_to_cents(rv_coins),
|
|
"feed_count": int(sum(f.unit_count for f in feed)),
|
|
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
|
|
"feed_cash_cents": _coins_to_cents(feed_coins),
|
|
}
|
|
|
|
|
|
def user_coin_records(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
date_from: datetime | None = None,
|
|
date_to: datetime | None = None,
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[dict], int | None, int]:
|
|
"""金币发放记录(提现详情底部表),按时间倒序、offset 游标分页 + 总数。
|
|
|
|
合并三类来源(Phase1 信息流不分比价/领券):
|
|
- 激励视频 / 签到膨胀 = ad_reward_record(granted)
|
|
- 信息流广告 = ad_feed_reward_record(granted)
|
|
- 签到 = coin_transaction(biz_type='signin')
|
|
每来源各取 offset+limit+1 条(倒序)再归并切片,避免 union 全量拉取。
|
|
"""
|
|
offset = max(cursor or 0, 0)
|
|
fetch = offset + limit + 1
|
|
rows: list[dict] = []
|
|
|
|
for rec in db.execute(
|
|
select(AdRewardRecord)
|
|
.where(
|
|
AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.status == "granted",
|
|
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
|
)
|
|
.order_by(AdRewardRecord.created_at.desc())
|
|
.limit(fetch)
|
|
).scalars():
|
|
is_video = rec.reward_scene == "reward_video"
|
|
rows.append({
|
|
"source": rec.reward_scene,
|
|
"source_label": "激励视频" if is_video else "签到膨胀",
|
|
"created_at": rec.created_at,
|
|
"ecpm": rec.ecpm_raw,
|
|
"coin": rec.coin,
|
|
})
|
|
|
|
for rec in db.execute(
|
|
select(AdFeedRewardRecord)
|
|
.where(
|
|
AdFeedRewardRecord.user_id == user_id,
|
|
AdFeedRewardRecord.status == "granted",
|
|
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
|
)
|
|
.order_by(AdFeedRewardRecord.created_at.desc())
|
|
.limit(fetch)
|
|
).scalars():
|
|
rows.append({
|
|
"source": "feed",
|
|
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
|
|
"created_at": rec.created_at,
|
|
"ecpm": rec.ecpm_raw,
|
|
"coin": rec.coin,
|
|
})
|
|
|
|
for rec in db.execute(
|
|
select(CoinTransaction)
|
|
.where(
|
|
CoinTransaction.user_id == user_id,
|
|
CoinTransaction.biz_type == "signin",
|
|
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
|
)
|
|
.order_by(CoinTransaction.created_at.desc())
|
|
.limit(fetch)
|
|
).scalars():
|
|
rows.append({
|
|
"source": "signin",
|
|
"source_label": "签到",
|
|
"created_at": rec.created_at,
|
|
"ecpm": None,
|
|
"coin": rec.amount,
|
|
})
|
|
|
|
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
|
has_more = len(rows) > offset + limit
|
|
|
|
# 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条)
|
|
def _count(model, *conds) -> int:
|
|
return int(db.execute(select(func.count()).select_from(model).where(*conds)).scalar_one())
|
|
|
|
total = (
|
|
_count(
|
|
AdRewardRecord, AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.status == "granted",
|
|
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
|
)
|
|
+ _count(
|
|
AdFeedRewardRecord, AdFeedRewardRecord.user_id == user_id,
|
|
AdFeedRewardRecord.status == "granted",
|
|
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
|
)
|
|
+ _count(
|
|
CoinTransaction, CoinTransaction.user_id == user_id,
|
|
CoinTransaction.biz_type == "signin",
|
|
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
|
)
|
|
)
|
|
return rows[offset:offset + limit], (offset + limit if has_more else None), total
|
|
|
|
|
|
def _attach_price_report_comparison(db: Session, records: list[PriceReport]) -> None:
|
|
"""给每条上报瞬态挂关联比价记录的 trace + 设备/版本快照:
|
|
trace_id/trace_url(点 Trace 看完整比价过程)、device_model/rom_name/android_version(机型OS版本列)、
|
|
app_version(提交版本号列,= 提交时我们 app 的 versionName)。
|
|
comparison_record_id 为空 / 关联记录查不到 → 全 None。"""
|
|
cids = {r.comparison_record_id for r in records if r.comparison_record_id is not None}
|
|
rows = (
|
|
db.execute(
|
|
select(
|
|
ComparisonRecord.id,
|
|
ComparisonRecord.trace_id,
|
|
ComparisonRecord.trace_url,
|
|
ComparisonRecord.device_model,
|
|
ComparisonRecord.rom_name,
|
|
ComparisonRecord.android_version,
|
|
ComparisonRecord.app_version,
|
|
).where(ComparisonRecord.id.in_(cids))
|
|
).all()
|
|
if cids
|
|
else []
|
|
)
|
|
cmap = {row.id: row for row in rows}
|
|
for r in records:
|
|
row = cmap.get(r.comparison_record_id)
|
|
r.trace_id = row.trace_id if row else None
|
|
r.trace_url = row.trace_url if row else None
|
|
r.device_model = row.device_model if row else None
|
|
r.rom_name = row.rom_name if row else None
|
|
r.android_version = row.android_version if row else None
|
|
r.app_version = row.app_version if row else None
|
|
|
|
|
|
def list_price_reports(
|
|
db: Session,
|
|
*,
|
|
status: str | None = None,
|
|
user_id: int | None = None,
|
|
sort_by: str = "id",
|
|
sort_order: str = "desc",
|
|
limit: int = 20,
|
|
cursor: int | None = None,
|
|
) -> tuple[list[PriceReport], int | None, int]:
|
|
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,按 id·提交时间排序。
|
|
join User 取 phone 瞬态挂(列表展示完整手机号);按 comparison_record_id 挂 trace_id/trace_url。"""
|
|
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)
|
|
|
|
sort_cols = {"id": PriceReport.id, "created_at": PriceReport.created_at}
|
|
sort_col = sort_cols.get(sort_by, PriceReport.id)
|
|
order_fn = asc if sort_order == "asc" else desc
|
|
id_order = asc(PriceReport.id) if sort_order == "asc" else desc(PriceReport.id)
|
|
items, next_cursor, total = offset_paginate(
|
|
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
|
)
|
|
_attach_user_info(db, items)
|
|
_attach_price_report_comparison(db, items)
|
|
return items, next_cursor, total
|
|
|
|
|
|
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()),
|
|
}
|