diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 3b9cbaf..eea9635 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -27,6 +27,7 @@ from app.repositories import ad_watch as crud_watch from app.repositories import app_config from app.repositories import signin as crud_signin from app.schemas.ad import ( + AdRewardResultOut, AdRewardStatusOut, EcpmReportIn, EcpmReportOut, @@ -242,6 +243,24 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: ) +@router.get( + "/reward-result/{ad_session_id}", + response_model=AdRewardResultOut, + summary="按广告会话查本次服务端权威发奖结果(弹窗展示实发金币用)", +) +def reward_result(ad_session_id: str, user: CurrentUser, db: DbSession) -> AdRewardResultOut: + """激励视频关闭后,客户端按本次 `ad_session_id` 查服务端到底发了多少金币,弹窗只展示这个权威数字。 + + 只读、只查本人本会话的 reward_video 记录:没有记录=pending(S2S 尚未到账,客户端继续有限重试, + 别把"未到账"当 0);granted 返回真实 coin;capped/closed_early/ecpm_missing 返回终态(coin=0, + 不伪装成 granted)。不返回 eCPM / 回调原文 / 他人信息。 + """ + rec = crud_ad.find_reward_video_result(db, user.id, ad_session_id) + if rec is None: + return AdRewardResultOut(ad_session_id=ad_session_id, status="pending", coin=None) + return AdRewardResultOut(ad_session_id=ad_session_id, status=rec.status, coin=rec.coin) + + @router.post( "/watch-report", response_model=WatchReportOut, diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index f9fb16f..61e1b3f 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -41,6 +41,27 @@ def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None: return _find_by_trans(db, trans_id) +def find_reward_video_result( + db: Session, user_id: int, ad_session_id: str +) -> AdRewardRecord | None: + """按 (user_id, ad_session_id, reward_scene='reward_video') 查**最新一条**发奖记录, + 供客户端弹窗查这次广告的权威发奖结果。 + + 只读、按 user_id 隔离(他人查不到)。取最新:同一会话可能先有 closed_early 留痕、S2S 延迟 + 到账后又写 granted,按 (created_at, id) 倒序让 granted 覆盖早先的 closed_early。查不到返回 None。 + """ + return db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + AdRewardRecord.reward_scene == "reward_video", + ) + .order_by(AdRewardRecord.created_at.desc(), AdRewardRecord.id.desc()) + .limit(1) + ).scalar_one_or_none() + + def _granted_today(db: Session, user_id: int, reward_date: str) -> int: return db.execute( select(func.count()) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index ceb74af..267f230 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime +from typing import Literal from pydantic import BaseModel, Field @@ -43,6 +44,23 @@ class AdRewardStatusOut(BaseModel): watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截") +class AdRewardResultOut(BaseModel): + """客户端弹窗按 `ad_session_id` 查这次广告服务端**权威发奖结果**,只展示这个数字。 + + - `pending`:本人没有该会话的 reward_video 记录(S2S 尚未到账)——客户端继续有限重试, + **别把"未到账"当成 0 金币**,`coin=null`。 + - `granted`:返回真实到账金币 `coin`(>0)。 + - `capped`:达每日上限,`coin=0`,客户端走上限提示而非"获得 0 金币"。 + - `closed_early` / `ecpm_missing`:终态,`coin=0`,**不能伪装成 granted**。 + + 不返回 eCPM、回调原文或其他用户信息。 + """ + + ad_session_id: str + status: Literal["pending", "granted", "capped", "closed_early", "ecpm_missing"] + coin: int | None = None + + class EcpmReportIn(BaseModel): """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index ebc0c98..ffc3a59 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -356,3 +356,117 @@ def test_callback_disabled_returns_503(client, monkeypatch) -> None: # 503 发生在验签/发奖之前,不需要真实用户 r = _callback(client, _signed(1, "trans_disabled")) assert r.status_code == 503, r.text + + +# ===== 按广告会话查权威发奖结果(弹窗展示实发金币用)===== + + +def _reward_result(client, token: str, ad_session_id: str): + return client.get(f"/api/v1/ad/reward-result/{ad_session_id}", headers=_auth(token)) + + +def test_reward_result_requires_auth(client) -> None: + """按会话查发奖结果需登录。""" + assert client.get("/api/v1/ad/reward-result/sess_noauth_001").status_code == 401 + + +def test_reward_result_pending_when_no_record(client) -> None: + """本人没有这次会话的发奖记录 → pending、coin=null(不能把"尚未到账"当成 0 金币)。""" + phone = "13800003601" + token = _login(client, phone) + r = _reward_result(client, token, "sess_pending_never") + assert r.status_code == 200, r.text + body = r.json() + assert body["ad_session_id"] == "sess_pending_never" + assert body["status"] == "pending" + assert body["coin"] is None + + +def test_reward_result_granted_returns_real_coin(client) -> None: + """本人存在 granted 记录 → 返回真实到账金币(与发奖公式一致),status=granted。""" + phone = "13800003602" + token = _login(client, phone) + uid = _user_id(phone) + session = "sess_granted_0001" + r = _callback(client, _signed(uid, "trans_rr_grant", ecpm="200", + extra=json.dumps({"ad_session_id": session}))) + assert r.status_code == 200, r.text + expected = calculate_ad_reward_coin("200", 1) + assert expected > 0 + assert _coin_balance(client, token) == expected + + body = _reward_result(client, token, session).json() + assert body["status"] == "granted" + assert body["coin"] == expected + + +def test_reward_result_capped_returns_zero(client, monkeypatch) -> None: + """达每日上限记 capped → status=capped、coin=0(客户端走上限提示,不显示"获得 0 金币")。""" + monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 0) + phone = "13800003603" + token = _login(client, phone) + uid = _user_id(phone) + session = "sess_capped_0001" + before = _coin_balance(client, token) + r = _callback(client, _signed(uid, "trans_rr_capped", ecpm="200", + extra=json.dumps({"ad_session_id": session}))) + assert r.status_code == 200, r.text + assert _coin_balance(client, token) == before # capped 未加币 + + body = _reward_result(client, token, session).json() + assert body["status"] == "capped" + assert body["coin"] == 0 + + +def test_reward_result_closed_early_not_granted(client) -> None: + """提前关闭留痕(closed_early)不是成功奖励 → 返回终态、coin=0,不能伪装成 granted。""" + phone = "13800003604" + token = _login(client, phone) + session = "sess_closed_early_0001" + r = client.post("/api/v1/ad/reward-noshow", + json={"ad_session_id": session, "watched_seconds": 2}, + headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["status"] == "closed_early" + + body = _reward_result(client, token, session).json() + assert body["status"] == "closed_early" + assert body["status"] != "granted" + assert body["coin"] == 0 + + +def test_reward_result_isolated_per_user(client) -> None: + """他人用同一会话 id 查 → 读不到,仍 pending(会话结果按 user_id 隔离)。""" + owner_phone = "13800003605" + owner_token = _login(client, owner_phone) + owner_uid = _user_id(owner_phone) + session = "sess_shared_0001" + _callback(client, _signed(owner_uid, "trans_rr_shared", ecpm="200", + extra=json.dumps({"ad_session_id": session}))) + assert _reward_result(client, owner_token, session).json()["status"] == "granted" + + other_token = _login(client, "13800003606") + body = _reward_result(client, other_token, session).json() + assert body["status"] == "pending" + assert body["coin"] is None + + +def test_reward_result_granted_supersedes_earlier_closed_early(client) -> None: + """同一会话先留痕 closed_early、S2S 延迟到账后又 granted → 查最新返回 granted 与真实金币 + (弹窗最终展示真实到账,不被早先的 closed_early 顶掉;这正是 S2S 延迟场景)。""" + phone = "13800003607" + token = _login(client, phone) + uid = _user_id(phone) + session = "sess_late_grant_0001" + # 1) 客户端以为提前关闭,先留痕 closed_early + assert client.post("/api/v1/ad/reward-noshow", + json={"ad_session_id": session}, + headers=_auth(token)).json()["status"] == "closed_early" + # 2) S2S 延迟到账,写入 granted(不同 trans_id) + r = _callback(client, _signed(uid, "trans_late_grant", ecpm="200", + extra=json.dumps({"ad_session_id": session}))) + assert r.status_code == 200, r.text + expected = calculate_ad_reward_coin("200", 1) + body = _reward_result(client, token, session).json() + assert body["status"] == "granted" + assert body["coin"] == expected