128db7fb86
报表主表从「按 用户×类型×应用×代码位 聚合」改成「逐条广告事件」(每次广告一行): - 激励视频:展示(ad_ecpm)与发奖(ad_reward)按 ad_session_id 合并成一行,直接给出 eCPM/收益 + 状态/应发/实发/一致;展开看该条金币复算因子 - 信息流:轮播每条展示各一行;整场发奖(client_event_id 与展示 impressionId 对不上)单独成行 - 纯展示行不计对账(matched 恒 true);有展示无发奖 / 有发奖无展示各自成行 - 每行补 user_phone(批量查 User.phone,完整不脱敏,与用户/钱包/比价记录页一致) - 合计与对账在全量上统计、不受 limit 影响;event_key 作前端稳定 rowKey ad_audit.audit_rows 顺带补返回 ad_session_id(供展示↔发奖按会话合并)。 真实库验证:逐条输出正确、合计交叉核对一致(展示条数=ecpm行数、实发=库实发)、schema 校验通过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #72 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
253 lines
10 KiB
Python
253 lines
10 KiB
Python
"""看广告金币审计:复算 expected_coin 并与实发对比。
|
|
|
|
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
|
|
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
|
|
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
|
|
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
|
|
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
|
|
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
|
|
复算需叠加本日之前的累计份数)。
|
|
|
|
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core import rewards
|
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
|
from app.models.ad_reward import AdRewardRecord
|
|
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
|
|
|
|
|
|
def _prior_granted_counts(
|
|
db: Session, *, date: str, user_id: int | None
|
|
) -> dict[int, int]:
|
|
"""各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。
|
|
LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。"""
|
|
stmt = (
|
|
select(AdRewardRecord.user_id, func.count())
|
|
.where(
|
|
AdRewardRecord.reward_date < date,
|
|
AdRewardRecord.reward_scene == "reward_video",
|
|
AdRewardRecord.status == "granted",
|
|
)
|
|
.group_by(AdRewardRecord.user_id)
|
|
)
|
|
if user_id is not None:
|
|
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
|
return {uid: n for uid, n in db.execute(stmt).all()}
|
|
|
|
|
|
def _reward_video_rows(
|
|
db: Session, *, date: str, user_id: int | None
|
|
) -> list[dict]:
|
|
"""看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)。"""
|
|
stmt = (
|
|
select(AdRewardRecord)
|
|
.where(
|
|
AdRewardRecord.reward_date == date,
|
|
AdRewardRecord.reward_scene == "reward_video",
|
|
)
|
|
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at, AdRewardRecord.id)
|
|
)
|
|
if user_id is not None:
|
|
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
|
|
|
# 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐
|
|
granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id)
|
|
rows: list[dict] = []
|
|
for rec in db.execute(stmt).scalars():
|
|
if rec.status == "granted":
|
|
nth = granted_n.get(rec.user_id, 0) + 1
|
|
granted_n[rec.user_id] = nth
|
|
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
|
|
rows.append({
|
|
"scene": "reward_video",
|
|
"record_id": rec.id,
|
|
"user_id": rec.user_id,
|
|
"ad_session_id": rec.ad_session_id,
|
|
"app_env": rec.app_env,
|
|
"our_code_id": rec.our_code_id,
|
|
"created_at": rec.created_at,
|
|
"status": rec.status,
|
|
"ecpm": rec.ecpm_raw,
|
|
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
|
"units": 1,
|
|
"lt_index_start": nth,
|
|
"lt_index_end": nth,
|
|
"lt_factor_start": rewards.ad_lt_factor(nth),
|
|
"lt_factor_end": rewards.ad_lt_factor(nth),
|
|
"expected_coin": expected,
|
|
"actual_coin": rec.coin,
|
|
"matched": expected == rec.coin,
|
|
})
|
|
else:
|
|
# capped / ecpm_missing:不发金币,校验实发确为 0
|
|
rows.append({
|
|
"scene": "reward_video",
|
|
"record_id": rec.id,
|
|
"user_id": rec.user_id,
|
|
"ad_session_id": rec.ad_session_id,
|
|
"app_env": rec.app_env,
|
|
"our_code_id": rec.our_code_id,
|
|
"created_at": rec.created_at,
|
|
"status": rec.status,
|
|
"ecpm": rec.ecpm_raw,
|
|
"ecpm_factor": None,
|
|
"units": 1,
|
|
"lt_index_start": None,
|
|
"lt_index_end": None,
|
|
"lt_factor_start": None,
|
|
"lt_factor_end": None,
|
|
"expected_coin": 0,
|
|
"actual_coin": rec.coin,
|
|
"matched": rec.coin == 0,
|
|
})
|
|
return rows
|
|
|
|
|
|
def _feed_prior_granted_units(
|
|
db: Session, *, date: str, user_id: int | None
|
|
) -> dict[int, int]:
|
|
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
|
|
stmt = (
|
|
select(
|
|
AdFeedRewardRecord.user_id,
|
|
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
|
|
)
|
|
.where(
|
|
AdFeedRewardRecord.reward_date < date,
|
|
AdFeedRewardRecord.status == "granted",
|
|
)
|
|
.group_by(AdFeedRewardRecord.user_id)
|
|
)
|
|
if user_id is not None:
|
|
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
|
return {uid: int(n) for uid, n in db.execute(stmt).all()}
|
|
|
|
|
|
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
|
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。"""
|
|
stmt = (
|
|
select(AdFeedRewardRecord)
|
|
.where(AdFeedRewardRecord.reward_date == date)
|
|
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id)
|
|
)
|
|
if user_id is not None:
|
|
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
|
|
|
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
|
|
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
|
|
rows: list[dict] = []
|
|
for rec in db.execute(stmt).scalars():
|
|
if rec.status == "granted":
|
|
existing = granted_units.get(rec.user_id, 0)
|
|
units = rec.unit_count
|
|
expected = sum(
|
|
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
|
|
for offset in range(1, units + 1)
|
|
)
|
|
granted_units[rec.user_id] = existing + units
|
|
start = existing + 1 if units > 0 else None
|
|
end = existing + units if units > 0 else None
|
|
rows.append({
|
|
"scene": "feed",
|
|
"record_id": rec.id,
|
|
"user_id": rec.user_id,
|
|
"ad_session_id": rec.ad_session_id,
|
|
"app_env": rec.app_env,
|
|
"our_code_id": rec.our_code_id,
|
|
"created_at": rec.created_at,
|
|
"status": rec.status,
|
|
"ecpm": rec.ecpm_raw,
|
|
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
|
"units": units,
|
|
"lt_index_start": start,
|
|
"lt_index_end": end,
|
|
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
|
|
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
|
|
"expected_coin": expected,
|
|
"actual_coin": rec.coin,
|
|
"matched": expected == rec.coin,
|
|
})
|
|
else:
|
|
rows.append({
|
|
"scene": "feed",
|
|
"record_id": rec.id,
|
|
"user_id": rec.user_id,
|
|
"ad_session_id": rec.ad_session_id,
|
|
"app_env": rec.app_env,
|
|
"our_code_id": rec.our_code_id,
|
|
"created_at": rec.created_at,
|
|
"status": rec.status,
|
|
"ecpm": rec.ecpm_raw,
|
|
"ecpm_factor": None,
|
|
"units": rec.unit_count,
|
|
"lt_index_start": None,
|
|
"lt_index_end": None,
|
|
"lt_factor_start": None,
|
|
"lt_factor_end": None,
|
|
"expected_coin": 0,
|
|
"actual_coin": rec.coin,
|
|
"matched": rec.coin == 0,
|
|
})
|
|
return rows
|
|
|
|
|
|
def audit_rows(
|
|
db: Session, *, date: str, user_id: int | None, scene: str | None = None
|
|
) -> list[dict]:
|
|
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"。
|
|
|
|
每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
|
|
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
|
|
"""
|
|
rows: list[dict] = []
|
|
if scene in (None, "reward_video"):
|
|
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
|
if scene in (None, "feed"):
|
|
rows.extend(_feed_rows(db, date=date, user_id=user_id))
|
|
return rows
|
|
|
|
|
|
def ad_coin_audit(
|
|
db: Session,
|
|
*,
|
|
date: str,
|
|
user_id: int | None,
|
|
scene: str | None,
|
|
limit: int,
|
|
only_mismatch: bool = False,
|
|
) -> dict:
|
|
"""当日发奖复算。返回 {total, mismatch_count, truncated, items}。
|
|
|
|
scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。
|
|
关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit
|
|
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
|
|
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
|
|
"""
|
|
rows = audit_rows(db, date=date, user_id=user_id, scene=scene)
|
|
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
|
|
|
|
total = len(rows)
|
|
mismatch_count = sum(1 for r in rows if not r["matched"])
|
|
display = [r for r in rows if not r["matched"]] if only_mismatch else rows
|
|
return {
|
|
"total": total,
|
|
"mismatch_count": mismatch_count,
|
|
"truncated": len(display) > limit,
|
|
"items": display[:limit],
|
|
}
|
|
|
|
|
|
def formula_snapshot() -> dict:
|
|
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
|
|
return {
|
|
"coin_per_yuan": rewards.COIN_PER_YUAN,
|
|
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
|
|
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
|
|
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
|
|
}
|