feat(admin): 运营后台跟进——review 加固 + 反馈改版 + 列表页码分页 (#51)
本 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>
This commit was merged in pull request #51.
This commit is contained in:
@@ -50,7 +50,7 @@ def _reward_video_rows(
|
||||
AdRewardRecord.reward_date == date,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
)
|
||||
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at)
|
||||
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at, AdRewardRecord.id)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
||||
@@ -127,7 +127,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
stmt = (
|
||||
select(AdFeedRewardRecord)
|
||||
.where(AdFeedRewardRecord.reward_date == date)
|
||||
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at)
|
||||
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||
@@ -205,7 +205,7 @@ def ad_coin_audit(
|
||||
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
||||
if scene in (None, "feed"):
|
||||
rows.extend(_feed_rows(db, date=date, user_id=user_id))
|
||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
|
||||
|
||||
total = len(rows)
|
||||
mismatch_count = sum(1 for r in rows if not r["matched"])
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.admin import AdminAuditLog
|
||||
@@ -49,8 +49,9 @@ def list_audit_logs(
|
||||
admin_id: int | None = None,
|
||||
limit: int = 50,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[AdminAuditLog], int | None]:
|
||||
"""游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。"""
|
||||
) -> tuple[list[AdminAuditLog], int | None, int]:
|
||||
"""offset 分页(id 倒序)+ total。cursor 即 offset((page-1)*pageSize),支持页码跳页。
|
||||
返回 (rows, next_cursor, total)。"""
|
||||
stmt = select(AdminAuditLog)
|
||||
if action:
|
||||
stmt = stmt.where(AdminAuditLog.action == action)
|
||||
@@ -58,13 +59,15 @@ def list_audit_logs(
|
||||
stmt = stmt.where(AdminAuditLog.target_type == target_type)
|
||||
if admin_id is not None:
|
||||
stmt = stmt.where(AdminAuditLog.admin_id == admin_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(AdminAuditLog.id < cursor)
|
||||
stmt = stmt.order_by(AdminAuditLog.id.desc())
|
||||
rows = list(db.execute(stmt.limit(limit + 1)).scalars().all())
|
||||
|
||||
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(AdminAuditLog.id.desc()).offset(offset).limit(limit + 1)
|
||||
).scalars().all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
# next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]——
|
||||
# rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。
|
||||
next_cursor = items[-1].id if has_more else None
|
||||
return items, next_cursor
|
||||
next_cursor = offset + limit if has_more else None
|
||||
return items, next_cursor, total
|
||||
|
||||
@@ -38,6 +38,28 @@ def cursor_paginate(
|
||||
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,
|
||||
*,
|
||||
@@ -53,11 +75,11 @@ def list_users(
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[User], int | None]:
|
||||
) -> tuple[list[User], int | None, int]:
|
||||
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
|
||||
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
||||
日期入参统一转 UTC naive 比较(User 时间均为 UTC naive,见 _as_utc_naive)。"""
|
||||
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
||||
stmt = select(User)
|
||||
if phone:
|
||||
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
|
||||
@@ -68,13 +90,13 @@ def list_users(
|
||||
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_naive(created_from))
|
||||
stmt = stmt.where(User.created_at >= _as_utc(created_from))
|
||||
if created_to is not None:
|
||||
stmt = stmt.where(User.created_at <= _as_utc_naive(created_to))
|
||||
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_naive(last_login_from))
|
||||
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_naive(last_login_to))
|
||||
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
|
||||
|
||||
sort_cols = {
|
||||
"id": User.id,
|
||||
@@ -84,14 +106,7 @@ def list_users(
|
||||
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)
|
||||
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
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
|
||||
@@ -160,7 +175,7 @@ def list_all_withdraw_orders(
|
||||
quick_filter: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[WithdrawOrder], int | 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:
|
||||
@@ -190,16 +205,16 @@ def list_all_withdraw_orders(
|
||||
|
||||
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))
|
||||
stmt = stmt.where(date_col >= _as_utc(date_from))
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(date_col <= _as_utc_naive(date_to))
|
||||
stmt = stmt.where(date_col <= _as_utc(date_to))
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
# 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)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
if quick_filter == "abnormal":
|
||||
stmt = stmt.where(
|
||||
@@ -244,6 +259,53 @@ def list_all_withdraw_orders(
|
||||
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 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]:
|
||||
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
|
||||
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
|
||||
数据变动可能错位一条——admin 低频场景可接受。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)
|
||||
stmt = stmt.order_by(order_fn(sort_col), id_order)
|
||||
|
||||
offset = max(cursor or 0, 0)
|
||||
@@ -254,29 +316,6 @@ def list_all_withdraw_orders(
|
||||
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(
|
||||
@@ -463,14 +502,14 @@ def list_price_reports(
|
||||
user_id: int | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[PriceReport], int | None]:
|
||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。"""
|
||||
) -> tuple[list[PriceReport], int | None, int]:
|
||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,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)
|
||||
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def price_report_summary(db: Session) -> dict:
|
||||
|
||||
@@ -17,6 +17,12 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _active_super_count(db: AdminDb) -> int:
|
||||
return sum(
|
||||
1 for a in admin_repo.list_admins(db) if a.role == "super_admin" and a.status == "active"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表")
|
||||
def list_admins(db: AdminDb) -> list[AdminOut]:
|
||||
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
|
||||
@@ -48,16 +54,29 @@ def update_admin(
|
||||
if admin_id == admin.id and body.status == "disabled":
|
||||
raise HTTPException(status_code=400, detail="不能禁用自己")
|
||||
|
||||
# 防自锁:降级 / 禁用某个 super_admin 前,确认操作后仍至少剩 1 个 active super_admin,
|
||||
# 否则会进入「零可用超管」死局——本路由仅 super 可进,只能改库恢复。
|
||||
demotes_super = (
|
||||
target.role == "super_admin"
|
||||
and target.status == "active"
|
||||
and (
|
||||
(body.role is not None and body.role != "super_admin")
|
||||
or body.status == "disabled"
|
||||
)
|
||||
)
|
||||
if demotes_super and _active_super_count(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
|
||||
|
||||
changes: dict = {}
|
||||
if body.role is not None:
|
||||
if body.role is not None and body.role != target.role:
|
||||
changes["role"] = {"before": target.role, "after": body.role}
|
||||
target.role = body.role
|
||||
changes["role"] = body.role
|
||||
if body.status is not None:
|
||||
if body.status is not None and body.status != target.status:
|
||||
changes["status"] = {"before": target.status, "after": body.status}
|
||||
target.status = body.status
|
||||
changes["status"] = body.status
|
||||
if body.password is not None:
|
||||
target.password_hash = hash_password(body.password)
|
||||
changes["password"] = "reset"
|
||||
target.password_hash = hash_password(body.password)
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
db.commit()
|
||||
|
||||
@@ -26,9 +26,11 @@ def list_audit_logs(
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminAuditLogOut]:
|
||||
items, next_cursor = audit_repo.list_audit_logs(
|
||||
items, next_cursor, total = audit_repo.list_audit_logs(
|
||||
db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor,
|
||||
items=[AdminAuditLogOut.model_validate(x) for x in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。"""
|
||||
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
@@ -25,11 +26,25 @@ def list_feedbacks(
|
||||
db: AdminDb,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
content: Annotated[str | None, Query(max_length=100)] = None,
|
||||
created_from: Annotated[datetime | None, Query()] = None,
|
||||
created_to: Annotated[datetime | None, Query()] = None,
|
||||
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[FeedbackOut]:
|
||||
items, next_cursor = queries.list_feedbacks(
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
db,
|
||||
status=status,
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
created_from=created_from,
|
||||
created_to=created_to,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor,
|
||||
|
||||
@@ -40,11 +40,13 @@ def list_price_reports(
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[PriceReportOut]:
|
||||
items, next_cursor = queries.list_price_reports(
|
||||
items, next_cursor, total = queries.list_price_reports(
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor,
|
||||
items=[PriceReportOut.model_validate(r) for r in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +62,9 @@ def approve_price_report(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
rep = db.get(PriceReport, report_id)
|
||||
# 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
|
||||
# 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
@@ -88,7 +92,7 @@ def reject_price_report(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
rep = db.get(PriceReport, report_id)
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
|
||||
@@ -45,7 +45,7 @@ def list_users(
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminUserListItem]:
|
||||
items, next_cursor = queries.list_users(
|
||||
items, next_cursor, total = queries.list_users(
|
||||
db, phone=phone, register_channel=register_channel, status=status,
|
||||
nickname=nickname, created_from=created_from, created_to=created_to,
|
||||
last_login_from=last_login_from, last_login_to=last_login_to,
|
||||
@@ -54,6 +54,7 @@ def list_users(
|
||||
return CursorPage(
|
||||
items=[AdminUserListItem.model_validate(u) for u in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@@ -126,7 +127,8 @@ def grant_user_coins(
|
||||
if body.mode == "set":
|
||||
if body.amount < 0:
|
||||
raise HTTPException(status_code=400, detail="目标金币值不能为负")
|
||||
before = wallet_repo.get_or_create_account(db, user_id, commit=False).coin_balance
|
||||
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
|
||||
before = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True).coin_balance
|
||||
delta = body.amount - before
|
||||
if delta == 0:
|
||||
raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整")
|
||||
@@ -134,9 +136,9 @@ def grant_user_coins(
|
||||
if body.amount == 0:
|
||||
raise HTTPException(status_code=400, detail="amount 不能为 0")
|
||||
delta = body.amount
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护)
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
|
||||
if delta < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False)
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
if acc_now.coin_balance + delta < 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
|
||||
@@ -174,7 +176,10 @@ def grant_user_cash(
|
||||
if body.mode == "set":
|
||||
if body.amount_cents < 0:
|
||||
raise HTTPException(status_code=400, detail="目标现金值不能为负")
|
||||
before = wallet_repo.get_or_create_account(db, user_id, commit=False).cash_balance_cents
|
||||
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
|
||||
before = wallet_repo.get_or_create_account(
|
||||
db, user_id, commit=False, lock=True
|
||||
).cash_balance_cents
|
||||
delta = body.amount_cents - before
|
||||
if delta == 0:
|
||||
raise HTTPException(
|
||||
@@ -184,9 +189,9 @@ def grant_user_cash(
|
||||
if body.amount_cents == 0:
|
||||
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
|
||||
delta = body.amount_cents
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护)
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
|
||||
if delta < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False)
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
if acc_now.cash_balance_cents + delta < 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
|
||||
|
||||
@@ -63,7 +63,7 @@ def list_withdraws(
|
||||
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(
|
||||
items, next_cursor, total = queries.list_all_withdraw_orders(
|
||||
db,
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
@@ -78,7 +78,9 @@ def list_withdraws(
|
||||
cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor,
|
||||
items=[WithdrawOrderOut.model_validate(o) for o in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@@ -87,7 +89,12 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
|
||||
return WithdrawSummaryOut(**queries.withdraw_summary(db))
|
||||
|
||||
|
||||
@router.get("/health-check", response_model=WxpayHealthCheckOut, summary="提现配置健康检查")
|
||||
@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
|
||||
@@ -160,7 +167,7 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
||||
withdraw_success_cents=overview["withdraw_success_cents"],
|
||||
)
|
||||
|
||||
recent_withdraws, _ = queries.list_all_withdraw_orders(
|
||||
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(
|
||||
|
||||
@@ -9,10 +9,15 @@ T = TypeVar("T")
|
||||
|
||||
|
||||
class CursorPage(BaseModel, Generic[T]):
|
||||
"""游标分页响应:items + 下一页游标(next_cursor=None 表示末页)。"""
|
||||
"""分页响应:items + 下一页游标(next_cursor=None 表示末页)+ 可选 total。
|
||||
|
||||
next_cursor:offset 分页时即下一页 offset,「加载更多」用;末页为 None。
|
||||
total:符合筛选条件的总条数,页码分页(antd pagination)用;不需要总数的接口可不传(None)。
|
||||
"""
|
||||
|
||||
items: list[T]
|
||||
next_cursor: int | None = None
|
||||
total: int | None = None
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class PriceReportOut(BaseModel):
|
||||
@@ -36,6 +36,14 @@ class PriceReportOut(BaseModel):
|
||||
class PriceReportRejectRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def _reason_not_blank(cls, v: str) -> str:
|
||||
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计/用户端记录到空理由
|
||||
if not v.strip():
|
||||
raise ValueError("拒绝理由不能为空")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class PriceReportSummary(BaseModel):
|
||||
"""审核台顶部各状态计数。"""
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
@@ -37,6 +37,13 @@ class AdminUserOverview(BaseModel):
|
||||
feedback_total: int
|
||||
|
||||
|
||||
def _strip_reason(v: str) -> str:
|
||||
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
|
||||
if not v.strip():
|
||||
raise ValueError("操作原因不能为空")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class GrantCoinsRequest(BaseModel):
|
||||
mode: Literal["delta", "set"] = Field(
|
||||
"delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)"
|
||||
@@ -47,6 +54,8 @@ class GrantCoinsRequest(BaseModel):
|
||||
)
|
||||
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
|
||||
|
||||
_v_reason = field_validator("reason")(_strip_reason)
|
||||
|
||||
|
||||
class GrantCashRequest(BaseModel):
|
||||
mode: Literal["delta", "set"] = Field(
|
||||
@@ -58,6 +67,8 @@ class GrantCashRequest(BaseModel):
|
||||
)
|
||||
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
|
||||
|
||||
_v_reason = field_validator("reason")(_strip_reason)
|
||||
|
||||
|
||||
class SetUserStatusRequest(BaseModel):
|
||||
status: Literal["active", "disabled"] = Field(
|
||||
|
||||
Reference in New Issue
Block a user