Compare commits

...

1 Commits

Author SHA1 Message Date
OuYingJun1024 375572ac13 feat(coin): 金币数值体系二期——看广告按 eCPM 发奖 + ad_session_id 幂等 + 签到膨胀固定金币
- rewards: 激励视频实发改按 calculate_ad_reward_coin(eCPM, 当日第N次) 公式;AD_REWARD_COIN/MAX_AD_REWARD_COIN 降为历史兼容口径;新增 SIGNIN_BOOST_COIN=2000

- 签到膨胀: Day1-13 看完激励视频额外发固定金币、Day14 不允许 (signin.py / signin-boost)

- ad_session_id 幂等: ad_ecpm/ad_reward/ad_feed_reward 记录加 ad_session_id 列 + 唯一索引(新迁移 coin_reward_phase2 / ad_feed_reward_session)

- ad.py + schemas + repositories: ecpm-report / feed-reward / reward-status / test-grant 改造;config_schema 增 signin_boost_coin;admin stats overview 补充

- 同步 docs/api + docs/database + docs/integrations/pangle 及 tests(test_ad_reward / test_welfare)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:19:01 +08:00
33 changed files with 650 additions and 154 deletions
@@ -0,0 +1,38 @@
"""add ad_session_id to ad feed reward record
Revision ID: ad_feed_reward_session
Revises: coin_reward_phase2
Create Date: 2026-06-09
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad_feed_reward_session"
down_revision = "coin_reward_phase2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ad_feed_reward_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_ad_feed_reward_record_ad_session_id"),
"ad_feed_reward_record",
["ad_session_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_ad_feed_reward_record_ad_session_id"),
table_name="ad_feed_reward_record",
)
op.drop_column("ad_feed_reward_record", "ad_session_id")
@@ -0,0 +1,71 @@
"""coin reward points phase2
Revision ID: coin_reward_phase2
Revises: 0cf18d590b1d
Create Date: 2026-06-08
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "coin_reward_phase2"
down_revision = "0cf18d590b1d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ad_ecpm_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_ad_ecpm_record_ad_session_id"),
"ad_ecpm_record",
["ad_session_id"],
unique=False,
)
op.create_index(
"uq_ad_ecpm_record_session",
"ad_ecpm_record",
["ad_session_id"],
unique=True,
)
op.add_column(
"ad_reward_record",
sa.Column(
"reward_scene",
sa.String(length=32),
nullable=False,
server_default="reward_video",
),
)
op.add_column(
"ad_reward_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.add_column(
"ad_reward_record",
sa.Column("ecpm_raw", sa.String(length=32), nullable=True),
)
op.create_index(
op.f("ix_ad_reward_record_ad_session_id"),
"ad_reward_record",
["ad_session_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_ad_reward_record_ad_session_id"), table_name="ad_reward_record")
op.drop_column("ad_reward_record", "ecpm_raw")
op.drop_column("ad_reward_record", "ad_session_id")
op.drop_column("ad_reward_record", "reward_scene")
op.drop_index("uq_ad_ecpm_record_session", table_name="ad_ecpm_record")
op.drop_index(op.f("ix_ad_ecpm_record_ad_session_id"), table_name="ad_ecpm_record")
op.drop_column("ad_ecpm_record", "ad_session_id")
+34
View File
@@ -10,8 +10,11 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback from app.models.feedback import Feedback
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.user import User from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder from app.models.wallet import CoinTransaction, WithdrawOrder
@@ -71,6 +74,37 @@ def dashboard_overview(db: Session) -> dict:
"coins": { "coins": {
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计) # 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
"granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0), "granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0),
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
),
"reward_video_watch_count": _count(
AdRewardRecord,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
),
"feed_ad_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "feed_ad_reward",
),
"feed_ad_watch_count": _count(
AdFeedRewardRecord,
AdFeedRewardRecord.status == "granted",
),
"signin_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin",
),
"signin_count": _count(SigninRecord),
"signin_boost_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin_boost",
),
"signin_boost_watch_count": _count(SigninBoostRecord),
}, },
"cash": { "cash": {
"withdraw_success_cents": _sum( "withdraw_success_cents": _sum(
+8
View File
@@ -14,6 +14,14 @@ class DashboardUsers(BaseModel):
class DashboardCoins(BaseModel): class DashboardCoins(BaseModel):
reward_video_coin_total: int = 0
reward_video_watch_count: int = 0
feed_ad_coin_total: int = 0
feed_ad_watch_count: int = 0
signin_coin_total: int = 0
signin_count: int = 0
signin_boost_coin_total: int = 0
signin_boost_watch_count: int = 0
granted_total: int granted_total: int
+148 -19
View File
@@ -10,6 +10,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import json
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -23,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch from app.repositories import ad_watch as crud_watch
from app.repositories import signin as crud_signin
from app.schemas.ad import ( from app.schemas.ad import (
AdRewardStatusOut, AdRewardStatusOut,
EcpmReportIn, EcpmReportIn,
@@ -30,6 +32,7 @@ from app.schemas.ad import (
FeedRewardIn, FeedRewardIn,
FeedRewardOut, FeedRewardOut,
PangleCallbackOut, PangleCallbackOut,
TestGrantIn,
TestGrantOut, TestGrantOut,
WatchReportIn, WatchReportIn,
WatchReportOut, WatchReportOut,
@@ -44,6 +47,23 @@ REASON_OK = 0
REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字 REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造) REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
REWARD_SCENE_REWARD_VIDEO = "reward_video"
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
def _parse_extra(raw_extra: str | None) -> dict[str, str]:
"""解析客户端 setMediaExtra 透传的 JSON;旧格式/异常返回空 dict。"""
if not raw_extra:
return {}
try:
data = json.loads(raw_extra)
except (TypeError, ValueError):
return {}
if not isinstance(data, dict):
return {}
return {str(k): str(v) for k, v in data.items() if v is not None}
@router.get( @router.get(
"/pangle-callback", "/pangle-callback",
@@ -52,9 +72,9 @@ REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))], dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
) )
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / reward_amount / sign 等 query 参数。 """穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / ecpm / sign 等 query 参数。
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/reward_amount → 幂等发金币。 流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/场景/eCPM → 幂等发金币。
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。 验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
granted / capped → is_verify=true + reason=0。 granted / capped → is_verify=true + reason=0。
""" """
@@ -77,19 +97,86 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS) return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
user_id = int(raw_user_id) user_id = int(raw_user_id)
coin = rewards.resolve_ad_reward_coin(db, params.get("reward_amount"))
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign") raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
try: extra = {}
rec = crud_ad.grant_ad_reward( for extra_key in ("extra", "gromoreExtra", "gromore_extra"):
db, user_id, trans_id, coin=coin, extra.update(_parse_extra(params.get(extra_key)))
reward_name=params.get("reward_name"), raw=raw[:1024], reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
ad_session_id = extra.get("ad_session_id")
ecpm = params.get("ecpm")
existing = crud_ad.find_by_trans(db, trans_id)
if existing is not None:
logger.info(
"pangle callback idempotent user_id=%d trans_id=%s status=%s scene=%s",
user_id, trans_id, existing.status, existing.reward_scene,
) )
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
try:
if reward_scene not in SUPPORTED_REWARD_SCENES:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene[:32],
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="unknown_scene",
)
logger.warning(
"pangle callback unknown scene user_id=%d trans_id=%s scene=%s",
user_id, trans_id, reward_scene,
)
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user_id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
commit=False,
)
db.commit()
db.refresh(rec)
else:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
reward_scene=REWARD_SCENE_REWARD_VIDEO,
reward_name=params.get("reward_name"), raw=raw[:1024],
)
except crud_ad.UnknownUserError: except crud_ad.UnknownUserError:
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id) logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER) return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
logger.info( logger.info(
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin "ad reward user_id=%d trans_id=%s scene=%s status=%s coin=%d",
user_id, trans_id, rec.reward_scene, rec.status, rec.coin,
) )
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币) # granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
return PangleCallbackOut(is_verify=True, reason=REASON_OK) return PangleCallbackOut(is_verify=True, reason=REASON_OK)
@@ -153,11 +240,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
crud_ecpm.create_ecpm_record( crud_ecpm.create_ecpm_record(
db, user.id, db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id, adn=payload.adn, slot_id=payload.slot_id,
) )
logger.info( logger.info(
"ad ecpm report user_id=%d type=%s ecpm=%s adn=%s slot=%s", "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ecpm, payload.adn, payload.slot_id, user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
) )
return EcpmReportOut(ok=True) return EcpmReportOut(ok=True)
@@ -168,7 +256,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
summary="[仅本地联调]模拟穿山甲回调发奖", summary="[仅本地联调]模拟穿山甲回调发奖",
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))], dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
) )
def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut: def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = None) -> TestGrantOut:
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后 """⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。 调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
@@ -178,18 +266,58 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
if not settings.AD_REWARD_TEST_GRANT_ENABLED: if not settings.AD_REWARD_TEST_GRANT_ENABLED:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限) reward_scene = (payload.reward_scene if payload is not None else REWARD_SCENE_REWARD_VIDEO)
if reward_scene not in SUPPORTED_REWARD_SCENES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次)
trans_id = f"test-{user.id}-{uuid.uuid4().hex}" trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
try: if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
rec = crud_ad.grant_ad_reward( try:
db, user.id, trans_id, reward_name="测试发奖", raw="client debug test-grant" boost, _balance = crud_signin.boost_today_signin(
) db, user.id, ad_ref_id=trans_id, commit=False
except crud_ad.UnknownUserError as e: )
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, reward_name="测试签到膨胀",
raw="client debug test-grant signin_boost", commit=False,
)
db.commit()
db.refresh(rec)
else:
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm="200", reward_name="测试发奖",
raw="client debug test-grant ecpm=200",
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
(used, limit, coin_per, round_count, cooldown_until, (used, limit, coin_per, round_count, cooldown_until,
_watched, _watch_limit) = crud_ad.today_status(db, user.id) _watched, _watch_limit) = 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 scene=%s status=%s coin=%d",
user.id, reward_scene, rec.status, rec.coin,
)
return TestGrantOut( return TestGrantOut(
granted=(rec.status == "granted"), granted=(rec.status == "granted"),
status=rec.status, status=rec.status,
@@ -220,6 +348,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
client_event_id=payload.client_event_id, client_event_id=payload.client_event_id,
ecpm=payload.ecpm, ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds, duration_seconds=payload.duration_seconds,
ad_session_id=payload.ad_session_id,
adn=payload.adn, adn=payload.adn,
slot_id=payload.slot_id, slot_id=payload.slot_id,
) )
+17 -8
View File
@@ -12,7 +12,9 @@ import logging
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.repositories import ad_reward as crud_ad
from app.repositories import signin as crud_signin from app.repositories import signin as crud_signin
from app.repositories import wallet as crud_wallet
from app.schemas.welfare import ( from app.schemas.welfare import (
SigninBoostRequest, SigninBoostRequest,
SigninBoostResultOut, SigninBoostResultOut,
@@ -54,14 +56,21 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
def boost_signin( def boost_signin(
payload: SigninBoostRequest, user: CurrentUser, db: DbSession payload: SigninBoostRequest, user: CurrentUser, db: DbSession
) -> SigninBoostResultOut: ) -> SigninBoostResultOut:
try: if not payload.ad_ref_id:
record, balance = crud_signin.boost_today_signin( raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required")
db, user.id, ad_ref_id=payload.ad_ref_id ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id)
) if (
except crud_signin.NotSignedTodayError as e: ad_rec is None
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="not signed today") from e or ad_rec.user_id != user.id
except crud_signin.AlreadyBoostedError as e: or ad_rec.reward_scene != "signin_boost"
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already boosted today") from e or ad_rec.status != "granted"
):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified")
record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id)
if record is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted")
acc = crud_wallet.get_or_create_account(db, user.id)
balance = acc.coin_balance
logger.info( logger.info(
"signin boost ok user_id=%d date=%s coin=%d", "signin boost ok user_id=%d date=%s coin=%d",
+7 -2
View File
@@ -42,7 +42,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"ad_reward_coin": { "ad_reward_coin": {
"default": r.AD_REWARD_COIN, "label": "看广告单次金币", "default": r.AD_REWARD_COIN, "label": "看广告单次金币",
"group": "看广告", "type": "int", "group": "看广告", "type": "int",
"help": "看完一个激励视频发的金币;须与穿山甲后台代码位'奖励数量'保持一致", "help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算",
}, },
"ad_daily_limit": { "ad_daily_limit": {
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)", "default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
@@ -50,7 +50,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
}, },
"ad_max_coin": { "ad_max_coin": {
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限", "default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
"group": "看广告", "type": "int", "help": "夹紧穿山甲回调里异常的 reward_amount,防刷爆", "group": "看广告", "type": "int", "help": "历史 reward_amount 口径保留项;正式发放按 eCPM 公式计算",
}, },
"ad_round_count": { "ad_round_count": {
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数", "default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
@@ -60,4 +60,9 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。", "group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
}, },
"signin_boost_coin": {
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
"group": "签到", "type": "int",
"help": "Day1-Day13 签到后看完激励视频额外发放的固定金币;Day14 不展示也不允许膨胀。",
},
} }
+11 -7
View File
@@ -144,14 +144,14 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_t
return max(0, round(yuan * COIN_PER_YUAN)) return max(0, round(yuan * COIN_PER_YUAN))
SIGNIN_BOOST_COIN: int = 2000
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
# 看完一个激励视频发的金币(666 金币 ≈¥0.0666,汇率 10000 金币=1 元)。 # 历史固定金币口径保留项。正式激励视频实发按 calculate_ad_reward_coin(eCPM, 当日第 N 次)
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"; # 计算;该值只用于旧接口兼容、配置页展示和本地联调兜底。
# ③ test-grant 本地联调的发奖额。
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
# 代码位"奖励数量"配成与本值一致(=666),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
AD_REWARD_COIN: int = 666 AD_REWARD_COIN: int = 666
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额 # 历史 reward_amount 口径保留项。正式激励视频实发按 eCPM 公式计算
MAX_AD_REWARD_COIN: int = 1000 MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。 # 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。
DAILY_AD_REWARD_LIMIT: int = 500 DAILY_AD_REWARD_LIMIT: int = 500
@@ -169,7 +169,7 @@ VIDEO_ROUND_COOLDOWN_SECONDS: int = 3
def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001 def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001
"""把穿山甲回调的 reward_amount(后台代码位配的"奖励数量")解析成本次发放金币 """历史 reward_amount 口径解析函数,保留给旧脚本/旧配置兼容
缺失 / 非数字 / ≤0 → 回退配置单次金币;超过配置单次上限 → 夹紧(均从 app_config 读)。 缺失 / 非数字 / ≤0 → 回退配置单次金币;超过配置单次上限 → 夹紧(均从 app_config 读)。
注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign 注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
@@ -236,3 +236,7 @@ def get_ad_round_count(db) -> int: # noqa: ANN001
def get_ad_cooldown_sec(db) -> int: # noqa: ANN001 def get_ad_cooldown_sec(db) -> int: # noqa: ANN001
return int(_cfg(db, "ad_cooldown_sec")) return int(_cfg(db, "ad_cooldown_sec"))
def get_signin_boost_coin(db) -> int: # noqa: ANN001
return int(_cfg(db, "signin_boost_coin"))
+2 -2
View File
@@ -8,9 +8,9 @@
我们客户端是 useMediation(true) 融合,回调走 GroMore 广告位层级,**不是**联盟代码位层级): 我们客户端是 useMediation(true) 融合,回调走 GroMore 广告位层级,**不是**联盟代码位层级):
**sign = SHA256("{m-key}:{trans_id}")** 的十六进制串。注意: **sign = SHA256("{m-key}:{trans_id}")** 的十六进制串。注意:
- 是**普通 SHA256**,不是 HMAC,也不是 RSA; - 是**普通 SHA256**,不是 HMAC,也不是 RSA;
- **只对 `m-key:trans_id` 这一个字符串**签名,user_id / reward_amount 等其余参数**不参与**签名 - **只对 `m-key:trans_id` 这一个字符串**签名,user_id / ecpm / extra 等其余参数**不参与**签名
(它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 就连合法 sign (它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 就连合法 sign
都造不出,自然也无法注入假 user_id/reward_amount)。 都造不出,自然也无法注入假 user_id/ecpm/extra)。
m-key(安全密钥)在穿山甲后台「GroMore 聚合管理 → 搜广告位ID → 编辑」处获取,配到 m-key(安全密钥)在穿山甲后台「GroMore 聚合管理 → 搜广告位ID → 编辑」处获取,配到
`settings.PANGLE_REWARD_SECRET`。(联盟代码位层级用的是另一套 Security Key + isValid 响应,我们不走那条。) `settings.PANGLE_REWARD_SECRET`。(联盟代码位层级用的是另一套 Security Key + isValid 响应,我们不走那条。)
""" """
+10 -8
View File
@@ -1,11 +1,8 @@
"""广告展示 eCPM 上报记录(内部收益统计/对账)。 """广告展示 eCPM 上报记录(内部收益统计/对账)。
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息。和发奖记录 每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息。`ad_session_id`
[ad_reward.AdRewardRecord] 是**两条独立的数据流**: 由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"
- 发奖走穿山甲 S2S 回调(后端 → 有 trans_id、无 ecpm); "奖励完成"绑定。没有 session id 的旧上报仍可作为按天对账补充。
- eCPM 走客户端上报(客户端 → 有 ecpm、无 trans_id)。
两者没有公共键,无法逐条一一对应,所以本表用于**按用户/按天聚合收益**口径的对账,
不做"这条发奖 = 这条 ecpm"的精确关联。穿山甲后台报表才是结算权威,本表是细粒度补充。
⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认, ⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认,
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。 确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。
@@ -14,7 +11,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base from app.db.base import Base
@@ -22,6 +19,9 @@ from app.db.base import Base
class AdEcpmRecord(Base): class AdEcpmRecord(Base):
__tablename__ = "ad_ecpm_record" __tablename__ = "ad_ecpm_record"
__table_args__ = (
UniqueConstraint("ad_session_id", name="uq_ad_ecpm_record_session"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
@@ -29,11 +29,13 @@ class AdEcpmRecord(Base):
) )
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报 # 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False) ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt) # 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True) adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位) # 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 客户端上报的 eCPM 原始字符串(单位待确认,原样存) # 客户端上报的 eCPM 原始字符串(单位:元/千次展示,原样存)
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
+1
View File
@@ -20,6 +20,7 @@ class AdFeedRewardRecord(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
client_event_id: Mapped[str] = mapped_column(String(64), nullable=False) client_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False Integer, ForeignKey("user.id"), index=True, nullable=False
) )
+14 -5
View File
@@ -1,8 +1,8 @@
"""看激励视频发奖记录(穿山甲 S2S 回调)。 """看激励视频发奖记录(穿山甲 S2S 回调)。
每条 = 穿山甲一次发奖回调。`trans_id`(穿山甲交易号)唯一,做幂等键:穿山甲会重试回调, 每条 = 穿山甲/GroMore 一次服务端激励回调。`trans_id`(交易号)唯一,做幂等键:
同号只发一次金币。`reward_date`(北京时间日期串)给"每日上限"计数用——按日期串等值查, 穿山甲会重试回调,同号只处理一次。`reward_scene` 区分福利页激励视频、签到膨胀等
在 SQL 里做跨时区 date 比较(SQLite 上不可靠)。审计 / 对账时整张表可逐笔回溯 同奖励场景,避免统计和每日上限互相污染
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,8 +25,14 @@ class AdRewardRecord(Base):
) )
# 实发金币(capped 时为 0) # 实发金币(capped 时为 0)
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# granted(已发) / capped(当日超限未发) # granted(已发) / capped(当日超限未发) / ecpm_missing(缺 eCPM 未发)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted") status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
# reward_video(福利页看视频) / signin_boost(签到膨胀)
reward_scene: Mapped[str] = mapped_column(String(32), nullable=False, default="reward_video")
# 客户端生成并通过 extra 透传的广告会话 id
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报)
ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数 # 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
# 穿山甲上报的奖励名(参考,不作发奖依据) # 穿山甲上报的奖励名(参考,不作发奖依据)
@@ -39,4 +45,7 @@ class AdRewardRecord(Base):
) )
def __repr__(self) -> str: # pragma: no cover def __repr__(self) -> str: # pragma: no cover
return f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} {self.status} coin={self.coin}>" return (
f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} "
f"scene={self.reward_scene} {self.status} coin={self.coin}>"
)
+30 -1
View File
@@ -7,6 +7,7 @@ user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条
from __future__ import annotations from __future__ import annotations
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.rewards import cn_today from app.core.rewards import cn_today
@@ -19,24 +20,52 @@ def create_ecpm_record(
*, *,
ad_type: str, ad_type: str,
ecpm_raw: str, ecpm_raw: str,
ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
) -> AdEcpmRecord: ) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。""" """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。"""
if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None:
return existing
rec = AdEcpmRecord( rec = AdEcpmRecord(
user_id=user_id, user_id=user_id,
ad_type=ad_type, ad_type=ad_type,
ad_session_id=ad_session_id,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
ecpm_raw=ecpm_raw, ecpm_raw=ecpm_raw,
report_date=cn_today().isoformat(), report_date=cn_today().isoformat(),
) )
db.add(rec) db.add(rec)
db.commit() try:
db.commit()
except IntegrityError:
db.rollback()
if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None:
return existing
raise
db.refresh(rec) db.refresh(rec)
return rec return rec
def find_by_session(
db: Session, *, user_id: int, ad_session_id: str | None
) -> AdEcpmRecord | None:
"""按广告会话找 eCPM。旧客户端无 ad_session_id 时返回 None。"""
if not ad_session_id:
return None
return db.execute(
select(AdEcpmRecord).where(
AdEcpmRecord.user_id == user_id,
AdEcpmRecord.ad_session_id == ad_session_id,
)
).scalar_one_or_none()
def count_today(db: Session, user_id: int) -> int: def count_today(db: Session, user_id: int) -> int:
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。""" """该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
return db.execute( return db.execute(
+3
View File
@@ -63,6 +63,7 @@ def grant_feed_reward(
client_event_id: str, client_event_id: str,
ecpm: str, ecpm: str,
duration_seconds: int, duration_seconds: int,
ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
) -> AdFeedRewardRecord: ) -> AdFeedRewardRecord:
@@ -82,6 +83,7 @@ def grant_feed_reward(
reward_date=today, reward_date=today,
duration_seconds=safe_duration, duration_seconds=safe_duration,
unit_count=unit_count, unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
@@ -103,6 +105,7 @@ def grant_feed_reward(
reward_date=today, reward_date=today,
duration_seconds=safe_duration, duration_seconds=safe_duration,
unit_count=unit_count, unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
+80 -13
View File
@@ -5,8 +5,8 @@
2. trans_id 唯一 → 同一交易号二次回调不重复发(穿山甲会重试)。 2. trans_id 唯一 → 同一交易号二次回调不重复发(穿山甲会重试)。
3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。 3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。
发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务, 普通福利页激励视频按 eCPM 公式发金币;缺 eCPM 时记 status='ecpm_missing' 但不发金币。
保证"记一笔 + 加金币"原子化。单次金币 / 每日上限 / 每轮冷却 都从 app_config 读(运营后台可改) 签到膨胀等其他激励视频场景复用本表记录 S2S 幂等,实际发币由各自业务仓储完成
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,6 +21,7 @@ from app.core.ad_cooldown import compute_cooldown
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
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
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import wallet as crud_wallet from app.repositories import wallet as crud_wallet
from app.repositories.ad_watch import watched_seconds_today from app.repositories.ad_watch import watched_seconds_today
@@ -35,6 +36,11 @@ def _find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
).scalar_one_or_none() ).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: def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute( return db.execute(
select(func.count()) select(func.count())
@@ -43,6 +49,7 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
AdRewardRecord.user_id == user_id, AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date, AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted", AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
) )
).scalar_one() ).scalar_one()
@@ -52,16 +59,13 @@ def grant_ad_reward(
user_id: int, user_id: int,
trans_id: str, trans_id: str,
*, *,
coin: int | None = None, ecpm: str | None = None,
ad_session_id: str | None = None,
reward_scene: str = "reward_video",
reward_name: str | None = None, reward_name: str | None = None,
raw: str | None = None, raw: str | None = None,
) -> AdRewardRecord: ) -> AdRewardRecord:
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。 """福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。"""
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见
rewards.resolve_ad_reward_coin);None → 读配置 get_ad_reward_coin(test-grant / 缺省场景)。
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。
"""
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发 # #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
existing = _find_by_trans(db, trans_id) existing = _find_by_trans(db, trans_id)
if existing is not None: if existing is not None:
@@ -83,24 +87,86 @@ def grant_ad_reward(
rec = AdRewardRecord( rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="capped", trans_id=trans_id, user_id=user_id, coin=0, status="capped",
reward_date=today, reward_name=reward_name, raw=raw, reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
) )
return _commit_record(db, rec, trans_id) return _commit_record(db, rec, trans_id)
if coin is None: ecpm_raw = ecpm
coin = rewards.get_ad_reward_coin(db) if not ecpm_raw and ad_session_id:
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
ecpm_raw = 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,
)
return _commit_record(db, rec, trans_id)
coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_today(db, user_id, today) + 1)
# 发金币 + 记一笔,同事务 # 发金币 + 记一笔,同事务
crud_wallet.grant_coins( crud_wallet.grant_coins(
db, user_id, coin, db, user_id, coin,
biz_type="ad_reward", ref_id=trans_id, remark="看视频奖励金币", biz_type="reward_video", ref_id=trans_id, remark="看视频奖励金币",
) )
rec = AdRewardRecord( rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=coin, status="granted", trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
reward_date=today, reward_name=reward_name, raw=raw, reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw,
) )
return _commit_record(db, rec, trans_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: def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardRecord:
"""提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。""" """提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。"""
db.add(rec) db.add(rec)
@@ -125,6 +191,7 @@ def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> li
AdRewardRecord.user_id == user_id, AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date, AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted", AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
) )
.order_by(AdRewardRecord.created_at.desc()) .order_by(AdRewardRecord.created_at.desc())
).scalars() ).scalars()
@@ -153,7 +220,7 @@ def today_status(
return ( return (
len(granted_desc), len(granted_desc),
rewards.get_ad_daily_limit(db), rewards.get_ad_daily_limit(db),
rewards.get_ad_reward_coin(db), 0,
state.round_count, state.round_count,
state.cooldown_until, state.cooldown_until,
watched_seconds_today(db, user_id, today=today), watched_seconds_today(db, user_id, today=today),
+27 -6
View File
@@ -32,6 +32,10 @@ class AlreadyBoostedError(Exception):
"""今天签到奖励已经膨胀过。""" """今天签到奖励已经膨胀过。"""
class LastCycleDayBoostBlockedError(Exception):
"""14 天循环最后一天不允许签到膨胀。"""
@dataclass @dataclass
class SigninStep: class SigninStep:
day: int # 1..14 day: int # 1..14
@@ -148,13 +152,27 @@ def _today_record(db: Session, user_id: int) -> SigninRecord | None:
).scalar_one_or_none() ).scalar_one_or_none()
def boost_by_ad_ref(
db: Session, user_id: int, ad_ref_id: str
) -> SigninBoostRecord | None:
"""按广告交易号查签到膨胀记录。S2S 发奖后客户端确认用。"""
return db.execute(
select(SigninBoostRecord).where(
SigninBoostRecord.user_id == user_id,
SigninBoostRecord.ad_ref_id == ad_ref_id,
)
).scalar_one_or_none()
def boost_today_signin( def boost_today_signin(
db: Session, user_id: int, *, ad_ref_id: str | None = None db: Session, user_id: int, *, ad_ref_id: str | None = None, commit: bool = True
) -> tuple[SigninBoostRecord, int]: ) -> tuple[SigninBoostRecord, int]:
"""签到后看广告膨胀:补发一笔等额签到金币。返回 (膨胀记录, 补发后余额)。""" """签到后看广告膨胀:固定补发配置金币。返回 (膨胀记录, 补发后余额)。"""
record = _today_record(db, user_id) record = _today_record(db, user_id)
if record is None: if record is None:
raise NotSignedTodayError raise NotSignedTodayError
if record.cycle_day == SIGNIN_CYCLE_LEN:
raise LastCycleDayBoostBlockedError
today = record.signin_date today = record.signin_date
existing = db.execute( existing = db.execute(
@@ -169,17 +187,20 @@ def boost_today_signin(
boost = SigninBoostRecord( boost = SigninBoostRecord(
user_id=user_id, user_id=user_id,
signin_date=today, signin_date=today,
coin_awarded=record.coin_awarded, coin_awarded=rewards.get_signin_boost_coin(db),
ad_ref_id=ad_ref_id, ad_ref_id=ad_ref_id,
) )
db.add(boost) db.add(boost)
try: try:
acc, _ = crud_wallet.grant_coins( acc, _ = crud_wallet.grant_coins(
db, user_id, record.coin_awarded, db, user_id, boost.coin_awarded,
biz_type="signin_boost", ref_id=today.isoformat(), biz_type="signin_boost", ref_id=ad_ref_id or today.isoformat(),
remark=f"签到膨胀 第{record.cycle_day}", remark=f"签到膨胀 第{record.cycle_day}",
) )
db.commit() if commit:
db.commit()
else:
db.flush()
except IntegrityError as e: except IntegrityError as e:
db.rollback() db.rollback()
raise AlreadyBoostedError from e raise AlreadyBoostedError from e
+23 -5
View File
@@ -28,7 +28,7 @@ class AdRewardStatusOut(BaseModel):
used_today: int = Field(..., description="今日已成功发奖次数") used_today: int = Field(..., description="今日已成功发奖次数")
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="历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0")
round_count: int = Field( round_count: int = Field(
..., description="本轮已看次数,当前 round_size=1,客户端可由 cooldown_until 判断短冷却" ..., description="本轮已看次数,当前 round_size=1,客户端可由 cooldown_until 判断短冷却"
) )
@@ -46,11 +46,15 @@ class AdRewardStatusOut(BaseModel):
class EcpmReportIn(BaseModel): class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串(单位待确认) user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按元/千次展示处理
""" """
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等") ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,单位待确认,原样上报") ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按元/千次展示处理")
ad_session_id: str | None = Field(
None, min_length=8, max_length=64,
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
)
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
@@ -78,16 +82,27 @@ class WatchReportOut(BaseModel):
watch_seconds_remaining: int = Field(..., description="今日剩余可观看秒数") watch_seconds_remaining: int = Field(..., description="今日剩余可观看秒数")
class TestGrantIn(BaseModel):
"""[仅本地联调]模拟发奖入参。"""
reward_scene: str = Field(
"reward_video",
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
)
class TestGrantOut(BaseModel): class TestGrantOut(BaseModel):
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。""" """[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)") granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)")
status: str = Field(..., description="granted / capped") status: str = Field(
..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene"
)
coin: int = Field(..., description="本次发放金币(capped 时为 0)") coin: int = Field(..., description="本次发放金币(capped 时为 0)")
used_today: int = Field(..., description="今日已成功发奖次数") used_today: int = Field(..., description="今日已成功发奖次数")
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="历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0")
round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut") round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut")
cooldown_until: datetime | None = Field( cooldown_until: datetime | None = Field(
None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut" None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut"
@@ -101,6 +116,9 @@ class FeedRewardIn(BaseModel):
""" """
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
)
ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理") ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理")
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
adn: str | None = Field(None, description="实际投放 ADN") adn: str | None = Field(None, description="实际投放 ADN")
+4 -2
View File
@@ -8,7 +8,8 @@
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | | `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
| `ecpm` | str | | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,**单位待确认(分/元)**,原样上报 | | `ad_session_id` | str\|null | | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,后端按“元/千次展示”参与金币公式 |
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | | `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | | `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
@@ -24,7 +25,8 @@
## 说明 ## 说明
客户端在广告**展示后**(`onAdShow``getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。 客户端在广告**展示后**(`onAdShow``getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。
- **best-effort**:客户端 fire-and-forget,丢一两条不影响业务;穿山甲后台报表是结算权威兜底 - 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常
- **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。
-`ad_ecpm_record` 表,`report_date` 用北京时间当天,供「按用户/按天聚合」对账。 -`ad_ecpm_record` 表,`report_date` 用北京时间当天,供「按用户/按天聚合」对账。
- **与发奖是两条独立流**:发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S,有 `trans_id`、无 ecpm),本接口客户端上报(有 ecpm、无 `trans_id`)。两者无公共键,**不逐条一一对应**,只做按用户/按天聚合口径。 - **与发奖是两条独立流**:发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S,有 `trans_id`、无 ecpm),本接口客户端上报(有 ecpm、无 `trans_id`)。两者无公共键,**不逐条一一对应**,只做按用户/按天聚合口径。
- eCPM 是**每千次展示预估**,单条展示预估收益 ≈ `ecpm / 1000`,且为客户端预估口径,非最终结算。 - eCPM 是**每千次展示预估**,单条展示预估收益 ≈ `ecpm / 1000`,且为客户端预估口径,非最终结算。
+1
View File
@@ -11,6 +11,7 @@
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---| |---|---|---:|---|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 | | `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 |
| `duration_seconds` | int | 是 | 实际展示/播放秒数 | | `duration_seconds` | int | 是 | 实际展示/播放秒数 |
| `adn` | string\|null | 否 | 实际投放 ADN | | `adn` | string\|null | 否 | 实际投放 ADN |
+8 -3
View File
@@ -14,8 +14,8 @@ GroMore 以 GET 回调,关键参数:
| `user_id` | string | 客户端 `setUserID` 传入的用户标识(须为数字 = 本系统 user.id) | | `user_id` | string | 客户端 `setUserID` 传入的用户标识(须为数字 = 本系统 user.id) |
| `trans_id` | string | 交易号(**幂等键** + **唯一参与签名的字段** | | `trans_id` | string | 交易号(**幂等键** + **唯一参与签名的字段** |
| `reward_name` | string | 奖励名(广告位配置,入库备注) | | `reward_name` | string | 奖励名(广告位配置,入库备注) |
| `reward_amount` | int | 奖励数量(广告位配置)→ **本次发放金币**。缺/坏/≤0 回退 `AD_REWARD_COIN`,超 `MAX_AD_REWARD_COIN` 夹紧 | | `ecpm` | string\|null | GroMore 回调携带的 eCPM。普通激励视频优先用它计算金币 |
| `extra` | string | 客户端透传的 customData(可空,入库备注) | | `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id``reward_scene`;`reward_scene=signin_boost` 表示签到膨胀 |
| `mediation_rit` | string | 代码位 IDGroMore 带,目前仅入 raw 备查) | | `mediation_rit` | string | 代码位 IDGroMore 带,目前仅入 raw 备查) |
| `prime_rit` | string | 广告位 ID(同上) | | `prime_rit` | string | 广告位 ID(同上) |
| `adn_name` | string | 实际出广告的 ADN 名(同上,可用于收益分析) | | `adn_name` | string | 实际出广告的 ADN 名(同上,可用于收益分析) |
@@ -37,4 +37,9 @@ GroMore 以 GET 回调,关键参数:
- `503` 回调未配置(`pangle_callback_configured=false` - `503` 回调未配置(`pangle_callback_configured=false`
## 说明 ## 说明
**发奖唯一可信入口**:验签 → 取 `user_id` → 按 `reward_amount` 解析金币 → 幂等发金币(按 `trans_id` 去重 + 每日上限)。客户端不参与发奖,被破解也刷不到钱。验签过但参数缺/坏或 user 不存在 → 不发(`is_verify=false` + `reason`;granted / capped → `is_verify=true` + `reason=0` **发奖唯一可信入口**:验签 → 取 `user_id`/`extra` → 按 `reward_scene` 分流 → 幂等处理(按 `trans_id` 去重)。客户端不直接发奖,被破解也刷不到钱。
- `reward_scene=reward_video` 或缺省:普通激励视频。金币按 `eCPM / 1000 * eCPM因子 * 当日次数因子 * 10000` 计算;若回调没有 `ecpm`,会按 `extra.ad_session_id` 查客户端 `/ad/ecpm-report` 的上报值;两边都没有 eCPM 时不发币,记录 `status=ecpm_missing`
- `reward_scene=signin_boost`:签到膨胀。要求用户当天已签到且不是 Day14;看完视频固定发 `2000` 金币,写 `signin_boost_record``coin_transaction.biz_type=signin_boost`
- 未知 `reward_scene`:不发币,记录 `status=unknown_scene`,返回 `is_verify=false/reason=1`
- 验签过但参数缺/坏或 user 不存在 → 不发(`is_verify=false` + `reason`;granted / capped / ecpm_missing / 业务不满足已记录 → `is_verify=true` + `reason=0`
+2 -2
View File
@@ -13,7 +13,7 @@
| `used_today` | int | 今日已成功发奖次数 | | `used_today` | int | 今日已成功发奖次数 |
| `daily_limit` | int | 每日发奖次数上限 | | `daily_limit` | int | 每日发奖次数上限 |
| `remaining` | int | 今日剩余可领次数 | | `remaining` | int | 今日剩余可领次数 |
| `coin_per_ad` | int | 看完一个激励视频发的金币 | | `coin_per_ad` | int | 历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0 |
| `round_count` | int | 本轮已看次数;当前 `round_size=1`,广告关闭后进入短冷却 | | `round_count` | int | 本轮已看次数;当前 `round_size=1`,广告关闭后进入短冷却 |
| `cooldown_until` | datetime\|null | 3 秒短冷却结束时间(UTC ISO);null 表示不在冷却 | | `cooldown_until` | datetime\|null | 3 秒短冷却结束时间(UTC ISO);null 表示不在冷却 |
| `watched_seconds_today` | int | 今日已上报的激励视频观看秒数;当前仅兼容/排查用 | | `watched_seconds_today` | int | 今日已上报的激励视频观看秒数;当前仅兼容/排查用 |
@@ -22,7 +22,7 @@
## 说明 ## 说明
福利页「看视频赚金币」用: 福利页「看视频赚金币」用:
- 展示「今日还能看 N 次」「看一次得 M 金币」(`remaining`/`coin_per_ad`) - 展示「今日还能看 N 次」(`remaining`);单次金币正式按 eCPM 动态计算,不再用 `coin_per_ad` 预估
- 任务行 CTA 4 态推导:`adLoading` → Loading;`remaining==0` → Capped("明天再来");`cooldown_until` 在未来 → CoolingDown(显示 MM:SS 倒计时,不可点);否则 Normal("去赚取") - 任务行 CTA 4 态推导:`adLoading` → Loading;`remaining==0` → Capped("明天再来");`cooldown_until` 在未来 → CoolingDown(显示 MM:SS 倒计时,不可点);否则 Normal("去赚取")
- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`cooldown_until!=null` 显示「广告冷却中,3秒后再来」 - 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`cooldown_until!=null` 显示「广告冷却中,3秒后再来」
+10 -4
View File
@@ -5,7 +5,11 @@
> ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。 > ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。
## 入参 ## 入参
无(用户由 token 确定 请求体可省略;用户由 token 确定。
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 |
## 出参 ## 出参
响应 `200`:`TestGrantOut` 响应 `200`:`TestGrantOut`
@@ -13,15 +17,17 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `granted` | bool | 本次是否真发了金币(达每日上限则 false) | | `granted` | bool | 本次是否真发了金币(达每日上限则 false) |
| `status` | string | `granted` / `capped`(达上限) | | `status` | string | `granted` / `capped`(达上限) / `not_signed` / `already_boosted` / `last_day` / `unknown_scene` |
| `coin` | int | 本次发放金币(capped 时 0 | | `coin` | int | 本次发放金币(capped 时 0 |
| `used_today` | int | 今日已成功发奖次数 | | `used_today` | int | 今日已成功发奖次数 |
| `daily_limit` | int | 每日发奖次数上限 | | `daily_limit` | int | 每日发奖次数上限 |
| `remaining` | int | 今日剩余可领次数 | | `remaining` | int | 今日剩余可领次数 |
| `coin_per_ad` | int | 看完一个激励视频发的金币 | | `coin_per_ad` | int | 历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0 |
## 错误码 ## 错误码
- `404` 开关未开(伪装不存在) / 用户不存在 - `404` 开关未开(伪装不存在) / 用户不存在
## 说明 ## 说明
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限)。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启** 没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)
`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
+9 -1
View File
@@ -31,6 +31,14 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `granted_total` | int | 累计发放金币(coin_transaction 中所有 `amount > 0` 之和;负数兑换/扣减不计) | | `granted_total` | int | 累计发放金币(coin_transaction 中所有 `amount > 0` 之和;负数兑换/扣减不计) |
| `reward_video_coin_total` | int | 普通激励视频累计发放金币(`biz_type=reward_video/ad_reward`) |
| `reward_video_watch_count` | int | 普通激励视频有效发奖视频数(`ad_reward_record.reward_scene=reward_video,status=granted`) |
| `feed_ad_coin_total` | int | 信息流广告累计发放金币(`biz_type=feed_ad_reward`) |
| `feed_ad_watch_count` | int | 信息流广告有效完成视频数(`ad_feed_reward_record.status=granted`) |
| `signin_coin_total` | int | 签到累计发放金币(`biz_type=signin`) |
| `signin_count` | int | 签到次数(`signin_record`) |
| `signin_boost_coin_total` | int | 签到膨胀累计发放金币(`biz_type=signin_boost`) |
| `signin_boost_watch_count` | int | 签到膨胀有效视频数(`signin_boost_record`) |
**DashboardCash** **DashboardCash**
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
@@ -64,6 +72,6 @@
## 说明 ## 说明
- 全部为全局只读聚合(count / sum / DAU / 成功率),不改任何数据。 - 全部为全局只读聚合(count / sum / DAU / 成功率),不改任何数据。
- `new_today` / `dau` 按**北京时区**(UTC+8)切天,其余金额/计数无时区概念。 - `new_today` / `dau` 按**北京时区**(UTC+8)切天,其余金额/计数无时区概念。
- 金额单位:`*_cents` 为分;金币(`granted_total`)为个数。 - 金额单位:`*_cents` 为分;金币(`*_coin_total`/`granted_total`)为个数。
- CPS 收入数据源未接入(referral-link 只换链接,转化/佣金未回收),`cps` 恒为 `{available:false, note:...}`,前端显示"待接入"。 - CPS 收入数据源未接入(referral-link 只换链接,转化/佣金未回收),`cps` 恒为 `{available:false, note:...}`,前端显示"待接入"。
- 关联表:[user](../database/user.md) / [coin_transaction](../database/coin_transaction.md) / [withdraw_order](../database/withdraw_order.md) / [comparison_record](../database/comparison_record.md) / [feedback](../database/feedback.md)。 - 关联表:[user](../database/user.md) / [coin_transaction](../database/coin_transaction.md) / [withdraw_order](../database/withdraw_order.md) / [comparison_record](../database/comparison_record.md) / [feedback](../database/feedback.md)。
+7 -6
View File
@@ -1,6 +1,6 @@
# POST /api/v1/signin/boost — 签到后看广告膨胀金币 # POST /api/v1/signin/boost — 签到后看广告膨胀金币
用户当天已签到后,看完一条激励视频,补发一笔等额签到金币 用户 Day1-Day13 当天已签到后,看完一条激励视频,由穿山甲 S2S 回调固定补发 2000 金币。本接口只用于 S2S 发奖后的确认
## 鉴权 ## 鉴权
@@ -10,7 +10,7 @@
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---| |---|---|---:|---|
| `ad_ref_id` | string\|null | | 广告会话/交易号。当前开发期可空,后续接 S2S 后可回填 | | `ad_ref_id` | string | | 穿山甲 S2S 回调的 `trans_id`。回调需先以 `extra.reward_scene=signin_boost` 完成发奖 |
## 响应 ## 响应
@@ -23,10 +23,11 @@
## 错误 ## 错误
- `401`: 未登录 - `401`: 未登录
- `409`: 当天未签到,或当天已经膨胀过 - `409`: 缺少/无效广告回调记录,非本人广告,回调未发奖,当天未签到,Day14,或当天已经膨胀过
## 数据写入 ## 数据写入
- `signin_boost_record` 新增一行,用 `(user_id, signin_date)` 唯一约束防重复 - 本接口不直接发奖;实际写入发生在 `/ad/pangle-callback``reward_scene=signin_boost` 分支
- `coin_account` 增加余额 - 回调写 `signin_boost_record` 新增一行,用 `(user_id, signin_date)` 唯一约束防重复
- `coin_transaction` 写入 `biz_type=signin_boost` - 回调使 `coin_account` 增加固定 `2000` 金币
- 回调写入 `coin_transaction.biz_type=signin_boost`
+3 -3
View File
@@ -64,7 +64,7 @@
| 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` ) + `cash_transaction`(C `exchange_in` +) | 同事务 | | 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` ) + `cash_transaction`(C `exchange_in` +) | 同事务 |
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` ) | 同事务,**不打款** | | 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` ) | 同事务,**不打款** |
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | | | 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | |
| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `ad_reward`) | `trans_id` 幂等 | | 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 |
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | | | 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | | | 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
| 信息流广告结算 `POST /ad/feed-reward` | `ad_feed_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `feed_ad_reward`) | `client_event_id` 幂等 | | 信息流广告结算 `POST /ad/feed-reward` | `ad_feed_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `feed_ad_reward`) | `client_event_id` 幂等 |
@@ -105,7 +105,7 @@
| `signin` | 当天日期串(= `signin_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | | `signin` | 当天日期串(= `signin_record.signin_date` 的 ISO `YYYY-MM-DD`) | + |
| `signin_boost` | 当天日期串(= `signin_boost_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | | `signin_boost` | 当天日期串(= `signin_boost_record.signin_date` 的 ISO `YYYY-MM-DD`) | + |
| `task_<key>` | `user_task.task_key` | + | | `task_<key>` | `user_task.task_key` | + |
| `ad_reward` | `ad_reward_record.trans_id` | + | | `reward_video` / `ad_reward`(历史) | `ad_reward_record.trans_id` | + |
| `feed_ad_reward` | `ad_feed_reward_record.client_event_id` | + | | `feed_ad_reward` | `ad_feed_reward_record.client_event_id` | + |
| `exchange_out` | null(兑现金,无单据) | | | `exchange_out` | null(兑现金,无单据) | |
| `admin_grant` / `admin_deduct` | null(原因记在 `remark`=`admin:<reason>`) | + / | | `admin_grant` / `admin_deduct` | null(原因记在 `remark`=`admin:<reason>`) | + / |
@@ -118,7 +118,7 @@
| `exchange_in` | null | + | | `exchange_in` | null | + |
- **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 - **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
- **广告流互不关联**:`ad_reward_record` / `ad_watch_log` / `ad_ecpm_record` / `ad_feed_reward_record` 之间**无公共键**,各自只按 `(user_id, 日期串)` 聚合。别试图 join 它们逐条对应 - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count` - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count`
### ER 关系(文字版) ### ER 关系(文字版)
+6 -5
View File
@@ -2,7 +2,7 @@
> 模型 `app/models/ad_ecpm.py` · 仓库 `app/repositories/ad_ecpm.py` · 接口 [ad-ecpm-report](../api/ad-ecpm-report.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) > 模型 `app/models/ad_ecpm.py` · 仓库 `app/repositories/ad_ecpm.py` · 接口 [ad-ecpm-report](../api/ad-ecpm-report.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。看广告三条数据流之一(**收益对账**),与 `ad_reward`(发奖,有 trans_id 无 ecpm)、`ad_watch_log`(时长)并列,**无公共键**——只用于**按用户/按天聚合**估算内部广告收益,不做"这条发奖 = 这条展示"的逐条精确关联。穿山甲后台报表才是结算权威,本表是细粒度补充 每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM。`ad_session_id` 可与 S2S 回调 `extra.ad_session_id` 对齐:当普通激励视频回调没有 `ecpm` 时,后端会用它匹配本表作为发奖 eCPM 依据
## 用在哪 / 增删改查 ## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。 - **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。
@@ -15,18 +15,19 @@
| `id` | Integer | PK, autoincrement | | | `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 | | `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 |
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 |
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | | `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) |
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | | `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(单位待确认,原样存) | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**;后端按“元/千次展示”参与金币公式 |
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 关系 / Join Key ## 关系 / Join Key
- `user_id``user.id`(多对一)。 - `user_id``user.id`(多对一)。
- `ad_reward_record` / `ad_watch_log` **无公共键**(独立数据流),只按 `(user_id, report_date)` 聚合 - `ad_session_id` → 可关联 `ad_reward_record.ad_session_id`
## 索引与约束 ## 索引与约束
- PK `id`;index `user_id``report_date``created_at` - PK `id`;index `user_id``report_date``created_at``ad_session_id`;UNIQUE `ad_session_id`
## 注意 ## 注意
- ⚠️ `ecpm_raw` 单位(分/元)截至 2026-05-31 未最终确认;确认后再加一列解析好的数值,在此之前对账按"待定单位"处理 - S2S 回调自带 `ecpm` 优先;缺失时才使用同 `ad_session_id` 的客户端上报值。两者都没有时普通激励视频不发币,`ad_reward_record.status=ecpm_missing`
+1
View File
@@ -8,6 +8,7 @@
|---|---|---|---| |---|---|---|---|
| `id` | Integer | PK | 自增主键 | | `id` | Integer | PK | 自增主键 |
| `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id | | `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id |
| `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` |
| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 | | `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 |
+13 -9
View File
@@ -2,10 +2,10 @@
> 模型 `app/models/ad_reward.py` · 仓库 `app/repositories/ad_reward.py` · 接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) / [ad-test-grant](../api/ad-test-grant.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) > 模型 `app/models/ad_reward.py` · 仓库 `app/repositories/ad_reward.py` · 接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) / [ad-test-grant](../api/ad-test-grant.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每条 = 穿山甲一次**服务端发奖回调**(用户看完激励视频)。是看广告三条数据流之一(**发奖**;另两条:`ad_watch_log` 时长、`ad_ecpm_record` 收益,三者无公共键)`trans_id` 唯一做幂等键(穿山甲会重试,同号只一次)。`reward_date`(北京时间日期串)给"每日上限"计数用。 每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。
## 用在哪 / 增删改查 ## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward`)或 `POST /ad/test-grant`(本地联调)。三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'``coin=0`、不发币。旧的观看总时长闸仅在 `DAILY_AD_WATCH_SECONDS_LIMIT > 0` 时启用,当前默认停用。否则 `granted` + `grant_coins(biz_type='ad_reward', ref_id=trans_id)` 加币,同事务 - **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'``coin=0`、不发币。否则按 eCPM 公式发币
- **U / D**:无。 - **U / D**:无。
- **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 - **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。
@@ -13,10 +13,13 @@
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---| |---|---|---|---|
| `id` | Integer | PK, autoincrement | | | `id` | Integer | PK, autoincrement | |
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=ad_reward) | | `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等) |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) |
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped` 时为 0 | | `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 |
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限,记录但不发) | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` |
| `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 |
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/业务不满足时为 0 |
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `not_signed`/`already_boosted`/`last_day` |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
@@ -24,12 +27,13 @@
## 关系 / Join Key ## 关系 / Join Key
- `user_id``user.id`(多对一)。 - `user_id``user.id`(多对一)。
- `trans_id` ← 被 `coin_transaction.ref_id` 引用(`granted` 那条发币流水);`capped` 行不发币、无对应流水。 - `trans_id` ← 被 `coin_transaction.ref_id` 引用(`granted` 那条发币流水);未发币状态无对应流水。
- `ad_watch_log` / `ad_ecpm_record` **无公共键**;当前不逐条关联 eCPM 和发奖回调 - `ad_session_id` → 可关联 `ad_ecpm_record.ad_session_id`
## 索引与约束 ## 索引与约束
- PK `id`;UNIQUE+index `trans_id`;index `user_id``reward_date``created_at` - PK `id`;UNIQUE+index `trans_id`;index `user_id``reward_date``created_at``ad_session_id`
## 注意 ## 注意
- 当前公网回调尚未接入前,激励视频精确 eCPM 绑定发奖先搁置;实发金币仍以穿山甲回调 `reward_amount` 为准(`resolve_ad_reward_coin` 解析,缺/坏回退默认、超上限夹紧),单次金币/每日上限/单次上限均从 `app_config` 读(运营后台可改) - 普通激励视频 eCPM 公式发奖;若 S2S 与客户端会话上报都缺 eCPM,记录 `status='ecpm_missing'``coin=0`,不发币
- 签到膨胀复用本表记录 S2S 幂等,实发固定 `2000` 金币由 `signin_boost_record`/`coin_transaction.biz_type=signin_boost` 承载。
- 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。 - 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。
+3 -3
View File
@@ -12,7 +12,7 @@
| 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) | | 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) |
| 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) | | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) |
| 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | `user_task.task_key` | | 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | `user_task.task_key` |
| 看广告发奖回调 `POST /ad/pangle-callback` | `ad_reward` | + | `ad_reward_record.trans_id` | | 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`ad_reward`) | + | `ad_reward_record.trans_id` |
| 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` | | 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` |
| 金币兑现金 `POST /wallet/exchange` | `exchange_out` | | null(配套 `cash_transaction.exchange_in`) | | 金币兑现金 `POST /wallet/exchange` | `exchange_out` | | null(配套 `cash_transaction.exchange_in`) |
| admin 手动加金币 | `admin_grant` | + | null(`remark`=`admin:<reason>`) | | admin 手动加金币 | `admin_grant` | + | null(`remark`=`admin:<reason>`) |
@@ -29,14 +29,14 @@
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `amount` | Integer | NOT NULL | 本笔变动金币;**正=入账(赚),负=出账(花/兑换)** | | `amount` | Integer | NOT NULL | 本笔变动金币;**正=入账(赚),负=出账(花/兑换)** |
| `balance_after` | Integer | NOT NULL | 本笔后金币余额(= 当时 `coin_account.coin_balance`,对账用) | | `balance_after` | Integer | NOT NULL | 本笔后金币余额(= 当时 `coin_account.coin_balance`,对账用) |
| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `signin_boost` / `task_<key>` / `ad_reward` / `feed_ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 | | `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `signin_boost` / `task_<key>` / `reward_video` / `ad_reward`(历史) / `feed_ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 |
| `ref_id` | String(64) | nullable | **关联业务键,指向随 `biz_type` 变(见上表)**;无关联时为 null | | `ref_id` | String(64) | nullable | **关联业务键,指向随 `biz_type` 变(见上表)**;无关联时为 null |
| `remark` | String(128) | nullable | 备注(如签到「每日签到 第N天」、admin「admin:<reason>」) | | `remark` | String(128) | nullable | 备注(如签到「每日签到 第N天」、admin「admin:<reason>」) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 |
## 关系 / Join Key ## 关系 / Join Key
- `user_id``user.id`(多对一)。 - `user_id``user.id`(多对一)。
- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`签到日 / `task_<key>``user_task.task_key` / `ad_reward``ad_reward_record.trans_id` / `feed_ad_reward``ad_feed_reward_record.client_event_id` / 其余 null。 - `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost``ad_reward_record.trans_id` / `task_<key>``user_task.task_key` / `reward_video`/`ad_reward``ad_reward_record.trans_id` / `feed_ad_reward``ad_feed_reward_record.client_event_id` / 其余 null。
## 索引与约束 ## 索引与约束
- PK `id`;index `user_id``created_at` - PK `id`;index `user_id``created_at`
+4 -4
View File
@@ -1,6 +1,6 @@
# signin_boost_record — 签到膨胀记录 # signin_boost_record — 签到膨胀记录
App 用户当天签到后,看完激励视频可把签到奖励膨胀一次。本表记录膨胀动作,并用唯一约束防重复补发。 App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 金币一次。Day14 不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。
## 字段 ## 字段
@@ -9,8 +9,8 @@ App 用户当天签到后,看完激励视频可把签到奖励膨胀一次。
| `id` | Integer | PK | 自增主键 | | `id` | Integer | PK | 自增主键 |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | | `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 |
| `coin_awarded` | Integer | NOT NULL | 本次补发金币 | | `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 2000 |
| `ad_ref_id` | String(64) | nullable | 广告会话/交易号,开发期可空 | | `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` |
| `created_at` | DateTime | NOT NULL | 创建时间 | | `created_at` | DateTime | NOT NULL | 创建时间 |
## 约束 ## 约束
@@ -19,4 +19,4 @@ App 用户当天签到后,看完激励视频可把签到奖励膨胀一次。
## 关联 ## 关联
- 膨胀成功时写 `coin_transaction.biz_type=signin_boost``ref_id=signin_date` - 膨胀成功时写 `coin_transaction.biz_type=signin_boost``ref_id=ad_ref_id`
+1 -1
View File
@@ -9,7 +9,7 @@
**`sign = SHA256("{m-key}:{trans_id}")` 的十六进制串。** **`sign = SHA256("{m-key}:{trans_id}")` 的十六进制串。**
- 是**普通 SHA256**,不是 HMAC,也不是 RSA。 - 是**普通 SHA256**,不是 HMAC,也不是 RSA。
- **只对 `m-key:trans_id` 这一个字符串**签名,`user_id` / `reward_amount` 等其余参数**不参与**签名。它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 连合法 sign 都造不出,自然无法注入假 `user_id` - **只对 `m-key:trans_id` 这一个字符串**签名,`user_id` / `ecpm` / `extra` 等其余参数**不参与**签名。它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 连合法 sign 都造不出,自然无法注入假 `user_id`
- 这是穿山甲 **GroMore「广告位 → 服务端激励回调」官方规范**supportcenter/26240)。我们客户端是 `useMediation(true)` 融合,回调走 **GroMore 广告位层级**,不是联盟代码位层级。 - 这是穿山甲 **GroMore「广告位 → 服务端激励回调」官方规范**supportcenter/26240)。我们客户端是 `useMediation(true)` 融合,回调走 **GroMore 广告位层级**,不是联盟代码位层级。
## 函数 ## 函数
+19 -28
View File
@@ -10,9 +10,7 @@ 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 ( from app.core.rewards import (
AD_REWARD_COIN,
DAILY_AD_REWARD_LIMIT, DAILY_AD_REWARD_LIMIT,
MAX_AD_REWARD_COIN,
VIDEO_ROUND_COOLDOWN_SECONDS, VIDEO_ROUND_COOLDOWN_SECONDS,
VIDEO_ROUND_REQUIRED_COUNT, VIDEO_ROUND_REQUIRED_COUNT,
calculate_ad_reward_coin, calculate_ad_reward_coin,
@@ -57,41 +55,34 @@ def _coin_balance(client, token: str) -> int:
def test_callback_grants_coins(client) -> None: def test_callback_grants_coins(client) -> None:
"""验签通过的回调(无 reward_amount → 回退 AD_REWARD_COIN)→ 发金币到账 + 计数 +1。""" """验签通过且带 eCPM → 按数值公式发金币到账 + 计数 +1。"""
phone = "13800003001" phone = "13800003001"
token = _login(client, phone) token = _login(client, phone)
uid = _user_id(phone) uid = _user_id(phone)
r = _callback(client, _signed(uid, "trans_a1", reward_name="金币")) r = _callback(client, _signed(uid, "trans_a1", reward_name="金币", ecpm="200"))
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json() == {"is_verify": True, "reason": 0} assert r.json() == {"is_verify": True, "reason": 0}
assert _coin_balance(client, token) == AD_REWARD_COIN assert _coin_balance(client, token) == calculate_ad_reward_coin("200", 1)
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 1 assert st["used_today"] == 1
assert st["daily_limit"] == DAILY_AD_REWARD_LIMIT assert st["daily_limit"] == DAILY_AD_REWARD_LIMIT
assert st["remaining"] == DAILY_AD_REWARD_LIMIT - 1 assert st["remaining"] == DAILY_AD_REWARD_LIMIT - 1
assert st["coin_per_ad"] == AD_REWARD_COIN assert st["coin_per_ad"] == 0
def test_callback_uses_reward_amount(client) -> None: def test_callback_without_ecpm_records_exception(client) -> None:
"""带合法 reward_amount按它发金币(而非常量);异常值回退/夹紧""" """S2S 和客户端会话都没有 eCPM发金币,只记录异常状态"""
phone = "13800003011" phone = "13800003011"
token = _login(client, phone) token = _login(client, phone)
uid = _user_id(phone) uid = _user_id(phone)
# 合法值:发 250 assert _callback(client, _signed(uid, "ecpm_missing")).status_code == 200
assert _callback(client, _signed(uid, "ra_ok", reward_amount="250")).status_code == 200 assert _coin_balance(client, token) == 0
assert _coin_balance(client, token) == 250 st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 0
# ≤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: def test_callback_idempotent(client) -> None:
@@ -100,11 +91,11 @@ def test_callback_idempotent(client) -> None:
token = _login(client, phone) token = _login(client, phone)
uid = _user_id(phone) uid = _user_id(phone)
p = _signed(uid, "trans_dup") p = _signed(uid, "trans_dup", ecpm="200")
assert _callback(client, p).status_code == 200 assert _callback(client, p).status_code == 200
assert _callback(client, p).status_code == 200 # 重试 assert _callback(client, p).status_code == 200 # 重试
assert _coin_balance(client, token) == AD_REWARD_COIN # 没翻倍 assert _coin_balance(client, token) == calculate_ad_reward_coin("200", 1) # 没翻倍
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 1 assert st["used_today"] == 1
@@ -134,15 +125,15 @@ def test_daily_count_cap(client, monkeypatch) -> None:
uid = _user_id(phone) uid = _user_id(phone)
for i in range(3): for i in range(3):
r = _callback(client, _signed(uid, f"trans_cap_{i}")) r = _callback(client, _signed(uid, f"trans_cap_{i}", ecpm="200"))
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
# 第 4 次:capped,仍 is_verify(不让穿山甲重试),但不加币 # 第 4 次:capped,仍 is_verify(不让穿山甲重试),但不加币
r = _callback(client, _signed(uid, "trans_cap_over")) r = _callback(client, _signed(uid, "trans_cap_over", ecpm="200"))
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"is_verify": True, "reason": 0} assert r.json() == {"is_verify": True, "reason": 0}
assert _coin_balance(client, token) == 3 * AD_REWARD_COIN assert _coin_balance(client, token) == sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 3 assert st["used_today"] == 3
assert st["remaining"] == 0 assert st["remaining"] == 0
@@ -169,7 +160,7 @@ def test_daily_watch_time_cap(client, monkeypatch) -> None:
# 此时回调发奖 → 时长闸命中 → capped(is_verify 仍 true,不让重试),不加金币 # 此时回调发奖 → 时长闸命中 → capped(is_verify 仍 true,不让重试),不加金币
before = _coin_balance(client, token) before = _coin_balance(client, token)
r = _callback(client, _signed(uid, "trans_time_cap")) r = _callback(client, _signed(uid, "trans_time_cap", ecpm="200"))
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"is_verify": True, "reason": 0} assert r.json() == {"is_verify": True, "reason": 0}
assert _coin_balance(client, token) == before # 未加币 assert _coin_balance(client, token) == before # 未加币
@@ -209,7 +200,7 @@ def test_reward_status_mid_round(client) -> None:
phone = "13800003102" phone = "13800003102"
token = _login(client, phone) token = _login(client, phone)
uid = _user_id(phone) uid = _user_id(phone)
_callback(client, _signed(uid, "trans_mid_1")) _callback(client, _signed(uid, "trans_mid_1", ecpm="200"))
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 1 assert st["used_today"] == 1
assert st["round_count"] == 0 assert st["round_count"] == 0
@@ -224,7 +215,7 @@ def test_reward_status_round_complete_enters_cooldown(client) -> None:
token = _login(client, phone) token = _login(client, phone)
uid = _user_id(phone) uid = _user_id(phone)
for i in range(VIDEO_ROUND_REQUIRED_COUNT): for i in range(VIDEO_ROUND_REQUIRED_COUNT):
_callback(client, _signed(uid, f"trans_rc_{i}")) _callback(client, _signed(uid, f"trans_rc_{i}", ecpm="200"))
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json() st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT
@@ -248,7 +239,7 @@ def test_reward_status_cooldown_expired(client) -> None:
token = _login(client, phone) token = _login(client, phone)
uid = _user_id(phone) uid = _user_id(phone)
for i in range(VIDEO_ROUND_REQUIRED_COUNT): for i in range(VIDEO_ROUND_REQUIRED_COUNT):
_callback(client, _signed(uid, f"trans_exp_{i}")) _callback(client, _signed(uid, f"trans_exp_{i}", ecpm="200"))
# 把当日所有 granted 记录的 created_at 推到 11 分钟前(覆盖冷却末尾那条) # 把当日所有 granted 记录的 created_at 推到 11 分钟前(覆盖冷却末尾那条)
db = SessionLocal() db = SessionLocal()
+35 -7
View File
@@ -4,15 +4,20 @@
""" """
from __future__ import annotations from __future__ import annotations
import json
from app.core.config import settings
from app.core.rewards import ( from app.core.rewards import (
COIN_PER_CENT, COIN_PER_CENT,
COIN_PER_YUAN, COIN_PER_YUAN,
MIN_EXCHANGE_COIN, MIN_EXCHANGE_COIN,
SIGNIN_BOOST_COIN,
SIGNIN_REWARDS, SIGNIN_REWARDS,
TASK_ENABLE_NOTIFICATION, TASK_ENABLE_NOTIFICATION,
TASK_REWARDS, TASK_REWARDS,
) )
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.integrations import pangle
from app.repositories import wallet as crud_wallet from app.repositories import wallet as crud_wallet
from app.repositories.user import get_user_by_phone from app.repositories.user import get_user_by_phone
@@ -29,6 +34,12 @@ def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
def _signed_ad_callback(user_id: int, trans_id: str, **params: str) -> dict[str, str]:
body = {"user_id": str(user_id), "trans_id": trans_id, **params}
body["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
return body
def test_account_auto_created_empty(client) -> None: def test_account_auto_created_empty(client) -> None:
"""首次访问账户:自动建空账户,余额全 0。""" """首次访问账户:自动建空账户,余额全 0。"""
token = _login(client, "13800001001") token = _login(client, "13800001001")
@@ -91,8 +102,9 @@ def test_signin_flow(client) -> None:
def test_signin_boost_flow(client) -> None: def test_signin_boost_flow(client) -> None:
"""签到后看广告膨胀 → 补发一笔等额签到金币,每天只能膨胀一次。""" """签到后看广告膨胀 → S2S 固定补发 2000 金币,每天只能膨胀一次。"""
token = _login(client, "13800001011") phone = "13800001011"
token = _login(client, phone)
r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token)) r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token))
assert r.status_code == 409 assert r.status_code == 409
@@ -101,14 +113,30 @@ def test_signin_boost_flow(client) -> None:
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
first_coin = r.json()["coin_awarded"] first_coin = r.json()["coin_awarded"]
r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "ad-session-1"}, headers=_auth(token)) with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
uid = user.id
extra = json.dumps({"reward_scene": "signin_boost", "ad_session_id": "signin-session-1"})
r = client.get(
"/api/v1/ad/pangle-callback",
params=_signed_ad_callback(uid, "signin-boost-trans-1", extra=extra, ecpm="200"),
)
assert r.status_code == 200, r.text
assert r.json() == {"is_verify": True, "reason": 0}
r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "signin-boost-trans-1"}, headers=_auth(token))
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
body = r.json() body = r.json()
assert body["coin_awarded"] == first_coin assert body["coin_awarded"] == SIGNIN_BOOST_COIN
assert body["coin_balance"] == first_coin * 2 assert body["coin_balance"] == first_coin + SIGNIN_BOOST_COIN
r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "ad-session-1"}, headers=_auth(token)) r = client.get(
assert r.status_code == 409 "/api/v1/ad/pangle-callback",
params=_signed_ad_callback(uid, "signin-boost-trans-2", extra=extra, ecpm="200"),
)
assert r.status_code == 200, r.text
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
types = [t["biz_type"] for t in r.json()["items"]] types = [t["biz_type"] for t in r.json()["items"]]