f7d86011c1
- 新增 GET /admin/api/ad-revenue-report:展示条数/收益 + 复用金币审计逐条复算做发奖对账 - ad_ecpm/ad_reward/ad_feed_reward 各加 app_env + our_code_id 两列(alembic 迁移) - ecpm-report / feed-reward 接收并落库 app_env/our_code_id;激励发奖按 ad_session_id 回填 - ad_audit 抽出 audit_rows,报表与逐条审计复用同一复算口径 - 组级 matched 改「组内逐条全一致」,避免应发和==实发和的互相抵消掩盖错误 - list_feedbacks 改 offset 分页并返回 total(配合 admin 页码分页) - 反馈正文上限 _CONTENT_MAX 2000→200 - 文档:新增 admin-ad-revenue-report,更新 ecpm/feed-reward/feedback 及对应 db docs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #54 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""看激励视频发奖 CRUD。
|
|
|
|
发奖三道闸(仿提现的资金安全思路):
|
|
1. 验签不过 → API 层直接拒,不进这里。
|
|
2. trans_id 唯一 → 同一交易号二次回调不重复发(穿山甲会重试)。
|
|
3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。
|
|
|
|
普通福利页激励视频按 eCPM 公式发金币;缺 eCPM 时记 status='ecpm_missing' 但不发金币。
|
|
签到膨胀等其他激励视频场景复用本表记录 S2S 幂等,实际发币由各自业务仓储完成。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core import rewards
|
|
from app.core.ad_cooldown import compute_cooldown
|
|
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
|
|
from app.models.ad_reward import AdRewardRecord
|
|
from app.models.user import User
|
|
from app.repositories import ad_ecpm as crud_ecpm
|
|
from app.repositories import wallet as crud_wallet
|
|
from app.repositories.ad_watch import watched_seconds_today
|
|
|
|
|
|
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 find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
|
|
"""按 S2S 交易号查询处理记录,供路由在分场景前做幂等短路。"""
|
|
return _find_by_trans(db, trans_id)
|
|
|
|
|
|
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",
|
|
AdRewardRecord.reward_scene == "reward_video",
|
|
)
|
|
).scalar_one()
|
|
|
|
|
|
def _granted_cumulative(db: Session, user_id: int) -> int:
|
|
"""账号累计已发奖的看视频次数(不按天重置)——LT 因子(因子2)用它定"第 N 次广告"。
|
|
与 _granted_today 区别仅在去掉 reward_date 过滤;每日次数上限/冷却仍按当日统计。"""
|
|
return db.execute(
|
|
select(func.count())
|
|
.select_from(AdRewardRecord)
|
|
.where(
|
|
AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.status == "granted",
|
|
AdRewardRecord.reward_scene == "reward_video",
|
|
)
|
|
).scalar_one()
|
|
|
|
|
|
def grant_ad_reward(
|
|
db: Session,
|
|
user_id: int,
|
|
trans_id: str,
|
|
*,
|
|
ecpm: str | None = None,
|
|
ad_session_id: str | None = None,
|
|
reward_scene: str = "reward_video",
|
|
reward_name: str | None = None,
|
|
raw: str | None = None,
|
|
) -> AdRewardRecord:
|
|
"""福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。"""
|
|
# #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()
|
|
|
|
# 按 ad_session_id 匹配客户端 eCPM 上报:既用于缺 eCPM 时回退取值,也把「来源」
|
|
# (我们的应用 app_env + 我们配置的代码位 our_code_id)回填到发奖记录,供广告收益报表聚合。
|
|
# S2S 回调本身不带这俩;查不到(未上报 eCPM)则留空。
|
|
ecpm_rec = (
|
|
crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
|
if ad_session_id else None
|
|
)
|
|
src_app_env = ecpm_rec.app_env if ecpm_rec is not None else None
|
|
src_code_id = ecpm_rec.our_code_id if ecpm_rec is not None else None
|
|
|
|
# #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段,
|
|
# 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。
|
|
over_time = (
|
|
DAILY_AD_WATCH_SECONDS_LIMIT > 0
|
|
and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
|
|
)
|
|
over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db)
|
|
if over_time or over_count:
|
|
rec = AdRewardRecord(
|
|
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
|
reward_date=today, reward_name=reward_name, raw=raw,
|
|
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
|
|
app_env=src_app_env, our_code_id=src_code_id,
|
|
)
|
|
return _commit_record(db, rec, trans_id)
|
|
|
|
ecpm_raw = ecpm or (ecpm_rec.ecpm_raw if ecpm_rec is not None else None)
|
|
|
|
if not ecpm_raw:
|
|
rec = AdRewardRecord(
|
|
trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing",
|
|
reward_date=today, reward_name=reward_name, raw=raw,
|
|
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None,
|
|
app_env=src_app_env, our_code_id=src_code_id,
|
|
)
|
|
return _commit_record(db, rec, trans_id)
|
|
|
|
# LT 因子(因子2)按"账号累计第 N 次看广告"递减(2.0→1.0),不再按天重置;
|
|
# 每日次数上限/冷却仍按当日统计(over_count 用 _granted_today)。
|
|
coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_cumulative(db, user_id) + 1)
|
|
|
|
# 发金币 + 记一笔,同事务
|
|
crud_wallet.grant_coins(
|
|
db, user_id, coin,
|
|
biz_type="reward_video", 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,
|
|
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw,
|
|
app_env=src_app_env, our_code_id=src_code_id,
|
|
)
|
|
return _commit_record(db, rec, trans_id)
|
|
|
|
|
|
def record_reward_noshow(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
ad_session_id: str,
|
|
ecpm: str | None = None,
|
|
adn: str | None = None,
|
|
slot_id: str | None = None,
|
|
app_env: str | None = None,
|
|
our_code_id: str | None = None,
|
|
reward_scene: str = "reward_video",
|
|
) -> AdRewardRecord:
|
|
"""客户端上报「激励视频展示了但用户提前关/跳过、未触发发奖」,落一条 coin=0 status='closed_early'
|
|
记录,供广告收益报表把「不发金币的原因」也呈现出来(只留痕,不发币)。
|
|
|
|
幂等键 trans_id = 'noreward:{ad_session_id}'(每次展示唯一)。若同一 ad_session_id 已有 granted
|
|
记录(S2S 已发奖,正常路径),说明用户其实看完了 → 跳过不写、原样返回那条,避免与正常发奖重复。
|
|
app_env/our_code_id 由客户端直接带上(它本就持有);查不到 user 抛 UnknownUserError。
|
|
"""
|
|
if db.get(User, user_id) is None:
|
|
raise UnknownUserError
|
|
# 同一次展示已正常发奖 → 不再记 closed_early(防与 S2S granted 重复)
|
|
granted = db.execute(
|
|
select(AdRewardRecord)
|
|
.where(
|
|
AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.ad_session_id == ad_session_id,
|
|
AdRewardRecord.status == "granted",
|
|
)
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if granted is not None:
|
|
return granted
|
|
|
|
trans_id = f"noreward:{ad_session_id}"
|
|
existing = _find_by_trans(db, trans_id)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
rec = AdRewardRecord(
|
|
trans_id=trans_id, user_id=user_id, coin=0, status="closed_early",
|
|
reward_date=cn_today().isoformat(), reward_name=None, raw=None,
|
|
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
|
|
app_env=app_env, our_code_id=our_code_id,
|
|
)
|
|
return _commit_record(db, rec, trans_id)
|
|
|
|
|
|
def record_external_reward(
|
|
db: Session,
|
|
user_id: int,
|
|
trans_id: str,
|
|
*,
|
|
coin: int,
|
|
reward_scene: str,
|
|
ad_session_id: str | None = None,
|
|
ecpm: str | None = None,
|
|
reward_name: str | None = None,
|
|
raw: str | None = None,
|
|
status: str = "granted",
|
|
commit: bool = True,
|
|
) -> AdRewardRecord:
|
|
"""记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。"""
|
|
existing = _find_by_trans(db, trans_id)
|
|
if existing is not None:
|
|
return existing
|
|
if db.get(User, user_id) is None:
|
|
raise UnknownUserError
|
|
rec = AdRewardRecord(
|
|
trans_id=trans_id,
|
|
user_id=user_id,
|
|
coin=coin,
|
|
status=status,
|
|
reward_date=cn_today().isoformat(),
|
|
reward_name=reward_name,
|
|
raw=raw,
|
|
reward_scene=reward_scene,
|
|
ad_session_id=ad_session_id,
|
|
ecpm_raw=ecpm,
|
|
)
|
|
db.add(rec)
|
|
if commit:
|
|
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)
|
|
else:
|
|
db.flush()
|
|
return rec
|
|
|
|
|
|
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 _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> list[datetime]:
|
|
"""当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。"""
|
|
return list(
|
|
db.execute(
|
|
select(AdRewardRecord.created_at)
|
|
.where(
|
|
AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.reward_date == reward_date,
|
|
AdRewardRecord.status == "granted",
|
|
AdRewardRecord.reward_scene == "reward_video",
|
|
)
|
|
.order_by(AdRewardRecord.created_at.desc())
|
|
).scalars()
|
|
)
|
|
|
|
|
|
def today_status(
|
|
db: Session, user_id: int
|
|
) -> tuple[int, int, int, int, datetime | None, int, int]:
|
|
"""客户端查"今日看广告发奖"进度。
|
|
|
|
返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC),
|
|
今日已观看总秒数, 每日观看总时长上限(秒))。
|
|
次数上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);次数维度的"本轮已看
|
|
几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);观看时长字段保留给
|
|
旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸。
|
|
"""
|
|
today = cn_today().isoformat()
|
|
granted_desc = _granted_times_today_desc(db, user_id, today)
|
|
state = compute_cooldown(
|
|
granted_desc,
|
|
datetime.now(timezone.utc),
|
|
round_size=rewards.get_ad_round_count(db),
|
|
cooldown_seconds=rewards.get_ad_cooldown_sec(db),
|
|
)
|
|
return (
|
|
len(granted_desc),
|
|
rewards.get_ad_daily_limit(db),
|
|
0,
|
|
state.round_count,
|
|
state.cooldown_until,
|
|
watched_seconds_today(db, user_id, today=today),
|
|
DAILY_AD_WATCH_SECONDS_LIMIT,
|
|
)
|