da7ce69494
- 新增 POST /api/v1/ad/ecpm-report:激励视频展示后客户端上报本次 eCPM,落 ad_ecpm_record 做内部收益统计(model/repository/schema/alembic 迁移 + 接口文档) - 看广告冷却策略抽到 app/core/ad_cooldown.py 纯函数;ad_reward.today_status 只取数据,换策略只改这一处 - savings.dishes 改 JSON().with_variant(JSONB,"postgresql"):SQLite 无 visit_JSONB 会让 create_all 编译崩,修复后测试套件恢复 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #8 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
239 lines
8.8 KiB
Python
239 lines
8.8 KiB
Python
"""看激励视频发奖测试(穿山甲 S2S 回调)。
|
|
|
|
回调无 JWT,靠验签——测试用配置里的 mock 密钥自签自验。覆盖:发奖到账、幂等、
|
|
验签失败、每日上限、未知用户、客户端进度查询、回调未配置。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
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,
|
|
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
|
|
|
|
|
|
def _login(client, phone: str) -> str:
|
|
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
|
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["access_token"]
|
|
|
|
|
|
def _auth(token: str) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _user_id(phone: str) -> int:
|
|
db = SessionLocal()
|
|
try:
|
|
return db.execute(select(User.id).where(User.phone == phone)).scalar_one()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _signed(user_id: int, trans_id: str, **extra: str) -> dict[str, str]:
|
|
"""构造带合法签名的回调参数(模拟穿山甲服务器)。sign 只对 trans_id 签(穿山甲规范)。"""
|
|
params = {"user_id": str(user_id), "trans_id": trans_id, **extra}
|
|
params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
|
|
return params
|
|
|
|
|
|
def _callback(client, params: dict[str, str]):
|
|
return client.get("/api/v1/ad/pangle-callback", params=params)
|
|
|
|
|
|
def _coin_balance(client, token: str) -> int:
|
|
return client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]
|
|
|
|
|
|
def test_callback_grants_coins(client) -> None:
|
|
"""验签通过的回调(无 reward_amount → 回退 AD_REWARD_COIN)→ 发金币到账 + 计数 +1。"""
|
|
phone = "13800003001"
|
|
token = _login(client, phone)
|
|
uid = _user_id(phone)
|
|
|
|
r = _callback(client, _signed(uid, "trans_a1", reward_name="金币"))
|
|
assert r.status_code == 200, r.text
|
|
assert r.json() == {"is_verify": True, "reason": 0}
|
|
|
|
assert _coin_balance(client, token) == AD_REWARD_COIN
|
|
|
|
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
|
assert st["used_today"] == 1
|
|
assert st["daily_limit"] == DAILY_AD_REWARD_LIMIT
|
|
assert st["remaining"] == DAILY_AD_REWARD_LIMIT - 1
|
|
assert st["coin_per_ad"] == AD_REWARD_COIN
|
|
|
|
|
|
def test_callback_uses_reward_amount(client) -> None:
|
|
"""带合法 reward_amount → 按它发金币(而非常量);异常值回退/夹紧。"""
|
|
phone = "13800003011"
|
|
token = _login(client, phone)
|
|
uid = _user_id(phone)
|
|
|
|
# 合法值:发 250
|
|
assert _callback(client, _signed(uid, "ra_ok", reward_amount="250")).status_code == 200
|
|
assert _coin_balance(client, token) == 250
|
|
|
|
# ≤0 / 非数字 → 回退 AD_REWARD_COIN(累加 AD_REWARD_COIN,当前 666)
|
|
assert _callback(client, _signed(uid, "ra_zero", reward_amount="0")).status_code == 200
|
|
assert _coin_balance(client, token) == 250 + AD_REWARD_COIN
|
|
|
|
# 超上限 → 夹紧到 MAX_AD_REWARD_COIN
|
|
assert _callback(client, _signed(uid, "ra_big", reward_amount="999999")).status_code == 200
|
|
assert _coin_balance(client, token) == 250 + AD_REWARD_COIN + MAX_AD_REWARD_COIN
|
|
|
|
|
|
def test_callback_idempotent(client) -> None:
|
|
"""同一 trans_id 二次回调 → 只发一次金币(穿山甲重试不重复发)。"""
|
|
phone = "13800003002"
|
|
token = _login(client, phone)
|
|
uid = _user_id(phone)
|
|
|
|
p = _signed(uid, "trans_dup")
|
|
assert _callback(client, p).status_code == 200
|
|
assert _callback(client, p).status_code == 200 # 重试
|
|
|
|
assert _coin_balance(client, token) == AD_REWARD_COIN # 没翻倍
|
|
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
|
assert st["used_today"] == 1
|
|
|
|
|
|
def test_callback_bad_sign(client) -> None:
|
|
"""签名错误 → 403,不发金币。"""
|
|
phone = "13800003003"
|
|
token = _login(client, phone)
|
|
uid = _user_id(phone)
|
|
|
|
params = {"user_id": str(uid), "trans_id": "trans_bad", "sign": "deadbeef"}
|
|
r = _callback(client, params)
|
|
assert r.status_code == 403, r.text
|
|
assert _coin_balance(client, token) == 0
|
|
|
|
|
|
def test_daily_cap(client) -> None:
|
|
"""达到每日上限后继续回调 → 受理但不发(capped),余额封顶。"""
|
|
phone = "13800003004"
|
|
token = _login(client, phone)
|
|
uid = _user_id(phone)
|
|
|
|
for i in range(DAILY_AD_REWARD_LIMIT):
|
|
r = _callback(client, _signed(uid, f"trans_cap_{i}"))
|
|
assert r.status_code == 200, r.text
|
|
|
|
# 第 limit+1 次:capped,仍 is_valid(不让穿山甲重试),但不加币
|
|
r = _callback(client, _signed(uid, "trans_cap_over"))
|
|
assert r.status_code == 200
|
|
assert r.json() == {"is_verify": True, "reason": 0}
|
|
|
|
assert _coin_balance(client, token) == DAILY_AD_REWARD_LIMIT * AD_REWARD_COIN
|
|
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
|
assert st["used_today"] == DAILY_AD_REWARD_LIMIT
|
|
assert st["remaining"] == 0
|
|
|
|
|
|
def test_callback_unknown_user(client) -> None:
|
|
"""验签过但 user_id 不存在 → 不发奖(is_verify false + reason),不崩。"""
|
|
params = _signed(999999, "trans_ghost")
|
|
r = _callback(client, params)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json() == {"is_verify": False, "reason": 2}
|
|
|
|
|
|
def test_reward_status_requires_auth(client) -> None:
|
|
"""客户端进度接口需登录。"""
|
|
r = client.get("/api/v1/ad/reward-status")
|
|
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)
|
|
# 503 发生在验签/发奖之前,不需要真实用户
|
|
r = _callback(client, _signed(1, "trans_disabled"))
|
|
assert r.status_code == 503, r.text
|