Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84251770b4 | |||
| 2e91c9f72f |
+8
-4
@@ -234,13 +234,17 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
|
||||
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
|
||||
所有真实值,不影响正规发奖。
|
||||
|
||||
下限(2026-07):有真实正 eCPM 时单份至少 1 金币——低 eCPM 单份收益四舍五入成 0 时兜底为 1,
|
||||
避免用户看了广告却因数值太小被记 too_short 零发。eCPM 缺失/为 0/非法(没有真实广告价值)仍返 0,
|
||||
不凭空铸币、不破坏 ecpm_missing 语义;防刷上限仍由 AD_ECPM_MAX_FEN 钳顶把守。
|
||||
下限(2026-08,产品口径「看了就保底 1」):**任何输入**都至少 1 金币,与前端展示公式
|
||||
FeedRewardFormula.singleUnitCoin 完全对齐(那边注释:"无论 eCPM 是否为空、非法或非正数,
|
||||
单条广告最低都发 1 金币,不能出现 +0")。此前 eCPM 缺失/为 0 返 0,造成两端不一致:
|
||||
小球显示 +1、后端信息流记 too_short 零发;激励视频侧 "0" 字符串还是 truthy、绕过
|
||||
ecpm_missing 的 `if not ecpm_raw` 判定,落成 granted 0 币且白占当日额度/LT 计数。
|
||||
防刷影响:伪造 eCPM≤0 每天至多多骗 每日上限×1 金币(500 金币=0.05 元),量级可控;
|
||||
天价伪造仍由 AD_ECPM_MAX_FEN 钳顶把守。
|
||||
"""
|
||||
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
|
||||
if ecpm_yuan <= 0:
|
||||
return 0
|
||||
return 1 # 保底:缺失/为 0/非法也发 1(镜像前端 validEcpmFen 判非法 → 直接返 1)
|
||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
|
||||
return max(1, round(yuan * COIN_PER_YUAN))
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from datetime import datetime, timedelta
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
@@ -54,7 +55,7 @@ _SEED_MAX_CENTS = 100000
|
||||
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
|
||||
_REAL_ROWS_TTL_SECONDS = 30
|
||||
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
|
||||
_real_rows_cache: dict = {"at": None, "rows": None}
|
||||
_real_rows_cache: dict = {"at": None, "rows": None, "test_phones": None}
|
||||
|
||||
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
|
||||
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
|
||||
@@ -198,10 +199,16 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
|
||||
"""
|
||||
now = datetime.now(CN_TZ)
|
||||
test_phones = tuple(sorted(settings.test_account_phones))
|
||||
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
|
||||
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
|
||||
if (
|
||||
cached is not None
|
||||
and at is not None
|
||||
and _real_rows_cache["test_phones"] == test_phones
|
||||
and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS
|
||||
):
|
||||
return cached
|
||||
rows = db.execute(
|
||||
stmt = (
|
||||
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname)
|
||||
.join(User, User.id == ComparisonRecord.user_id)
|
||||
.where(
|
||||
@@ -211,9 +218,12 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(_REAL_ROWS_FETCH_CAP)
|
||||
).all()
|
||||
)
|
||||
if test_phones:
|
||||
stmt = stmt.where(User.phone.not_in(test_phones))
|
||||
rows = db.execute(stmt).all()
|
||||
out = [(int(uid), int(sc), nick) for uid, sc, nick in rows]
|
||||
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
|
||||
_real_rows_cache.update(rows=out, at=now, test_phones=test_phones)
|
||||
return out
|
||||
|
||||
|
||||
@@ -340,7 +350,7 @@ def list_real_records(
|
||||
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
|
||||
pool: list[tuple[int, dict]] = []
|
||||
if mode != "seed":
|
||||
rows = db.execute(
|
||||
stmt = (
|
||||
select(
|
||||
ComparisonRecord.user_id,
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
@@ -355,7 +365,11 @@ def list_real_records(
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(_REAL_BROWSE_CAP)
|
||||
).all()
|
||||
)
|
||||
test_phones = tuple(sorted(settings.test_account_phones))
|
||||
if test_phones:
|
||||
stmt = stmt.where(User.phone.not_in(test_phones))
|
||||
rows = db.execute(stmt).all()
|
||||
for uid, sc, nick, ca in rows:
|
||||
pool.append((
|
||||
int(uid),
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""发奖公式下限:有真实正 eCPM 时最低发 1 金币,eCPM 缺失/为 0 仍发 0。
|
||||
"""发奖公式下限:看了就保底 1 金币,与前端展示公式完全对齐。
|
||||
|
||||
针对「eCPM 过低时公式四舍五入成 0 金币 → 被记 too_short 不发」的问题:只要广告有真实
|
||||
正 eCPM,单份金币至少 1(不再 0);但 eCPM 缺失/为 0/非法(没有真实广告价值)仍发 0,
|
||||
不凭空铸币、不破坏 ecpm_missing 语义。公式是发奖与后台审计对账的唯一口径,改这一处两边同源。
|
||||
产品口径(2026-08「看了就保底 1」):`calculate_ad_reward_coin` 对**任何输入**都至少返回 1,
|
||||
镜像客户端 FeedRewardFormula.singleUnitCoin(那边:eCPM 空/非法/非正数都返 1)。此前 eCPM
|
||||
缺失/为 0 返 0,与前端小球显示的 +1 不一致,且信息流侧把这类看满一份的广告记成 too_short 零发。
|
||||
公式是发奖与后台审计对账的唯一口径,改这一处两边同源。
|
||||
|
||||
注:激励视频(S2S 回调)路径在公式之前还有一道 `if not ecpm_raw` 早退——**完全没上报 eCPM**
|
||||
的回调仍记 ecpm_missing 零发(见 test_ad_reward.test_callback_without_ecpm_records_exception),
|
||||
那是"回调缺字段"的数据完整性闸,区别于"广告如实上报 eCPM=0"(=看了真广告 → 保底 1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,19 +15,23 @@ 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 分场景)。"""
|
||||
"""真实但极低的 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_zero_or_missing_ecpm_also_floors_to_one() -> None:
|
||||
"""eCPM 为 0 / 缺失 / 非法都保底 1(2026-08「看了就保底 1」,与前端 FeedRewardFormula 对齐)。
|
||||
|
||||
尤其 "0"(广告如实上报零价值)此前返 0,导致小球显示 +1、后端信息流记 too_short 零发的
|
||||
前后端不一致 —— 现在两端都 1。
|
||||
"""
|
||||
assert calculate_ad_reward_coin("0", 1) == 1
|
||||
assert calculate_ad_reward_coin(None, 1) == 1
|
||||
assert calculate_ad_reward_coin("", 1) == 1
|
||||
assert calculate_ad_reward_coin("abc", 1) == 1
|
||||
|
||||
|
||||
def test_normal_ecpm_value_unchanged() -> None:
|
||||
@@ -57,3 +66,31 @@ def test_feed_reward_low_ecpm_grants_one_coin_instead_of_too_short() -> None:
|
||||
assert rec.coin == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_feed_reward_zero_ecpm_grants_one_coin() -> None:
|
||||
"""端到端:eCPM 如实上报 0(用户反馈的真实广告返回 eCPM=0 场景),看满一份也保底 1、
|
||||
状态 granted —— 与前端小球显示的 +1 一致,不再前显示后零发。"""
|
||||
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="19900000044", username="feedzero44", register_channel="sms")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
rec = grant_feed_reward(
|
||||
db, user.id,
|
||||
client_event_id="feed-zero-ecpm-0001",
|
||||
ecpm="0", # 广告如实上报 eCPM=0
|
||||
duration_seconds=15, # 看满一份
|
||||
ad_type="draw",
|
||||
feed_scene="comparison",
|
||||
)
|
||||
assert rec.status == "granted"
|
||||
assert rec.coin == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import ops_marquee
|
||||
|
||||
|
||||
def _clear_real_rows_cache() -> None:
|
||||
ops_marquee._real_rows_cache.update(at=None, rows=None, test_phones=None)
|
||||
|
||||
|
||||
def test_real_marquee_records_exclude_configured_test_account(monkeypatch) -> None:
|
||||
legacy_test_phone = "19900009991"
|
||||
listed_test_phone = "19900009992"
|
||||
real_phone = "19900009993"
|
||||
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", legacy_test_phone)
|
||||
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONES", listed_test_phone)
|
||||
_clear_real_rows_cache()
|
||||
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
legacy_test_user = User(
|
||||
phone=legacy_test_phone,
|
||||
username="29900009991",
|
||||
register_channel="sms",
|
||||
)
|
||||
listed_test_user = User(
|
||||
phone=listed_test_phone,
|
||||
username="29900009992",
|
||||
register_channel="sms",
|
||||
)
|
||||
real_user = User(
|
||||
phone=real_phone,
|
||||
username="29900009993",
|
||||
register_channel="sms",
|
||||
)
|
||||
db.add_all([legacy_test_user, listed_test_user, real_user])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
ComparisonRecord(
|
||||
user_id=legacy_test_user.id,
|
||||
trace_id="marquee-legacy-test-account-trace",
|
||||
status="success",
|
||||
saved_amount_cents=29_991,
|
||||
items=[],
|
||||
comparison_results=[],
|
||||
),
|
||||
ComparisonRecord(
|
||||
user_id=listed_test_user.id,
|
||||
trace_id="marquee-listed-test-account-trace",
|
||||
status="success",
|
||||
saved_amount_cents=29_992,
|
||||
items=[],
|
||||
comparison_results=[],
|
||||
),
|
||||
ComparisonRecord(
|
||||
user_id=real_user.id,
|
||||
trace_id="marquee-real-account-trace",
|
||||
status="success",
|
||||
saved_amount_cents=29_993,
|
||||
items=[],
|
||||
comparison_results=[],
|
||||
),
|
||||
])
|
||||
db.flush()
|
||||
|
||||
app_rows = ops_marquee._recent_real_rows(db)
|
||||
browse_rows, _ = ops_marquee.list_real_records(db, mode="real", limit=1_000)
|
||||
finally:
|
||||
_clear_real_rows_cache()
|
||||
|
||||
assert all(saved not in {29_991, 29_992} for _uid, saved, _nickname in app_rows)
|
||||
assert any(saved == 29_993 for _uid, saved, _nickname in app_rows)
|
||||
assert all(row["saved_amount_cents"] not in {29_991, 29_992} for row in browse_rows)
|
||||
assert any(row["saved_amount_cents"] == 29_993 for row in browse_rows)
|
||||
Reference in New Issue
Block a user