diff --git a/alembic/versions/coin_reward_points_phase1.py b/alembic/versions/coin_reward_points_phase1.py new file mode 100644 index 0000000..7293a0e --- /dev/null +++ b/alembic/versions/coin_reward_points_phase1.py @@ -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') diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 494da35..6ab6c41 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -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) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index ecff3b6..d6e6b99 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -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), + ) diff --git a/app/api/v1/signin.py b/app/api/v1/signin.py index 3bd440a..001b09d 100644 --- a/app/api/v1/signin.py +++ b/app/api/v1/signin.py @@ -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(), + ) diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 9fefc6e..ab6d4c8 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -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 秒。", }, } diff --git a/app/core/rewards.py b/app/core/rewards.py index 8f1b745..2d06c31 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 9b70807..43772fb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/ad_feed_reward.py b/app/models/ad_feed_reward.py new file mode 100644 index 0000000..c434312 --- /dev/null +++ b/app/models/ad_feed_reward.py @@ -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"" + ) diff --git a/app/models/ad_watch_log.py b/app/models/ad_watch_log.py index 5eb848c..7a9d5ed 100644 --- a/app/models/ad_watch_log.py +++ b/app/models/ad_watch_log.py @@ -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 diff --git a/app/models/signin.py b/app/models/signin.py index f23724a..63a9043 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -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"" ) + + +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"" diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py new file mode 100644 index 0000000..1ab8994 --- /dev/null +++ b/app/repositories/ad_feed_reward.py @@ -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 diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index 77bee4b..ded6d73 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -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) diff --git a/app/repositories/ad_watch.py b/app/repositories/ad_watch.py index c98ed69..75b859c 100644 --- a/app/repositories/ad_watch.py +++ b/app/repositories/ad_watch.py @@ -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 diff --git a/app/repositories/signin.py b/app/repositories/signin.py index 04f894a..747233a 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -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 diff --git a/app/schemas/ad.py b/app/schemas/ad.py index a6cdb65..4a4ddd9 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -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="每日信息流展示次数上限") diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index a48c65f..8977faf 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -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): diff --git a/docs/api/README.md b/docs/api/README.md index 971f8a3..6556d39 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -3,7 +3,7 @@ > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -> 最后更新: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`)。 --- diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md new file mode 100644 index 0000000..477390d --- /dev/null +++ b/docs/api/ad-feed-reward.md @@ -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`。 diff --git a/docs/api/ad-reward-status.md b/docs/api/ad-reward-status.md index 5486edc..57968af 100644 --- a/docs/api/ad-reward-status.md +++ b/docs/api/ad-reward-status.md @@ -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)。 diff --git a/docs/api/signin-boost.md b/docs/api/signin-boost.md new file mode 100644 index 0000000..3085338 --- /dev/null +++ b/docs/api/signin-boost.md @@ -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`。 diff --git a/docs/api/signin-do.md b/docs/api/signin-do.md index 513ba60..3bb6504 100644 --- a/docs/api/signin-do.md +++ b/docs/api/signin-do.md @@ -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 天。 diff --git a/docs/api/signin-status.md b/docs/api/signin-status.md index 8e09a54..541d6c8 100644 --- a/docs/api/signin-status.md +++ b/docs/api/signin-status.md @@ -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 天。 diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 6050ad4..65605ac 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -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_`、`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_`) | 同事务 | | 金币兑现金 `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_` | `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:`) | + / − | @@ -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` 对账兜底。 diff --git a/docs/database/README.md b/docs/database/README.md index 8065639..1c0a8de 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -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) | ### 比价 / 省钱 | 表 | 用途 | 模型 | 文档 | diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md new file mode 100644 index 0000000..ecea866 --- /dev/null +++ b/docs/database/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`。 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 3b7c766..16ac5c3 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -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 回滚返回已存在那条(幂等兜底)。 diff --git a/docs/database/ad_watch_log.md b/docs/database/ad_watch_log.md index 9b6db33..7f783bb 100644 --- a/docs/database/ad_watch_log.md +++ b/docs/database/ad_watch_log.md @@ -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 不可靠)。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md index e061c41..947831c 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -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() | 最后修改时间 | diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md index c036d2c..0beefe8 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -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_`(如 `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:`) | | 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_` / `ad_reward` / `exchange_out` / `admin_grant` / `admin_deduct`。无 DB 枚举约束,靠写入方约定 | +| `biz_type` | String(32) | NOT NULL | 取值见上表:`signin` / `signin_boost` / `task_` / `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:」) | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `task_`→`user_task.task_key` / `ad_reward`→`ad_reward_record.trans_id` / 其余 null。 +- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`→签到日 / `task_`→`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 等)在**同一事务**写,不会只发币不留痕。 diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md new file mode 100644 index 0000000..e7174a1 --- /dev/null +++ b/docs/database/signin_boost_record.md @@ -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`。 diff --git a/docs/database/signin_record.md b/docs/database/signin_record.md index 23b5249..786beab 100644 --- a/docs/database/signin_record.md +++ b/docs/database/signin_record.md @@ -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() | 时间 | diff --git a/docs/database/user.md b/docs/database/user.md index faae054..04b5adb 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -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 后台管理员,两套体系)。 ## 索引与约束 diff --git a/docs/看广告赚金币上线清单.md b/docs/看广告赚金币上线清单.md index c410817..3f0ccd7 100644 --- a/docs/看广告赚金币上线清单.md +++ b/docs/看广告赚金币上线清单.md @@ -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`) diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index c72bb39..2b30f3e 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -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) diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 1a5fead..12797bc 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -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 diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 6650efb..ffe0331 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -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")