56f4548654
让客户端 4 态任务行 CTA (Normal/Loading/Capped/CoolingDown) 与 ExitReward 弹窗 limit note (本轮看完/今日看完 二选一) 都由后端权威派生, 跨设备 / 重装 / 杀进程都一致, 客户端不再依赖 in-memory 本地存储。 - core/rewards.py: 加 VIDEO_ROUND_REQUIRED_COUNT=3 / VIDEO_ROUND_COOLDOWN_SECONDS=600 - repositories/ad_reward.py: today_status 从 3-tuple 扩成 5-tuple, 从已有 created_at 字段按 ORDER BY DESC + OFFSET round_count 派生最近一个 已完成轮末尾时间; 零新字段零新表 - schemas/ad.py: AdRewardStatusOut + TestGrantOut 加 round_count + cooldown_until - api/v1/ad.py: reward_status / test_grant 接 5-tuple - tests: 加 4 个覆盖 (初始 / 未达一轮 / 刚达一轮进冷却 / 冷却已过) - docs/api/ad-reward-status.md: 补字段说明 + 客户端 4 态 CTA 推导规则 + "冷却仅 UX, 发奖不受影响" 注解 冷却是 UX 约束: pangle-callback 发奖只看 daily_limit, 冷却期间穿山甲 回调照常发奖, 不另设拒发分支。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
159 lines
5.8 KiB
Python
159 lines
5.8 KiB
Python
"""看激励视频发奖 CRUD。
|
|
|
|
发奖三道闸(仿提现的资金安全思路):
|
|
1. 验签不过 → API 层直接拒,不进这里。
|
|
2. trans_id 唯一 → 同一交易号二次回调不重复发(穿山甲会重试)。
|
|
3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。
|
|
|
|
发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务,
|
|
保证"记一笔 + 加金币"原子化。
|
|
"""
|
|
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,
|
|
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
|
|
|
|
|
|
class UnknownUserError(Exception):
|
|
"""回调里的 user_id 不存在(可能是伪造)。"""
|
|
|
|
|
|
def _find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
|
|
return db.execute(
|
|
select(AdRewardRecord).where(AdRewardRecord.trans_id == trans_id)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
|
return db.execute(
|
|
select(func.count())
|
|
.select_from(AdRewardRecord)
|
|
.where(
|
|
AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.reward_date == reward_date,
|
|
AdRewardRecord.status == "granted",
|
|
)
|
|
).scalar_one()
|
|
|
|
|
|
def grant_ad_reward(
|
|
db: Session,
|
|
user_id: int,
|
|
trans_id: str,
|
|
*,
|
|
coin: int = AD_REWARD_COIN,
|
|
reward_name: str | None = None,
|
|
raw: str | None = None,
|
|
) -> AdRewardRecord:
|
|
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
|
|
|
|
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见
|
|
rewards.resolve_ad_reward_coin);默认 AD_REWARD_COIN 供 test-grant / 缺省场景用。
|
|
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。
|
|
"""
|
|
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
|
|
existing = _find_by_trans(db, trans_id)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
if db.get(User, user_id) is None:
|
|
raise UnknownUserError
|
|
|
|
today = cn_today().isoformat()
|
|
|
|
# #3 每日上限:超了记一条 capped(不发金币),让审计能看到"今天到顶了"
|
|
if _granted_today(db, user_id, today) >= DAILY_AD_REWARD_LIMIT:
|
|
rec = AdRewardRecord(
|
|
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
|
reward_date=today, reward_name=reward_name, raw=raw,
|
|
)
|
|
return _commit_record(db, rec, trans_id)
|
|
|
|
# 发金币 + 记一笔,同事务
|
|
crud_wallet.grant_coins(
|
|
db, user_id, coin,
|
|
biz_type="ad_reward", ref_id=trans_id, remark="看广告奖励",
|
|
)
|
|
rec = AdRewardRecord(
|
|
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
|
|
reward_date=today, reward_name=reward_name, raw=raw,
|
|
)
|
|
return _commit_record(db, rec, trans_id)
|
|
|
|
|
|
def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardRecord:
|
|
"""提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。"""
|
|
db.add(rec)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
existing = _find_by_trans(db, trans_id)
|
|
if existing is not None:
|
|
return existing
|
|
raise
|
|
db.refresh(rec)
|
|
return rec
|
|
|
|
|
|
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<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
|