From 9e88ca72d3937052b196407b05fc02c4743e2e01 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 14:04:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=90=E7=8E=B0=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E8=AF=A6=E6=83=85=E7=82=B9=E5=87=BB=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=90=8E=E6=8F=90=E7=A4=BA=E6=93=8D=E4=BD=9C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题现象 在“提现审核”页点击用户所在行后,提现单主详情可以打开,但用户统计和金币记录区域为空,页面连续提示“操作失败”。 ## 原因说明 打开抽屉时前端会继续请求两个子接口: - `/admin/api/users/{user_id}/reward-stats` - `/admin/api/users/{user_id}/coin-records` 这两个接口原来都使用 `select(AdRewardRecord)` 加载完整 ORM 对象。SQLAlchemy 会把模型映射的所有列自动展开到 SQL 中,其中包括后来新增的 `boost_round_id`。当旧本地数据库或滚动发布中的数据库尚未补齐该列时,即使提现详情本身完全不使用这个字段,查询仍会报 `no such column: ad_reward_record.boost_round_id`,两个接口均返回 500。 前端的统一错误处理只会展示响应 JSON 中字符串类型的 `detail`;该 500 返回的是普通 `Internal Server Error`,因此最终回退成通用文案“操作失败”。本地前端开启了 React Strict Mode,初始化副作用在开发环境会执行两次,所以两个失败接口会形成截图中的四条“操作失败”提示。 ## 修复方案 - 用户奖励统计只查询实际需要的 `ecpm_raw`、`coin` 等字段。 - 金币记录只查询页面展示、排序所需字段。 - 同步缩小信息流广告和签到记录的字段投影,避免将来新增无关 ORM 列再次拖垮详情页。 - 增加 SQL 级回归测试:主动拦截任何包含 `ad_reward_record.boost_round_id` 的详情查询,并验证两个接口仍返回 200。 该改动不会改变统计口径或返回结构。数据库迁移仍应正常执行;这里增加的是旧库及滚动发布期间的向后兼容保护。 ## 验证结果 - `pytest tests/test_admin_read.py -q`:17 passed - 新增回归测试覆盖 `reward-stats` 与 `coin-records` - `git diff --check`:通过 - 本地实际提现用户接口验证:两个接口均返回 200 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/171 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/queries.py | 41 ++++++++++++++++++++++--------- tests/test_admin_read.py | 32 +++++++++++++++++++++++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 33b510d..75550e7 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -1171,24 +1171,30 @@ def user_reward_stats( acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口 cash_balance = acc.cash_balance_cents if acc else 0 - rv = list(db.execute( - select(AdRewardRecord).where( + # 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时, + # SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。 + rv = db.execute( + select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where( AdRewardRecord.user_id == user_id, AdRewardRecord.reward_scene == "reward_video", AdRewardRecord.status == "granted", *_window_conds(AdRewardRecord.created_at, date_from, date_to), ) - ).scalars()) + ).all() 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( + feed = db.execute( + select( + AdFeedRewardRecord.unit_count, + AdFeedRewardRecord.ecpm_raw, + AdFeedRewardRecord.coin, + ).where( AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.status == "granted", *_window_conds(AdFeedRewardRecord.created_at, date_from, date_to), ) - ).scalars()) + ).all() 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) @@ -1246,8 +1252,14 @@ def user_coin_records( signin_from = date_from + timedelta(hours=8) if date_from is not None else None signin_to = date_to + timedelta(hours=8) if date_to is not None else None + # 三类来源都只取页面需要的列,避免无关 ORM 新列造成旧库查询失败。 for rec in db.execute( - select(AdRewardRecord) + select( + AdRewardRecord.reward_scene, + AdRewardRecord.created_at, + AdRewardRecord.ecpm_raw, + AdRewardRecord.coin, + ) .where( AdRewardRecord.user_id == user_id, AdRewardRecord.status == "granted", @@ -1255,7 +1267,7 @@ def user_coin_records( ) .order_by(AdRewardRecord.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): is_video = rec.reward_scene == "reward_video" rows.append({ "source": rec.reward_scene, @@ -1266,7 +1278,12 @@ def user_coin_records( }) for rec in db.execute( - select(AdFeedRewardRecord) + select( + AdFeedRewardRecord.feed_scene, + AdFeedRewardRecord.created_at, + AdFeedRewardRecord.ecpm_raw, + AdFeedRewardRecord.coin, + ) .where( AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.status == "granted", @@ -1274,7 +1291,7 @@ def user_coin_records( ) .order_by(AdFeedRewardRecord.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): rows.append({ "source": "feed", "source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"), @@ -1284,7 +1301,7 @@ def user_coin_records( }) for rec in db.execute( - select(CoinTransaction) + select(CoinTransaction.created_at, CoinTransaction.amount) .where( CoinTransaction.user_id == user_id, CoinTransaction.biz_type == "signin", @@ -1292,7 +1309,7 @@ def user_coin_records( ) .order_by(CoinTransaction.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): rows.append({ "source": "signin", "source_label": "签到", diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index a01ca96..06b19aa 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -5,10 +5,11 @@ from datetime import datetime import pytest from fastapi.testclient import TestClient +from sqlalchemy import event from app.admin.main import admin_app from app.admin.repositories import admin_user as admin_repo -from app.db.session import SessionLocal +from app.db.session import SessionLocal, engine from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback from app.models.wallet import CashTransaction, WithdrawOrder @@ -132,6 +133,35 @@ def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> Non assert admin_client.get("/admin/api/users/999999", headers=_auth(admin_token)).status_code == 404 +def test_user_reward_detail_does_not_select_unrelated_new_ad_columns( + admin_client: TestClient, admin_token: str +) -> None: + """旧库缺少无关新列时,提现详情的统计和金币记录仍应可读。""" + uid = _seed_user_with_data("13800000022") + + def reject_full_ad_reward_projection( + _conn, _cursor, statement: str, _parameters, _context, _executemany + ) -> None: + if "ad_reward_record.boost_round_id" in statement: + raise AssertionError("提现详情不应查询未使用的 boost_round_id") + + event.listen(engine, "before_cursor_execute", reject_full_ad_reward_projection) + try: + stats = admin_client.get( + f"/admin/api/users/{uid}/reward-stats", headers=_auth(admin_token) + ) + records = admin_client.get( + f"/admin/api/users/{uid}/coin-records", + params={"limit": 10, "cursor": 0}, + headers=_auth(admin_token), + ) + finally: + event.remove(engine, "before_cursor_execute", reject_full_ad_reward_projection) + + assert stats.status_code == 200, stats.text + assert records.status_code == 200, records.text + + 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))