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:
@@ -0,0 +1,76 @@
|
||||
"""coin reward points phase 1 tables
|
||||
|
||||
Revision ID: coin_reward_phase1
|
||||
Revises: pstat3growth
|
||||
Create Date: 2026-06-07 15:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'coin_reward_phase1'
|
||||
down_revision: Union[str, Sequence[str], None] = 'pstat3growth'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'signin_boost_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('signin_date', sa.Date(), nullable=False),
|
||||
sa.Column('coin_awarded', sa.Integer(), nullable=False),
|
||||
sa.Column('ad_ref_id', sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_boost_user_date'),
|
||||
)
|
||||
with op.batch_alter_table('signin_boost_record', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_signin_boost_record_user_id'), ['user_id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'ad_feed_reward_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('client_event_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('reward_date', sa.String(length=10), nullable=False),
|
||||
sa.Column('duration_seconds', sa.Integer(), nullable=False),
|
||||
sa.Column('unit_count', sa.Integer(), nullable=False),
|
||||
sa.Column('ecpm_raw', sa.String(length=32), nullable=False),
|
||||
sa.Column('adn', sa.String(length=32), nullable=True),
|
||||
sa.Column('slot_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('coin', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('client_event_id', name='uq_ad_feed_reward_client_event'),
|
||||
)
|
||||
with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_reward_date'), ['reward_date'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_created_at'), ['created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_created_at'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_reward_date'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_user_id'))
|
||||
op.drop_table('ad_feed_reward_record')
|
||||
|
||||
with op.batch_alter_table('signin_boost_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_signin_boost_record_user_id'))
|
||||
op.drop_table('signin_boost_record')
|
||||
@@ -34,8 +34,8 @@ def _validate(key: str, value: Any) -> None:
|
||||
raise ValueError("需为非空整数列表")
|
||||
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
|
||||
raise ValueError("列表元素需为非负整数")
|
||||
if key == "signin_rewards" and len(value) != 7:
|
||||
raise ValueError("签到档位必须正好 7 个(对应 7 天循环)")
|
||||
if key == "signin_rewards" and len(value) != 14:
|
||||
raise ValueError("签到档位必须正好 14 个(对应 14 天循环)")
|
||||
elif t == "dict_str_int":
|
||||
if not isinstance(value, dict) or not all(
|
||||
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
|
||||
|
||||
+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(),
|
||||
)
|
||||
|
||||
@@ -14,9 +14,9 @@ from app.core import rewards as r
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
|
||||
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"signin_rewards": {
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位",
|
||||
"group": "签到", "type": "int_list",
|
||||
"help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。",
|
||||
"help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14。",
|
||||
},
|
||||
"min_exchange_coin": {
|
||||
"default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币",
|
||||
@@ -46,7 +46,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"ad_daily_limit": {
|
||||
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
|
||||
"group": "看广告", "type": "int",
|
||||
"group": "看广告", "type": "int", "help": "福利页激励视频每日可发奖次数上限,默认 500。",
|
||||
},
|
||||
"ad_max_coin": {
|
||||
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
|
||||
@@ -54,10 +54,10 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"ad_round_count": {
|
||||
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
|
||||
"group": "看广告", "type": "int", "help": "看完一轮触发冷却。",
|
||||
"group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。",
|
||||
},
|
||||
"ad_cooldown_sec": {
|
||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "每轮冷却(秒)",
|
||||
"group": "看广告", "type": "int",
|
||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
||||
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
|
||||
},
|
||||
}
|
||||
|
||||
+74
-19
@@ -17,9 +17,12 @@ def cn_today() -> date:
|
||||
return datetime.now(CN_TZ).date()
|
||||
|
||||
|
||||
# ===== 签到:7 天循环,断签重置回第 1 天 =====
|
||||
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引。
|
||||
SIGNIN_REWARDS: tuple[int, ...] = (10, 20, 30, 50, 80, 120, 200)
|
||||
# ===== 签到:14 天循环,断签重置回第 1 天 =====
|
||||
# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。
|
||||
SIGNIN_REWARDS: tuple[int, ...] = (
|
||||
200, 120, 150, 180, 200, 250, 500,
|
||||
200, 250, 300, 350, 400, 500, 3000,
|
||||
)
|
||||
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
|
||||
|
||||
|
||||
@@ -74,6 +77,64 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
return RECORD_MILESTONES[milestone - 1]
|
||||
|
||||
|
||||
# ===== 看激励视频 / 信息流广告发金币 =====
|
||||
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
|
||||
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
(0.1, 0, 100),
|
||||
(0.3, 101, 200),
|
||||
(0.4, 201, 400),
|
||||
(0.6, 401, None),
|
||||
)
|
||||
AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
(2.0, 1, 1),
|
||||
(1.5, 2, 2),
|
||||
(1.3, 3, 3),
|
||||
(1.1, 4, 10),
|
||||
(1.0, 11, None),
|
||||
)
|
||||
|
||||
|
||||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。"""
|
||||
if ecpm is None:
|
||||
return 0.0
|
||||
try:
|
||||
value = float(str(ecpm).strip())
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return max(0.0, value)
|
||||
|
||||
|
||||
def ad_ecpm_factor(ecpm_yuan: float) -> float:
|
||||
"""eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
|
||||
if ecpm_yuan > 400:
|
||||
return 0.6
|
||||
if ecpm_yuan > 200:
|
||||
return 0.4
|
||||
if ecpm_yuan > 100:
|
||||
return 0.3
|
||||
return 0.1
|
||||
|
||||
|
||||
def ad_lt_factor(today_count_after_this: int) -> float:
|
||||
"""LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励。"""
|
||||
count = max(1, today_count_after_this)
|
||||
for factor, lo, hi in AD_LT_FACTOR_TABLE:
|
||||
if count >= lo and (hi is None or count <= hi):
|
||||
return factor
|
||||
return 1.0
|
||||
|
||||
|
||||
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
|
||||
"""按金币数值体系计算单份广告奖励金币。
|
||||
|
||||
单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。
|
||||
"""
|
||||
ecpm_yuan = parse_ecpm_yuan(ecpm)
|
||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
|
||||
return max(0, round(yuan * COIN_PER_YUAN))
|
||||
|
||||
|
||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||
# 看完一个激励视频发的金币(666 金币 ≈¥0.0666,汇率 10000 金币=1 元)。
|
||||
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
|
||||
@@ -83,25 +144,19 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
AD_REWARD_COIN: int = 666
|
||||
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
||||
MAX_AD_REWARD_COIN: int = 1000
|
||||
# 每用户每日发奖次数上限——现作为"看广告时长上限"的**次数兜底**(防前端少报观看时长绕过时长闸)。
|
||||
# 主闸是 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟);次数兜底要 ≥ 时长闸内的"合理最大次数",
|
||||
# 否则会比主闸先掐、误伤看大量短广告的正常用户:50 分钟里若广告各 ~15s,理论可看 ~200 次,
|
||||
# 故取 200(≈时长闸的次数等价),正常用户先撞 50 分钟时长闸,仅前端时长全报 0 等异常时本闸才兜住。
|
||||
# 不要为"更严"而下调到 120 之类(会在 ~30 分钟就掐短广告用户);要更严应在客户端如实上报时长。
|
||||
DAILY_AD_REWARD_LIMIT: int = 200
|
||||
# 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。
|
||||
DAILY_AD_REWARD_LIMIT: int = 500
|
||||
|
||||
# ===== 看激励视频每日总时长上限(防刷主闸)=====
|
||||
# 用户每天最多看 50 分钟激励视频,超了不发奖 / 不给看。时长由前端 onAdClose 上报真实观看
|
||||
# 秒数累计(POST /api/v1/ad/watch-report → ad_watch_log 表 SUM)。前端不可信,故配合上面
|
||||
# DAILY_AD_REWARD_LIMIT 次数兜底一起防刷。要调上限直接改这里。
|
||||
DAILY_AD_WATCH_SECONDS_LIMIT: int = 50 * 60 # 3000 秒 = 50 分钟
|
||||
# ===== 看激励视频每日总时长上限(已停用)=====
|
||||
# 旧版本用 50 分钟时长闸;当前产品只保留每日 500 次上限。保留字段返回给旧客户端,
|
||||
# 但值为 0,客户端按 limit<=0 视为未启用。
|
||||
DAILY_AD_WATCH_SECONDS_LIMIT: int = 0
|
||||
# 单次上报观看时长的合理上限(夹紧异常值):激励视频一般 15~60 秒,超 120 秒按 120 记,防刷大值。
|
||||
MAX_SINGLE_WATCH_SECONDS: int = 120
|
||||
# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端,
|
||||
# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit,
|
||||
# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。
|
||||
VIDEO_ROUND_REQUIRED_COUNT: int = 3
|
||||
VIDEO_ROUND_COOLDOWN_SECONDS: int = 600
|
||||
# 每次广告关闭后 3 秒内不允许再次点击观看。沿用 round_count/cooldown_until 字段表达:
|
||||
# round_size=1 表示每次成功发奖后都会派生一个 3 秒 cooldown_until。
|
||||
VIDEO_ROUND_REQUIRED_COUNT: int = 1
|
||||
VIDEO_ROUND_COOLDOWN_SECONDS: int = 3
|
||||
|
||||
|
||||
def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
@@ -12,7 +13,7 @@ from app.models.platform_stat import PlatformStatDisplay # noqa: F401
|
||||
from app.models.price_observation import PriceObservation # noqa: F401
|
||||
from app.models.price_report import PriceReport # noqa: F401
|
||||
from app.models.savings import SavingsRecord # noqa: F401
|
||||
from app.models.signin import SigninRecord # noqa: F401
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401
|
||||
from app.models.task import UserTask # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.wallet import ( # noqa: F401
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""信息流广告奖励记录。
|
||||
|
||||
点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AdFeedRewardRecord(Base):
|
||||
__tablename__ = "ad_feed_reward_record"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_event_id", name="uq_ad_feed_reward_client_event"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
client_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
duration_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
unit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<AdFeedRewardRecord user_id={self.user_id} event={self.client_event_id} "
|
||||
f"{self.status} coin={self.coin}>"
|
||||
)
|
||||
@@ -1,15 +1,14 @@
|
||||
"""看激励视频观看时长记录(每日总时长防刷)。
|
||||
"""看激励视频观看时长记录(旧版每日总时长字段)。
|
||||
|
||||
每条 = 客户端 onAdClose 上报的一次激励视频实际观看秒数。按 (user_id, watch_date) 聚合
|
||||
SUM(watch_seconds) 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(超了不再发奖 /
|
||||
不给看,见 core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT)。
|
||||
SUM(watch_seconds) 得当日总观看时长。当前产品只保留每日 500 次上限,
|
||||
core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸;本表保留用于旧客户端兼容和排查。
|
||||
|
||||
这是看广告的第三条数据流,与另两条并列、互不关联:
|
||||
- ad_reward(AdRewardRecord):穿山甲 S2S 回调发金币(后端,有 trans_id);
|
||||
- ad_ecpm(AdEcpmRecord):客户端上报展示收益(前端,有 ecpm);
|
||||
- ad_watch_log(本表):客户端上报观看时长(前端,有 watch_seconds)。
|
||||
前端上报的时长不可信 → 时长是防刷"主闸",配合 ad_reward 的每日次数上限(DAILY_AD_REWARD_LIMIT)
|
||||
做"次数兜底",前端少报时长也刷不过次数闸。
|
||||
前端上报的时长不可信,不能作为唯一发奖依据;当前发奖闸口以 ad_reward 的每日次数上限为准。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
+30
-2
@@ -1,14 +1,14 @@
|
||||
"""签到记录表。
|
||||
|
||||
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
|
||||
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。
|
||||
- cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。
|
||||
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -39,3 +39,31 @@ class SigninRecord(Base):
|
||||
f"<SigninRecord user_id={self.user_id} date={self.signin_date} "
|
||||
f"day={self.cycle_day} streak={self.streak}>"
|
||||
)
|
||||
|
||||
|
||||
class SigninBoostRecord(Base):
|
||||
"""签到后看广告膨胀记录。
|
||||
|
||||
一天最多膨胀一次,补发金额等于当天签到原始奖励。独立表用于防并发重复补发,
|
||||
后续接入真实 S2S 广告 session 时可把 ad_ref_id 回填为广告会话/交易号。
|
||||
"""
|
||||
|
||||
__tablename__ = "signin_boost_record"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
signin_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
ad_ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<SigninBoostRecord user_id={self.user_id} date={self.signin_date} coin={self.coin_awarded}>"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""信息流广告奖励 CRUD。
|
||||
|
||||
点位 2:每展示满 10 秒累计一份奖励,视频完成后一次性入账。当前一期由客户端在
|
||||
完成回调后上报;后续若 SDK/S2S 能提供更强确认信号,可继续复用本表的幂等键。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.rewards import cn_today
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
FEED_REWARD_UNIT_SECONDS = 10
|
||||
|
||||
|
||||
def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None:
|
||||
return db.execute(
|
||||
select(AdFeedRewardRecord).where(
|
||||
AdFeedRewardRecord.client_event_id == client_event_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
return db.execute(
|
||||
select(func.count())
|
||||
.select_from(AdFeedRewardRecord)
|
||||
.where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.reward_date == reward_date,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int:
|
||||
"""按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号。"""
|
||||
if unit_count <= 0:
|
||||
return 0
|
||||
existing_units = db.execute(
|
||||
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
|
||||
.where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.reward_date == today,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
)
|
||||
).scalar_one()
|
||||
total = 0
|
||||
for offset in range(1, unit_count + 1):
|
||||
total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset)
|
||||
return total
|
||||
|
||||
|
||||
def grant_feed_reward(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
client_event_id: str,
|
||||
ecpm: str,
|
||||
duration_seconds: int,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
) -> AdFeedRewardRecord:
|
||||
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。"""
|
||||
existing = _find_by_event(db, client_event_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
today = cn_today().isoformat()
|
||||
safe_duration = max(0, min(duration_seconds, 24 * 60 * 60))
|
||||
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
coin=0,
|
||||
status="capped",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
coin = _unit_reward_total(db, user_id, ecpm, unit_count, today)
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark=f"信息流广告奖励 {unit_count}份",
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
coin=coin,
|
||||
status="granted",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
|
||||
def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord:
|
||||
db.add(rec)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
existing = _find_by_event(db, client_event_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
@@ -72,10 +72,12 @@ def grant_ad_reward(
|
||||
|
||||
today = cn_today().isoformat()
|
||||
|
||||
# #3 每日上限:观看时长(主闸,50 分钟) 或 发奖次数(兜底) 任一到顶 → 记 capped 不发金币,
|
||||
# 让审计能看到"今天到顶了"。时长由前端 watch-report 累计(防刷主闸),次数防前端少报时长
|
||||
# 绕过;次数上限走 app_config(运营后台可改,默认 rewards.DAILY_AD_REWARD_LIMIT)。
|
||||
over_time = watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
|
||||
# #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(
|
||||
@@ -137,8 +139,8 @@ def today_status(
|
||||
返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC),
|
||||
今日已观看总秒数, 每日观看总时长上限(秒))。
|
||||
次数上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);次数维度的"本轮已看
|
||||
几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);时长维度(50 分钟
|
||||
主闸)查 ad_watch_log 当日累计——前端据此展示"今日已看 X/50 分钟"+ 满则不给看。
|
||||
几次 + 冷却到几点"委托 [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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""看激励视频观看时长 CRUD(每日总时长防刷)。
|
||||
"""看激励视频观看时长 CRUD(旧版每日总时长字段)。
|
||||
|
||||
前端 onAdClose 上报本次观看秒数 → add_watch_seconds 落库 + 返回当日累计;
|
||||
watched_seconds_today 给"时长闸"(发奖 / 展示前)判断当天是否已达 50 分钟上限。
|
||||
当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 时不再用本累计做发奖拦截。
|
||||
鉴权接口已确保 user 存在(JWT),故不做 UnknownUser 校验。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""签到 CRUD:状态查询 + 执行签到。
|
||||
|
||||
7 天循环规则:
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续)
|
||||
14 天循环规则:
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续)
|
||||
- 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置)
|
||||
今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。
|
||||
"""
|
||||
@@ -11,11 +11,12 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today
|
||||
from app.models.signin import SigninRecord
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
@@ -23,9 +24,17 @@ class AlreadySignedError(Exception):
|
||||
"""今天已经签过了。"""
|
||||
|
||||
|
||||
class NotSignedTodayError(Exception):
|
||||
"""今天尚未签到,不能膨胀。"""
|
||||
|
||||
|
||||
class AlreadyBoostedError(Exception):
|
||||
"""今天签到奖励已经膨胀过。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SigninStep:
|
||||
day: int # 1..7
|
||||
day: int # 1..14
|
||||
coin: int
|
||||
status: str # claimed / today / locked
|
||||
|
||||
@@ -34,7 +43,7 @@ class SigninStep:
|
||||
class SigninStatus:
|
||||
today_signed: bool
|
||||
consecutive_days: int # 当前已确认的连续签到天数
|
||||
today_cycle_day: int # 今天落在循环的第几档(1..7)
|
||||
today_cycle_day: int # 今天落在循环的第几档(1..14)
|
||||
today_coin: int # 今天这一档的金币
|
||||
can_claim: bool # 现在能否签到(= not today_signed)
|
||||
steps: list[SigninStep]
|
||||
@@ -51,7 +60,7 @@ def _latest_record(db: Session, user_id: int) -> SigninRecord | None:
|
||||
|
||||
|
||||
def _next_cycle_day(last: SigninRecord | None, today) -> int:
|
||||
"""若现在签到,今天会落在第几档(1..7)。"""
|
||||
"""若现在签到,今天会落在第几档(1..14)。"""
|
||||
if last is not None and last.signin_date == today - timedelta(days=1):
|
||||
return last.cycle_day % SIGNIN_CYCLE_LEN + 1
|
||||
return 1
|
||||
@@ -127,3 +136,52 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record, acc.coin_balance
|
||||
|
||||
|
||||
def _today_record(db: Session, user_id: int) -> SigninRecord | None:
|
||||
today = cn_today()
|
||||
return db.execute(
|
||||
select(SigninRecord).where(
|
||||
SigninRecord.user_id == user_id,
|
||||
SigninRecord.signin_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def boost_today_signin(
|
||||
db: Session, user_id: int, *, ad_ref_id: str | None = None
|
||||
) -> tuple[SigninBoostRecord, int]:
|
||||
"""签到后看广告膨胀:补发一笔等额签到金币。返回 (膨胀记录, 补发后余额)。"""
|
||||
record = _today_record(db, user_id)
|
||||
if record is None:
|
||||
raise NotSignedTodayError
|
||||
|
||||
today = record.signin_date
|
||||
existing = db.execute(
|
||||
select(SigninBoostRecord).where(
|
||||
SigninBoostRecord.user_id == user_id,
|
||||
SigninBoostRecord.signin_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise AlreadyBoostedError
|
||||
|
||||
boost = SigninBoostRecord(
|
||||
user_id=user_id,
|
||||
signin_date=today,
|
||||
coin_awarded=record.coin_awarded,
|
||||
ad_ref_id=ad_ref_id,
|
||||
)
|
||||
db.add(boost)
|
||||
try:
|
||||
acc, _ = crud_wallet.grant_coins(
|
||||
db, user_id, record.coin_awarded,
|
||||
biz_type="signin_boost", ref_id=today.isoformat(),
|
||||
remark=f"签到膨胀 第{record.cycle_day}天",
|
||||
)
|
||||
db.commit()
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise AlreadyBoostedError from e
|
||||
db.refresh(boost)
|
||||
return boost, acc.coin_balance
|
||||
|
||||
+26
-5
@@ -30,17 +30,17 @@ class AdRewardStatusOut(BaseModel):
|
||||
remaining: int = Field(..., description="今日剩余可领次数")
|
||||
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
||||
round_count: int = Field(
|
||||
..., description="本轮(3 次一组)已看次数,0..N-1。客户端可由它判断'刚刚看完一轮'"
|
||||
..., description="本轮已看次数,当前 round_size=1,客户端可由 cooldown_until 判断短冷却"
|
||||
)
|
||||
cooldown_until: datetime | None = Field(
|
||||
None,
|
||||
description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。"
|
||||
description="广告关闭后 3 秒短冷却结束时间(UTC ISO),null 表示不在冷却。"
|
||||
"冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点",
|
||||
)
|
||||
# ===== 看广告每日总时长(50 分钟防刷主闸):客户端据此展示"今日已看 X/50 分钟"、满则不给看 =====
|
||||
# ===== 旧版每日观看总时长字段:当前产品仅保留每日 500 次上限,limit=0 表示未启用时长闸 =====
|
||||
watched_seconds_today: int = Field(0, description="今日已观看激励视频总秒数(前端累计上报)")
|
||||
watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒,默认 3000=50 分钟)")
|
||||
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数(<=0 时客户端不应再展示广告)")
|
||||
watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒);0 表示当前未启用")
|
||||
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截")
|
||||
|
||||
|
||||
class EcpmReportIn(BaseModel):
|
||||
@@ -92,3 +92,24 @@ class TestGrantOut(BaseModel):
|
||||
cooldown_until: datetime | None = Field(
|
||||
None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut"
|
||||
)
|
||||
|
||||
|
||||
class FeedRewardIn(BaseModel):
|
||||
"""信息流广告完成后结算奖励。
|
||||
|
||||
每展示满 10 秒累计一份奖励,视频完成后一次性入账。client_event_id 用于客户端超时重试幂等。
|
||||
"""
|
||||
|
||||
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
|
||||
ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理")
|
||||
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
|
||||
adn: str | None = Field(None, description="实际投放 ADN")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位")
|
||||
|
||||
|
||||
class FeedRewardOut(BaseModel):
|
||||
granted: bool = Field(..., description="本次是否入账。达上限时 false")
|
||||
status: str = Field(..., description="granted / capped")
|
||||
coin: int = Field(..., description="本次发放金币")
|
||||
unit_count: int = Field(..., description="按 10 秒折算出的奖励份数")
|
||||
daily_limit: int = Field(..., description="每日信息流展示次数上限")
|
||||
|
||||
+11
-1
@@ -172,7 +172,7 @@ class WithdrawOrderPage(BaseModel):
|
||||
# ===== 签到 =====
|
||||
|
||||
class SigninStepOut(BaseModel):
|
||||
day: int = Field(..., description="循环内第几天 1..7")
|
||||
day: int = Field(..., description="循环内第几天 1..14")
|
||||
coin: int
|
||||
status: str = Field(..., description="claimed / today / locked")
|
||||
|
||||
@@ -193,6 +193,16 @@ class SigninResultOut(BaseModel):
|
||||
coin_balance: int = Field(..., description="签到后金币余额")
|
||||
|
||||
|
||||
class SigninBoostRequest(BaseModel):
|
||||
ad_ref_id: str | None = Field(None, description="广告会话/交易号。当前开发期可空,后续接 S2S 时回填")
|
||||
|
||||
|
||||
class SigninBoostResultOut(BaseModel):
|
||||
coin_awarded: int = Field(..., description="本次膨胀补发金币")
|
||||
coin_balance: int = Field(..., description="膨胀补发后金币余额")
|
||||
signin_date: str = Field(..., description="被膨胀的签到日期 YYYY-MM-DD")
|
||||
|
||||
|
||||
# ===== 任务 =====
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
|
||||
+4
-2
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-06-07(运营后台 Admin 子应用 A1–A26;首页门面数据 #39–#40 + 轮播种子 A21–A26)
|
||||
> 最后更新:2026-06-07(金币数值体系一期:签到膨胀 + 信息流广告结算)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -49,6 +49,7 @@
|
||||
| **签到**(前缀 `/api/v1/signin`) |||
|
||||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
|
||||
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
|
||||
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin-boost.md) |
|
||||
| **任务**(前缀 `/api/v1/tasks`) |||
|
||||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
|
||||
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
|
||||
@@ -61,6 +62,7 @@
|
||||
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
|
||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
|
||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
|
||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
|
||||
@@ -104,7 +106,7 @@
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
|
||||
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
|
||||
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`、`ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
|
||||
> 金额字段一律以**分**为单位(`*_cents`)。
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币
|
||||
|
||||
点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。
|
||||
|
||||
## 鉴权
|
||||
|
||||
需要 Bearer token。
|
||||
|
||||
## 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
|
||||
| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 |
|
||||
| `duration_seconds` | int | 是 | 实际展示/播放秒数 |
|
||||
| `adn` | string\|null | 否 | 实际投放 ADN |
|
||||
| `slot_id` | string\|null | 否 | 实际展示代码位 |
|
||||
|
||||
## 响应
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `granted` | bool | 本次是否入账;达上限时为 `false` |
|
||||
| `status` | string | `granted` / `capped` |
|
||||
| `coin` | int | 本次发放金币 |
|
||||
| `unit_count` | int | 按 10 秒折算出的奖励份数 |
|
||||
| `daily_limit` | int | 每日信息流展示次数上限,默认 500 |
|
||||
|
||||
## 计算口径
|
||||
|
||||
- 奖励份数:`duration_seconds // 10`。
|
||||
- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。
|
||||
- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。
|
||||
- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。
|
||||
|
||||
## 数据写入
|
||||
|
||||
- `ad_feed_reward_record` 新增一行。
|
||||
- 入账时 `coin_account` 增加余额。
|
||||
- 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`。
|
||||
@@ -14,15 +14,18 @@
|
||||
| `daily_limit` | int | 每日发奖次数上限 |
|
||||
| `remaining` | int | 今日剩余可领次数 |
|
||||
| `coin_per_ad` | int | 看完一个激励视频发的金币 |
|
||||
| `round_count` | int | 本轮(3 次一组)已看次数,0..N-1。客户端可据「拿到 round_count==0 && cooldown_until!=null」判定刚刚看完一轮 |
|
||||
| `cooldown_until` | datetime\|null | 本轮 10 分钟冷却结束时间(UTC ISO);null 表示不在冷却 |
|
||||
| `round_count` | int | 本轮已看次数;当前 `round_size=1`,广告关闭后进入短冷却 |
|
||||
| `cooldown_until` | datetime\|null | 3 秒短冷却结束时间(UTC ISO);null 表示不在冷却 |
|
||||
| `watched_seconds_today` | int | 今日已上报的激励视频观看秒数;当前仅兼容/排查用 |
|
||||
| `watch_seconds_limit` | int | 每日观看总时长上限;当前默认 0 表示未启用时长闸 |
|
||||
| `watch_seconds_remaining` | int | 今日剩余可观看秒数;`watch_seconds_limit=0` 时客户端不据此拦截 |
|
||||
|
||||
## 说明
|
||||
福利页「看视频赚金币」用:
|
||||
- 展示「今日还能看 N 次」「看一次得 M 金币」(`remaining`/`coin_per_ad`)
|
||||
- 任务行 CTA 4 态推导:`adLoading` → Loading;`remaining==0` → Capped("明天再来");`cooldown_until` 在未来 → CoolingDown(显示 MM:SS 倒计时,不可点);否则 Normal("去赚取")
|
||||
- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`round_count==0 && cooldown_until!=null` 显示「本轮视频已看完,10分钟后再来」
|
||||
- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`cooldown_until!=null` 显示「广告冷却中,3秒后再来」
|
||||
|
||||
冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(每 N=3 条算一轮)末尾那次的 `created_at`,加 10 分钟。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。
|
||||
冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(当前每 N=1 条算一轮)末尾那次的 `created_at`,加 3 秒。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。
|
||||
|
||||
真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# POST /api/v1/signin/boost — 签到后看广告膨胀金币
|
||||
|
||||
用户当天已签到后,看完一条激励视频,补发一笔等额签到金币。
|
||||
|
||||
## 鉴权
|
||||
|
||||
需要 Bearer token。
|
||||
|
||||
## 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `ad_ref_id` | string\|null | 否 | 广告会话/交易号。当前开发期可空,后续接 S2S 后可回填 |
|
||||
|
||||
## 响应
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin_awarded` | int | 本次膨胀补发金币 |
|
||||
| `coin_balance` | int | 补发后的金币余额 |
|
||||
| `signin_date` | string | 被膨胀的签到日期,格式 `YYYY-MM-DD` |
|
||||
|
||||
## 错误
|
||||
|
||||
- `401`: 未登录
|
||||
- `409`: 当天未签到,或当天已经膨胀过
|
||||
|
||||
## 数据写入
|
||||
|
||||
- `signin_boost_record` 新增一行,用 `(user_id, signin_date)` 唯一约束防重复。
|
||||
- `coin_account` 增加余额。
|
||||
- `coin_transaction` 写入 `biz_type=signin_boost`。
|
||||
@@ -11,7 +11,7 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `coin_awarded` | int | 本次签到发放金币 |
|
||||
| `cycle_day` | int | 本次签到落在循环第几天 |
|
||||
| `cycle_day` | int | 本次签到落在 14 天循环第几天 |
|
||||
| `streak` | int | 签到后的连续天数 |
|
||||
| `coin_balance` | int | 签到后金币余额 |
|
||||
|
||||
@@ -20,3 +20,4 @@
|
||||
|
||||
## 说明
|
||||
发金币已计入 [wallet-account](./wallet-account.md) 的余额(客户端就地刷新即可,不必另叠)。
|
||||
断签后从第 1 天重新开始;第 15 天回到第 1 天。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# GET /api/v1/signin/status — 今日签到状态 + 7 天档位
|
||||
# GET /api/v1/signin/status — 今日签到状态 + 14 天档位
|
||||
|
||||
> 所属:Signin 组(前缀 `/api/v1/signin`) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
|---|---|---|
|
||||
| `today_signed` | bool | 今日是否已签 |
|
||||
| `consecutive_days` | int | 当前连续签到天数 |
|
||||
| `today_cycle_day` | int | 今日处于循环内第几天(1..7) |
|
||||
| `today_cycle_day` | int | 今日处于循环内第几天(1..14) |
|
||||
| `today_coin` | int | 今日签到可得金币 |
|
||||
| `can_claim` | bool | 今日是否可领(= 未签) |
|
||||
| `steps` | SigninStepOut[] | 7 天档位 |
|
||||
| `steps` | SigninStepOut[] | 14 天档位 |
|
||||
|
||||
**SigninStepOut**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `day` | int | 循环内第几天 1..7 |
|
||||
| `day` | int | 循环内第几天 1..14 |
|
||||
| `coin` | int | 该档金币 |
|
||||
| `status` | string | `claimed`(已领) / `today`(今日待领) / `locked`(未到) |
|
||||
|
||||
## 说明
|
||||
福利页签到行 + 签到弹窗 7 档 timeline 数据源。
|
||||
福利页签到行 + 签到弹窗 14 档 timeline 数据源。断签后从第 1 天重新开始,第 15 天回到第 1 天。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **19 张业务表** + `alembic_version`(框架的迁移版本指针)。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。
|
||||
|
||||
---
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
| 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 |
|
||||
| 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 |
|
||||
| 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) |
|
||||
| 每日签到 | [`signin_record`](./signin_record.md) | 7 天循环发币 |
|
||||
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 14 天循环发币;签到后看广告可膨胀一次 |
|
||||
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
|
||||
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | **三条独立数据流**:发奖 / 时长闸 / 收益对账 |
|
||||
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 |
|
||||
| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 |
|
||||
| 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 |
|
||||
| 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 |
|
||||
| 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 |
|
||||
@@ -58,6 +59,7 @@
|
||||
| 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_<id>`、`status=deleted`) |
|
||||
| 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U |
|
||||
| 签到 `POST /signin/do` | `signin_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin`) | 同事务 |
|
||||
| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 |
|
||||
| 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_<key>`) | 同事务 |
|
||||
| 金币兑现金 `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` −) | 同事务,**不打款** |
|
||||
@@ -65,6 +67,7 @@
|
||||
| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `ad_reward`) | `trans_id` 幂等 |
|
||||
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(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` 幂等 |
|
||||
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
|
||||
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
|
||||
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
|
||||
@@ -90,7 +93,7 @@
|
||||
## 三、表间关系 & Join Key
|
||||
|
||||
### 硬外键(数据库 FK 约束)
|
||||
- **15 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`price_report`、`feedback`。
|
||||
- **17 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`。
|
||||
- `admin_audit_log.admin_id` → `admin_user.id`。
|
||||
- `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。
|
||||
|
||||
@@ -100,8 +103,10 @@
|
||||
| biz_type | ref_id 指向 | amount 符号 |
|
||||
|---|---|---|
|
||||
| `signin` | 当天日期串(= `signin_record.signin_date` 的 ISO `YYYY-MM-DD`) | + |
|
||||
| `signin_boost` | 当天日期串(= `signin_boost_record.signin_date` 的 ISO `YYYY-MM-DD`) | + |
|
||||
| `task_<key>` | `user_task.task_key` | + |
|
||||
| `ad_reward` | `ad_reward_record.trans_id` | + |
|
||||
| `feed_ad_reward` | `ad_feed_reward_record.client_event_id` | + |
|
||||
| `exchange_out` | null(兑现金,无单据) | − |
|
||||
| `admin_grant` / `admin_deduct` | null(原因记在 `remark`=`admin:<reason>`) | + / − |
|
||||
|
||||
@@ -113,7 +118,7 @@
|
||||
| `exchange_in` | null | + |
|
||||
|
||||
- **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
|
||||
- **三条广告流互不关联**:`ad_reward_record` / `ad_watch_log` / `ad_ecpm_record` 之间**无公共键**,各自只按 `(user_id, 日期串)` 聚合。别试图 join 它们逐条对应。
|
||||
- **广告流互不关联**:`ad_reward_record` / `ad_watch_log` / `ad_ecpm_record` / `ad_feed_reward_record` 之间**无公共键**,各自只按 `(user_id, 日期串)` 聚合。别试图 join 它们逐条对应。
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
|
||||
### ER 关系(文字版)
|
||||
@@ -121,8 +126,8 @@
|
||||
user ─1:1─ coin_account
|
||||
user ─1:1─ wechat_transfer_authorization
|
||||
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
|
||||
user_task, comparison_record, comparison_milestone_claim,
|
||||
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record,
|
||||
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
|
||||
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
|
||||
price_report, feedback }
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
admin_user ─1:N─ admin_audit_log
|
||||
@@ -135,7 +140,7 @@ app_config (独立, 无外键, key 为主键)
|
||||
|
||||
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。
|
||||
2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。
|
||||
3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方在同一事务里 commit(保证"记录"和"加币"原子化)。signin / task / ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。
|
||||
3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方在同一事务里 commit(保证"记录"和"加币"原子化)。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。
|
||||
|
||||
- **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。
|
||||
- **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款都写 `cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-06-06(补全 19 张表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(20 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(22 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -25,11 +25,13 @@
|
||||
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) |
|
||||
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) |
|
||||
| `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) |
|
||||
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
|
||||
| `signin_record` | 签到记录(14 天循环) | `models/signin.py` | [详情](./signin_record.md) |
|
||||
| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) |
|
||||
| `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) |
|
||||
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
|
||||
| `ad_watch_log` | 看广告观看时长(每日 50min 防刷主闸) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) |
|
||||
| `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) |
|
||||
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) |
|
||||
| `ad_feed_reward_record` | 信息流广告结算记录(10 秒一份,client_event_id 幂等) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
|
||||
|
||||
### 比价 / 省钱
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ad_feed_reward_record — 信息流广告奖励记录
|
||||
|
||||
点位 2 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK | 自增主键 |
|
||||
| `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id |
|
||||
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
|
||||
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` |
|
||||
| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 |
|
||||
| `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 |
|
||||
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 |
|
||||
| `adn` | String(32) | nullable | 实际投放 ADN |
|
||||
| `slot_id` | String(64) | nullable | 实际展示代码位 |
|
||||
| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 |
|
||||
| `status` | String(16) | NOT NULL | `granted` / `capped` |
|
||||
| `created_at` | DateTime | index, NOT NULL | 创建时间 |
|
||||
|
||||
## 约束
|
||||
|
||||
- `UNIQUE(client_event_id)`:同一完成事件重试不重复发奖。
|
||||
|
||||
## 关联
|
||||
|
||||
- 入账时写 `coin_transaction.biz_type=feed_ad_reward`,`ref_id=client_event_id`。
|
||||
@@ -5,7 +5,7 @@
|
||||
每条 = 穿山甲一次**服务端发奖回调**(用户看完激励视频)。是看广告三条数据流之一(**发奖**;另两条:`ad_watch_log` 时长、`ad_ecpm_record` 收益,三者无公共键)。`trans_id` 唯一做幂等键(穿山甲会重试,同号只发一次)。`reward_date`(北京时间日期串)给"每日上限"计数用。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward`)或 `POST /ad/test-grant`(本地联调)。三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`)或观看总时长(`DAILY_AD_WATCH_SECONDS_LIMIT`=50min,查 `ad_watch_log`)任一到顶** → 记一行 `status='capped'`、`coin=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`、不发币。旧的观看总时长闸仅在 `DAILY_AD_WATCH_SECONDS_LIMIT > 0` 时启用,当前默认停用。否则 `granted` + `grant_coins(biz_type='ad_reward', ref_id=trans_id)` 加币,同事务。
|
||||
- **U / D**:无。
|
||||
- **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=ad_reward) |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) |
|
||||
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped` 时为 0 |
|
||||
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数或时长超限,记录但不发) |
|
||||
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限,记录但不发) |
|
||||
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
|
||||
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
|
||||
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
|
||||
@@ -25,11 +25,11 @@
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一)。
|
||||
- `trans_id` ← 被 `coin_transaction.ref_id` 引用(`granted` 那条发币流水);`capped` 行不发币、无对应流水。
|
||||
- 与 `ad_watch_log` / `ad_ecpm_record` **无公共键**,仅在发奖时读 `ad_watch_log` 当日时长做闸。
|
||||
- 与 `ad_watch_log` / `ad_ecpm_record` **无公共键**;当前不逐条关联 eCPM 和发奖回调。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;UNIQUE+index `trans_id`;index `user_id`、`reward_date`、`created_at`。
|
||||
|
||||
## 注意
|
||||
- 实发金币以穿山甲回调 `reward_amount` 为准(`resolve_ad_reward_coin` 解析,缺/坏回退默认、超上限夹紧),单次金币/每日上限/单次上限均从 `app_config` 读(运营后台可改)。
|
||||
- 当前公网回调尚未接入前,激励视频精确 eCPM 绑定发奖先搁置;实发金币仍以穿山甲回调 `reward_amount` 为准(`resolve_ad_reward_coin` 解析,缺/坏回退默认、超上限夹紧),单次金币/每日上限/单次上限均从 `app_config` 读(运营后台可改)。
|
||||
- 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# ad_watch_log — 看激励视频观看时长(每日防刷主闸)
|
||||
# ad_watch_log — 看激励视频观看时长(旧版兼容字段)
|
||||
|
||||
> 模型 `app/models/ad_watch_log.py` · 仓库 `app/repositories/ad_watch.py` · 接口 `POST /api/v1/ad/watch-report`(上报)、读取面 [ad-reward-status](../api/ad-reward-status.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每条 = 客户端 `onAdClose` 上报的一次激励视频**实际观看秒数**。按 `(user_id, watch_date)` 聚合 `SUM(watch_seconds)` 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(`rewards.DAILY_AD_WATCH_SECONDS_LIMIT`)。是看广告三条数据流之一(**时长闸**),与 `ad_reward`(发奖)、`ad_ecpm`(收益)并列、**互不关联**。
|
||||
每条 = 客户端 `onAdClose` 上报的一次激励视频**实际观看秒数**。按 `(user_id, watch_date)` 聚合 `SUM(watch_seconds)` 得当日总观看时长。当前产品只保留每日 500 次上限,`rewards.DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示不启用时长闸;本表保留用于旧客户端兼容和排查。
|
||||
|
||||
> 防刷设计:前端上报时长不可信 → 时长是**主闸**;配合 `ad_reward_record` 的每日**次数兜底**(`DAILY_AD_REWARD_LIMIT`,默认 200),前端少报时长也刷不过次数闸。
|
||||
> 防刷设计:前端上报时长不可信,不能作为唯一发奖依据;当前发奖闸口以 `ad_reward_record` 的每日次数上限(`DAILY_AD_REWARD_LIMIT`,默认 500)为准。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /ad/watch-report`(`add_watch_seconds`)。每次看完激励视频上报一次 → 写一行,`watch_seconds` 服务端夹 `[0, MAX_SINGLE_WATCH_SECONDS=120]`(防异常大值)。鉴权接口已确保 user 存在,不校验 UnknownUser。
|
||||
- **U / D**:无。当日多次观看 = 多行,靠 SUM 聚合。
|
||||
- **R**:`watched_seconds_today` 当日累计 —— 被 `ad_reward.grant_ad_reward`(发奖前的时长闸)和 `GET /ad/reward-status`(展示"今日已看 X/50 分钟" + 满则不给看)调用。
|
||||
- **R**:`watched_seconds_today` 当日累计 —— 被 `ad_reward.grant_ad_reward` 和 `GET /ad/reward-status` 读取;当 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 时不触发 capped。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
@@ -22,11 +22,11 @@
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一)。
|
||||
- 与 `ad_reward_record` / `ad_ecpm_record` **无公共键**:不做逐条关联,只按 `(user_id, watch_date)` 聚合。发奖时 `ad_reward` 读本表当日累计判时长闸。
|
||||
- 与 `ad_reward_record` / `ad_ecpm_record` **无公共键**:不做逐条关联,只按 `(user_id, watch_date)` 聚合。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`watch_date`、`created_at`。
|
||||
|
||||
## 注意
|
||||
- 上限走常量 `DAILY_AD_WATCH_SECONDS_LIMIT=3000`(50min)/ `MAX_SINGLE_WATCH_SECONDS=120`(暂未挂 app_config,改值改 `core/rewards.py`)。
|
||||
- 当前 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示时长闸停用;`MAX_SINGLE_WATCH_SECONDS=120` 仍用于夹单次上报秒数。
|
||||
- 按日期串等值查,不在 SQL 跨时区比 date(SQLite 不可靠)。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` |
|
||||
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 7 档 / `dict_str_int` 如 task_rewards) |
|
||||
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards) |
|
||||
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 |
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
| 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 |
|
||||
|---|---|---|---|
|
||||
| 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_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 /ad/pangle-callback` | `ad_reward` | + | `ad_reward_record.trans_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`) |
|
||||
| admin 手动加金币 | `admin_grant` | + | null(`remark`=`admin:<reason>`) |
|
||||
| admin 手动扣金币 | `admin_deduct` | − | null |
|
||||
@@ -27,17 +29,17 @@
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
|
||||
| `amount` | Integer | NOT NULL | 本笔变动金币;**正=入账(赚),负=出账(花/兑换)** |
|
||||
| `balance_after` | Integer | NOT NULL | 本笔后金币余额(= 当时 `coin_account.coin_balance`,对账用) |
|
||||
| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `task_<key>` / `ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 |
|
||||
| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `signin_boost` / `task_<key>` / `ad_reward` / `feed_ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 |
|
||||
| `ref_id` | String(64) | nullable | **关联业务键,指向随 `biz_type` 变(见上表)**;无关联时为 null |
|
||||
| `remark` | String(128) | nullable | 备注(如签到「每日签到 第N天」、admin「admin:<reason>」) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一)。
|
||||
- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `task_<key>`→`user_task.task_key` / `ad_reward`→`ad_reward_record.trans_id` / 其余 null。
|
||||
- `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。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`created_at`。
|
||||
|
||||
## 注意
|
||||
- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward)在**同一事务**写,不会只发币不留痕。
|
||||
- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward/feed_ad_reward 等)在**同一事务**写,不会只发币不留痕。
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# signin_boost_record — 签到膨胀记录
|
||||
|
||||
App 用户当天签到后,看完激励视频可把签到奖励膨胀一次。本表记录膨胀动作,并用唯一约束防重复补发。
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK | 自增主键 |
|
||||
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
|
||||
| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 |
|
||||
| `coin_awarded` | Integer | NOT NULL | 本次补发金币 |
|
||||
| `ad_ref_id` | String(64) | nullable | 广告会话/交易号,开发期可空 |
|
||||
| `created_at` | DateTime | NOT NULL | 创建时间 |
|
||||
|
||||
## 约束
|
||||
|
||||
- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。
|
||||
|
||||
## 关联
|
||||
|
||||
- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id=signin_date`。
|
||||
@@ -1,13 +1,13 @@
|
||||
# signin_record — 签到记录(7 天循环)
|
||||
# signin_record — 签到记录(14 天循环)
|
||||
|
||||
> 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(10,20,30,50,80,120,200)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
|
||||
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..14)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,120,150,180,200,250,500,200,250,300,350,400,500,3000)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 15 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /signin/do`(`do_signin`)。今天未签且校验通过 → 算出 `cycle_day`/`streak`(昨天签过则在上次基础上 +1、否则重置 1)→ 写一行 + `grant_coins(biz_type='signin', ref_id=今天日期)` 加币,**同事务 commit**。今天已签再调 → 抛 `AlreadySignedError`(409),不重复写。
|
||||
- **U / D**:无。每天一行,历史不改。
|
||||
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
|
||||
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、14 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
@@ -15,7 +15,7 @@ App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
|
||||
| `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) |
|
||||
| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 |
|
||||
| `cycle_day` | Integer | NOT NULL | 取值 `1..14`:14 天循环里今天第几档,决定发币额;断签重置回 1 |
|
||||
| `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 |
|
||||
| `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) |
|
||||
| `created_at` | DateTime(tz) | server_default now() | 时间 |
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`。
|
||||
- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`。
|
||||
- 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。
|
||||
|
||||
## 索引与约束
|
||||
|
||||
+5
-5
@@ -8,7 +8,7 @@
|
||||
- **发奖只走服务端**:激励视频播完,穿山甲服务器 S2S 回调后端 `/api/v1/ad/pangle-callback` 发金币。
|
||||
客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。
|
||||
- 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。
|
||||
- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=10`(**占位值,上线前按 eCPM 实测收益重定**)、`VIDEO_ROUND_REQUIRED_COUNT=3`、`VIDEO_ROUND_COOLDOWN_SECONDS=600`(本轮 3 次后冷却 10 分钟)。
|
||||
- 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=500`、`VIDEO_ROUND_REQUIRED_COUNT=1`、`VIDEO_ROUND_COOLDOWN_SECONDS=3`(每次广告关闭后 3 秒短冷却)。旧的观看时长闸 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示停用。
|
||||
- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。
|
||||
|
||||
---
|
||||
@@ -57,7 +57,7 @@
|
||||
## C. 部署 + 包名
|
||||
|
||||
- [ ] **后端部署到公网**(由服务器管理员;`/opt/shaguabijia-app-server`,uvicorn 127.0.0.1:8770,nginx 反代)
|
||||
- [ ] **跑迁移**:`alembic upgrade head`(建 `ad_reward_record` 表,迁移 `c8d9e0f1a2b3`)
|
||||
- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`signin_boost_record`、`ad_feed_reward_record` 等表)
|
||||
- [ ] **包名定稿**:当前 `com.jishisongfu.shaguabijia`。穿山甲(APP_ID 5830519)、极光、微信都绑"包名 + 签名",定了再上,别再换
|
||||
- 微信提现链路当前因复用 elderhelper 的 appid + 包名切换已 dead,要恢复需申请傻瓜比价自己的微信 appid(另见客户端 build.gradle 注释)
|
||||
- [ ] (可选,提升真实填充)集成 **MSA OAID SDK**:申请证书(绑包名、审核几天)。App 侧当前 `getDevOaid=null`,有 OAID 后投放匹配 + 填充会明显改善
|
||||
@@ -74,9 +74,9 @@
|
||||
- [ ] 真机看完一条**真实**激励视频 → 穿山甲 S2S 回调 → 后端发奖 → 客户端余额真到账
|
||||
- [ ] **幂等**:同一 `trans_id` 不重复发
|
||||
- [ ] **每日上限**:超过 `DAILY_AD_REWARD_LIMIT` 返回 capped、不发币、按钮显示「已达上限」(Capped 态)
|
||||
- [ ] **本轮冷却**(PR #5):连看 3 张 → 按钮变 CoolingDown 显 MM:SS 倒计时不可点 → 10 分钟后自动恢复 Normal
|
||||
- [ ] **弹窗 limit note**:`round_count==0 && cooldown_until!=null` → "本轮视频已看完,10分钟后再来";`used_today>=daily_limit` → "今日视频已到限额,明天再来"
|
||||
- [ ] **跨设备/杀进程一致**:在手机 A 看完 3 张进入冷却 → 手机 B(同账号)登录后 reward-status 也返回相同的 `cooldown_until`(权威源在后端,客户端不存本地)
|
||||
- [ ] **短冷却**:看完 1 张 → 按钮变 CoolingDown 显 MM:SS 倒计时不可点 → 3 秒后自动恢复 Normal
|
||||
- [ ] **弹窗 limit note**:`cooldown_until!=null` → "广告冷却中,3秒后再来";`used_today>=daily_limit` → "今日视频已到限额,明天再来"
|
||||
- [ ] **跨设备/杀进程一致**:在手机 A 看完 1 张进入短冷却 → 手机 B(同账号)登录后 reward-status 也返回相同的 `cooldown_until`(权威源在后端,客户端不存本地)
|
||||
- [ ] 中途退出广告 → 客户端提示「未看完视频,本次没有奖励哦」,且不发奖
|
||||
- [ ] 收益明细显示「看广告奖励」(biz_type=`ad_reward`)
|
||||
|
||||
|
||||
+62
-6
@@ -15,6 +15,7 @@ from app.core.rewards import (
|
||||
MAX_AD_REWARD_COIN,
|
||||
VIDEO_ROUND_COOLDOWN_SECONDS,
|
||||
VIDEO_ROUND_REQUIRED_COUNT,
|
||||
calculate_ad_reward_coin,
|
||||
)
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
@@ -204,19 +205,19 @@ def test_reward_status_initial_round_state(client) -> None:
|
||||
|
||||
|
||||
def test_reward_status_mid_round(client) -> None:
|
||||
"""看 1 次(未到一轮)→ round_count=1, cooldown_until 仍 None。"""
|
||||
"""当前 round_size=1:看 1 次 → round_count=0, cooldown_until 进入 3 秒短冷却。"""
|
||||
phone = "13800003102"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
_callback(client, _signed(uid, "trans_mid_1"))
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == 1
|
||||
assert st["round_count"] == 1
|
||||
assert st["cooldown_until"] is None
|
||||
assert st["round_count"] == 0
|
||||
assert st["cooldown_until"] is not None
|
||||
|
||||
|
||||
def test_reward_status_round_complete_enters_cooldown(client) -> None:
|
||||
"""刚看完一轮 N 次 → round_count=0(下一轮起点) + cooldown_until 是未来 ~10 分钟。"""
|
||||
"""刚看完一条广告 → round_count=0(下一轮起点) + cooldown_until 是未来 ~3 秒。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
phone = "13800003103"
|
||||
@@ -234,12 +235,12 @@ def test_reward_status_round_complete_enters_cooldown(client) -> None:
|
||||
cd = cd.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = (cd - now).total_seconds()
|
||||
# 配置 600s,允许 ±30s 容差(本机 / CI 慢 IO)
|
||||
# 配置 3s,允许 ±30s 容差(本机 / CI 慢 IO)
|
||||
assert 0 < delta <= VIDEO_ROUND_COOLDOWN_SECONDS + 30
|
||||
|
||||
|
||||
def test_reward_status_cooldown_expired(client) -> None:
|
||||
"""看完一轮后冷却已过(手动改 created_at 到 11 分钟前)→ cooldown_until 回到 None。"""
|
||||
"""看完一条广告后冷却已过(手动改 created_at 到冷却前)→ cooldown_until 回到 None。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select, update
|
||||
|
||||
@@ -268,6 +269,61 @@ def test_reward_status_cooldown_expired(client) -> None:
|
||||
assert st["cooldown_until"] is None
|
||||
|
||||
|
||||
def test_feed_reward_grants_by_10_second_units(client) -> None:
|
||||
"""信息流广告完成后:每满 10 秒一份奖励,同一 client_event_id 幂等。"""
|
||||
phone = "13800003301"
|
||||
token = _login(client, phone)
|
||||
|
||||
payload = {
|
||||
"client_event_id": "feed_evt_0001",
|
||||
"ecpm": "200",
|
||||
"duration_seconds": 30,
|
||||
"adn": "pangle",
|
||||
"slot_id": "slot_feed",
|
||||
}
|
||||
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
|
||||
assert body["granted"] is True
|
||||
assert body["status"] == "granted"
|
||||
assert body["unit_count"] == 3
|
||||
assert body["coin"] == expected
|
||||
assert _coin_balance(client, token) == expected
|
||||
|
||||
# 重试同一个事件不重复发
|
||||
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["coin"] == expected
|
||||
assert _coin_balance(client, token) == expected
|
||||
|
||||
|
||||
def test_feed_reward_daily_display_cap(client, monkeypatch) -> None:
|
||||
"""信息流展示次数到每日上限后继续完成 → capped,不发金币。"""
|
||||
monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 1)
|
||||
phone = "13800003302"
|
||||
token = _login(client, phone)
|
||||
|
||||
first = {
|
||||
"client_event_id": "feed_evt_cap_1",
|
||||
"ecpm": "201",
|
||||
"duration_seconds": 10,
|
||||
}
|
||||
second = {
|
||||
"client_event_id": "feed_evt_cap_2",
|
||||
"ecpm": "201",
|
||||
"duration_seconds": 10,
|
||||
}
|
||||
assert client.post("/api/v1/ad/feed-reward", json=first, headers=_auth(token)).status_code == 200
|
||||
before = _coin_balance(client, token)
|
||||
r = client.post("/api/v1/ad/feed-reward", json=second, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["granted"] is False
|
||||
assert r.json()["status"] == "capped"
|
||||
assert r.json()["coin"] == 0
|
||||
assert _coin_balance(client, token) == before
|
||||
|
||||
|
||||
def test_callback_disabled_returns_503(client, monkeypatch) -> None:
|
||||
"""未配置回调(开关关)时 → 503。"""
|
||||
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)
|
||||
|
||||
@@ -69,14 +69,17 @@ def test_list_config(admin_client: TestClient, token: str) -> None:
|
||||
assert r.status_code == 200, r.text
|
||||
items = {i["key"]: i for i in r.json()}
|
||||
assert "signin_rewards" in items and "ad_daily_limit" in items
|
||||
assert items["signin_rewards"]["value"] == [10, 20, 30, 50, 80, 120, 200]
|
||||
assert items["signin_rewards"]["value"] == [
|
||||
200, 120, 150, 180, 200, 250, 500,
|
||||
200, 250, 300, 350, 400, 500, 3000,
|
||||
]
|
||||
assert items["signin_rewards"]["overridden"] is False
|
||||
|
||||
|
||||
def test_update_signin_takes_effect(admin_client: TestClient, token: str) -> None:
|
||||
r = admin_client.patch(
|
||||
"/admin/api/config/signin_rewards",
|
||||
json={"value": [100, 200, 300, 400, 500, 600, 700]},
|
||||
json={"value": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400]},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -116,7 +119,7 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
|
||||
|
||||
|
||||
def test_config_validation(admin_client: TestClient, token: str) -> None:
|
||||
# 签到档位长度≠7
|
||||
# 签到档位长度≠14
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/signin_rewards", json={"value": [1, 2, 3]}, headers=_auth(token)
|
||||
).status_code == 400
|
||||
|
||||
@@ -90,6 +90,32 @@ def test_signin_flow(client) -> None:
|
||||
assert txn["balance_after"] == SIGNIN_REWARDS[0]
|
||||
|
||||
|
||||
def test_signin_boost_flow(client) -> None:
|
||||
"""签到后看广告膨胀 → 补发一笔等额签到金币,每天只能膨胀一次。"""
|
||||
token = _login(client, "13800001011")
|
||||
|
||||
r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token))
|
||||
assert r.status_code == 409
|
||||
|
||||
r = client.post("/api/v1/signin", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
first_coin = r.json()["coin_awarded"]
|
||||
|
||||
r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "ad-session-1"}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["coin_awarded"] == first_coin
|
||||
assert body["coin_balance"] == first_coin * 2
|
||||
|
||||
r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "ad-session-1"}, headers=_auth(token))
|
||||
assert r.status_code == 409
|
||||
|
||||
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
|
||||
types = [t["biz_type"] for t in r.json()["items"]]
|
||||
assert "signin" in types
|
||||
assert "signin_boost" in types
|
||||
|
||||
|
||||
def test_task_claim_flow(client) -> None:
|
||||
"""任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。"""
|
||||
token = _login(client, "13800001003")
|
||||
|
||||
Reference in New Issue
Block a user