diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 75550e7..21a3775 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -1228,6 +1228,11 @@ def _cn_wall_to_utc(dt: datetime) -> datetime: return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None) +def _coin_record_sort_key(row: dict) -> datetime: + """金币明细跨数据源排序键:兼容 SQLite naive 与 PostgreSQL aware 时间。""" + return _as_utc(row["created_at"]) + + def user_coin_records( db: Session, user_id: int, @@ -1319,7 +1324,10 @@ def user_coin_records( "coin": rec.amount, }) - rows.sort(key=lambda r: r["created_at"], reverse=True) + # SQLite 常返回 naive datetime,PostgreSQL timestamptz 返回 aware datetime; + # 统一成 aware UTC 排序,避免线上合并广告记录与签到记录时抛 + # “can't compare offset-naive and offset-aware datetimes”。 + rows.sort(key=_coin_record_sort_key, reverse=True) has_more = len(rows) > offset + limit # 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条) diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index fce483a..da4c8c3 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -86,7 +86,7 @@ def get_user_reward_stats( date_to: Annotated[datetime | None, Query()] = None, ) -> UserRewardStats: """提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。""" - if user_repo.get_user_by_id(db, user_id) is None: + if not user_repo.user_exists(db, user_id): raise HTTPException(status_code=404, detail="用户不存在") return UserRewardStats( **queries.user_reward_stats(db, user_id, date_from=date_from, date_to=date_to) diff --git a/app/repositories/user.py b/app/repositories/user.py index 2157cfc..f0a2a0a 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -93,6 +93,11 @@ def get_user_by_id(db: Session, user_id: int) -> User | None: return db.get(User, user_id) +def user_exists(db: Session, user_id: int) -> bool: + """只查主键判断用户是否存在,避免只读统计接口依赖完整用户表结构。""" + return db.scalar(select(User.id).where(User.id == user_id)) is not None + + def get_user_by_phone(db: Session, phone: str) -> User | None: stmt = select(User).where(User.phone == phone) return db.execute(stmt).scalar_one_or_none() diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 06b19aa..dfe720d 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -1,7 +1,7 @@ """Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。""" from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime import pytest from fastapi.testclient import TestClient @@ -9,6 +9,7 @@ from sqlalchemy import event from app.admin.main import admin_app from app.admin.repositories import admin_user as admin_repo +from app.admin.repositories import queries from app.db.session import SessionLocal, engine from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback @@ -144,6 +145,8 @@ def test_user_reward_detail_does_not_select_unrelated_new_ad_columns( ) -> None: if "ad_reward_record.boost_round_id" in statement: raise AssertionError("提现详情不应查询未使用的 boost_round_id") + if "FROM user" in statement and "user.phone" in statement: + raise AssertionError("奖励统计的用户存在性检查不应展开完整 user 表") event.listen(engine, "before_cursor_execute", reject_full_ad_reward_projection) try: @@ -162,6 +165,17 @@ def test_user_reward_detail_does_not_select_unrelated_new_ad_columns( assert records.status_code == 200, records.text +def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None: + """线上 PostgreSQL 返回 aware,SQLite/历史转换可能返回 naive,二者必须可混排。""" + naive = datetime(2038, 1, 1, 8, 0) + aware = datetime(2038, 1, 1, 7, 0, tzinfo=UTC) + + rows = [{"created_at": aware}, {"created_at": naive}] + rows.sort(key=queries._coin_record_sort_key, reverse=True) + + assert rows == [{"created_at": naive}, {"created_at": aware}] + + def test_user_filter_by_status(admin_client: TestClient, admin_token: str) -> None: _seed_user_with_data("13800000003") r = admin_client.get("/admin/api/users", params={"status": "active"}, headers=_auth(admin_token))