diff --git a/alembic/versions/ad_reward_boost_round_id.py b/alembic/versions/ad_reward_boost_round_id.py new file mode 100644 index 0000000..d63afa6 --- /dev/null +++ b/alembic/versions/ad_reward_boost_round_id.py @@ -0,0 +1,47 @@ +"""ad_reward_record.boost_round_id(金币膨胀「本轮累计」标签) + +看完一条激励视频后客户端要弹「本轮累计获得 N 金币」,N 必须等于这一轮实际到账之和(否则用户 +会认为少发了钱)。单条到账额 reward-result 已经能给,但「一轮」的边界只有客户端知道(点「放弃 +赚钱」才算结束),客户端自己累加又会在进程被杀后丢失。 + +解法:客户端把轮次 id 随 mediaExtra 透传,穿山甲 S2S 原样带回,发奖时打在记录上; +reward-result 按 (user_id, boost_round_id) 对 granted 记录求和返回 round_coin。 + +本列是**纯标签**:不参与发奖判定,发多少/发不发完全不受影响。客户端就算一直复用同一个 id, +也只是把展示数字滚大,不产生任何新入账(求和的是已发生的发奖记录),无资损风险。 + +Revision ID: ad_reward_boost_round_id +Revises: comparison_llm_cost +Create Date: 2026-07-20 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "ad_reward_boost_round_id" +down_revision: str | Sequence[str] | None = "comparison_llm_cost" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # 可空、无默认:SQLite 原生支持 ADD COLUMN,不需要 batch_alter_table(同 comparison_llm_cost)。 + # 存量行留 NULL = 「不属于任何一轮」,求和时天然不参与,老客户端行为不变。 + op.add_column( + "ad_reward_record", + sa.Column("boost_round_id", sa.String(length=64), nullable=True), + ) + # 求和恒带 user_id(轮 id 是客户端生成的,不能跨用户信任),故建复合索引而非单列 + op.create_index( + "ix_ad_reward_user_boost_round", + "ad_reward_record", + ["user_id", "boost_round_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_ad_reward_user_boost_round", table_name="ad_reward_record") + op.drop_column("ad_reward_record", "boost_round_id") diff --git a/alembic/versions/drop_signin_boost_record.py b/alembic/versions/drop_signin_boost_record.py new file mode 100644 index 0000000..0447661 --- /dev/null +++ b/alembic/versions/drop_signin_boost_record.py @@ -0,0 +1,56 @@ +"""下线签到膨胀:drop signin_boost_record + +产品 2026-07 确认「固定 3000 金币的签到膨胀」从来不是设计内的口径 —— 奖励只有「签到」和 +「看视频」两种。签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径 +(按 eCPM 公式发,记在 ad_reward_record),signin_boost 场景整体摘除。 + +⚠️ **只 drop 这张表,不动 coin_transaction**:`biz_type='signin_boost'` 的金币流水是真发过的 +钱,账必须留得住(admin 大盘的 signin_boost_coin_total / signin_boost_watch_count 改为从 +coin_transaction 统计,继续能查回历史)。本表只是「哪天膨胀过」的业务留痕,金额与去向都能 +从流水还原,drop 掉不影响对账。 + +downgrade 只重建空表结构,**不恢复数据** —— 真要回滚得先从备份捞行。 + +Revision ID: drop_signin_boost_record +Revises: ad_reward_boost_round_id +Create Date: 2026-07-20 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "drop_signin_boost_record" +down_revision: str | Sequence[str] | None = "ad_reward_boost_round_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + 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") + + +def downgrade() -> None: + # 只还结构不还数据(见模块 docstring) + op.create_table( + "signin_boost_record", + sa.Column("id", sa.Integer(), 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 + ) diff --git a/alembic/versions/merge_signin_boost_main.py b/alembic/versions/merge_signin_boost_main.py new file mode 100644 index 0000000..8838a4e --- /dev/null +++ b/alembic/versions/merge_signin_boost_main.py @@ -0,0 +1,32 @@ +"""合并两个 alembic head:drop_signin_boost_record(本分支)+ merge_active_phone(main)。 + +两条线同从 comparison_llm_cost 分叉——本分支的 ad_reward_boost_round_id → drop_signin_boost_record +走「金币膨胀本轮累计 + 下线签到膨胀」;main 侧的 phone_rebind_log / analytics_active_idx 两支已由 +merge_active_phone 收敛。88f2380 把 main 合进本分支后,两条迁移线在 git 上汇合了、在 alembic 图上 +却没有,于是 `alembic upgrade head`(单数)报 "Multiple head revisions are present"——按 CLAUDE.md +run.sh 启动即自动迁移,app server 会直接起不来。 + +本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_signin_boost_main +Revises: drop_signin_boost_record, merge_active_phone +Create Date: 2026-07-21 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_signin_boost_main" +down_revision: str | Sequence[str] | None = ( + "drop_signin_boost_record", + "merge_active_phone", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index fbc3c7c..fc6a83a 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -25,7 +25,7 @@ from app.models.coupon_state import ( from app.models.cps_order import CpsOrder from app.models.feedback import Feedback from app.models.savings import SavingsRecord -from app.models.signin import SigninBoostRecord, SigninRecord +from app.models.signin import SigninRecord from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder @@ -678,7 +678,14 @@ def dashboard_overview( CoinTransaction.amount > 0, CoinTransaction.biz_type == "signin_boost", ), - "signin_boost_watch_count": _count(SigninBoostRecord), + # 签到膨胀 2026-07 已下线,signin_boost_record 表随之 drop。这两项保留为**历史口径** + # (钱是真发过的,账要能查回)。次数改数金币流水:一次膨胀 = 一笔 signin_boost 流水, + # 与原来数 signin_boost_record 行数等价。 + "signin_boost_watch_count": _count( + CoinTransaction, + CoinTransaction.biz_type == "signin_boost", + CoinTransaction.amount > 0, + ), }, "cash": { "withdraw_success_cents": _sum( diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index cff56ee..bf98e80 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -3,6 +3,8 @@ 路由前缀 `/api/v1/ad`: GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调 GET /reward-status 客户端查今日看广告发奖进度(Bearer) + GET /reward-result/{ad_session_id} + 客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用) 发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责 看完后刷新余额,不参与发奖,被破解也刷不到钱。 @@ -13,7 +15,7 @@ import json import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status from app.api.deps import CurrentUser, DbSession from app.core import rewards @@ -25,8 +27,8 @@ 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.repositories import app_config -from app.repositories import signin as crud_signin from app.schemas.ad import ( + AdRewardResultOut, AdRewardStatusOut, EcpmReportIn, EcpmReportOut, @@ -52,11 +54,14 @@ REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字 REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造) REWARD_SCENE_REWARD_VIDEO = "reward_video" -REWARD_SCENE_SIGNIN_BOOST = "signin_boost" # 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径 # ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。 REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad" -SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST} +# 2026-07 下线 signin_boost(签到膨胀):它按固定 3000 金币发,与广告实际收益脱钩,产品确认 +# 从来不是设计内的口径。签到弹窗里的「看广告膨胀」现在与福利页看视频走同一条 reward_video +# 路径(按 eCPM 公式发),奖励只剩「签到」+「看视频」两种。历史发币流水(coin_transaction +# .biz_type='signin_boost')保留不动——钱是真发过的,账必须留。 +SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO} def _parse_extra(raw_extra: str | None) -> dict[str, str]: @@ -118,6 +123,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: extra.update(_parse_extra(params.get(extra_key))) reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO ad_session_id = extra.get("ad_session_id") + # 「这条广告属于哪一轮膨胀」。纯标签:不参与发奖判定,只让 reward-result 能把同一轮求和成 + # 弹窗要显示的累计值(见 crud_ad.round_coin_total)。老客户端不带 → NULL → 累计值返 null。 + boost_round_id = (extra.get("boost_round_id") or None) + if boost_round_id is not None: + boost_round_id = boost_round_id[:64] ecpm = params.get("ecpm") # 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按 @@ -169,50 +179,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: user_id, trans_id, reward_scene, ) return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS) - if reward_scene == REWARD_SCENE_SIGNIN_BOOST: - try: - boost, _balance = crud_signin.boost_today_signin( - db, user_id, ad_ref_id=trans_id, commit=False - ) - except crud_signin.NotSignedTodayError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="not_signed", - ) - except crud_signin.AlreadyBoostedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="already_boosted", - ) - except crud_signin.LastCycleDayBoostBlockedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="last_day", - ) - else: - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=boost.coin_awarded, - reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - commit=False, - ) - db.commit() - db.refresh(rec) - else: - rec = crud_ad.grant_ad_reward( - db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id, - reward_scene=REWARD_SCENE_REWARD_VIDEO, - reward_name=params.get("reward_name"), raw=raw[:1024], - ) + rec = crud_ad.grant_ad_reward( + db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id, + reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id, + reward_name=params.get("reward_name"), raw=raw[:1024], + ) except crud_ad.UnknownUserError: logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id) return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER) @@ -242,6 +213,46 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: ) +@router.get( + "/reward-result/{ad_session_id}", + response_model=AdRewardResultOut, + summary="按 ad_session_id 查本次广告的权威发奖结果", + dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))], +) +def reward_result( + user: CurrentUser, + db: DbSession, + ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"), +) -> AdRewardResultOut: + """客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 / + coin_per_ad 估算(修「弹窗数值与真实金币对不上」)。 + + round_coin 是「恭喜累计获得奖励」弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有 + granted 记录之和。由服务端求和而不是客户端自己累加——客户端进程被杀/重建后本地累计会丢, + 发奖记录不会。取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条。 + + S2S 回调异步:查不到记录 = 回调还没到 → 返 200 + status='pending' 让客户端继续重试, + **不返 404**(404 只表示路由不存在)。纯只读:发奖仍只由验签过的 S2S 回调完成, + 这里不写库、不产生任何奖励,被刷也只是查自己的记录。 + """ + rec = crud_ad.find_by_session(db, user.id, ad_session_id) + if rec is None: + # 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到") + return AdRewardResultOut( + ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None, + ) + # 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额 + # toast 要显示的是前两条已到账的总额,不是空。 + round_coin = ( + crud_ad.round_coin_total(db, user.id, rec.boost_round_id) + if rec.boost_round_id + else None + ) + return AdRewardResultOut( + ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin, + ) + + @router.post( "/watch-report", response_model=WatchReportOut, @@ -329,55 +340,27 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N if reward_scene not in SUPPORTED_REWARD_SCENES: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene") - # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次) + # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限) trans_id = f"test-{user.id}-{uuid.uuid4().hex}" - if reward_scene == REWARD_SCENE_SIGNIN_BOOST: - try: - boost, _balance = crud_signin.boost_today_signin( - db, user.id, ad_ref_id=trans_id, commit=False - ) - except crud_signin.NotSignedTodayError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="not_signed", - ) - except crud_signin.AlreadyBoostedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="already_boosted", - ) - except crud_signin.LastCycleDayBoostBlockedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="last_day", - ) - else: - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=boost.coin_awarded, - reward_scene=reward_scene, reward_name="测试签到膨胀", - raw="client debug test-grant signin_boost", commit=False, - ) - db.commit() - db.refresh(rec) - else: - # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式); - # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。 - ad_session_id = payload.ad_session_id if payload is not None else None - ecpm_val = "200" - if ad_session_id: - ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id) - if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0: - ecpm_val = ecpm_rec.ecpm_raw - try: - rec = crud_ad.grant_ad_reward( - db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id, - reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}", - ) - except crud_ad.UnknownUserError as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e + # 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包 + # 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。 + boost_round_id = (payload.boost_round_id if payload is not None else None) or None + # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式); + # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。 + ad_session_id = payload.ad_session_id if payload is not None else None + ecpm_val = "200" + if ad_session_id: + ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id) + if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0: + ecpm_val = ecpm_rec.ecpm_raw + try: + rec = crud_ad.grant_ad_reward( + db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id, + boost_round_id=boost_round_id, + reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}", + ) + except crud_ad.UnknownUserError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e (used, limit, coin_per, round_count, cooldown_until, _watched, _watch_limit) = crud_ad.today_status(db, user.id) diff --git a/app/api/v1/signin.py b/app/api/v1/signin.py index 45f3646..02848e0 100644 --- a/app/api/v1/signin.py +++ b/app/api/v1/signin.py @@ -1,9 +1,12 @@ """签到 endpoint。 路由前缀 `/api/v1/signin`: - GET /status 今日签到状态 + 14 天档位 + GET /status 今日签到状态 + 7 天档位 POST / 执行今日签到 - POST /boost 签到后看广告膨胀金币 + +2026-07 下线 `POST /boost`(签到膨胀):它按固定 3000 金币补发、与广告实际收益脱钩。 +签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径(按 eCPM 发, +`/ad/pangle-callback` → `/ad/reward-result` 取金额),奖励只剩「签到」+「看视频」两种。 """ from __future__ import annotations @@ -12,15 +15,8 @@ import logging from fastapi import APIRouter, HTTPException, status from app.api.deps import CurrentUser, DbSession -from app.repositories import ad_reward as crud_ad from app.repositories import signin as crud_signin -from app.repositories import wallet as crud_wallet -from app.schemas.welfare import ( - SigninBoostRequest, - SigninBoostResultOut, - SigninResultOut, - SigninStatusOut, -) +from app.schemas.welfare import SigninResultOut, SigninStatusOut logger = logging.getLogger("shagua.signin") @@ -50,34 +46,3 @@ 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: - if not payload.ad_ref_id: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required") - ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id) - if ( - ad_rec is None - or ad_rec.user_id != user.id - or ad_rec.reward_scene != "signin_boost" - or ad_rec.status != "granted" - ): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified") - record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id) - if record is None: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted") - acc = crud_wallet.get_or_create_account(db, user.id) - balance = acc.coin_balance - - logger.info( - "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 9edb043..a46e96f 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -66,11 +66,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", "group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。", }, - "signin_boost_coin": { - "default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币", - "group": "签到", "type": "int", - "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", - }, "comparing_ad_enabled": { "default": True, "label": "比价/领券期信息流广告", "group": "看广告", "type": "bool", "hidden": True, diff --git a/app/core/rewards.py b/app/core/rewards.py index eccdc25..284dd97 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -239,8 +239,8 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i return max(0, round(yuan * COIN_PER_YUAN)) -# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。 -SIGNIN_BOOST_COIN: int = 3000 +# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认 +# 非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 calculate_ad_reward_coin。 # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== @@ -334,5 +334,3 @@ def get_ad_cooldown_sec(db) -> int: # noqa: ANN001 return int(_cfg(db, "ad_cooldown_sec")) -def get_signin_boost_coin(db) -> int: # noqa: ANN001 - return int(_cfg(db, "signin_boost_coin")) diff --git a/app/models/__init__.py b/app/models/__init__.py index f2c2fc0..c9a046b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -42,7 +42,7 @@ from app.models.ops_stat_config import OpsStatConfig # 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 SigninBoostRecord, SigninRecord # noqa: F401 +from app.models.signin import SigninRecord # noqa: F401 from app.models.store_mapping import StoreMapping # noqa: F401 from app.models.task import UserTask # noqa: F401 from app.models.user import User # noqa: F401 diff --git a/app/models/ad_reward.py b/app/models/ad_reward.py index d45be76..ac9ab06 100644 --- a/app/models/ad_reward.py +++ b/app/models/ad_reward.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -16,6 +16,10 @@ from app.db.base import Base class AdRewardRecord(Base): __tablename__ = "ad_reward_record" + __table_args__ = ( + # 「本轮膨胀累计发了多少」= SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted' + Index("ix_ad_reward_user_boost_round", "user_id", "boost_round_id"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # 穿山甲交易号,幂等键(同号回调不重复发奖) @@ -31,6 +35,10 @@ class AdRewardRecord(Base): reward_scene: Mapped[str] = mapped_column(String(32), nullable=False, default="reward_video") # 客户端生成并通过 extra 透传的广告会话 id ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # 客户端生成并通过 extra 透传的「膨胀轮」id:一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的 + # 若干条广告。纯标签,不影响发多少/发不发,只用于把同一轮的发奖记录求和成弹窗要显示的累计值。 + # 轮次边界完全由客户端定(它才知道用户点了放弃);老客户端/extra 丢失时为 NULL → 累计值返 null。 + boost_round_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报) ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True) # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。 diff --git a/app/models/signin.py b/app/models/signin.py index 0a94ea0..1fadf2c 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -1,6 +1,10 @@ """签到记录表。 每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 + +2026-07 下线 `signin_boost_record`(签到膨胀):膨胀按固定 3000 金币补发、与广告实际收益 +脱钩,产品确认非设计内口径。签到弹窗的「看广告膨胀」改走 reward_video(按 eCPM 发,记在 +`ad_reward_record`)。历史发币流水 `coin_transaction.biz_type='signin_boost'` 保留不动。 - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1 (周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 由 14 天改 7 天一轮)。 - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 @@ -9,7 +13,7 @@ from __future__ import annotations from datetime import date, datetime -from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -42,29 +46,3 @@ class SigninRecord(Base): ) -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_reward.py b/app/repositories/ad_reward.py index f9fb16f..3854f12 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -41,6 +41,61 @@ def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None: return _find_by_trans(db, trans_id) +def find_by_session(db: Session, user_id: int, ad_session_id: str) -> AdRewardRecord | None: + """按广告会话 id 查该用户本次广告的发奖记录,供客户端轮询弹窗金额(reward-result)。 + + 同一 ad_session_id 可能命中多条,**必须显式优先 granted**,不能只取最近一条: + - 客户端先上报 closed_early、S2S 随后才姗姗来迟 → 两条,granted 反而是后写的; + - record_reward_noshow 只在写入前查 granted,挡不住这种后到的竞态; + - 本地联调重复调 test-grant → 同 session 多条 granted(trans_id 各不相同)。 + granted 是唯一「真发了钱」的状态,取它才是权威金额;都没有再取最近一条,让客户端 + 知道没发的原因(capped/closed_early…)。按 user_id 收窄,防止拿别人的 session 探测结果。 + """ + granted = db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + AdRewardRecord.status == "granted", + ) + .order_by(AdRewardRecord.created_at.desc()) + .limit(1) + ).scalars().first() + if granted is not None: + return granted + return db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + ) + .order_by(AdRewardRecord.created_at.desc()) + .limit(1) + ).scalars().first() + + +def round_coin_total(db: Session, user_id: int, boost_round_id: str) -> int: + """本轮膨胀累计已发金币 = 该轮所有 granted 记录的 coin 之和(含刚发的这条)。 + + 客户端弹窗要显示的就是它:第 1 条弹 40、第 2 条弹 60(=40+20),点「放弃赚钱」后余额涨 60, + 三个数必须相等。之所以由服务端求和而不是客户端自己累加——客户端进程被杀/低内存重建后 + 本地累计就丢了,而发奖记录不会丢。 + + **必须带 user_id**:boost_round_id 是客户端生成的,不带 user_id 就等于让任何人拿别人的 + 轮 id 查别人发了多少。未发奖的状态(capped/closed_early/ecpm_missing)coin 本就是 0, + 这里按 status 过滤只是让意图显式。 + """ + return int( + db.execute( + select(func.coalesce(func.sum(AdRewardRecord.coin), 0)).where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.boost_round_id == boost_round_id, + AdRewardRecord.status == "granted", + ) + ).scalar_one() + ) + + def _granted_today(db: Session, user_id: int, reward_date: str) -> int: return db.execute( select(func.count()) @@ -78,8 +133,12 @@ def grant_ad_reward( reward_scene: str = "reward_video", reward_name: str | None = None, raw: str | None = None, + boost_round_id: str | None = None, ) -> AdRewardRecord: - """福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。""" + """福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。 + + boost_round_id 只是随记录存下的标签(见 round_coin_total),**不参与任何发奖判定**。 + """ # #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发 existing = _find_by_trans(db, trans_id) if existing is not None: @@ -112,7 +171,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="capped", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -123,7 +182,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -140,7 +199,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=coin, status="granted", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -206,8 +265,13 @@ def record_external_reward( raw: str | None = None, status: str = "granted", commit: bool = True, + boost_round_id: str | None = None, ) -> AdRewardRecord: - """记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。""" + """记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。 + + boost_round_id 同 grant_ad_reward:纯标签。签到膨胀场景的 coin 也会计入本轮累计 + (它的 coin 就是实发额),所以这里也要存,否则一轮里混了膨胀就会漏算。 + """ existing = _find_by_trans(db, trans_id) if existing is not None: return existing @@ -224,6 +288,7 @@ def record_external_reward( reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + boost_round_id=boost_round_id, ) db.add(rec) if commit: diff --git a/app/repositories/signin.py b/app/repositories/signin.py index c65ce38..067fa7a 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -11,12 +11,11 @@ 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 SigninBoostRecord, SigninRecord +from app.models.signin import SigninRecord from app.repositories import wallet as crud_wallet @@ -24,18 +23,6 @@ class AlreadySignedError(Exception): """今天已经签过了。""" -class NotSignedTodayError(Exception): - """今天尚未签到,不能膨胀。""" - - -class AlreadyBoostedError(Exception): - """今天签到奖励已经膨胀过。""" - - -class LastCycleDayBoostBlockedError(Exception): - """循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。""" - - @dataclass class SigninStep: day: int # 1..14 @@ -141,69 +128,3 @@ 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_by_ad_ref( - db: Session, user_id: int, ad_ref_id: str -) -> SigninBoostRecord | None: - """按广告交易号查签到膨胀记录。S2S 发奖后客户端确认用。""" - return db.execute( - select(SigninBoostRecord).where( - SigninBoostRecord.user_id == user_id, - SigninBoostRecord.ad_ref_id == ad_ref_id, - ) - ).scalar_one_or_none() - - -def boost_today_signin( - db: Session, user_id: int, *, ad_ref_id: str | None = None, commit: bool = True -) -> tuple[SigninBoostRecord, int]: - """签到后看广告膨胀:固定补发配置金币。返回 (膨胀记录, 补发后余额)。""" - record = _today_record(db, user_id) - if record is None: - raise NotSignedTodayError - if record.cycle_day == SIGNIN_CYCLE_LEN: - raise LastCycleDayBoostBlockedError - - 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=rewards.get_signin_boost_coin(db), - ad_ref_id=ad_ref_id, - ) - db.add(boost) - try: - acc, _ = crud_wallet.grant_coins( - db, user_id, boost.coin_awarded, - biz_type="signin_boost", ref_id=ad_ref_id or today.isoformat(), - remark=f"签到膨胀 第{record.cycle_day}天", - ) - if commit: - db.commit() - else: - db.flush() - 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 7a6feb4..9a6df95 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -43,6 +43,35 @@ class AdRewardStatusOut(BaseModel): watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截") +class AdRewardResultOut(BaseModel): + """按 ad_session_id 查本次广告的**权威发奖结果**(福利页看完视频的弹窗金额只认它)。 + + S2S 回调是异步的,客户端看完广告立刻轮询多半还查不到记录 —— 这种「还没到账」返回 + 200 + status='pending' 让客户端重试,**不返 404**:404 只应表示路由不存在,两者混在 + 一起客户端没法区分「后端没部署」和「再等等」。纯只读,不产生任何奖励。 + """ + + ad_session_id: str = Field(..., description="回显请求的广告会话 id") + status: str = Field( + ..., + description="pending(S2S 未到账,客户端应继续轮询) / granted(已发奖) / capped(当日超限未发) / " + "ecpm_missing(缺 eCPM 未发) / closed_early(提前关闭未发);其余同 AdRewardRecord.status。" + "客户端只在 granted 且 coin>0 时弹窗,其它一律不弹(不显示假数字)", + ) + coin: int | None = Field( + None, + description="本次实发金币:granted 为真实到账额;未发奖的状态为 0;pending 为 null", + ) + round_coin: int | None = Field( + None, + description="**本轮膨胀累计已发金币**(含本条)——客户端「恭喜累计获得奖励」弹窗显示的就是它。" + "轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 boost_round_id 定。" + "本条不是 granted(capped/closed_early/…)时**仍返本轮累计**,只是这条按 0 计。" + "pending(没记录,取不到轮 id)、或该记录没有 boost_round_id(老客户端 / extra 丢失)时为 null," + "客户端见 null 退回只显示单条 coin", + ) + + class EcpmReportIn(BaseModel): """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 @@ -111,13 +140,20 @@ class TestGrantIn(BaseModel): reward_scene: str = Field( "reward_video", - description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)", + description="模拟发奖场景。当前只支持 reward_video(普通激励视频);signin_boost(签到膨胀)" + "已于 2026-07 下线,传它会 422", ) ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端" "已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币", ) + boost_round_id: str | None = Field( + None, max_length=64, + description="本次广告属于哪一轮膨胀。正式链路走穿山甲 S2S 的 mediaExtra,test-grant 不经 S2S、" + "拿不到 extra,故在 body 里补一个——不传的话 debug 包 reward-result 的 round_coin 恒为 null," + "「弹窗 40 → 60 → toast +60」那套验收在本地跑不起来", + ) class TestGrantOut(BaseModel): @@ -125,7 +161,7 @@ class TestGrantOut(BaseModel): granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)") status: str = Field( - ..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene" + ..., description="granted / capped / ecpm_missing / unknown_scene" ) coin: int = Field(..., description="本次发放金币(capped 时为 0)") used_today: int = Field(..., description="今日已成功发奖次数") diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index d6ba63a..a9aa811 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -223,16 +223,6 @@ 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 7d29d6f..57c3a62 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -78,7 +78,6 @@ | **签到**(前缀 `/api/v1/signin`) ||| | 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) | | 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) | -| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) | | **任务**(前缀 `/api/v1/tasks`) ||| | 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) | | 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) | @@ -89,6 +88,7 @@ | **看广告发奖**(前缀 `/api/v1/ad`) ||| | 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) | | 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) | +| 33a | `GET /api/v1/ad/reward-result/{ad_session_id}` | Bearer | [详情](./ad/ad-reward-result.md)(本次实发金币 + 本轮膨胀累计 `round_coin`,弹窗数字用它) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) | diff --git a/docs/api/ad/ad-pangle-callback.md b/docs/api/ad/ad-pangle-callback.md index b059afd..5233cc9 100644 --- a/docs/api/ad/ad-pangle-callback.md +++ b/docs/api/ad/ad-pangle-callback.md @@ -15,7 +15,15 @@ GroMore 以 GET 回调,关键参数: | `trans_id` | string | 交易号(**幂等键** + **唯一参与签名的字段**) | | `reward_name` | string | 奖励名(广告位配置,入库备注) | | `ecpm` | string\|null | GroMore 回调携带的 eCPM。普通激励视频优先用它计算金币 | -| `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id`、`reward_scene`;`reward_scene=signin_boost` 表示签到膨胀 | +| `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id`、`reward_scene`、`srv_env`、`boost_round_id` | + +### `extra` 里的 `boost_round_id` + +客户端生成的「这条广告属于哪一轮膨胀」标签(32 位十六进制,同 `ad_session_id` 格式),随发奖记录存进 `ad_reward_record.boost_round_id`。 + +**它不参与任何发奖判定** —— 发多少、发不发完全不受影响,只是让 [`/ad/reward-result`](./ad-reward-result.md) 能把同一轮的 granted 记录求和成 `round_coin`(客户端「恭喜累计获得奖励」弹窗显示的数)。 + +轮次边界由客户端定(只有它知道用户点没点「放弃赚钱」):点「去膨胀」新生成一个 → 点「继续看视频膨胀」复用同一个 → 点「放弃赚钱」/ ✕ / 返回 / 到每日上限 / 跨天 则丢弃。不带此字段(老客户端 / GroMore 偶发丢 extra)时存 NULL,`round_coin` 返 `null`。 | `mediation_rit` | string | 代码位 ID(GroMore 带,目前仅入 raw 备查) | | `prime_rit` | string | 广告位 ID(同上) | | `adn_name` | string | 实际出广告的 ADN 名(同上,可用于收益分析) | @@ -40,6 +48,6 @@ GroMore 以 GET 回调,关键参数: **发奖唯一可信入口**:验签 → 取 `user_id`/`extra` → 按 `reward_scene` 分流 → 幂等处理(按 `trans_id` 去重)。客户端不直接发奖,被破解也刷不到钱。 - `reward_scene=reward_video` 或缺省:普通激励视频。金币按 `eCPM / 1000 * eCPM因子 * 当日次数因子 * 10000` 计算;若回调没有 `ecpm`,会按 `extra.ad_session_id` 查客户端 `/ad/ecpm-report` 的上报值;两边都没有 eCPM 时不发币,记录 `status=ecpm_missing`。 -- `reward_scene=signin_boost`:签到膨胀。要求用户当天已签到且不是 Day14;看完视频固定发 `2000` 金币,写 `signin_boost_record` 与 `coin_transaction.biz_type=signin_boost`。 +- ~~`reward_scene=signin_boost`~~(签到膨胀):**2026-07 已下线**。它按固定 3000 金币发、与广告实际收益脱钩,产品确认非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 `reward_video`。现在传 `signin_boost` 会落到「未知场景」分支(不发币,`status=unknown_scene`)。 - 未知 `reward_scene`:不发币,记录 `status=unknown_scene`,返回 `is_verify=false/reason=1`。 - 验签过但参数缺/坏或 user 不存在 → 不发(`is_verify=false` + `reason`);granted / capped / ecpm_missing / 业务不满足已记录 → `is_verify=true` + `reason=0`。 diff --git a/docs/api/ad/ad-reward-result.md b/docs/api/ad/ad-reward-result.md new file mode 100644 index 0000000..273e16a --- /dev/null +++ b/docs/api/ad/ad-reward-result.md @@ -0,0 +1,66 @@ +# GET /api/v1/ad/reward-result/{ad_session_id} — 查本次广告的权威发奖结果 + 本轮累计 + +客户端看完激励视频后轮询本接口,拿**本次实发金币**和**本轮累计**用于「恭喜累计获得奖励」弹窗。不再用余额差 / `coin_per_ad` 估算。 + +**纯只读**:发奖仍只由验签过的 S2S 回调完成,本接口不写库、不产生任何奖励。按 `user_id` 收窄,被刷也只能查到自己的记录。 + +## 鉴权 + +需要 Bearer token。 + +## 路径参数 + +| 参数 | 类型 | 约束 | 说明 | +|---|---|---:|---| +| `ad_session_id` | string | 长度 8~64 | 本次广告会话 id,客户端生成,与 `mediaExtra` / `ecpm-report` 同值 | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ad_session_id` | string | 回显请求值 | +| `status` | string | `pending`(S2S 未到账,继续轮询) / `granted` / `capped`(当日超限) / `ecpm_missing` / `closed_early`(提前关闭) | +| `coin` | int \| null | **本条**实发金币。granted 为真实到账额;未发奖的状态为 0;pending 为 `null` | +| `round_coin` | int \| null | **本轮累计已发金币**(含本条) ← 弹窗显示的就是它 | + +```json +{ "ad_session_id": "3f2a9c1b7e4d8a60", "status": "granted", "coin": 20, "round_coin": 60 } +``` + +### `round_coin` 的口径 + +「轮」= 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 `boost_round_id` 定(见 [ad-pangle-callback](./ad-pangle-callback.md))。 + +```sql +SELECT COALESCE(SUM(coin), 0) FROM ad_reward_record +WHERE user_id = :user_id -- 恒带,轮 id 是客户端生成的不可跨用户信任 + AND boost_round_id = :该会话记录的 boost_round_id + AND status = 'granted' +``` + +由服务端求和而非客户端自己累加:客户端进程被杀 / 低内存重建后本地累计会丢,发奖记录不会。 + +**要守住的不变量:弹窗数字 == 本轮实际到账之和 == 用户看到的余额涨幅。** 三者对不上,用户就会认为少发了钱。 + +| 情形 | `round_coin` | +|---|---| +| 本条 `granted` | 本轮累计(含本条) | +| 本条 `capped` / `closed_early` / `ecpm_missing` | **仍返本轮累计**,该条按 0 计(撞上限那下的 toast 要能显示前几条的总额,不能是空) | +| `status=pending`(没记录) | `null` —— 连属于哪一轮都不知道。**不是 0**,0 会被读成「本轮没赚到」 | +| 该记录没有 `boost_round_id`(老客户端 / extra 丢失) | `null`,客户端退回只显示单条 `coin` | + +## 错误 + +- `401`: 未登录 +- `422`: `ad_session_id` 长度不在 8~64 + +**查不到记录不返 404**,而是 200 + `status="pending"`。404 只应表示路由不存在;两者混在一起客户端没法区分「后端没部署」和「再等等」。 + +## 实现注意 + +同一 `ad_session_id` 可能有多条记录,取值时**显式优先 `granted`**,不能只取最近一条: + +- 客户端先报 `closed_early`、S2S 随后姗姗来迟 → 两条,`granted` 反而是后写的 +- 本地联调重复调 `test-grant` → 同 session 多条 `granted`(`trans_id` 各不相同) + +都没有 `granted` 才取最近一条,让客户端知道没发的原因。 diff --git a/docs/api/ad/ad-test-grant.md b/docs/api/ad/ad-test-grant.md index 490f96b..60891e8 100644 --- a/docs/api/ad/ad-test-grant.md +++ b/docs/api/ad/ad-test-grant.md @@ -9,7 +9,8 @@ | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| -| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 | +| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。当前**只支持** `reward_video`;`signin_boost`(签到膨胀)已于 2026-07 下线,传它返 `422` | +| `boost_round_id` | string | 否 | `null` | 本次广告属于哪一轮膨胀。正式链路走 S2S 的 `mediaExtra`,本接口不经 S2S 拿不到 extra,故由 body 补。**不传的话 debug 包 `/ad/reward-result` 的 `round_coin` 恒为 `null`**,「弹窗 40 → 60 → toast +60」那套累计验收在本地跑不起来 | | `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 | ## 出参 @@ -33,4 +34,4 @@ `reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。 -`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 +它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 diff --git a/docs/api/admin/admin-stats-overview.md b/docs/api/admin/admin-stats-overview.md index 6165445..2c7f089 100644 --- a/docs/api/admin/admin-stats-overview.md +++ b/docs/api/admin/admin-stats-overview.md @@ -37,8 +37,8 @@ | `feed_ad_watch_count` | int | 信息流广告有效完成视频数(`ad_feed_reward_record.status=granted`) | | `signin_coin_total` | int | 签到累计发放金币(`biz_type=signin`) | | `signin_count` | int | 签到次数(`signin_record`) | -| `signin_boost_coin_total` | int | 签到膨胀累计发放金币(`biz_type=signin_boost`) | -| `signin_boost_watch_count` | int | 签到膨胀有效视频数(`signin_boost_record`) | +| `signin_boost_coin_total` | int | **历史口径**:签到膨胀累计发放金币(`biz_type=signin_boost`)。功能已下线,数字不再增长,保留供对账 | +| `signin_boost_watch_count` | int | **历史口径**:签到膨胀次数。膨胀 2026-07 已下线、`signin_boost_record` 表已 drop,改数 `coin_transaction.biz_type='signin_boost'` 的入账笔数(一次膨胀 = 一笔,与原口径等价),只会停在历史值不再增长 | **DashboardCash** | 字段 | 类型 | 说明 | diff --git a/docs/api/signin/signin-boost.md b/docs/api/signin/signin-boost.md deleted file mode 100644 index a1ec64c..0000000 --- a/docs/api/signin/signin-boost.md +++ /dev/null @@ -1,33 +0,0 @@ -# POST /api/v1/signin/boost — 签到后看广告膨胀金币 - -用户 Day1-Day13 当天已签到后,看完一条激励视频,由穿山甲 S2S 回调固定补发 2000 金币。本接口只用于 S2S 发奖后的确认。 - -## 鉴权 - -需要 Bearer token。 - -## 请求体 - -| 字段 | 类型 | 必填 | 说明 | -|---|---|---:|---| -| `ad_ref_id` | string | 是 | 穿山甲 S2S 回调的 `trans_id`。回调需先以 `extra.reward_scene=signin_boost` 完成发奖 | - -## 响应 - -| 字段 | 类型 | 说明 | -|---|---|---| -| `coin_awarded` | int | 本次膨胀补发金币 | -| `coin_balance` | int | 补发后的金币余额 | -| `signin_date` | string | 被膨胀的签到日期,格式 `YYYY-MM-DD` | - -## 错误 - -- `401`: 未登录 -- `409`: 缺少/无效广告回调记录,非本人广告,回调未发奖,当天未签到,Day14,或当天已经膨胀过 - -## 数据写入 - -- 本接口不直接发奖;实际写入发生在 `/ad/pangle-callback` 的 `reward_scene=signin_boost` 分支。 -- 回调写 `signin_boost_record` 新增一行,用 `(user_id, signin_date)` 唯一约束防重复。 -- 回调使 `coin_account` 增加固定 `2000` 金币。 -- 回调写入 `coin_transaction.biz_type=signin_boost`。 diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 5f5f525..edbfd1d 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -35,7 +35,7 @@ | 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 | | 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 | | 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) | -| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 | +| 每日签到 | [`signin_record`](./signin_record.md) | 7 天循环发币。签到弹窗的「看广告膨胀」2026-07 起走 `reward_video`(按 eCPM 发,记 `ad_reward_record`),不再有独立的膨胀表 | | 一次性任务(开消息提醒等) | [`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) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 | | 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 | @@ -102,12 +102,11 @@ | 注销 `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`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C −:`cash_transaction.withdraw` 或 `invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121 按 `source` 分账 | | 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | | -| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 | +| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`) | `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` 幂等 | @@ -175,7 +174,7 @@ ## 三、表间关系 & Join Key ### 硬外键(数据库 FK 约束) -- **19 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。 +- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。 - `admin_audit_log.admin_id` → `admin_user.id`。 - `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。 - **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。 @@ -187,7 +186,7 @@ | 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`) | + | + | `signin_boost`(**历史,2026-07 已下线**) | 当时的广告 `trans_id`,无则当天日期 ISO 串。不再产生新行,存量保留供对账 | + | | `task_` | `user_task.task_key` | + | | `reward_video` / `ad_reward`(历史) | `ad_reward_record.trans_id` | + | | `feed_ad_reward` | `ad_feed_reward_record.client_event_id` | + | @@ -225,7 +224,7 @@ user ─1:1─ coin_account user ─1:1─ wechat_transfer_authorization user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order, - signin_record, signin_boost_record, user_task, comparison_record(user_id 可空), + signin_record, user_task, comparison_record(user_id 可空), comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness } (device_liveness 硬 FK; (user_id,device_id) 唯一) @@ -255,7 +254,7 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去 1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。 2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。 -3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。 +3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`(`signin_boost` 2026-07 已下线,存量流水保留);`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。 - **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。 - **提现状态机**:`reviewing`(发起即原子扣款、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」。 diff --git a/docs/database/README.md b/docs/database/README.md index 5f93769..ef191e2 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -35,7 +35,6 @@ | `withdraw_order` | 提现单(现金→微信零钱,含人工审核态;`source` 分账 coin_cash/invite_cash) | `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_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` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) | diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index dc6eaf9..c628e7a 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -2,7 +2,7 @@ > 模型 `app/models/ad_reward.py` · 仓库 `app/repositories/ad_reward.py` · 接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) / [ad-test-grant](../api/ad-test-grant.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 +每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、提现看视频等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 ## 用在哪 / 增删改查 - **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'`、`coin=0` 留痕(同 session 已 granted 则跳过)。 @@ -13,15 +13,16 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | +| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | -| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 | +| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频(当前唯一发币场景);`withdrawal_ad` 提现门槛视频(只留痕不发币);`signin_boost` **历史值,2026-07 已下线** | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` | +| `boost_round_id` | String(64) | nullable | 「这条广告属于哪一轮膨胀」,来自 `extra.boost_round_id`。一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条。**纯标签,不参与发奖判定**;仅供 `/ad/reward-result` 求和出 `round_coin`(弹窗显示的累计值)。老客户端 / extra 丢失时 NULL | | `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 | | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 | | `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) | | `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day`/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | @@ -34,8 +35,10 @@ ## 索引与约束 - PK `id`;UNIQUE+index `trans_id`;index `user_id`、`reward_date`、`created_at`、`ad_session_id`。 +- 复合 index `ix_ad_reward_user_boost_round` = `(user_id, boost_round_id)`:算「本轮累计已发」用。**求和恒带 `user_id`** —— `boost_round_id` 是客户端生成的,不带 `user_id` 等于让任何人拿别人的轮 id 查别人发了多少。 ## 注意 - 普通激励视频按 eCPM 公式发奖;若 S2S 与客户端会话上报都缺 eCPM,记录 `status='ecpm_missing'`、`coin=0`,不发币。 -- 签到膨胀复用本表记录 S2S 幂等,实发固定 `2000` 金币由 `signin_boost_record`/`coin_transaction.biz_type=signin_boost` 承载。 +- 签到膨胀(`reward_scene=signin_boost`)2026-07 已下线,存量行保留供对账;签到弹窗的「看广告膨胀」现与福利页看视频同走 `reward_video`(按 eCPM 公式发)。 +- **膨胀轮累计**:`SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted'`,由 `/ad/reward-result` 返回为 `round_coin`。客户端就算一直复用同一个轮 id,也只是把展示数字滚大 —— 求和的是**已发生**的发奖记录,不产生任何新入账,无资损风险。 - 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 1f3a32d..dbe151f 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -14,7 +14,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` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | +| `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` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | | `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled / comparing_ad_enabled) | | `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 f29958d..a2778a2 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -10,7 +10,7 @@ | 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 | |---|---|---|---| | 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) | - | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO 串 | + | ~~签到后看广告膨胀~~(**2026-07 已下线**) | `signin_boost` | + | 历史行:当时的广告 `trans_id`,无则当天日期 ISO 串。不再产生新行;签到弹窗的看广告改走 `reward_video` | | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` | | 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`ad_reward`) | + | `ad_reward_record.trans_id` | | 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` | @@ -36,7 +36,7 @@ ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`→`signin_boost_record.ad_ref_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 +- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`(历史)→当时的广告 `trans_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md deleted file mode 100644 index ea71341..0000000 --- a/docs/database/signin_boost_record.md +++ /dev/null @@ -1,22 +0,0 @@ -# signin_boost_record — 签到膨胀记录 - -App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 - -## 字段 - -| 字段 | 类型 | 约束 | 说明 | -|---|---|---|---| -| `id` | Integer | PK | 自增主键 | -| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | -| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | -| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) | -| `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` | -| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | - -## 约束 - -- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。 - -## 关联 - -- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)。 diff --git a/docs/database/user.md b/docs/database/user.md index ce33696..d6b0d7e 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -29,7 +29,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`、`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`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 +- **被引用方(本表是 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`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 - 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。 ## 索引与约束 diff --git a/docs/database/数据表字典-产品参考.md b/docs/database/数据表字典-产品参考.md index fa59348..822d4c7 100644 --- a/docs/database/数据表字典-产品参考.md +++ b/docs/database/数据表字典-产品参考.md @@ -27,7 +27,6 @@ | 钱包 | `withdraw_order` | 提现单 | 现金→微信零钱提现 | | 钱包 | `wechat_transfer_authorization` | 微信转账免确认授权 | 用户授权后转账免逐笔确认 | | 激励 | `signin_record` | 签到记录 | 每日签到 | -| 激励 | `signin_boost_record` | 签到膨胀记录 | 签到后看广告翻倍补发 | | 激励 | `user_task` | 一次性任务完成 | 只能领一次的任务 | | 激励 | `comparison_milestone_claim` | 比价战绩领取 | 比价次数里程碑奖励 | | 比价 | `comparison_record` | 比价记录 | 用户视角「我的比价记录」 | @@ -181,18 +180,6 @@ App 用户主表。两种登录(极光一键 / 短信验证码)都映射到 | coin_awarded | 整数 | 本次发放金币 | | created_at | 时间 | 时间 | -## `signin_boost_record` — 签到膨胀记录 - -签到后看广告「膨胀」翻倍,一天最多一次,补发金额=当天签到原始奖励。 - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | 整数 | **主键** | -| user_id | 整数 | **外键→user** | -| signin_date | 日期 | 与 user_id 组成**唯一**(防并发重复补发) | -| coin_awarded | 整数 | 补发金币 | -| ad_ref_id | 字符串 | 广告会话/交易号,可空 | -| created_at | 时间 | 时间 | ## `user_task` — 一次性任务完成记录 @@ -379,7 +366,7 @@ profile「累计帮你省了」「省钱战绩」的唯一数据源。(user_id, | user_id | 整数 | **外键→user** | | coin | 整数 | 实发金币(超限为 0) | | status | 字符串 | granted(已发)/ capped(当日超限)/ ecpm_missing(缺 eCPM) | -| reward_scene | 字符串 | reward_video(福利页看视频)/ signin_boost(签到膨胀) | +| reward_scene | 字符串 | reward_video(福利页看视频,当前唯一发币场景)/ withdrawal_ad(提现门槛视频,不发币)/ signin_boost(**历史值,2026-07 已下线**) | | ad_session_id | 字符串 | 广告会话 id,可空 | | ecpm_raw | 字符串 | 本次发奖采用的 eCPM 原始值,可空 | | app_env | 字符串 | 应用环境 prod/test(回填),可空 | diff --git a/docs/guides/看广告赚金币上线清单.md b/docs/guides/看广告赚金币上线清单.md index 2ef65a4..04efae1 100644 --- a/docs/guides/看广告赚金币上线清单.md +++ b/docs/guides/看广告赚金币上线清单.md @@ -70,7 +70,7 @@ ## C. 部署 + 包名 - [ ] **后端部署到公网**(由服务器管理员;`/opt/shaguabijia-app-server`,uvicorn 127.0.0.1:8770,nginx 反代) -- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`signin_boost_record`、`ad_feed_reward_record` 等表) +- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`ad_feed_reward_record` 等表) - [ ] **包名定稿**:当前 `com.jishisongfu.shaguabijia`。穿山甲(APP_ID 5830519)、极光、微信都绑"包名 + 签名",定了再上,别再换 - 微信提现链路当前因复用 elderhelper 的 appid + 包名切换已 dead,要恢复需申请傻瓜比价自己的微信 appid(另见客户端 build.gradle 注释) - [ ] (可选,提升真实填充)集成 **MSA OAID SDK**:申请证书(绑包名、审核几天)。App 侧当前 `getDevOaid=null`,有 OAID 后投放匹配 + 填充会明显改善 diff --git a/scripts/reset_onboarding.py b/scripts/reset_onboarding.py new file mode 100644 index 0000000..0837543 --- /dev/null +++ b/scripts/reset_onboarding.py @@ -0,0 +1,138 @@ +"""重置指定用户的新手引导完成标记,让这个账号重新进新手引导页,方便反复测试引导流程。 + +原理:是否跳过引导只由 onboarding_completion 表里 (user_id, device_id) 那一行决定 +(见 app/models/onboarding.py)。删掉该用户的行 → 登录响应 onboarding_completed=false、 +GET /api/v1/user/onboarding/status 也返 false → 客户端下次登录/启动重走引导。 +本地 SharedPreferences 标记卸载即丢、以后端为准,所以删这一行就够,不用重装 App。 + +默认删该用户**所有设备**的记录(换机/多设备一起放开);只想放开某一台用 --device-id +(device_id = 客户端硬件级 ANDROID_ID,与登录 / onboarding/complete 传的是同一个值)。 + +与已有两个入口的分工: + - admin「设备维度引导管理」按**设备**重置(该设备上所有账号一起),本脚本按**账号**; + - POST /api/v1/user/onboarding/reset 要客户端自己带 device_id 调,本脚本从库里反查设备。 + +用法(在项目根、已 pip install -e . 的环境里跑): + python scripts/reset_onboarding.py # 默认测试号 11111111111 + python scripts/reset_onboarding.py 13800138000 # 指定手机号 + python scripts/reset_onboarding.py --user-id 5 # 直接指定 user_id + python scripts/reset_onboarding.py --dry-run # 预览(照常执行再回滚),不落库 + python scripts/reset_onboarding.py --device-id abc123 # 只放开这一台设备,其余设备照旧跳过 + +走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**默认只允许 APP_ENV=dev 改库** +(--dry-run 只读,任何环境都能跑)。线上确实要给某个用户开引导时加 --force —— 这张表只存 +"引导走过没"的标记,删了最坏结果是用户多看一次引导,不涉及金额/账目。 +""" +from __future__ import annotations + +import argparse +import sys + +from sqlalchemy import delete, select + +from app.core.config import settings +from app.db.session import SessionLocal, engine +from app.models.onboarding import OnboardingCompletion +from app.models.user import User + +# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码。stderr 也要设: +# SystemExit(如"用户不存在")的中文提示走的是 stderr。 +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8") + +# dev 下 engine 是 echo=True(APP_DEBUG),几十行 SQL 会把前后对比刷没。echo 走 SQLAlchemy 自己的 +# InstanceLogger,不吃 logging.setLevel,只能改 engine.echo。 +engine.echo = False + +DEFAULT_PHONE = "11111111111" + + +def resolve_user(db, phone: str, user_id: int | None) -> User: + if user_id is not None: + user = db.get(User, user_id) + if user is None: + raise SystemExit(f"user_id={user_id} 不存在") + return user + user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + if user is None: + raise SystemExit(f"手机号 {phone} 没有对应用户(注意 phone 才是登录账号,username 是展示 ID)") + return user + + +def print_state(db, user: User, device_id: str | None, label: str) -> None: + """打印该用户当前的引导完成标记。--device-id 时只看那一台,便于确认没误伤别的设备。""" + stmt = ( + select(OnboardingCompletion.device_id, OnboardingCompletion.completed_at) + .where(OnboardingCompletion.user_id == user.id) + .order_by(OnboardingCompletion.completed_at.desc()) + ) + if device_id: + stmt = stmt.where(OnboardingCompletion.device_id == device_id) + rows = db.execute(stmt).all() + + print(f"--- {label} ---") + scope = f"device_id={device_id}" if device_id else "全部设备" + if not rows: + print(f" onboarding_completion({scope}): (无) → 该用户会走引导") + return + print(f" onboarding_completion({scope}): {len(rows)} 条 → 这些设备上会跳过引导") + for did, at in rows: + print(f" device_id={did} 完成于 {at}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="重置指定用户的新手引导,让其重新进引导页") + parser.add_argument("phone", nargs="?", default=DEFAULT_PHONE, + help=f"手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--user-id", type=int, default=None, help="直接按 user_id 定位,优先于 phone") + parser.add_argument("--device-id", default=None, + help="只重置这一台设备(硬件级 ANDROID_ID);默认重置该用户所有设备") + parser.add_argument("--dry-run", action="store_true", help="预览,最后回滚不落库") + parser.add_argument("--force", action="store_true", help="非 dev 环境也允许改库(仅删引导标记,不涉及账目)") + args = parser.parse_args() + + if not args.dry_run and settings.APP_ENV != "dev" and not args.force: + raise SystemExit( + f"APP_ENV={settings.APP_ENV},默认只有 dev 能改库。确认要在该环境重置请加 --force" + "(--dry-run 只读,任意环境可跑)" + ) + + db = SessionLocal() + try: + user = resolve_user(db, args.phone, args.user_id) + print(f"DB: {settings.DATABASE_URL} APP_ENV: {settings.APP_ENV}") + print(f"用户: id={user.id} phone={user.phone} username={user.username}") + print_state(db, user, args.device_id, "before") + + stmt = delete(OnboardingCompletion).where(OnboardingCompletion.user_id == user.id) + if args.device_id: + stmt = stmt.where(OnboardingCompletion.device_id == args.device_id) + deleted = db.execute(stmt).rowcount + + if not deleted: + # 没记录本来就会走引导 —— 常见于:换了新设备、或运营/客户端已经重置过一次。 + print("该用户(该范围内)本来就没有完成标记,已经会走引导了,无需处理。") + db.rollback() + else: + print_state(db, user, args.device_id, "after") + if args.dry_run: + db.rollback() + print(f"(dry-run:以上 after 为预览,已回滚,库没动;真跑会删 {deleted} 条)") + return + db.commit() + print(f"完成:删掉 {deleted} 条完成标记,{user.phone} 下次登录会重走新手引导。") + + # 测试号无论库里有没有记录都恒走引导(见 app/core/test_account.py),提醒一句免得白跑 + if settings.test_account_phone and user.phone == settings.test_account_phone: + print(f"提示:{user.phone} 是配置的测试账号(TEST_ACCOUNT_PHONE)," + "登录响应 onboarding_completed 恒为 false、本就每次都走引导,无需重置。") + + print("提醒:客户端是在登录响应 / 启动时查 onboarding/status 的,已经在首页的 App 不会自动跳转," + "退出登录重进(或杀掉重开)才会看到引导页。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/reset_signin_today.py b/scripts/reset_signin_today.py new file mode 100644 index 0000000..afff914 --- /dev/null +++ b/scripts/reset_signin_today.py @@ -0,0 +1,218 @@ +"""撤销指定用户「今天已签到」的状态,让今天可以重新签到,方便反复测试签到流程。 + +与 reset_signin.py 的区别:那个删**全部**签到历史(连续天数从头再来);本脚本只精确撤销 +**今天**这一次,昨天及以前的记录原样保留 —— 所以重签后 cycle_day / streak 会接着昨天继续, +7 天循环的档位不会被打乱,可以连着好几天测「第 N 档」的奖励。 + +默认是**完整撤销**(等于今天这次签到从没发生过): + 1. 删 signin_record 今天这行 → 今天变回未签到 + 2. 删今天的 signin 金币流水,并把金币从 coin_account 余额 / 累计收益里扣回 + +金币默认要退:签到流水**没有**唯一索引拦重复(ux_coin_transaction_task_ref 只覆盖 +biz_type LIKE 'task%'),不退的话每测一轮余额就白涨一次奖励,coin_transaction 里还会堆出 +同一 ref_id(日期)的重复流水,收益明细页会看到两条今天的签到。真想留着奖励用 --keep-coins。 + +例外:签到的金币若已被兑换成现金(余额已不够退),**自动跳过退款**并保留今天的签到流水。 +因为 coin_balance 必须恒等于流水总和,硬退会把余额退成负数 —— 夹到 0 又会吃掉别处赚的金币, +两种做法都会让账对不上。这时重签会再发一次奖励,余额多涨一档,属可接受的测试噪音。 + +用法(在项目根、已 pip install -e . 的环境里跑): + python scripts/reset_signin_today.py # 默认测试号 11111111111 + python scripts/reset_signin_today.py 13800138000 # 指定手机号 + python scripts/reset_signin_today.py --user-id 5 # 直接指定 user_id + python scripts/reset_signin_today.py --dry-run # 预览(照常执行再回滚),不落库 + python scripts/reset_signin_today.py --keep-coins # 只删签到记录,保留已发金币 + +「今天」直接复用 app.core.rewards.cn_today(北京时间),与签到判重同源,不自己算时区。 +走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**只允许 APP_ENV=dev 时改库** +(--dry-run 只读,任何环境都能跑)。 +""" +from __future__ import annotations + +import argparse +import sys + +from sqlalchemy import select + +from app.core.config import settings +from app.core.rewards import cn_today +from app.db.session import SessionLocal, engine +from app.models.signin import SigninRecord +from app.models.user import User +from app.models.wallet import CoinAccount, CoinTransaction +from app.repositories import signin as crud_signin + +# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +# dev 下 engine 是 echo=True(APP_DEBUG),几十行 SQL 会把前后对比刷没。echo 走 SQLAlchemy 自己的 +# InstanceLogger,不吃 logging.setLevel,只能改 engine.echo。 +engine.echo = False + +DEFAULT_PHONE = "11111111111" + + +def resolve_user(db, phone: str, user_id: int | None) -> User: + if user_id is not None: + user = db.get(User, user_id) + if user is None: + raise SystemExit(f"user_id={user_id} 不存在") + return user + user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + if user is None: + raise SystemExit(f"手机号 {phone} 没有对应用户(注意 phone 才是登录账号,username 是展示 ID)") + return user + + + +def print_state(db, user: User, today, label: str) -> None: + print(f"--- {label} ---") + rec = db.execute( + select(SigninRecord).where( + SigninRecord.user_id == user.id, SigninRecord.signin_date == today + ) + ).scalar_one_or_none() + if rec is None: + print(f" signin_record {today}: (无)") + else: + print(f" signin_record {today}: 第{rec.cycle_day}档 连续{rec.streak}天 +{rec.coin_awarded}金币") + + last = db.execute( + select(SigninRecord.signin_date) + .where(SigninRecord.user_id == user.id) + .order_by(SigninRecord.signin_date.desc()) + .limit(1) + ).scalar_one_or_none() + print(f" 最近一次签到: {last or '(从未签到)'}") + + + rows = db.execute( + select(CoinTransaction).where( + CoinTransaction.user_id == user.id, + CoinTransaction.biz_type == "signin", + CoinTransaction.ref_id == today.isoformat(), + ) + ).scalars().all() + print(f" coin_transaction(signin, 今天): {len(rows)} 条 / {sum(r.amount for r in rows)} 金币") + + acc = db.get(CoinAccount, user.id) + if acc is None: + print(" coin_account: (无)") + else: + print(f" coin_account: coin={acc.coin_balance} earned={acc.total_coin_earned}") + + # 用 App 自己的 get_status 复核,而不是脚本里重算一遍规则 —— 这行就是客户端会看到的 + st = crud_signin.get_status(db, user.id) + print(f" [签到接口] can_claim={st.can_claim} today_signed={st.today_signed} " + f"今天第{st.today_cycle_day}档({st.today_coin}金币) 已连续{st.consecutive_days}天") + + +def refund_today(db, user_id: int, today) -> None: + """退回今天签到(含膨胀)发的金币:删流水 + 扣余额。 + + 不变量:coin_balance 必须恒等于流水总和。所以余额不够退时**整笔跳过**,而不是硬退成 + 负数、或夹到 0 —— 夹到 0 会吃掉用户在别处赚的金币,两种做法都会让余额和流水对不上。 + """ + rows = list(db.execute( + select(CoinTransaction).where( + CoinTransaction.user_id == user_id, + CoinTransaction.biz_type == "signin", + CoinTransaction.ref_id == today.isoformat(), + ) + ).scalars().all()) + if not rows: + return + acc = db.get(CoinAccount, user_id) + if acc is None: + return + + # 从最近一笔往回退,退到余额兜不住为止:正常情况下今天只有一笔,整笔退掉 = 干净的撤销。 + # 少数情况今天堆了多笔(上一轮测试时金币已被兑换、退不掉而留下的),这样也能保证 + # 「本轮新发的那笔」一定被退掉 —— 否则每测一轮余额就永久多涨一档。 + rows.sort(key=lambda r: r.id, reverse=True) + refundable: list[CoinTransaction] = [] + total = 0 + for r in rows: + if total + r.amount > acc.coin_balance: + break + refundable.append(r) + total += r.amount + + for r in refundable: + db.delete(r) + if total: + acc.coin_balance -= total + acc.total_coin_earned = max(0, acc.total_coin_earned - total) + print(f" 已退回 {total} 金币({len(refundable)}/{len(rows)} 笔)") + + stuck = len(rows) - len(refundable) + if stuck: + # 典型场景:签完就把金币兑换成现金了(exchange_out),这笔奖励已经变成 cash_balance_cents, + # 余额里已经没有它了。硬退会把余额退成负数 / 夹到 0 又会吃掉别处赚的金币,两者都会让账对不上。 + print(f" ⚠️ 还有 {stuck} 笔今天的签到流水退不掉(金币已被兑换/花掉,余额 {acc.coin_balance} 兜不住)," + f"原样保留 —— 硬退会让余额和流水总和对不上。") + print(" → 收益明细今天会多出几条签到记录,不影响签到功能测试;想彻底清干净用 reset_signin.py --with-coins。") + + +def main() -> None: + parser = argparse.ArgumentParser(description="撤销用户今天的签到,让今天能重新签") + parser.add_argument("phone", nargs="?", default=DEFAULT_PHONE, + help=f"手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--user-id", type=int, default=None, help="直接按 user_id 定位,优先于 phone") + parser.add_argument("--keep-coins", action="store_true", + help="不退已发金币(余额会越测越高,且留下重复流水)") + parser.add_argument("--dry-run", action="store_true", help="预览,最后回滚不落库") + args = parser.parse_args() + + if not args.dry_run and settings.APP_ENV != "dev": + raise SystemExit(f"APP_ENV={settings.APP_ENV},拒绝改库(只有 dev 能改;--dry-run 可任意环境)") + + today = cn_today() + db = SessionLocal() + try: + user = resolve_user(db, args.phone, args.user_id) + print(f"DB: {settings.DATABASE_URL}") + print(f"用户: id={user.id} phone={user.phone} 今天(北京): {today} keep_coins: {args.keep_coins}") + print_state(db, user, today, "before") + + # 今天的签到记录 —— 只删今天,昨天及以前保留,重签后 streak 接着涨 + rec = db.execute( + select(SigninRecord).where( + SigninRecord.user_id == user.id, SigninRecord.signin_date == today + ) + ).scalar_one_or_none() + if rec is not None: + db.delete(rec) + + if rec is None: + print("今天本来就没签到,无需处理。") + db.rollback() + return + + # 退金币 + if args.keep_coins: + print("(--keep-coins:保留已发金币,流水和余额不动)") + else: + refund_today(db, user.id, today) + # 注:更早流水的 balance_after 是当时的快照,不回改 —— 收益明细里历史行的 + # 余额列会与现余额对不上,dev 测试库无妨。 + + # SessionLocal 是 autoflush=False,不 flush 的话下面 print_state 的 select + # 读到的还是删之前的旧行,"after" 会骗人 + db.flush() + print_state(db, user, today, "after") + + if args.dry_run: + db.rollback() + print("(dry-run:以上 after 为预览,已回滚,库没动)") + return + db.commit() + print(f"完成:{user.phone} 今天({today})可以重新签到了。" + f"提醒:App 内存状态不会自动同步,杀掉重进福利页(当天未签到)会重新自动弹签到弹窗。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index ebc0c98..1b1603b 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -356,3 +356,272 @@ def test_callback_disabled_returns_503(client, monkeypatch) -> None: # 503 发生在验签/发奖之前,不需要真实用户 r = _callback(client, _signed(1, "trans_disabled")) assert r.status_code == 503, r.text + + +# ===== 按 ad_session_id 查权威发奖结果(GET /reward-result/{ad_session_id})===== +# 客户端看完广告轮询它拿弹窗金额:只认 status='granted' 且 coin>0,其余一律不弹。 + + +def _reward_result(client, token: str, session_id: str): + return client.get(f"/api/v1/ad/reward-result/{session_id}", headers=_auth(token)) + + +def _session_extra(session_id: str, **kv: str) -> str: + return json.dumps({"ad_session_id": session_id, **kv}) + + +def test_reward_result_pending_when_s2s_not_arrived(client) -> None: + """S2S 还没回调 → 200 + pending(**不是 404**),客户端据此继续轮询。""" + token = _login(client, "13800003601") + + r = _reward_result(client, token, "sess-not-yet-arrived") + assert r.status_code == 200, r.text + assert r.json() == { + "ad_session_id": "sess-not-yet-arrived", + "status": "pending", + "coin": None, + # 没记录 → 连属于哪一轮都不知道,累计值一并为 null(不是 0,0 会被读成"本轮没赚到") + "round_coin": None, + } + + +def test_reward_result_returns_granted_coin(client) -> None: + """S2S 发奖后按会话查 → granted + 本次真实到账额(与钱包入账一致)。""" + phone = "13800003602" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-granted-1" + + r = _callback( + client, + _signed(uid, "trans_rr_1", ecpm="200", extra=_session_extra(session_id)), + ) + assert r.json() == {"is_verify": True, "reason": 0} + + expected = calculate_ad_reward_coin("200", 1) + body = _reward_result(client, token, session_id).json() + assert body["status"] == "granted" + assert body["coin"] == expected + # 弹窗金额必须等于真实入账,这正是本接口存在的意义(不用余额差估算) + assert _coin_balance(client, token) == expected + + +def test_reward_result_prefers_granted_over_earlier_noshow(client) -> None: + """竞态:客户端先报 closed_early、S2S 随后才到 → 同一会话两条记录,必须返回 granted 那条。 + + 只按 created_at 取最近一条是不够的(SQLite 下两条可能同一时间戳),故仓储层显式优先 granted。 + """ + phone = "13800003603" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-race-noshow" + + # 1) 客户端以为没发奖,先留痕 + r = client.post( + "/api/v1/ad/reward-noshow", + json={"ad_session_id": session_id, "watched_seconds": 3}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "closed_early" + assert _reward_result(client, token, session_id).json()["status"] == "closed_early" + + # 2) S2S 姗姗来迟,真发了钱 + _callback(client, _signed(uid, "trans_rr_race", ecpm="200", extra=_session_extra(session_id))) + + body = _reward_result(client, token, session_id).json() + assert body["status"] == "granted" + assert body["coin"] == calculate_ad_reward_coin("200", 1) + + +def test_reward_result_capped_reports_zero_not_popup(client) -> None: + """达每日上限 → capped + coin=0;客户端不弹「获得 0 金币」。""" + phone = "13800003604" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-capped-1" + + db = SessionLocal() + try: + db.add( + AdRewardRecord( + trans_id="trans_rr_capped", user_id=_user_id(phone), coin=0, status="capped", + reward_scene="reward_video", ad_session_id=session_id, reward_date="2026-07-17", + ) + ) + db.commit() + finally: + db.close() + assert uid # 记录挂在该用户名下 + + body = _reward_result(client, token, session_id).json() + assert body["status"] == "capped" + assert body["coin"] == 0 + + +def test_reward_result_scoped_to_owner(client) -> None: + """别人的会话查不到(按 user_id 收窄)→ pending,不泄漏他人发奖结果。""" + phone_a = "13800003605" + token_a = _login(client, phone_a) + uid_a = _user_id(phone_a) + token_b = _login(client, "13800003606") + session_id = "sess-owner-only" + + _callback(client, _signed(uid_a, "trans_rr_owner", ecpm="200", extra=_session_extra(session_id))) + + assert _reward_result(client, token_a, session_id).json()["status"] == "granted" + assert _reward_result(client, token_b, session_id).json()["status"] == "pending" + + +def test_reward_result_requires_auth(client) -> None: + """无 Bearer → 401,不裸奔。""" + assert client.get("/api/v1/ad/reward-result/sess-anon-1").status_code == 401 + + +# ===== 膨胀轮累计(boost_round_id → reward-result.round_coin)===== +# 不变量:弹窗数字 == 本轮实际到账之和 == 余额涨幅。三者对不上用户就认为少发了钱。 + + +def _round_extra(session_id: str, round_id: str | None = None, **kv: str) -> str: + data = {"ad_session_id": session_id, **kv} + if round_id is not None: + data["boost_round_id"] = round_id + return json.dumps(data) + + +def test_round_coin_accumulates_across_ads_in_same_round(client) -> None: + """一轮连看两条 → round_coin 逐条累计,且等于余额涨幅(第七节验收 1、2 步)。""" + phone = "13800003701" + token = _login(client, phone) + uid = _user_id(phone) + round_id = "b7e1c93a4f6d802b" + + _callback(client, _signed( + uid, "trans_round_1", ecpm="200", extra=_round_extra("sess-r1-a", round_id))) + first = calculate_ad_reward_coin("200", 1) + body = _reward_result(client, token, "sess-r1-a").json() + assert body["coin"] == first + assert body["round_coin"] == first # 第 1 条:本轮累计 == 本条 + + _callback(client, _signed( + uid, "trans_round_2", ecpm="200", extra=_round_extra("sess-r1-b", round_id))) + second = calculate_ad_reward_coin("200", 2) # LT 因子递减,第 2 条比第 1 条少 + body = _reward_result(client, token, "sess-r1-b").json() + assert body["coin"] == second + assert body["round_coin"] == first + second # 累计 = 两条之和 + + # 弹窗数字必须等于真实余额涨幅 —— 这条不变量是整个方案的目的 + assert _coin_balance(client, token) == first + second + + +def test_new_round_restarts_accumulation(client) -> None: + """换新轮 id → round_coin 从头累计,不接着上一轮往上加(第七节最后一句验收)。""" + phone = "13800003702" + token = _login(client, phone) + uid = _user_id(phone) + + _callback(client, _signed(uid, "trans_r2_old", ecpm="200", extra=_round_extra("sess-r2-a", "round-old"))) + old = _reward_result(client, token, "sess-r2-a").json()["round_coin"] + assert old > 0 + + _callback(client, _signed(uid, "trans_r2_new", ecpm="200", extra=_round_extra("sess-r2-b", "round-new"))) + body = _reward_result(client, token, "sess-r2-b").json() + assert body["round_coin"] == body["coin"] # 新轮 = 只有本条 + assert body["round_coin"] != old + body["coin"] + + +def test_round_coin_null_without_round_id(client) -> None: + """extra 没带 boost_round_id(老客户端 / GroMore 丢字段)→ round_coin=null,客户端退回显示单条。""" + phone = "13800003703" + token = _login(client, phone) + uid = _user_id(phone) + + _callback(client, _signed(uid, "trans_r3", ecpm="200", extra=_round_extra("sess-r3-noround", None))) + body = _reward_result(client, token, "sess-r3-noround").json() + assert body["coin"] == calculate_ad_reward_coin("200", 1) + assert body["round_coin"] is None + + +def test_round_coin_null_when_pending(client) -> None: + """S2S 未到账 → 没有记录 → 连轮 id 都不知道,round_coin 也是 null(不是 0)。""" + token = _login(client, "13800003704") + body = _reward_result(client, token, "sess-r4-pending").json() + assert body == { + "ad_session_id": "sess-r4-pending", + "status": "pending", + "coin": None, + "round_coin": None, + } + + +def test_round_coin_returned_on_capped(client) -> None: + """撞每日上限那条不是 granted,但 round_coin **仍返本轮累计**(该条按 0 计)。 + + 客户端的限额 toast 要显示前面几条已到账的总额,不能是空。 + """ + phone = "13800003705" + token = _login(client, phone) + uid = _user_id(phone) + round_id = "round-capped" + + _callback(client, _signed(uid, "trans_cap_ok", ecpm="200", extra=_round_extra("sess-cap-a", round_id))) + earned = _reward_result(client, token, "sess-cap-a").json()["round_coin"] + assert earned > 0 + + # 手插一条同轮的 capped 记录(跑满 500 次太慢),模拟第 N 条撞上限 + db = SessionLocal() + try: + db.add(AdRewardRecord( + trans_id="trans_cap_hit", user_id=uid, coin=0, status="capped", + reward_scene="reward_video", ad_session_id="sess-cap-b", + reward_date="2026-07-20", boost_round_id=round_id, + )) + db.commit() + finally: + db.close() + + body = _reward_result(client, token, "sess-cap-b").json() + assert body["status"] == "capped" + assert body["coin"] == 0 # 这条没发钱 + assert body["round_coin"] == earned # 但本轮累计照常返回 + + +def test_round_coin_scoped_to_owner(client) -> None: + """轮 id 是客户端生成的,不能跨用户信任:拿别人的轮 id 查不到别人的金币。""" + phone_a = "13800003706" + token_a = _login(client, phone_a) + uid_a = _user_id(phone_a) + phone_b = "13800003707" + token_b = _login(client, phone_b) + uid_b = _user_id(phone_b) + shared_round = "round-collision" + + _callback(client, _signed(uid_a, "trans_own_a", ecpm="200", extra=_round_extra("sess-own-a", shared_round))) + a_total = _reward_result(client, token_a, "sess-own-a").json()["round_coin"] + + # B 用同一个轮 id(伪造或碰撞)看一条:B 的累计里不能混进 A 的钱 + _callback(client, _signed(uid_b, "trans_own_b", ecpm="200", extra=_round_extra("sess-own-b", shared_round))) + b_body = _reward_result(client, token_b, "sess-own-b").json() + assert b_body["round_coin"] == b_body["coin"] + assert b_body["round_coin"] < a_total + b_body["coin"] + + +def test_test_grant_accepts_boost_round_id(client, monkeypatch) -> None: + """debug 的 test-grant 不经 S2S、拿不到 mediaExtra,轮 id 由 body 补 → 本地也能验累计。""" + monkeypatch.setattr(settings, "AD_REWARD_TEST_GRANT_ENABLED", True) + token = _login(client, "13800003708") + round_id = "round-testgrant" + + coins = [] + for i in range(2): + r = client.post( + "/api/v1/ad/test-grant", + json={"reward_scene": "reward_video", "boost_round_id": round_id, + "ad_session_id": f"sess-tg-{i}-padding"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + coins.append(r.json()["coin"]) + + body = _reward_result(client, token, "sess-tg-1-padding").json() + assert body["round_coin"] == sum(coins) diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 07af7bc..bd99334 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -11,7 +11,6 @@ from app.core.rewards import ( COIN_PER_CENT, COIN_PER_YUAN, MIN_EXCHANGE_COIN, - SIGNIN_BOOST_COIN, SIGNIN_REWARDS, TASK_ENABLE_NOTIFICATION, TASK_REWARDS, @@ -106,48 +105,6 @@ def test_signin_flow(client) -> None: assert txn["balance_after"] == SIGNIN_REWARDS[0] -def test_signin_boost_flow(client) -> None: - """签到后看广告膨胀 → S2S 固定补发 2000 金币,每天只能膨胀一次。""" - phone = "13800001011" - token = _login(client, phone) - - 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"] - - with SessionLocal() as db: - user = get_user_by_phone(db, phone) - assert user is not None - uid = user.id - - extra = json.dumps({"reward_scene": "signin_boost", "ad_session_id": "signin-session-1"}) - r = client.get( - "/api/v1/ad/pangle-callback", - params=_signed_ad_callback(uid, "signin-boost-trans-1", extra=extra, ecpm="200"), - ) - assert r.status_code == 200, r.text - assert r.json() == {"is_verify": True, "reason": 0} - - r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "signin-boost-trans-1"}, headers=_auth(token)) - assert r.status_code == 200, r.text - body = r.json() - assert body["coin_awarded"] == SIGNIN_BOOST_COIN - assert body["coin_balance"] == first_coin + SIGNIN_BOOST_COIN - - r = client.get( - "/api/v1/ad/pangle-callback", - params=_signed_ad_callback(uid, "signin-boost-trans-2", extra=extra, ecpm="200"), - ) - assert r.status_code == 200, r.text - - r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) - 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: """打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。"""