Merge pull request 'feat(ad-reward): reward-status 加本轮 3 次冷却字段 (round_count + cooldown_until)' (#5) from feat/welfare-video-round-cooldown into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
+6
-2
@@ -84,12 +84,14 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
|||||||
|
|
||||||
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
||||||
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
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(
|
return AdRewardStatusOut(
|
||||||
used_today=used,
|
used_today=used,
|
||||||
daily_limit=limit,
|
daily_limit=limit,
|
||||||
remaining=max(0, limit - used),
|
remaining=max(0, limit - used),
|
||||||
coin_per_ad=coin_per,
|
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:
|
except crud_ad.UnknownUserError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from 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)
|
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
|
||||||
return TestGrantOut(
|
return TestGrantOut(
|
||||||
granted=(rec.status == "granted"),
|
granted=(rec.status == "granted"),
|
||||||
@@ -128,4 +130,6 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
|||||||
daily_limit=limit,
|
daily_limit=limit,
|
||||||
remaining=max(0, limit - used),
|
remaining=max(0, limit - used),
|
||||||
coin_per_ad=coin_per,
|
coin_per_ad=coin_per,
|
||||||
|
round_count=round_count,
|
||||||
|
cooldown_until=cooldown_until,
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-5
@@ -59,16 +59,22 @@ TASK_REWARDS: dict[str, int] = {
|
|||||||
|
|
||||||
|
|
||||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||||
# 看完一个激励视频发的金币(≈¥0.01,汇率 10000 金币=1 元)。
|
# 看完一个激励视频发的金币(100 金币 ≈¥0.01,汇率 10000 金币=1 元)。
|
||||||
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"。
|
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
|
||||||
|
# ③ test-grant 本地联调的发奖额。
|
||||||
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
|
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
|
||||||
# 代码位"奖励数量"配成与本值一致(如 100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
|
# 代码位"奖励数量"配成与本值一致(=100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
|
||||||
AD_REWARD_COIN: int = 100
|
AD_REWARD_COIN: int = 100
|
||||||
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
||||||
MAX_AD_REWARD_COIN: int = 1000
|
MAX_AD_REWARD_COIN: int = 1000
|
||||||
# 每用户每日发奖次数上限,防刷 + 控成本。
|
# 每用户每日发奖次数上限,防刷 + 控成本。
|
||||||
# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
|
# ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
|
||||||
DAILY_AD_REWARD_LIMIT: int = 10
|
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:
|
def resolve_ad_reward_coin(reward_amount: str | int | None) -> int:
|
||||||
|
|||||||
@@ -10,11 +10,19 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
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.repositories import wallet as crud_wallet
|
||||||
from app.models.ad_reward import AdRewardRecord
|
from app.models.ad_reward import AdRewardRecord
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -102,7 +110,49 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR
|
|||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
def today_status(db: Session, user_id: int) -> tuple[int, int, int]:
|
def _last_completed_round_end_at(
|
||||||
"""客户端查"今日看广告发奖"进度:返回 (今日已发次数, 每日上限, 单次金币)。"""
|
db: Session, user_id: int, reward_date: str, round_count: int
|
||||||
used = _granted_today(db, user_id, cn_today().isoformat())
|
) -> datetime | None:
|
||||||
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN
|
"""当日 granted 记录中**最近一个已完成轮**末尾那次的 created_at。
|
||||||
|
|
||||||
|
思路:把当日 granted 按时间倒序排,跳过当前未完成轮的 round_count 条,下一条
|
||||||
|
即"上一轮最后一次"。round_count==0 且 used>=N 时跳 0 条直接取最近一条。
|
||||||
|
used<N(还没完成第一轮)调用方应直接判 None,不进这里。
|
||||||
|
"""
|
||||||
|
return db.execute(
|
||||||
|
select(AdRewardRecord.created_at)
|
||||||
|
.where(
|
||||||
|
AdRewardRecord.user_id == user_id,
|
||||||
|
AdRewardRecord.reward_date == reward_date,
|
||||||
|
AdRewardRecord.status == "granted",
|
||||||
|
)
|
||||||
|
.order_by(AdRewardRecord.created_at.desc())
|
||||||
|
.offset(round_count)
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def today_status(
|
||||||
|
db: Session, user_id: int
|
||||||
|
) -> 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
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +29,14 @@ class AdRewardStatusOut(BaseModel):
|
|||||||
daily_limit: int = Field(..., description="每日发奖次数上限")
|
daily_limit: int = Field(..., description="每日发奖次数上限")
|
||||||
remaining: int = Field(..., description="今日剩余可领次数")
|
remaining: int = Field(..., description="今日剩余可领次数")
|
||||||
coin_per_ad: 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):
|
class TestGrantOut(BaseModel):
|
||||||
@@ -39,3 +49,7 @@ class TestGrantOut(BaseModel):
|
|||||||
daily_limit: int = Field(..., description="每日发奖次数上限")
|
daily_limit: int = Field(..., description="每日发奖次数上限")
|
||||||
remaining: int = Field(..., description="今日剩余可领次数")
|
remaining: int = Field(..., description="今日剩余可领次数")
|
||||||
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
||||||
|
round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut")
|
||||||
|
cooldown_until: datetime | None = Field(
|
||||||
|
None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut"
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
| `daily_limit` | int | 每日发奖次数上限 |
|
| `daily_limit` | int | 每日发奖次数上限 |
|
||||||
| `remaining` | int | 今日剩余可领次数 |
|
| `remaining` | int | 今日剩余可领次数 |
|
||||||
| `coin_per_ad` | 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)。
|
||||||
|
|||||||
+83
-1
@@ -9,8 +9,15 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.integrations import pangle
|
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.db.session import SessionLocal
|
||||||
|
from app.models.ad_reward import AdRewardRecord
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -148,6 +155,81 @@ def test_reward_status_requires_auth(client) -> None:
|
|||||||
assert r.status_code == 401
|
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:
|
def test_callback_disabled_returns_503(client, monkeypatch) -> None:
|
||||||
"""未配置回调(开关关)时 → 503。"""
|
"""未配置回调(开关关)时 → 503。"""
|
||||||
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)
|
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)
|
||||||
|
|||||||
Reference in New Issue
Block a user