feat(coin): 金币数值体系一期 — 签到加成 + 信息流广告结算
签到加成(boost): - 新增 signin_boost_record 表(user+date 唯一)+ POST /api/v1/signin/boost - signin 状态/结算逻辑调整 信息流广告结算: - 新增 ad_feed_reward_record 表(client_event_id 幂等)+ POST /api/v1/ad/feed-reward - ad_feed_reward 模型/repo、ad schema、按 ecpm/时长结算金币 金币数值:core/rewards.py 数值调整、config_schema/admin config 适配 迁移 coin_reward_phase1(down_revision pstat3growth);测试 + API/DB 文档同步 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+39
-3
@@ -20,12 +20,15 @@ from app.core.config import settings
|
||||
from app.integrations import pangle
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_feed_reward as crud_feed
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
EcpmReportOut,
|
||||
FeedRewardIn,
|
||||
FeedRewardOut,
|
||||
PangleCallbackOut,
|
||||
TestGrantOut,
|
||||
WatchReportIn,
|
||||
@@ -112,15 +115,15 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
@router.post(
|
||||
"/watch-report",
|
||||
response_model=WatchReportOut,
|
||||
summary="上报激励视频观看时长(每日 50 分钟防刷计时)",
|
||||
summary="上报激励视频观看时长(旧客户端兼容字段)",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
|
||||
)
|
||||
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
|
||||
"""客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。
|
||||
|
||||
Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
||||
当日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0,
|
||||
客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)。
|
||||
当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口
|
||||
仍保留用于旧客户端兼容和排查观看时长。
|
||||
"""
|
||||
total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
|
||||
limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
|
||||
@@ -198,3 +201,36 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
round_count=round_count,
|
||||
cooldown_until=cooldown_until,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/feed-reward",
|
||||
response_model=FeedRewardOut,
|
||||
summary="信息流广告完成后结算金币",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
|
||||
)
|
||||
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
|
||||
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
|
||||
|
||||
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。
|
||||
"""
|
||||
rec = crud_feed.grant_feed_reward(
|
||||
db,
|
||||
user.id,
|
||||
client_event_id=payload.client_event_id,
|
||||
ecpm=payload.ecpm,
|
||||
duration_seconds=payload.duration_seconds,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
)
|
||||
logger.info(
|
||||
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
||||
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
|
||||
)
|
||||
return FeedRewardOut(
|
||||
granted=(rec.status == "granted"),
|
||||
status=rec.status,
|
||||
coin=rec.coin,
|
||||
unit_count=rec.unit_count,
|
||||
daily_limit=rewards.get_ad_daily_limit(db),
|
||||
)
|
||||
|
||||
+32
-2
@@ -1,8 +1,9 @@
|
||||
"""签到 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/signin`:
|
||||
GET /status 今日签到状态 + 7 天档位
|
||||
GET /status 今日签到状态 + 14 天档位
|
||||
POST / 执行今日签到
|
||||
POST /boost 签到后看广告膨胀金币
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,7 +13,12 @@ from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.schemas.welfare import SigninResultOut, SigninStatusOut
|
||||
from app.schemas.welfare import (
|
||||
SigninBoostRequest,
|
||||
SigninBoostResultOut,
|
||||
SigninResultOut,
|
||||
SigninStatusOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.signin")
|
||||
|
||||
@@ -42,3 +48,27 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
|
||||
streak=record.streak,
|
||||
coin_balance=balance,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币")
|
||||
def boost_signin(
|
||||
payload: SigninBoostRequest, user: CurrentUser, db: DbSession
|
||||
) -> SigninBoostResultOut:
|
||||
try:
|
||||
record, balance = crud_signin.boost_today_signin(
|
||||
db, user.id, ad_ref_id=payload.ad_ref_id
|
||||
)
|
||||
except crud_signin.NotSignedTodayError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="not signed today") from e
|
||||
except crud_signin.AlreadyBoostedError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already boosted today") from e
|
||||
|
||||
logger.info(
|
||||
"signin boost ok user_id=%d date=%s coin=%d",
|
||||
user.id, record.signin_date, record.coin_awarded,
|
||||
)
|
||||
return SigninBoostResultOut(
|
||||
coin_awarded=record.coin_awarded,
|
||||
coin_balance=balance,
|
||||
signin_date=record.signin_date.isoformat(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user