Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93a942dff1 | |||
| 34798ed7b5 |
@@ -43,7 +43,7 @@ _KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
|
|||||||
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
|
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
|
||||||
|
|
||||||
|
|
||||||
def _business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
def business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
||||||
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
|
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
|
||||||
prod_config = app_config.get_ad_config(db)
|
prod_config = app_config.get_ad_config(db)
|
||||||
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
|
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
|
||||||
@@ -320,10 +320,10 @@ def ad_revenue_report(
|
|||||||
|
|
||||||
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
|
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
|
||||||
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
|
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
|
||||||
business_code_ids: set[str] | None = None
|
business_ids: set[str] | None = None
|
||||||
if revenue_scope == "business":
|
if revenue_scope == "business":
|
||||||
business_code_ids = _business_code_ids(db, app_env)
|
business_ids = business_code_ids(db, app_env)
|
||||||
events = [e for e in events if e.get("our_code_id") in business_code_ids]
|
events = [e for e in events if e.get("our_code_id") in business_ids]
|
||||||
|
|
||||||
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||||
@@ -381,7 +381,7 @@ def ad_revenue_report(
|
|||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
app_env=app_env,
|
app_env=app_env,
|
||||||
our_code_ids=business_code_ids,
|
our_code_ids=business_ids,
|
||||||
)
|
)
|
||||||
if pangle_aggs:
|
if pangle_aggs:
|
||||||
by_date = {a["date"]: a for a in pangle_aggs}
|
by_date = {a["date"]: a for a in pangle_aggs}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core import rewards
|
from app.core import rewards
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.models.ad_ecpm import AdEcpmRecord
|
||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
from app.models.ad_reward import AdRewardRecord
|
from app.models.ad_reward import AdRewardRecord
|
||||||
from app.models.admin import AdminAuditLog
|
from app.models.admin import AdminAuditLog
|
||||||
@@ -1257,11 +1258,15 @@ def user_reward_stats(
|
|||||||
date_from: datetime | None = None,
|
date_from: datetime | None = None,
|
||||||
date_to: datetime | None = None,
|
date_to: datetime | None = None,
|
||||||
withdraw_source: str | None = None,
|
withdraw_source: str | None = None,
|
||||||
|
app_env: str | None = None,
|
||||||
|
revenue_scope: str = "all",
|
||||||
|
feed_scene: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
||||||
|
|
||||||
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
|
口径:激励视频/信息流奖励数量只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加)。
|
||||||
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
|
平均 Draw eCPM 与广告收益报表一致:基于 ad_ecpm_record 的全部 draw/feed 展示记录计算,
|
||||||
|
不以是否发奖为筛选条件。各「提现」= 该来源累计金币折现。
|
||||||
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
||||||
"""
|
"""
|
||||||
withdraw_source_conds = (
|
withdraw_source_conds = (
|
||||||
@@ -1296,30 +1301,68 @@ def user_reward_stats(
|
|||||||
|
|
||||||
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
|
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
|
||||||
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
|
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
|
||||||
|
business_ids: set[str] | None = None
|
||||||
|
if revenue_scope == "business":
|
||||||
|
# 与广告收益报表共用正式/测试业务代码位集合,避免两个页面随配置切换后再次漂移。
|
||||||
|
from app.admin.repositories.ad_revenue import business_code_ids
|
||||||
|
|
||||||
|
business_ids = business_code_ids(db, app_env)
|
||||||
|
|
||||||
|
rv_conds = [
|
||||||
|
AdRewardRecord.user_id == user_id,
|
||||||
|
AdRewardRecord.reward_scene == "reward_video",
|
||||||
|
AdRewardRecord.status == "granted",
|
||||||
|
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||||
|
]
|
||||||
|
if app_env is not None:
|
||||||
|
rv_conds.append(AdRewardRecord.app_env == app_env)
|
||||||
|
if business_ids is not None:
|
||||||
|
rv_conds.append(AdRewardRecord.our_code_id.in_(business_ids))
|
||||||
rv = db.execute(
|
rv = db.execute(
|
||||||
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
|
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
|
||||||
AdRewardRecord.user_id == user_id,
|
*rv_conds,
|
||||||
AdRewardRecord.reward_scene == "reward_video",
|
|
||||||
AdRewardRecord.status == "granted",
|
|
||||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
||||||
rv_coins = sum(r.coin for r in rv)
|
rv_coins = sum(r.coin for r in rv)
|
||||||
|
|
||||||
feed = db.execute(
|
feed_reward_conds = [
|
||||||
|
AdFeedRewardRecord.user_id == user_id,
|
||||||
|
AdFeedRewardRecord.status == "granted",
|
||||||
|
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||||
|
]
|
||||||
|
if app_env is not None:
|
||||||
|
feed_reward_conds.append(AdFeedRewardRecord.app_env == app_env)
|
||||||
|
if feed_scene is not None:
|
||||||
|
feed_reward_conds.append(AdFeedRewardRecord.feed_scene == feed_scene)
|
||||||
|
if business_ids is not None:
|
||||||
|
feed_reward_conds.append(AdFeedRewardRecord.our_code_id.in_(business_ids))
|
||||||
|
feed_rewards = db.execute(
|
||||||
select(
|
select(
|
||||||
AdFeedRewardRecord.unit_count,
|
AdFeedRewardRecord.unit_count,
|
||||||
AdFeedRewardRecord.ecpm_raw,
|
|
||||||
AdFeedRewardRecord.coin,
|
AdFeedRewardRecord.coin,
|
||||||
).where(
|
).where(
|
||||||
AdFeedRewardRecord.user_id == user_id,
|
*feed_reward_conds,
|
||||||
AdFeedRewardRecord.status == "granted",
|
|
||||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
feed_coins = sum(f.coin for f in feed_rewards)
|
||||||
feed_coins = sum(f.coin for f in feed)
|
|
||||||
|
feed_impression_conds = [
|
||||||
|
AdEcpmRecord.user_id == user_id,
|
||||||
|
AdEcpmRecord.ad_type.in_(("draw", "feed")),
|
||||||
|
*_window_conds(AdEcpmRecord.created_at, date_from, date_to),
|
||||||
|
]
|
||||||
|
if app_env is not None:
|
||||||
|
feed_impression_conds.append(AdEcpmRecord.app_env == app_env)
|
||||||
|
if feed_scene is not None:
|
||||||
|
feed_impression_conds.append(AdEcpmRecord.feed_scene == feed_scene)
|
||||||
|
if business_ids is not None:
|
||||||
|
feed_impression_conds.append(AdEcpmRecord.our_code_id.in_(business_ids))
|
||||||
|
feed_impressions = db.execute(
|
||||||
|
select(AdEcpmRecord.ecpm_raw).where(*feed_impression_conds)
|
||||||
|
).all()
|
||||||
|
# 与 ad_revenue.category_stats 相同:每次展示权重相同,非法原值按 parse_ecpm_fen 记 0。
|
||||||
|
feed_ecpms = [rewards.parse_ecpm_fen(row.ecpm_raw) for row in feed_impressions]
|
||||||
|
|
||||||
trad_coins = db.execute(
|
trad_coins = db.execute(
|
||||||
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
|
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
|
||||||
@@ -1338,7 +1381,7 @@ def user_reward_stats(
|
|||||||
"reward_video_count": len(rv),
|
"reward_video_count": len(rv),
|
||||||
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
|
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
|
||||||
"reward_video_cash_cents": _coins_to_cents(rv_coins),
|
"reward_video_cash_cents": _coins_to_cents(rv_coins),
|
||||||
"feed_count": int(sum(f.unit_count for f in feed)),
|
"feed_count": int(sum(f.unit_count for f in feed_rewards)),
|
||||||
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
|
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
|
||||||
"feed_cash_cents": _coins_to_cents(feed_coins),
|
"feed_cash_cents": _coins_to_cents(feed_coins),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ def get_user_reward_stats(
|
|||||||
withdraw_source: Annotated[
|
withdraw_source: Annotated[
|
||||||
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
||||||
] = None,
|
] = None,
|
||||||
|
app_env: Annotated[str | None, Query(pattern="^(prod|test)$")] = None,
|
||||||
|
revenue_scope: Annotated[str, Query(pattern="^(business|all)$")] = "all",
|
||||||
|
feed_scene: Annotated[
|
||||||
|
str | None, Query(pattern="^(comparison|coupon|welfare)$")
|
||||||
|
] = None,
|
||||||
) -> UserRewardStats:
|
) -> UserRewardStats:
|
||||||
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
||||||
if not user_repo.user_exists(db, user_id):
|
if not user_repo.user_exists(db, user_id):
|
||||||
@@ -99,6 +104,9 @@ def get_user_reward_stats(
|
|||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
withdraw_source=withdraw_source,
|
withdraw_source=withdraw_source,
|
||||||
|
app_env=app_env,
|
||||||
|
revenue_scope=revenue_scope,
|
||||||
|
feed_scene=feed_scene,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class UserRewardStats(BaseModel):
|
|||||||
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
|
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
|
||||||
reward_video_cash_cents: int # 激励视频提现(金币折现)
|
reward_video_cash_cents: int # 激励视频提现(金币折现)
|
||||||
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
|
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
|
||||||
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
|
feed_avg_ecpm: float # 全部 Draw/feed 实际展示的平均 eCPM(分/千次,含未发奖展示)
|
||||||
feed_cash_cents: int # 信息流广告提现(金币折现)
|
feed_cash_cents: int # 信息流广告提现(金币折现)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,115 @@ def test_user_reward_stats_can_scope_withdrawals_by_account(
|
|||||||
assert invite.json()["cash_balance_cents"] == 456
|
assert invite.json()["cash_balance_cents"] == 456
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_reward_stats_draw_ecpm_uses_all_filtered_impressions(
|
||||||
|
admin_client: TestClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""Draw 平均 eCPM 应与收益报表一致,不能只平均成功发奖记录。"""
|
||||||
|
from app.models.ad_ecpm import AdEcpmRecord
|
||||||
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
|
|
||||||
|
uid = _seed_user_with_data("13800000024")
|
||||||
|
created_at = datetime(2038, 1, 15, 4, tzinfo=UTC)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
AdFeedRewardRecord(
|
||||||
|
client_event_id="reward-stats-granted-high",
|
||||||
|
ad_session_id="reward-stats-granted-high",
|
||||||
|
user_id=uid,
|
||||||
|
reward_date="2038-01-15",
|
||||||
|
duration_seconds=10,
|
||||||
|
unit_count=1,
|
||||||
|
ecpm_raw="9000",
|
||||||
|
ad_type="draw",
|
||||||
|
feed_scene="coupon",
|
||||||
|
app_env="prod",
|
||||||
|
our_code_id="104098712",
|
||||||
|
coin=9,
|
||||||
|
status="granted",
|
||||||
|
created_at=created_at,
|
||||||
|
),
|
||||||
|
AdEcpmRecord(
|
||||||
|
user_id=uid,
|
||||||
|
ad_type="draw",
|
||||||
|
feed_scene="coupon",
|
||||||
|
ad_session_id="reward-stats-impression-low",
|
||||||
|
app_env="prod",
|
||||||
|
our_code_id="104098712",
|
||||||
|
ecpm_raw="1000",
|
||||||
|
report_date="2038-01-15",
|
||||||
|
created_at=created_at,
|
||||||
|
),
|
||||||
|
AdEcpmRecord(
|
||||||
|
user_id=uid,
|
||||||
|
ad_type="feed",
|
||||||
|
feed_scene="coupon",
|
||||||
|
ad_session_id="reward-stats-impression-mid",
|
||||||
|
app_env="prod",
|
||||||
|
our_code_id="104098712",
|
||||||
|
ecpm_raw="3000",
|
||||||
|
report_date="2038-01-15",
|
||||||
|
created_at=created_at,
|
||||||
|
),
|
||||||
|
# 同用户但不同场景/环境/非业务代码位,均不应进入本次详情筛选。
|
||||||
|
AdEcpmRecord(
|
||||||
|
user_id=uid,
|
||||||
|
ad_type="draw",
|
||||||
|
feed_scene="comparison",
|
||||||
|
ad_session_id="reward-stats-other-scene",
|
||||||
|
app_env="prod",
|
||||||
|
our_code_id="104098712",
|
||||||
|
ecpm_raw="7000",
|
||||||
|
report_date="2038-01-15",
|
||||||
|
created_at=created_at,
|
||||||
|
),
|
||||||
|
AdEcpmRecord(
|
||||||
|
user_id=uid,
|
||||||
|
ad_type="draw",
|
||||||
|
feed_scene="coupon",
|
||||||
|
ad_session_id="reward-stats-test-env",
|
||||||
|
app_env="test",
|
||||||
|
our_code_id="104127529",
|
||||||
|
ecpm_raw="8000",
|
||||||
|
report_date="2038-01-15",
|
||||||
|
created_at=created_at,
|
||||||
|
),
|
||||||
|
AdEcpmRecord(
|
||||||
|
user_id=uid,
|
||||||
|
ad_type="draw",
|
||||||
|
feed_scene="coupon",
|
||||||
|
ad_session_id="reward-stats-non-business",
|
||||||
|
app_env="prod",
|
||||||
|
our_code_id="demo-slot",
|
||||||
|
ecpm_raw="9000",
|
||||||
|
report_date="2038-01-15",
|
||||||
|
created_at=created_at,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
response = admin_client.get(
|
||||||
|
f"/admin/api/users/{uid}/reward-stats",
|
||||||
|
params={
|
||||||
|
"date_from": "2038-01-15T00:00:00Z",
|
||||||
|
"date_to": "2038-01-15T23:59:59Z",
|
||||||
|
"app_env": "prod",
|
||||||
|
"revenue_scope": "business",
|
||||||
|
"feed_scene": "coupon",
|
||||||
|
},
|
||||||
|
headers=_auth(admin_token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["feed_count"] == 1
|
||||||
|
# 全部真实展示 (1000 + 3000) / 2;不能返回成功发奖记录的 9000。
|
||||||
|
assert data["feed_avg_ecpm"] == 2000.0
|
||||||
|
|
||||||
|
|
||||||
def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None:
|
def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None:
|
||||||
"""线上 PostgreSQL 返回 aware,SQLite/历史转换可能返回 naive,二者必须可混排。"""
|
"""线上 PostgreSQL 返回 aware,SQLite/历史转换可能返回 naive,二者必须可混排。"""
|
||||||
naive = datetime(2038, 1, 1, 8, 0)
|
naive = datetime(2038, 1, 1, 8, 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user