diff --git a/app/core/rewards.py b/app/core/rewards.py index 284dd97..e7d74d4 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -233,10 +233,16 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值 会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于 所有真实值,不影响正规发奖。 + + 下限(2026-07):有真实正 eCPM 时单份至少 1 金币——低 eCPM 单份收益四舍五入成 0 时兜底为 1, + 避免用户看了广告却因数值太小被记 too_short 零发。eCPM 缺失/为 0/非法(没有真实广告价值)仍返 0, + 不凭空铸币、不破坏 ecpm_missing 语义;防刷上限仍由 AD_ECPM_MAX_FEN 钳顶把守。 """ ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0) + if ecpm_yuan <= 0: + return 0 yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this) - return max(0, round(yuan * COIN_PER_YUAN)) + return max(1, round(yuan * COIN_PER_YUAN)) # 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认 diff --git a/tests/test_ad_reward_coin_floor.py b/tests/test_ad_reward_coin_floor.py new file mode 100644 index 0000000..d374bec --- /dev/null +++ b/tests/test_ad_reward_coin_floor.py @@ -0,0 +1,59 @@ +"""发奖公式下限:有真实正 eCPM 时最低发 1 金币,eCPM 缺失/为 0 仍发 0。 + +针对「eCPM 过低时公式四舍五入成 0 金币 → 被记 too_short 不发」的问题:只要广告有真实 +正 eCPM,单份金币至少 1(不再 0);但 eCPM 缺失/为 0/非法(没有真实广告价值)仍发 0, +不凭空铸币、不破坏 ecpm_missing 语义。公式是发奖与后台审计对账的唯一口径,改这一处两边同源。 +""" +from __future__ import annotations + +from app.core.rewards import calculate_ad_reward_coin + + +def test_low_positive_ecpm_floors_to_one_coin() -> None: + """真实但极低的 eCPM 原本四舍五入成 0 金币,现在兜底为 1(线上 record 4667 的 43 分场景)。""" + # 43 分 = ¥0.43 CPM,因子1=0.1,重度用户 LT 第 69 条=1.0 → 0.43/1000×0.1×1.0×10000=0.43 → 旧口径 round=0 + assert calculate_ad_reward_coin("43", 69) == 1 + # 更低的 5 分同理:算出来 <0.5,旧口径也是 0 + assert calculate_ad_reward_coin("5", 11) == 1 + + +def test_zero_or_missing_ecpm_stays_zero() -> None: + """eCPM 缺失 / 为 0 / 非法(没有真实广告价值)不兜底,仍发 0。""" + assert calculate_ad_reward_coin("0", 1) == 0 + assert calculate_ad_reward_coin(None, 1) == 0 + assert calculate_ad_reward_coin("", 1) == 0 + assert calculate_ad_reward_coin("abc", 1) == 0 + + +def test_normal_ecpm_value_unchanged() -> None: + """正常量级 eCPM 本就 ≥1,下限不改变其取值(回归保护)。""" + # 200 分 = ¥2 CPM,因子1=0.1,LT 第 1 条=2.0 → 2/1000×0.1×2.0×10000 = 4 + assert calculate_ad_reward_coin("200", 1) == 4 + + +def test_feed_reward_low_ecpm_grants_one_coin_instead_of_too_short() -> None: + """端到端:低 eCPM 的信息流广告看满一份(≥10s),原本单份金币算成 0 被记 too_short, + 现在兜底发 1 金币、状态 granted(线上 record 4667 那类「看了却零发」的修复)。""" + from app.db.session import SessionLocal + from app.models.user import User + from app.repositories.ad_feed_reward import grant_feed_reward + + db = SessionLocal() + try: + user = User(phone="19900000043", username="feedfloor43", register_channel="sms") + db.add(user) + db.commit() + db.refresh(user) + + rec = grant_feed_reward( + db, user.id, + client_event_id="feed-floor-low-ecpm-0001", + ecpm="2", # ¥0.02 CPM,单份收益四舍五入 <0.5 → 旧口径算成 0 + duration_seconds=15, # ≥10s → 看满一份,不走「时长不足」的 else 分支 + ad_type="draw", + feed_scene="comparison", + ) + assert rec.status == "granted" + assert rec.coin == 1 + finally: + db.close()