diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 57fcd19..e4441d3 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -84,12 +84,14 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: @router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度") def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: - used, limit, coin_per = crud_ad.today_status(db, user.id) + used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id) return AdRewardStatusOut( used_today=used, daily_limit=limit, remaining=max(0, limit - used), coin_per_ad=coin_per, + round_count=round_count, + cooldown_until=cooldown_until, ) @@ -118,7 +120,7 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut: except crud_ad.UnknownUserError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e - used, limit, coin_per = crud_ad.today_status(db, user.id) + used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id) logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin) return TestGrantOut( granted=(rec.status == "granted"), @@ -128,4 +130,6 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut: daily_limit=limit, remaining=max(0, limit - used), coin_per_ad=coin_per, + round_count=round_count, + cooldown_until=cooldown_until, ) diff --git a/app/core/rewards.py b/app/core/rewards.py index ff98e65..4b2b6b2 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -59,16 +59,22 @@ TASK_REWARDS: dict[str, int] = { # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== -# 看完一个激励视频发的金币(≈¥0.01,汇率 10000 金币=1 元)。 -# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"。 +# 看完一个激励视频发的金币(100 金币 ≈¥0.01,汇率 10000 金币=1 元)。 +# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"; +# ③ test-grant 本地联调的发奖额。 # 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把 -# 代码位"奖励数量"配成与本值一致(如 100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。 +# 代码位"奖励数量"配成与本值一致(=100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。 AD_REWARD_COIN: int = 100 # 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。 MAX_AD_REWARD_COIN: int = 1000 # 每用户每日发奖次数上限,防刷 + 控成本。 -# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。 -DAILY_AD_REWARD_LIMIT: int = 10 +# ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。 +DAILY_AD_REWARD_LIMIT: int = 20 +# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端, +# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit, +# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。 +VIDEO_ROUND_REQUIRED_COUNT: int = 3 +VIDEO_ROUND_COOLDOWN_SECONDS: int = 600 def resolve_ad_reward_coin(reward_amount: str | int | None) -> int: diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index bb7d008..a901443 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -10,11 +10,19 @@ """ from __future__ import annotations +from datetime import datetime, timedelta, timezone + from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, cn_today +from app.core.rewards import ( + AD_REWARD_COIN, + DAILY_AD_REWARD_LIMIT, + VIDEO_ROUND_COOLDOWN_SECONDS, + VIDEO_ROUND_REQUIRED_COUNT, + cn_today, +) from app.repositories import wallet as crud_wallet from app.models.ad_reward import AdRewardRecord from app.models.user import User @@ -102,7 +110,49 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR return rec -def today_status(db: Session, user_id: int) -> tuple[int, int, int]: - """客户端查"今日看广告发奖"进度:返回 (今日已发次数, 每日上限, 单次金币)。""" - used = _granted_today(db, user_id, cn_today().isoformat()) - return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN +def _last_completed_round_end_at( + db: Session, user_id: int, reward_date: str, round_count: int +) -> datetime | None: + """当日 granted 记录中**最近一个已完成轮**末尾那次的 created_at。 + + 思路:把当日 granted 按时间倒序排,跳过当前未完成轮的 round_count 条,下一条 + 即"上一轮最后一次"。round_count==0 且 used>=N 时跳 0 条直接取最近一条。 + used tuple[int, int, int, int, datetime | None]: + """客户端查"今日看广告发奖"进度。 + + 返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。 + - round_count = used % VIDEO_ROUND_REQUIRED_COUNT,展示用(0..N-1) + - cooldown_until 计算:取最近一个已完成轮末尾的 created_at + 10 min;若仍 > now 则返回, + 否则返回 None。冷却 = UX 约束(客户端 CTA 倒计时不可点),后端发奖逻辑不受影响。 + """ + today = cn_today().isoformat() + used = _granted_today(db, user_id, today) + round_count = used % VIDEO_ROUND_REQUIRED_COUNT + cooldown_until: datetime | None = None + if used >= VIDEO_ROUND_REQUIRED_COUNT: + last_end = _last_completed_round_end_at(db, user_id, today, round_count) + if last_end is not None: + # SQLAlchemy 在 SQLite 上拿到的 created_at 可能是 naive,统一按 UTC 解读再比较 + if last_end.tzinfo is None: + last_end = last_end.replace(tzinfo=timezone.utc) + cd_end = last_end + timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS) + if cd_end > datetime.now(timezone.utc): + cooldown_until = cd_end + return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN, round_count, cooldown_until diff --git a/app/schemas/ad.py b/app/schemas/ad.py index 83d960b..47635e9 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -4,6 +4,8 @@ """ from __future__ import annotations +from datetime import datetime + from pydantic import BaseModel, Field @@ -27,6 +29,14 @@ class AdRewardStatusOut(BaseModel): daily_limit: int = Field(..., description="每日发奖次数上限") remaining: int = Field(..., description="今日剩余可领次数") coin_per_ad: int = Field(..., description="看完一个激励视频发的金币") + round_count: int = Field( + ..., description="本轮(3 次一组)已看次数,0..N-1。客户端可由它判断'刚刚看完一轮'" + ) + cooldown_until: datetime | None = Field( + None, + description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。" + "冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点", + ) class TestGrantOut(BaseModel): @@ -39,3 +49,7 @@ class TestGrantOut(BaseModel): daily_limit: int = Field(..., description="每日发奖次数上限") remaining: int = Field(..., description="今日剩余可领次数") coin_per_ad: int = Field(..., description="看完一个激励视频发的金币") + round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut") + cooldown_until: datetime | None = Field( + None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut" + ) diff --git a/docs/api/ad-reward-status.md b/docs/api/ad-reward-status.md index 7cd7e2d..5486edc 100644 --- a/docs/api/ad-reward-status.md +++ b/docs/api/ad-reward-status.md @@ -14,6 +14,15 @@ | `daily_limit` | int | 每日发奖次数上限 | | `remaining` | int | 今日剩余可领次数 | | `coin_per_ad` | int | 看完一个激励视频发的金币 | +| `round_count` | int | 本轮(3 次一组)已看次数,0..N-1。客户端可据「拿到 round_count==0 && cooldown_until!=null」判定刚刚看完一轮 | +| `cooldown_until` | datetime\|null | 本轮 10 分钟冷却结束时间(UTC ISO);null 表示不在冷却 | ## 说明 -福利页「看视频赚金币」用:展示「今日还能看 N 次」「看一次得 M 金币」。真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。 +福利页「看视频赚金币」用: +- 展示「今日还能看 N 次」「看一次得 M 金币」(`remaining`/`coin_per_ad`) +- 任务行 CTA 4 态推导:`adLoading` → Loading;`remaining==0` → Capped("明天再来");`cooldown_until` 在未来 → CoolingDown(显示 MM:SS 倒计时,不可点);否则 Normal("去赚取") +- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`round_count==0 && cooldown_until!=null` 显示「本轮视频已看完,10分钟后再来」 + +冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(每 N=3 条算一轮)末尾那次的 `created_at`,加 10 分钟。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。 + +真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。 diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index ff1d00c..516df34 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -9,8 +9,15 @@ from sqlalchemy import select from app.core.config import settings from app.integrations import pangle -from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, MAX_AD_REWARD_COIN +from app.core.rewards import ( + AD_REWARD_COIN, + DAILY_AD_REWARD_LIMIT, + MAX_AD_REWARD_COIN, + VIDEO_ROUND_COOLDOWN_SECONDS, + VIDEO_ROUND_REQUIRED_COUNT, +) from app.db.session import SessionLocal +from app.models.ad_reward import AdRewardRecord from app.models.user import User @@ -148,6 +155,81 @@ def test_reward_status_requires_auth(client) -> None: assert r.status_code == 401 +def test_reward_status_initial_round_state(client) -> None: + """新用户没看广告 → round_count=0, cooldown_until=None。""" + phone = "13800003101" + token = _login(client, phone) + st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() + assert st["used_today"] == 0 + assert st["round_count"] == 0 + assert st["cooldown_until"] is None + + +def test_reward_status_mid_round(client) -> None: + """看 1 次(未到一轮)→ round_count=1, cooldown_until 仍 None。""" + phone = "13800003102" + token = _login(client, phone) + uid = _user_id(phone) + _callback(client, _signed(uid, "trans_mid_1")) + st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() + assert st["used_today"] == 1 + assert st["round_count"] == 1 + assert st["cooldown_until"] is None + + +def test_reward_status_round_complete_enters_cooldown(client) -> None: + """刚看完一轮 N 次 → round_count=0(下一轮起点) + cooldown_until 是未来 ~10 分钟。""" + from datetime import datetime, timezone + + phone = "13800003103" + token = _login(client, phone) + uid = _user_id(phone) + for i in range(VIDEO_ROUND_REQUIRED_COUNT): + _callback(client, _signed(uid, f"trans_rc_{i}")) + + st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() + assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT + assert st["round_count"] == 0 + assert st["cooldown_until"] is not None + cd = datetime.fromisoformat(st["cooldown_until"].replace("Z", "+00:00")) + if cd.tzinfo is None: + cd = cd.replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + delta = (cd - now).total_seconds() + # 配置 600s,允许 ±30s 容差(本机 / CI 慢 IO) + assert 0 < delta <= VIDEO_ROUND_COOLDOWN_SECONDS + 30 + + +def test_reward_status_cooldown_expired(client) -> None: + """看完一轮后冷却已过(手动改 created_at 到 11 分钟前)→ cooldown_until 回到 None。""" + from datetime import datetime, timedelta, timezone + from sqlalchemy import select, update + + phone = "13800003104" + token = _login(client, phone) + uid = _user_id(phone) + for i in range(VIDEO_ROUND_REQUIRED_COUNT): + _callback(client, _signed(uid, f"trans_exp_{i}")) + + # 把当日所有 granted 记录的 created_at 推到 11 分钟前(覆盖冷却末尾那条) + db = SessionLocal() + try: + eleven_min_ago = datetime.now(timezone.utc) - timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS + 60) + db.execute( + update(AdRewardRecord) + .where(AdRewardRecord.user_id == uid) + .values(created_at=eleven_min_ago) + ) + db.commit() + finally: + db.close() + + st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() + assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT + assert st["round_count"] == 0 + assert st["cooldown_until"] is None + + def test_callback_disabled_returns_503(client, monkeypatch) -> None: """未配置回调(开关关)时 → 503。""" monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)