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:
OuYingJun1024
2026-06-07 22:52:06 +08:00
parent 4faf0b8e16
commit 356d6e52b9
36 changed files with 824 additions and 117 deletions
@@ -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')
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
)
+6 -6
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
+43
View File
@@ -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}>"
)
+4 -5
View File
@@ -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
View File
@@ -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}>"
+126
View File
@@ -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
+8 -6
View File
@@ -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)
+2 -2
View File
@@ -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
+64 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 + 轮播种子 A21A26
> 最后更新: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`)。
---
+40
View File
@@ -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`
+7 -4
View File
@@ -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)。
+32
View File
@@ -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`
+2 -1
View File
@@ -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 天。
+5 -5
View File
@@ -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 天。
+13 -8
View File
@@ -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` 对账兜底。
+6 -4
View File
@@ -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) |
### 比价 / 省钱
| 表 | 用途 | 模型 | 文档 |
+28
View File
@@ -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`
+4 -4
View File
@@ -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 回滚返回已存在那条(幂等兜底)。
+6 -6
View File
@@ -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 不可靠)。
+1 -1
View File
@@ -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() | 最后修改时间 |
+5 -3
View File
@@ -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 等)在**同一事务**写,不会只发币不留痕。
+22
View File
@@ -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`
+4 -4
View File
@@ -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() | 时间 |
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+6 -3
View File
@@ -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
+26
View File
@@ -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")